diff --git a/.dockerignore b/.dockerignore index c478976..d2dd001 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,6 +37,4 @@ docker-compose.yml # Logs *.log - - - +scripts/* diff --git a/.env.example b/.env.example index b90ebc4..5c8d8f8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Database DATABASE_URL=sqlite:wallet.db +# Optional: max concurrent SQLite read-pool connections. Shared by every +# chain's monitor/sweeper/webhook-retry loop plus inbound registrations. +# Default: 20. +# DB_READ_POOL_SIZE=20 + # Blockchain Provider (choose one) # For HTTP: RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY diff --git a/.gitignore b/.gitignore index 4f095d6..b3eb303 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,10 @@ # Database files *.db *.db-shm -*.db-wal# Docker +*.db-wal +*.db.bak + +# Docker docker-compose.override.yml # Backups @@ -14,3 +17,9 @@ backups/ # Logs *.log +.envrc +chains.toml +*.bak + +# One-off operational scripts containing real PII / account data +scripts/retry_deposit_webhooks.sh diff --git a/Cargo.lock b/Cargo.lock index 97d81d0..4c3b669 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,7 +299,7 @@ checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -428,7 +428,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -445,7 +445,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "syn-solidity", "tiny-keccak", ] @@ -463,7 +463,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.111", + "syn 2.0.118", "syn-solidity", ] @@ -561,6 +561,56 @@ dependencies = [ "ws_stream_wasm", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -652,7 +702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -690,7 +740,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -802,7 +852,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -813,7 +863,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -841,7 +891,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1096,6 +1146,57 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "coins-bip32" version = "0.11.1" @@ -1147,6 +1248,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1163,7 +1270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -1225,6 +1332,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1337,7 +1453,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1357,7 +1473,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "unicode-xid", ] @@ -1390,7 +1506,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1434,7 +1550,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1488,7 +1604,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1522,21 +1638,39 @@ dependencies = [ "async-trait", "axum", "bip39", + "clap", "dotenvy", "futures", "hex", + "r2d2", + "r2d2_sqlite", "redb", "reqwest", + "rusqlite", + "rusqlite_migration", "serde", "serde_json", "tempfile", "thiserror 1.0.69", "tokio", + "toml", "tracing", "tracing-subscriber", "wiremock", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -1614,6 +1748,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" @@ -1715,7 +1855,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -1803,10 +1943,22 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "glob" version = "0.3.3" @@ -1876,7 +2028,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1884,6 +2036,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -2236,7 +2400,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -2295,6 +2459,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2364,7 +2534,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2395,6 +2565,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2558,7 +2739,7 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -2567,6 +2748,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -2590,7 +2777,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -2636,7 +2823,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -2737,7 +2924,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -2809,7 +2996,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.7", ] [[package]] @@ -2838,9 +3025,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -2872,9 +3059,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2885,6 +3072,34 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a289c0a3bf56505c470efa2366e76010f1d892e2492a2f96b223386d63b7e2" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + [[package]] name = "radium" version = "0.7.0" @@ -2925,6 +3140,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2982,6 +3208,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -3142,6 +3374,16 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.17", +] + [[package]] name = "ruint" version = "1.17.0" @@ -3176,6 +3418,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rusqlite_migration" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e4d2d97ff816796ed012b789c7381ae42c09a809822a75d29a01022181184" +dependencies = [ + "log", + "rusqlite", +] + [[package]] name = "rustc-hex" version = "2.1.0" @@ -3280,6 +3547,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3380,7 +3656,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3418,6 +3694,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3437,7 +3722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3448,7 +3733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -3560,6 +3845,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3572,6 +3869,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -3590,7 +3893,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3612,9 +3915,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -3630,7 +3933,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3650,7 +3953,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3719,7 +4022,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3730,7 +4033,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3841,7 +4144,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -3905,6 +4208,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.7.3" @@ -3914,6 +4238,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.14", +] + [[package]] name = "toml_edit" version = "0.23.7" @@ -3921,7 +4259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.7.3", "toml_parser", "winnow 0.7.14", ] @@ -3935,6 +4273,12 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.4.13" @@ -4016,7 +4360,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -4178,6 +4522,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "rand 0.10.1", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4286,7 +4648,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -4623,7 +4985,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "synstructure", ] @@ -4644,7 +5006,7 @@ checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -4664,7 +5026,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", "synstructure", ] @@ -4685,7 +5047,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] [[package]] @@ -4718,5 +5080,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.118", ] diff --git a/Cargo.toml b/Cargo.toml index e8cefc6..5b3c6f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,10 @@ path = "src/lib.rs" name = "evm_hot_wallet" path = "src/main.rs" +[[bin]] +name = "migrate_redb_to_sqlite" +path = "src/bin/migrate_redb_to_sqlite.rs" + [dependencies] tokio = { version = "1.36", features = ["full"] } axum = "0.7" @@ -29,6 +33,12 @@ hex = "0.4" thiserror = "1.0" futures = "0.3.31" async-trait = "0.1.89" +toml = "0.8" +rusqlite = { version = "0.39", features = ["bundled"] } +r2d2 = "0.8.10" +r2d2_sqlite = "0.34" +rusqlite_migration = "2.5.0" +clap = { version = "4.6.1", features = ["derive"] } [dev-dependencies] tempfile = "3.23.0" diff --git a/Dockerfile b/Dockerfile index 04088a2..1b65c6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,9 @@ RUN apt-get update && apt-get install -y \ libssl-dev \ && rm -rf /var/lib/apt/lists/* -# Copy manifests +# Copy manifests and migrations COPY Cargo.toml Cargo.lock ./ +COPY migrations ./migrations # Copy source code COPY src ./src @@ -27,10 +28,12 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ ca-certificates \ libssl3 \ + sqlite3 \ && rm -rf /var/lib/apt/lists/* -# Copy the built binary from the builder stage +# Copy the built binaries from the builder stage COPY --from=builder /app/target/release/evm_hot_wallet /usr/local/bin/evm_hot_wallet +COPY --from=builder /app/target/release/migrate_redb_to_sqlite /usr/local/bin/migrate_redb_to_sqlite # Create a directory for the database RUN mkdir -p /app/data diff --git a/Makefile b/Makefile index 9509c5a..d1f749c 100644 --- a/Makefile +++ b/Makefile @@ -39,8 +39,9 @@ clippy: ## Run clippy lints health: ## Check service health @curl -f http://localhost:3000/health && echo " - Service is healthy!" || echo " - Service is unhealthy!" -backup: ## Backup database +backup: ## Backup database (WAL-safe) @mkdir -p backups + docker exec evm-hot-wallet sqlite3 /app/data/wallet.db "PRAGMA wal_checkpoint(TRUNCATE);" docker cp evm-hot-wallet:/app/data/wallet.db ./backups/wallet-$$(date +%Y%m%d-%H%M%S).db @echo "Database backed up to ./backups/" diff --git a/README.md b/README.md index 6cff406..a3ffeb5 100644 --- a/README.md +++ b/README.md @@ -4,53 +4,49 @@ A Rust-based hot wallet service for EVM-compatible blockchains that monitors dep ## Features -- πŸ” **Real-time Monitoring**: Dual-mode blockchain monitoring with WebSocket subscriptions and HTTP polling fallback -- πŸ’Έ **Automatic Sweeping**: Automatically sweeps detected deposits to a configured treasury address -- 🚰 **Faucet Integration**: Built-in faucet for funding new addresses with existential deposits -- πŸ” **HD Wallet Support**: BIP-39 mnemonic-based hierarchical deterministic wallet for generating unique addresses +- πŸ” **Multi-Chain Monitoring**: One process monitors multiple EVM chains (Base, Polygon, others) via HTTP polling +- πŸ’Έ **Automatic Sweeping**: Per-chain sweepers transfer detected deposits to each chain's treasury address +- 🚰 **Lazy Faucet**: Funds deposit addresses with gas just-in-time at sweep time (not at registration) +- πŸ” **HD Wallet Support**: BIP-39 mnemonic-based hierarchical deterministic wallet β€” same addresses on every EVM chain - πŸ“‘ **REST API**: Simple API for registering users and generating deposit addresses - πŸͺ **Per-Account Webhooks**: Custom webhook URLs per user for deposit detection and sweep notifications -- πŸ—„οΈ **Embedded Database**: Uses `redb` for efficient, embedded storage +- πŸ—„οΈ **Embedded Database**: SQLite (rusqlite, WAL mode) - πŸͺ™ **ERC-20 Support**: Monitors and sweeps both native ETH and ERC-20 token deposits - πŸ§ͺ **Well-Tested**: Comprehensive unit and E2E tests with mocked providers - πŸš€ **CI/CD Ready**: GitHub Actions workflow for formatting, linting, and testing ## Architecture -The service consists of four main components: +The service runs one **Monitor** and one **Sweeper** per configured chain, sharing a single database and HD wallet. -### 1. Monitor -Monitors the blockchain for incoming transactions to registered addresses: -- **WebSocket Mode**: Real-time block subscriptions for instant deposit detection -- **HTTP Polling Mode**: Fallback polling mechanism with configurable intervals +### 1. Monitor (per chain) +Polls the chain's RPC endpoint for incoming transactions to registered addresses: +- **HTTP polling only** with configurable interval and block confirmation offset - **Native ETH & ERC-20**: Detects both native token and ERC-20 token transfers -- **Smart Filtering**: Automatically ignores deposits from the faucet address to prevent sweeping existential deposits -- Tracks last processed block to handle restarts gracefully -- Records detected deposits in the database with token metadata - -### 2. Sweeper -Processes detected deposits and transfers funds to the treasury: -- Retrieves pending deposits from the database -- Derives private keys for each deposit address -- **Native ETH**: Calculates gas costs and transfers maximum available balance -- **ERC-20 Tokens**: Sweeps ERC-20 tokens (requires native balance for gas) -- Sends webhook notifications on successful sweeps -- Marks deposits as swept in the database - -### 3. Faucet -Automatically funds newly registered addresses with an existential deposit: +- **Smart Filtering**: Ignores deposits from that chain's faucet address +- Per-chain last processed block cursor for graceful restarts +- Records detected deposits with chain-prefixed keys and sends webhooks including `chain` / `chain_id` + +### 2. Sweeper (per chain) +Processes detected deposits for its chain only: +- Retrieves pending deposits scoped to the chain +- Derives private keys for each deposit address (same mnemonic, all chains) +- **Native ETH**: Calculates gas costs and transfers maximum available balance to the chain treasury +- **ERC-20 Tokens**: Sweeps ERC-20 tokens (lazy-funds native gas if needed) +- Sends chain-aware webhook notifications on successful sweeps + +### 3. Faucet (per chain) +Lazy-funds deposit addresses when a sweep needs gas: - Uses a separate mnemonic for security isolation -- Sends configurable amount to new addresses upon registration -- Ensures addresses have sufficient balance for future transactions -- Faucet deposits are automatically excluded from sweeping +- Sends the chain's configured existential deposit only when sweeping +- Faucet deposits are excluded from sweeping on that chain ### 4. API Server -HTTP API for user management and address generation: -- `POST /register` - Register a new user with a webhook URL and receive a unique deposit address -- Deterministic address derivation using hash-based indexing -- Automatic funding via faucet upon registration -- Per-account webhook configuration for custom notification endpoints -- Thread-safe database access +HTTP API for user management and operations: +- `POST /register` β€” Register a user with a webhook URL; returns a chain-agnostic deposit address +- `POST /verify_transfer` β€” Verify a transfer on a specific chain (requires `chain` field) +- `GET/POST /block_number` β€” Read or set the per-chain block cursor (requires `chain`) +- `GET /health` β€” Health check listing configured chains ## Installation @@ -75,83 +71,82 @@ cargo test ## Configuration -The service is configured via environment variables. Create a `.env` file or set these variables: +Secrets stay in environment variables. Per-chain settings (RPC, treasury, tokens, gas) live in a TOML file. -### Required Variables +### Environment variables -| Variable | Description | Example | -|----------|-------------|---------| -| `MNEMONIC` | BIP-39 mnemonic phrase for HD wallet (used to derive user deposit addresses) | `test test test test test test test test test test test junk` | -| `FAUCET_MNEMONIC` | BIP-39 mnemonic phrase for faucet wallet (used to fund new addresses) | `another twelve word phrase for faucet` | -| `FAUCET_ADDRESS` | Ethereum address of the faucet (derived from `FAUCET_MNEMONIC` at index 0) | `0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266` | -| `TREASURY_ADDRESS` | Ethereum address where funds will be swept | `0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb` | -| `RPC_URL` or `WS_URL` | Blockchain node endpoint (use WS for real-time, RPC for polling) | `https://eth-mainnet.g.alchemy.com/v2/...` or `wss://eth-mainnet.g.alchemy.com/v2/...` | +| Variable | Required | Description | Default | +|----------|----------|-------------|---------| +| `MNEMONIC` | yes | BIP-39 mnemonic for user deposit address derivation (same addresses on all EVM chains) | β€” | +| `FAUCET_MNEMONIC` | yes | BIP-39 mnemonic for the faucet wallet (lazy-funds addresses for gas at sweep time) | β€” | +| `CHAINS_CONFIG` | no | Path to chains TOML file | `chains.toml` | +| `DATABASE_URL` | no | SQLite database file path (`sqlite:` prefix optional) | `sqlite:wallet.db` | +| `DB_READ_POOL_SIZE` | no | Max concurrent SQLite read-pool connections, shared by every chain's monitor/sweeper/webhook-retry loop plus inbound registrations. Raise this if you configure more chains or see `"timed out waiting for connection"` under load | `20` | +| `PORT` | no | API server port | `3000` | +| `WEBHOOK_JWT_TOKEN` | no | Optional JWT sent as `Authorization: Bearer` on webhooks and admin endpoints | β€” | +| `WEBHOOK_MAX_RETRIES` | no | Max delivery attempts before marking a webhook `failed` | `5` | +| `WEBHOOK_RETRY_DELAY_MS` | no | Delay between delivery attempts in the worker batch | `1000` | +| `WEBHOOK_RETRY_POLL_INTERVAL` | no | Worker poll interval (seconds) when no enqueue/admin notify | `30` | +| `WEBHOOK_RETRY_BATCH_SIZE` | no | Max pending deliveries processed per worker batch | `50` | +| `WEBHOOK_LEASE_SECONDS` | no | Claim lease duration to prevent duplicate POSTs | `60` | +| `LEGACY_CHAIN` | no | Chain name for redbβ†’SQLite importer only | `polygon` | -### Optional Variables +### Chains file (`chains.toml`) -| Variable | Description | Default | -|----------|-------------|---------| -| `DATABASE_URL` | Path to the database file | `sqlite:wallet.db` | -| `PORT` | API server port | `3000` | -| `POLL_INTERVAL` | Block polling interval in seconds (HTTP mode only) | `10` | -| `BLOCK_OFFSET_FROM_HEAD` | Number of blocks to stay behind chain head for confirmation safety | `20` | -| `EXISTENTIAL_DEPOSIT` | Amount in wei to fund new addresses with | `10000000000000000` (0.01 ETH) | +Copy [`chains.toml.example`](chains.toml.example) to `chains.toml`. Each `[[chains]]` block configures one network: -### Example `.env` File +- `name` β€” short id used in API/webhooks (`base`, `polygon`, …) +- `chain_id` β€” EVM chain ID (included in webhooks) +- `rpc_url` β€” HTTP(S) RPC endpoint (polling only) +- `treasury_address`, `faucet_address`, `existential_deposit` +- `allowed_token_addresses` β€” required per chain (non-empty) +- Optional: `min_deposits`, `min_deposit_default`, `min_deposit_native`, `poll_interval`, `block_offset_from_head` -```env -# Database -DATABASE_URL=sqlite:wallet.db - -# Blockchain Connection (choose one) -RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY -# For WebSocket (comment out RPC_URL if using WS): -# WS_URL=wss://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY +### Example `.env` -# Hot Wallet Configuration -# This mnemonic is used to derive deposit addresses for users -MNEMONIC=your twelve word mnemonic phrase goes here for hot wallet - -# Faucet Configuration -# This mnemonic is for the faucet that funds new addresses with existential deposit -FAUCET_MNEMONIC=another twelve word mnemonic phrase for faucet wallet funding +```env +DATABASE_URL=wallet.db +MNEMONIC=your twelve word mnemonic phrase goes here +FAUCET_MNEMONIC=another twelve word mnemonic phrase for faucet +PORT=3000 +CHAINS_CONFIG=chains.toml +``` -# Faucet Address (derived from FAUCET_MNEMONIC at index 0) -# This address is used to identify and skip faucet deposits from being swept -# To get this address: derive it from your FAUCET_MNEMONIC using BIP39/BIP44 at path m/44'/60'/0'/0/0 -FAUCET_ADDRESS=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 +See [WEBHOOK_SPEC.md](./WEBHOOK_SPEC.md) for chain-aware webhook payloads and id format (`{chain}:{tx_hash}`). -# Existential Deposit (in wei) -# Default: 10000000000000000 (0.01 ETH on Ethereum) -# Adjust based on network: lower for testnets, consider gas costs -EXISTENTIAL_DEPOSIT=10000000000000000 +## Migration (redb β†’ SQLite) -# Treasury address where funds are swept to -TREASURY_ADDRESS=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb +One-shot offline cutover from the legacy redb file: -# API Server Port -PORT=3000 +```bash +cp evm_wallet.db evm_wallet.db.bak +cargo run --release --bin migrate_redb_to_sqlite -- \ + --from evm_wallet.db \ + --to wallet.db \ + --legacy-chain polygon +``` -# Polling interval in seconds (for monitoring new blocks) -POLL_INTERVAL=10 +Verify block cursors and row counts: -# Block Offset from Head (number of blocks behind current head for confirmation safety) -BLOCK_OFFSET_FROM_HEAD=20 +```bash +sqlite3 wallet.db "SELECT key, value FROM state WHERE key LIKE 'last_block:%';" +sqlite3 wallet.db "SELECT chain, status, COUNT(*) FROM deposits GROUP BY 1, 2;" ``` +Point `DATABASE_URL` at the new SQLite file (bare path or `sqlite:` prefix), then start the service. Keep the redb backup for at least 7 days. + ## Usage ### Running the Service ```bash -# With .env file +# With .env + chains.toml cargo run --release # Or with environment variables MNEMONIC="..." \ -TREASURY_ADDRESS="0x..." \ -WEBHOOK_URL="https://..." \ -RPC_URL="https://..." \ +FAUCET_MNEMONIC="..." \ +CHAINS_CONFIG=chains.toml \ cargo run --release ``` @@ -171,32 +166,59 @@ curl -X POST http://localhost:3000/register \ Response: ```json { - "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", - "funding_tx": "0xabc123..." // Optional: transaction hash of faucet funding + "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" } ``` -**Note**: Upon registration, the address is automatically funded with the configured existential deposit from the faucet. This ensures the address has enough balance for gas fees when sweeping deposits. +**Note**: Registration does **not** fund the address. The sweeper lazy-funds gas on each chain when a deposit is swept. The optional `funding_tx` field is omitted (legacy clients may still see `"funding_tx": null`). + +**Important**: Each user registers with their own `webhook_url`. Webhooks include `chain`, `chain_id`, and chain-scoped `id` values β€” see [WEBHOOK_SPEC.md](./WEBHOOK_SPEC.md). + +### Verifying Transfers + +```bash +curl -X POST http://localhost:3000/verify_transfer \ + -H "Content-Type: application/json" \ + -d '{ + "chain": "polygon", + "tx_hash": "0xabc...", + "to_address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "amount": "1000000000000000000", + "token_type": "native" + }' +``` + +### Per-Chain Block Cursor + +```bash +# Get last processed block for polygon +curl "http://localhost:3000/block_number?chain=polygon" -**Important**: Each user registers with their own `webhook_url`. This allows per-user notification endpoints for deposit detection and sweep events. +# Reset cursor (admin) +curl -X POST http://localhost:3000/block_number \ + -H "Content-Type: application/json" \ + -d '{"chain": "polygon", "block_number": 12345678}' +``` ### Webhook Notifications -The service sends webhook notifications to the per-account `webhook_url` for deposit events. Each webhook includes a unique `id` field for idempotency and deduplication. +The service sends webhook notifications to the per-account `webhook_url` for deposit events. Each webhook includes `chain`, `chain_id`, and a chain-scoped `id` field for idempotency. #### Unique Identifier (`id` field) -- **Native ETH deposits**: `id` = transaction hash (e.g., `"0xabc123..."`) -- **ERC20 deposits**: `id` = transaction hash + log index (e.g., `"0xabc123...:0"`) +- **Native ETH deposits**: `{chain}:{tx_hash}` (e.g. `polygon:0xabc...`) +- **ERC20 deposits**: `{chain}:{tx_hash}:{log_index}` (e.g. `base:0xabc...:0`) -This ensures unique identification even when multiple ERC20 transfers occur in the same transaction. +See [WEBHOOK_SPEC.md](./WEBHOOK_SPEC.md) for full payload examples. #### 1. Deposit Detection -When a deposit is first detected on the blockchain, a POST request is sent to the account's webhook URL: +When a deposit is first detected on a chain, a POST request is sent to the account's webhook URL: **Native ETH Deposit Detected:** ```json { - "id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "id": "polygon:0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "chain": "polygon", + "chain_id": 137, "event": "deposit_detected", "account_id": "user_123", "tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -208,7 +230,9 @@ When a deposit is first detected on the blockchain, a POST request is sent to th **ERC-20 Token Deposit Detected:** ```json { - "id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0", + "id": "polygon:0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0", + "chain": "polygon", + "chain_id": 137, "event": "deposit_detected", "account_id": "user_123", "tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -233,7 +257,9 @@ When a deposit is successfully swept to the treasury, a POST request is sent to **Native ETH Deposit Swept:** ```json { - "id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "id": "polygon:0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "chain": "polygon", + "chain_id": 137, "event": "deposit_swept", "account_id": "user_123", "original_tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", @@ -245,10 +271,12 @@ When a deposit is successfully swept to the treasury, a POST request is sent to **ERC-20 Token Deposit Swept:** ```json { - "id": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0", + "id": "polygon:0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0", + "chain": "polygon", + "chain_id": 137, "event": "deposit_swept", "account_id": "user_123", - "original_tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:0", + "original_tx_hash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "amount": "1000000", "token_type": "erc20", "token_symbol": "USDC", @@ -257,30 +285,7 @@ When a deposit is successfully swept to the treasury, a POST request is sent to } ``` -#### 3. Faucet Funding -When a newly registered address is funded with an existential deposit: - -**Faucet Funding Success:** -```json -{ - "event": "faucet_funding", - "account_id": "user_123", - "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", - "success": true, - "tx_hash": "0xabc..." -} -``` - -**Faucet Funding Failure:** -```json -{ - "event": "faucet_funding", - "account_id": "user_123", - "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", - "success": false, - "error": "Insufficient faucet balance" -} -``` +Registration does **not** emit a `faucet_funding` webhook. Gas is funded silently at sweep time when needed. #### Webhook Best Practices @@ -312,9 +317,6 @@ app.post('/webhook', async (req, res) => { case 'deposit_swept': await handleDepositSwept(req.body); break; - case 'faucet_funding': - await handleFaucetFunding(req.body); - break; } res.status(200).send('OK'); @@ -354,13 +356,55 @@ console.log(wallet.address); - Addresses are case-insensitive but should be in checksummed format **"Faucet has insufficient balance"** -- Ensure the faucet address has enough native currency to fund new addresses -- Each registration requires at least `EXISTENTIAL_DEPOSIT` amount +- Ensure the faucet address has enough native currency on each chain to fund sweeps +- Each lazy fund requires at least that chain's `existential_deposit` amount **"ERC-20 sweep fails with insufficient gas"** - Addresses need native balance (ETH/MATIC/etc.) to pay for ERC-20 transfer gas - Consider increasing `EXISTENTIAL_DEPOSIT` if you expect ERC-20 deposits +**"Deposit detected but never swept after faucet was refilled"** +- ERC20 sweeps retry automatically while `status = 'detected'`. Transient faucet/gas errors no longer count toward the permanent-failure limit. +- If a deposit was marked `failed` before this fix (or after 5 non-funding errors), re-queue it with: + +```bash +curl -X POST http://localhost:8080/admin/retry_sweeps \ + -H "Authorization: Bearer $WEBHOOK_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"chain":"base","tx_hash":"0x...","log_index":120}' +``` + +Omit `log_index` for native deposits. The sweeper picks up re-queued rows on the next poll cycle (~10s by default). + +**"Webhook delivery failed"** +- Webhooks are persisted in SQLite and retried by a background worker. Non-2xx responses are treated as failures (including 503). +- Re-queue a permanently failed webhook with: + +```bash +curl -X POST http://localhost:8080/admin/retry_webhooks \ + -H "Authorization: Bearer $WEBHOOK_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"base:0xabc:120","event":"deposit_swept"}' +``` + +Inspect failed deliveries: + +```sql +SELECT id, event, status, attempt_count, last_http_status, last_error +FROM webhook_deliveries WHERE status = 'failed'; +``` + +The legacy [`scripts/retry_deposit_webhooks.sh`](scripts/retry_deposit_webhooks.sh) script can still be used for manual replays; prefer the admin API for operational retries. + +**Manual SQL recovery** (if the API is unavailable): + +```sql +UPDATE erc20_deposits SET status = 'detected' + WHERE chain = 'base' AND tx_hash = '0x...' AND log_index = 120 AND status = 'failed'; +DELETE FROM sweep_failures + WHERE chain = 'base' AND tx_hash = '0x...' AND log_index = 120; +``` + ## Debugging ### RPC Request/Response Debugging @@ -486,7 +530,9 @@ emvhot/ β”‚ β”œβ”€β”€ main.rs # Entry point, service orchestration β”‚ β”œβ”€β”€ api.rs # REST API server β”‚ β”œβ”€β”€ config.rs # Configuration management -β”‚ β”œβ”€β”€ db.rs # Database layer (redb) +β”‚ β”œβ”€β”€ db.rs # Database layer (SQLite) +β”‚ β”œβ”€β”€ redb_store.rs # Legacy redb read/migrate (importer only) +β”‚ β”œβ”€β”€ redb_import.rs # redb β†’ SQLite import library β”‚ β”œβ”€β”€ monitor.rs # Blockchain monitoring service β”‚ β”œβ”€β”€ sweeper.rs # Fund sweeping service β”‚ β”œβ”€β”€ wallet.rs # HD wallet implementation @@ -505,7 +551,7 @@ emvhot/ Key dependencies: - **alloy**: Ethereum library for transaction handling and providers - **axum**: Web framework for the REST API -- **redb**: Embedded key-value database +- **rusqlite**: Embedded SQLite database (WAL mode) - **tokio**: Async runtime - **tracing**: Logging and diagnostics @@ -522,31 +568,33 @@ See [`Cargo.toml`](./Cargo.toml) for the complete list. 5. **Monitor gas prices** - The sweeper uses on-chain gas prices which may be high during congestion 6. **Database backups** - Regularly backup your database to prevent data loss 7. **Hot wallet risks** - This is a hot wallet service; funds are only as secure as the server -8. **Faucet funding** - Ensure the faucet address is properly funded to support new user registrations -9. **Correct FAUCET_ADDRESS** - Double-check that `FAUCET_ADDRESS` matches the address derived from `FAUCET_MNEMONIC` at index 0 +8. **Faucet funding** - Keep the faucet wallet funded on every configured chain for lazy gas funding at sweep time +9. **Per-chain faucet_address** - In `chains.toml`, each chain's `faucet_address` must match the address derived from `FAUCET_MNEMONIC` at index 0 ## How It Works ### Registration Flow 1. User calls `POST /register` with their account ID and webhook URL -2. System derives a deterministic address using hash-based indexing -3. Faucet automatically sends existential deposit to the new address -4. Address and webhook URL are registered in the database -5. Address is ready to receive deposits with custom webhook notifications +2. System derives a deterministic address (same address on Base, Polygon, and all EVM chains) +3. Address and webhook URL are stored in the database β€” **no on-chain funding yet** +4. Address is ready to receive deposits on any configured chain ### Deposit Detection & Sweeping Flow -1. **Monitor** watches the blockchain for transactions to registered addresses -2. When a deposit is detected, **Monitor checks if it's from the faucet**: +1. Each chain's **Monitor** polls its RPC for transactions to registered addresses +2. When a deposit is detected, **Monitor checks if it's from that chain's faucet**: - If yes: Skip recording (prevents sweeping existential deposits) - - If no: - - Record the deposit in the database - - Send "deposit_detected" webhook to the account's webhook URL -3. **Sweeper** processes recorded deposits: - - Derives the private key for the deposit address - - Calculates gas costs - - Transfers funds to the treasury address - - Sends "deposit_swept" webhook to the account's webhook URL -4. Deposit is marked as "swept" in the database + - If no: Record with chain-prefixed key and send `deposit_detected` webhook +3. That chain's **Sweeper** processes its pending deposits: + - Lazy-funds gas from the chain faucet if needed + - Transfers funds to the chain's treasury address + - Sends `deposit_swept` webhook with chain-scoped `id` +4. Deposit is marked as swept in the database + +**Sweep failure behavior:** +- **Native deposits** stay `detected` and retry every poll cycle until the sweep succeeds. +- **ERC20 deposits** stay `detected` on transient faucet/gas errors and retry indefinitely. +- Other ERC20 errors increment a failure counter; after 5 attempts the deposit is marked `failed` and stops retrying until re-queued via `POST /admin/retry_sweeps`. +- On startup (and periodically when the queue is non-empty), the sweeper logs counts of detected/failed deposits per chain. ### ERC-20 Token Support - Monitor detects ERC-20 `Transfer` events to registered addresses @@ -565,10 +613,13 @@ cp env.docker.example .env # Or use: make setup ``` -2. **Edit `.env` with your configuration:** +2. **Edit configuration:** ```bash -# Set your RPC endpoint, mnemonics, addresses, etc. +# Set mnemonics in .env nano .env +# Copy and edit per-chain settings +cp chains.toml.example chains.toml +nano chains.toml ``` 3. **Build and start the service:** @@ -635,15 +686,17 @@ docker ps docker logs evm-hot-wallet ``` -**Backup the database:** +**Backup the database (WAL-safe):** ```bash +docker exec evm-hot-wallet sqlite3 /app/data/wallet.db "PRAGMA wal_checkpoint(TRUNCATE);" docker cp evm-hot-wallet:/app/data/wallet.db ./backup-wallet.db +# Or use: make backup ``` ### Production Deployment Notes 1. **Persistent Storage**: Database is stored in a Docker volume (`wallet-data`) to persist across container restarts -2. **Environment Variables**: All configuration is loaded from `.env` file +2. **Environment Variables**: Secrets in `.env`; per-chain settings in mounted `chains.toml` 3. **Network**: Service runs on port 3000 by default (configurable) 4. **Security**: - Never commit `.env` file with real secrets @@ -660,7 +713,7 @@ The docker-compose.yml includes a health check. You can also manually check: curl http://localhost:3000/health ``` -Note: You may need to implement a `/health` endpoint in the API if it doesn't exist. +Note: `/health` returns OK and lists configured chain names. ## Roadmap @@ -674,10 +727,10 @@ Note: You may need to implement a `/health` endpoint in the API if it doesn't ex - [x] Automatic token metadata caching - [ ] Webhook signature verification (HMAC) - [ ] Configurable gas price strategies -- [ ] Multi-chain support +- [x] Multi-chain support (Base, Polygon, others via `chains.toml`) - [ ] Admin dashboard - [ ] Prometheus metrics -- [ ] Health check endpoint +- [ ] Health check endpoint (basic `/health` exists; richer per-chain status planned) ## Contributing diff --git a/WEBHOOK_SPEC.md b/WEBHOOK_SPEC.md new file mode 100644 index 0000000..0e233aa --- /dev/null +++ b/WEBHOOK_SPEC.md @@ -0,0 +1,73 @@ +# Webhook Specification + +Multi-chain hot wallet: one registration address works on every configured EVM chain. Webhooks are sent per deposit/sweep event and include which chain the event occurred on. + +All deposit and sweep webhooks include: +- `chain` β€” short name from `chains.toml` (e.g. `base`, `polygon`) +- `chain_id` β€” numeric EVM chain ID + +Polling-only: there is no WebSocket streaming mode. + +## Idempotency (`id` field) + +- **Native deposits**: `{chain}:{tx_hash}` (e.g. `polygon:0xabc...`) +- **ERC20 deposits**: `{chain}:{tx_hash}:{log_index}` (e.g. `base:0xabc...:0`) +- **Native swept**: `{chain}:{original_tx_hash}` +- **ERC20 swept**: `{chain}:{tx_hash}:{log_index}` + +## deposit_detected + +```json +{ + "id": "polygon:0x1234...", + "chain": "polygon", + "chain_id": 137, + "event": "deposit_detected", + "account_id": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", + "registration_id": "user_123", + "tx_hash": "0x1234...", + "amount": "1000000", + "token_type": "erc20", + "token_symbol": "USDT", + "token_address": "0xc2132d05d31c914a87c6611c10748aeb04b58e8f", + "token_decimals": 6 +} +``` + +## deposit_swept + +Same fields as detection, plus for ERC20: + +```json +{ + "sweep_tx_hash": "0xsweep..." +} +``` + +## Lazy faucet + +Registration does **not** fund addresses. Gas is funded just-in-time by the sweeper when a deposit needs sweeping. No `faucet_funding` webhook is emitted at registration time. + +## Delivery and retries + +Monitor and sweeper **enqueue** webhooks only; a background worker performs HTTP delivery with configurable retries. + +| Status | Meaning | +|--------|---------| +| `pending` | Awaiting delivery or scheduled for retry | +| `delivered` | Receiver returned HTTP 2xx | +| `failed` | `WEBHOOK_MAX_RETRIES` attempts exhausted | + +- Idempotency is unchanged: `(id, event)` is the primary key; already-`delivered` rows are not re-enqueued. +- Non-2xx HTTP responses (including 503) count as failures and are retried. +- The worker wakes immediately on enqueue and on `POST /admin/retry_webhooks`. +- Duplicate POSTs are prevented via a lease (`WEBHOOK_LEASE_SECONDS`) claimed before each attempt. + +Admin retry (requires `WEBHOOK_JWT_TOKEN`): + +```bash +curl -X POST http://localhost:3000/admin/retry_webhooks \ + -H "Authorization: Bearer $WEBHOOK_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"base:0xabc:120","event":"deposit_swept"}' +``` diff --git a/chains.toml.example b/chains.toml.example new file mode 100644 index 0000000..26d6468 --- /dev/null +++ b/chains.toml.example @@ -0,0 +1,59 @@ +# Copy to chains.toml (or set CHAINS_CONFIG=/path/to/chains.toml) +# Secrets (MNEMONIC, FAUCET_MNEMONIC, DATABASE_URL, PORT, WEBHOOK_JWT_TOKEN, LEGACY_CHAIN) stay in env. +# +# Each [[chains]] block runs its own Monitor + Sweeper in one process. +# Registration is chain-agnostic; the same derived address receives deposits on all chains. +# RPC URLs are polled over HTTP(S) only (no WebSocket streaming). +# Lazy faucet: addresses are funded for gas at sweep time, not at registration. +# +# Production tips: +# - Use a SEPARATE Alchemy API key per chain. A shared key means a busy chain +# (e.g. Polygon) throttles every other chain's compute-unit budget too. +# - poll_interval = 2 keeps the monitor glued to a ~2s-block-time chain once caught +# up; this only affects steady-state polling, not backlog catch-up speed. +# - When the monitor falls more than ~10 blocks behind head, it automatically +# switches from per-block scanning to a batched catch-up: one address-filtered +# ranged eth_getLogs call per catch_up_chunk_size blocks (bisecting the range if +# a provider rejects it as too large/too many results), plus concurrent +# eth_getBlockByNumber fetches (up to block_fetch_concurrency at a time) for +# native transfers. This is what lets a multi-thousand-block backlog actually +# drain instead of perpetually trailing a fast chain. + +[[chains]] +name = "base" +chain_id = 8453 +rpc_url = "https://base-mainnet.g.alchemy.com/v2/YOUR_BASE_KEY" +treasury_address = "0x1111111111111111111111111111111111111111" +faucet_address = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" +existential_deposit = "10000000000000000" +allowed_token_addresses = [ + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC on Base +] +block_offset_from_head = 20 +poll_interval = 2 +get_logs_max_retries = 30 +get_logs_delay_ms = 50 +catch_up_chunk_size = 500 +block_fetch_concurrency = 10 +min_deposit_default = "0" +min_deposit_native = "0" + +[[chains]] +name = "polygon" +chain_id = 137 +rpc_url = "https://polygon-mainnet.g.alchemy.com/v2/YOUR_POLYGON_KEY" +treasury_address = "0x2222222222222222222222222222222222222222" +faucet_address = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" +existential_deposit = "10000000000000000" +allowed_token_addresses = [ + "0xc2132D05D31c914a87C6611C10748AEb04B58e8f", # USDT on Polygon +] +block_offset_from_head = 20 +poll_interval = 2 +get_logs_max_retries = 30 +get_logs_delay_ms = 50 +catch_up_chunk_size = 500 +block_fetch_concurrency = 10 +min_deposit_native = "0" +[chains.min_deposits] +"0xc2132d05d31c914a87c6611c10748aeb04b58e8f" = "10000" diff --git a/docker-compose.yml b/docker-compose.yml index 8664170..422bb3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,23 +12,13 @@ services: volumes: # Persist database - wallet-data:/app/data - # Mount .env file for configuration + # Per-chain configuration (copy chains.toml.example to chains.toml) + - ./chains.toml:/app/chains.toml:ro + env_file: + - .env environment: - # Database - - DATABASE_URL=/app/data/wallet.db - - # These will be read from .env file - # Uncomment and set here if you prefer docker-compose configuration over .env - # - MNEMONIC=your twelve word mnemonic phrase goes here for hot wallet - # - FAUCET_MNEMONIC=another twelve word mnemonic phrase for faucet wallet funding - # - FAUCET_ADDRESS=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 - # - TREASURY_ADDRESS=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb - # - RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY - # Or for WebSocket: - # - WS_URL=wss://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY - # - EXISTENTIAL_DEPOSIT=10000000000000000 - # - PORT=3000 - # - POLL_INTERVAL=10 + DATABASE_URL: /app/data/wallet.db + CHAINS_CONFIG: /app/chains.toml healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s @@ -45,6 +35,3 @@ volumes: networks: evm-network: driver: bridge - - - diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dd51b7e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,27 @@ +# Fix plans + +Engineering fix plans for issues found in review. Each doc has problem, root cause with +file:line refs, an opinionated fix (with options where there's a real decision), tests, and +risk-if-not-fixed. + +## Priority order + +1. [Spam-token sweep guard](plan-spam-token-sweep-guard.md) β€” main plan, active Alchemy + credit drain. Ship first; set `ALLOWED_TOKEN_ADDRESSES` before deploying. +2. [P0: register address collisions](fix-p0-register-address-collision.md) β€” silent money + misattribution / unrecoverable funds at scale. +3. [P0: verify reports unmined tx as Success](fix-p0-verify-unmined-tx.md) β€” payments + "verified" before they finalize. +4. [P1: verify buffer-overrun panic](fix-p1-verify-buffer-overrun-panic.md) β€” crafted token + log crashes `/verify_transfer`. +5. [P1: unauthenticated API](fix-p1-api-authentication.md) β€” faucet drain + monitor tampering. +6. [P1: faucet nonce race](fix-p1-faucet-nonce-race.md) β€” funding/sweep failures under load. + +## Notes + +- The P0 register fix and the P1 API-auth fix compound: closing auth blunts the faucet-drain + vector, and fixing the index allocator stops account-table pollution from spam registers. +- The P1 buffer-overrun panic shares a decoder with the monitor β€” fix extracts one shared + `decode_transfer_amount` helper used by both detection and verification. +- Two unresolved decisions are flagged inline: empty-allowlist posture + (plan-spam-token-sweep-guard.md) and missing-receipt error shape (fix-p0-verify-unmined-tx.md). diff --git a/docs/design-faucet-scale.md b/docs/design-faucet-scale.md new file mode 100644 index 0000000..a4262a9 --- /dev/null +++ b/docs/design-faucet-scale.md @@ -0,0 +1,60 @@ +# Design: High-throughput faucet scaling (deferred) + +This document captures the scaling path for faucet funding when registration and sweep volume exceeds what a single EOA can sustain. It is **not implemented** in the P1 nonce-race fix; that fix builds the wallet-bound provider once, reuses alloy's `NonceFiller`, shares one `Faucet` instance, and resets the nonce cache on send failure. + +## Current ceiling (single faucet EOA) + +- One faucet account (`faucet_mnemonic` index 0) sends every existential-deposit funding tx. +- Nonces are strictly ordered; nodes cap pending txs per account (geth `txpool.accountslots`, typically ~16). +- With a reused provider + `NonceFiller`, throughput is bounded by RPC latency and chain inclusion, not by per-call nonce races. +- Practical upper bound for one EOA: on the order of tens to low hundreds of funding txs/sec under ideal conditions, not thousands/sec. + +Per-user derived deposit addresses do **not** share this bottleneck: each has its own nonce sequence. The sweeper also processes deposits sequentially today. + +## Target: thousands of funding ops/sec + +Requires one or more of: + +### 1. Faucet account pool + +- Derive N faucet signers from the same mnemonic (indices 0..N-1). +- Round-robin or least-loaded assignment when funding an address. +- Each signer has an independent nonce lane β†’ NΓ— parallel in-flight funding txs (still capped per account by mempool limits). +- Operational requirements: + - Monitor and top up balances across all pool accounts. + - Persist assignment or idempotency if a fund is retried after partial failure. + - One `Faucet` (or pool manager) holding N wallet-bound providers, each with its own `NonceFiller`. + +### 2. Batched funding (disperse / multicall) + +- Single tx funds K deposit addresses (native transfer batch or disperse contract). +- Divides on-chain tx count by K; best lever for burst registration. +- Requires: + - Deployed disperse (or similar) contract owned/trusted by the service. + - Batch sizing policy (gas limit, K max). + - Failure semantics: partial batch failure vs all-or-nothing. + +### 3. Sweeper loop concurrency + +- `process_deposits` is sequential; at high deposit volume, sweep latency grows independently of faucet throughput. +- Options: worker pool with per-address locking, or queue + dedicated sweep workers. +- Faucet funding from the sweeper remains a call into the shared pool/batch layer above. + +## Recommended sequencing + +1. **Done (P1):** Single shared `Faucet`, provider built once, `NonceFiller` reuse, reset on send failure. +2. **Next when sustained load > ~10–20 funds/sec:** Faucet account pool (N=4–16), minimal API change at call sites. +3. **When registration bursts dominate:** Batch disperse for register path; keep pool for sweeper top-ups. +4. **When sweep backlog grows:** Parallelize sweeper with per-address serialization only. + +## Metrics to watch before building + +- Faucet funding error rate (`nonce too low`, `replacement`, RPC errors). +- Time from register/sweep-trigger to funded balance on chain. +- Mempool depth / pending count for faucet address(es). +- Sweeper queue depth and age of oldest unswept deposit. + +## Out of scope for this design doc + +- Alloy upgrade off 0.1.4. +- API authentication / rate limits on `POST /register` (see `docs/fix-p1-api-authentication.md`). diff --git a/docs/fix-p0-register-address-collision.md b/docs/fix-p0-register-address-collision.md new file mode 100644 index 0000000..c4b0ab7 --- /dev/null +++ b/docs/fix-p0-register-address-collision.md @@ -0,0 +1,101 @@ +# Fix (P0): Deposit-address collisions in `register` + +Severity: P0 β€” silent money misattribution / unrecoverable funds +Confidence: 8/10 +Location: `src/lib.rs:404-416`, `src/db.rs:65-96` + +## Problem + +`register` derives the wallet index from a hash of the account `id`: + +```rust +let mut hasher = DefaultHasher::new(); +request.id.hash(&mut hasher); +let hash = hasher.finish(); +let index = (hash & 0x7FFFFFFF) as u32; +``` + +Two distinct defects: + +### A. Birthday collisions on a 31-bit index + +The index space is `2^31` (~2.1B) and there is **no collision check**. By the birthday +bound, ~46,000 accounts give a ~50% chance that two different `id`s derive the **same +index**, hence the **same deposit address**. `ADDRESS_TO_ID` is last-writer-wins +(`src/db.rs:77-78`), so once two accounts share an address: + +- The monitor maps incoming deposits at that address to whichever `id` registered last. +- Deposits intended for account A are attributed (and swept/credited) under account B. + +This is silent β€” no error, no log. For a payment processor, 46k accounts is reachable. + +``` +id "alice" ─hash─> index 12345 ─> 0xABC... ┐ + β”œβ”€ same address, ADDRESS_TO_ID["0xABC"] = last writer +id "bob" ─hash─> index 12345 ─> 0xABC... β”˜ +``` + +### B. `DefaultHasher` is not stable across Rust versions + +`std::collections::hash_map::DefaultHasher` is explicitly documented as **not guaranteed +stable** between Rust releases. The derived index is persisted only indirectly (via the +stored address). If the toolchain changes and the DB is ever rebuilt/replayed, the same +`id` derives a **different** address. Funds already sent to the old address become +unreachable (the service no longer derives that index for that `id`). + +## Fix + +Pick one of two approaches. + +### Option 1 (recommended): sequential index + persisted counter + +- Store a monotonic `next_index` counter in the `STATE` table. +- On `register`, allocate `index = next_index`, increment, and persist atomically in the + same write transaction that inserts the account. +- No hashing, no collisions, stable forever. `get_next_derivation_index` already exists + (`src/db.rs:51-63`) but is O(N) and unused β€” replace it with the counter. + +```rust +// db.rs: allocate-and-persist in one write txn +pub fn allocate_next_index(&self) -> Result { + let write_txn = self.db.begin_write()?; + let next = { + let mut state = write_txn.open_table(STATE)?; + let cur: u32 = state.get("next_index")? + .map(|v| v.value().parse().unwrap_or(0)).unwrap_or(0); + state.insert("next_index", (cur + 1).to_string().as_str())?; + cur + }; + write_txn.commit()?; + Ok(next) +} +``` + +### Option 2: stable keyed hash + collision loop + +- Replace `DefaultHasher` with a fixed algorithm (e.g. SHA-256 of `id`, take low 31 bits). +- After deriving the address, check `ADDRESS_TO_ID`; on collision, re-hash with a salt/counter + until a free index is found. Persist the chosen index alongside the account. + +Option 1 is simpler, fully deterministic, and removes the collision class entirely. Option 2 +keeps id-derived indices (useful only if you need to re-derive without DB state, which this +service does not β€” it always checks `get_account_by_id` first at `src/lib.rs:391`). + +## Migration / compatibility + +- Existing accounts already have a stored `index` and `address`; leave them untouched. +- Initialize the `next_index` counter above the current max existing index (one-time scan) + so new sequential allocations never collide with legacy hash-derived ones. + +## Tests + +- Registering N accounts yields N **distinct** indices and addresses. +- Re-registering an existing `id` returns the existing address (no new index) β€” preserves + current behavior at `src/lib.rs:391-402`. +- Forced-collision test (Option 2) or counter-monotonicity test (Option 1). +- Counter persists across `Db` reopen (write, drop, reopen, allocate -> no reuse). + +## Risk if not fixed + +Direct, silent loss or misattribution of customer funds at scale. Highest-priority bug in +the codebase. diff --git a/docs/fix-p0-verify-unmined-tx.md b/docs/fix-p0-verify-unmined-tx.md new file mode 100644 index 0000000..a3eccb5 --- /dev/null +++ b/docs/fix-p0-verify-unmined-tx.md @@ -0,0 +1,90 @@ +# Fix (P0): `verify_native_transfer` reports unmined transactions as Success + +Severity: P0 β€” payments "verified" before they are final +Confidence: 7/10 +Location: `src/lib.rs:191-254` (compare with ERC20 path `src/lib.rs:256-284`) + +## Problem + +In `verify_native_transfer`, the receipt is optional and the revert/status check is only +run when a receipt exists: + +```rust +let receipt = self.provider.get_transaction_receipt(tx_hash).await?; +let block_number = receipt.as_ref().and_then(|r| r.block_number); + +// Check if transaction was successful +if let Some(ref r) = receipt { + if !r.status() { + return Ok(VerifyTransferResponse::Error { /* reverted */ }); + } +} + +// ... proceeds to compare tx.to / tx.value regardless of receipt presence +``` + +If `get_transaction_receipt` returns `None` (transaction is in the mempool but **not yet +mined**), the `if let Some` block is skipped entirely. Execution falls through to compare +`tx.to` and `tx.value` (which are available from the pending tx) and returns +`VerifyTransferResponse::Success` with `block_number: None`. + +Consequences: + +- A transaction that is still pending β€” and may later be **dropped, replaced (RBF), or + reverted** β€” is reported as a verified, successful payment. +- The ERC20 path does NOT have this bug: it errors when the receipt is missing + (`src/lib.rs:269-273`). The native path is inconsistent. + +``` +get_transaction_by_hash -> Some(pending tx) // exists in mempool +get_transaction_receipt -> None // not mined yet + -> status check SKIPPED + -> to/value match // from mempool data + -> Success (block_number: None) <-- WRONG +``` + +## Fix + +Require a receipt with a successful status (and, ideally, a minimum confirmation depth) +before returning Success. Mirror the ERC20 path. + +```rust +let receipt = self + .provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| anyhow::anyhow!("Transaction not yet mined"))?; // or return Error variant + +if !receipt.status() { + return Ok(VerifyTransferResponse::Error { + message: "Transaction failed (reverted)".to_string(), + token_type: Some("native".to_string()), + block_number: receipt.block_number, + }); +} +``` + +Decisions to confirm: + +- **Missing receipt -> `Err` vs `Error` variant.** Returning the structured + `VerifyTransferResponse::Error { message: "not yet mined" }` is friendlier to callers than + an HTTP 500; the ERC20 path currently uses `Err` (HTTP 500). Pick one and make both paths + consistent. +- **Confirmation depth.** Optionally require `current_block - receipt.block_number >= N` + (reuse `block_offset_from_head` from config) so verification matches the monitor's + confirmation policy and resists reorgs. + +## Tests + +- Mined + success -> `Success` with `block_number: Some`. +- Mined + reverted (`status() == false`) -> `Error` "reverted". +- No receipt (pending) -> NOT `Success` (either `Err` or `Error` "not yet mined"). +- (If depth added) mined but below confirmation depth -> NOT `Success`. + +Use the existing wiremock harness in `src/tests.rs` to stub `eth_getTransactionByHash` / +`eth_getTransactionReceipt`. + +## Risk if not fixed + +Downstream systems credit a user/order based on a "verified" payment that never finalizes, +enabling double-spend / dropped-tx fraud on the native-currency path. diff --git a/docs/fix-p1-api-authentication.md b/docs/fix-p1-api-authentication.md new file mode 100644 index 0000000..b60ae7f --- /dev/null +++ b/docs/fix-p1-api-authentication.md @@ -0,0 +1,86 @@ +# Fix (P1): Unauthenticated API β€” faucet drain and monitor tampering + +Severity: P1 β€” financial (faucet drain) + availability (missed deposits) +Confidence: 7/10 +Location: `src/api.rs:35-55`, `src/lib.rs:385-489` (`register` -> faucet), `src/lib.rs:130-138` (`set_block_number`) + +## Problem + +The server binds `0.0.0.0` with **no authentication on any route**: + +```rust +let app = Router::new() + .route("/health", get(health::)) + .route("/register", post(register::)) + .route("/verify_transfer", post(verify_transfer::)) + .route("/block_number", get(get_block_number::)) + .route("/block_number", post(set_block_number::)) + .with_state(state); +let addr = format!("0.0.0.0:{}", port); +``` + +There is an outbound `webhook_jwt_token` for webhooks, but nothing protects inbound requests. + +### Attack 1 β€” faucet drain (financial) + +`POST /register` spawns a fire-and-forget faucet transfer of `existential_deposit` to every +newly derived address (`src/lib.rs:423-483`), with no auth and no rate limit. An attacker +loops `register` with random `id`s and drains the faucet wallet. Each call also writes an +account row and (with the collision bug) pollutes the index space. + +### Attack 2 β€” monitor tampering (missed deposits) + +`POST /block_number` calls `set_last_processed_block` (`src/lib.rs:131-133`, +`src/db.rs:177-185`). An attacker can **fast-forward** the last processed block; the monitor +then skips every block in the gap (`catch_up` starts at `last_processed`, +`src/monitor.rs:44-73`). Real deposits that land in skipped blocks are **never detected and +never swept** β€” funds arrive on-chain but the system is blind to them. Rewinding is less +harmful (re-scan; `record_deposit`/`record_erc20_deposit` dedup), but forwarding is a +silent money-loss lever. + +## Fix + +Add authentication to mutating routes. Two reasonable postures: + +### Option A (recommended): shared-secret / JWT middleware + +- Require an `Authorization: Bearer ` header on `register`, `verify_transfer`, and + `POST /block_number`. Keep `/health` and (optionally) `GET /block_number` open. +- Reuse a config secret (a new `API_AUTH_TOKEN`, or validate the existing JWT). Reject with + `401` when missing/invalid. +- Implement as an axum middleware layer so it is uniform and testable. + +```rust +// sketch +async fn require_auth(headers: HeaderMap, req: Request, next: Next) -> Result { + let ok = headers.get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(|t| constant_time_eq(t, expected_token)) // avoid timing leaks + .unwrap_or(false); + if ok { Ok(next.run(req).await) } else { Err(StatusCode::UNAUTHORIZED) } +} +``` + +### Option B: bind to localhost behind an authenticated gateway + +- Bind `127.0.0.1` (config-driven) and terminate auth at a trusted reverse proxy. +- Simpler code, but pushes the security boundary to deployment config β€” easy to get wrong. + +Additionally, regardless of option: + +- **Rate-limit / gate `register`** so a flood cannot drain the faucet (per-caller limit, or + require auth which implies a trusted caller). +- Consider validating `set_block_number` input (reject values far ahead of chain head) as + defense-in-depth. + +## Tests + +- Mutating route without/with-bad token -> `401`; with valid token -> `200`. +- `/health` reachable without auth. +- `set_block_number` rejects a value far beyond current chain head (if bound added). + +## Risk if not fixed + +If the port is reachable beyond a trusted network, an attacker can empty the faucet and/or +blind the monitor to real deposits. Both are direct money loss. diff --git a/docs/fix-p1-faucet-nonce-race.md b/docs/fix-p1-faucet-nonce-race.md new file mode 100644 index 0000000..439d819 --- /dev/null +++ b/docs/fix-p1-faucet-nonce-race.md @@ -0,0 +1,82 @@ +# Fix (P1): Faucet nonce race under concurrency + +Severity: P1 β€” funding/sweep failures under load +Confidence: 7/10 +Location: `src/faucet.rs:36-85`, callers `src/lib.rs:430` (register) and `src/sweeper.rs:264,425` (sweeper) + +## Problem + +All faucet funding uses the single faucet signer at index 0: + +```rust +let signer = self.wallet.get_signer(0)?; +let faucet_provider = ProviderBuilder::new() + .with_recommended_fillers() // fetches nonce per-tx via eth_getTransactionCount(pending) + .wallet(wallet) + .on_provider(&self.provider); +let pending_tx = faucet_provider.send_transaction(tx).await?; +``` + +Multiple callers invoke `fund_new_address` **concurrently**: + +- `register` spawns a fire-and-forget funding task per registration (`src/lib.rs:430`). +- The sweeper funds addresses from its loop when balances are too low for gas + (`src/sweeper.rs:264` for native, `src/sweeper.rs:425` for ERC20). + +`with_recommended_fillers` resolves the nonce independently for each transaction by querying +`eth_getTransactionCount(..., pending)`. When two funding transactions are built before +either is mined, both read the **same** nonce. The chain accepts one; the other fails with +`nonce too low` / `already known` / replacement errors. + +``` +t0: register("a") ─ getTransactionCount(pending) = 7 ─ send nonce=7 ┐ +t0: sweeper fund ─ getTransactionCount(pending) = 7 ─ send nonce=7 β”˜ one of these is rejected +``` + +Net effect under load: intermittent funding failures, which cascade into failed sweeps +(addresses never get gas) β€” exactly when volume is highest. + +## Fix + +Serialize faucet sends so nonces are assigned in order. + +### Option A (recommended): mutex around faucet sends + +- Wrap the send section in an `async` mutex held by the `Faucet` (e.g. `tokio::sync::Mutex<()>`). + Acquire before building/sending, release after `send_transaction` returns the pending tx + (you can release before waiting for the receipt, since the nonce is consumed at submission). +- Minimal change, removes the race. Throughput is bounded by submission latency, acceptable + for a faucet. + +```rust +pub struct Faucet

{ + wallet: Wallet, + provider: P, + existential_deposit: U256, + send_lock: tokio::sync::Mutex<()>, +} +// in fund_new_address: +let _guard = self.send_lock.lock().await; +// build + send_transaction under the guard +``` + +### Option B: explicit nonce manager + +- Track the faucet nonce in the `Faucet` (seed from `getTransactionCount` once, then + increment locally), assign explicitly with `.with_nonce(n)`. More robust at high + throughput but needs reconciliation on restart and on send failure (gap handling). + +Option A is the right-sized fix for current volume; revisit Option B only if the faucet +becomes a throughput bottleneck. + +## Tests + +- Two concurrent `fund_new_address` calls both succeed (serialized), each with a distinct + nonce β€” verify against a wiremock that records submitted nonces, or an integration test on + a local node (anvil). +- Faucet insufficient-balance path still returns the existing error (`src/faucet.rs:53-61`). + +## Risk if not fixed + +Under registration/sweep bursts, funding txs collide and fail, leaving deposit addresses +without gas so their sweeps never complete β€” funds sit unswept until manual intervention. diff --git a/docs/fix-p1-verify-buffer-overrun-panic.md b/docs/fix-p1-verify-buffer-overrun-panic.md new file mode 100644 index 0000000..5bbb00f --- /dev/null +++ b/docs/fix-p1-verify-buffer-overrun-panic.md @@ -0,0 +1,74 @@ +# Fix (P1): Panic in `verify_erc20_transfer` on Transfer logs with >32 bytes of data + +Severity: P1 β€” request-handler panic (DoS / crash) +Confidence: 7/10 +Location: `src/lib.rs:324-329` (safe reference impl: `src/monitor.rs:196-208`) + +## Problem + +```rust +// src/lib.rs +let amount = if !log.data().data.is_empty() { + U256::from_be_slice(&log.data().data) +} else { + U256::ZERO +}; +``` + +`U256::from_be_slice` **panics if the slice is longer than 32 bytes** (ruint contract). A +standard ERC20 `Transfer` carries exactly 32 bytes in `data`, but a non-standard or +malicious token can emit a `Transfer(address,address,uint256)` log with extra trailing data. +When `/verify_transfer` processes such a log, the handler panics. + +The monitor already handles this correctly by taking the first 32 bytes: + +```rust +// src/monitor.rs +let amount = if log.data().data.len() >= 32 { + let amount_bytes: [u8; 32] = log.data().data[..32].try_into().expect("slice length is 32"); + U256::from_be_bytes(amount_bytes) +} else if !log.data().data.is_empty() { + U256::from_be_slice(&log.data().data) +} else { + U256::ZERO +}; +``` + +So the codebase has both a safe and an unsafe decoder for the same value β€” the verify path +uses the unsafe one. + +## Fix + +Make the verify path use the same bounded decode as the monitor. Extract a single shared +helper to remove the duplication (DRY) and guarantee both paths behave identically. + +```rust +/// Decode an ERC20 Transfer `value` from log data, tolerant of non-standard tokens +/// that pad or append extra bytes. Never panics. +pub fn decode_transfer_amount(data: &[u8]) -> U256 { + if data.len() >= 32 { + let mut buf = [0u8; 32]; + buf.copy_from_slice(&data[..32]); + U256::from_be_bytes(buf) + } else if !data.is_empty() { + U256::from_be_slice(data) + } else { + U256::ZERO + } +} +``` + +Call it from both `src/lib.rs` (verify) and `src/monitor.rs` (detection). + +## Tests + +- `decode_transfer_amount`: empty -> 0; <32 bytes -> right-aligned value; exactly 32 bytes + -> exact value; **>32 bytes -> first 32 decoded, no panic** (the regression case). +- Verify-path test: receipt log with a 64-byte `data` field does not panic and decodes the + leading 32 bytes. + +## Risk if not fixed + +A single crafted token transaction passed to `/verify_transfer` crashes the request task. +Combined with the unauthenticated API (see `fix-p1-api-authentication.md`), this is a remote +crash vector. diff --git a/docs/plan-spam-token-sweep-guard.md b/docs/plan-spam-token-sweep-guard.md new file mode 100644 index 0000000..85ae7c1 --- /dev/null +++ b/docs/plan-spam-token-sweep-guard.md @@ -0,0 +1,147 @@ +# Plan: Spam-token sweep guard (stop the Alchemy credit drain) + +Status: ready to implement +Priority: P0 (active credit burn) +Scope: `src/config.rs`, `src/main.rs`, `src/monitor.rs`, `src/sweeper.rs`, `src/tests.rs`, `src/e2e_tests.rs`, `.env`, `README.md` + +## Problem + +The sweeper logs a flood of: + +``` +ERROR evm_hot_wallet::sweeper: Failed to sweep ERC20 deposit 0x..:NNN: buffer overrun while deserializing +``` + +every poll cycle, and each attempt spends Alchemy compute units. + +## Root cause + +`buffer overrun while deserializing` is alloy's ABI-decode failure on the `balanceOf` +call at the start of `sweep_erc20_deposit` (`src/sweeper.rs:686-688`): + +```rust +let contract = IERC20::new(token_address, provider); +let balance = contract.balanceOf(owner_address).call().await?._0; +Ok(balance) +``` + +The error appears one RPC round-trip after the `Signer address:` log line, confirming it +is the `balanceOf` call, not the transaction send. + +The deposits are **spam tokens**: the symbol `USDC` shows up at 10+ different contract +addresses in the logs (and `USDT0` at several more). Legit stablecoins have one canonical +address per chain. These fake tokens emit counterfeit `Transfer(address,address,uint256)` +events to get picked up by indexers. Their `balanceOf` returns empty/garbage data, so +decoding into `uint256` fails (one even returns `execution reverted`). + +How they enter the system: the monitor records a deposit for **any** token whose `Transfer` +event targets a monitored address, as long as the symbol is <= 5 chars +(`src/monitor.rs:193-246`). There is no token allowlist. The sweeper then calls `balanceOf` +on each, every cycle, forever β€” and every new block brings fresh spam. + +``` +Block ──> monitor.process_erc20_transfers ──(any token, symbol<=5)──> ERC20_DEPOSITS(detected) + β”‚ + sweeper.process_deposits ──balanceOf RPC──> buffer overrun ──> retry next cycle +``` + +## What already exists + +- Infinite-retry guard: `MAX_SWEEP_RETRIES = 5` (`src/sweeper.rs:65`), `SWEEP_FAILURES` + table, `increment_sweep_failure_count`, `mark_erc20_deposits_failed_for_account_token`, + `mark_erc20_deposit_failed` (`src/db.rs:418-514`). Committed 2026-04-04 (`bcd4a3e`), on + `main`. It caps retries at 5 per deposit but still costs 5 calls each and does nothing + about the steady influx of new spam. +- Crude symbol-length filter in the monitor (`src/monitor.rs:219`). + +So if the running binary still shows endless retries, **confirm the deployed commit** β€” it +may predate `bcd4a3e`. + +## Fix + +### 1. Env-configured allowlist on `Config` (`src/config.rs`) + +- Add `pub allowed_token_addresses: std::collections::HashSet` (normalized lowercase). +- In `from_env`, parse `ALLOWED_TOKEN_ADDRESSES` (comma-separated): trim, lowercase, drop + empties. Normalize so a bare and a `0x`-prefixed address compare equal (addresses from + `Address::to_string()` always carry the `0x` prefix). +- Add `pub fn is_token_allowed(&self, token_address: &str) -> bool` that returns `true` + when the set is empty (allowlist disabled) or contains the normalized address. + +**Empty-set semantics (UNRESOLVED decision β€” defaulting to allow-all):** empty/unset = +allowlist disabled = allow all, for safe rollout. Consequence: the drain only fully stops +once `ALLOWED_TOKEN_ADDRESSES` is populated; until then only fail-fast (5 -> 1 calls per +spam token) mitigates it. **Deploy order: set the env var, then ship.** Log a loud `WARN` +at startup (`src/main.rs`) when the set is empty so the inert state is obvious. + +Alternative postures if you want a hard guarantee on deploy: empty = block all sweeps, or +empty = fatal startup error. Both risk halting real fund movement on a misconfigured deploy. + +### 2. Filter spam at detection (`src/monitor.rs`) + +In `process_erc20_transfers`, after confirming `to_address` is monitored and **before** +`get_or_fetch_token_metadata` (which itself makes 3 RPC calls), `continue` if +`!self.config.is_token_allowed(&token_address.to_string())`. Log at `debug` to avoid log +spam. Prevents spam from ever entering the DB and saves the symbol/decimals/name RPC calls. + +### 3. Skip + fail-fast in the sweeper (`src/sweeper.rs`) + +- At the top of the per-deposit loop in `process_deposits` (around line 137), if + `!self.config.is_token_allowed(&deposit.token_address)`, mark just that deposit failed via + `self.db.mark_erc20_deposit_failed(&deposit.key)` and `continue` β€” **no RPC call**. Because + the loop visits every detected deposit in one cycle, all currently-stored spam is cleared + to `failed` in a single pass at zero Alchemy cost. +- Extract a pure classifier helper (not an inline string match): + + ```rust + /// Deterministic, non-retryable sweep errors (junk / non-ERC20 token contracts). + /// Matches stable alloy decode-failure substrings. Unit-tested so an alloy upgrade + /// that changes the wording fails a test instead of silently re-enabling retries. + fn is_permanent_sweep_error(err_debug: &str) -> bool { + let s = err_debug.to_ascii_lowercase(); + s.contains("buffer overrun") || s.contains("deserializ") + } + ``` + +- In the `Err(e)` branch (lines 195-216), if `is_permanent_sweep_error(&format!("{:?}", e))`, + mark the account+token failed immediately (1 strike) via + `mark_erc20_deposits_failed_for_account_token`. Keep the existing 5-retry path for other + (potentially transient) errors like `execution reverted`. + +### 4. Update remaining `Config` literals + +Add `allowed_token_addresses: Default::default(),` (empty = disabled) to the 7 literals in +`src/tests.rs` and the 1 in `src/e2e_tests.rs`. + +### 5. Docs / env + +- Add a commented `ALLOWED_TOKEN_ADDRESSES=` entry to `.env` with the format and the + deploy-order note (set before shipping). +- Note the variable in `README.md`. + +## Tests (harness already has wiremock + tempfile) + +- `Config::is_token_allowed`: empty -> allows all; present (case-insensitive, with/without + `0x`) -> true; absent -> false. +- Allowlist parsing: comma list, surrounding whitespace, mixed case, empty entries dropped. +- `is_permanent_sweep_error`: `"...buffer overrun while deserializing"` -> true; + `"execution reverted"` and generic errors -> false. +- Monitor skip (highest value): a `Transfer` to a monitored address for a non-allowlisted + token is NOT recorded (and triggers no metadata RPC); an allowlisted one IS recorded. +- Sweeper skip (DB-level): a `detected` deposit whose token is not allowlisted is marked + `failed` and leaves the `detected` set, with no provider call. + +## Result + +- Once `ALLOWED_TOKEN_ADDRESSES` is set, spam tokens are skipped with **zero** RPC calls in + both monitor and sweeper β€” the credit drain is eliminated. +- Even with the allowlist off, a decode error now permanently marks the deposit failed after + 1 attempt instead of retrying forever. +- No DB migration: existing spam `detected` rows are marked `failed` on the next sweep cycle + without any RPC. + +## NOT in scope (deferred) + +- Dedupe the duplicated `IERC20` `sol!` block across `monitor.rs`/`sweeper.rs`. +- Pruning/compaction of old `failed`/`swept` rows in `ERC20_DEPOSITS`. +- Fast-failing `execution reverted` (kept on the 5-retry path; reverts can be transient). diff --git a/env.docker.example b/env.docker.example index 80edc9b..02c2396 100644 --- a/env.docker.example +++ b/env.docker.example @@ -7,43 +7,33 @@ # Database (using Docker volume path) DATABASE_URL=/app/data/wallet.db -# Blockchain Connection -# Use either RPC_URL (HTTP polling) or WS_URL (WebSocket streaming) -RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY -# WS_URL=wss://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY - -# Hot Wallet Mnemonic -# This mnemonic derives deposit addresses for users -# SECURITY: Use a dedicated mnemonic, keep it secure, back it up +# Optional: max concurrent SQLite read-pool connections. Shared by every +# chain's monitor/sweeper/webhook-retry loop plus inbound registrations, so +# raise this if you configure more chains or see "timed out waiting for +# connection" errors under load. Default: 20. +# DB_READ_POOL_SIZE=20 + +# Hot Wallet Mnemonic (derives deposit addresses β€” same on all EVM chains) MNEMONIC=your twelve word mnemonic phrase goes here for hot wallet -# Faucet Configuration -# Separate mnemonic for funding new addresses with existential deposit +# Faucet Mnemonic (lazy-funds addresses for gas at sweep time) FAUCET_MNEMONIC=another twelve word mnemonic phrase for faucet wallet funding -# Faucet Address (derived from FAUCET_MNEMONIC at index 0) -# Use: cast wallet address --mnemonic "your faucet mnemonic" --mnemonic-index 0 -FAUCET_ADDRESS=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 - -# Treasury Address -# All swept funds are sent here -TREASURY_ADDRESS=0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb +# Path to per-chain configuration (mount chains.toml into the container) +CHAINS_CONFIG=/app/chains.toml -# Existential Deposit (in wei) -# Amount to fund each new address -# 10000000000000000 = 0.01 ETH -EXISTENTIAL_DEPOSIT=10000000000000000 +# Optional: chain name used when migrating legacy single-chain DB data +# LEGACY_CHAIN=polygon # API Server PORT=3000 -# Blockchain Polling Interval (seconds) -# Only used when RPC_URL is configured (not WS_URL) -POLL_INTERVAL=10 - -# Block Offset from Head -# Number of blocks to stay behind the current head for confirmation safety -# Default: 20 blocks (~4 minutes on most chains) -BLOCK_OFFSET_FROM_HEAD=20 - +# Optional: JWT sent as Authorization: Bearer on webhooks and admin endpoints +# WEBHOOK_JWT_TOKEN=your-secret-token +# Webhook delivery retries (background worker) +# WEBHOOK_MAX_RETRIES=5 +# WEBHOOK_RETRY_DELAY_MS=1000 +# WEBHOOK_RETRY_POLL_INTERVAL=30 +# WEBHOOK_RETRY_BATCH_SIZE=50 +# WEBHOOK_LEASE_SECONDS=60 diff --git a/examples/library_usage.rs b/examples/library_usage.rs index ab553aa..a57ff72 100644 --- a/examples/library_usage.rs +++ b/examples/library_usage.rs @@ -1,51 +1,21 @@ /// Example showing how to use the evm_hot_wallet library programmatically -/// -/// This demonstrates using the HotWalletService as a library without the web server use evm_hot_wallet::{config::Config, HotWalletService, RegisterRequest}; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - // Load configuration from environment let config = Config::from_env()?; - - // Create service based on provider type - match &config.provider_url { - evm_hot_wallet::config::ProviderUrl::Http(_) => { - // Create HTTP service - let service = HotWalletService::new_http(config).await?; - - // Start background services - service.start_background_services().await?; - - // Use the service programmatically - example_usage(&service).await?; - } - evm_hot_wallet::config::ProviderUrl::Ws(_) => { - // Create WebSocket service - let service = HotWalletService::new_ws(config).await?; - - // Start background services - service.start_background_services().await?; - - // Use the service programmatically - example_usage(&service).await?; - } - } - + let service = HotWalletService::new(config).await?; + service.start_background_services().await?; + example_usage(&service).await?; Ok(()) } -async fn example_usage(service: &HotWalletService) -> anyhow::Result<()> -where - T: alloy::transports::Transport + Clone + Send + Sync + 'static, -{ - // Check health +async fn example_usage(service: &HotWalletService) -> anyhow::Result<()> { let health = service.health().await?; println!("Health check: {}", health); - // Register a new account let request = RegisterRequest { id: "example_user_123".to_string(), webhook_url: "https://example.com/webhook".to_string(), @@ -54,11 +24,6 @@ where let response = service.register(request).await?; println!("Registered address: {}", response.address); - if let Some(tx) = response.funding_tx { - println!("Funding transaction: {}", tx); - } - - // Keep the program running to allow background services to work println!("Service is running. Background services (monitor and sweeper) are active."); println!("Press Ctrl+C to stop."); diff --git a/migrations/V1__initial.sql b/migrations/V1__initial.sql new file mode 100644 index 0000000..4dedc16 --- /dev/null +++ b/migrations/V1__initial.sql @@ -0,0 +1,60 @@ +CREATE TABLE accounts ( + id TEXT PRIMARY KEY NOT NULL, + derivation_index INTEGER NOT NULL, + address TEXT NOT NULL UNIQUE, + webhook_url TEXT NOT NULL +); + +CREATE TABLE deposits ( + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + account_id TEXT NOT NULL, + amount TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (chain, tx_hash) +); +CREATE INDEX idx_deposits_chain_status ON deposits (chain, status); + +CREATE TABLE erc20_deposits ( + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + log_index INTEGER NOT NULL, + account_id TEXT NOT NULL, + amount TEXT NOT NULL, + token_address TEXT NOT NULL, + token_symbol TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (chain, tx_hash, log_index) +); +CREATE INDEX idx_erc20_deposits_chain_status ON erc20_deposits (chain, status); + +CREATE TABLE token_metadata ( + chain TEXT NOT NULL, + token_address TEXT NOT NULL, + symbol TEXT NOT NULL, + decimals INTEGER NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (chain, token_address) +); + +CREATE TABLE sweep_meta ( + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + log_index INTEGER NOT NULL DEFAULT 0, + sweep_tx_hash TEXT NOT NULL DEFAULT '', + zero_balance_retry_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (chain, tx_hash, log_index) +); + +CREATE TABLE sweep_failures ( + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + log_index INTEGER NOT NULL DEFAULT 0, + consecutive_failure_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (chain, tx_hash, log_index) +); + +CREATE TABLE state ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL +); diff --git a/migrations/V2__webhook_deliveries.sql b/migrations/V2__webhook_deliveries.sql new file mode 100644 index 0000000..d511be4 --- /dev/null +++ b/migrations/V2__webhook_deliveries.sql @@ -0,0 +1,16 @@ +CREATE TABLE webhook_deliveries ( + id TEXT NOT NULL, + event TEXT NOT NULL, + registration_id TEXT NOT NULL, + webhook_url TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_http_status INTEGER, + last_error TEXT, + leased_until INTEGER, + updated_at INTEGER NOT NULL, + PRIMARY KEY (id, event) +); +CREATE INDEX idx_webhook_deliveries_pending + ON webhook_deliveries (status, attempt_count, updated_at); diff --git a/migrations/V3__next_index_counter.sql b/migrations/V3__next_index_counter.sql new file mode 100644 index 0000000..4371332 --- /dev/null +++ b/migrations/V3__next_index_counter.sql @@ -0,0 +1,11 @@ +-- P0 register address-collision fix (docs/fix-p0-register-address-collision.md, +-- Option 1): new registrations allocate a sequential derivation index from a +-- persisted counter instead of hashing the account id. +-- +-- Seed the counter above the current max index so new sequential allocations +-- never collide with legacy hash-derived indices. Existing accounts are left +-- untouched. INSERT OR IGNORE keeps this idempotent if a 'next_index' row +-- somehow already exists. +INSERT OR IGNORE INTO state (key, value) +SELECT 'next_index', CAST(COALESCE(MAX(derivation_index) + 1, 0) AS TEXT) +FROM accounts; diff --git a/scripts/_api_env.sh b/scripts/_api_env.sh new file mode 100755 index 0000000..295ba09 --- /dev/null +++ b/scripts/_api_env.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Shared helpers for evmhot API scripts. Source this file; do not execute directly. + +_api_env_loaded() { + : +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +load_env() { + if [[ -f "${PROJECT_ROOT}/.envrc" ]]; then + # shellcheck disable=SC1091 + set -a + source "${PROJECT_ROOT}/.envrc" + set +a + fi + if [[ -f "${PROJECT_ROOT}/.env" ]]; then + # shellcheck disable=SC1091 + set -a + source "${PROJECT_ROOT}/.env" + set +a + fi +} + +CHAINS_CONFIG="${CHAINS_CONFIG:-${PROJECT_ROOT}/chains.toml}" +PORT="${PORT:-3000}" +API_BASE="${EVMHOT_API_URL:-http://localhost:${PORT}}" + +list_chain_names() { + if [[ ! -f "${CHAINS_CONFIG}" ]]; then + echo "Error: chains config not found: ${CHAINS_CONFIG}" >&2 + return 1 + fi + grep '^name = ' "${CHAINS_CONFIG}" | sed 's/name = "\(.*\)"/\1/' +} + +# Read a field from the [[chains]] block matching $1 (e.g. rpc_url, block_offset_from_head). +get_chain_field() { + local chain="$1" + local field="$2" + awk -v chain="${chain}" -v field="${field}" ' + /^name = / { + gsub(/^name = "|"$/, "") + current = $0 + } + current == chain && $0 ~ ("^" field " = ") { + sub(/^[^=]+= /, "") + gsub(/^"|"$/, "") + print + exit + } + ' "${CHAINS_CONFIG}" +} + +get_chain_block_offset() { + local chain="$1" + local offset + offset="$(get_chain_field "${chain}" "block_offset_from_head")" + if [[ -z "${offset}" ]]; then + echo 20 + else + echo "${offset}" + fi +} + +rpc_block_number() { + local rpc_url="$1" + local hex + hex="$( + curl -sS -X POST "${rpc_url}" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \ + | sed -n 's/.*"result"[[:space:]]*:[[:space:]]*"\(0x[^"]*\)".*/\1/p' + )" + if [[ -z "${hex}" ]]; then + echo "Error: failed to fetch block number from ${rpc_url}" >&2 + return 1 + fi + printf "%d" "${hex}" +} + +api_get_block_number() { + local chain="$1" + curl -sS "${API_BASE}/block_number?chain=${chain}" +} + +api_set_block_number() { + local chain="$1" + local block_number="$2" + curl -sS -X POST "${API_BASE}/block_number" \ + -H "Content-Type: application/json" \ + -d "{\"chain\":\"${chain}\",\"block_number\":${block_number}}" +} + +print_usage_header() { + echo "API: ${API_BASE}" + echo "Chains config: ${CHAINS_CONFIG}" + echo +} diff --git a/scripts/cursors.example.txt b/scripts/cursors.example.txt new file mode 100644 index 0000000..c09296f --- /dev/null +++ b/scripts/cursors.example.txt @@ -0,0 +1,5 @@ +# Per-chain monitor cursors (chain name + block number). +# Used with: ./scripts/set_all_block_numbers.sh --file scripts/cursors.example.txt +# +polygon 89211738 +base 47864100 diff --git a/scripts/get_block_numbers.sh b/scripts/get_block_numbers.sh new file mode 100755 index 0000000..ef88a19 --- /dev/null +++ b/scripts/get_block_numbers.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Print last-processed block cursor for one or all configured chains. +# +# Usage: +# ./scripts/get_block_numbers.sh # all chains in chains.toml +# ./scripts/get_block_numbers.sh polygon # single chain + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_api_env.sh" + +load_env +print_usage_header + +chains=() +if [[ $# -ge 1 ]]; then + chains=("$@") +else + while IFS= read -r chain; do + chains+=("${chain}") + done < <(list_chain_names) +fi + +printf "%-12s %s\n" "CHAIN" "BLOCK_NUMBER" +printf "%-12s %s\n" "-----" "------------" + +for chain in "${chains[@]}"; do + response="$(api_get_block_number "${chain}")" + block_number="$(echo "${response}" | sed -n 's/.*"block_number"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')" + if [[ -z "${block_number}" ]]; then + echo "Error: failed to read block_number for ${chain}: ${response}" >&2 + exit 1 + fi + printf "%-12s %s\n" "${chain}" "${block_number}" +done diff --git a/scripts/set_all_block_numbers.sh b/scripts/set_all_block_numbers.sh new file mode 100755 index 0000000..385ca9a --- /dev/null +++ b/scripts/set_all_block_numbers.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Set block cursors for all chains (or a subset) in one run. +# +# Usage: +# ./scripts/set_all_block_numbers.sh polygon=89211738 base=47864100 +# ./scripts/set_all_block_numbers.sh --file cursors.txt +# ./scripts/set_all_block_numbers.sh --from-rpc +# +# cursors.txt format (one per line): +# polygon 89211738 +# base 47864100 +# +# --from-rpc sets each chain to (latest RPC block - block_offset_from_head), +# matching what the monitor uses as its catch-up ceiling. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_api_env.sh" + +usage() { + cat <&2 + return 1 + fi + printf "%-12s %s -> %s\n" "${chain}" "${before_block:-?}" "${after_block}" +} + +sync_chain_from_rpc() { + local chain="$1" + local rpc_url offset head target + + rpc_url="$(get_chain_field "${chain}" "rpc_url")" + if [[ -z "${rpc_url}" ]]; then + echo "Error: no rpc_url for chain ${chain} in ${CHAINS_CONFIG}" >&2 + return 1 + fi + + offset="$(get_chain_block_offset "${chain}")" + head="$(rpc_block_number "${rpc_url}")" + target=$((head - offset)) + + echo "${chain}: rpc head=${head}, offset=${offset}, setting cursor=${target}" + set_chain_block "${chain}" "${target}" +} + +load_env +print_usage_header + +if [[ $# -lt 1 ]]; then + usage >&2 + exit 1 +fi + +case "$1" in + --file) + if [[ $# -ne 2 ]]; then + usage >&2 + exit 1 + fi + file="$2" + if [[ ! -f "${file}" ]]; then + echo "Error: file not found: ${file}" >&2 + exit 1 + fi + while read -r chain block_number _; do + [[ -z "${chain}" || "${chain}" =~ ^# ]] && continue + set_chain_block "${chain}" "${block_number}" + done < "${file}" + ;; + --from-rpc) + shift + chains=() + if [[ $# -ge 1 ]]; then + chains=("$@") + else + while IFS= read -r chain; do + chains+=("${chain}") + done < <(list_chain_names) + fi + for chain in "${chains[@]}"; do + sync_chain_from_rpc "${chain}" + done + ;; + -h | --help) + usage + ;; + *) + for pair in "$@"; do + if [[ "${pair}" != *"="* ]]; then + echo "Error: expected CHAIN=BLOCK, got: ${pair}" >&2 + exit 1 + fi + chain="${pair%%=*}" + block_number="${pair#*=}" + if ! [[ "${block_number}" =~ ^[0-9]+$ ]]; then + echo "Error: invalid block number in ${pair}" >&2 + exit 1 + fi + set_chain_block "${chain}" "${block_number}" + done + ;; +esac + +echo +echo "Done." diff --git a/scripts/set_block_number.sh b/scripts/set_block_number.sh new file mode 100755 index 0000000..5350a94 --- /dev/null +++ b/scripts/set_block_number.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Set last-processed block cursor for one chain via POST /block_number. +# +# Usage: +# ./scripts/set_block_number.sh polygon 89211738 +# ./scripts/set_block_number.sh base 47864100 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/_api_env.sh" + +usage() { + cat <&2 + exit 1 +fi + +chain="$1" +block_number="$2" + +if ! [[ "${block_number}" =~ ^[0-9]+$ ]]; then + echo "Error: BLOCK_NUMBER must be a non-negative integer, got: ${block_number}" >&2 + exit 1 +fi + +load_env +print_usage_header + +before="$(api_get_block_number "${chain}")" +before_block="$(echo "${before}" | sed -n 's/.*"block_number"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')" + +response="$(api_set_block_number "${chain}" "${block_number}")" +after_block="$(echo "${response}" | sed -n 's/.*"block_number"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')" + +if [[ -z "${after_block}" ]]; then + echo "Error: failed to set block_number for ${chain}: ${response}" >&2 + exit 1 +fi + +echo "${chain}: ${before_block:-?} -> ${after_block}" diff --git a/src/api.rs b/src/api.rs index 3894065..fc0bf60 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,51 +1,54 @@ -use alloy::transports::Transport; use axum::{ - extract::{Json, State}, - http::StatusCode, + extract::{Json, Query, State}, + http::{header::AUTHORIZATION, HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, Router, }; use evm_hot_wallet::{ - HotWalletService, RegisterRequest, RegisterResponse, VerifyTransferRequest, + db::WriteQueueError, HotWalletService, RegisterRequest, RegisterResponse, RetrySweepRequest, + RetrySweepResponse, RetryWebhookRequest, RetryWebhookResponse, VerifyTransferRequest, VerifyTransferResponse, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use tokio::net::TcpListener; #[derive(Deserialize)] pub struct SetBlockNumberRequest { + pub chain: String, pub block_number: u64, } +#[derive(Deserialize)] +pub struct ChainQuery { + pub chain: String, +} + #[derive(Serialize)] pub struct BlockNumberResponse { + pub chain: String, pub block_number: u64, } -use tokio::net::TcpListener; #[derive(Clone)] -struct AppState -where - T: Transport + Clone, -{ - service: Arc>, +struct AppState { + service: Arc, } -pub async fn start_server(service: HotWalletService, port: u16) -where - T: Transport + Clone + Send + Sync + 'static, -{ +pub async fn start_server(service: HotWalletService, port: u16) { let state = AppState { service: Arc::new(service), }; let app = Router::new() - .route("/health", get(health::)) - .route("/register", post(register::)) - .route("/verify_transfer", post(verify_transfer::)) - .route("/block_number", get(get_block_number::)) - .route("/block_number", post(set_block_number::)) + .route("/health", get(health)) + .route("/register", post(register)) + .route("/verify_transfer", post(verify_transfer)) + .route("/block_number", get(get_block_number)) + .route("/block_number", post(set_block_number)) + .route("/admin/retry_sweeps", post(retry_sweeps)) + .route("/admin/retry_webhooks", post(retry_webhooks)) .with_state(state); let addr = format!("0.0.0.0:{}", port); @@ -54,36 +57,37 @@ where axum::serve(listener, app).await.unwrap(); } -async fn health(State(state): State>) -> impl IntoResponse -where - T: Transport + Clone + Send + Sync + 'static, -{ +async fn health(State(state): State) -> impl IntoResponse { match state.service.health().await { Ok(msg) => (StatusCode::OK, msg), Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ERROR".to_string()), } } -async fn register( - State(state): State>, +async fn register( + State(state): State, Json(payload): Json, -) -> Result, ApiError> -where - T: Transport + Clone + Send + Sync + 'static, -{ +) -> Result, ApiError> { match state.service.register(payload).await { Ok(response) => Ok(Json(response)), - Err(e) => Err(ApiError::Internal(format!("Failed to register: {}", e))), + Err(e) => Err(map_write_error(e, "Failed to register")), + } +} + +/// Maps write-queue saturation/timeout errors to 503 (retryable, the write +/// path is overloaded or down) instead of a generic 500. +fn map_write_error(e: anyhow::Error, context: &str) -> ApiError { + if e.downcast_ref::().is_some() { + ApiError::ServiceUnavailable(format!("{}: {}", context, e)) + } else { + ApiError::Internal(format!("{}: {}", context, e)) } } -async fn verify_transfer( - State(state): State>, +async fn verify_transfer( + State(state): State, Json(payload): Json, -) -> Result, ApiError> -where - T: Transport + Clone + Send + Sync + 'static, -{ +) -> Result, ApiError> { match state.service.verify_transfer(payload).await { Ok(response) => Ok(Json(response)), Err(e) => Err(ApiError::Internal(format!( @@ -93,45 +97,101 @@ where } } -async fn get_block_number( - State(state): State>, -) -> Result, ApiError> -where - T: Transport + Clone + Send + Sync + 'static, -{ +async fn get_block_number( + State(state): State, + Query(query): Query, +) -> Result, ApiError> { let block_number = state .service - .get_block_number() + .get_block_number(&query.chain) .map_err(|e| ApiError::Internal(e.to_string()))?; - Ok(Json(BlockNumberResponse { block_number })) + Ok(Json(BlockNumberResponse { + chain: query.chain, + block_number, + })) } -async fn set_block_number( - State(state): State>, +async fn set_block_number( + State(state): State, Json(payload): Json, -) -> Result, ApiError> -where - T: Transport + Clone + Send + Sync + 'static, -{ +) -> Result, ApiError> { state .service - .set_block_number(payload.block_number) - .map_err(|e| ApiError::Internal(e.to_string()))?; + .set_block_number(&payload.chain, payload.block_number) + .await + .map_err(|e| map_write_error(e, "Failed to set block number"))?; Ok(Json(BlockNumberResponse { + chain: payload.chain, block_number: payload.block_number, })) } -// Error handling for the API +fn authorize_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> { + let Some(expected) = state.service.config().webhook_jwt_token.as_ref() else { + return Err(ApiError::Unauthorized( + "Admin auth is not configured (set WEBHOOK_JWT_TOKEN)".to_string(), + )); + }; + let authorized = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {expected}")); + if !authorized { + return Err(ApiError::Unauthorized("Unauthorized".to_string())); + } + Ok(()) +} + +async fn retry_sweeps( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result, ApiError> { + authorize_admin(&state, &headers)?; + state + .service + .retry_sweep(payload) + .await + .map(Json) + .map_err(|e| map_write_error(e, "Failed to retry sweep")) +} + +async fn retry_webhooks( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result, ApiError> { + authorize_admin(&state, &headers)?; + state + .service + .retry_webhook(payload) + .await + .map(Json) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("No failed webhook delivery found") { + ApiError::NotFound(msg) + } else { + map_write_error(e, "Failed to retry webhook") + } + }) +} + #[derive(Debug)] enum ApiError { Internal(String), + Unauthorized(String), + NotFound(String), + ServiceUnavailable(String), } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, message) = match self { ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + ApiError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg), }; (status, message).into_response() diff --git a/src/bin/migrate_redb_to_sqlite.rs b/src/bin/migrate_redb_to_sqlite.rs new file mode 100644 index 0000000..e48fce9 --- /dev/null +++ b/src/bin/migrate_redb_to_sqlite.rs @@ -0,0 +1,77 @@ +use clap::Parser; +use evm_hot_wallet::redb_import::migrate_redb_file_to_sqlite; +use std::path::PathBuf; + +#[derive(Parser)] +#[command( + name = "migrate_redb_to_sqlite", + about = "One-shot migration from redb to SQLite" +)] +struct Args { + #[arg(long, help = "Path to existing redb database file")] + from: PathBuf, + #[arg(long, help = "Path for new SQLite database file")] + to: PathBuf, + #[arg( + long, + default_value = "polygon", + help = "Legacy chain name for v1 key namespacing" + )] + legacy_chain: String, + #[arg(long, default_value_t = false, help = "Overwrite existing SQLite file")] + force: bool, +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let summary = + migrate_redb_file_to_sqlite(&args.from, &args.to, &args.legacy_chain, args.force)?; + + println!( + "Migration complete: {} -> {}", + args.from.display(), + args.to.display() + ); + println!( + "accounts: {} redb -> {} inserted", + summary.accounts.0, summary.accounts.1 + ); + println!( + "deposits: {} redb -> {} inserted", + summary.deposits.0, summary.deposits.1 + ); + println!( + "erc20_deposits: {} redb -> {} inserted", + summary.erc20_deposits.0, summary.erc20_deposits.1 + ); + println!( + "token_metadata: {} redb -> {} inserted", + summary.token_metadata.0, summary.token_metadata.1 + ); + println!( + "state: {} redb -> {} inserted", + summary.state.0, summary.state.1 + ); + println!( + "sweep_meta: {} redb -> {} inserted", + summary.sweep_meta.0, summary.sweep_meta.1 + ); + println!( + "sweep_failures: {} redb -> {} inserted", + summary.sweep_failures.0, summary.sweep_failures.1 + ); + if summary.orphan_address_mappings > 0 { + println!( + "orphan address_to_id mappings (no account row): {}", + summary.orphan_address_mappings + ); + } + println!("block cursors:"); + for (chain, block) in &summary.block_cursors { + println!(" last_block:{chain} = {block}"); + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 63c2db6..7e6c884 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,29 +1,153 @@ -use anyhow::Result; +use alloy::primitives::U256; +use anyhow::{bail, Context, Result}; use dotenvy::dotenv; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::env; +use std::fs; +use std::path::Path; +use std::str::FromStr; -#[derive(Clone, Debug)] -pub enum ProviderUrl { - Http(String), - Ws(String), +#[derive(Clone, Debug, Default)] +pub struct MinDepositSettings { + /// Per-token minimum deposit amounts keyed by lowercased token contract address. + pub per_token: HashMap, + /// Fallback minimum for ERC20 tokens not listed in `per_token`. + pub default: U256, + /// Minimum native (ETH/MATIC) deposit amount. + pub native: U256, } -#[derive(Clone, Debug)] -pub struct Config { - pub database_url: String, - pub provider_url: ProviderUrl, - pub mnemonic: String, +impl MinDepositSettings { + pub fn for_token(&self, token_addr: &str) -> U256 { + self.per_token + .get(&token_addr.to_lowercase()) + .copied() + .unwrap_or(self.default) + } +} + +/// Per-chain configuration loaded from `chains.toml`. +#[derive(Clone, Debug, Deserialize)] +pub struct ChainConfigRaw { + pub name: String, + pub chain_id: u64, + pub rpc_url: String, pub treasury_address: String, - pub port: u16, - pub poll_interval: u64, - pub faucet_mnemonic: String, + pub faucet_address: String, + #[serde(default = "default_existential_deposit")] pub existential_deposit: String, + #[serde(default)] + pub allowed_token_addresses: Vec, + #[serde(default)] + pub min_deposit_default: Option, + #[serde(default)] + pub min_deposit_native: Option, + /// Map of token address -> raw amount string + #[serde(default)] + pub min_deposits: HashMap, + #[serde(default = "default_block_offset")] + pub block_offset_from_head: u64, + #[serde(default = "default_poll_interval")] + pub poll_interval: u64, + #[serde(default = "default_get_logs_max_retries")] + pub get_logs_max_retries: u32, + #[serde(default = "default_get_logs_delay_ms")] + pub get_logs_delay_ms: u64, + /// Block span per ranged `eth_getLogs` call when the monitor is far behind head. + /// A soft performance hint, not a correctness knob: the monitor bisects any range + /// that a provider rejects as too large, regardless of this setting. + #[serde(default = "default_catch_up_chunk_size")] + pub catch_up_chunk_size: u64, + /// Max number of blocks fetched concurrently for native-transfer scanning while + /// draining a backlog. + #[serde(default = "default_block_fetch_concurrency")] + pub block_fetch_concurrency: u64, +} + +fn default_existential_deposit() -> String { + "10000000000000000".to_string() +} + +fn default_block_offset() -> u64 { + 20 +} + +fn default_poll_interval() -> u64 { + 10 +} + +fn default_get_logs_max_retries() -> u32 { + 30 +} + +fn default_get_logs_delay_ms() -> u64 { + 50 +} + +fn default_catch_up_chunk_size() -> u64 { + 500 +} + +fn default_block_fetch_concurrency() -> u64 { + 10 +} + +#[derive(Clone, Debug, Deserialize)] +struct ChainsFile { + chains: Vec, +} + +/// Validated per-chain runtime configuration. +#[derive(Clone, Debug)] +pub struct ChainConfig { + pub name: String, + pub chain_id: u64, + pub rpc_url: String, + pub treasury_address: String, pub faucet_address: String, + pub existential_deposit: String, pub block_offset_from_head: u64, + pub poll_interval: u64, pub get_logs_max_retries: u32, pub get_logs_delay_ms: u64, + pub catch_up_chunk_size: u64, + pub block_fetch_concurrency: u64, + pub min_deposits: MinDepositSettings, + /// Lowercased `0x`-prefixed ERC20 contract addresses permitted for detection and sweep. + pub allowed_token_addresses: HashSet, +} + +impl ChainConfig { + /// Returns true when the allowlist is empty (tests only) or contains the normalized address. + pub fn is_token_allowed(&self, token_address: &str) -> bool { + self.allowed_token_addresses.is_empty() + || self + .allowed_token_addresses + .contains(&normalize_token_address(token_address)) + } +} + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub mnemonic: String, + pub faucet_mnemonic: String, + pub port: u16, /// Optional JWT token for webhook authorization pub webhook_jwt_token: Option, + pub webhook_max_retries: u32, + pub webhook_retry_delay_ms: u64, + pub webhook_retry_poll_interval_secs: u64, + pub webhook_retry_batch_size: u32, + pub webhook_lease_seconds: u64, + /// Default chain name for the redbβ†’SQLite importer only (`LEGACY_CHAIN`, default: `polygon`). + pub legacy_chain: String, + /// Max concurrent SQLite read-pool connections (`DB_READ_POOL_SIZE`, default 20). + /// Shared by every chain's monitor/sweeper/webhook-retry loop plus inbound + /// registrations, so this should scale with the number of configured chains. + pub db_read_pool_size: u32, + pub chains: Vec, } impl Config { @@ -33,51 +157,326 @@ impl Config { let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:wallet.db".to_string()); - let provider_url = if let Ok(ws_url) = env::var("WS_URL") { - ProviderUrl::Ws(ws_url) - } else if let Ok(rpc_url) = env::var("RPC_URL") { - ProviderUrl::Http(rpc_url) - } else { - return Err(anyhow::anyhow!("Either RPC_URL or WS_URL must be set")); - }; - - let mnemonic = env::var("MNEMONIC").expect("MNEMONIC must be set"); - let treasury_address = env::var("TREASURY_ADDRESS").expect("TREASURY_ADDRESS must be set"); - let faucet_mnemonic = env::var("FAUCET_MNEMONIC").expect("FAUCET_MNEMONIC must be set"); - let faucet_address = env::var("FAUCET_ADDRESS").expect("FAUCET_ADDRESS must be set"); - let existential_deposit = - env::var("EXISTENTIAL_DEPOSIT").unwrap_or_else(|_| "10000000000000000".to_string()); // Default: 0.01 ETH + let mnemonic = env::var("MNEMONIC").context("MNEMONIC must be set")?; + let faucet_mnemonic = env::var("FAUCET_MNEMONIC").context("FAUCET_MNEMONIC must be set")?; let port = env::var("PORT") .unwrap_or_else(|_| "3000".to_string()) .parse()?; - let poll_interval = env::var("POLL_INTERVAL") - .unwrap_or_else(|_| "10".to_string()) - .parse()?; - let block_offset_from_head = env::var("BLOCK_OFFSET_FROM_HEAD") - .unwrap_or_else(|_| "20".to_string()) - .parse()?; - let get_logs_max_retries = env::var("GET_LOGS_MAX_RETRIES") - .unwrap_or_else(|_| "30".to_string()) - .parse()?; - let get_logs_delay_ms = env::var("GET_LOGS_DELAY_MS") - .unwrap_or_else(|_| "50".to_string()) - .parse()?; let webhook_jwt_token = env::var("WEBHOOK_JWT_TOKEN").ok(); + let webhook_max_retries = env_u32("WEBHOOK_MAX_RETRIES", 5); + let webhook_retry_delay_ms = env_u64("WEBHOOK_RETRY_DELAY_MS", 1000); + let webhook_retry_poll_interval_secs = env_u64("WEBHOOK_RETRY_POLL_INTERVAL", 30); + let webhook_retry_batch_size = env_u32("WEBHOOK_RETRY_BATCH_SIZE", 50); + let webhook_lease_seconds = env_u64("WEBHOOK_LEASE_SECONDS", 60); + let legacy_chain = env::var("LEGACY_CHAIN").unwrap_or_else(|_| "polygon".to_string()); + let db_read_pool_size = env_u32("DB_READ_POOL_SIZE", 20); + + let chains_config_path = + env::var("CHAINS_CONFIG").unwrap_or_else(|_| "chains.toml".to_string()); + let chains = load_chains_from_file(&chains_config_path)?; Ok(Self { database_url, - provider_url, mnemonic, - treasury_address, - port, - poll_interval, faucet_mnemonic, - existential_deposit, - faucet_address, - block_offset_from_head, - get_logs_max_retries, - get_logs_delay_ms, + port, webhook_jwt_token, + webhook_max_retries, + webhook_retry_delay_ms, + webhook_retry_poll_interval_secs, + webhook_retry_batch_size, + webhook_lease_seconds, + legacy_chain, + db_read_pool_size, + chains, }) } + + pub fn chain(&self, name: &str) -> Option<&ChainConfig> { + self.chains.iter().find(|c| c.name == name) + } + + /// Address derived from `FAUCET_MNEMONIC` at index 0 (same on all EVM chains). + pub fn derived_faucet_address(&self) -> Result { + use crate::wallet::Wallet; + Ok(Wallet::new(self.faucet_mnemonic.clone()) + .derive_address(0)? + .to_string()) + } +} + +pub fn load_chains_from_file(path: impl AsRef) -> Result> { + let content = fs::read_to_string(path.as_ref()) + .with_context(|| format!("Failed to read chains config: {:?}", path.as_ref()))?; + parse_chains_toml(&content) +} + +pub fn parse_chains_toml(content: &str) -> Result> { + let file: ChainsFile = + toml::from_str(content).context("Failed to parse chains.toml as TOML")?; + + if file.chains.is_empty() { + bail!("chains.toml must define at least one [[chains]] entry"); + } + + let mut names = HashSet::new(); + let mut chains = Vec::with_capacity(file.chains.len()); + + for raw in file.chains { + validate_chain_name(&raw.name)?; + + if !names.insert(raw.name.clone()) { + bail!("Duplicate chain name in chains.toml: {}", raw.name); + } + + if raw.allowed_token_addresses.is_empty() { + bail!( + "Chain '{}' must define at least one allowed_token_addresses entry", + raw.name + ); + } + + let allowed_token_addresses: HashSet = raw + .allowed_token_addresses + .iter() + .map(|a| normalize_token_address(a)) + .collect(); + + let min_deposit_default = parse_u256_str( + raw.min_deposit_default.as_deref().unwrap_or("0"), + &format!("chains.{}.min_deposit_default", raw.name), + )?; + let min_deposit_native = parse_u256_str( + raw.min_deposit_native.as_deref().unwrap_or("0"), + &format!("chains.{}.min_deposit_native", raw.name), + )?; + + let mut per_token = HashMap::new(); + for (address, amount_str) in &raw.min_deposits { + let amount = parse_u256_str( + amount_str, + &format!("chains.{}.min_deposits[{}]", raw.name, address), + )?; + per_token.insert(normalize_token_address(address), amount); + } + + chains.push(ChainConfig { + name: raw.name, + chain_id: raw.chain_id, + rpc_url: raw.rpc_url, + treasury_address: raw.treasury_address, + faucet_address: raw.faucet_address, + existential_deposit: raw.existential_deposit, + block_offset_from_head: raw.block_offset_from_head, + poll_interval: raw.poll_interval, + get_logs_max_retries: raw.get_logs_max_retries, + get_logs_delay_ms: raw.get_logs_delay_ms, + catch_up_chunk_size: raw.catch_up_chunk_size, + block_fetch_concurrency: raw.block_fetch_concurrency, + min_deposits: MinDepositSettings { + per_token, + default: min_deposit_default, + native: min_deposit_native, + }, + allowed_token_addresses, + }); + } + + Ok(chains) +} + +fn validate_chain_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Chain name must not be empty"); + } + if name.contains(':') { + bail!("Chain name '{}' must not contain ':'", name); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + bail!( + "Chain name '{}' must match [a-z0-9_-]+ (lowercase alphanumeric, underscore, hyphen)", + name + ); + } + Ok(()) +} + +fn parse_u256_str(value: &str, field: &str) -> Result { + U256::from_str(value.trim()).with_context(|| format!("Invalid {field} value: {value}")) +} + +fn env_u64(name: &str, default: u64) -> u64 { + env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn env_u32(name: &str, default: u32) -> u32 { + env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// Parse `MIN_DEPOSITS` as comma-separated `address=rawamount` pairs. +pub fn parse_min_deposits_env(raw: String) -> Result> { + let mut map = HashMap::new(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(map); + } + + for segment in trimmed.split(',') { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + + let Some((address, amount_str)) = segment.split_once('=') else { + continue; + }; + + let address = address.trim().to_lowercase(); + if address.is_empty() { + continue; + } + + let amount = U256::from_str(amount_str.trim()) + .with_context(|| format!("Invalid MIN_DEPOSITS amount for {address}: {amount_str}"))?; + map.insert(address, amount); + } + + Ok(map) +} + +/// Normalize an EVM address to lowercase with a `0x` prefix. +pub fn normalize_token_address(address: &str) -> String { + let trimmed = address.trim().to_lowercase(); + if trimmed.is_empty() { + return trimmed; + } + if trimmed.starts_with("0x") { + trimmed + } else { + format!("0x{trimmed}") + } +} + +/// Parse `ALLOWED_TOKEN_ADDRESSES` as a comma-separated list of contract addresses. +pub fn parse_allowed_token_addresses_env(raw: String) -> Result> { + let mut set = HashSet::new(); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(set); + } + + for segment in trimmed.split(',') { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + set.insert(normalize_token_address(segment)); + } + + Ok(set) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_TOML: &str = r#" +[[chains]] +name = "base" +chain_id = 8453 +rpc_url = "https://base.example.com" +treasury_address = "0x1111111111111111111111111111111111111111" +faucet_address = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" +allowed_token_addresses = ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"] + +[[chains]] +name = "polygon" +chain_id = 137 +rpc_url = "https://polygon.example.com" +treasury_address = "0x2222222222222222222222222222222222222222" +faucet_address = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" +allowed_token_addresses = ["0xc2132d05d31c914a87c6611c10748aeb04b58e8f"] +min_deposit_native = "1000" +[chains.min_deposits] +"0xc2132d05d31c914a87c6611c10748aeb04b58e8f" = "10000" +"#; + + #[test] + fn test_parse_valid_chains_toml() { + let chains = parse_chains_toml(SAMPLE_TOML).unwrap(); + assert_eq!(chains.len(), 2); + assert_eq!(chains[0].name, "base"); + assert_eq!(chains[0].chain_id, 8453); + assert_eq!(chains[1].name, "polygon"); + assert_eq!(chains[1].min_deposits.native, U256::from(1000u64)); + assert!(chains[1].is_token_allowed("0xc2132d05d31c914a87c6611c10748aeb04b58e8f")); + } + + #[test] + fn test_reject_empty_chains() { + let err = parse_chains_toml("chains = []").unwrap_err(); + assert!(err.to_string().contains("at least one")); + } + + #[test] + fn test_reject_duplicate_chain_names() { + let toml = r#" +[[chains]] +name = "base" +chain_id = 1 +rpc_url = "http://localhost" +treasury_address = "0x1" +faucet_address = "0x2" +allowed_token_addresses = ["0xabc"] + +[[chains]] +name = "base" +chain_id = 2 +rpc_url = "http://localhost" +treasury_address = "0x1" +faucet_address = "0x2" +allowed_token_addresses = ["0xabc"] +"#; + let err = parse_chains_toml(toml).unwrap_err(); + assert!(err.to_string().contains("Duplicate")); + } + + #[test] + fn test_reject_chain_without_tokens() { + let toml = r#" +[[chains]] +name = "base" +chain_id = 1 +rpc_url = "http://localhost" +treasury_address = "0x1" +faucet_address = "0x2" +allowed_token_addresses = [] +"#; + let err = parse_chains_toml(toml).unwrap_err(); + assert!(err.to_string().contains("allowed_token_addresses")); + } + + #[test] + fn test_reject_invalid_chain_name() { + let toml = r#" +[[chains]] +name = "Base:main" +chain_id = 1 +rpc_url = "http://localhost" +treasury_address = "0x1" +faucet_address = "0x2" +allowed_token_addresses = ["0xabc"] +"#; + let err = parse_chains_toml(toml).unwrap_err(); + assert!(err.to_string().contains(':')); + } } diff --git a/src/db.rs b/src/db.rs index 34ecaed..3f659d9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,17 +1,45 @@ -use anyhow::Result; -use redb::{Database, ReadableTable, TableDefinition}; -use std::sync::Arc; - -const ACCOUNTS: TableDefinition<&str, (u32, &str, &str)> = TableDefinition::new("accounts"); // account_id -> (index, address, webhook_url) -const ADDRESS_TO_ID: TableDefinition<&str, &str> = TableDefinition::new("address_to_id"); -const DEPOSITS: TableDefinition<&str, (&str, &str, &str)> = TableDefinition::new("deposits"); // tx_hash -> (account_id, amount, status) -const STATE: TableDefinition<&str, &str> = TableDefinition::new("state"); -const TOKEN_METADATA: TableDefinition<&str, (&str, u64, &str)> = - TableDefinition::new("token_metadata"); // token_address -> (symbol, decimals, name) -const ERC20_DEPOSITS: TableDefinition<&str, (&str, &str, &str, &str, &str)> = - TableDefinition::new("erc20_deposits"); // tx_hash:log_index -> (account_id, amount, token_address, token_symbol, status) -const SWEEP_META: TableDefinition<&str, (&str, u64)> = TableDefinition::new("sweep_meta"); // deposit_key -> (sweep_tx_hash, zero_balance_retry_count) -const SWEEP_FAILURES: TableDefinition<&str, u64> = TableDefinition::new("sweep_failures"); // deposit_key -> consecutive_failure_count +use anyhow::{anyhow, Result}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite_migration::{Migrations, M}; +use std::any::Any; +use std::collections::VecDeque; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{sync_channel, RecvTimeoutError, SyncSender}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DepositQueueCounts { + pub native_detected: u64, + pub native_failed: u64, + pub erc20_detected: u64, + pub erc20_failed: u64, +} + +impl DepositQueueCounts { + pub fn has_pending(&self) -> bool { + self.native_detected > 0 + || self.native_failed > 0 + || self.erc20_detected > 0 + || self.erc20_failed > 0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebhookDeliveryRecord { + pub id: String, + pub event: String, + pub registration_id: String, + pub webhook_url: String, + pub payload: String, + pub status: String, + pub attempt_count: u64, + pub last_http_status: Option, + pub last_error: Option, +} #[derive(Clone, Debug)] pub struct Erc20Deposit { @@ -22,46 +50,808 @@ pub struct Erc20Deposit { pub token_symbol: String, } +/// Typed errors for the write-queue fast paths, so HTTP layers can map +/// queue-full / timeout conditions to 503s instead of generic 500s. +#[derive(Debug, thiserror::Error)] +pub enum WriteQueueError { + /// The interactive write lane is at capacity; the caller should fail fast + /// (HTTP 503) and let the client retry. + #[error("write queue full: interactive lane at capacity")] + QueueFull, + /// The write was enqueued but no result arrived within the configured + /// timeout. The command may still execute later (at-least-once); all + /// interactive writes are idempotent, so a retry is safe. + #[error("write timed out after {0:?} (command may still execute)")] + Timeout(Duration), + /// The dedicated writer thread is gone. In production this precedes a + /// process abort; only reads can still be served. + #[error("database writer is not running")] + WriterGone, +} + +/// Result payload flowing back from the writer thread. Type-erased because a +/// single command channel carries closures with heterogeneous return types. +type WriteResult = Result>; + +/// A write closure must be `Fn` (not `FnOnce`): when a batched transaction +/// fails, the writer rolls the batch back and re-executes each command +/// individually, so a command may run more than once. +type WriteFn = Box WriteResult + Send>; + +struct WriteCommand { + run: WriteFn, + reply: SyncSender, + enqueued_at: Instant, +} + +/// Tuning for the single-writer actor. All knobs are env-overridable with +/// safe defaults, so no deployment config change is required. +#[derive(Clone, Debug)] +pub struct WriterConfig { + /// Max time an interactive caller waits for its write result + /// (`EVM_WRITE_TIMEOUT_MS`, default 5000). + pub write_timeout: Duration, + /// Interactive lane capacity (`EVM_INTERACTIVE_QUEUE_CAPACITY`, default 64). + pub interactive_capacity: usize, + /// Background lane capacity (`EVM_BACKGROUND_QUEUE_CAPACITY`, default 2048). + pub background_capacity: usize, + /// Max background commands grouped into one transaction + /// (`EVM_BACKGROUND_BATCH_SIZE`, default 50). + pub batch_size: usize, + /// Minimum time between opportunistic runtime `wal_checkpoint(PASSIVE)` + /// attempts (`EVM_CHECKPOINT_INTERVAL_SECS`, default 30). Keeps the WAL + /// from growing unbounded under sustained load without adding a + /// checkpoint after every single background batch. + pub checkpoint_interval: Duration, + /// Abort the process if the writer thread panics (always true in + /// production; disabled only by writer-death unit tests). + pub abort_on_panic: bool, +} + +impl Default for WriterConfig { + fn default() -> Self { + Self { + write_timeout: Duration::from_millis(5000), + interactive_capacity: 64, + background_capacity: 2048, + batch_size: 50, + checkpoint_interval: Duration::from_secs(30), + abort_on_panic: true, + } + } +} + +impl WriterConfig { + pub fn from_env() -> Self { + fn env_parse(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) + } + let d = Self::default(); + Self { + write_timeout: Duration::from_millis(env_parse( + "EVM_WRITE_TIMEOUT_MS", + d.write_timeout.as_millis() as u64, + )), + interactive_capacity: env_parse( + "EVM_INTERACTIVE_QUEUE_CAPACITY", + d.interactive_capacity, + ), + background_capacity: env_parse("EVM_BACKGROUND_QUEUE_CAPACITY", d.background_capacity), + batch_size: env_parse("EVM_BACKGROUND_BATCH_SIZE", d.batch_size).max(1), + checkpoint_interval: Duration::from_secs(env_parse( + "EVM_CHECKPOINT_INTERVAL_SECS", + d.checkpoint_interval.as_secs(), + )), + abort_on_panic: true, + } + } +} + +/// Warn when an interactive write sat in the queue longer than this before +/// the writer picked it up. +const INTERACTIVE_WAIT_WARN: Duration = Duration::from_millis(500); + +enum Lane { + Interactive, + Background, +} + +struct LaneQueues { + interactive: VecDeque, + background: VecDeque, + /// False once the writer thread is gone (or is being asked to exit). + writer_alive: bool, + /// True only for the graceful path where the last `Db` clone was dropped; + /// distinguishes teardown from an unexpected writer death. + shutting_down: bool, +} + +/// The two-lane work queue feeding the single writer thread. +/// +/// Hand-rolled on `Mutex`+`Condvar` rather than channels because the writer +/// must (a) wait on both lanes at once with strict interactive priority, and +/// (b) peek the interactive lane cheaply between batched background commands. +/// Neither std nor tokio mpsc channels can express that without polling. +struct WriteQueue { + lanes: Mutex, + /// Signaled when work arrives (or on shutdown); waited on by the writer. + work_available: Condvar, + /// Signaled when background capacity frees up (or on shutdown); waited on + /// by blocked background producers. + space_available: Condvar, + interactive_capacity: usize, + background_capacity: usize, +} + +impl WriteQueue { + fn new(interactive_capacity: usize, background_capacity: usize) -> Self { + Self { + lanes: Mutex::new(LaneQueues { + interactive: VecDeque::new(), + background: VecDeque::new(), + writer_alive: true, + shutting_down: false, + }), + work_available: Condvar::new(), + space_available: Condvar::new(), + interactive_capacity, + background_capacity, + } + } + + fn lock(&self) -> MutexGuard<'_, LaneQueues> { + self.lanes.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn try_push_interactive(&self, cmd: WriteCommand) -> std::result::Result<(), WriteQueueError> { + let mut lanes = self.lock(); + if !lanes.writer_alive { + return Err(WriteQueueError::WriterGone); + } + if lanes.interactive.len() >= self.interactive_capacity { + return Err(WriteQueueError::QueueFull); + } + lanes.interactive.push_back(cmd); + self.work_available.notify_one(); + Ok(()) + } + + /// Blocking push: waits for capacity instead of dropping. Correctness + /// requirement for the monitor β€” if a `record_deposit` were dropped but + /// the chunk's `set_last_processed_block` later succeeded, the cursor + /// would advance past an unrecorded deposit and lose it permanently. + fn push_background(&self, cmd: WriteCommand) -> std::result::Result<(), WriteQueueError> { + let mut lanes = self.lock(); + while lanes.writer_alive && lanes.background.len() >= self.background_capacity { + lanes = self + .space_available + .wait(lanes) + .unwrap_or_else(PoisonError::into_inner); + } + if !lanes.writer_alive { + return Err(WriteQueueError::WriterGone); + } + lanes.background.push_back(cmd); + self.work_available.notify_one(); + Ok(()) + } + + /// Writer side: blocks until work arrives. Returns `None` when the queue + /// has been shut down / marked dead, which is the writer's exit signal. + fn pop_blocking(&self) -> Option<(WriteCommand, Lane)> { + let mut lanes = self.lock(); + loop { + if !lanes.writer_alive { + return None; + } + if let Some(cmd) = lanes.interactive.pop_front() { + return Some((cmd, Lane::Interactive)); + } + if let Some(cmd) = lanes.background.pop_front() { + self.space_available.notify_one(); + return Some((cmd, Lane::Background)); + } + lanes = self + .work_available + .wait(lanes) + .unwrap_or_else(PoisonError::into_inner); + } + } + + fn try_pop_background(&self) -> Option { + let mut lanes = self.lock(); + let cmd = lanes.background.pop_front(); + if cmd.is_some() { + self.space_available.notify_one(); + } + cmd + } + + fn has_interactive(&self) -> bool { + !self.lock().interactive.is_empty() + } + + fn depths(&self) -> (usize, usize) { + let lanes = self.lock(); + (lanes.interactive.len(), lanes.background.len()) + } + + /// Graceful teardown (last `Db` clone dropped): the writer exits its loop + /// and the death handler treats it as expected (no abort). + fn shutdown(&self) { + let mut lanes = self.lock(); + lanes.writer_alive = false; + lanes.shutting_down = true; + self.work_available.notify_all(); + self.space_available.notify_all(); + } + + /// Unexpected-death signal (also used by tests to simulate writer death + /// without a panic). Producers are woken with `WriterGone`. + fn mark_writer_dead(&self) { + let mut lanes = self.lock(); + lanes.writer_alive = false; + self.work_available.notify_all(); + self.space_available.notify_all(); + } + + fn is_shutting_down(&self) -> bool { + self.lock().shutting_down + } +} + +/// Marks the queue shut down when the last `Db` clone is dropped, so writer +/// threads (and their open connections) don't leak β€” mainly relevant for +/// tests; the production `Db` lives for the whole process. +struct WriterShutdown { + queue: Arc, +} + +impl Drop for WriterShutdown { + fn drop(&mut self) { + self.queue.shutdown(); + } +} + +/// Cloneable handle to the dedicated writer thread. +/// +/// Design note: this is a hand-rolled single-writer actor. `tokio-rusqlite` +/// and `rusqlite-isle` implement the base "one thread owns the Connection" +/// pattern, but neither provides the priority lanes or transaction batching +/// that are the substance of this change, so we own the implementation +/// instead of wrapping a crate and re-implementing the interesting parts. +#[derive(Clone)] +struct WriterHandle { + queue: Arc, + /// Flipped false when the writer thread dies; surfaced through + /// `Db::writer_healthy` and the service health endpoint. + healthy: Arc, + write_timeout: Duration, + _shutdown: Arc, +} + #[derive(Clone)] pub struct Db { - db: Arc, + writer: WriterHandle, + read: Pool, +} + +/// Strip `sqlite:` scheme; rusqlite expects a filesystem path. +pub fn normalize_db_path(database_url: &str) -> &str { + database_url.strip_prefix("sqlite:").unwrap_or(database_url) +} + +pub fn migrations() -> Migrations<'static> { + Migrations::new(vec![ + M::up(include_str!("../migrations/V1__initial.sql")), + M::up(include_str!("../migrations/V2__webhook_deliveries.sql")), + M::up(include_str!("../migrations/V3__next_index_counter.sql")), + ]) +} + +fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +fn apply_pragmas(conn: &Connection) -> rusqlite::Result<()> { + // synchronous=NORMAL: in WAL mode this skips the per-commit fsync (only + // checkpoints sync). On EFS every fsync is a network round-trip, so this + // is the single biggest write-latency lever. Tradeoff: on power loss / + // hard crash the last few commits may roll back, but the DB stays + // consistent and all affected data is recoverable (registers are retried + // by callers, deposits re-detected from the last block cursor). + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA busy_timeout=5000; + PRAGMA synchronous=NORMAL;", + )?; + Ok(()) +} + +pub fn apply_pragmas_for_import(conn: &Connection) -> Result<()> { + apply_pragmas(conn).map_err(Into::into) +} + +/// Result columns of `PRAGMA wal_checkpoint(..)`: `busy` is non-zero if the +/// checkpoint could not fully complete because of a concurrent reader/writer, +/// `log_frames` is the WAL size in frames at the time of the call, and +/// `checkpointed_frames` is how many of those were moved into the main +/// database file (for `TRUNCATE`, a fully successful checkpoint truncates the +/// WAL to zero afterward; for `PASSIVE`, only what could be moved without +/// blocking is). +struct WalCheckpointResult { + busy: i64, + log_frames: i64, + checkpointed_frames: i64, +} + +fn run_wal_checkpoint(conn: &Connection, mode: &str) -> rusqlite::Result { + conn.query_row(&format!("PRAGMA wal_checkpoint({mode})"), [], |row| { + Ok(WalCheckpointResult { + busy: row.get(0)?, + log_frames: row.get(1)?, + checkpointed_frames: row.get(2)?, + }) + }) +} + +/// Size in bytes of the `-wal` sidecar file, if present. `None` once the WAL +/// has been fully checkpointed away (SQLite may delete or zero it) or if the +/// path can't be stat'd for any other reason β€” purely informational logging, +/// never treated as an error. +fn wal_file_size_bytes(db_path: &str) -> Option { + std::fs::metadata(format!("{db_path}-wal")) + .ok() + .map(|m| m.len()) +} + +/// Forces a full checkpoint before the writer starts serving traffic, so +/// every restart begins from a small WAL regardless of how large it grew in +/// the previous run. Without this, restarting alone does not help: SQLite +/// simply reopens the same oversized `-wal` file and resumes fighting +/// auto-checkpoint attempts against it on every commit (the root cause of the +/// 2026-07 write-queue-saturation incident β€” see docs/fix-p0-* history). +fn checkpoint_startup(conn: &Connection, db_path: &str) -> Result<()> { + let before_bytes = wal_file_size_bytes(db_path); + let result = run_wal_checkpoint(conn, "TRUNCATE")?; + let after_bytes = wal_file_size_bytes(db_path); + tracing::info!( + wal_bytes_before = ?before_bytes, + wal_bytes_after = ?after_bytes, + busy = result.busy, + log_frames = result.log_frames, + checkpointed_frames = result.checkpointed_frames, + "startup WAL checkpoint complete" + ); + if result.busy != 0 { + tracing::warn!( + log_frames = result.log_frames, + checkpointed_frames = result.checkpointed_frames, + "startup WAL checkpoint did not fully complete (busy); WAL may still be large" + ); + } + Ok(()) +} + +/// Opportunistic, non-blocking runtime checkpoint: called from the writer +/// thread after a background batch commits. Only runs when nothing +/// interactive is waiting and at least `interval` has passed since the last +/// attempt, so it never competes with request latency and never runs on +/// every single batch. `PASSIVE` mode never blocks concurrent readers or +/// writers, so it's safe to call from the single writer thread with no extra +/// locking. +/// +/// Returns the checkpoint outcome when an attempt was actually made (mainly +/// so tests can assert on it deterministically); production callers ignore +/// it. Note `PASSIVE` never truncates the physical `-wal` file β€” unlike +/// `TRUNCATE`, its "before/after WAL bytes" log fields are expected to be +/// equal even on a fully successful checkpoint; `log_frames`/ +/// `checkpointed_frames` are the meaningful signal here instead. +fn maybe_checkpoint( + conn: &Connection, + queue: &WriteQueue, + db_path: &str, + interval: Duration, + last_checkpoint: &mut Instant, +) -> Option { + if queue.has_interactive() || last_checkpoint.elapsed() < interval { + return None; + } + *last_checkpoint = Instant::now(); + + let before_bytes = wal_file_size_bytes(db_path); + match run_wal_checkpoint(conn, "PASSIVE") { + Ok(result) => { + let after_bytes = wal_file_size_bytes(db_path); + tracing::info!( + wal_bytes_before = ?before_bytes, + wal_bytes_after = ?after_bytes, + busy = result.busy, + log_frames = result.log_frames, + checkpointed_frames = result.checkpointed_frames, + "opportunistic runtime WAL checkpoint" + ); + Some(result) + } + Err(err) => { + tracing::warn!( + error = %err, + "opportunistic runtime WAL checkpoint failed" + ); + None + } + } +} + +/// Parse `"0xtx:42"` -> (`0xtx`, 42). Bare `"0xtx"` -> (`0xtx`, 0). +fn parse_local_key(local_key: &str) -> Result<(String, i64)> { + if let Some((tx, idx)) = local_key.rsplit_once(':') { + if !idx.is_empty() && idx.chars().all(|c| c.is_ascii_digit()) { + return Ok((tx.to_string(), idx.parse()?)); + } + } + Ok((local_key.to_string(), 0)) +} + +fn last_block_key(chain: &str) -> String { + format!("last_block:{chain}") +} + +/// r2d2's own default when `.max_size(..)` is not set on the pool builder. +/// Used by `Db::new` so existing callers (in particular the ~50 test call +/// sites that construct a `Db` directly) keep their current behavior. +const DEFAULT_READ_POOL_MAX_SIZE: u32 = 10; + +/// Spawns the dedicated writer thread that exclusively owns the write +/// `Connection`. Returns the cloneable handle used by `Db`. +fn spawn_writer(conn: Connection, db_path: String, cfg: WriterConfig) -> WriterHandle { + let queue = Arc::new(WriteQueue::new( + cfg.interactive_capacity, + cfg.background_capacity, + )); + let healthy = Arc::new(AtomicBool::new(true)); + + let thread_queue = Arc::clone(&queue); + let thread_healthy = Arc::clone(&healthy); + let batch_size = cfg.batch_size; + let checkpoint_interval = cfg.checkpoint_interval; + let abort_on_panic = cfg.abort_on_panic; + + std::thread::Builder::new() + .name("evmhot-sqlite-writer".to_string()) + .spawn(move || { + let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| { + writer_loop( + &conn, + &thread_queue, + batch_size, + &db_path, + checkpoint_interval, + ); + })); + let graceful = thread_queue.is_shutting_down() && outcome.is_ok(); + thread_healthy.store(false, Ordering::SeqCst); + thread_queue.mark_writer_dead(); + if !graceful { + // A half-alive process that serves reads but can never write + // again is worse than a restart (silent-zombie failure mode). + // Aborting lets ECS restart the single task cleanly. + tracing::error!( + panicked = outcome.is_err(), + "SQLite writer thread died unexpectedly; database writes are impossible" + ); + if abort_on_panic { + std::process::abort(); + } + } + }) + .expect("failed to spawn SQLite writer thread"); + + WriterHandle { + queue: Arc::clone(&queue), + healthy, + write_timeout: cfg.write_timeout, + _shutdown: Arc::new(WriterShutdown { queue }), + } +} + +/// Core loop of the writer thread. Interactive commands always jump ahead; +/// background commands are grouped into batched transactions (fewer commits +/// means fewer WAL appends, which matters on EFS where each fsync is a +/// network round-trip). +fn writer_loop( + conn: &Connection, + queue: &WriteQueue, + batch_size: usize, + db_path: &str, + checkpoint_interval: Duration, +) { + // Owned by this thread alone (the writer), so no locking is needed even + // though it's mutated on every background batch. + let mut last_checkpoint = Instant::now(); + while let Some((cmd, lane)) = queue.pop_blocking() { + match lane { + Lane::Interactive => execute_interactive(conn, queue, cmd), + Lane::Background => run_background_batch( + conn, + queue, + cmd, + batch_size, + db_path, + checkpoint_interval, + &mut last_checkpoint, + ), + } + } +} + +fn execute_interactive(conn: &Connection, queue: &WriteQueue, cmd: WriteCommand) { + let waited = cmd.enqueued_at.elapsed(); + if waited >= INTERACTIVE_WAIT_WARN { + let (interactive_depth, background_depth) = queue.depths(); + tracing::warn!( + waited_ms = waited.as_millis() as u64, + interactive_depth, + background_depth, + "interactive write waited unusually long in the queue" + ); + } + let result = (cmd.run)(conn); + // The receiver may be gone if the caller timed out; the write still + // executed (at-least-once semantics β€” interactive writes are idempotent). + let _ = cmd.reply.send(result); +} + +/// Executes up to `batch_size` background commands inside one +/// `BEGIN IMMEDIATE; ...; COMMIT;`. Replies are buffered and only delivered +/// after a successful commit, so a caller never sees Ok for a write that was +/// later rolled back. If anything in the batch fails, the whole batch rolls +/// back and every command is re-executed individually so only the truly +/// failing command returns an error β€” preserving per-command semantics. +/// Between commands the interactive lane is peeked; if something is waiting, +/// the batch commits early so interactive latency stays bounded at roughly +/// one command plus one commit. +fn run_background_batch( + conn: &Connection, + queue: &WriteQueue, + first: WriteCommand, + batch_size: usize, + db_path: &str, + checkpoint_interval: Duration, + last_checkpoint: &mut Instant, +) { + if conn.execute_batch("BEGIN IMMEDIATE").is_err() { + // busy_timeout exhausted or similar: fall back to executing this one + // command outside an explicit transaction. + let result = (first.run)(conn); + let _ = first.reply.send(result); + return; + } + + let mut executed: Vec<(WriteCommand, WriteResult)> = Vec::new(); + let mut failed = false; + let mut next = Some(first); + + loop { + let Some(cmd) = next.take() else { break }; + let result = (cmd.run)(conn); + let is_err = result.is_err(); + executed.push((cmd, result)); + if is_err { + failed = true; + break; + } + if executed.len() >= batch_size || queue.has_interactive() { + break; + } + next = queue.try_pop_background(); + } + + if failed { + let _ = conn.execute_batch("ROLLBACK"); + reexecute_individually(conn, executed); + return; + } + + match conn.execute_batch("COMMIT") { + Ok(()) => { + for (cmd, result) in executed { + let _ = cmd.reply.send(result); + } + maybe_checkpoint(conn, queue, db_path, checkpoint_interval, last_checkpoint); + } + Err(_) => { + let _ = conn.execute_batch("ROLLBACK"); + reexecute_individually(conn, executed); + } + } +} + +/// Poison-batch fallback: after a rollback, run each command on its own +/// (implicit per-statement transactions) so only the genuinely failing +/// command reports an error. Commands are `Fn`, not `FnOnce`, precisely so +/// this re-execution is possible. +fn reexecute_individually(conn: &Connection, batch: Vec<(WriteCommand, WriteResult)>) { + for (cmd, _) in batch { + let result = (cmd.run)(conn); + let _ = cmd.reply.send(result); + } +} + +fn downcast_result(boxed: Box) -> Result { + boxed + .downcast::() + .map(|b| *b) + .map_err(|_| anyhow!("writer returned an unexpected result type")) } impl Db { - pub fn new(path: &str) -> Result { - let db = Database::create(path)?; + pub fn new(database_url: &str) -> Result { + Self::with_pool_size(database_url, DEFAULT_READ_POOL_MAX_SIZE) + } - // Initialize tables - let write_txn = db.begin_write()?; - { - let _ = write_txn.open_table(ACCOUNTS)?; - let _ = write_txn.open_table(ADDRESS_TO_ID)?; - let _ = write_txn.open_table(DEPOSITS)?; - let _ = write_txn.open_table(STATE)?; - let _ = write_txn.open_table(TOKEN_METADATA)?; - let _ = write_txn.open_table(ERC20_DEPOSITS)?; - let _ = write_txn.open_table(SWEEP_META)?; - let _ = write_txn.open_table(SWEEP_FAILURES)?; + /// Same as `Db::new`, but with an explicit read-pool size instead of + /// r2d2's default of 10. Production wiring (`HotWalletService::new`) + /// uses this so the pool size is configurable via `Config::db_read_pool_size` + /// (env `DB_READ_POOL_SIZE`) instead of being a silent hardcoded default. + /// + /// That default matters because every chain's monitor, sweeper, and + /// webhook-retry loop, plus inbound `/evm/register` calls, all share this + /// one pool. With multiple chains configured, those background loops + /// alone can exceed a small fixed pool under a catch-up backlog or a + /// flaky RPC provider, producing sustained `"timed out waiting for + /// connection"` errors even with the 5s fail-fast timeout below. + pub fn with_pool_size(database_url: &str, max_size: u32) -> Result { + Self::with_options(database_url, max_size, WriterConfig::from_env()) + } + + /// Full-control constructor; production goes through `with_pool_size` + /// (env-derived `WriterConfig`), tests use this to shrink queues/timeouts. + pub fn with_options( + database_url: &str, + max_size: u32, + writer_config: WriterConfig, + ) -> Result { + let path = normalize_db_path(database_url); + let mut write_conn = Connection::open(path)?; + apply_pragmas(&write_conn)?; + migrations().to_latest(&mut write_conn)?; + checkpoint_startup(&write_conn, path)?; + + let manager = SqliteConnectionManager::file(path).with_init(|c| apply_pragmas(&*c)); + // Explicit, short connection_timeout: r2d2's default is 30s, which + // means a read-pool contention spike (e.g. every connection busy + // during a catch-up backlog) would block whichever thread called + // `read.get()` for up to 30s. Callers on the async paths route + // through `Db::blocking`, so that block lands on the blocking pool + // rather than a Tokio worker thread, but failing fast is still + // preferable to a long silent stall either way. + let read_pool = Pool::builder() + .max_size(max_size) + .connection_timeout(Duration::from_secs(5)) + .build(manager)?; + + Ok(Self { + writer: spawn_writer(write_conn, path.to_string(), writer_config), + read: read_pool, + }) + } + + /// False once the dedicated writer thread has died. Reads still work, + /// but every write will fail with `WriteQueueError::WriterGone`; in + /// production the process aborts shortly after this flips. + pub fn writer_healthy(&self) -> bool { + self.writer.healthy.load(Ordering::SeqCst) + } + + /// Runs a `Db` operation on Tokio's blocking thread pool. + /// + /// Every `Db` method is a synchronous call: the write side blocks on the + /// writer actor's queue (bounded by the interactive timeout or background + /// backpressure), and the read side blocks on `r2d2::Pool::get()`. + /// Calling them directly from an async fn risks stalling whichever Tokio + /// worker thread happens to run the call, which can starve everything + /// else on that runtime (see the monitor/sweeper/webhook callers). `Db` + /// is a cheap `Clone`, so this just moves a clone onto `spawn_blocking`. + pub async fn blocking(&self, f: F) -> Result + where + F: FnOnce(&Db) -> Result + Send + 'static, + T: Send + 'static, + { + let db = self.clone(); + tokio::task::spawn_blocking(move || f(&db)) + .await + .map_err(|e| anyhow!("Db blocking task panicked or was cancelled: {e}"))? + } + + /// Background-lane write: blocks (backpressure) when the lane is full + /// rather than dropping β€” dropping a monitor write could advance the + /// block cursor past an unrecorded deposit and lose it permanently. + /// Waits without a timeout for the result; the writer always executes + /// every dequeued command. + fn with_write(&self, f: F) -> Result + where + F: Fn(&Connection) -> Result + Send + 'static, + T: Send + 'static, + { + let (reply_tx, reply_rx) = sync_channel::(1); + self.writer + .queue + .push_background(WriteCommand { + run: Box::new(move |conn| f(conn).map(|v| Box::new(v) as Box)), + reply: reply_tx, + enqueued_at: Instant::now(), + }) + .map_err(anyhow::Error::from)?; + match reply_rx.recv() { + Ok(result) => downcast_result(result?), + Err(_) => Err(WriteQueueError::WriterGone.into()), } - write_txn.commit()?; + } - Ok(Self { db: Arc::new(db) }) + /// Interactive-lane write: fails fast with `WriteQueueError::QueueFull` + /// when the lane is at capacity (the HTTP layer maps this to a 503), and + /// gives up waiting after the configured write timeout. The command may + /// still execute after a timeout (at-least-once); every interactive write + /// is idempotent, so the caller's retry is safe. + fn with_write_priority(&self, f: F) -> Result + where + F: Fn(&Connection) -> Result + Send + 'static, + T: Send + 'static, + { + let (reply_tx, reply_rx) = sync_channel::(1); + self.writer + .queue + .try_push_interactive(WriteCommand { + run: Box::new(move |conn| f(conn).map(|v| Box::new(v) as Box)), + reply: reply_tx, + enqueued_at: Instant::now(), + }) + .map_err(|e| { + if matches!(e, WriteQueueError::QueueFull) { + let (interactive_depth, background_depth) = self.writer.queue.depths(); + tracing::warn!( + interactive_depth, + background_depth, + "interactive write queue full; failing fast" + ); + } + anyhow::Error::from(e) + })?; + match reply_rx.recv_timeout(self.writer.write_timeout) { + Ok(result) => downcast_result(result?), + Err(RecvTimeoutError::Timeout) => { + let (interactive_depth, background_depth) = self.writer.queue.depths(); + tracing::warn!( + timeout_ms = self.writer.write_timeout.as_millis() as u64, + interactive_depth, + background_depth, + "interactive write timed out waiting for the writer (command may still execute)" + ); + Err(WriteQueueError::Timeout(self.writer.write_timeout).into()) + } + Err(RecvTimeoutError::Disconnected) => Err(WriteQueueError::WriterGone.into()), + } } #[allow(dead_code)] pub fn get_next_derivation_index(&self) -> Result { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ACCOUNTS)?; - // This is inefficient O(N) but fine for MVP. - // Better: Store a counter in STATE table. - let last = table.iter()?.next_back(); - - match last { - Some(Ok((_, v))) => Ok(v.value().0 + 1), - _ => Ok(0), - } + let conn = self.read.get()?; + let idx: u32 = conn.query_row( + "SELECT COALESCE(MAX(derivation_index) + 1, 0) FROM accounts", + [], + |row| row.get(0), + )?; + Ok(idx) } + /// Interactive lane: triggered directly by `POST /register`. pub fn register_account( &self, id: &str, @@ -69,154 +859,257 @@ impl Db { address: &str, webhook_url: &str, ) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut accounts = write_txn.open_table(ACCOUNTS)?; - accounts.insert(id, (index, address, webhook_url))?; + let id = id.to_string(); + let address = address.to_string(); + let webhook_url = webhook_url.to_string(); + self.with_write_priority(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO accounts (id, derivation_index, address, webhook_url) + VALUES (?1, ?2, ?3, ?4)", + params![id, index, address, webhook_url], + )?; + Ok(()) + }) + } - let mut addr_map = write_txn.open_table(ADDRESS_TO_ID)?; - addr_map.insert(address, id)?; - } - write_txn.commit()?; - Ok(()) + /// Registers an account with a sequentially allocated derivation index + /// (P0 collision fix β€” see docs/fix-p0-register-address-collision.md, + /// Option 1). Runs as one writer command wrapping one transaction: + /// existing-id check, `next_index` allocation, address derivation, and + /// the account insert are atomic, so a re-register race can never burn an + /// index or produce two addresses for one id. + /// + /// Returns `(derivation_index, address, created)` where `created` is + /// false when the id already existed (its stored address is returned). + pub fn register_account_auto( + &self, + id: &str, + webhook_url: &str, + derive_address: impl Fn(u32) -> Result + Send + 'static, + ) -> Result<(u32, String, bool)> { + let id = id.to_string(); + let webhook_url = webhook_url.to_string(); + self.with_write_priority(move |conn| { + // Interactive commands run outside any writer-managed batch + // transaction, so this command owns its own transaction. + let tx = conn.unchecked_transaction()?; + + let existing: Option<(u32, String)> = tx + .query_row( + "SELECT derivation_index, address FROM accounts WHERE id = ?1", + [id.as_str()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((index, address)) = existing { + tx.commit()?; + return Ok((index, address, false)); + } + + let next_index: u32 = tx + .query_row( + "SELECT value FROM state WHERE key = 'next_index'", + [], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|v| v.parse::()) + .transpose()? + .unwrap_or(0); + + let address = derive_address(next_index)?; + + tx.execute( + "INSERT INTO accounts (id, derivation_index, address, webhook_url) + VALUES (?1, ?2, ?3, ?4)", + params![id, next_index, address, webhook_url], + )?; + tx.execute( + "INSERT INTO state (key, value) VALUES ('next_index', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![(next_index + 1).to_string()], + )?; + tx.commit()?; + + Ok((next_index, address, true)) + }) } pub fn get_registration_id_by_address(&self, address: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ADDRESS_TO_ID)?; - let result = table.get(address)?; - Ok(result.map(|v| v.value().to_string())) + let conn = self.read.get()?; + conn.query_row( + "SELECT id FROM accounts WHERE address = ?1", + [address], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) } pub fn get_account_by_address(&self, address: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ADDRESS_TO_ID)?; - let result = table.get(address)?; - Ok(result.map(|v| v.value().to_string())) + self.get_registration_id_by_address(address) } pub fn get_account_by_id(&self, id: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ACCOUNTS)?; - let result = table.get(id)?; - Ok(result.map(|v| { - let val = v.value(); - (val.0, val.1.to_string(), val.2.to_string()) - })) + let conn = self.read.get()?; + conn.query_row( + "SELECT derivation_index, address, webhook_url FROM accounts WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(Into::into) } pub fn get_webhook_url(&self, account_id: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ACCOUNTS)?; - let result = table.get(account_id)?; - Ok(result.map(|v| v.value().2.to_string())) - } - - /// Record a deposit and return true if it was newly recorded, false if it was a duplicate - pub fn record_deposit(&self, tx_hash: &str, account_id: &str, amount: &str) -> Result { - let write_txn = self.db.begin_write()?; - let is_new = { - let mut deposits = write_txn.open_table(DEPOSITS)?; - // Check if exists to avoid overwrite and duplicates - if deposits.get(tx_hash)?.is_none() { - deposits.insert(tx_hash, (account_id, amount, "detected"))?; - true - } else { - false - } - }; - write_txn.commit()?; - Ok(is_new) + let conn = self.read.get()?; + conn.query_row( + "SELECT webhook_url FROM accounts WHERE id = ?1", + [account_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) } - pub fn mark_deposit_swept(&self, tx_hash: &str) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut deposits = write_txn.open_table(DEPOSITS)?; - let (account_id, amount) = { - let current_val = deposits.get(tx_hash)?; - if let Some(v) = current_val { - let val = v.value(); - (val.0.to_string(), val.1.to_string()) - } else { - return Ok(()); - } - }; + pub fn record_deposit( + &self, + chain: &str, + tx_hash: &str, + account_id: &str, + amount: &str, + ) -> Result { + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + let account_id = account_id.to_string(); + let amount = amount.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT OR IGNORE INTO deposits (chain, tx_hash, account_id, amount, status) + VALUES (?1, ?2, ?3, ?4, 'detected')", + params![chain, tx_hash, account_id, amount], + )?; + Ok(conn.changes() == 1) + }) + } - deposits.insert(tx_hash, (account_id.as_str(), amount.as_str(), "swept"))?; - } - write_txn.commit()?; - Ok(()) + pub fn mark_deposit_swept(&self, chain: &str, tx_hash: &str) -> Result<()> { + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + self.with_write(move |conn| { + conn.execute( + "UPDATE deposits SET status = 'swept' WHERE chain = ?1 AND tx_hash = ?2", + params![chain, tx_hash], + )?; + Ok(()) + }) } - pub fn get_detected_deposits(&self) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(DEPOSITS)?; - let mut results = Vec::new(); - for item in table.iter()? { - let (tx_hash, value) = item?; - let (account_id, amount, status) = value.value(); - if status == "detected" { - results.push(( - tx_hash.value().to_string(), - account_id.to_string(), - amount.to_string(), - )); - } - } - Ok(results) + pub fn mark_deposit_failed(&self, chain: &str, tx_hash: &str) -> Result<()> { + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + self.with_write(move |conn| { + conn.execute( + "UPDATE deposits SET status = 'failed' WHERE chain = ?1 AND tx_hash = ?2", + params![chain, tx_hash], + )?; + Ok(()) + }) } - pub fn get_last_processed_block(&self) -> Result { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(STATE)?; - let result = table.get("last_block")?; - Ok(result.map(|v| v.value().parse().unwrap_or(0)).unwrap_or(0)) + pub fn get_detected_deposits(&self, chain: &str) -> Result> { + let conn = self.read.get()?; + let mut stmt = conn.prepare( + "SELECT tx_hash, account_id, amount FROM deposits + WHERE chain = ?1 AND status = 'detected'", + )?; + let rows = stmt.query_map([chain], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + rows.collect::>>() + .map_err(Into::into) } - pub fn set_last_processed_block(&self, block: u64) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut state = write_txn.open_table(STATE)?; - state.insert("last_block", block.to_string().as_str())?; - } - write_txn.commit()?; - Ok(()) + pub fn get_last_processed_block(&self, chain: &str) -> Result { + let conn = self.read.get()?; + let key = last_block_key(chain); + let val: Option = conn + .query_row("SELECT value FROM state WHERE key = ?1", [key], |row| { + row.get(0) + }) + .optional()?; + Ok(val.map(|v| v.parse().unwrap_or(0)).unwrap_or(0)) + } + + pub fn set_last_processed_block(&self, chain: &str, block: u64) -> Result<()> { + self.with_write(Self::set_last_processed_block_fn(chain, block)) + } + + /// Same write as `set_last_processed_block`, but on the interactive lane. + /// Used by the `POST /block_number` HTTP path so an admin cursor reset is + /// not stuck behind a monitor catch-up backlog (the monitor itself keeps + /// using the background-lane variant). + pub fn set_last_processed_block_priority(&self, chain: &str, block: u64) -> Result<()> { + self.with_write_priority(Self::set_last_processed_block_fn(chain, block)) } - // ========== ERC20 Token Metadata ========== + fn set_last_processed_block_fn( + chain: &str, + block: u64, + ) -> impl Fn(&Connection) -> Result<()> + Send + 'static { + let key = last_block_key(chain); + let block_str = block.to_string(); + move |conn| { + conn.execute( + "INSERT INTO state (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, block_str], + )?; + Ok(()) + } + } pub fn store_token_metadata( &self, + chain: &str, address: &str, symbol: &str, decimals: u8, name: &str, ) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut metadata = write_txn.open_table(TOKEN_METADATA)?; - metadata.insert(address, (symbol, decimals as u64, name))?; - } - write_txn.commit()?; - Ok(()) + let chain = chain.to_string(); + let address = address.to_string(); + let symbol = symbol.to_string(); + let name = name.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT OR REPLACE INTO token_metadata + (chain, token_address, symbol, decimals, name) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![chain, address, symbol, decimals, name], + )?; + Ok(()) + }) } - pub fn get_token_metadata(&self, address: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(TOKEN_METADATA)?; - let result = table.get(address)?; - Ok(result.map(|v| { - let val = v.value(); - (val.0.to_string(), val.1 as u8, val.2.to_string()) - })) + pub fn get_token_metadata( + &self, + chain: &str, + address: &str, + ) -> Result> { + let conn = self.read.get()?; + conn.query_row( + "SELECT symbol, decimals, name FROM token_metadata + WHERE chain = ?1 AND token_address = ?2", + params![chain, address], + |row| Ok((row.get(0)?, row.get::<_, u8>(1)?, row.get(2)?)), + ) + .optional() + .map_err(Into::into) } - // ========== ERC20 Deposits ========== - - /// Record an ERC20 deposit and return true if it was newly recorded, false if it was a duplicate + #[allow(clippy::too_many_arguments)] pub fn record_erc20_deposit( &self, + chain: &str, tx_hash: &str, log_index: u64, account_id: &str, @@ -224,292 +1117,1657 @@ impl Db { token_address: &str, token_symbol: &str, ) -> Result { - let write_txn = self.db.begin_write()?; - let is_new = { - let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; - let key = format!("{}:{}", tx_hash, log_index); - if deposits.get(key.as_str())?.is_none() { - deposits.insert( - key.as_str(), - (account_id, amount, token_address, token_symbol, "detected"), + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + let account_id = account_id.to_string(); + let amount = amount.to_string(); + let token_address = token_address.to_string(); + let token_symbol = token_symbol.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT OR IGNORE INTO erc20_deposits + (chain, tx_hash, log_index, account_id, amount, token_address, token_symbol, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'detected')", + params![ + chain, + tx_hash, + log_index as i64, + account_id, + amount, + token_address, + token_symbol + ], + )?; + Ok(conn.changes() == 1) + }) + } + + pub fn get_detected_erc20_deposits(&self, chain: &str) -> Result> { + let conn = self.read.get()?; + let mut stmt = conn.prepare( + "SELECT tx_hash, log_index, account_id, amount, token_address, token_symbol + FROM erc20_deposits WHERE chain = ?1 AND status = 'detected'", + )?; + let rows = stmt.query_map([chain], |row| { + let tx_hash: String = row.get(0)?; + let log_index: i64 = row.get(1)?; + Ok(Erc20Deposit { + key: format!("{tx_hash}:{log_index}"), + account_id: row.get(2)?, + amount: row.get(3)?, + token_address: row.get(4)?, + token_symbol: row.get(5)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + pub fn mark_erc20_deposit_swept(&self, chain: &str, local_key: &str) -> Result<()> { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let chain = chain.to_string(); + self.with_write(move |conn| { + conn.execute( + "UPDATE erc20_deposits SET status = 'swept' + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + )?; + Ok(()) + }) + } + + pub fn mark_erc20_deposits_swept_for_account_token( + &self, + chain: &str, + account_id: &str, + token_address: &str, + ) -> Result> { + let chain = chain.to_string(); + let account_id = account_id.to_string(); + let token_address = token_address.to_string(); + self.with_write(move |conn| { + let mut stmt = conn.prepare( + "UPDATE erc20_deposits SET status = 'swept' + WHERE chain = ?1 AND account_id = ?2 AND token_address = ?3 AND status = 'detected' + RETURNING tx_hash, log_index, amount", + )?; + let rows = stmt.query_map(params![chain, account_id, token_address], |row| { + let tx_hash: String = row.get(0)?; + let log_index: i64 = row.get(1)?; + let amount: String = row.get(2)?; + Ok((format!("{tx_hash}:{log_index}"), amount)) + })?; + rows.collect::>>() + .map_err(Into::into) + }) + } + + pub fn increment_zero_balance_count(&self, chain: &str, local_key: &str) -> Result { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let chain = chain.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT INTO sweep_meta (chain, tx_hash, log_index, sweep_tx_hash, zero_balance_retry_count) + VALUES (?1, ?2, ?3, '', 1) + ON CONFLICT(chain, tx_hash, log_index) DO UPDATE SET + zero_balance_retry_count = zero_balance_retry_count + 1", + params![chain, tx_hash, log_index], + )?; + let count: i64 = conn.query_row( + "SELECT zero_balance_retry_count FROM sweep_meta + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + |row| row.get(0), + )?; + Ok(count as u64) + }) + } + + #[allow(dead_code)] + pub fn set_sweep_tx_hash(&self, chain: &str, local_key: &str, tx_hash: &str) -> Result<()> { + let (deposit_tx, log_index) = parse_local_key(local_key)?; + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT INTO sweep_meta (chain, tx_hash, log_index, sweep_tx_hash, zero_balance_retry_count) + VALUES (?1, ?2, ?3, ?4, 0) + ON CONFLICT(chain, tx_hash, log_index) DO UPDATE SET sweep_tx_hash = excluded.sweep_tx_hash", + params![chain, deposit_tx, log_index, tx_hash], + )?; + Ok(()) + }) + } + + pub fn set_sweep_tx_hash_for_keys( + &self, + chain: &str, + local_keys: &[String], + tx_hash: &str, + ) -> Result<()> { + let chain = chain.to_string(); + let local_keys = local_keys.to_vec(); + let tx_hash = tx_hash.to_string(); + self.with_write(move |conn| { + for local_key in &local_keys { + let (deposit_tx, log_index) = parse_local_key(local_key)?; + let existing: i64 = conn + .query_row( + "SELECT zero_balance_retry_count FROM sweep_meta + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, deposit_tx, log_index], + |row| row.get(0), + ) + .unwrap_or(0); + conn.execute( + "INSERT INTO sweep_meta (chain, tx_hash, log_index, sweep_tx_hash, zero_balance_retry_count) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(chain, tx_hash, log_index) DO UPDATE SET sweep_tx_hash = excluded.sweep_tx_hash", + params![chain, deposit_tx, log_index, tx_hash, existing], )?; - true - } else { - false - } - }; - write_txn.commit()?; - Ok(is_new) - } - - pub fn get_detected_erc20_deposits(&self) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(ERC20_DEPOSITS)?; - let mut results = Vec::new(); - for item in table.iter()? { - let (key, value) = item?; - let (account_id, amount, token_address, token_symbol, status) = value.value(); - if status == "detected" { - results.push(Erc20Deposit { - key: key.value().to_string(), // tx_hash:log_index - account_id: account_id.to_string(), - amount: amount.to_string(), - token_address: token_address.to_string(), - token_symbol: token_symbol.to_string(), - }); } - } - Ok(results) + Ok(()) + }) } - pub fn mark_erc20_deposit_swept(&self, key: &str) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; - let (account_id, amount, token_address, token_symbol) = { - let current_val = deposits.get(key)?; - if let Some(v) = current_val { - let val = v.value(); - ( - val.0.to_string(), - val.1.to_string(), - val.2.to_string(), - val.3.to_string(), - ) - } else { - return Ok(()); - } - }; - - deposits.insert( - key, - ( - account_id.as_str(), - amount.as_str(), - token_address.as_str(), - token_symbol.as_str(), - "swept", - ), + #[allow(dead_code)] + pub fn get_sweep_meta(&self, chain: &str, local_key: &str) -> Result> { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let conn = self.read.get()?; + conn.query_row( + "SELECT sweep_tx_hash, zero_balance_retry_count FROM sweep_meta + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + |row| { + let count: i64 = row.get(1)?; + Ok((row.get(0)?, count as u64)) + }, + ) + .optional() + .map_err(Into::into) + } + + pub fn increment_sweep_failure_count(&self, chain: &str, local_key: &str) -> Result { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let chain = chain.to_string(); + self.with_write(move |conn| { + conn.execute( + "INSERT INTO sweep_failures (chain, tx_hash, log_index, consecutive_failure_count) + VALUES (?1, ?2, ?3, 1) + ON CONFLICT(chain, tx_hash, log_index) DO UPDATE SET + consecutive_failure_count = consecutive_failure_count + 1", + params![chain, tx_hash, log_index], )?; - } - write_txn.commit()?; - Ok(()) + let count: i64 = conn.query_row( + "SELECT consecutive_failure_count FROM sweep_failures + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + |row| row.get(0), + )?; + Ok(count as u64) + }) } - /// Mark all detected ERC20 deposits for a given (account_id, token_address) as swept. - /// Returns the list of deposit keys that were marked. - pub fn mark_erc20_deposits_swept_for_account_token( + pub fn mark_erc20_deposit_failed(&self, chain: &str, local_key: &str) -> Result<()> { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let chain = chain.to_string(); + self.with_write(move |conn| { + conn.execute( + "UPDATE erc20_deposits SET status = 'failed' + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + )?; + Ok(()) + }) + } + + pub fn mark_erc20_deposits_failed_for_account_token( &self, + chain: &str, account_id: &str, token_address: &str, ) -> Result> { - let write_txn = self.db.begin_write()?; - let mut marked_keys = Vec::new(); - { - let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; - - // First pass: collect keys that need updating - let keys_to_update: Vec<(String, String, String, String)> = { - let mut to_update = Vec::new(); - for item in deposits.iter()? { - let (key, value) = item?; - let (acc_id, amount, tok_addr, tok_symbol, status) = value.value(); - if status == "detected" && acc_id == account_id && tok_addr == token_address { - to_update.push(( - key.value().to_string(), - amount.to_string(), - tok_symbol.to_string(), - acc_id.to_string(), - )); - } - } - to_update - }; - - // Second pass: update the entries - for (key, amount, tok_symbol, acc_id) in &keys_to_update { - deposits.insert( - key.as_str(), - ( - acc_id.as_str(), - amount.as_str(), - token_address, - tok_symbol.as_str(), - "swept", - ), + let chain = chain.to_string(); + let account_id = account_id.to_string(); + let token_address = token_address.to_string(); + self.with_write(move |conn| { + let mut stmt = conn.prepare( + "UPDATE erc20_deposits SET status = 'failed' + WHERE chain = ?1 AND account_id = ?2 AND token_address = ?3 AND status = 'detected' + RETURNING tx_hash, log_index", + )?; + let rows = stmt.query_map(params![chain, account_id, token_address], |row| { + let tx_hash: String = row.get(0)?; + let log_index: i64 = row.get(1)?; + Ok(format!("{tx_hash}:{log_index}")) + })?; + rows.collect::>>() + .map_err(Into::into) + }) + } + + pub fn deposit_queue_counts(&self, chain: &str) -> Result { + let conn = self.read.get()?; + let native_detected: i64 = conn.query_row( + "SELECT COUNT(*) FROM deposits WHERE chain = ?1 AND status = 'detected'", + [chain], + |row| row.get(0), + )?; + let native_failed: i64 = conn.query_row( + "SELECT COUNT(*) FROM deposits WHERE chain = ?1 AND status = 'failed'", + [chain], + |row| row.get(0), + )?; + let erc20_detected: i64 = conn.query_row( + "SELECT COUNT(*) FROM erc20_deposits WHERE chain = ?1 AND status = 'detected'", + [chain], + |row| row.get(0), + )?; + let erc20_failed: i64 = conn.query_row( + "SELECT COUNT(*) FROM erc20_deposits WHERE chain = ?1 AND status = 'failed'", + [chain], + |row| row.get(0), + )?; + Ok(DepositQueueCounts { + native_detected: native_detected as u64, + native_failed: native_failed as u64, + erc20_detected: erc20_detected as u64, + erc20_failed: erc20_failed as u64, + }) + } + + /// Interactive lane: triggered directly by the admin retry-sweep HTTP endpoint. + pub fn retry_native_deposit(&self, chain: &str, tx_hash: &str) -> Result { + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + self.with_write_priority(move |conn| { + conn.execute( + "UPDATE deposits SET status = 'detected' + WHERE chain = ?1 AND tx_hash = ?2 AND status = 'failed'", + params![chain, tx_hash], + )?; + Ok(conn.changes() == 1) + }) + } + + /// Interactive lane: triggered directly by the admin retry-sweep HTTP endpoint. + pub fn retry_erc20_deposit(&self, chain: &str, tx_hash: &str, log_index: u64) -> Result { + let chain = chain.to_string(); + let tx_hash = tx_hash.to_string(); + self.with_write_priority(move |conn| { + conn.execute( + "UPDATE erc20_deposits SET status = 'detected' + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3 AND status = 'failed'", + params![chain, tx_hash, log_index as i64], + )?; + let updated = conn.changes() == 1; + if updated { + conn.execute( + "DELETE FROM sweep_failures + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index as i64], + )?; + } + Ok(updated) + }) + } + + pub fn get_sweep_failure_count(&self, chain: &str, local_key: &str) -> Result { + let (tx_hash, log_index) = parse_local_key(local_key)?; + let conn = self.read.get()?; + let count: Option = conn + .query_row( + "SELECT consecutive_failure_count FROM sweep_failures + WHERE chain = ?1 AND tx_hash = ?2 AND log_index = ?3", + params![chain, tx_hash, log_index], + |row| row.get(0), + ) + .optional()?; + Ok(count.unwrap_or(0) as u64) + } + + /// Insert or refresh a pending delivery. Returns true when the worker should be notified. + pub fn upsert_webhook_delivery( + &self, + id: &str, + event: &str, + registration_id: &str, + webhook_url: &str, + payload: &str, + ) -> Result { + let id = id.to_string(); + let event = event.to_string(); + let registration_id = registration_id.to_string(); + let webhook_url = webhook_url.to_string(); + let payload = payload.to_string(); + self.with_write(move |conn| { + let now = now_unix_secs(); + let existing: Option = conn + .query_row( + "SELECT status FROM webhook_deliveries WHERE id = ?1 AND event = ?2", + params![id, event], + |row| row.get(0), + ) + .optional()?; + + if existing.as_deref() == Some("delivered") { + return Ok(false); + } + + if existing.is_none() { + conn.execute( + "INSERT INTO webhook_deliveries + (id, event, registration_id, webhook_url, payload, status, attempt_count, + last_http_status, last_error, leased_until, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 0, NULL, NULL, NULL, ?6)", + params![id, event, registration_id, webhook_url, payload, now], )?; - marked_keys.push(key.clone()); + return Ok(true); + } + + if existing.as_deref() == Some("failed") { + return Ok(false); + } + + conn.execute( + "UPDATE webhook_deliveries + SET webhook_url = ?3, payload = ?4, updated_at = ?5 + WHERE id = ?1 AND event = ?2 AND status = 'pending'", + params![id, event, webhook_url, payload, now], + )?; + Ok(true) + }) + } + + pub fn claim_webhook_delivery( + &self, + id: &str, + event: &str, + lease_until: i64, + max_retries: u32, + ) -> Result { + let now = now_unix_secs(); + let id = id.to_string(); + let event = event.to_string(); + self.with_write(move |conn| { + conn.execute( + "UPDATE webhook_deliveries + SET leased_until = ?3, updated_at = ?4 + WHERE id = ?1 AND event = ?2 + AND status = 'pending' + AND attempt_count < ?5 + AND (leased_until IS NULL OR leased_until < ?4)", + params![id, event, lease_until, now, max_retries as i64], + )?; + Ok(conn.changes() == 1) + }) + } + + pub fn record_webhook_attempt( + &self, + id: &str, + event: &str, + http_status: Option, + error: Option<&str>, + status: &str, + ) -> Result { + let id = id.to_string(); + let event = event.to_string(); + let error = error.map(str::to_string); + let status = status.to_string(); + self.with_write(move |conn| { + let now = now_unix_secs(); + conn.execute( + "UPDATE webhook_deliveries + SET attempt_count = attempt_count + 1, + last_http_status = ?3, + last_error = ?4, + status = ?5, + leased_until = NULL, + updated_at = ?6 + WHERE id = ?1 AND event = ?2", + params![id, event, http_status.map(i64::from), error, status, now], + )?; + let count: i64 = conn.query_row( + "SELECT attempt_count FROM webhook_deliveries WHERE id = ?1 AND event = ?2", + params![id, event], + |row| row.get(0), + )?; + Ok(count as u64) + }) + } + + pub fn get_pending_webhook_delivery_keys( + &self, + max_retries: u32, + batch_size: u32, + ) -> Result> { + let now = now_unix_secs(); + let conn = self.read.get()?; + let mut stmt = conn.prepare( + "SELECT id, event FROM webhook_deliveries + WHERE status = 'pending' + AND attempt_count < ?1 + AND (leased_until IS NULL OR leased_until < ?2) + ORDER BY updated_at ASC + LIMIT ?3", + )?; + let rows = stmt.query_map(params![max_retries as i64, now, batch_size as i64], |row| { + Ok((row.get(0)?, row.get(1)?)) + })?; + rows.collect::>>() + .map_err(Into::into) + } + + pub fn get_webhook_delivery( + &self, + id: &str, + event: &str, + ) -> Result> { + let conn = self.read.get()?; + conn.query_row( + "SELECT id, event, registration_id, webhook_url, payload, status, attempt_count, + last_http_status, last_error + FROM webhook_deliveries WHERE id = ?1 AND event = ?2", + params![id, event], + |row| { + let http_status: Option = row.get(7)?; + Ok(WebhookDeliveryRecord { + id: row.get(0)?, + event: row.get(1)?, + registration_id: row.get(2)?, + webhook_url: row.get(3)?, + payload: row.get(4)?, + status: row.get(5)?, + attempt_count: row.get::<_, i64>(6)? as u64, + last_http_status: http_status.map(|s| s as u16), + last_error: row.get(8)?, + }) + }, + ) + .optional() + .map_err(Into::into) + } + + /// Interactive lane: triggered directly by the admin retry-webhook HTTP endpoint. + pub fn retry_webhook_delivery(&self, id: &str, event: &str) -> Result { + let now = now_unix_secs(); + let id = id.to_string(); + let event = event.to_string(); + self.with_write_priority(move |conn| { + conn.execute( + "UPDATE webhook_deliveries + SET status = 'pending', attempt_count = 0, leased_until = NULL, + last_http_status = NULL, last_error = NULL, updated_at = ?3 + WHERE id = ?1 AND event = ?2 AND status = 'failed'", + params![id, event, now], + )?; + Ok(conn.changes() == 1) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn test_chain_isolated_deposits() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.record_deposit("base", "0xabc", "user1", "100").unwrap(); + db.record_deposit("polygon", "0xabc", "user2", "200") + .unwrap(); + + let base = db.get_detected_deposits("base").unwrap(); + let polygon = db.get_detected_deposits("polygon").unwrap(); + + assert_eq!(base.len(), 1); + assert_eq!(base[0].0, "0xabc"); + assert_eq!(base[0].2, "100"); + assert_eq!(polygon.len(), 1); + assert_eq!(polygon[0].2, "200"); + } + + #[test] + fn test_per_chain_last_block() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.set_last_processed_block("base", 100).unwrap(); + db.set_last_processed_block("polygon", 200).unwrap(); + + assert_eq!(db.get_last_processed_block("base").unwrap(), 100); + assert_eq!(db.get_last_processed_block("polygon").unwrap(), 200); + } + + #[test] + fn test_record_deposit_duplicate_returns_false() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + assert!(db.record_deposit("base", "0xabc", "user1", "100").unwrap()); + assert!(!db.record_deposit("base", "0xabc", "user1", "100").unwrap()); + } + + #[test] + fn test_record_erc20_deposit_duplicate_returns_false() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + assert!(db + .record_erc20_deposit("polygon", "0xabc", 1, "user1", "100", "0xtoken", "USDC") + .unwrap()); + assert!(!db + .record_erc20_deposit("polygon", "0xabc", 1, "user1", "100", "0xtoken", "USDC") + .unwrap()); + } + + #[test] + fn test_increment_zero_balance_count_monotonic() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + assert_eq!( + db.increment_zero_balance_count("polygon", "0xabc:1") + .unwrap(), + 1 + ); + assert_eq!( + db.increment_zero_balance_count("polygon", "0xabc:1") + .unwrap(), + 2 + ); + assert_eq!( + db.increment_sweep_failure_count("polygon", "0xabc:1") + .unwrap(), + 1 + ); + } + + #[test] + fn test_db_new_idempotent_on_same_path() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + + let db1 = Db::new(path).unwrap(); + db1.register_account("u1", 0, "0x1", "https://example.com") + .unwrap(); + + let db2 = Db::new(path).unwrap(); + let acct = db2.get_account_by_id("u1").unwrap().unwrap(); + assert_eq!(acct.1, "0x1"); + } + + #[test] + fn test_retry_erc20_deposit_resets_failed_status_and_clears_failures() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.record_erc20_deposit("base", "0xabc", 120, "user1", "100", "0xtoken", "USDC") + .unwrap(); + db.mark_erc20_deposit_failed("base", "0xabc:120").unwrap(); + db.increment_sweep_failure_count("base", "0xabc:120") + .unwrap(); + + assert_eq!(db.get_detected_erc20_deposits("base").unwrap().len(), 0); + assert_eq!(db.get_sweep_failure_count("base", "0xabc:120").unwrap(), 1); + + assert!(db.retry_erc20_deposit("base", "0xabc", 120).unwrap()); + assert_eq!(db.get_detected_erc20_deposits("base").unwrap().len(), 1); + assert_eq!(db.get_sweep_failure_count("base", "0xabc:120").unwrap(), 0); + assert!(!db.retry_erc20_deposit("base", "0xabc", 120).unwrap()); + } + + #[test] + fn test_retry_native_deposit() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.record_deposit("polygon", "0xabc", "user1", "100") + .unwrap(); + db.mark_deposit_failed("polygon", "0xabc").unwrap(); + + assert!(db.retry_native_deposit("polygon", "0xabc").unwrap()); + assert_eq!(db.get_detected_deposits("polygon").unwrap().len(), 1); + } + + #[test] + fn test_deposit_queue_counts() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.record_deposit("base", "0x1", "u1", "100").unwrap(); + db.record_erc20_deposit("base", "0x2", 1, "u1", "200", "0xt", "USDC") + .unwrap(); + db.mark_erc20_deposit_failed("base", "0x2:1").unwrap(); + + let counts = db.deposit_queue_counts("base").unwrap(); + assert_eq!( + counts, + DepositQueueCounts { + native_detected: 1, + native_failed: 0, + erc20_detected: 0, + erc20_failed: 1, } + ); + } + + #[test] + fn test_normalize_db_path_strips_sqlite_scheme() { + let dir = tempfile::tempdir().unwrap(); + let bare = dir.path().join("wallet.db"); + let bare_str = bare.to_str().unwrap(); + + let db_bare = Db::new(bare_str).unwrap(); + db_bare + .register_account("u1", 0, "0x1", "https://example.com") + .unwrap(); + + let prefixed = format!("sqlite:{bare_str}"); + let db_prefixed = Db::new(&prefixed).unwrap(); + assert!(db_prefixed.get_account_by_id("u1").unwrap().is_some()); + } + + #[test] + fn test_upsert_webhook_delivery_skips_delivered() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + assert!(db + .upsert_webhook_delivery( + "polygon:0xabc", + "deposit_detected", + "user1", + "https://example.com/hook", + r#"{"id":"polygon:0xabc","event":"deposit_detected"}"#, + ) + .unwrap()); + + db.record_webhook_attempt( + "polygon:0xabc", + "deposit_detected", + Some(200), + None, + "delivered", + ) + .unwrap(); + + assert!(!db + .upsert_webhook_delivery( + "polygon:0xabc", + "deposit_detected", + "user1", + "https://example.com/hook", + r#"{"id":"polygon:0xabc","event":"deposit_detected"}"#, + ) + .unwrap()); + + let row = db + .get_webhook_delivery("polygon:0xabc", "deposit_detected") + .unwrap() + .unwrap(); + assert_eq!(row.status, "delivered"); + } + + #[test] + fn test_claim_webhook_delivery_respects_lease() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.upsert_webhook_delivery( + "base:0x1", + "deposit_swept", + "user1", + "https://example.com/hook", + r#"{"id":"base:0x1","event":"deposit_swept"}"#, + ) + .unwrap(); + + let now = now_unix_secs(); + assert!(db + .claim_webhook_delivery("base:0x1", "deposit_swept", now + 60, 5) + .unwrap()); + assert!(!db + .claim_webhook_delivery("base:0x1", "deposit_swept", now + 120, 5) + .unwrap()); + } + + #[test] + fn test_retry_webhook_delivery_resets_failed_row() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.upsert_webhook_delivery( + "polygon:0xdead", + "deposit_detected", + "user1", + "https://example.com/hook", + r#"{"id":"polygon:0xdead","event":"deposit_detected"}"#, + ) + .unwrap(); + db.record_webhook_attempt( + "polygon:0xdead", + "deposit_detected", + Some(503), + Some("HTTP status 503"), + "failed", + ) + .unwrap(); + + assert!(db + .retry_webhook_delivery("polygon:0xdead", "deposit_detected") + .unwrap()); + + let row = db + .get_webhook_delivery("polygon:0xdead", "deposit_detected") + .unwrap() + .unwrap(); + assert_eq!(row.status, "pending"); + assert_eq!(row.attempt_count, 0); + assert!(!db + .retry_webhook_delivery("polygon:0xdead", "deposit_detected") + .unwrap()); + } + + #[test] + fn test_new_uses_default_pool_max_size() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(db.read.max_size(), DEFAULT_READ_POOL_MAX_SIZE); + } + + #[test] + fn test_with_pool_size_configures_read_pool_capacity() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::with_pool_size(tmp.path().to_str().unwrap(), 3).unwrap(); + assert_eq!(db.read.max_size(), 3); + } + + /// Regression test for the pool-exhaustion incident: a hardcoded pool size + /// (previously r2d2's implicit default of 10, with no way to raise it) is + /// shared by every chain's monitor/sweeper/webhook loops plus inbound + /// registrations. This proves `with_pool_size` actually bounds concurrent + /// checkouts to the configured value, rather than silently falling back to + /// r2d2's default. + #[test] + fn test_read_pool_respects_configured_max_size() { + use std::sync::Barrier; + use std::thread; + use std::time::Duration; + + let tmp = NamedTempFile::new().unwrap(); + let pool_size = 2u32; + let db = Db::with_pool_size(tmp.path().to_str().unwrap(), pool_size).unwrap(); + + // Barrier for "every thread below has a connection checked out", + // signaling the main thread that the pool is genuinely exhausted. + // Parties = pool_size worker threads + the main thread itself. + let barrier = Arc::new(Barrier::new(pool_size as usize + 1)); + let handles: Vec<_> = (0..pool_size) + .map(|_| { + let db = db.clone(); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + let _conn = db + .read + .get() + .expect("pool should have capacity for this thread"); + barrier.wait(); + // Hold the connection past the 5s connection_timeout so the + // main thread's extra `get()` below contends for a pool + // that's genuinely exhausted, not just briefly busy. + thread::sleep(Duration::from_secs(6)); + }) + }) + .collect(); + + barrier.wait(); + let extra = db.read.get(); + assert!( + extra.is_err(), + "expected read.get() to fail once all {pool_size} pooled connections are checked out" + ); + + for h in handles { + h.join().unwrap(); + } + } + + // ========== Single-writer actor tests ========== + + use std::sync::atomic::AtomicUsize; + use std::thread; + + fn test_writer_config() -> WriterConfig { + WriterConfig { + abort_on_panic: false, + ..WriterConfig::default() } - write_txn.commit()?; - Ok(marked_keys) } - // ========== Sweep Metadata (new table, existing schemas unchanged) ========== + fn db_with(config: WriterConfig) -> (NamedTempFile, Db) { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::with_options(tmp.path().to_str().unwrap(), 5, config).unwrap(); + (tmp, db) + } - /// Increment zero-balance retry count for a deposit. Returns the new count. - pub fn increment_zero_balance_count(&self, key: &str) -> Result { - let write_txn = self.db.begin_write()?; - let new_count = { - let mut meta = write_txn.open_table(SWEEP_META)?; - let (sweep_tx_hash, count) = match meta.get(key)? { - Some(v) => { - let val = v.value(); - (val.0.to_string(), val.1) + /// Occupies the writer thread for `hold` by parking a background command + /// on it. Returns after the command has definitely started executing. + /// Re-execution-safe (write closures are `Fn`): only the first run sleeps. + fn occupy_writer(db: &Db, hold: Duration) -> thread::JoinHandle<()> { + let started = Arc::new(AtomicBool::new(false)); + let started_inner = Arc::clone(&started); + let first_run = Arc::new(AtomicBool::new(true)); + let db = db.clone(); + let handle = thread::spawn(move || { + db.with_write(move |_conn| { + started_inner.store(true, Ordering::SeqCst); + if first_run.swap(false, Ordering::SeqCst) { + thread::sleep(hold); } - None => (String::new(), 0), - }; - let new_count = count + 1; - meta.insert(key, (sweep_tx_hash.as_str(), new_count))?; - new_count - }; - write_txn.commit()?; - Ok(new_count) + Ok(()) + }) + .unwrap(); + }); + while !started.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(1)); + } + handle } - /// Store the on-chain sweep tx hash for a single deposit key. - #[allow(dead_code)] - pub fn set_sweep_tx_hash(&self, key: &str, tx_hash: &str) -> Result<()> { - let write_txn = self.db.begin_write()?; - { - let mut meta = write_txn.open_table(SWEEP_META)?; - let count = match meta.get(key)? { - Some(v) => v.value().1, - None => 0, - }; - meta.insert(key, (tx_hash, count))?; - } - write_txn.commit()?; - Ok(()) + #[test] + fn test_pragma_synchronous_reads_back_normal() { + let (_tmp, db) = db_with(test_writer_config()); + let conn = db.read.get().unwrap(); + let mode: i64 = conn + .query_row("PRAGMA synchronous", [], |row| row.get(0)) + .unwrap(); + assert_eq!(mode, 1, "expected synchronous=NORMAL (1), got {mode}"); + } + + // ========== WAL checkpoint tests ========== + + /// Writes rows without ever checkpointing, then returns the WAL size in + /// bytes so callers can assert it's grown past zero. + fn write_rows_without_checkpoint(conn: &Connection, count: usize) { + conn.execute_batch("CREATE TABLE IF NOT EXISTS t (v TEXT)") + .unwrap(); + for i in 0..count { + conn.execute("INSERT INTO t (v) VALUES (?1)", params![format!("row-{i}")]) + .unwrap(); + } } - /// Store the on-chain sweep tx hash for multiple deposit keys in one transaction. - pub fn set_sweep_tx_hash_for_keys(&self, keys: &[String], tx_hash: &str) -> Result<()> { - let write_txn = self.db.begin_write()?; + /// Regression for the 2026-07 write-queue-saturation incident: a + /// restart alone did nothing because SQLite just reopened the same + /// oversized WAL. `checkpoint_startup` must actually shrink it. + #[test] + fn test_checkpoint_startup_truncates_existing_wal() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 500); + + let wal_before = wal_file_size_bytes(path).unwrap_or(0); + assert!( + wal_before > 0, + "expected uncheckpointed writes to leave a non-empty WAL, got {wal_before}" + ); + + checkpoint_startup(&conn, path).unwrap(); + + let wal_after = wal_file_size_bytes(path).unwrap_or(0); + assert!( + wal_after < wal_before, + "expected startup checkpoint to shrink the WAL: before={wal_before} after={wal_after}" + ); + } + + /// `Db::with_options` must run the startup checkpoint itself (not just + /// the standalone helper) so every real construction path is covered, + /// including the one production actually uses. + #[test] + fn test_db_with_options_checkpoints_preexisting_wal_on_open() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 500); + // Deliberately leak rather than drop: closing the last connection to + // a WAL database triggers SQLite's own checkpoint-on-close, which + // would clean up the WAL before `Db::with_options` ever gets a + // chance to and defeat the point of this test. A real incident looks + // like this too β€” the previous process's connection never got a + // clean close (killed, or the close-time checkpoint itself stalled + // on EFS), leaving an oversized WAL for the next process to inherit. + std::mem::forget(conn); + + let wal_before = wal_file_size_bytes(path).unwrap_or(0); + assert!( + wal_before > 0, + "expected uncheckpointed writes to leave a non-empty WAL, got {wal_before}" + ); + + let db = Db::with_options(path, 5, test_writer_config()).unwrap(); + let wal_after = wal_file_size_bytes(path).unwrap_or(0); + assert!( + wal_after < wal_before, + "expected Db::with_options to checkpoint the pre-existing WAL on open: \ + before={wal_before} after={wal_after}" + ); + drop(db); + } + + #[test] + fn test_run_wal_checkpoint_reports_frame_counts() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 200); + + // PASSIVE never truncates the physical file, so its returned counts + // are the reliable signal for "how much was actually pending." + let result = run_wal_checkpoint(&conn, "PASSIVE").unwrap(); + assert_eq!( + result.busy, 0, + "expected an uncontended checkpoint to succeed" + ); + assert!( + result.log_frames > 0, + "expected a non-zero WAL frame count before checkpointing" + ); + assert_eq!( + result.checkpointed_frames, result.log_frames, + "a fully successful checkpoint with no concurrent readers should \ + checkpoint every WAL frame" + ); + + // Nothing new to do: with no writes in between, a repeat PASSIVE + // checkpoint reports the same (already fully backfilled) counts + // rather than erroring or double-counting. + let second = run_wal_checkpoint(&conn, "PASSIVE").unwrap(); + assert_eq!(second.busy, 0); + assert_eq!(second.checkpointed_frames, second.log_frames); + } + + /// `TRUNCATE` mode additionally shrinks the physical `-wal` file to zero + /// bytes on full success β€” this is the property `checkpoint_startup` + /// relies on to fix "restart reopens the same oversized WAL." + #[test] + fn test_run_wal_checkpoint_truncate_shrinks_file_on_full_success() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 200); + + let wal_before = wal_file_size_bytes(path).unwrap_or(0); + assert!(wal_before > 0); + + let result = run_wal_checkpoint(&conn, "TRUNCATE").unwrap(); + assert_eq!( + result.busy, 0, + "expected an uncontended checkpoint to succeed" + ); + + let wal_after = wal_file_size_bytes(path).unwrap_or(0); + assert_eq!( + wal_after, 0, + "expected a fully successful TRUNCATE checkpoint to shrink the WAL to 0 bytes" + ); + } + + fn make_test_queue() -> WriteQueue { + WriteQueue::new(64, 2048) + } + + /// `maybe_checkpoint` must skip entirely (no attempt, timer untouched) + /// while an interactive command is waiting, so the opportunistic + /// checkpoint never adds latency to a real request. + #[test] + fn test_maybe_checkpoint_skips_when_interactive_waiting() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 500); + let wal_before = wal_file_size_bytes(path).unwrap_or(0); + assert!(wal_before > 0); + + let queue = make_test_queue(); + let (reply_tx, _reply_rx) = sync_channel::(1); + queue + .try_push_interactive(WriteCommand { + run: Box::new(|_conn| Ok(Box::new(()) as Box)), + reply: reply_tx, + enqueued_at: Instant::now(), + }) + .unwrap(); + + let mut last_checkpoint = Instant::now() - Duration::from_secs(3600); + let outcome = maybe_checkpoint( + &conn, + &queue, + path, + Duration::from_secs(30), + &mut last_checkpoint, + ); + + assert!( + outcome.is_none(), + "expected no checkpoint attempt while an interactive command is queued" + ); + let wal_after = wal_file_size_bytes(path).unwrap_or(0); + assert_eq!(wal_after, wal_before); + } + + /// `maybe_checkpoint` must skip when the interval hasn't elapsed yet, + /// even with an empty interactive lane, so it never runs on every single + /// background batch. + #[test] + fn test_maybe_checkpoint_skips_before_interval_elapses() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 500); + let wal_before = wal_file_size_bytes(path).unwrap_or(0); + assert!(wal_before > 0); + + let queue = make_test_queue(); + let mut last_checkpoint = Instant::now(); + let outcome = maybe_checkpoint( + &conn, + &queue, + path, + Duration::from_secs(3600), + &mut last_checkpoint, + ); + + assert!( + outcome.is_none(), + "expected no checkpoint attempt before the interval elapses" + ); + let wal_after = wal_file_size_bytes(path).unwrap_or(0); + assert_eq!(wal_after, wal_before); + } + + /// Once both gates are open (no interactive work, interval elapsed) the + /// checkpoint actually runs and shrinks the WAL, and the timer resets so + /// the next call doesn't immediately re-run. + #[test] + fn test_maybe_checkpoint_runs_and_resets_timer_once_due() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + let conn = Connection::open(path).unwrap(); + apply_pragmas(&conn).unwrap(); + write_rows_without_checkpoint(&conn, 500); + + let queue = make_test_queue(); + let mut last_checkpoint = Instant::now() - Duration::from_secs(3600); + let outcome = maybe_checkpoint( + &conn, + &queue, + path, + Duration::from_secs(30), + &mut last_checkpoint, + ); + + // PASSIVE never shrinks the physical file (see module docs on + // `maybe_checkpoint`), so the frame counts it returns β€” not WAL + // byte size β€” are the correct signal that it actually ran. + let result = outcome.expect("expected a due checkpoint to actually run"); + assert_eq!(result.busy, 0); + assert!( + result.log_frames > 0 && result.checkpointed_frames == result.log_frames, + "expected the due checkpoint to fully backfill the pending frames, got {:?}/{:?}", + result.log_frames, + result.checkpointed_frames + ); + assert!( + last_checkpoint.elapsed() < Duration::from_secs(5), + "expected the timer to reset to roughly now after running" + ); + + // Immediately calling again should be a no-op (interval not + // elapsed), proving the reset timer actually gates the next call. + write_rows_without_checkpoint(&conn, 500); + let second_outcome = maybe_checkpoint( + &conn, + &queue, + path, + Duration::from_secs(30), + &mut last_checkpoint, + ); + assert!( + second_outcome.is_none(), + "expected the just-reset timer to skip an immediate second checkpoint" + ); + } + + /// Regression (mandatory): one failing command inside a batch rolls the + /// batch back, all other commands are re-executed individually and land, + /// and only the truly failing command reports an error. + #[test] + fn test_batch_poison_command_falls_back_to_individual_execution() { + let (_tmp, db) = db_with(test_writer_config()); + + // Park the writer so the commands below queue up and get batched + // into a single transaction together. + let hold = occupy_writer(&db, Duration::from_millis(200)); + + let mut handles = Vec::new(); + for i in 0..5 { + let db = db.clone(); + handles.push(thread::spawn(move || { + let tx_hash = format!("0xgood{i}"); + db.record_deposit("base", &tx_hash, "user", "100") + })); + } + // Poison command: syntactically invalid SQL fails at execute time. + let poison_db = db.clone(); + let poison = thread::spawn(move || { + poison_db.with_write(|conn| { + conn.execute("THIS IS NOT SQL", [])?; + Ok(()) + }) + }); + + for h in handles { + assert!( + h.join().unwrap().is_ok(), + "good commands must land despite the poison command in the same batch" + ); + } + assert!( + poison.join().unwrap().is_err(), + "the poison command must be the only one that errors" + ); + hold.join().unwrap(); + + let deposits = db.get_detected_deposits("base").unwrap(); + assert_eq!(deposits.len(), 5, "all 5 good deposits must be committed"); + } + + /// Regression (mandatory): a caller whose interactive timeout fires still + /// gets its write executed (at-least-once), and the retry hits the + /// existing-account fast path with no duplicate index. + #[test] + fn test_timeout_then_late_execution_register_retry_is_safe() { + let (_tmp, db) = db_with(WriterConfig { + write_timeout: Duration::from_millis(50), + ..test_writer_config() + }); + + // Writer stuck well past the interactive timeout. + let hold = occupy_writer(&db, Duration::from_millis(400)); + + let err = db + .register_account_auto("late_user", "https://example.com", |i| Ok(format!("0xaddr{i}"))) + .unwrap_err(); + assert!( + matches!( + err.downcast_ref::(), + Some(WriteQueueError::Timeout(_)) + ), + "expected Timeout, got: {err}" + ); + + hold.join().unwrap(); + // Give the writer a moment to drain the late command. + thread::sleep(Duration::from_millis(200)); + + let stored = db.get_account_by_id("late_user").unwrap(); + assert!( + stored.is_some(), + "the timed-out register must still execute (at-least-once)" + ); + let (index, address, _) = stored.unwrap(); + + // Retry returns the existing account: same index, same address. + let (retry_index, retry_address, created) = db + .register_account_auto("late_user", "https://example.com", |i| Ok(format!("0xaddr{i}"))) + .unwrap(); + assert!(!created); + assert_eq!(retry_index, index); + assert_eq!(retry_address, address); + } + + /// Contention: a saturated background lane must not delay an interactive + /// register beyond (roughly) one in-flight command, far under the timeout. + #[test] + fn test_background_saturation_does_not_delay_interactive_register() { + let (_tmp, db) = db_with(test_writer_config()); + + // Queue a pile of slow background commands (~25 x 20ms = 500ms of + // writer work), then one interactive register. If priority did not + // work, the register would wait for the whole pile. + let hold = occupy_writer(&db, Duration::from_millis(100)); + let mut producers = Vec::new(); + for i in 0..25 { + let db = db.clone(); + producers.push(thread::spawn(move || { + db.with_write(move |conn| { + thread::sleep(Duration::from_millis(20)); + conn.execute( + "INSERT OR IGNORE INTO deposits (chain, tx_hash, account_id, amount, status) + VALUES ('base', ?1, 'u', '1', 'detected')", + [format!("0xslow{i}")], + )?; + Ok(()) + }) + .unwrap(); + })); + } + thread::sleep(Duration::from_millis(50)); // let producers enqueue + + let started = Instant::now(); + db.register_account("prio_user", 0, "0xprio", "https://example.com") + .unwrap(); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_millis(1000), + "interactive register took {elapsed:?}; priority lane is not jumping ahead" + ); + + hold.join().unwrap(); + for p in producers { + p.join().unwrap(); + } + } + + #[test] + fn test_interactive_queue_full_returns_typed_error_immediately() { + let (_tmp, db) = db_with(WriterConfig { + interactive_capacity: 1, + ..test_writer_config() + }); + + // Writer stuck; one interactive command occupies the only lane slot. + let hold = occupy_writer(&db, Duration::from_millis(300)); + let occupant_db = db.clone(); + let occupant = thread::spawn(move || { + occupant_db + .register_account("occupant", 0, "0xocc", "https://example.com") + .unwrap(); + }); + thread::sleep(Duration::from_millis(50)); // let the occupant enqueue + + let started = Instant::now(); + let err = db + .register_account("rejected", 1, "0xrej", "https://example.com") + .unwrap_err(); + assert!( + matches!( + err.downcast_ref::(), + Some(WriteQueueError::QueueFull) + ), + "expected QueueFull, got: {err}" + ); + assert!( + started.elapsed() < Duration::from_millis(100), + "queue-full must fail fast, not wait" + ); + + hold.join().unwrap(); + occupant.join().unwrap(); + } + + /// Backpressure: a full background lane blocks the producer (no write is + /// ever dropped), and FIFO order within the lane guarantees a chunk's + /// deposits commit before its cursor advance. + #[test] + fn test_background_lane_full_blocks_producer_and_preserves_order() { + let (_tmp, db) = db_with(WriterConfig { + background_capacity: 2, + ..test_writer_config() + }); + + let hold = occupy_writer(&db, Duration::from_millis(300)); + + let order = Arc::new(Mutex::new(Vec::<&'static str>::new())); + let mut producers = Vec::new(); + // One producer issues deposit-then-cursor in program order, like the + // monitor does; extra producers overfill the capacity-2 lane so at + // least one send() must block instead of dropping. { - let mut meta = write_txn.open_table(SWEEP_META)?; - for key in keys { - let count = match meta.get(key.as_str())? { - Some(v) => v.value().1, - None => 0, - }; - meta.insert(key.as_str(), (tx_hash, count))?; + let db = db.clone(); + let order = Arc::clone(&order); + producers.push(thread::spawn(move || { + let o1 = Arc::clone(&order); + db.with_write(move |conn| { + o1.lock().unwrap().push("deposit"); + conn.execute( + "INSERT OR IGNORE INTO deposits (chain, tx_hash, account_id, amount, status) + VALUES ('base', '0xdep', 'u', '1', 'detected')", + [], + )?; + Ok(()) + }) + .unwrap(); + let o2 = Arc::clone(&order); + db.with_write(move |conn| { + o2.lock().unwrap().push("cursor"); + conn.execute( + "INSERT INTO state (key, value) VALUES ('last_block:base', '42') + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [], + )?; + Ok(()) + }) + .unwrap(); + })); + } + for i in 0..4 { + let db = db.clone(); + producers.push(thread::spawn(move || { + db.record_deposit("base", &format!("0xfill{i}"), "u", "1") + .unwrap(); + })); + } + + hold.join().unwrap(); + for p in producers { + p.join().unwrap(); // every blocked producer completes; nothing dropped + } + + let recorded = order.lock().unwrap().clone(); + let dep_pos = recorded.iter().position(|s| *s == "deposit").unwrap(); + let cur_pos = recorded.iter().position(|s| *s == "cursor").unwrap(); + assert!( + dep_pos < cur_pos, + "a chunk's record_deposit must execute before its set_last_processed_block" + ); + assert_eq!(db.get_detected_deposits("base").unwrap().len(), 5); + assert_eq!(db.get_last_processed_block("base").unwrap(), 42); + } + + #[test] + fn test_fifo_ordering_preserved_within_background_lane() { + let (_tmp, db) = db_with(test_writer_config()); + let hold = occupy_writer(&db, Duration::from_millis(400)); + + let order = Arc::new(Mutex::new(Vec::::new())); + let db2 = db.clone(); + let order2 = Arc::clone(&order); + let producer = thread::spawn(move || { + let mut waiters = Vec::new(); + for i in 0..10 { + let db3 = db2.clone(); + let o = Arc::clone(&order2); + // Sequential blocking sends from one thread would serialize on + // the replies; enqueue via short-lived threads spawned in + // order with a small delay so queue order is deterministic. + waiters.push(thread::spawn(move || { + db3.with_write(move |_conn| { + o.lock().unwrap().push(i); + Ok(()) + }) + .unwrap(); + })); + thread::sleep(Duration::from_millis(10)); + } + for w in waiters { + w.join().unwrap(); } + }); + + producer.join().unwrap(); + hold.join().unwrap(); + + let recorded = order.lock().unwrap().clone(); + assert_eq!(recorded, (0..10).collect::>()); + } + + #[test] + fn test_writer_death_flags_unhealthy_and_fails_subsequent_writes() { + let (_tmp, db) = db_with(test_writer_config()); + assert!(db.writer_healthy()); + + // A panicking command kills the writer loop (abort disabled in tests). + let result = db.with_write(|_conn| -> Result<()> { panic!("boom") }); + assert!(result.is_err()); + + // The death handler runs on the writer thread; poll briefly. + let deadline = Instant::now() + Duration::from_secs(2); + while db.writer_healthy() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); } - write_txn.commit()?; - Ok(()) + assert!(!db.writer_healthy(), "writer_healthy must flip false"); + + let err = db.record_deposit("base", "0xdead", "u", "1").unwrap_err(); + assert!( + matches!( + err.downcast_ref::(), + Some(WriteQueueError::WriterGone) + ), + "expected WriterGone, got: {err}" + ); + let err = db + .register_account("dead", 0, "0xd", "https://example.com") + .unwrap_err(); + assert!(matches!( + err.downcast_ref::(), + Some(WriteQueueError::WriterGone) + )); + + // Reads still work. + assert!(db.get_detected_deposits("base").unwrap().is_empty()); } - /// Read sweep metadata for a deposit key. - #[allow(dead_code)] - pub fn get_sweep_meta(&self, key: &str) -> Result> { - let read_txn = self.db.begin_read()?; - let table = read_txn.open_table(SWEEP_META)?; - let result = table.get(key)?; - Ok(result.map(|v| { - let val = v.value(); - (val.0.to_string(), val.1) - })) - } - - // ========== Sweep Failure Tracking ========== - - /// Increment the sweep failure count for a deposit. Returns the new count. - pub fn increment_sweep_failure_count(&self, key: &str) -> Result { - let write_txn = self.db.begin_write()?; - let new_count = { - let mut failures = write_txn.open_table(SWEEP_FAILURES)?; - let count = match failures.get(key)? { - Some(v) => v.value(), - None => 0, - }; - let new_count = count + 1; - failures.insert(key, new_count)?; - new_count - }; - write_txn.commit()?; - Ok(new_count) - } - - /// Mark a single ERC20 deposit as permanently failed. - pub fn mark_erc20_deposit_failed(&self, key: &str) -> Result<()> { - let write_txn = self.db.begin_write()?; + /// The three rerouted HTTP write paths (retry sweeps, retry webhook, + /// cursor set) plus register must complete even when the background lane + /// is full and blocked β€” proof they ride the interactive lane. + #[test] + fn test_priority_paths_bypass_full_background_lane() { + let (_tmp, db) = db_with(WriterConfig { + background_capacity: 1, + ..test_writer_config() + }); + + // Seed rows the priority calls will touch (writer still healthy). + db.record_deposit("base", "0xn", "u", "1").unwrap(); + db.mark_deposit_failed("base", "0xn").unwrap(); + db.record_erc20_deposit("base", "0xe", 1, "u", "1", "0xt", "USDC") + .unwrap(); + db.mark_erc20_deposit_failed("base", "0xe:1").unwrap(); + db.upsert_webhook_delivery("wid", "ev", "u", "https://example.com", "{}") + .unwrap(); + db.record_webhook_attempt("wid", "ev", Some(500), Some("err"), "failed") + .unwrap(); + + // Stall the writer and overfill the capacity-1 background lane so + // background senders are blocked in push_background. + let hold = occupy_writer(&db, Duration::from_millis(500)); + let mut background = Vec::new(); + for i in 0..3 { + let db = db.clone(); + background.push(thread::spawn(move || { + db.record_deposit("base", &format!("0xbg{i}"), "u", "1") + .unwrap(); + })); + } + thread::sleep(Duration::from_millis(50)); + + let started = Instant::now(); + assert!(db.retry_native_deposit("base", "0xn").unwrap()); + assert!(db.retry_erc20_deposit("base", "0xe", 1).unwrap()); + assert!(db.retry_webhook_delivery("wid", "ev").unwrap()); + db.set_last_processed_block_priority("base", 7).unwrap(); + db.register_account("prio2", 3, "0xp2", "https://example.com") + .unwrap(); + assert!( + started.elapsed() < Duration::from_secs(3), + "priority paths must not wait behind the blocked background lane" + ); + + hold.join().unwrap(); + for b in background { + b.join().unwrap(); + } + } + + // ========== P0 collision fix: sequential next_index counter ========== + + #[test] + fn test_register_account_auto_allocates_distinct_sequential_indices() { + let (_tmp, db) = db_with(test_writer_config()); + + let mut seen_indices = std::collections::HashSet::new(); + let mut seen_addresses = std::collections::HashSet::new(); + for i in 0..10 { + let (index, address, created) = db + .register_account_auto(&format!("user{i}"), "https://example.com", |idx| { + Ok(format!("0xaddr{idx}")) + }) + .unwrap(); + assert!(created); + assert_eq!(index, i, "indices must be sequential"); + assert!(seen_indices.insert(index)); + assert!(seen_addresses.insert(address)); + } + } + + #[test] + fn test_register_account_auto_reregister_returns_existing() { + let (_tmp, db) = db_with(test_writer_config()); + + let (index, address, created) = db + .register_account_auto("alice", "https://example.com", |i| Ok(format!("0xaddr{i}"))) + .unwrap(); + assert!(created); + + let (index2, address2, created2) = db + .register_account_auto("alice", "https://example.com", |i| Ok(format!("0xaddr{i}"))) + .unwrap(); + assert!(!created2, "re-register must not create a new account"); + assert_eq!(index2, index); + assert_eq!(address2, address); + + // No index was burned by the re-register. + let (bob_index, _, _) = db + .register_account_auto("bob", "https://example.com", |i| Ok(format!("0xaddr{i}"))) + .unwrap(); + assert_eq!(bob_index, index + 1); + } + + #[test] + fn test_next_index_counter_survives_db_reopen() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + { - let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; - let (account_id, amount, token_address, token_symbol) = { - let current_val = deposits.get(key)?; - if let Some(v) = current_val { - let val = v.value(); - ( - val.0.to_string(), - val.1.to_string(), - val.2.to_string(), - val.3.to_string(), - ) - } else { - return Ok(()); - } - }; - - deposits.insert( - key, - ( - account_id.as_str(), - amount.as_str(), - token_address.as_str(), - token_symbol.as_str(), - "failed", - ), - )?; + let db = Db::with_options(&path, 5, test_writer_config()).unwrap(); + for i in 0..3 { + db.register_account_auto(&format!("u{i}"), "https://example.com", |idx| { + Ok(format!("0xaddr{idx}")) + }) + .unwrap(); + } } - write_txn.commit()?; - Ok(()) + + let db = Db::with_options(&path, 5, test_writer_config()).unwrap(); + let (index, _, created) = db + .register_account_auto("u_new", "https://example.com", |idx| { + Ok(format!("0xaddr{idx}")) + }) + .unwrap(); + assert!(created); + assert_eq!(index, 3, "counter must persist across reopen (no reuse)"); } - /// Mark all detected ERC20 deposits for a given (account_id, token_address) as permanently failed. - /// Returns the list of deposit keys that were marked. - pub fn mark_erc20_deposits_failed_for_account_token( - &self, - account_id: &str, - token_address: &str, - ) -> Result> { - let write_txn = self.db.begin_write()?; - let mut marked_keys = Vec::new(); + #[test] + fn test_migration_seeds_counter_above_legacy_max_index() { + let tmp = NamedTempFile::new().unwrap(); + let path = tmp.path().to_str().unwrap().to_string(); + + // Simulate a pre-V3 database with legacy hash-derived indices. { - let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; - - let keys_to_update: Vec<(String, String, String, String)> = { - let mut to_update = Vec::new(); - for item in deposits.iter()? { - let (key, value) = item?; - let (acc_id, amount, tok_addr, tok_symbol, status) = value.value(); - if status == "detected" && acc_id == account_id && tok_addr == token_address { - to_update.push(( - key.value().to_string(), - amount.to_string(), - tok_symbol.to_string(), - acc_id.to_string(), - )); - } + let mut conn = Connection::open(&path).unwrap(); + apply_pragmas(&conn).unwrap(); + Migrations::new(vec![ + M::up(include_str!("../migrations/V1__initial.sql")), + M::up(include_str!("../migrations/V2__webhook_deliveries.sql")), + ]) + .to_latest(&mut conn) + .unwrap(); + conn.execute( + "INSERT INTO accounts (id, derivation_index, address, webhook_url) + VALUES ('legacy', 12345, '0xlegacy', 'https://example.com')", + [], + ) + .unwrap(); + } + + // Opening through Db runs V3, which must seed the counter past 12345. + let db = Db::with_options(&path, 5, test_writer_config()).unwrap(); + let (index, _, created) = db + .register_account_auto("fresh", "https://example.com", |idx| { + Ok(format!("0xaddr{idx}")) + }) + .unwrap(); + assert!(created); + assert_eq!( + index, 12346, + "new allocations must start above the legacy max index" + ); + } + + /// E2E saturation: sustained synthetic catch-up traffic on the background + /// lane must not push register latency past the interactive timeout. + #[test] + fn test_register_latency_stays_bounded_under_background_saturation() { + let (_tmp, db) = db_with(test_writer_config()); // 5s timeout, batch 50 + + let stop = Arc::new(AtomicBool::new(false)); + let writes_done = Arc::new(AtomicUsize::new(0)); + let mut hammers = Vec::new(); + for t in 0..4 { + let db = db.clone(); + let stop = Arc::clone(&stop); + let writes_done = Arc::clone(&writes_done); + hammers.push(thread::spawn(move || { + let mut i = 0usize; + while !stop.load(Ordering::Relaxed) { + db.record_deposit("base", &format!("0xh{t}x{i}"), "u", "1") + .unwrap(); + db.set_last_processed_block("base", i as u64).unwrap(); + writes_done.fetch_add(2, Ordering::Relaxed); + i += 1; } - to_update - }; - - for (key, amount, tok_symbol, acc_id) in &keys_to_update { - deposits.insert( - key.as_str(), - ( - acc_id.as_str(), - amount.as_str(), - token_address, - tok_symbol.as_str(), - "failed", - ), - )?; - marked_keys.push(key.clone()); - } + })); + } + + // Let the hammers build a steady stream, then measure registers. + thread::sleep(Duration::from_millis(100)); + let mut worst = Duration::ZERO; + for i in 0..20 { + let started = Instant::now(); + db.register_account_auto(&format!("sat_user{i}"), "https://example.com", |idx| { + Ok(format!("0xaddr{idx}")) + }) + .unwrap(); + worst = worst.max(started.elapsed()); } - write_txn.commit()?; - Ok(marked_keys) + + stop.store(true, Ordering::Relaxed); + for h in hammers { + h.join().unwrap(); + } + + assert!( + worst < WriterConfig::default().write_timeout, + "worst register latency {worst:?} exceeded the interactive timeout \ + under background saturation ({} background writes)", + writes_done.load(Ordering::Relaxed) + ); } } diff --git a/src/e2e_tests.rs b/src/e2e_tests.rs index 09e1dfe..476add6 100644 --- a/src/e2e_tests.rs +++ b/src/e2e_tests.rs @@ -1,12 +1,15 @@ -use crate::config::{Config, ProviderUrl}; use crate::db::Db; use crate::faucet::Faucet; use crate::monitor::Monitor; use crate::sweeper::Sweeper; +use crate::test_support::{ + chain_treasury, http_provider_boxed, test_chain_config_named, test_config, + test_config_multichain, test_webhook_deliverer, TEST_CHAIN, +}; use crate::traits::Service; use crate::wallet::Wallet; -use alloy::providers::ProviderBuilder; -use serde_json::json; +use crate::webhook::WebhookRetryService; +use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; use tempfile::NamedTempFile; @@ -14,174 +17,362 @@ use tokio::time::sleep; use wiremock::matchers::method; use wiremock::{Mock, MockServer, ResponseTemplate}; +const BLOCK_NUMBER_HEX: &str = "0xA"; +const BLOCK_HASH: &str = "0x000000000000000000000000000000000000000000000000000000000000000a"; +const PARENT_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000009"; +const ROOT_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +const SHARED_TX_HASH: &str = "0x0000000000000000000000000000000000000000000000000000000000000001"; +const BASE_SWEEP_TX: &str = "0x00000000000000000000000000000000000000000000000000000000000000b1"; +const POLYGON_SWEEP_TX: &str = "0x00000000000000000000000000000000000000000000000000000000000000b2"; + #[tokio::test] async fn test_e2e_deposit_sweep_flow() { let _ = tracing_subscriber::fmt::try_init(); - // 1. Setup Mock RPC let rpc_server = MockServer::start().await; let webhook_server = MockServer::start().await; - // 2. Setup Config & DB let db_file = NamedTempFile::new().unwrap(); let db_path = db_file.path().to_str().unwrap(); - let config = Config { - database_url: db_path.to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3001, - poll_interval: 1, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - block_offset_from_head: 0, // Use 0 for tests to avoid underflow with low block numbers - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_path, rpc_server.uri()); + let chain_cfg = test_chain_config_named(TEST_CHAIN, rpc_server.uri()); let wallet = Wallet::new(config.mnemonic.clone()); let db = Db::new(&config.database_url).unwrap(); - // 3. Register Users - // User 1 -> Index 0 let addr1 = wallet.derive_address(0).unwrap(); let addr1_str = addr1.to_string(); let webhook_url = webhook_server.uri(); db.register_account("user_1", 0, &addr1_str, &webhook_url) .unwrap(); - // User 2 -> Index 1 let addr2 = wallet.derive_address(1).unwrap(); let addr2_str = addr2.to_string(); db.register_account("user_2", 1, &addr2_str, &webhook_url) .unwrap(); - // 4. Initialize Provider - let provider = ProviderBuilder::new().on_http(rpc_server.uri().parse().unwrap()); + let provider = http_provider_boxed(&rpc_server.uri()); + let treasury = chain_treasury(&config); + + mount_deposit_sweep_rpc_mocks( + &rpc_server, + &addr1_str, + treasury.as_str(), + SHARED_TX_HASH, + "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x89", + ) + .await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount(&webhook_server) + .await; + + let deliverer = test_webhook_deliverer(db.clone()); + spawn_webhook_worker(Arc::clone(&deliverer)); + spawn_chain_workers( + chain_cfg, + deliverer, + config.faucet_mnemonic.clone(), + db.clone(), + wallet.clone(), + provider, + ); + + wait_until_detected(&db, TEST_CHAIN).await; + wait_until_swept(&db, TEST_CHAIN).await; + + sleep(Duration::from_millis(500)).await; +} + +#[tokio::test] +async fn test_e2e_multichain_same_address_both_swept() { + let _ = tracing_subscriber::fmt::try_init(); + + let base_rpc = MockServer::start().await; + let polygon_rpc = MockServer::start().await; + let webhook_server = MockServer::start().await; + + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_str().unwrap(); + + let config = test_config_multichain(db_path, base_rpc.uri(), polygon_rpc.uri()); + let base_cfg = test_chain_config_named("base", base_rpc.uri()); + let polygon_cfg = test_chain_config_named("polygon", polygon_rpc.uri()); + + let wallet = Wallet::new(config.mnemonic.clone()); + let db = Db::new(&config.database_url).unwrap(); + + let addr = wallet.derive_address(0).unwrap(); + let addr_str = addr.to_string(); + db.register_account("multichain_user", 0, &addr_str, &webhook_server.uri()) + .unwrap(); + + mount_deposit_sweep_rpc_mocks( + &base_rpc, + &addr_str, + &base_cfg.treasury_address, + SHARED_TX_HASH, + BASE_SWEEP_TX, + "0x2105", + ) + .await; + mount_deposit_sweep_rpc_mocks( + &polygon_rpc, + &addr_str, + &polygon_cfg.treasury_address, + SHARED_TX_HASH, + POLYGON_SWEEP_TX, + "0x89", + ) + .await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(4) + .mount(&webhook_server) + .await; + + let deliverer = test_webhook_deliverer(db.clone()); + spawn_webhook_worker(Arc::clone(&deliverer)); + spawn_chain_workers( + base_cfg.clone(), + Arc::clone(&deliverer), + config.faucet_mnemonic.clone(), + db.clone(), + wallet.clone(), + http_provider_boxed(&base_rpc.uri()), + ); + spawn_chain_workers( + polygon_cfg, + deliverer, + config.faucet_mnemonic, + db.clone(), + wallet, + http_provider_boxed(&polygon_rpc.uri()), + ); + + wait_until_detected(&db, "base").await; + wait_until_detected(&db, "polygon").await; + wait_until_swept(&db, "base").await; + wait_until_swept(&db, "polygon").await; +} + +#[tokio::test] +async fn test_e2e_one_chain_down_other_sweeps() { + let _ = tracing_subscriber::fmt::try_init(); + + let base_rpc = MockServer::start().await; + let dead_rpc = MockServer::start().await; + let webhook_server = MockServer::start().await; + + let db_file = NamedTempFile::new().unwrap(); + let db_path = db_file.path().to_str().unwrap(); + + let config = test_config_multichain(db_path, base_rpc.uri(), dead_rpc.uri()); + let base_cfg = test_chain_config_named("base", base_rpc.uri()); + let dead_cfg = test_chain_config_named("polygon", dead_rpc.uri()); + + let wallet = Wallet::new(config.mnemonic.clone()); + let db = Db::new(&config.database_url).unwrap(); + + let addr = wallet.derive_address(0).unwrap(); + let addr_str = addr.to_string(); + db.register_account("isolated_user", 0, &addr_str, &webhook_server.uri()) + .unwrap(); + + mount_deposit_sweep_rpc_mocks( + &base_rpc, + &addr_str, + &base_cfg.treasury_address, + SHARED_TX_HASH, + BASE_SWEEP_TX, + "0x2105", + ) + .await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(503).set_body_string("RPC unavailable")) + .mount(&dead_rpc) + .await; - // 5. Mock RPC Responses + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount(&webhook_server) + .await; + + let deliverer = test_webhook_deliverer(db.clone()); + spawn_webhook_worker(Arc::clone(&deliverer)); + spawn_chain_workers( + base_cfg.clone(), + Arc::clone(&deliverer), + config.faucet_mnemonic.clone(), + db.clone(), + wallet.clone(), + http_provider_boxed(&base_rpc.uri()), + ); + spawn_chain_workers( + dead_cfg, + deliverer, + config.faucet_mnemonic, + db.clone(), + wallet, + http_provider_boxed(&dead_rpc.uri()), + ); + + wait_until_detected(&db, "base").await; + wait_until_swept(&db, "base").await; + + for _ in 0..5 { + let polygon_deposits = db.get_detected_deposits("polygon").unwrap(); + assert!( + polygon_deposits.is_empty(), + "dead chain must not record deposits" + ); + sleep(Duration::from_millis(200)).await; + } +} + +fn spawn_webhook_worker(deliverer: Arc) { + let worker = WebhookRetryService::new(deliverer); + tokio::spawn(async move { + worker.run().await; + }); +} + +fn spawn_chain_workers( + chain_cfg: crate::config::ChainConfig, + deliverer: Arc, + faucet_mnemonic: String, + db: Db, + wallet: Wallet, + provider: alloy::providers::RootProvider, +) { + let monitor = Monitor::new( + chain_cfg.clone(), + Arc::clone(&deliverer), + db.clone(), + provider.clone(), + ); + let faucet = Arc::new( + Faucet::new( + faucet_mnemonic, + provider.clone(), + &chain_cfg.existential_deposit, + ) + .unwrap(), + ); + let sweeper = Sweeper::new(chain_cfg, deliverer, db, wallet, provider, faucet); + + tokio::spawn(async move { + monitor.run().await; + }); + tokio::spawn(async move { + sweeper.run().await; + }); +} + +async fn wait_until_detected(db: &Db, chain: &str) { + let mut detected = false; + for _ in 0..20 { + let deposits = db.get_detected_deposits(chain).unwrap(); + if !deposits.is_empty() { + detected = true; + break; + } + sleep(Duration::from_millis(500)).await; + } + assert!(detected, "Deposit should be detected on {chain}"); +} + +async fn wait_until_swept(db: &Db, chain: &str) { + let mut swept = false; + for _ in 0..20 { + let deposits = db.get_detected_deposits(chain).unwrap(); + if deposits.is_empty() { + swept = true; + break; + } + sleep(Duration::from_millis(500)).await; + } + assert!(swept, "Deposit should be swept on {chain}"); +} - // eth_blockNumber (Start at 10, increment) +async fn mount_deposit_sweep_rpc_mocks( + rpc_server: &MockServer, + deposit_addr: &str, + treasury: &str, + tx_hash: &str, + sweep_tx_hash: &str, + chain_id_hex: &str, +) { Mock::given(method("POST")) .and(body_json_contains("eth_blockNumber")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, - "result": "0xA" // 10 + "result": BLOCK_NUMBER_HEX }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_getBlockByNumber (Block 10 with deposit for User 1) - let block_hash = "0x000000000000000000000000000000000000000000000000000000000000000a"; - let parent_hash = "0x0000000000000000000000000000000000000000000000000000000000000009"; - let tx_hash = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let root_hash = "0x0000000000000000000000000000000000000000000000000000000000000000"; - - let block_10_response = json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { - "number": "0xA", - "hash": block_hash, - "parentHash": parent_hash, - "nonce": "0x0000000000000000", - "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "transactionsRoot": root_hash, - "stateRoot": root_hash, - "receiptsRoot": root_hash, - "miner": "0x0000000000000000000000000000000000000000", - "difficulty": "0x0", - "totalDifficulty": "0x0", - "extraData": "0x", - "size": "0x0", - "gasLimit": "0x0", - "gasUsed": "0x0", - "timestamp": "0x0", - "transactions": [ - { - "hash": tx_hash, - "nonce": "0x0", - "blockHash": block_hash, - "blockNumber": "0xA", - "transactionIndex": "0x0", - "from": "0x0000000000000000000000000000000000000000", - "to": addr1_str, // Use the derived address string - "value": "0xDE0B6B3A7640000", // 1 ETH - "gas": "0x5208", // 21000 - "gasPrice": "0x3B9ACA00", - "input": "0x", - "v": "0x1b", - "r": "0x1", - "s": "0x1", - "type": "0x0", - "chainId": "0x1" - } - ], - "uncles": [] - } - }); + let block_response = block_with_deposit(deposit_addr, tx_hash); Mock::given(method("POST")) .and(body_json_contains("eth_getBlockByNumber")) - .respond_with(ResponseTemplate::new(200).set_body_json(block_10_response)) - .mount(&rpc_server) + .respond_with(ResponseTemplate::new(200).set_body_json(block_response)) + .mount(rpc_server) .await; - // eth_getBalance (Return 1 ETH for User 1) Mock::given(method("POST")) .and(body_json_contains("eth_getBalance")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, - "result": "0x0DE0B6B3A7640000" // 1 ETH (padded to even length) + "result": "0x0DE0B6B3A7640000" }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_gasPrice Mock::given(method("POST")) .and(body_json_contains("eth_gasPrice")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, - "result": "0x3B9ACA00" // 1 Gwei + "result": "0x3B9ACA00" }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_feeHistory (for EIP-1559 fee estimation) Mock::given(method("POST")) .and(body_json_contains("eth_feeHistory")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, "result": { - "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], // 1 Gwei + "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], "gasUsedRatio": [0.5], "oldestBlock": "0x9", - "reward": [["0x3B9ACA00"]] // 1 Gwei priority fee + "reward": [["0x3B9ACA00"]] } }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_maxPriorityFeePerGas (fallback for EIP-1559) Mock::given(method("POST")) .and(body_json_contains("eth_maxPriorityFeePerGas")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, - "result": "0x3B9ACA00" // 1 Gwei + "result": "0x3B9ACA00" }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_getTransactionCount (Nonce) Mock::given(method("POST")) .and(body_json_contains("eth_getTransactionCount")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -189,11 +380,9 @@ async fn test_e2e_deposit_sweep_flow() { "id": 1, "result": "0x00" }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_sendRawTransaction (Sweep) - let sweep_tx_hash = "0x0000000000000000000000000000000000000000000000000000000000000002"; Mock::given(method("POST")) .and(body_json_contains("eth_sendRawTransaction")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -201,47 +390,44 @@ async fn test_e2e_deposit_sweep_flow() { "id": 1, "result": sweep_tx_hash }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_getTransactionReceipt (Confirm sweep) Mock::given(method("POST")) - .and(body_json_contains("eth_getTransactionReceipt")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "jsonrpc": "2.0", - "id": 1, - "result": { - "transactionHash": sweep_tx_hash, - "transactionIndex": "0x1", - "blockHash": block_hash, - "blockNumber": "0xB", // Next block - "from": addr1_str, - "to": config.treasury_address, - "cumulativeGasUsed": "0x5208", - "gasUsed": "0x5208", - "contractAddress": null, - "logs": [], - "status": "0x1", - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "type": "0x0", - "effectiveGasPrice": "0x3B9ACA00" - } - }))) - .mount(&rpc_server) - .await; + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "transactionHash": sweep_tx_hash, + "transactionIndex": "0x1", + "blockHash": BLOCK_HASH, + "blockNumber": "0xB", + "from": deposit_addr, + "to": treasury, + "cumulativeGasUsed": "0x5208", + "gasUsed": "0x5208", + "contractAddress": null, + "logs": [], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "effectiveGasPrice": "0x3B9ACA00" + } + }))) + .mount(rpc_server) + .await; - // eth_chainId Mock::given(method("POST")) .and(body_json_contains("eth_chainId")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": 1, - "result": "0x89" // Polygon 137 + "result": chain_id_hex }))) - .mount(&rpc_server) + .mount(rpc_server) .await; - // eth_getLogs (for ERC20 Transfer events - return empty array) Mock::given(method("POST")) .and(body_json_contains("eth_getLogs")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -249,82 +435,57 @@ async fn test_e2e_deposit_sweep_flow() { "id": 1, "result": [] }))) - .mount(&rpc_server) - .await; - - // Webhook Expectation (exactly 2 calls: 1 deposit_detected + 1 deposit_swept) - // Now that we have duplicate detection, we should only get exactly 2 webhook calls - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200)) - .expect(2) - .mount(&webhook_server) + .mount(rpc_server) .await; +} - // 5. Run Monitor & Sweeper - let monitor = Monitor::new(config.clone(), db.clone(), provider.clone()); - let faucet = Arc::new( - Faucet::new( - config.faucet_mnemonic.clone(), - provider.clone(), - &config.existential_deposit, - ) - .unwrap(), - ); - let sweeper = Sweeper::new( - config.clone(), - db.clone(), - wallet.clone(), - provider.clone(), - faucet, - ); - - // Run monitor once (manually or spawn short lived) - // We can't easily "run once" with the loop, but we can spawn and wait a bit. - // For testability, it's better if Monitor/Sweeper had a `run_once` method, but we can just let them run. - - let _monitor_handle = tokio::spawn(async move { - monitor.run().await; - }); - - let _sweeper_handle = tokio::spawn(async move { - sweeper.run().await; - }); - - // 6. Wait and Verify - // Wait for deposit detection - let mut detected = false; - for _ in 0..10 { - let deposits = db.get_detected_deposits().unwrap(); - if !deposits.is_empty() { - detected = true; - break; - } - sleep(Duration::from_millis(500)).await; - } - assert!(detected, "Deposit should be detected"); - - // Wait for sweep (status change in DB) - let mut swept = false; - for _ in 0..10 { - // We don't have a direct "get_swept_deposits" but we can check if detected list is empty - // assuming we only had one. Or check DB directly if we exposed a method. - // Let's check if detected becomes empty. - let deposits = db.get_detected_deposits().unwrap(); - if deposits.is_empty() { - swept = true; - break; +fn block_with_deposit(deposit_addr: &str, tx_hash: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "number": BLOCK_NUMBER_HEX, + "hash": BLOCK_HASH, + "parentHash": PARENT_HASH, + "nonce": "0x0000000000000000", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": ROOT_HASH, + "stateRoot": ROOT_HASH, + "receiptsRoot": ROOT_HASH, + "miner": "0x0000000000000000000000000000000000000000", + "difficulty": "0x0", + "totalDifficulty": "0x0", + "extraData": "0x", + "size": "0x0", + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "transactions": [ + { + "hash": tx_hash, + "nonce": "0x0", + "blockHash": BLOCK_HASH, + "blockNumber": BLOCK_NUMBER_HEX, + "transactionIndex": "0x0", + "from": "0x0000000000000000000000000000000000000000", + "to": deposit_addr, + "value": "0xDE0B6B3A7640000", + "gas": "0x5208", + "gasPrice": "0x3B9ACA00", + "input": "0x", + "v": "0x1b", + "r": "0x1", + "s": "0x1", + "type": "0x0", + "chainId": "0x1" + } + ], + "uncles": [] } - sleep(Duration::from_millis(500)).await; - } - assert!(swept, "Deposit should be swept"); - - // Verify Webhook (Wiremock expectation) - // The expectation is checked on Drop or manually. - // Since we are in a test, we can just wait a bit for the async call to finish. - sleep(Duration::from_millis(500)).await; + }) } -// Helper matcher fn body_json_contains(substring: &str) -> impl wiremock::Match { BodyContains(substring.to_string()) } diff --git a/src/faucet.rs b/src/faucet.rs index 74a0fb5..861a2ce 100644 --- a/src/faucet.rs +++ b/src/faucet.rs @@ -1,38 +1,63 @@ -use alloy::network::TransactionBuilder; +use alloy::network::{Ethereum, EthereumWallet, TransactionBuilder}; use alloy::primitives::{Address, U256}; -use alloy::providers::Provider; +use alloy::providers::fillers::{FillProvider, JoinFill, RecommendedFiller, WalletFiller}; +use alloy::providers::{Provider, ProviderBuilder, RootProvider}; use alloy::rpc::types::TransactionRequest; +use alloy::transports::{BoxTransport, Transport}; use anyhow::Result; use std::str::FromStr; +use tokio::sync::RwLock; use tracing::{error, info}; use crate::wallet::Wallet; -pub struct Faucet

{ - wallet: Wallet, - provider: P, +type FaucetProvider = FillProvider< + JoinFill>, + RootProvider, + BoxTransport, + Ethereum, +>; + +pub struct Faucet { + root: RootProvider, + wallet: EthereumWallet, + faucet_address: Address, existential_deposit: U256, + provider: RwLock, } -impl Faucet> -where - T: alloy::transports::Transport + Clone, -{ - pub fn new( +impl Faucet { + pub fn new( faucet_mnemonic: String, - provider: alloy::providers::RootProvider, + provider: RootProvider, existential_deposit_str: &str, ) -> Result { - let wallet = Wallet::new(faucet_mnemonic); + let signer = Wallet::new(faucet_mnemonic).get_signer(0)?; + let faucet_address = signer.address(); + let wallet = EthereumWallet::from(signer); + let root = provider.boxed(); let existential_deposit = U256::from_str(existential_deposit_str)?; + let provider = RwLock::new(Self::build_provider(&root, &wallet)); Ok(Self { + root, wallet, - provider, + faucet_address, existential_deposit, + provider, }) } + fn build_provider( + root: &RootProvider, + wallet: &EthereumWallet, + ) -> FaucetProvider { + ProviderBuilder::new() + .with_recommended_fillers() + .wallet(wallet.clone()) + .on_provider(root.clone()) + } + /// Send existential deposit to a newly created address pub async fn fund_new_address(&self, to_address: &str) -> Result { let to = Address::from_str(to_address)?; @@ -41,15 +66,9 @@ where "Funding new address {} with {} wei", to_address, self.existential_deposit ); + info!("Faucet address: {}", self.faucet_address); - // Get the faucet signer (using index 0 from the faucet mnemonic) - let signer = self.wallet.get_signer(0)?; - let faucet_address = signer.address(); - - info!("Faucet address: {}", faucet_address); - - // Check faucet balance - let balance = self.provider.get_balance(faucet_address).await?; + let balance = self.root.get_balance(self.faucet_address).await?; if balance < self.existential_deposit { error!( "Faucet has insufficient balance: {} < {}", @@ -60,20 +79,24 @@ where )); } - // Create a provider with the faucet wallet - let wallet = alloy::network::EthereumWallet::from(signer); - let faucet_provider = alloy::providers::ProviderBuilder::new() - .with_recommended_fillers() - .wallet(wallet) - .on_provider(&self.provider); - - // Build and send transaction let tx = TransactionRequest::default() + .with_from(self.faucet_address) .with_to(to) .with_value(self.existential_deposit); - let pending_tx = faucet_provider.send_transaction(tx).await?; - let receipt = pending_tx.get_receipt().await?; + let receipt = { + let provider = self.provider.read().await; + let pending = match provider.send_transaction(tx).await { + Ok(p) => p, + Err(e) => { + drop(provider); + error!("Faucet send failed, resetting nonce cache: {e}"); + *self.provider.write().await = Self::build_provider(&self.root, &self.wallet); + return Err(e.into()); + } + }; + pending.get_receipt().await? + }; let tx_hash = receipt.transaction_hash.to_string(); info!( @@ -88,9 +111,7 @@ where #[allow(dead_code)] pub async fn needs_funding(&self, address: &str) -> Result { let addr = Address::from_str(address)?; - let balance = self.provider.get_balance(addr).await?; - - // If balance is less than existential deposit, it needs funding + let balance = self.root.get_balance(addr).await?; Ok(balance < self.existential_deposit) } } diff --git a/src/lib.rs b/src/lib.rs index d886e85..658915c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,18 +3,23 @@ pub mod config; pub mod db; pub(crate) mod faucet; mod monitor; +pub mod redb_import; +pub mod redb_store; mod sweeper; pub mod traits; mod wallet; +mod webhook; #[cfg(test)] mod e2e_tests; #[cfg(test)] +mod test_support; +#[cfg(test)] mod tests; -use alloy::providers::{ProviderBuilder, WsConnect}; -use alloy::transports::Transport; -use config::{Config, ProviderUrl}; +use alloy::providers::{ProviderBuilder, RootProvider}; +use alloy::transports::BoxTransport; +use config::{ChainConfig, Config}; use db::Db; use faucet::Faucet; use monitor::Monitor; @@ -23,6 +28,7 @@ use std::sync::Arc; use sweeper::Sweeper; use traits::Service; use wallet::Wallet; +use webhook::{WebhookDeliverer, WebhookRetryService}; /// Request structure for registering a new account #[derive(Deserialize, Clone)] @@ -42,6 +48,8 @@ pub struct RegisterResponse { /// Request structure for verifying a transfer #[derive(Deserialize, Clone, Debug)] pub struct VerifyTransferRequest { + /// Chain name (e.g. "base", "polygon") + pub chain: String, /// Transaction hash to verify pub tx_hash: String, /// Expected recipient address @@ -49,7 +57,6 @@ pub struct VerifyTransferRequest { /// Expected amount (as string to handle large numbers) pub amount: String, /// Token type: "native" for ETH/native currency, or "erc20" for ERC20 tokens - /// Defaults to "native" if not specified #[serde(default = "default_token_type")] pub token_type: String, /// Token contract address (required for ERC20) @@ -68,76 +75,257 @@ fn default_token_type() -> String { #[derive(Serialize, Clone, Debug)] #[serde(tag = "status", rename_all = "lowercase")] pub enum VerifyTransferResponse { - /// Transfer was successfully verified Success { - /// Actual recipient address found in the transaction actual_to: String, - /// Actual amount found in the transaction actual_amount: String, - /// Token type ("native" or "erc20") token_type: String, - /// Token symbol (for ERC20) #[serde(skip_serializing_if = "Option::is_none")] token_symbol: Option, - /// Block number where the transaction was included #[serde(skip_serializing_if = "Option::is_none")] block_number: Option, }, - /// Transfer verification failed Error { - /// Error message describing why verification failed message: String, - /// Token type ("native" or "erc20") if known #[serde(skip_serializing_if = "Option::is_none")] token_type: Option, - /// Block number where the transaction was included (if found) #[serde(skip_serializing_if = "Option::is_none")] block_number: Option, }, } -/// Core Hot Wallet Service that manages background tasks and provides account registration -pub struct HotWalletService -where - T: Transport + Clone + Send + Sync + 'static, -{ +/// Request to re-queue a failed deposit for sweeping. +#[derive(Deserialize, Clone, Debug)] +pub struct RetrySweepRequest { + pub chain: String, + pub tx_hash: String, + /// Required for ERC20 deposits; omit for native deposits. + #[serde(default)] + pub log_index: Option, +} + +/// Response for a sweep retry request. +#[derive(Serialize, Clone, Debug)] +pub struct RetrySweepResponse { + pub retried: bool, + pub token_type: String, +} + +/// Request to re-queue a failed webhook delivery. +#[derive(Deserialize, Clone, Debug)] +pub struct RetryWebhookRequest { + pub id: String, + pub event: String, +} + +/// Response for a webhook retry request. +#[derive(Serialize, Clone, Debug)] +pub struct RetryWebhookResponse { + pub retried: bool, + pub status: String, +} + +/// Per-chain runtime context (provider + faucet). +pub struct ChainContext { + pub cfg: ChainConfig, + pub provider: RootProvider, + pub faucet: Arc, +} + +/// Core Hot Wallet Service that manages background tasks and provides account registration. +pub struct HotWalletService { config: Config, db: Db, wallet: Wallet, - faucet: Arc>>, - provider: alloy::providers::RootProvider, + chains: Vec, + webhook_deliverer: Arc, } -impl HotWalletService -where - T: Transport + Clone + Send + Sync + 'static, -{ - /// Get a reference to the database +impl HotWalletService { pub fn db(&self) -> &Db { &self.db } - /// Get a reference to the configuration pub fn config(&self) -> &Config { &self.config } - /// Health check method - returns Ok if service is healthy + pub fn chain_names(&self) -> Vec { + self.chains.iter().map(|c| c.cfg.name.clone()).collect() + } + pub async fn health(&self) -> anyhow::Result { - Ok("OK".to_string()) + if !self.db.writer_healthy() { + return Err(anyhow::anyhow!( + "database writer is not running; writes are impossible" + )); + } + let names: Vec<_> = self.chain_names(); + Ok(format!("OK (chains: {})", names.join(", "))) } - /// Set the last processed block number manually - pub fn set_block_number(&self, block_number: u64) -> anyhow::Result<()> { - self.db.set_last_processed_block(block_number) + /// Interactive-lane write routed through `Db::blocking` so this HTTP path + /// never blocks a Tokio worker thread on the write queue. + pub async fn set_block_number(&self, chain: &str, block_number: u64) -> anyhow::Result<()> { + if self.config.chain(chain).is_none() { + return Err(anyhow::anyhow!("Unknown chain: {chain}")); + } + let chain = chain.to_string(); + self.db + .blocking(move |db| db.set_last_processed_block_priority(&chain, block_number)) + .await } - /// Get the current last processed block number - pub fn get_block_number(&self) -> anyhow::Result { - self.db.get_last_processed_block() + pub fn get_block_number(&self, chain: &str) -> anyhow::Result { + if self.config.chain(chain).is_none() { + return Err(anyhow::anyhow!("Unknown chain: {chain}")); + } + self.db.get_last_processed_block(chain) + } + + /// Interactive-lane write routed through `Db::blocking` so this HTTP path + /// never blocks a Tokio worker thread on the write queue. + pub async fn retry_sweep( + &self, + request: RetrySweepRequest, + ) -> anyhow::Result { + if self.config.chain(&request.chain).is_none() { + return Err(anyhow::anyhow!("Unknown chain: {}", request.chain)); + } + + if let Some(log_index) = request.log_index { + let retried = self + .db + .blocking(move |db| { + db.retry_erc20_deposit(&request.chain, &request.tx_hash, log_index) + }) + .await?; + Ok(RetrySweepResponse { + retried, + token_type: "erc20".to_string(), + }) + } else { + let retried = self + .db + .blocking(move |db| db.retry_native_deposit(&request.chain, &request.tx_hash)) + .await?; + Ok(RetrySweepResponse { + retried, + token_type: "native".to_string(), + }) + } + } + + /// Interactive-lane write routed through `Db::blocking` so this HTTP path + /// never blocks a Tokio worker thread on the write queue. + pub async fn retry_webhook( + &self, + request: RetryWebhookRequest, + ) -> anyhow::Result { + let id = request.id.clone(); + let event = request.event.clone(); + let retried = self + .db + .blocking(move |db| db.retry_webhook_delivery(&id, &event)) + .await?; + if !retried { + return Err(anyhow::anyhow!( + "No failed webhook delivery found for id={} event={}", + request.id, + request.event + )); + } + self.webhook_deliverer.notify_worker(); + Ok(RetryWebhookResponse { + retried: true, + status: "pending".to_string(), + }) + } + + pub async fn new(config: Config) -> anyhow::Result { + let db = Db::with_pool_size(&config.database_url, config.db_read_pool_size)?; + let wallet = Wallet::new(config.mnemonic.clone()); + + let mut chains = Vec::with_capacity(config.chains.len()); + for chain_cfg in &config.chains { + let provider = ProviderBuilder::new() + .on_builtin(&chain_cfg.rpc_url) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to connect to chain '{}' at {}: {e}", + chain_cfg.name, + chain_cfg.rpc_url + ) + })?; + + let faucet = Arc::new(Faucet::new( + config.faucet_mnemonic.clone(), + provider.clone(), + &chain_cfg.existential_deposit, + )?); + + chains.push(ChainContext { + cfg: chain_cfg.clone(), + provider, + faucet, + }); + } + + let webhook_deliverer = Arc::new(WebhookDeliverer::new(db.clone(), &config)?); + + Ok(Self { + config, + db, + wallet, + chains, + webhook_deliverer, + }) + } + + pub async fn start_background_services(&self) -> anyhow::Result<()> { + let webhook_worker = WebhookRetryService::new(Arc::clone(&self.webhook_deliverer)); + tokio::spawn(async move { + tracing::info!("Starting Webhook retry worker"); + webhook_worker.run().await; + }); + + for ctx in &self.chains { + let chain_name = ctx.cfg.name.clone(); + let monitor = Monitor::new( + ctx.cfg.clone(), + Arc::clone(&self.webhook_deliverer), + self.db.clone(), + ctx.provider.clone(), + ); + tokio::spawn(async move { + tracing::info!("[{chain_name}] Starting Monitor"); + monitor.run().await; + }); + + let sweeper = Sweeper::new( + ctx.cfg.clone(), + Arc::clone(&self.webhook_deliverer), + self.db.clone(), + self.wallet.clone(), + ctx.provider.clone(), + Arc::clone(&ctx.faucet), + ); + let chain_name = ctx.cfg.name.clone(); + tokio::spawn(async move { + tracing::info!("[{chain_name}] Starting Sweeper"); + sweeper.run().await; + }); + } + Ok(()) + } + + fn chain_context(&self, name: &str) -> anyhow::Result<&ChainContext> { + self.chains + .iter() + .find(|c| c.cfg.name == name) + .ok_or_else(|| anyhow::anyhow!("Unknown chain: {name}")) } - /// Verify if a transaction contains a transfer matching the expected criteria pub async fn verify_transfer( &self, request: VerifyTransferRequest, @@ -148,27 +336,24 @@ where info!("Verifying transfer: {:?}", request); - // Parse the transaction hash + let ctx = self.chain_context(&request.chain)?; + let tx_hash: FixedBytes<32> = request .tx_hash .parse() .map_err(|_| anyhow::anyhow!("Invalid transaction hash format"))?; - // Parse expected values let expected_to = Address::from_str(&request.to_address) .map_err(|_| anyhow::anyhow!("Invalid to_address format"))?; let expected_amount = U256::from_str(&request.amount) .map_err(|_| anyhow::anyhow!("Invalid amount format"))?; - // Determine if this is a native or ERC20 transfer based on token_type let is_native = request.token_type.to_lowercase() == "native"; if is_native { - // Verify native ETH transfer - self.verify_native_transfer(tx_hash, expected_to, expected_amount) + self.verify_native_transfer(&ctx.provider, tx_hash, expected_to, expected_amount) .await } else { - // Verify ERC20 transfer - token_address is required let token_address_str = request .token_address .as_ref() @@ -178,6 +363,7 @@ where .map_err(|_| anyhow::anyhow!("Invalid token_address format"))?; self.verify_erc20_transfer( + &ctx.provider, tx_hash, expected_to, expected_amount, @@ -190,27 +376,21 @@ where async fn verify_native_transfer( &self, + provider: &RootProvider, tx_hash: alloy::primitives::FixedBytes<32>, expected_to: alloy::primitives::Address, expected_amount: alloy::primitives::U256, ) -> anyhow::Result { use alloy::providers::Provider; - use tracing::info; - // Fetch the transaction - let tx = self - .provider + let tx = provider .get_transaction_by_hash(tx_hash) .await? .ok_or_else(|| anyhow::anyhow!("Transaction not found"))?; - info!("Found transaction: {:?}", tx.hash); - - // Get block number from transaction receipt for confirmation - let receipt = self.provider.get_transaction_receipt(tx_hash).await?; + let receipt = provider.get_transaction_receipt(tx_hash).await?; let block_number = receipt.as_ref().and_then(|r| r.block_number); - // Check if transaction was successful if let Some(ref r) = receipt { if !r.status() { return Ok(VerifyTransferResponse::Error { @@ -221,7 +401,6 @@ where } } - // For native transfers, check the `to` field and `value` field let actual_to = tx.to; let actual_amount = tx.value; @@ -255,6 +434,7 @@ where async fn verify_erc20_transfer( &self, + provider: &RootProvider, tx_hash: alloy::primitives::FixedBytes<32>, expected_to: alloy::primitives::Address, expected_amount: alloy::primitives::U256, @@ -263,18 +443,14 @@ where ) -> anyhow::Result { use alloy::primitives::{Address, FixedBytes, U256}; use alloy::providers::Provider; - use tracing::info; - // Fetch the transaction receipt to get logs - let receipt = self - .provider + let receipt = provider .get_transaction_receipt(tx_hash) .await? .ok_or_else(|| anyhow::anyhow!("Transaction receipt not found"))?; let block_number = receipt.block_number; - // Check if transaction was successful if !receipt.status() { return Ok(VerifyTransferResponse::Error { message: "Transaction failed (reverted)".to_string(), @@ -283,10 +459,8 @@ where }); } - // Fetch token symbol from chain if we need to validate it - let actual_symbol = self.fetch_token_symbol(token_address).await.ok(); + let actual_symbol = self.fetch_token_symbol(provider, token_address).await.ok(); - // Validate token symbol if provided if let Some(expected) = expected_symbol { if let Some(ref actual) = actual_symbol { if !actual.eq_ignore_ascii_case(expected) { @@ -302,38 +476,24 @@ where } } - // ERC20 Transfer event signature: Transfer(address,address,uint256) let transfer_signature: FixedBytes<32> = alloy::primitives::keccak256("Transfer(address,address,uint256)".as_bytes()); - // Look for Transfer events from the specified token for log in receipt.inner.logs() { - // Check if this is from the expected token contract if log.address() != token_address { continue; } - - // Check if this is a Transfer event if log.topics().len() < 3 || log.topics()[0] != transfer_signature { continue; } - // Decode Transfer event: topic[1] = from, topic[2] = to let to_address = Address::from_slice(&log.topics()[2].as_slice()[12..]); - - // Decode amount from data let amount = if !log.data().data.is_empty() { U256::from_be_slice(&log.data().data) } else { U256::ZERO }; - info!( - "Found ERC20 Transfer: to={}, amount={}, symbol={:?}", - to_address, amount, actual_symbol - ); - - // Check if this transfer matches our criteria let to_matches = to_address .to_string() .eq_ignore_ascii_case(&expected_to.to_string()); @@ -350,7 +510,6 @@ where } } - // No matching transfer found Ok(VerifyTransferResponse::Error { message: format!( "No matching ERC20 Transfer event found to {} with amount >= {}", @@ -361,9 +520,9 @@ where }) } - /// Fetch token symbol from the blockchain async fn fetch_token_symbol( &self, + provider: &RootProvider, token_address: alloy::primitives::Address, ) -> anyhow::Result { use alloy::sol; @@ -375,22 +534,28 @@ where } } - let contract = IERC20Symbol::new(token_address, &self.provider); + let contract = IERC20Symbol::new(token_address, provider); let symbol = contract.symbol().call().await?._0; Ok(symbol) } - /// Register a new account with the hot wallet service - /// Returns the derived address and optionally a funding transaction hash + /// Register a new account. Address derivation is chain-agnostic; no faucet funding at registration. + /// + /// The derivation index is allocated from a persisted sequential counter + /// inside a single atomic writer command (P0 collision fix, replacing the + /// old `DefaultHasher`-derived index that had birthday collisions at + /// ~46k accounts and was unstable across Rust versions). The read-side + /// existing-account check below is only a fast path; the authoritative + /// check happens again inside the write transaction, so a re-register + /// race can never allocate a second index for the same id. pub async fn register(&self, request: RegisterRequest) -> anyhow::Result { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - use tracing::{error, info}; - - // Check if account already exists - if let Ok(Some((_index, existing_address, _webhook))) = - self.db.get_account_by_id(&request.id) - { + use tracing::info; + + let existing = { + let id = request.id.clone(); + self.db.blocking(move |db| db.get_account_by_id(&id)).await + }; + if let Ok(Some((_index, existing_address, _webhook))) = existing { info!( "Account {} already exists with address {}", request.id, existing_address @@ -401,310 +566,34 @@ where }); } - // Derive deterministic index from account_id using hash - let mut hasher = DefaultHasher::new(); - request.id.hash(&mut hasher); - let hash = hasher.finish(); - let index = (hash & 0x7FFFFFFF) as u32; - - // Derive address from the deterministic index - let address = self.wallet.derive_address(index)?; - let address_str = address.to_string(); - - // Save to DB with webhook URL - self.db - .register_account(&request.id, index, &address_str, &request.webhook_url)?; - - info!( - "Registered account {} with address {} (index: {})", - request.id, address_str, index - ); - - // Fire-and-forget: Fund the new address with existential deposit in the background - let faucet = Arc::clone(&self.faucet); - let db = self.db.clone(); - let account_id = request.id.clone(); - let address_for_funding = address_str.clone(); - let webhook_jwt_token = self.config.webhook_jwt_token.clone(); + let (index, address_str, created) = { + let id = request.id.clone(); + let webhook_url = request.webhook_url.clone(); + let wallet = self.wallet.clone(); + self.db + .blocking(move |db| { + db.register_account_auto(&id, &webhook_url, move |index| { + Ok(wallet.derive_address(index)?.to_string()) + }) + }) + .await? + }; - tokio::spawn(async move { + if created { info!( - "Background task: Starting faucet funding for address {}", - address_for_funding + "Registered account {} with address {} (index: {})", + request.id, address_str, index ); - - match faucet.fund_new_address(&address_for_funding).await { - Ok(tx_hash) => { - info!( - "Successfully funded address {} with tx: {}", - address_for_funding, tx_hash - ); - - // Send webhook notification for successful funding - if let Err(e) = send_faucet_funding_webhook( - &db, - &account_id, - &address_for_funding, - &tx_hash, - true, - None, - webhook_jwt_token.as_deref(), - ) - .await - { - error!( - "Failed to send faucet funding webhook for {}: {:?}", - account_id, e - ); - } - } - Err(e) => { - error!("Failed to fund address {}: {:?}", address_for_funding, e); - - // Send webhook notification for failed funding - if let Err(webhook_err) = send_faucet_funding_webhook( - &db, - &account_id, - &address_for_funding, - "", - false, - Some(&e.to_string()), - webhook_jwt_token.as_deref(), - ) - .await - { - error!( - "Failed to send faucet funding error webhook for {}: {:?}", - account_id, webhook_err - ); - } - } - } - }); + } else { + info!( + "Account {} already exists with address {}", + request.id, address_str + ); + } Ok(RegisterResponse { address: address_str, - funding_tx: None, // No longer waiting for funding - it's fire-and-forget - }) - } -} - -// HTTP Provider implementation -impl HotWalletService> { - /// Create a new HotWalletService with HTTP provider from configuration - pub async fn new_http(config: Config) -> anyhow::Result { - let db = Db::new(&config.database_url)?; - let wallet = Wallet::new(config.mnemonic.clone()); - - let url = match &config.provider_url { - ProviderUrl::Http(url) => url, - _ => return Err(anyhow::anyhow!("Expected HTTP provider URL")), - }; - - let provider = ProviderBuilder::new().on_http(url.parse()?); - let faucet = Faucet::new( - config.faucet_mnemonic.clone(), - provider.clone(), - &config.existential_deposit, - )?; - - Ok(Self { - config, - db, - wallet, - faucet: Arc::new(faucet), - provider, - }) - } - - /// Start background services (Monitor and Sweeper) for HTTP provider - /// Returns immediately after spawning the background tasks - pub async fn start_background_services(&self) -> anyhow::Result<()> { - let url = match &self.config.provider_url { - ProviderUrl::Http(url) => url, - _ => return Err(anyhow::anyhow!("Expected HTTP provider URL")), - }; - - let provider = ProviderBuilder::new().on_http(url.parse()?); - - // Spawn Monitor - tokio::spawn({ - let config = self.config.clone(); - let db = self.db.clone(); - let provider = provider.clone(); - - async move { - tracing::info!("Starting Monitor in Polling mode"); - Monitor::new(config, db, provider).run().await; - } - }); - - // Create faucet for sweeper - let sweeper_faucet = Arc::new(Faucet::new( - self.config.faucet_mnemonic.clone(), - provider.clone(), - &self.config.existential_deposit, - )?); - - // Spawn Sweeper - tokio::spawn({ - let config = self.config.clone(); - let db = self.db.clone(); - let wallet = self.wallet.clone(); - let provider = provider.clone(); - let faucet = sweeper_faucet; - async move { - tracing::info!("Starting Sweeper in Polling mode"); - Sweeper::new(config, db, wallet, provider, faucet) - .run() - .await; - } - }); - - Ok(()) - } -} - -// WebSocket Provider implementation -impl HotWalletService { - /// Create a new HotWalletService with WebSocket provider from configuration - pub async fn new_ws(config: Config) -> anyhow::Result { - let db = Db::new(&config.database_url)?; - let wallet = Wallet::new(config.mnemonic.clone()); - - let url = match &config.provider_url { - ProviderUrl::Ws(url) => url, - _ => return Err(anyhow::anyhow!("Expected WebSocket provider URL")), - }; - - let provider = ProviderBuilder::new().on_ws(WsConnect::new(url)).await?; - let faucet = Faucet::new( - config.faucet_mnemonic.clone(), - provider.clone(), - &config.existential_deposit, - )?; - - Ok(Self { - config, - db, - wallet, - faucet: Arc::new(faucet), - provider, + funding_tx: None, }) } - - /// Start background services (Monitor and Sweeper) for WebSocket provider - /// Returns immediately after spawning the background tasks - pub async fn start_background_services(&self) -> anyhow::Result<()> { - let url = match &self.config.provider_url { - ProviderUrl::Ws(url) => url, - _ => return Err(anyhow::anyhow!("Expected WebSocket provider URL")), - }; - - let provider = ProviderBuilder::new().on_ws(WsConnect::new(url)).await?; - - // Spawn Monitor - tokio::spawn({ - let config = self.config.clone(); - let db = self.db.clone(); - let provider = provider.clone(); - async move { - tracing::info!("Starting Monitor in Streaming mode"); - Monitor::new(config, db, provider).run().await; - } - }); - - // Create faucet for sweeper - let sweeper_faucet = Arc::new(Faucet::new( - self.config.faucet_mnemonic.clone(), - provider.clone(), - &self.config.existential_deposit, - )?); - - // Spawn Sweeper - tokio::spawn({ - let config = self.config.clone(); - let db = self.db.clone(); - let wallet = self.wallet.clone(); - let provider = provider.clone(); - let faucet = sweeper_faucet; - async move { - tracing::info!("Starting Sweeper in Streaming mode"); - Sweeper::new(config, db, wallet, provider, faucet) - .run() - .await; - } - }); - - Ok(()) - } -} - -/// Send webhook notification for faucet funding event -/// registration_id: The original id used when registering the account -/// address: The Polygon address (account_id in webhook) -/// jwt_token: Optional JWT token for authorization header -async fn send_faucet_funding_webhook( - db: &Db, - registration_id: &str, - address: &str, - tx_hash: &str, - success: bool, - error_message: Option<&str>, - jwt_token: Option<&str>, -) -> anyhow::Result<()> { - use tracing::{error, info}; - - // Get the webhook URL using registration_id (the key in ACCOUNTS table) - let Some(webhook_url) = db.get_webhook_url(registration_id)? else { - error!( - "No webhook URL found for registration_id: {}", - registration_id - ); - return Ok(()); - }; - - let client = reqwest::Client::new(); - - let mut payload = serde_json::json!({ - "event": "faucet_funding", - "account_id": address, - "registration_id": registration_id, - "success": success, - "id": format!("{}:funding", registration_id) - }); - - // Add tx_hash if funding was successful - if success && !tx_hash.is_empty() { - payload["tx_hash"] = serde_json::json!(tx_hash); - } - - // Add error message if funding failed - if let Some(error) = error_message { - payload["error"] = serde_json::json!(error); - } - - let mut request = client.post(&webhook_url).json(&payload); - - // Add JWT authorization header if provided - if let Some(token) = jwt_token { - request = request.header("Authorization", format!("Bearer {}", token)); - } - - let res = request.send().await; - - match res { - Ok(r) => info!( - "Faucet funding webhook sent to {}: status={}, registration_id={}", - webhook_url, - r.status(), - registration_id - ), - Err(e) => error!( - "Failed to send faucet funding webhook to {}: {:?}", - webhook_url, e - ), - } - - Ok(()) } diff --git a/src/main.rs b/src/main.rs index 9fca72e..3a71395 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,12 +4,6 @@ mod api; #[tokio::main] async fn main() -> anyhow::Result<()> { - // Enhanced logging configuration with support for RUST_LOG environment variable - // Examples: - // RUST_LOG=debug - all debug logs - // RUST_LOG=alloy=debug - alloy debug logs - // RUST_LOG=evm_hot_wallet=info,alloy=debug - app info, alloy debug - // RUST_LOG=evm_hot_wallet::rpc_debug=info - RPC request/response logs use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; tracing_subscriber::registry() @@ -19,61 +13,51 @@ async fn main() -> anyhow::Result<()> { let config = Config::from_env()?; - // Log configuration on startup - tracing::info!("πŸš€ Starting EVM Hot Wallet"); - tracing::info!("πŸ“Š Database: {}", config.database_url); - - match &config.provider_url { - evm_hot_wallet::config::ProviderUrl::Http(url) => { - tracing::info!("🌐 RPC Provider (HTTP): {}", url) - } - evm_hot_wallet::config::ProviderUrl::Ws(url) => { - tracing::info!("🌐 RPC Provider (WebSocket): {}", url) - } - } - tracing::info!("πŸ’° Treasury Address: {}", config.treasury_address); - tracing::info!("🚰 Faucet Address: {}", config.faucet_address); - tracing::info!("⚑ Existential Deposit: {} wei", config.existential_deposit); - tracing::info!("πŸ”„ Poll Interval: {} seconds", config.poll_interval); + tracing::info!("Starting EVM Hot Wallet (multi-chain)"); + tracing::info!("Database: {}", config.database_url); + tracing::info!("API Port: {}", config.port); tracing::info!( - "πŸ“¦ Block Offset from Head: {} blocks", - config.block_offset_from_head - ); - tracing::info!("🌐 API Port: {}", config.port); - tracing::info!( - "πŸ” Webhook JWT Auth: {}", + "Webhook JWT Auth: {}", if config.webhook_jwt_token.is_some() { "Enabled" } else { "Disabled" } ); + tracing::info!( + "Webhook retries: max={}, delay_ms={}, poll_interval_s={}, batch_size={}, lease_s={}", + config.webhook_max_retries, + config.webhook_retry_delay_ms, + config.webhook_retry_poll_interval_secs, + config.webhook_retry_batch_size, + config.webhook_lease_seconds + ); - let port = config.port; - - match &config.provider_url { - evm_hot_wallet::config::ProviderUrl::Http(_) => { - // Create the service with HTTP provider - let service = HotWalletService::new_http(config).await?; - - // Start background services - service.start_background_services().await?; - - // Start API server (blocks forever) - api::start_server(service, port).await; + let faucet_address = config.derived_faucet_address()?; + tracing::info!("Faucet address: {}", faucet_address); + + for chain in &config.chains { + if !chain.faucet_address.eq_ignore_ascii_case(&faucet_address) { + tracing::warn!( + "Chain {}: chains.toml faucet_address ({}) does not match derived faucet ({})", + chain.name, + chain.faucet_address, + faucet_address + ); } + tracing::info!( + "Chain: {} (id={}) rpc={} treasury={}", + chain.name, + chain.chain_id, + chain.rpc_url, + chain.treasury_address + ); + } - evm_hot_wallet::config::ProviderUrl::Ws(_) => { - // Create the service with WebSocket provider - let service = HotWalletService::new_ws(config).await?; - - // Start background services - service.start_background_services().await?; - - // Start API server (blocks forever) - api::start_server(service, port).await; - } - }; + let port = config.port; + let service = HotWalletService::new(config).await?; + service.start_background_services().await?; + api::start_server(service, port).await; Ok(()) } diff --git a/src/monitor.rs b/src/monitor.rs index c8d7139..2f8ba5f 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -1,15 +1,28 @@ -use crate::{config::Config, db::Db}; +use crate::{config::ChainConfig, db::Db, webhook::WebhookDeliverer}; use alloy::primitives::Address; -use alloy::providers::Provider; -use alloy::rpc::types::BlockNumberOrTag; +use alloy::providers::{Provider, RootProvider}; +use alloy::rpc::types::{Block, BlockNumberOrTag, Filter, Log}; +use alloy::transports::BoxTransport; use anyhow::Result; -use tracing::{error, info, warn}; +use futures::future::BoxFuture; +use futures::StreamExt; +use std::str::FromStr; +use std::sync::Arc; +use tracing::{debug, error, info, warn}; + +/// Below this gap (in blocks) between last-processed and head, `catch_up` scans +/// block-by-block. Above it, it switches to the batched path (ranged `eth_getLogs` +/// plus concurrent block fetches) so a large backlog actually drains instead of +/// perpetually trailing a fast-moving chain. +const BATCH_CATCHUP_MIN_GAP: u64 = 10; /// Information about a detected deposit for webhook notification struct DepositInfo<'a> { id: &'a str, - account_id: &'a str, // Polygon address - registration_id: &'a str, // Original id used when registering + chain: &'a str, + chain_id: u64, + account_id: &'a str, + registration_id: &'a str, tx_hash: &'a str, amount: &'a str, token_type: &'a str, @@ -18,255 +31,481 @@ struct DepositInfo<'a> { token_decimals: Option, } -pub struct Monitor

{ - config: Config, +pub struct Monitor { + chain: ChainConfig, + deliverer: Arc, db: Db, - provider: P, + provider: RootProvider, + /// Allowlisted token contracts parsed as addresses, used to narrow + /// eth_getLogs to the tokens we actually sweep. Empty (tests only) means + /// no address filter is applied. + allowed_token_filter: Vec

, } -impl Monitor> -where - T: alloy::transports::Transport + Clone, -{ - pub fn new(config: Config, db: Db, provider: alloy::providers::RootProvider) -> Self { +impl Monitor { + pub fn new( + chain: ChainConfig, + deliverer: Arc, + db: Db, + provider: RootProvider, + ) -> Self { + let allowed_token_filter = chain + .allowed_token_addresses + .iter() + .filter_map(|a| Address::from_str(a).ok()) + .collect(); Self { - config, + chain, + deliverer, db, provider, + allowed_token_filter, } } async fn catch_up(&self) -> Result<()> { let latest_block = self.provider.get_block_number().await?; - - // Use saturating_sub to prevent underflow if block_offset_from_head > latest_block - let current_block = latest_block.saturating_sub(self.config.block_offset_from_head); - let last_processed = self.db.get_last_processed_block()?; + let current_block = latest_block.saturating_sub(self.chain.block_offset_from_head); + let chain_name = self.chain.name.clone(); + let last_processed = self + .db + .blocking(move |db| db.get_last_processed_block(&chain_name)) + .await?; let start_block = if last_processed == 0 { - current_block // Start from now if fresh + current_block } else { last_processed }; info!("--------------------------------"); - info!("Offset from head: {}", self.config.block_offset_from_head); - info!("Start block: {}", start_block); - info!("Current block: {}", current_block); - info!("Last processed block: {}", last_processed); + info!( + "[{}] Offset from head: {}", + self.chain.name, self.chain.block_offset_from_head + ); + info!("[{}] Start block: {}", self.chain.name, start_block); + info!("[{}] Current block: {}", self.chain.name, current_block); + info!( + "[{}] Last processed block: {}", + self.chain.name, last_processed + ); if start_block > current_block { return Ok(()); } - // Process max 10 blocks at a time to avoid rate limits - // Ensure we don't exceed the latest confirmed block - info!("--------------------------------"); info!( - "Processing blocks from {} to {}", - start_block, current_block + "[{}] Processing blocks from {} to {}", + self.chain.name, start_block, current_block ); - for block_num in start_block..=current_block { - self.process_single_block(block_num).await?; + let gap = current_block - start_block; + if gap > BATCH_CATCHUP_MIN_GAP && !self.allowed_token_filter.is_empty() { + self.catch_up_batch(start_block, current_block).await + } else { + for block_num in start_block..=current_block { + self.process_single_block(block_num).await?; + } + Ok(()) } + } + + /// Drains a large backlog in chunks of `catch_up_chunk_size` blocks: one + /// address-filtered ranged `eth_getLogs` call per chunk for ERC20 transfers + /// (bisecting on provider range/response-size errors), plus concurrent + /// `eth_getBlockByNumber` fetches for native transfers. Checkpoints + /// `last_processed_block` once per chunk, which is safe to replay because + /// deposit recording (and the webhook it triggers) is idempotent. + /// + /// Requires a non-empty `allowed_token_filter`: an unfiltered ranged + /// `eth_getLogs` across all Transfer events would be the same unbounded-response + /// problem this path exists to avoid. Callers gate on this before invoking. + async fn catch_up_batch(&self, start_block: u64, end_block: u64) -> Result<()> { + let chunk_size = self.chain.catch_up_chunk_size.max(1); + let mut chunk_start = start_block; + + while chunk_start <= end_block { + let chunk_end = chunk_start.saturating_add(chunk_size - 1).min(end_block); + info!( + "[{}] Catch-up chunk {}..={} ({} blocks remaining after this chunk)", + self.chain.name, + chunk_start, + chunk_end, + end_block.saturating_sub(chunk_end) + ); + + self.process_erc20_transfers_ranged(chunk_start, chunk_end) + .await?; + self.process_native_range_concurrent(chunk_start, chunk_end) + .await?; + + let chain_name = self.chain.name.clone(); + self.db + .blocking(move |db| db.set_last_processed_block(&chain_name, chunk_end)) + .await?; + chunk_start = chunk_end + 1; + } + + Ok(()) + } + /// Fetches and scans `[from_block, to_block]` for native deposits, up to + /// `block_fetch_concurrency` blocks in flight at once. Uses `buffered` (not + /// `buffer_unordered`) so results are collected in block order, keeping + /// per-chunk checkpointing straightforward. + async fn process_native_range_concurrent(&self, from_block: u64, to_block: u64) -> Result<()> { + let concurrency = self.chain.block_fetch_concurrency.max(1) as usize; + + let results: Vec> = futures::stream::iter(from_block..=to_block) + .map(|block_num| async move { + if let Some(block) = self + .provider + .get_block_by_number(BlockNumberOrTag::Number(block_num), true) + .await? + { + self.handle_native_txs(&block).await?; + } + // Deserializing a hydrated block is a synchronous, non-yielding + // CPU burst once the HTTP response body lands; yielding here + // gives the runtime a chance to schedule other tasks (other + // chains' monitors, the webhook retry worker) between blocks + // instead of one chunk's fetches monopolizing a worker thread. + tokio::task::yield_now().await; + Ok(()) + }) + .buffered(concurrency) + .collect() + .await; + + for result in results { + result?; + } Ok(()) } async fn process_single_block(&self, block_num: u64) -> Result<()> { - info!("πŸ” Processing block {}", block_num); + info!("[{}] Processing block {}", self.chain.name, block_num); if let Some(block) = self .provider .get_block_by_number(BlockNumberOrTag::Number(block_num), true) .await? { - // Process native ETH transfers - if let Some(txs) = block.transactions.as_transactions() { - for tx in txs { - if let Some(to) = tx.to { - let to_address_str = to.to_string(); - let from_address_str = tx.from.to_string(); - - // Skip deposits from the faucet address - if from_address_str.eq_ignore_ascii_case(&self.config.faucet_address) { - info!( - "Skipping deposit from faucet address: {:?}, Account: {}", - tx.hash, to_address_str - ); - continue; - } - - if let Some(registration_id) = - self.db.get_registration_id_by_address(&to_address_str)? - { - info!( - "Native ETH deposit detected! Tx: {:?}, Address: {}, Registration ID: {}", - tx.hash, to_address_str, registration_id - ); - - // Only send webhook if this is a new deposit (not a duplicate) - let tx_hash_str = tx.hash.to_string(); - let is_new_deposit = self.db.record_deposit( - &tx_hash_str, - ®istration_id, - &tx.value.to_string(), - )?; - - // Send webhook notification for deposit detection only if it's new - if is_new_deposit { - let amount_str = tx.value.to_string(); - let deposit_info = DepositInfo { - id: &tx_hash_str, - account_id: &to_address_str, - registration_id: ®istration_id, - tx_hash: &tx_hash_str, - amount: &amount_str, - token_type: "native", - token_symbol: None, - token_address: None, - token_decimals: None, - }; - if let Err(e) = - self.send_deposit_detected_webhook(&deposit_info).await - { - error!("Failed to send deposit detected webhook: {:?}", e); - } - } - } - } - } + self.handle_native_txs(&block).await?; + self.process_erc20_transfers(block_num).await?; + } + + let chain_name = self.chain.name.clone(); + self.db + .blocking(move |db| db.set_last_processed_block(&chain_name, block_num)) + .await?; + Ok(()) + } + + /// Scans a hydrated block's transactions for native deposits to registered + /// addresses. Shared by the per-block steady-state path and the batch + /// catch-up path's concurrent block fetches. + async fn handle_native_txs(&self, block: &Block) -> Result<()> { + let Some(txs) = block.transactions.as_transactions() else { + return Ok(()); + }; + + for tx in txs { + let Some(to) = tx.to else { + continue; + }; + let to_address_str = to.to_string(); + let from_address_str = tx.from.to_string(); + + if from_address_str.eq_ignore_ascii_case(&self.chain.faucet_address) { + info!( + "[{}] Skipping deposit from faucet: {:?}, Account: {}", + self.chain.name, tx.hash, to_address_str + ); + continue; } - // Process ERC20 Transfer events - self.process_erc20_transfers(block_num).await?; + let registration_id = { + let addr = to_address_str.clone(); + self.db + .blocking(move |db| db.get_registration_id_by_address(&addr)) + .await? + }; + let Some(registration_id) = registration_id else { + continue; + }; + + if tx.value < self.chain.min_deposits.native { + info!( + "[{}] Skipping native deposit below minimum: tx={:?}, amount={}, min={}", + self.chain.name, tx.hash, tx.value, self.chain.min_deposits.native + ); + continue; + } + + info!( + "[{}] Native deposit detected! Tx: {:?}, Address: {}, Registration ID: {}", + self.chain.name, tx.hash, to_address_str, registration_id + ); + + let tx_hash_str = tx.hash.to_string(); + let is_new_deposit = { + let chain_name = self.chain.name.clone(); + let tx_hash_str = tx_hash_str.clone(); + let registration_id = registration_id.clone(); + let value_str = tx.value.to_string(); + self.db + .blocking(move |db| { + db.record_deposit(&chain_name, &tx_hash_str, ®istration_id, &value_str) + }) + .await? + }; + + if is_new_deposit { + let amount_str = tx.value.to_string(); + let deposit_id = format!("{}:{}", self.chain.name, tx_hash_str); + let deposit_info = DepositInfo { + id: &deposit_id, + chain: &self.chain.name, + chain_id: self.chain.chain_id, + account_id: &to_address_str, + registration_id: ®istration_id, + tx_hash: &tx_hash_str, + amount: &amount_str, + token_type: "native", + token_symbol: None, + token_address: None, + token_decimals: None, + }; + if let Err(e) = self.send_deposit_detected_webhook(&deposit_info).await { + error!( + "[{}] Failed to send deposit detected webhook: {:?}", + self.chain.name, e + ); + } + } } - // info!("Processing D {}", block_num); - self.db.set_last_processed_block(block_num)?; Ok(()) } - async fn process_erc20_transfers(&self, block_num: u64) -> Result<()> { + /// Builds the ERC20 Transfer filter for `[from_block, to_block]`, narrowed to + /// `allowed_token_filter` when non-empty (empty means no filter β€” tests only, + /// see the field doc on `Monitor`). + fn erc20_transfer_filter(&self, from_block: u64, to_block: u64) -> Filter { use alloy::primitives::FixedBytes; - use alloy::rpc::types::Filter; - // ERC20 Transfer event signature: Transfer(address,address,uint256) let transfer_signature: FixedBytes<32> = alloy::primitives::keccak256("Transfer(address,address,uint256)".as_bytes()); - let filter = Filter::new() - .from_block(block_num) - .to_block(block_num) + let mut filter = Filter::new() + .from_block(from_block) + .to_block(to_block) .event_signature(transfer_signature); + if !self.allowed_token_filter.is_empty() { + filter = filter.address(self.allowed_token_filter.clone()); + } + + filter + } + + async fn process_erc20_transfers(&self, block_num: u64) -> Result<()> { + let filter = self.erc20_transfer_filter(block_num, block_num); + let logs = self .get_logs_with_retry( &filter, - self.config.get_logs_max_retries, - self.config.get_logs_delay_ms, + self.chain.get_logs_max_retries, + self.chain.get_logs_delay_ms, ) .await?; - for log in logs { - // Decode Transfer event: topic[0] = signature, topic[1] = from, topic[2] = to - if log.topics().len() >= 3 { - let token_address = log.address(); - let from_address = Address::from_slice(&log.topics()[1].as_slice()[12..]); // Last 20 bytes of topic[1] - let to_address = Address::from_slice(&log.topics()[2].as_slice()[12..]); // Last 20 bytes of topic[2] - - let from_address_str = from_address.to_string(); - let to_address_str = to_address.to_string(); - - // Skip deposits from the faucet address - if from_address_str.eq_ignore_ascii_case(&self.config.faucet_address) { - info!( - "Skipping ERC20 deposit from faucet address: Token: {}, To: {}", - token_address, to_address_str + for log in &logs { + self.handle_erc20_log(log).await?; + } + + Ok(()) + } + + /// Fetches ERC20 Transfer logs for `[from_block, to_block]` in one ranged + /// `eth_getLogs` call. Used by the batch catch-up path. + async fn process_erc20_transfers_ranged(&self, from_block: u64, to_block: u64) -> Result<()> { + let logs = self.get_logs_ranged_bisect(from_block, to_block).await?; + for log in &logs { + self.handle_erc20_log(log).await?; + // A busy chunk can carry many logs; yield between them so this + // task doesn't hog a worker thread through the whole batch (see + // the equivalent comment in `process_native_range_concurrent`). + tokio::task::yield_now().await; + } + Ok(()) + } + + /// Ranged `eth_getLogs` that bisects on error instead of retrying the identical + /// request. Providers cap `eth_getLogs` responses (e.g. Alchemy rejects a range + /// with "Log response size exceeded" rather than returning a partial result), so + /// a busy-token range that overflows the cap would otherwise fail the same way on + /// every retry and wedge catch-up on that chunk forever. Splitting the range in + /// half and recursing (down to a single block) makes `catch_up_chunk_size` a + /// soft performance hint rather than a correctness requirement. + fn get_logs_ranged_bisect( + &self, + from_block: u64, + to_block: u64, + ) -> BoxFuture<'_, Result>> { + Box::pin(async move { + let filter = self.erc20_transfer_filter(from_block, to_block); + match self + .get_logs_with_retry( + &filter, + self.chain.get_logs_max_retries, + self.chain.get_logs_delay_ms, + ) + .await + { + Ok(logs) => Ok(logs), + Err(e) => { + if from_block >= to_block { + return Err(e); + } + warn!( + "[{}] get_logs failed for range {}..={} ({:?}), bisecting", + self.chain.name, from_block, to_block, e ); - continue; + let mid = from_block + (to_block - from_block) / 2; + let mut left = self.get_logs_ranged_bisect(from_block, mid).await?; + let right = self.get_logs_ranged_bisect(mid + 1, to_block).await?; + left.extend(right); + Ok(left) } + } + }) + } - // Check if this is one of our monitored addresses - if let Some(registration_id) = - self.db.get_registration_id_by_address(&to_address_str)? - { - // Decode the amount from data field (ABI-encoded uint256 is 32 bytes) - let amount = if log.data().data.len() >= 32 { - // Standard case: take first 32 bytes (ABI-encoded uint256) - let amount_bytes: [u8; 32] = log.data().data[..32] - .try_into() - .expect("slice length is 32"); - alloy::primitives::U256::from_be_bytes(amount_bytes) - } else if !log.data().data.is_empty() { - // Short data (non-standard, but handle gracefully) - alloy::primitives::U256::from_be_slice(&log.data().data) - } else { - alloy::primitives::U256::ZERO - }; - - info!( - "Detected ERC20 deposit: Token: {}, To: {}, From: {}, Amount: {}", - token_address, to_address_str, from_address_str, amount - ); + /// Handles a single ERC20 Transfer log: allowlist/faucet/min-deposit checks, + /// idempotent recording, and webhook dispatch. Shared by the per-block + /// steady-state path and the batch catch-up path's ranged log queries. + async fn handle_erc20_log(&self, log: &Log) -> Result<()> { + if log.topics().len() < 3 { + return Ok(()); + } - // Fetch token metadata (symbol, decimals, name) - let token_info = self.get_or_fetch_token_metadata(token_address).await?; + let token_address = log.address(); + let from_address = Address::from_slice(&log.topics()[1].as_slice()[12..]); + let to_address = Address::from_slice(&log.topics()[2].as_slice()[12..]); - // Skip tokens with symbol longer than 5 characters - if token_info.symbol.len() > 5 { - info!( - "Skipping ERC20 deposit: token symbol '{}' exceeds 5 characters", - token_info.symbol - ); - continue; - } + let from_address_str = from_address.to_string(); + let to_address_str = to_address.to_string(); - info!( - "ERC20 deposit detected! Token: {} ({}), Amount: {}, Address: {}, Registration ID: {}, Tx: {:?}", - token_info.symbol, token_address, amount, to_address_str, registration_id, log.transaction_hash - ); + if from_address_str.eq_ignore_ascii_case(&self.chain.faucet_address) { + info!( + "[{}] Skipping ERC20 deposit from faucet: Token: {}, To: {}", + self.chain.name, token_address, to_address_str + ); + return Ok(()); + } - // Store ERC20 deposit - if let Some(tx_hash) = log.transaction_hash { - let log_index = log.log_index.unwrap_or(0); - let tx_hash_str = tx_hash.to_string(); - let deposit_id = format!("{}:{}", tx_hash_str, log_index); - - // Only send webhook if this is a new deposit (not a duplicate) - let is_new_deposit = self.db.record_erc20_deposit( - &tx_hash_str, - log_index, - ®istration_id, - &amount.to_string(), - &token_address.to_string(), - &token_info.symbol, - )?; - - // Send webhook notification for ERC20 deposit detection only if it's new - if is_new_deposit { - let token_addr_str = token_address.to_string(); - let amount_str = amount.to_string(); - let deposit_info = DepositInfo { - id: &deposit_id, - account_id: &to_address_str, - registration_id: ®istration_id, - tx_hash: &tx_hash_str, - amount: &amount_str, - token_type: "erc20", - token_symbol: Some(&token_info.symbol), - token_address: Some(&token_addr_str), - token_decimals: Some(token_info.decimals), - }; - if let Err(e) = self.send_deposit_detected_webhook(&deposit_info).await - { - error!("Failed to send ERC20 deposit detected webhook: {:?}", e); - } - } - } - } + let registration_id = { + let addr = to_address_str.clone(); + self.db + .blocking(move |db| db.get_registration_id_by_address(&addr)) + .await? + }; + let Some(registration_id) = registration_id else { + return Ok(()); + }; + + if !self.chain.is_token_allowed(&token_address.to_string()) { + debug!( + "[{}] Skipping non-allowlisted ERC20 token: {}", + self.chain.name, token_address + ); + return Ok(()); + } + + let amount = if log.data().data.len() >= 32 { + let amount_bytes: [u8; 32] = log.data().data[..32] + .try_into() + .expect("slice length is 32"); + alloy::primitives::U256::from_be_bytes(amount_bytes) + } else if !log.data().data.is_empty() { + alloy::primitives::U256::from_be_slice(&log.data().data) + } else { + alloy::primitives::U256::ZERO + }; + + let token_address_lc = token_address.to_string().to_lowercase(); + let min_deposit = self.chain.min_deposits.for_token(&token_address_lc); + if amount < min_deposit { + info!( + "[{}] Skipping ERC20 deposit below minimum: token={}, amount={}, min={}", + self.chain.name, token_address, amount, min_deposit + ); + return Ok(()); + } + + let token_info = self.get_or_fetch_token_metadata(token_address).await?; + + if token_info.symbol.len() > 5 { + info!( + "[{}] Skipping ERC20 deposit: token symbol '{}' exceeds 5 characters", + self.chain.name, token_info.symbol + ); + return Ok(()); + } + + let Some(tx_hash) = log.transaction_hash else { + return Ok(()); + }; + let log_index = log.log_index.unwrap_or(0); + let tx_hash_str = tx_hash.to_string(); + let deposit_id = format!("{}:{}:{}", self.chain.name, tx_hash_str, log_index); + + let is_new_deposit = { + let chain_name = self.chain.name.clone(); + let tx_hash_str = tx_hash_str.clone(); + let registration_id = registration_id.clone(); + let amount_str = amount.to_string(); + let token_address_str = token_address.to_string(); + let symbol = token_info.symbol.clone(); + self.db + .blocking(move |db| { + db.record_erc20_deposit( + &chain_name, + &tx_hash_str, + log_index, + ®istration_id, + &amount_str, + &token_address_str, + &symbol, + ) + }) + .await? + }; + + if is_new_deposit { + let token_addr_str = token_address.to_string(); + let amount_str = amount.to_string(); + let deposit_info = DepositInfo { + id: &deposit_id, + chain: &self.chain.name, + chain_id: self.chain.chain_id, + account_id: &to_address_str, + registration_id: ®istration_id, + tx_hash: &tx_hash_str, + amount: &amount_str, + token_type: "erc20", + token_symbol: Some(&token_info.symbol), + token_address: Some(&token_addr_str), + token_decimals: Some(token_info.decimals), + }; + if let Err(e) = self.send_deposit_detected_webhook(&deposit_info).await { + error!( + "[{}] Failed to send ERC20 deposit detected webhook: {:?}", + self.chain.name, e + ); } } @@ -275,52 +514,57 @@ where async fn get_logs_with_retry( &self, - filter: &alloy::rpc::types::Filter, + filter: &Filter, max_retries: u32, delay_ms: u64, - ) -> Result> { + ) -> Result> { use std::time::Duration; use tokio::time::sleep; - let mut last_result = Ok(Vec::new()); - - for attempt in 1..=max_retries { - last_result = self.provider.get_logs(filter).await.map_err(|e| e.into()); + let attempts = max_retries.max(1); + let mut last_error: Option = None; - match &last_result { - Ok(logs) if !logs.is_empty() => { + for attempt in 1..=attempts { + match self.provider.get_logs(filter).await { + Ok(logs) => { if attempt > 1 { info!( - "get_logs succeeded with {} logs on attempt {}", + "[{}] get_logs succeeded with {} logs on attempt {}", + self.chain.name, logs.len(), attempt ); } - return last_result; + return Ok(logs); + } + Err(e) => { + warn!( + "[{}] get_logs failed on attempt {}/{}: {:?}", + self.chain.name, attempt, attempts, e + ); + last_error = Some(e.into()); } - Ok(_) => warn!( - "get_logs returned empty on attempt {}/{}", - attempt, max_retries - ), - Err(e) => warn!( - "get_logs failed on attempt {}/{}: {:?}", - attempt, max_retries, e - ), } - if attempt < max_retries { + if attempt < attempts { sleep(Duration::from_millis(delay_ms)).await; } } - last_result + Err(last_error.expect("loop runs at least once and only exits here after an Err")) } async fn get_or_fetch_token_metadata(&self, token_address: Address) -> Result { let token_address_str = token_address.to_string(); - // Check cache first - if let Some((symbol, decimals, name)) = self.db.get_token_metadata(&token_address_str)? { + let cached = { + let chain_name = self.chain.name.clone(); + let addr = token_address_str.clone(); + self.db + .blocking(move |db| db.get_token_metadata(&chain_name, &addr)) + .await? + }; + if let Some((symbol, decimals, name)) = cached { return Ok(TokenInfo { address: token_address_str, symbol, @@ -329,24 +573,23 @@ where }); } - // Fetch from blockchain match get_token_info(&self.provider, token_address).await { Ok(token_info) => { - // Cache it - self.db.store_token_metadata( - &token_address_str, - &token_info.symbol, - token_info.decimals, - &token_info.name, - )?; + let chain_name = self.chain.name.clone(); + let addr = token_address_str.clone(); + let symbol = token_info.symbol.clone(); + let name = token_info.name.clone(); + let decimals = token_info.decimals; + self.db + .blocking(move |db| db.store_token_metadata(&chain_name, &addr, &symbol, decimals, &name)) + .await?; Ok(token_info) } Err(e) => { warn!( - "Failed to fetch token metadata for {}: {:?}", - token_address, e + "[{}] Failed to fetch token metadata for {}: {:?}", + self.chain.name, token_address, e ); - // Return a default token info Ok(TokenInfo { address: token_address_str.clone(), symbol: "UNKNOWN".to_string(), @@ -358,8 +601,13 @@ where } async fn send_deposit_detected_webhook(&self, info: &DepositInfo<'_>) -> Result<()> { - // Get the webhook URL for this account using registration_id - let Some(webhook_url) = self.db.get_webhook_url(info.registration_id)? else { + let webhook_url = { + let registration_id = info.registration_id.to_string(); + self.db + .blocking(move |db| db.get_webhook_url(®istration_id)) + .await? + }; + let Some(webhook_url) = webhook_url else { error!( "No webhook URL found for registration_id: {}", info.registration_id @@ -367,10 +615,10 @@ where return Ok(()); }; - let client = reqwest::Client::new(); - let mut payload = serde_json::json!({ "id": info.id, + "chain": info.chain, + "chain_id": info.chain_id, "event": "deposit_detected", "account_id": info.account_id, "registration_id": info.registration_id, @@ -379,7 +627,6 @@ where "token_type": info.token_type }); - // Add ERC20-specific fields if provided if let Some(symbol) = info.token_symbol { payload["token_symbol"] = serde_json::json!(symbol); } @@ -390,33 +637,19 @@ where payload["token_decimals"] = serde_json::json!(decimals); } - let mut request = client.post(&webhook_url).json(&payload); - - // Add JWT authorization header if configured - if let Some(ref token) = self.config.webhook_jwt_token { - request = request.header("Authorization", format!("Bearer {}", token)); - } - - let res = request.send().await; + self.deliverer + .enqueue(&webhook_url, info.registration_id, payload) + .await?; - match res { - Ok(r) => info!( - "Deposit detected webhook sent to {}: status={}, registration_id={}", - webhook_url, - r.status(), - info.registration_id - ), - Err(e) => error!( - "Failed to send deposit detected webhook to {}: {:?}", - webhook_url, e - ), - } + info!( + "[{}] Deposit detected webhook enqueued for {} (registration_id={})", + info.chain, webhook_url, info.registration_id + ); Ok(()) } } -// ERC20 helper types and functions use alloy::sol; sol! { @@ -442,13 +675,10 @@ struct TokenInfo { name: String, } -async fn get_token_info( - provider: &alloy::providers::RootProvider, +async fn get_token_info( + provider: &RootProvider, token_address: Address, -) -> Result -where - T: alloy::transports::Transport + Clone, -{ +) -> Result { let contract = IERC20::new(token_address, provider); let symbol = contract.symbol().call().await?._0; @@ -466,59 +696,18 @@ where use crate::traits::Service; use async_trait::async_trait; -use alloy::transports::http::Http; -use reqwest::Client; - -// Implementation for HTTP Provider (Polling) #[async_trait] -impl Service for Monitor>> { +impl Service for Monitor { async fn run(&self) { use std::time::Duration; use tokio::time::sleep; - info!("Starting Monitor in Polling mode"); + info!("[{}] Starting Monitor in Polling mode", self.chain.name); loop { if let Err(e) = self.catch_up().await { - error!("Error in monitor loop: {:?}", e); + error!("[{}] Error in monitor loop: {:?}", self.chain.name, e); } - info!("Sleeping for {} seconds", self.config.poll_interval); - sleep(Duration::from_secs(self.config.poll_interval)).await; - } - } -} - -// Implementation for WebSocket Provider (Streaming) -#[async_trait] -impl Service for Monitor> { - async fn run(&self) { - use std::time::Duration; - use tokio::time::sleep; - - info!("Starting Monitor in Streaming mode"); - loop { - // 1. Catch up first - if let Err(e) = self.catch_up().await { - error!("Error during catch-up: {:?}", e); - } - - // 2. Subscribe - match self.provider.subscribe_blocks().await { - Ok(mut stream) => { - while let Ok(header) = stream.recv().await { - if let Some(block_num) = header.header.number { - info!("New block received via WS: {}", block_num); - if let Err(e) = self.process_single_block(block_num).await { - error!("Error processing block {}: {:?}", block_num, e); - } - } - } - error!("WebSocket stream ended"); - } - Err(e) => error!("Failed to subscribe to blocks: {:?}", e), - } - - // Reconnect delay - sleep(Duration::from_secs(5)).await; + sleep(Duration::from_secs(self.chain.poll_interval)).await; } } } diff --git a/src/redb_import.rs b/src/redb_import.rs new file mode 100644 index 0000000..2233918 --- /dev/null +++ b/src/redb_import.rs @@ -0,0 +1,236 @@ +use crate::db::{apply_pragmas_for_import, migrations}; +use crate::redb_store::RedbStore; +use anyhow::{anyhow, Result}; +use rusqlite::{params, Connection}; +use std::path::Path; +use tracing::warn; + +#[derive(Debug, Default)] +pub struct ImportSummary { + pub accounts: (usize, usize), + pub deposits: (usize, usize), + pub erc20_deposits: (usize, usize), + pub token_metadata: (usize, usize), + pub state: (usize, usize), + pub sweep_meta: (usize, usize), + pub sweep_failures: (usize, usize), + pub orphan_address_mappings: usize, + pub block_cursors: Vec<(String, u64)>, +} + +pub fn migrate_redb_file_to_sqlite( + from_redb: &Path, + to_sqlite: &Path, + legacy_chain: &str, + force: bool, +) -> Result { + if to_sqlite.exists() { + if force { + std::fs::remove_file(to_sqlite)?; + let _ = std::fs::remove_file(to_sqlite.with_extension("db-wal")); + let _ = std::fs::remove_file(to_sqlite.with_extension("db-shm")); + } else { + return Err(anyhow!( + "SQLite file already exists: {} (use --force to overwrite)", + to_sqlite.display() + )); + } + } + + let store = RedbStore::open( + from_redb + .to_str() + .ok_or_else(|| anyhow!("invalid redb path"))?, + )?; + store.migrate_v1_to_v2(legacy_chain)?; + let snapshot = store.export_snapshot()?; + + let to_str = to_sqlite + .to_str() + .ok_or_else(|| anyhow!("invalid sqlite path"))?; + let mut conn = Connection::open(to_str)?; + apply_pragmas_for_import(&conn)?; + migrations().to_latest(&mut conn)?; + + let tx = conn.unchecked_transaction()?; + + let mut summary = ImportSummary::default(); + summary.accounts.0 = snapshot.accounts.len(); + + for (id, index, address, webhook) in &snapshot.accounts { + tx.execute( + "INSERT OR IGNORE INTO accounts (id, derivation_index, address, webhook_url) + VALUES (?1, ?2, ?3, ?4)", + params![id, index, address, webhook], + )?; + if tx.changes() == 1 { + summary.accounts.1 += 1; + } + } + + let account_addresses: std::collections::HashSet = snapshot + .accounts + .iter() + .map(|(_, _, addr, _)| addr.clone()) + .collect(); + + for (address, id) in &snapshot.address_to_id { + if !account_addresses.contains(address) { + warn!( + orphan_address = %address, + registration_id = %id, + "address_to_id entry has no matching account row" + ); + summary.orphan_address_mappings += 1; + } + } + + summary.deposits.0 = snapshot.deposits.len(); + for (chain, tx_hash, account_id, amount, status) in &snapshot.deposits { + tx.execute( + "INSERT OR IGNORE INTO deposits (chain, tx_hash, account_id, amount, status) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![chain, tx_hash, account_id, amount, status], + )?; + if tx.changes() == 1 { + summary.deposits.1 += 1; + } + } + + summary.erc20_deposits.0 = snapshot.erc20_deposits.len(); + for (chain, tx_hash, log_index, account_id, amount, token_address, token_symbol, status) in + &snapshot.erc20_deposits + { + tx.execute( + "INSERT OR IGNORE INTO erc20_deposits + (chain, tx_hash, log_index, account_id, amount, token_address, token_symbol, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + chain, + tx_hash, + log_index, + account_id, + amount, + token_address, + token_symbol, + status + ], + )?; + if tx.changes() == 1 { + summary.erc20_deposits.1 += 1; + } + } + + summary.token_metadata.0 = snapshot.token_metadata.len(); + for (chain, token_address, symbol, decimals, name) in &snapshot.token_metadata { + tx.execute( + "INSERT OR IGNORE INTO token_metadata (chain, token_address, symbol, decimals, name) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![chain, token_address, symbol, decimals, name], + )?; + if tx.changes() == 1 { + summary.token_metadata.1 += 1; + } + } + + summary.state.0 = snapshot.state.len(); + for (key, value) in &snapshot.state { + tx.execute( + "INSERT OR IGNORE INTO state (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + if tx.changes() == 1 { + summary.state.1 += 1; + } + if let Some(chain) = key.strip_prefix("last_block:") { + let block: u64 = value.parse().unwrap_or(0); + summary.block_cursors.push((chain.to_string(), block)); + } + } + + summary.sweep_meta.0 = snapshot.sweep_meta.len(); + for (chain, tx_hash, log_index, sweep_tx_hash, count) in &snapshot.sweep_meta { + tx.execute( + "INSERT OR IGNORE INTO sweep_meta + (chain, tx_hash, log_index, sweep_tx_hash, zero_balance_retry_count) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![chain, tx_hash, log_index, sweep_tx_hash, *count as i64], + )?; + if tx.changes() == 1 { + summary.sweep_meta.1 += 1; + } + } + + summary.sweep_failures.0 = snapshot.sweep_failures.len(); + for (chain, tx_hash, log_index, count) in &snapshot.sweep_failures { + tx.execute( + "INSERT OR IGNORE INTO sweep_failures (chain, tx_hash, log_index, consecutive_failure_count) + VALUES (?1, ?2, ?3, ?4)", + params![chain, tx_hash, log_index, *count as i64], + )?; + if tx.changes() == 1 { + summary.sweep_failures.1 += 1; + } + } + + tx.commit()?; + + Ok(summary) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Db; + use crate::redb_store::RedbStore; + use tempfile::NamedTempFile; + + #[test] + fn test_import_legacy_redb_preserves_state() { + let redb_tmp = NamedTempFile::new().unwrap(); + let redb_path = redb_tmp.path().to_str().unwrap(); + + { + let store = RedbStore::open_without_migration(redb_path).unwrap(); + store + .insert_legacy_v1_state_for_test("0xlegacy", "user1", "500", "42", "swept") + .unwrap(); + store + .insert_v2_erc20_deposit_for_test( + "polygon", "0xswept", 1, "user1", "100", "0xtoken", "USDC", "swept", + ) + .unwrap(); + store + .insert_v2_sweep_meta_for_test("polygon", "0xswept", 1, "0xsweep_tx", 3) + .unwrap(); + } + + let sqlite_tmp = NamedTempFile::new().unwrap(); + let sqlite_path = sqlite_tmp.path(); + + let summary = + migrate_redb_file_to_sqlite(redb_tmp.path(), sqlite_path, "polygon", true).unwrap(); + + assert_eq!(summary.deposits.0, 1); + assert!(summary + .block_cursors + .iter() + .any(|(c, b)| c == "polygon" && *b == 42)); + + let db = Db::new(sqlite_path.to_str().unwrap()).unwrap(); + assert_eq!(db.get_last_processed_block("polygon").unwrap(), 42); + assert!(db.get_detected_deposits("polygon").unwrap().is_empty()); + assert!(db + .get_detected_erc20_deposits("polygon") + .unwrap() + .is_empty()); + + let meta = db.get_sweep_meta("polygon", "0xswept:1").unwrap(); + assert_eq!(meta, Some(("0xsweep_tx".to_string(), 3))); + assert_eq!( + db.increment_zero_balance_count("polygon", "0xswept:1") + .unwrap(), + 4 + ); + } +} diff --git a/src/redb_store.rs b/src/redb_store.rs new file mode 100644 index 0000000..2ed9f3d --- /dev/null +++ b/src/redb_store.rs @@ -0,0 +1,480 @@ +use anyhow::{anyhow, Result}; +use redb::{Database, ReadableTable, TableDefinition}; +use std::sync::Arc; + +const ACCOUNTS: TableDefinition<&str, (u32, &str, &str)> = TableDefinition::new("accounts"); +const ADDRESS_TO_ID: TableDefinition<&str, &str> = TableDefinition::new("address_to_id"); +const DEPOSITS: TableDefinition<&str, (&str, &str, &str)> = TableDefinition::new("deposits"); +const STATE: TableDefinition<&str, &str> = TableDefinition::new("state"); +const TOKEN_METADATA: TableDefinition<&str, (&str, u64, &str)> = + TableDefinition::new("token_metadata"); +const ERC20_DEPOSITS: TableDefinition<&str, (&str, &str, &str, &str, &str)> = + TableDefinition::new("erc20_deposits"); +const SWEEP_META: TableDefinition<&str, (&str, u64)> = TableDefinition::new("sweep_meta"); +const SWEEP_FAILURES: TableDefinition<&str, u64> = TableDefinition::new("sweep_failures"); + +pub const SCHEMA_VERSION: u32 = 2; + +type Erc20DepositRow = (String, String, i64, String, String, String, String, String); + +#[derive(Clone)] +pub struct RedbStore { + db: Arc, +} + +#[derive(Debug, Default)] +pub struct RedbSnapshot { + pub accounts: Vec<(String, u32, String, String)>, + pub address_to_id: Vec<(String, String)>, + pub deposits: Vec<(String, String, String, String, String)>, + pub erc20_deposits: Vec, + pub token_metadata: Vec<(String, String, String, u8, String)>, + pub state: Vec<(String, String)>, + pub sweep_meta: Vec<(String, String, i64, String, u64)>, + pub sweep_failures: Vec<(String, String, i64, u64)>, +} + +fn deposit_key(chain: &str, tx_hash: &str) -> String { + format!("{chain}:{tx_hash}") +} + +fn token_metadata_key(chain: &str, token_address: &str) -> String { + format!("{chain}:{token_address}") +} + +fn last_block_key(chain: &str) -> String { + format!("last_block:{chain}") +} + +/// Parse `"0xtx:42"` -> (`0xtx`, 42). Bare `"0xtx"` -> (`0xtx`, 0). +pub fn parse_local_key(local_key: &str) -> Result<(String, i64)> { + if let Some((tx, idx)) = local_key.rsplit_once(':') { + if !idx.is_empty() && idx.chars().all(|c| c.is_ascii_digit()) { + return Ok((tx.to_string(), idx.parse()?)); + } + } + Ok((local_key.to_string(), 0)) +} + +fn split_chain_key(full_key: &str) -> Result<(String, String)> { + let (chain, rest) = full_key + .split_once(':') + .ok_or_else(|| anyhow!("invalid chain-prefixed key: {full_key}"))?; + Ok((chain.to_string(), rest.to_string())) +} + +impl RedbStore { + pub fn open(path: &str) -> Result { + let db = Database::open(path).map_err(|e| { + anyhow!( + "failed to open redb at {path}: {e}. \ + If this file was copied via scp/cp while the service was running, \ + stop the writer first, copy on the server (cp … /tmp/snapshot.db), then transfer the snapshot." + ) + })?; + Ok(Self { db: Arc::new(db) }) + } + + #[cfg(test)] + pub fn open_without_migration(path: &str) -> Result { + Ok(Self { + db: Arc::new(Self::create_empty(path)?), + }) + } + + #[cfg(test)] + fn create_empty(path: &str) -> Result { + let db = Database::create(path)?; + let write_txn = db.begin_write()?; + { + let _ = write_txn.open_table(ACCOUNTS)?; + let _ = write_txn.open_table(ADDRESS_TO_ID)?; + let _ = write_txn.open_table(DEPOSITS)?; + let _ = write_txn.open_table(STATE)?; + let _ = write_txn.open_table(TOKEN_METADATA)?; + let _ = write_txn.open_table(ERC20_DEPOSITS)?; + let _ = write_txn.open_table(SWEEP_META)?; + let _ = write_txn.open_table(SWEEP_FAILURES)?; + } + write_txn.commit()?; + Ok(db) + } + + #[cfg(test)] + pub fn insert_legacy_v1_state_for_test( + &self, + tx_hash: &str, + account_id: &str, + amount: &str, + last_block: &str, + status: &str, + ) -> Result<()> { + let write_txn = self.db.begin_write()?; + { + let mut deposits = write_txn.open_table(DEPOSITS)?; + deposits.insert(tx_hash, (account_id, amount, status))?; + let mut state = write_txn.open_table(STATE)?; + state.insert("last_block", last_block)?; + } + write_txn.commit()?; + Ok(()) + } + + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub fn insert_v2_erc20_deposit_for_test( + &self, + chain: &str, + tx_hash: &str, + log_index: u64, + account_id: &str, + amount: &str, + token_address: &str, + token_symbol: &str, + status: &str, + ) -> Result<()> { + let key = format!("{chain}:{tx_hash}:{log_index}"); + let write_txn = self.db.begin_write()?; + { + let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; + deposits.insert( + key.as_str(), + (account_id, amount, token_address, token_symbol, status), + )?; + } + write_txn.commit()?; + Ok(()) + } + + #[cfg(test)] + pub fn insert_v2_sweep_meta_for_test( + &self, + chain: &str, + tx_hash: &str, + log_index: u64, + sweep_tx_hash: &str, + zero_balance_count: u64, + ) -> Result<()> { + let key = format!("{chain}:{tx_hash}:{log_index}"); + let write_txn = self.db.begin_write()?; + { + let mut meta = write_txn.open_table(SWEEP_META)?; + meta.insert(key.as_str(), (sweep_tx_hash, zero_balance_count))?; + } + write_txn.commit()?; + Ok(()) + } + + /// One-time migration: namespace legacy single-chain keys under `legacy_chain`. + #[allow(clippy::type_complexity)] + pub fn migrate_v1_to_v2(&self, legacy_chain: &str) -> Result<()> { + let version = self.get_schema_version()?; + if version >= SCHEMA_VERSION { + return Ok(()); + } + + let write_txn = self.db.begin_write()?; + { + { + let mut deposits = write_txn.open_table(DEPOSITS)?; + let legacy_keys: Vec<(String, (String, String, String))> = { + let mut keys = Vec::new(); + for item in deposits.iter()? { + let (key, value) = item?; + let key_str = key.value().to_string(); + if !key_str.contains(':') { + let val = value.value(); + keys.push(( + key_str, + (val.0.to_string(), val.1.to_string(), val.2.to_string()), + )); + } + } + keys + }; + for (old_key, (account_id, amount, status)) in legacy_keys { + let new_key = deposit_key(legacy_chain, &old_key); + deposits.remove(old_key.as_str())?; + deposits.insert( + new_key.as_str(), + (account_id.as_str(), amount.as_str(), status.as_str()), + )?; + } + } + + { + let mut deposits = write_txn.open_table(ERC20_DEPOSITS)?; + let legacy_keys: Vec<(String, (String, String, String, String, String))> = { + let mut keys = Vec::new(); + for item in deposits.iter()? { + let (key, value) = item?; + let key_str = key.value(); + if key_str.starts_with(&format!("{legacy_chain}:")) + || key_str.starts_with("last_block:") + { + continue; + } + let is_legacy = !key_str.contains(':') || key_str.starts_with("0x"); + if is_legacy { + let val = value.value(); + keys.push(( + key_str.to_string(), + ( + val.0.to_string(), + val.1.to_string(), + val.2.to_string(), + val.3.to_string(), + val.4.to_string(), + ), + )); + } + } + keys + }; + for (old_key, (account_id, amount, token_address, token_symbol, status)) in + legacy_keys + { + let new_key = format!("{legacy_chain}:{old_key}"); + deposits.remove(old_key.as_str())?; + deposits.insert( + new_key.as_str(), + ( + account_id.as_str(), + amount.as_str(), + token_address.as_str(), + token_symbol.as_str(), + status.as_str(), + ), + )?; + } + } + + { + let mut metadata = write_txn.open_table(TOKEN_METADATA)?; + let legacy_keys: Vec<(String, (String, u64, String))> = { + let mut keys = Vec::new(); + for item in metadata.iter()? { + let (key, value) = item?; + let key_str = key.value().to_string(); + if key_str.starts_with("0x") && !key_str.contains(':') { + let val = value.value(); + keys.push((key_str, (val.0.to_string(), val.1, val.2.to_string()))); + } + } + keys + }; + for (old_key, (symbol, decimals, name)) in legacy_keys { + let new_key = token_metadata_key(legacy_chain, &old_key); + metadata.remove(old_key.as_str())?; + metadata + .insert(new_key.as_str(), (symbol.as_str(), decimals, name.as_str()))?; + } + } + + { + let mut state = write_txn.open_table(STATE)?; + let legacy_block = state.get("last_block")?.map(|v| v.value().to_string()); + if let Some(block) = legacy_block { + state.remove("last_block")?; + state.insert(last_block_key(legacy_chain).as_str(), block.as_str())?; + } + } + + { + let mut meta = write_txn.open_table(SWEEP_META)?; + let legacy_meta: Vec<(String, (String, u64))> = { + let mut keys = Vec::new(); + for item in meta.iter()? { + let (key, value) = item?; + let key_str = key.value().to_string(); + if key_str.starts_with("0x") + && !key_str.starts_with(&format!("{legacy_chain}:")) + { + let val = value.value(); + keys.push((key_str, (val.0.to_string(), val.1))); + } + } + keys + }; + for (old_key, (sweep_tx, count)) in legacy_meta { + let new_key = if old_key.contains(':') { + format!("{legacy_chain}:{old_key}") + } else { + deposit_key(legacy_chain, &old_key) + }; + meta.remove(old_key.as_str())?; + meta.insert(new_key.as_str(), (sweep_tx.as_str(), count))?; + } + } + + { + let mut failures = write_txn.open_table(SWEEP_FAILURES)?; + let legacy_failures: Vec<(String, u64)> = { + let mut keys = Vec::new(); + for item in failures.iter()? { + let (key, value) = item?; + let key_str = key.value().to_string(); + if key_str.starts_with("0x") + && !key_str.starts_with(&format!("{legacy_chain}:")) + { + keys.push((key_str, value.value())); + } + } + keys + }; + for (old_key, count) in legacy_failures { + let new_key = if old_key.contains(':') { + format!("{legacy_chain}:{old_key}") + } else { + deposit_key(legacy_chain, &old_key) + }; + failures.remove(old_key.as_str())?; + failures.insert(new_key.as_str(), count)?; + } + } + + let mut state = write_txn.open_table(STATE)?; + state.insert("schema_version", SCHEMA_VERSION.to_string().as_str())?; + } + write_txn.commit()?; + Ok(()) + } + + pub fn get_schema_version(&self) -> Result { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(STATE)?; + let result = table.get("schema_version")?; + Ok(result.map(|v| v.value().parse().unwrap_or(0)).unwrap_or(0)) + } + + pub fn export_snapshot(&self) -> Result { + let mut snapshot = RedbSnapshot::default(); + let read_txn = self.db.begin_read()?; + + { + let table = read_txn.open_table(ACCOUNTS)?; + for item in table.iter()? { + let (id, value) = item?; + let val = value.value(); + snapshot.accounts.push(( + id.value().to_string(), + val.0, + val.1.to_string(), + val.2.to_string(), + )); + } + } + + { + let table = read_txn.open_table(ADDRESS_TO_ID)?; + for item in table.iter()? { + let (address, id) = item?; + snapshot + .address_to_id + .push((address.value().to_string(), id.value().to_string())); + } + } + + { + let table = read_txn.open_table(DEPOSITS)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + let (chain, tx_hash) = split_chain_key(key_str)?; + let (account_id, amount, status) = value.value(); + snapshot.deposits.push(( + chain, + tx_hash, + account_id.to_string(), + amount.to_string(), + status.to_string(), + )); + } + } + + { + let table = read_txn.open_table(ERC20_DEPOSITS)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + let (chain, local) = split_chain_key(key_str)?; + let (tx_hash, log_index) = parse_local_key(&local)?; + let (account_id, amount, token_address, token_symbol, status) = value.value(); + snapshot.erc20_deposits.push(( + chain, + tx_hash, + log_index, + account_id.to_string(), + amount.to_string(), + token_address.to_string(), + token_symbol.to_string(), + status.to_string(), + )); + } + } + + { + let table = read_txn.open_table(TOKEN_METADATA)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + let (chain, token_address) = split_chain_key(key_str)?; + let (symbol, decimals, name) = value.value(); + snapshot.token_metadata.push(( + chain, + token_address, + symbol.to_string(), + decimals as u8, + name.to_string(), + )); + } + } + + { + let table = read_txn.open_table(STATE)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + if key_str == "schema_version" { + continue; + } + snapshot + .state + .push((key_str.to_string(), value.value().to_string())); + } + } + + { + let table = read_txn.open_table(SWEEP_META)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + let (chain, local) = split_chain_key(key_str)?; + let (tx_hash, log_index) = parse_local_key(&local)?; + let (sweep_tx_hash, count) = value.value(); + snapshot.sweep_meta.push(( + chain, + tx_hash, + log_index, + sweep_tx_hash.to_string(), + count, + )); + } + } + + { + let table = read_txn.open_table(SWEEP_FAILURES)?; + for item in table.iter()? { + let (key, value) = item?; + let key_str = key.value(); + let (chain, local) = split_chain_key(key_str)?; + let (tx_hash, log_index) = parse_local_key(&local)?; + snapshot + .sweep_failures + .push((chain, tx_hash, log_index, value.value())); + } + } + + Ok(snapshot) + } +} diff --git a/src/sweeper.rs b/src/sweeper.rs index 4cb3027..03943f8 100644 --- a/src/sweeper.rs +++ b/src/sweeper.rs @@ -1,82 +1,96 @@ use crate::{ - config::Config, + config::ChainConfig, db::{Db, Erc20Deposit}, faucet::Faucet, wallet::Wallet, + webhook::WebhookDeliverer, }; use alloy::network::TransactionBuilder; use alloy::primitives::{Address, U256}; -use alloy::providers::Provider; +use alloy::providers::{Provider, RootProvider}; use alloy::rpc::types::TransactionRequest; use alloy::sol_types::SolCall; +use alloy::transports::BoxTransport; use anyhow::Result; use std::collections::HashSet; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; -use tracing::{error, info}; +use tracing::{error, info, warn}; -/// Information about an ERC20 deposit for webhook notification struct Erc20WebhookInfo<'a> { id: &'a str, - account_id: &'a str, // Polygon address - registration_id: &'a str, // Original id used when registering + chain: &'a str, + chain_id: u64, + account_id: &'a str, + registration_id: &'a str, deposit_key: &'a str, amount: &'a str, token_symbol: &'a str, token_address: &'a str, token_decimals: Option, - sweep_tx_hash: &'a str, // On-chain tx hash of the sweep (idempotency key for consumers) + sweep_tx_hash: &'a str, } -pub struct Sweeper

{ - config: Config, +pub struct Sweeper { + chain: ChainConfig, + deliverer: Arc, db: Db, wallet: Wallet, - provider: P, - faucet: Arc>, + provider: RootProvider, + faucet: Arc, } use crate::traits::Service; use async_trait::async_trait; #[async_trait] -impl Service for Sweeper> -where - T: alloy::transports::Transport + Clone, -{ +impl Service for Sweeper { async fn run(&self) { + self.log_deposit_queue("startup").await; + let mut cycle: u64 = 0; loop { if let Err(e) = self.process_deposits().await { - error!("Error in sweeper loop: {:?}", e); + error!("[{}] Error in sweeper loop: {:?}", self.chain.name, e); } - sleep(Duration::from_secs(self.config.poll_interval)).await; + cycle += 1; + if cycle.is_multiple_of(QUEUE_LOG_INTERVAL_CYCLES) { + self.log_deposit_queue("periodic").await; + } + sleep(Duration::from_secs(self.chain.poll_interval)).await; } } } -/// After this many consecutive zero-balance checks, a deposit is assumed to have been -/// swept as part of a consolidated sweep and is marked as swept to avoid infinite retries. const MAX_ZERO_BALANCE_RETRIES: u64 = 10; - -/// After this many consecutive sweep failures (e.g. "buffer overrun while deserializing"), -/// a deposit is marked as permanently failed to stop wasting RPC credits on deterministic errors. const MAX_SWEEP_RETRIES: u64 = 5; +const QUEUE_LOG_INTERVAL_CYCLES: u64 = 60; + +fn is_permanent_sweep_error(err_debug: &str) -> bool { + let s = err_debug.to_ascii_lowercase(); + s.contains("buffer overrun") || s.contains("deserializ") +} + +fn is_transient_funding_error(err_debug: &str) -> bool { + let s = err_debug.to_ascii_lowercase(); + s.contains("faucet has insufficient balance") + || s.contains("insufficient native balance for gas") + || s.contains("still insufficient balance after faucet") +} -impl Sweeper> -where - T: alloy::transports::Transport + Clone, -{ +impl Sweeper { pub fn new( - config: Config, + chain: ChainConfig, + deliverer: Arc, db: Db, wallet: Wallet, - provider: alloy::providers::RootProvider, - faucet: Arc>>, + provider: RootProvider, + faucet: Arc, ) -> Self { Self { - config, + chain, + deliverer, db, wallet, provider, @@ -84,21 +98,59 @@ where } } + #[cfg(test)] + pub(crate) async fn process_deposits_once(&self) -> Result<()> { + self.process_deposits().await + } + + async fn log_deposit_queue(&self, reason: &str) { + let chain_name = self.chain.name.clone(); + match self + .db + .blocking(move |db| db.deposit_queue_counts(&chain_name)) + .await + { + Ok(counts) => { + if counts.has_pending() { + info!( + "[{}] Sweeper queue ({reason}): native detected={}, native failed={}, erc20 detected={}, erc20 failed={}", + self.chain.name, + counts.native_detected, + counts.native_failed, + counts.erc20_detected, + counts.erc20_failed + ); + } + } + Err(e) => { + error!( + "[{}] Failed to read deposit queue counts: {:?}", + self.chain.name, e + ); + } + } + } + async fn process_deposits(&self) -> Result<()> { - // Process native ETH deposits - let deposits = self.db.get_detected_deposits()?; + let chain_name = self.chain.name.clone(); + let deposits = self + .db + .blocking(move |db| db.get_detected_deposits(&chain_name)) + .await?; for (tx_hash, registration_id, amount_str) in deposits { info!( - "Processing native ETH deposit: tx_hash={}, registration_id={}, amount={}", - tx_hash, registration_id, amount_str + "[{}] Processing native deposit: tx_hash={}, registration_id={}, amount={}", + self.chain.name, tx_hash, registration_id, amount_str ); - // Get account details to derive key (registration_id is the key in ACCOUNTS table) - let (derivation_index, address_str, _webhook_url) = self - .db - .get_account_by_id(®istration_id)? - .ok_or_else(|| anyhow::anyhow!("Account not found"))?; + let (derivation_index, address_str, _webhook_url) = { + let reg_id = registration_id.clone(); + self.db + .blocking(move |db| db.get_account_by_id(®_id)) + .await? + .ok_or_else(|| anyhow::anyhow!("Account not found"))? + }; let signer = self.wallet.get_signer(derivation_index)?; let wallet = alloy::network::EthereumWallet::from(signer); @@ -118,64 +170,73 @@ where ) .await { - Ok(_) => info!("Successfully swept native ETH deposit: {}", tx_hash), + Ok(_) => info!( + "[{}] Successfully swept native deposit: {}", + self.chain.name, tx_hash + ), Err(e) => { - error!("Failed to sweep native ETH deposit {}: {:?}", tx_hash, e); + let err_str = format!("{:?}", e); + if is_transient_funding_error(&err_str) { + warn!( + "[{}] Native deposit {} waiting for faucet funding: {}", + self.chain.name, tx_hash, err_str + ); + } else { + error!( + "[{}] Failed to sweep native deposit {}: {}", + self.chain.name, tx_hash, err_str + ); + } } } } - // Process ERC20 deposits - let erc20_deposits = self.db.get_detected_erc20_deposits()?; - - // Track (address, token) pairs already swept in this cycle to avoid redundant attempts. - // After sweeping the full token_balance for one deposit, all other deposits for the same - // address+token are already marked as swept by the bulk mark method. Any remaining ones - // would see zero balance and harmlessly skip, but we can avoid the RPC call entirely. + let chain_name = self.chain.name.clone(); + let erc20_deposits = self + .db + .blocking(move |db| db.get_detected_erc20_deposits(&chain_name)) + .await?; let mut swept_pairs: HashSet<(String, String)> = HashSet::new(); for deposit in erc20_deposits { - // deposit.account_id is actually the registration_id (original id from registration) let registration_id = &deposit.account_id; - info!( - "Processing ERC20 deposit: key={}, token={} ({}), registration_id={}, amount={}", - deposit.key, - deposit.token_symbol, - deposit.token_address, - registration_id, - deposit.amount - ); + if !self.chain.is_token_allowed(&deposit.token_address) { + info!( + "[{}] Skipping non-allowlisted ERC20 deposit: key={}, token={}", + self.chain.name, deposit.key, deposit.token_address + ); + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| db.mark_erc20_deposit_failed(&chain_name, &key)) + .await?; + continue; + } if deposit.token_symbol == "UNKNOWN" { - error!( - "Skipping ERC20 deposit token symbol for deposit: {}", - deposit.key - ); - self.db.mark_erc20_deposit_swept(&deposit.key)?; + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| db.mark_erc20_deposit_swept(&chain_name, &key)) + .await?; continue; } - // Get account details to derive key (registration_id is the key in ACCOUNTS table) - let (derivation_index, address_str, _webhook_url) = self - .db - .get_account_by_id(registration_id)? - .ok_or_else(|| anyhow::anyhow!("Account not found"))?; + let (derivation_index, address_str, _webhook_url) = { + let reg_id = registration_id.clone(); + self.db + .blocking(move |db| db.get_account_by_id(®_id)) + .await? + .ok_or_else(|| anyhow::anyhow!("Account not found"))? + }; - // Skip if we already swept this (address, token) pair in this cycle let pair_key = (address_str.clone(), deposit.token_address.clone()); if swept_pairs.contains(&pair_key) { - info!( - "Skipping ERC20 deposit {} - already swept address {} for token {} in this cycle", - deposit.key, address_str, deposit.token_symbol - ); continue; } let signer = self.wallet.get_signer(derivation_index)?; - - info!("Signer address: {}", signer.address()); - let wallet = alloy::network::EthereumWallet::from(signer); let sweep_provider = alloy::providers::ProviderBuilder::new() @@ -183,32 +244,78 @@ where .wallet(wallet) .on_provider(&self.provider); - // Try to sweep, but don't fail the entire loop if one sweep fails match self .sweep_erc20_deposit(&sweep_provider, &address_str, &deposit) .await { Ok(_) => { - info!("Successfully swept ERC20 deposit: {}", deposit.key); swept_pairs.insert(pair_key); } Err(e) => { - error!("Failed to sweep ERC20 deposit {}: {:?}", deposit.key, e); - if let Ok(failures) = self.db.increment_sweep_failure_count(&deposit.key) { - if failures >= MAX_SWEEP_RETRIES { - let registration_id = &deposit.account_id; - match self.db.mark_erc20_deposits_failed_for_account_token( - registration_id, - &deposit.token_address, - ) { - Ok(failed_keys) => { - error!( - "Permanently marked {} deposit(s) as failed for account={}, token={} after {} attempts: {:?}", - failed_keys.len(), registration_id, deposit.token_symbol, failures, e - ); - } - Err(db_err) => { - error!("Failed to mark deposits as failed: {:?}", db_err); + let err_str = format!("{:?}", e); + if is_transient_funding_error(&err_str) { + warn!( + "[{}] ERC20 deposit {} waiting for faucet funding: {}", + self.chain.name, deposit.key, err_str + ); + } else { + error!( + "[{}] Failed to sweep ERC20 deposit {}: {}", + self.chain.name, deposit.key, err_str + ); + if is_permanent_sweep_error(&err_str) { + let failed = { + let chain_name = self.chain.name.clone(); + let reg_id = registration_id.clone(); + let token_address = deposit.token_address.clone(); + self.db + .blocking(move |db| { + db.mark_erc20_deposits_failed_for_account_token( + &chain_name, + ®_id, + &token_address, + ) + }) + .await? + }; + for key in failed { + warn!( + "[{}] Permanently failed ERC20 deposit {} (permanent sweep error)", + self.chain.name, key + ); + } + } else { + let failures = { + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| { + db.increment_sweep_failure_count(&chain_name, &key) + }) + .await + }; + if let Ok(failures) = failures { + if failures >= MAX_SWEEP_RETRIES { + let failed = { + let chain_name = self.chain.name.clone(); + let reg_id = registration_id.clone(); + let token_address = deposit.token_address.clone(); + self.db + .blocking(move |db| { + db.mark_erc20_deposits_failed_for_account_token( + &chain_name, + ®_id, + &token_address, + ) + }) + .await? + }; + for key in failed { + warn!( + "[{}] Permanently failed ERC20 deposit {} after {} attempts: {}", + self.chain.name, key, failures, err_str + ); + } } } } @@ -229,74 +336,33 @@ where amount_str: &str, ) -> Result<()> where - SP: Provider, + SP: Provider, { let from_address = Address::from_str(from_address_str)?; - let to_address = Address::from_str(&self.config.treasury_address)?; + let to_address = Address::from_str(&self.chain.treasury_address)?; - // Check balance again to be sure (and to calculate gas) let mut balance = provider.get_balance(from_address).await?; - - // Standard ETH transfer gas limit let gas_limit: u128 = 21000; - - // Get current fee estimates (EIP-1559 compatible) let fee_estimate = provider.estimate_eip1559_fees(None).await?; let max_fee_per_gas = fee_estimate.max_fee_per_gas; - - // Calculate gas cost with 50% buffer for price fluctuations let gas_cost = U256::from(gas_limit) * U256::from(max_fee_per_gas); let gas_cost_with_buffer = gas_cost + (gas_cost / U256::from(10)); - info!( - "Gas estimation for native ETH transfer: gas_limit={}, max_fee_per_gas={}, gas_cost={} wei (with 50% buffer: {} wei)", - gas_limit, max_fee_per_gas, gas_cost, gas_cost_with_buffer - ); - - // If balance is too low to cover gas, try to fund via faucet if balance <= gas_cost_with_buffer { - info!( - "Balance too low to sweep: {} <= {}. Attempting to fund via faucet...", - balance, gas_cost_with_buffer - ); - - // Fund the address via faucet match self.faucet.fund_new_address(from_address_str).await { - Ok(tx_hash) => { - info!( - "Successfully funded address {} via faucet with tx: {}. Waiting for balance update...", - from_address_str, tx_hash - ); - - // Wait a bit for the transaction to be processed and balance to update + Ok(_) => { sleep(Duration::from_secs(2)).await; - - // Re-check the balance after funding balance = provider.get_balance(from_address).await?; - info!( - "Updated balance after faucet funding: {} wei for address {}", - balance, from_address_str - ); - - // Final check - if still not enough, return error if balance <= gas_cost_with_buffer { return Err(anyhow::anyhow!( - "Still insufficient balance after faucet funding. Address: {}, Balance: {} wei, Gas cost: {} wei", - from_address_str, balance, gas_cost_with_buffer + "Still insufficient balance after faucet funding" )); } } - Err(e) => { - return Err(anyhow::anyhow!( - "Failed to fund address {} via faucet: {}", - from_address_str, - e - )); - } + Err(e) => return Err(e), } } - // Use actual gas cost (without buffer) for value calculation to maximize sweep amount let value_to_send = balance - gas_cost_with_buffer; let tx = TransactionRequest::default() @@ -307,21 +373,36 @@ where let pending_tx = provider.send_transaction(tx).await?; let receipt = pending_tx.get_receipt().await?; - info!("Swept funds! Tx hash: {:?}", receipt.transaction_hash); + { + let chain_name = self.chain.name.clone(); + let tx_hash = tx_hash.to_string(); + self.db + .blocking(move |db| db.mark_deposit_swept(&chain_name, &tx_hash)) + .await?; + } - // Update DB - self.db.mark_deposit_swept(tx_hash)?; + let webhook_id = format!("{}:{}", self.chain.name, tx_hash); + if let Err(e) = self + .enqueue_deposit_swept_webhook( + &webhook_id, + from_address_str, + registration_id, + tx_hash, + amount_str, + None, + ) + .await + { + error!( + "[{}] Failed to enqueue deposit_swept webhook for {webhook_id}: {e:?}", + self.chain.name + ); + } - // Send Webhook (for native deposits, id = tx_hash) - // account_id = Polygon address, registration_id = original id from registration - self.send_webhook( - tx_hash, - from_address_str, - registration_id, - tx_hash, - amount_str, - ) - .await?; + info!( + "[{}] Swept funds! Tx hash: {:?}", + self.chain.name, receipt.transaction_hash + ); Ok(()) } @@ -333,332 +414,218 @@ where deposit: &Erc20Deposit, ) -> Result<()> where - SP: Provider, + SP: Provider, { let from_address = Address::from_str(from_address_str)?; - let to_address = Address::from_str(&self.config.treasury_address)?; + let to_address = Address::from_str(&self.chain.treasury_address)?; let token_address = Address::from_str(&deposit.token_address)?; - // Check token balance first let token_balance = get_token_balance(&self.provider, token_address, from_address).await?; if deposit.token_symbol.len() > 5 { - error!( - "Skipping ERC20 deposit token symbol '{}' exceeds 5 characters for deposit: {}", - deposit.token_symbol, deposit.key - ); - self.db.mark_erc20_deposit_swept(&deposit.key)?; + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| db.mark_erc20_deposit_swept(&chain_name, &key)) + .await?; return Ok(()); } if token_balance.is_zero() { - let retry_count = self.db.increment_zero_balance_count(&deposit.key)?; + let retry_count = { + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| db.increment_zero_balance_count(&chain_name, &key)) + .await? + }; if retry_count >= MAX_ZERO_BALANCE_RETRIES { - self.db.mark_erc20_deposit_swept(&deposit.key)?; - info!( - "Marking deposit {} as swept after {} zero-balance retries (funds likely consolidated in a prior sweep)", - deposit.key, retry_count - ); - } else { - info!( - "ERC20 balance is zero for {} at {}, retry {}/{} (will retry next cycle)", - deposit.token_symbol, from_address_str, retry_count, MAX_ZERO_BALANCE_RETRIES - ); + let chain_name = self.chain.name.clone(); + let key = deposit.key.clone(); + self.db + .blocking(move |db| db.mark_erc20_deposit_swept(&chain_name, &key)) + .await?; } return Ok(()); } - // Sweep the full on-chain token balance to ensure all funds are moved to treasury, - // regardless of how many individual deposits contributed to this balance. let amount = token_balance; - - // Build ERC20 transfer call data for gas estimation let transfer_call = IERC20::transferCall { to: to_address, amount, }; - let call_data = transfer_call.abi_encode(); - // Build transaction request for gas estimation let tx_for_estimate = TransactionRequest::default() .with_from(from_address) .with_to(token_address) .with_input(call_data.clone()); - // Estimate actual gas needed for this specific transaction let estimated_gas = provider.estimate_gas(&tx_for_estimate).await?; - let gas_limit_with_buffer = estimated_gas + (estimated_gas / 10); - - // Get current fee estimates (EIP-1559 compatible) let fee_estimate = provider.estimate_eip1559_fees(None).await?; let max_fee_per_gas = fee_estimate.max_fee_per_gas; - - // Calculate worst-case gas cost with safety buffer - // Add extra 10% buffer on top for gas price fluctuations let estimated_gas_cost = U256::from(gas_limit_with_buffer) * U256::from(max_fee_per_gas); let estimated_gas_cost_with_buffer = estimated_gas_cost + (estimated_gas_cost / U256::from(10)); - info!( - "Gas estimation for ERC20 transfer: gas={}, max_fee_per_gas={}, estimated_cost={} wei (with 50% buffer: {} wei)", - gas_limit_with_buffer, max_fee_per_gas, estimated_gas_cost, estimated_gas_cost_with_buffer - ); - - // Check native balance (need gas for ERC20 transfer) let mut native_balance = provider.get_balance(from_address).await?; - info!( - "Native balance: {} wei for address {}", - native_balance, from_address_str - ); - - // If insufficient balance for gas, try to fund via faucet if native_balance < estimated_gas_cost_with_buffer { - info!( - "Insufficient native balance for gas. Address: {}, Balance: {} wei, Estimated gas cost: {} wei. Attempting to fund via faucet...", - from_address_str, native_balance, estimated_gas_cost_with_buffer - ); - - // Fund the address via faucet match self.faucet.fund_new_address(from_address_str).await { - Ok(tx_hash) => { - info!( - "Successfully funded address {} via faucet with tx: {}. Waiting for balance update...", - from_address_str, tx_hash - ); - - // Wait a bit for the transaction to be processed and balance to update + Ok(_) => { sleep(Duration::from_secs(2)).await; - - // Re-check the balance after funding native_balance = provider.get_balance(from_address).await?; - info!( - "Updated native balance after faucet funding: {} wei for address {}", - native_balance, from_address_str - ); - - // Final check - if still not enough, error out if native_balance < estimated_gas_cost_with_buffer { - error!( - "Still insufficient balance after faucet funding. Address: {}, Balance: {} wei, Required: {} wei", - from_address_str, native_balance, estimated_gas_cost_with_buffer - ); return Err(anyhow::anyhow!( - "Insufficient native balance for gas even after faucet funding. Need at least {} wei, but only have {} wei", - estimated_gas_cost_with_buffer, - native_balance + "Insufficient native balance for gas even after faucet funding" )); } } - Err(e) => { - error!( - "Failed to fund address {} via faucet: {:?}", - from_address_str, e - ); - return Err(anyhow::anyhow!( - "Insufficient native balance for gas and faucet funding failed. Address: {}, Balance: {} wei, Error: {}", - from_address_str, - native_balance, - e - )); - } + Err(e) => return Err(e), } } - info!( - "Native balance check passed: {} wei (gas estimate with buffer: {} wei)", - native_balance, estimated_gas_cost_with_buffer - ); - - info!( - "Sweeping {} {} tokens (raw: {}) from {} to {} (native balance: {} wei)", - token_balance, - deposit.token_symbol, - token_balance, - from_address, - to_address, - native_balance - ); - - // Build final transaction with estimated gas limit let tx = TransactionRequest::default() .with_to(token_address) .with_input(call_data) .with_gas_limit(gas_limit_with_buffer); - info!("++++++++++++++++"); - info!("Transaction request: {:?}", tx); - info!("++++++++++++++++"); - let pending_tx = provider.send_transaction(tx).await?; - info!("++++++++++++++++"); - info!("Pending transaction: {:?}", pending_tx.tx_hash()); - info!("++++++++++++++++"); let receipt = pending_tx.get_receipt().await?; - info!("++++++++++++++++"); - info!("Receipt: {:?}", receipt.transaction_hash); - info!("++++++++++++++++"); - let sweep_tx_hash = receipt.transaction_hash.to_string(); - // Mark ALL detected deposits for this account+token as swept (consolidates multi-deposit sweeps) let registration_id = &deposit.account_id; - let marked_keys = self - .db - .mark_erc20_deposits_swept_for_account_token(registration_id, &deposit.token_address)?; - - // Store sweep tx hash for all marked deposits (audit trail + webhook idempotency key) - self.db - .set_sweep_tx_hash_for_keys(&marked_keys, &sweep_tx_hash)?; + let swept = { + let chain_name = self.chain.name.clone(); + let reg_id = registration_id.clone(); + let token_address = deposit.token_address.clone(); + self.db + .blocking(move |db| { + db.mark_erc20_deposits_swept_for_account_token( + &chain_name, + ®_id, + &token_address, + ) + }) + .await? + }; - info!( - "Marked {} ERC20 deposit(s) as swept for account={}, token={}, sweep_tx={}: {:?}", - marked_keys.len(), - registration_id, - deposit.token_symbol, - sweep_tx_hash, - marked_keys - ); + let keys: Vec = swept.iter().map(|(k, _)| k.clone()).collect(); + { + let chain_name = self.chain.name.clone(); + let keys = keys.clone(); + let sweep_tx_hash = sweep_tx_hash.clone(); + self.db + .blocking(move |db| db.set_sweep_tx_hash_for_keys(&chain_name, &keys, &sweep_tx_hash)) + .await?; + } - // Fetch token decimals from DB - let token_decimals = self - .db - .get_token_metadata(&deposit.token_address)? - .map(|(_, decimals, _)| decimals); - - // Send Webhook with the actual swept amount and the sweep tx hash for consumer deduplication - let swept_amount_str = amount.to_string(); - let webhook_info = Erc20WebhookInfo { - id: &deposit.key, - account_id: from_address_str, - registration_id, - deposit_key: &deposit.key, - amount: &swept_amount_str, - token_symbol: &deposit.token_symbol, - token_address: &deposit.token_address, - token_decimals, - sweep_tx_hash: &sweep_tx_hash, + let token_decimals = { + let chain_name = self.chain.name.clone(); + let token_address = deposit.token_address.clone(); + self.db + .blocking(move |db| db.get_token_metadata(&chain_name, &token_address)) + .await? + .map(|(_, decimals, _)| decimals) }; - self.send_erc20_webhook(&webhook_info).await?; + + for (key, dep_amount) in &swept { + let webhook_id = format!("{}:{}", self.chain.name, key); + let webhook_info = Erc20WebhookInfo { + id: &webhook_id, + chain: &self.chain.name, + chain_id: self.chain.chain_id, + account_id: from_address_str, + registration_id, + deposit_key: key, + amount: dep_amount, + token_symbol: &deposit.token_symbol, + token_address: &deposit.token_address, + token_decimals, + sweep_tx_hash: &sweep_tx_hash, + }; + if let Err(e) = self.enqueue_erc20_webhook(&webhook_info).await { + error!( + "[{}] swept webhook enqueue failed for {key}: {e:?}", + self.chain.name + ); + } + } Ok(()) } - async fn send_webhook( + async fn enqueue_deposit_swept_webhook( &self, id: &str, account_id: &str, registration_id: &str, tx_hash: &str, amount: &str, + erc20_info: Option<&Erc20WebhookInfo<'_>>, ) -> Result<()> { - // Get the webhook URL using registration_id (the key in ACCOUNTS table) - let Some(webhook_url) = self.db.get_webhook_url(registration_id)? else { - error!( - "No webhook URL found for registration_id: {}", - registration_id - ); - return Ok(()); + let webhook_url = { + let reg_id = registration_id.to_string(); + self.db + .blocking(move |db| db.get_webhook_url(®_id)) + .await? }; - - let client = reqwest::Client::new(); - let payload = serde_json::json!({ - "id": id, - "event": "deposit_swept", - "account_id": account_id, - "registration_id": registration_id, - "original_tx_hash": tx_hash, - "amount": amount, - "token_type": "native" - }); - - info!("++++++++++++++++"); - info!("Webhook URL: {}", webhook_url); - info!("Sending webhook: {:?}", payload); - info!("++++++++++++++++"); - - let mut request = client.post(&webhook_url).json(&payload); - - // Add JWT authorization header if configured - if let Some(ref token) = self.config.webhook_jwt_token { - request = request.header("Authorization", format!("Bearer {}", token)); - } - - let res = request.send().await; - - match res { - Ok(r) => info!( - "Webhook sent to {}: status={}, registration_id={}", - webhook_url, - r.status(), - registration_id - ), - Err(e) => error!("Failed to send webhook to {}: {:?}", webhook_url, e), - } - - Ok(()) - } - - async fn send_erc20_webhook(&self, info: &Erc20WebhookInfo<'_>) -> Result<()> { - // Get the webhook URL using registration_id (the key in ACCOUNTS table) - let Some(webhook_url) = self.db.get_webhook_url(info.registration_id)? else { - error!( - "No webhook URL found for registration_id: {}", - info.registration_id - ); + let Some(webhook_url) = webhook_url else { return Ok(()); }; - let client = reqwest::Client::new(); - let mut payload = serde_json::json!({ - "id": info.id, - "event": "deposit_swept", - "account_id": info.account_id, - "registration_id": info.registration_id, - "original_tx_hash": info.deposit_key.split(':').nth(0).unwrap(), - "amount": info.amount, - "token_type": "erc20", - "token_symbol": info.token_symbol, - "token_address": info.token_address, - "sweep_tx_hash": info.sweep_tx_hash - }); - - info!("++++++++++++++++"); - info!("Webhook URL: {}", webhook_url); - info!("Sending webhook: {:?}", payload); - info!("++++++++++++++++"); - - // Add decimals if available - if let Some(decimals) = info.token_decimals { - payload["token_decimals"] = serde_json::json!(decimals); - } - - let mut request = client.post(&webhook_url).json(&payload); - - // Add JWT authorization header if configured - if let Some(ref token) = self.config.webhook_jwt_token { - request = request.header("Authorization", format!("Bearer {}", token)); - } - - let res = request.send().await; + let payload = if let Some(info) = erc20_info { + let mut payload = serde_json::json!({ + "id": info.id, + "chain": info.chain, + "chain_id": info.chain_id, + "event": "deposit_swept", + "account_id": info.account_id, + "registration_id": info.registration_id, + "original_tx_hash": info.deposit_key.split(':').next().unwrap_or(info.deposit_key), + "amount": info.amount, + "token_type": "erc20", + "token_symbol": info.token_symbol, + "token_address": info.token_address, + "sweep_tx_hash": info.sweep_tx_hash + }); + if let Some(decimals) = info.token_decimals { + payload["token_decimals"] = serde_json::json!(decimals); + } + payload + } else { + serde_json::json!({ + "id": id, + "chain": self.chain.name, + "chain_id": self.chain.chain_id, + "event": "deposit_swept", + "account_id": account_id, + "registration_id": registration_id, + "original_tx_hash": tx_hash, + "amount": amount, + "token_type": "native" + }) + }; - match res { - Ok(r) => info!( - "ERC20 Webhook sent to {}: status={}, registration_id={}", - webhook_url, - r.status(), - info.registration_id - ), - Err(e) => error!("Failed to send ERC20 webhook to {}: {:?}", webhook_url, e), - } + self.deliverer + .enqueue(&webhook_url, registration_id, payload) + .await + } - Ok(()) + async fn enqueue_erc20_webhook(&self, info: &Erc20WebhookInfo<'_>) -> Result<()> { + self.enqueue_deposit_swept_webhook( + info.id, + info.account_id, + info.registration_id, + info.deposit_key, + info.amount, + Some(info), + ) + .await } } -// ERC20 helper types and functions use alloy::sol; sol! { @@ -675,15 +642,46 @@ sol! { } } -async fn get_token_balance( - provider: &alloy::providers::RootProvider, +async fn get_token_balance( + provider: &RootProvider, token_address: Address, owner_address: Address, -) -> Result -where - T: alloy::transports::Transport + Clone, -{ +) -> Result { let contract = IERC20::new(token_address, provider); let balance = contract.balanceOf(owner_address).call().await?._0; Ok(balance) } + +#[cfg(test)] +mod tests { + use super::{is_permanent_sweep_error, is_transient_funding_error}; + + #[test] + fn test_is_permanent_sweep_error() { + assert!(is_permanent_sweep_error( + "buffer overrun while deserializing" + )); + assert!(is_permanent_sweep_error( + "ABI decode failed: Deserialization error" + )); + assert!(!is_permanent_sweep_error("execution reverted")); + assert!(!is_permanent_sweep_error("network timeout")); + } + + #[test] + fn test_is_transient_funding_error() { + assert!(is_transient_funding_error( + "Faucet has insufficient balance to fund new address" + )); + assert!(is_transient_funding_error( + "Insufficient native balance for gas even after faucet funding" + )); + assert!(is_transient_funding_error( + "Still insufficient balance after faucet funding" + )); + assert!(!is_transient_funding_error("execution reverted")); + assert!(!is_transient_funding_error( + "buffer overrun while deserializing" + )); + } +} diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..d0549bb --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,127 @@ +use crate::config::{ChainConfig, Config, MinDepositSettings}; +use crate::db::Db; +use crate::webhook::WebhookDeliverer; +use alloy::providers::{ProviderBuilder, RootProvider}; +use alloy::transports::BoxTransport; +use std::collections::HashSet; +use std::sync::Arc; + +pub const TEST_CHAIN: &str = "polygon"; +pub const TEST_MNEMONIC: &str = "test test test test test test test test test test test junk"; +pub const TEST_FAUCET: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; +pub const TEST_TREASURY: &str = "0x9999999999999999999999999999999999999999"; +pub const TEST_EXISTENTIAL: &str = "10000000000000000"; + +pub fn default_webhook_config_fields() -> (u32, u64, u64, u32, u64) { + (5, 1000, 30, 50, 60) +} + +pub fn test_webhook_deliverer(db: Db) -> Arc { + Arc::new(WebhookDeliverer::new_for_test(db, None, 3, 10, 60, 50, 60).unwrap()) +} + +#[allow(dead_code)] +pub fn test_webhook_deliverer_fast(db: Db) -> Arc { + Arc::new(WebhookDeliverer::new_for_test(db, None, 3, 10, 5, 50, 60).unwrap()) +} + +pub fn test_chain_config(rpc_url: impl Into) -> ChainConfig { + ChainConfig { + name: TEST_CHAIN.to_string(), + chain_id: 137, + rpc_url: rpc_url.into(), + treasury_address: TEST_TREASURY.to_string(), + faucet_address: TEST_FAUCET.to_string(), + existential_deposit: TEST_EXISTENTIAL.to_string(), + block_offset_from_head: 0, + poll_interval: 1, + get_logs_max_retries: 30, + get_logs_delay_ms: 50, + catch_up_chunk_size: 500, + block_fetch_concurrency: 10, + min_deposits: MinDepositSettings::default(), + allowed_token_addresses: HashSet::new(), + } +} + +pub fn test_chain_config_named(name: &str, rpc_url: impl Into) -> ChainConfig { + let mut cfg = test_chain_config(rpc_url); + cfg.name = name.to_string(); + if name == "base" { + cfg.chain_id = 8453; + } else if name == "polygon" { + cfg.chain_id = 137; + } + cfg +} + +pub fn test_config_multichain( + db_path: impl Into, + base_rpc: impl Into, + polygon_rpc: impl Into, +) -> Config { + let ( + webhook_max_retries, + webhook_retry_delay_ms, + webhook_retry_poll_interval_secs, + webhook_retry_batch_size, + webhook_lease_seconds, + ) = default_webhook_config_fields(); + Config { + database_url: db_path.into(), + mnemonic: TEST_MNEMONIC.to_string(), + faucet_mnemonic: TEST_MNEMONIC.to_string(), + port: 3000, + webhook_jwt_token: None, + webhook_max_retries, + webhook_retry_delay_ms, + webhook_retry_poll_interval_secs, + webhook_retry_batch_size, + webhook_lease_seconds, + legacy_chain: TEST_CHAIN.to_string(), + db_read_pool_size: 20, + chains: vec![ + test_chain_config_named("base", base_rpc), + test_chain_config_named("polygon", polygon_rpc), + ], + } +} + +pub fn test_config(db_path: impl Into, rpc_url: impl Into) -> Config { + let ( + webhook_max_retries, + webhook_retry_delay_ms, + webhook_retry_poll_interval_secs, + webhook_retry_batch_size, + webhook_lease_seconds, + ) = default_webhook_config_fields(); + Config { + database_url: db_path.into(), + mnemonic: TEST_MNEMONIC.to_string(), + faucet_mnemonic: TEST_MNEMONIC.to_string(), + port: 3000, + webhook_jwt_token: None, + webhook_max_retries, + webhook_retry_delay_ms, + webhook_retry_poll_interval_secs, + webhook_retry_batch_size, + webhook_lease_seconds, + legacy_chain: TEST_CHAIN.to_string(), + db_read_pool_size: 20, + chains: vec![test_chain_config(rpc_url)], + } +} + +pub fn http_provider_boxed(url: &str) -> RootProvider { + ProviderBuilder::new() + .on_http(url.parse().expect("invalid test rpc url")) + .boxed() +} + +pub fn chain_treasury(config: &Config) -> String { + config.chains[0].treasury_address.clone() +} + +pub fn chain_existential(config: &Config) -> String { + config.chains[0].existential_deposit.clone() +} diff --git a/src/tests.rs b/src/tests.rs index 59094f0..1c7f8fd 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,11 +1,18 @@ -use crate::config::{Config, ProviderUrl}; +use crate::config::{parse_allowed_token_addresses_env, Config, MinDepositSettings}; use crate::db::Db; use crate::faucet::Faucet; use crate::monitor::Monitor; use crate::sweeper::Sweeper; +use crate::test_support::{ + self, http_provider_boxed, test_chain_config, test_config, test_webhook_deliverer, TEST_CHAIN, +}; +use crate::traits::Service; use crate::wallet::Wallet; -use crate::{HotWalletService, VerifyTransferRequest, VerifyTransferResponse}; -use alloy::providers::ProviderBuilder; +use crate::webhook::{WebhookDeliverer, WebhookRetryService}; +use crate::{ + HotWalletService, RegisterRequest, RetrySweepRequest, RetryWebhookRequest, + VerifyTransferRequest, VerifyTransferResponse, +}; use serde_json::json; use std::sync::Arc; use tempfile::NamedTempFile; @@ -55,16 +62,16 @@ fn test_db_operations() { // Test Deposits let tx_hash = "0xabc"; let amount = "100"; - db.record_deposit(tx_hash, id, amount).unwrap(); + db.record_deposit(TEST_CHAIN, tx_hash, id, amount).unwrap(); - let deposits = db.get_detected_deposits().unwrap(); + let deposits = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits.len(), 1); assert_eq!(deposits[0].0, tx_hash); assert_eq!(deposits[0].2, amount); // Test Sweep Mark - db.mark_deposit_swept(tx_hash).unwrap(); - let deposits_after = db.get_detected_deposits().unwrap(); + db.mark_deposit_swept(TEST_CHAIN, tx_hash).unwrap(); + let deposits_after = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after.len(), 0); } @@ -76,25 +83,17 @@ async fn test_monitor_creation_with_http_provider() { let db_file = NamedTempFile::new().unwrap(); let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http("http://localhost:8545".to_string()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let _config = test_config(db_file.path().to_str().unwrap(), "http://localhost:8545"); // Create provider and monitor (no actual connection needed for this test) - let provider = ProviderBuilder::new().on_http("http://localhost:8545".parse().unwrap()); - let _monitor = Monitor::new(config, db.clone(), provider); + let provider = http_provider_boxed("http://localhost:8545"); + let deliverer = test_webhook_deliverer(db.clone()); + let _monitor = Monitor::new( + test_chain_config("http://localhost:8545"), + deliverer, + db.clone(), + provider, + ); } #[test] @@ -112,22 +111,22 @@ fn test_monitor_db_operations() { .unwrap(); // Verify no deposits initially - let deposits_before = db.get_detected_deposits().unwrap(); + let deposits_before = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_before.len(), 0); // Simulate Monitor recording a deposit - db.record_deposit("0xtxhash", "test_user", "1000000000000000000") + db.record_deposit(TEST_CHAIN, "0xtxhash", "test_user", "1000000000000000000") .unwrap(); - let deposits_after = db.get_detected_deposits().unwrap(); + let deposits_after = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after.len(), 1); assert_eq!(deposits_after[0].0, "0xtxhash"); assert_eq!(deposits_after[0].1, "test_user"); assert_eq!(deposits_after[0].2, "1000000000000000000"); // Test block tracking - db.set_last_processed_block(100).unwrap(); - assert_eq!(db.get_last_processed_block().unwrap(), 100); + db.set_last_processed_block(TEST_CHAIN, 100).unwrap(); + assert_eq!(db.get_last_processed_block(TEST_CHAIN).unwrap(), 100); } #[test] @@ -163,34 +162,28 @@ async fn test_sweeper_creation() { let db_file = NamedTempFile::new().unwrap(); let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http("http://localhost:8545".to_string()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), "http://localhost:8545"); let wallet = Wallet::new(config.mnemonic.clone()); - let provider = ProviderBuilder::new().on_http("http://localhost:8545".parse().unwrap()); + let provider = http_provider_boxed("http://localhost:8545"); let faucet = Arc::new( Faucet::new( config.faucet_mnemonic.clone(), provider.clone(), - &config.existential_deposit, + &test_support::chain_existential(&config), ) .unwrap(), ); - Sweeper::new(config, db, wallet, provider, faucet); + let deliverer = test_webhook_deliverer(db.clone()); + Sweeper::new( + config.chains[0].clone(), + deliverer, + db, + wallet, + provider.clone(), + faucet, + ); } #[test] @@ -206,18 +199,18 @@ fn test_sweeper_deposit_workflow() { // Register account and create a deposit db.register_account("test_user", 0, &user_address, "https://webhook.example.com") .unwrap(); - db.record_deposit("0xtx123", "test_user", "1000000000000000000") + db.record_deposit(TEST_CHAIN, "0xtx123", "test_user", "1000000000000000000") .unwrap(); // Verify deposit exists - let deposits_before = db.get_detected_deposits().unwrap(); + let deposits_before = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_before.len(), 1); assert_eq!(deposits_before[0].0, "0xtx123"); assert_eq!(deposits_before[0].1, "test_user"); // Simulate sweep completion - db.mark_deposit_swept("0xtx123").unwrap(); - let deposits_after = db.get_detected_deposits().unwrap(); + db.mark_deposit_swept(TEST_CHAIN, "0xtx123").unwrap(); + let deposits_after = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after.len(), 0); // Verify the account details are correct for deriving keys @@ -272,28 +265,28 @@ fn test_sweeper_multiple_deposits() { .unwrap(); // Record deposits for each - db.record_deposit("0xtx1", "user_0", "1000000000000000000") + db.record_deposit(TEST_CHAIN, "0xtx1", "user_0", "1000000000000000000") .unwrap(); - db.record_deposit("0xtx2", "user_1", "2000000000000000000") + db.record_deposit(TEST_CHAIN, "0xtx2", "user_1", "2000000000000000000") .unwrap(); - db.record_deposit("0xtx3", "user_2", "3000000000000000000") + db.record_deposit(TEST_CHAIN, "0xtx3", "user_2", "3000000000000000000") .unwrap(); // Verify all deposits are tracked - let deposits = db.get_detected_deposits().unwrap(); + let deposits = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits.len(), 3); // Process one deposit at a time - db.mark_deposit_swept("0xtx1").unwrap(); - let deposits_after_1 = db.get_detected_deposits().unwrap(); + db.mark_deposit_swept(TEST_CHAIN, "0xtx1").unwrap(); + let deposits_after_1 = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after_1.len(), 2); - db.mark_deposit_swept("0xtx2").unwrap(); - let deposits_after_2 = db.get_detected_deposits().unwrap(); + db.mark_deposit_swept(TEST_CHAIN, "0xtx2").unwrap(); + let deposits_after_2 = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after_2.len(), 1); - db.mark_deposit_swept("0xtx3").unwrap(); - let deposits_after_3 = db.get_detected_deposits().unwrap(); + db.mark_deposit_swept(TEST_CHAIN, "0xtx3").unwrap(); + let deposits_after_3 = db.get_detected_deposits(TEST_CHAIN).unwrap(); assert_eq!(deposits_after_3.len(), 0); } @@ -312,6 +305,59 @@ impl wiremock::Match for BodyContains { } } +/// Case-insensitive body substring matcher. Used to check for a token address in an +/// `eth_getLogs` request without depending on whether alloy serializes `Address` in +/// checksummed or lowercase hex form. +fn body_json_contains_ci(substring: &str) -> impl wiremock::Match { + BodyContainsCi(substring.to_lowercase()) +} + +struct BodyContainsCi(String); +impl wiremock::Match for BodyContainsCi { + fn matches(&self, request: &wiremock::Request) -> bool { + let body_str = String::from_utf8_lossy(&request.body).to_lowercase(); + body_str.contains(&self.0) + } +} + +/// Matches a JSON field with an exact hex-encoded numeric value, e.g. +/// `field_hex("fromBlock", 1)` matches `"fromBlock":"0x1"` but not `"fromBlock":"0x15"`. +/// Plain substring matching on the hex digits alone would conflate those two. +fn field_hex(field: &str, value: u64) -> String { + format!("\"{field}\":\"0x{value:x}\"") +} + +fn empty_block_rpc_response() -> serde_json::Value { + let block_hash = "0x000000000000000000000000000000000000000000000000000000000000000a"; + let parent_hash = "0x0000000000000000000000000000000000000000000000000000000000000009"; + let root_hash = "0x0000000000000000000000000000000000000000000000000000000000000000"; + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "number": "0xA", + "hash": block_hash, + "parentHash": parent_hash, + "nonce": "0x0000000000000000", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": root_hash, + "stateRoot": root_hash, + "receiptsRoot": root_hash, + "miner": "0x0000000000000000000000000000000000000000", + "difficulty": "0x0", + "totalDifficulty": "0x0", + "extraData": "0x", + "size": "0x0", + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "transactions": [], + "uncles": [] + } + }) +} + #[tokio::test] async fn test_verify_native_transfer_success() { let _ = tracing_subscriber::fmt::try_init(); @@ -320,21 +366,7 @@ async fn test_verify_native_transfer_success() { let rpc_server = MockServer::start().await; let db_file = NamedTempFile::new().unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); let to_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; let tx_hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; @@ -395,10 +427,11 @@ async fn test_verify_native_transfer_success() { .await; // Create the service - let service = HotWalletService::new_http(config).await.unwrap(); + let service = HotWalletService::new(config).await.unwrap(); // Test verification let request = VerifyTransferRequest { + chain: TEST_CHAIN.to_string(), tx_hash: tx_hash.to_string(), to_address: to_address.to_string(), amount: amount.to_string(), @@ -436,21 +469,7 @@ async fn test_verify_native_transfer_amount_mismatch() { let rpc_server = MockServer::start().await; let db_file = NamedTempFile::new().unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); let to_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; let tx_hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; @@ -508,10 +527,11 @@ async fn test_verify_native_transfer_amount_mismatch() { .mount(&rpc_server) .await; - let service = HotWalletService::new_http(config).await.unwrap(); + let service = HotWalletService::new(config).await.unwrap(); // Request expects 2 ETH but tx only has 1 ETH let request = VerifyTransferRequest { + chain: TEST_CHAIN.to_string(), tx_hash: tx_hash.to_string(), to_address: to_address.to_string(), amount: "2000000000000000000".to_string(), // 2 ETH - more than actual @@ -544,21 +564,7 @@ async fn test_verify_erc20_transfer_success() { let rpc_server = MockServer::start().await; let db_file = NamedTempFile::new().unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); let to_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; let token_address = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; // USDT @@ -632,9 +638,10 @@ async fn test_verify_erc20_transfer_success() { .mount(&rpc_server) .await; - let service = HotWalletService::new_http(config).await.unwrap(); + let service = HotWalletService::new(config).await.unwrap(); let request = VerifyTransferRequest { + chain: TEST_CHAIN.to_string(), tx_hash: tx_hash.to_string(), to_address: to_address.to_string(), amount: amount.to_string(), @@ -672,21 +679,7 @@ async fn test_verify_erc20_transfer_symbol_mismatch() { let rpc_server = MockServer::start().await; let db_file = NamedTempFile::new().unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); let to_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; let token_address = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; @@ -729,10 +722,11 @@ async fn test_verify_erc20_transfer_symbol_mismatch() { .mount(&rpc_server) .await; - let service = HotWalletService::new_http(config).await.unwrap(); + let service = HotWalletService::new(config).await.unwrap(); // Request expects USDC but contract returns USDT let request = VerifyTransferRequest { + chain: TEST_CHAIN.to_string(), tx_hash: tx_hash.to_string(), to_address: to_address.to_string(), amount: "1000000".to_string(), @@ -767,21 +761,7 @@ async fn test_verify_transfer_reverted_transaction() { let rpc_server = MockServer::start().await; let db_file = NamedTempFile::new().unwrap(); - let config = Config { - database_url: db_file.path().to_str().unwrap().to_string(), - provider_url: ProviderUrl::Http(rpc_server.uri()), - mnemonic: "test test test test test test test test test test test junk".to_string(), - treasury_address: "0x9999999999999999999999999999999999999999".to_string(), - port: 3000, - poll_interval: 1, - block_offset_from_head: 0, - faucet_mnemonic: "test test test test test test test test test test test junk".to_string(), - existential_deposit: "10000000000000000".to_string(), - faucet_address: "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".to_string(), - get_logs_max_retries: 30, - get_logs_delay_ms: 50, - webhook_jwt_token: None, - }; + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); let to_address = "0x742d35Cc6634C0532925a3b844Bc454e4438f44e"; let tx_hash = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; @@ -840,9 +820,10 @@ async fn test_verify_transfer_reverted_transaction() { .mount(&rpc_server) .await; - let service = HotWalletService::new_http(config).await.unwrap(); + let service = HotWalletService::new(config).await.unwrap(); let request = VerifyTransferRequest { + chain: TEST_CHAIN.to_string(), tx_hash: tx_hash.to_string(), to_address: to_address.to_string(), amount: "1000000000000000000".to_string(), @@ -870,19 +851,23 @@ fn test_erc20_deposit_failure_tracking() { let db_file = NamedTempFile::new().unwrap(); let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); - db.record_erc20_deposit("0xabc", 1, "user_1", "1000000", "0xtoken", "USDC") - .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, "0xabc", 1, "user_1", "1000000", "0xtoken", "USDC", + ) + .unwrap(); - assert_eq!(db.get_detected_erc20_deposits().unwrap().len(), 1); + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 1); for i in 1..=5 { - let count = db.increment_sweep_failure_count("0xabc:1").unwrap(); + let count = db + .increment_sweep_failure_count(TEST_CHAIN, "0xabc:1") + .unwrap(); assert_eq!(count, i); } - db.mark_erc20_deposit_failed("0xabc:1").unwrap(); + db.mark_erc20_deposit_failed(TEST_CHAIN, "0xabc:1").unwrap(); - assert_eq!(db.get_detected_erc20_deposits().unwrap().len(), 0); + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); } #[test] @@ -890,21 +875,2636 @@ fn test_erc20_bulk_mark_failed_for_account_token() { let db_file = NamedTempFile::new().unwrap(); let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); - db.record_erc20_deposit("0xaaa", 1, "user_1", "1000000", "0xtoken_a", "USDC") - .unwrap(); - db.record_erc20_deposit("0xbbb", 2, "user_1", "2000000", "0xtoken_a", "USDC") - .unwrap(); - db.record_erc20_deposit("0xccc", 3, "user_1", "3000000", "0xtoken_b", "USDT") - .unwrap(); - - assert_eq!(db.get_detected_erc20_deposits().unwrap().len(), 3); + db.record_erc20_deposit( + TEST_CHAIN, + "0xaaa", + 1, + "user_1", + "1000000", + "0xtoken_a", + "USDC", + ) + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xbbb", + 2, + "user_1", + "2000000", + "0xtoken_a", + "USDC", + ) + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xccc", + 3, + "user_1", + "3000000", + "0xtoken_b", + "USDT", + ) + .unwrap(); + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 3); let failed = db - .mark_erc20_deposits_failed_for_account_token("user_1", "0xtoken_a") + .mark_erc20_deposits_failed_for_account_token(TEST_CHAIN, "user_1", "0xtoken_a") .unwrap(); assert_eq!(failed.len(), 2); - let remaining = db.get_detected_erc20_deposits().unwrap(); + let remaining = db.get_detected_erc20_deposits(TEST_CHAIN).unwrap(); assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].token_symbol, "USDT"); } + +// ========== Dust Attack / Min Deposit Tests ========== + +#[test] +fn test_parse_min_deposits_env_valid_and_empty() { + use crate::config::parse_min_deposits_env; + use alloy::primitives::U256; + + let empty = parse_min_deposits_env(String::new()).unwrap(); + assert!(empty.is_empty()); + + let parsed = parse_min_deposits_env( + "0xAbC=10000,0xc2132d05d31c914a87c6611c10748aeb04b58e8f=20000".to_string(), + ) + .unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed.get("0xabc").copied(), Some(U256::from(10000u64))); + assert_eq!( + parsed + .get("0xc2132d05d31c914a87c6611c10748aeb04b58e8f") + .copied(), + Some(U256::from(20000u64)) + ); +} + +#[test] +fn test_parse_min_deposits_env_skips_malformed_segments() { + use crate::config::parse_min_deposits_env; + use alloy::primitives::U256; + + let parsed = parse_min_deposits_env("badsegment,0xabc=10000,also-bad".to_string()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed.get("0xabc").copied(), Some(U256::from(10000u64))); +} + +#[test] +fn test_min_deposit_for_token_matches_checksummed_address() { + use alloy::primitives::U256; + use std::collections::HashMap; + + let mut per_token = HashMap::new(); + per_token.insert( + "0xc2132d05d31c914a87c6611c10748aeb04b58e8f".to_string(), + U256::from(10000u64), + ); + let settings = MinDepositSettings { + per_token, + default: U256::from(500u64), + native: U256::ZERO, + }; + + assert_eq!( + settings.for_token("0xc2132D05D31c914a87C6611C10748AEb04B58e8F"), + U256::from(10000u64) + ); + assert_eq!( + settings.for_token("0x0000000000000000000000000000000000000001"), + U256::from(500u64) + ); +} + +#[test] +fn test_erc20_bulk_mark_swept_returns_key_and_amount() { + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + + db.record_erc20_deposit( + TEST_CHAIN, + "0xaaa", + 1, + "user_1", + "3000000000", + "0xtoken_a", + "USDT", + ) + .unwrap(); + db.record_erc20_deposit(TEST_CHAIN, "0xbbb", 2, "user_1", "30", "0xtoken_a", "USDT") + .unwrap(); + + let swept = db + .mark_erc20_deposits_swept_for_account_token(TEST_CHAIN, "user_1", "0xtoken_a") + .unwrap(); + + assert_eq!(swept.len(), 2); + assert!(swept.contains(&("0xaaa:1".to_string(), "3000000000".to_string()))); + assert!(swept.contains(&("0xbbb:2".to_string(), "30".to_string()))); + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); +} + +#[tokio::test] +async fn test_monitor_skips_erc20_below_min_deposit() { + use alloy::primitives::U256; + use std::collections::HashMap; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let token_address = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut per_token = HashMap::new(); + per_token.insert( + "0xc2132d05d31c914a87c6611c10748aeb04b58e8f".to_string(), + U256::from(10000u64), + ); + + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + // 30 raw units, below threshold of 10000 + let amount_data = "0x000000000000000000000000000000000000000000000000000000000000001e"; + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xA" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "number": "0xA", + "hash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000009", + "transactions": [] + } + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [{ + "address": token_address, + "topics": [transfer_topic, from_topic, to_topic], + "data": amount_data, + "blockNumber": "0xA", + "transactionHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }] + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(1500)).await; + handle.abort(); + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); +} + +#[tokio::test] +async fn test_erc20_sweep_emits_per_deposit_webhooks() { + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let webhook_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + let token_address = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, &webhook_server.uri()) + .unwrap(); + db.store_token_metadata(TEST_CHAIN, token_address, "USDT", 6, "Tether USD") + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xaaa", + 1, + "user_1", + "3000000000", + token_address, + "USDT", + ) + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xbbb", + 2, + "user_1", + "30", + token_address, + "USDT", + ) + .unwrap(); + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&webhook_server) + .await; + + // balanceOf -> 3000000030 (aggregate on-chain balance) + Mock::given(method("POST")) + .and(body_json_contains("eth_call")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": "0x00000000000000000000000000000000000000000000000000000000b2d05e06" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_estimateGas")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x186a0" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_feeHistory")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], + "gasUsedRatio": [0.5], + "oldestBlock": "0x9", + "reward": [["0x3B9ACA00"]] + } + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0DE0B6B3A7640000" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionCount")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x00" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_chainId")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x89" + }))) + .mount(&rpc_server) + .await; + + let sweep_tx_hash = "0x00000000000000000000000000000000000000000000000000000000000000f1"; + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": sweep_tx_hash + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "transactionHash": sweep_tx_hash, + "transactionIndex": "0x1", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000b", + "blockNumber": "0xB", + "from": addr, + "to": token_address, + "cumulativeGasUsed": "0x186a0", + "gasUsed": "0x186a0", + "contractAddress": null, + "logs": [], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "effectiveGasPrice": "0x3B9ACA00" + } + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Arc::new( + Faucet::new( + config.faucet_mnemonic.clone(), + provider.clone(), + &test_support::chain_existential(&config), + ) + .unwrap(), + ); + let deliverer = test_webhook_deliverer(db.clone()); + let webhook_worker = WebhookRetryService::new(Arc::clone(&deliverer)); + tokio::spawn(async move { + webhook_worker.run().await; + }); + let sweeper = Sweeper::new( + config.chains[0].clone(), + deliverer, + db.clone(), + wallet, + provider.clone(), + faucet, + ); + let handle = tokio::spawn(async move { + sweeper.run().await; + }); + + let mut swept = false; + for _ in 0..20 { + if db + .get_detected_erc20_deposits(TEST_CHAIN) + .unwrap() + .is_empty() + { + swept = true; + break; + } + sleep(Duration::from_millis(300)).await; + } + handle.abort(); + assert!(swept, "ERC20 deposits should be marked swept"); + + sleep(Duration::from_millis(300)).await; + + for id in [ + format!("{TEST_CHAIN}:0xaaa:1"), + format!("{TEST_CHAIN}:0xbbb:2"), + ] { + let row = db + .get_webhook_delivery(&id, "deposit_swept") + .unwrap() + .unwrap_or_else(|| panic!("missing webhook delivery row for {id}")); + assert_eq!( + row.status, "delivered", + "webhook for {id} should be delivered" + ); + } + + let requests = webhook_server.received_requests().await.unwrap(); + let swept_events: Vec<_> = requests + .iter() + .filter(|req| { + let body = String::from_utf8_lossy(&req.body); + body.contains("deposit_swept") + }) + .collect(); + + assert_eq!( + swept_events.len(), + 2, + "expected one deposit_swept per deposit" + ); + + let mut amounts = Vec::new(); + let mut original_hashes = Vec::new(); + let mut sweep_hashes = Vec::new(); + for req in swept_events { + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); + amounts.push(body["amount"].as_str().unwrap().to_string()); + original_hashes.push(body["original_tx_hash"].as_str().unwrap().to_string()); + sweep_hashes.push(body["sweep_tx_hash"].as_str().unwrap().to_string()); + } + + amounts.sort(); + assert_eq!(amounts, vec!["30".to_string(), "3000000000".to_string()]); + assert!(original_hashes.contains(&"0xaaa".to_string())); + assert!(original_hashes.contains(&"0xbbb".to_string())); + assert!(sweep_hashes.iter().all(|h| h == sweep_tx_hash)); + assert!( + !amounts.contains(&"3000000030".to_string()), + "webhook must not use aggregate on-chain balance" + ); +} + +#[tokio::test] +async fn test_erc20_sweep_webhook_best_effort_on_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let webhook_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + let token_address = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, &webhook_server.uri()) + .unwrap(); + db.store_token_metadata(TEST_CHAIN, token_address, "USDT", 6, "Tether USD") + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xaaa", + 1, + "user_1", + "3000000000", + token_address, + "USDT", + ) + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xbbb", + 2, + "user_1", + "30", + token_address, + "USDT", + ) + .unwrap(); + + let webhook_attempts = StdArc::new(AtomicUsize::new(0)); + let attempts_for_mock = webhook_attempts.clone(); + Mock::given(method("POST")) + .respond_with(move |req: &wiremock::Request| { + let count = attempts_for_mock.fetch_add(1, Ordering::SeqCst); + let body = String::from_utf8_lossy(&req.body); + if body.contains("deposit_swept") && count == 0 { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200) + } + }) + .mount(&webhook_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_call")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": "0x00000000000000000000000000000000000000000000000000000000b2d05e06" + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_estimateGas")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x186a0" + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_feeHistory")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], + "gasUsedRatio": [0.5], + "oldestBlock": "0x9", + "reward": [["0x3B9ACA00"]] + } + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0DE0B6B3A7640000" + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionCount")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x00" + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_chainId")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x89" + }))) + .mount(&rpc_server) + .await; + + let sweep_tx_hash = "0x00000000000000000000000000000000000000000000000000000000000000f2"; + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": sweep_tx_hash + }))) + .mount(&rpc_server) + .await; + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "transactionHash": sweep_tx_hash, + "transactionIndex": "0x1", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000b", + "blockNumber": "0xB", + "from": addr, + "to": token_address, + "cumulativeGasUsed": "0x186a0", + "gasUsed": "0x186a0", + "contractAddress": null, + "logs": [], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "effectiveGasPrice": "0x3B9ACA00" + } + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Arc::new( + Faucet::new( + config.faucet_mnemonic.clone(), + provider.clone(), + &test_support::chain_existential(&config), + ) + .unwrap(), + ); + let deliverer = test_webhook_deliverer(db.clone()); + let webhook_worker = WebhookRetryService::new(Arc::clone(&deliverer)); + tokio::spawn(async move { + webhook_worker.run().await; + }); + let sweeper = Sweeper::new( + config.chains[0].clone(), + deliverer, + db.clone(), + wallet, + provider.clone(), + faucet, + ); + let handle = tokio::spawn(async move { + sweeper.run().await; + }); + + for _ in 0..20 { + if db + .get_detected_erc20_deposits(TEST_CHAIN) + .unwrap() + .is_empty() + { + break; + } + sleep(Duration::from_millis(300)).await; + } + handle.abort(); + + let row_a = db + .get_webhook_delivery(&format!("{TEST_CHAIN}:0xaaa:1"), "deposit_swept") + .unwrap(); + let row_b = db + .get_webhook_delivery(&format!("{TEST_CHAIN}:0xbbb:2"), "deposit_swept") + .unwrap(); + assert!( + row_a.is_some(), + "first deposit should enqueue webhook delivery" + ); + assert!( + row_b.is_some(), + "second deposit should enqueue webhook delivery" + ); + + let mut both_delivered = false; + for _ in 0..40 { + let a = db + .get_webhook_delivery(&format!("{TEST_CHAIN}:0xaaa:1"), "deposit_swept") + .unwrap(); + let b = db + .get_webhook_delivery(&format!("{TEST_CHAIN}:0xbbb:2"), "deposit_swept") + .unwrap(); + if a.as_ref().map(|r| r.status.as_str()) == Some("delivered") + && b.as_ref().map(|r| r.status.as_str()) == Some("delivered") + { + both_delivered = true; + break; + } + sleep(Duration::from_millis(100)).await; + } + + assert!( + both_delivered, + "worker should eventually deliver both webhooks" + ); + assert!( + webhook_attempts.load(Ordering::SeqCst) >= 3, + "first delivery may retry after 503 before both succeed" + ); +} + +// ========== Webhook Delivery Tests ========== + +#[tokio::test] +async fn test_webhook_attempt_stored_retries_until_success() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + let webhook_server = MockServer::start().await; + let attempts = StdArc::new(AtomicUsize::new(0)); + let attempts_for_mock = attempts.clone(); + Mock::given(method("POST")) + .respond_with(move |_: &wiremock::Request| { + let n = attempts_for_mock.fetch_add(1, Ordering::SeqCst) + 1; + if n <= 2 { + ResponseTemplate::new(503) + } else { + ResponseTemplate::new(200) + } + }) + .mount(&webhook_server) + .await; + + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + let deliverer = test_webhook_deliverer(db.clone()); + + let payload = json!({ + "id": "polygon:0xabc", + "event": "deposit_detected" + }); + deliverer + .enqueue(&webhook_server.uri(), "user1", payload) + .await + .unwrap(); + + for _ in 0..5 { + deliverer + .attempt_stored("polygon:0xabc", "deposit_detected") + .await + .unwrap(); + let row = db + .get_webhook_delivery("polygon:0xabc", "deposit_detected") + .unwrap() + .unwrap(); + if row.status == "delivered" { + assert_eq!(row.attempt_count, 3); + return; + } + } + panic!("webhook was not delivered after retries"); +} + +#[tokio::test] +async fn test_webhook_attempt_stored_marks_failed_after_max_retries() { + let webhook_server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(503)) + .mount(&webhook_server) + .await; + + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + let deliverer = test_webhook_deliverer(db.clone()); + + deliverer + .enqueue( + &webhook_server.uri(), + "user1", + json!({"id": "base:0x1", "event": "deposit_swept"}), + ) + .await + .unwrap(); + + for _ in 0..5 { + deliverer + .attempt_stored("base:0x1", "deposit_swept") + .await + .unwrap(); + } + + let row = db + .get_webhook_delivery("base:0x1", "deposit_swept") + .unwrap() + .unwrap(); + assert_eq!(row.status, "failed"); + assert_eq!(row.attempt_count, 3); +} + +#[tokio::test] +async fn test_webhook_enqueue_skips_already_delivered() { + let webhook_server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&webhook_server) + .await; + + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + let deliverer = test_webhook_deliverer(db.clone()); + + db.upsert_webhook_delivery( + "polygon:0xabc", + "deposit_detected", + "user1", + &webhook_server.uri(), + r#"{"id":"polygon:0xabc","event":"deposit_detected"}"#, + ) + .unwrap(); + db.record_webhook_attempt( + "polygon:0xabc", + "deposit_detected", + Some(200), + None, + "delivered", + ) + .unwrap(); + + deliverer + .enqueue( + &webhook_server.uri(), + "user1", + json!({"id": "polygon:0xabc", "event": "deposit_detected"}), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_webhook_lease_prevents_duplicate_post() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + use std::time::Duration; + + let webhook_server = MockServer::start().await; + let posts = StdArc::new(AtomicUsize::new(0)); + let posts_for_mock = posts.clone(); + Mock::given(method("POST")) + .respond_with(move |_: &wiremock::Request| { + posts_for_mock.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_delay(Duration::from_millis(200)) + }) + .mount(&webhook_server) + .await; + + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + let deliverer = + Arc::new(WebhookDeliverer::new_for_test(db.clone(), None, 3, 10, 60, 50, 60).unwrap()); + + deliverer + .enqueue( + &webhook_server.uri(), + "user1", + json!({"id": "polygon:0xabc", "event": "deposit_detected"}), + ) + .await + .unwrap(); + + let d1 = Arc::clone(&deliverer); + let d2 = Arc::clone(&deliverer); + let t1 = + tokio::spawn(async move { d1.attempt_stored("polygon:0xabc", "deposit_detected").await }); + let t2 = + tokio::spawn(async move { d2.attempt_stored("polygon:0xabc", "deposit_detected").await }); + let _ = tokio::join!(t1, t2); + + assert_eq!(posts.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn test_webhook_worker_wake_on_enqueue() { + use std::time::Duration; + use tokio::time::sleep; + + let webhook_server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&webhook_server) + .await; + + let db_file = NamedTempFile::new().unwrap(); + let db = Db::new(db_file.path().to_str().unwrap()).unwrap(); + let deliverer = + Arc::new(WebhookDeliverer::new_for_test(db.clone(), None, 3, 10, 60, 50, 60).unwrap()); + let worker = WebhookRetryService::new(Arc::clone(&deliverer)); + tokio::spawn(async move { + worker.run().await; + }); + + deliverer + .enqueue( + &webhook_server.uri(), + "user1", + json!({"id": "polygon:0xabc", "event": "deposit_detected"}), + ) + .await + .unwrap(); + + for _ in 0..40 { + if db + .get_webhook_delivery("polygon:0xabc", "deposit_detected") + .unwrap() + .is_some_and(|r| r.status == "delivered") + { + return; + } + sleep(Duration::from_millis(50)).await; + } + panic!("worker did not deliver webhook promptly after enqueue notify"); +} + +#[tokio::test] +async fn test_admin_retry_webhooks_resets_and_notifies() { + use std::time::Duration; + use tokio::time::sleep; + + let rpc_server = MockServer::start().await; + let webhook_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + + let db = Db::new(&config.database_url).unwrap(); + db.upsert_webhook_delivery( + "base:0xabc:120", + "deposit_swept", + "user1", + &webhook_server.uri(), + r#"{"id":"base:0xabc:120","event":"deposit_swept"}"#, + ) + .unwrap(); + db.record_webhook_attempt( + "base:0xabc:120", + "deposit_swept", + Some(503), + Some("HTTP status 503"), + "failed", + ) + .unwrap(); + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&webhook_server) + .await; + + let service = HotWalletService::new(config).await.unwrap(); + service.start_background_services().await.unwrap(); + + let response = service + .retry_webhook(RetryWebhookRequest { + id: "base:0xabc:120".to_string(), + event: "deposit_swept".to_string(), + }) + .await + .unwrap(); + assert!(response.retried); + assert_eq!(response.status, "pending"); + + for _ in 0..40 { + if db + .get_webhook_delivery("base:0xabc:120", "deposit_swept") + .unwrap() + .is_some_and(|r| r.status == "delivered") + { + return; + } + sleep(Duration::from_millis(100)).await; + } + panic!("admin retry did not lead to webhook delivery"); +} + +// ========== Token Allowlist Tests ========== + +fn test_config_with_allowlist(allowlist: std::collections::HashSet) -> Config { + let mut config = test_config(":memory:", "http://localhost:8545"); + config.chains[0].allowed_token_addresses = allowlist; + config +} + +#[test] +fn test_is_token_allowed_empty_allowlist_allows_all() { + let config = test_config_with_allowlist(Default::default()); + assert!(config.chains[0].is_token_allowed("0xdead000000000000000000000000000000000001")); + assert!(config.chains[0].is_token_allowed("dead000000000000000000000000000000000001")); +} + +#[test] +fn test_is_token_allowed_with_entries() { + let mut allowlist = std::collections::HashSet::new(); + allowlist.insert("0xc2132d05d31c914a87c6611c10748aeb04b58e8f".to_string()); + let config = test_config_with_allowlist(allowlist); + + assert!(config.chains[0].is_token_allowed("0xC2132D05D31c914a87C6611C10748AEb04B58e8F")); + assert!(config.chains[0].is_token_allowed("c2132d05d31c914a87c6611c10748aeb04b58e8f")); + assert!(!config.chains[0].is_token_allowed("0xdead000000000000000000000000000000000001")); +} + +#[test] +fn test_parse_allowed_token_addresses_env() { + let set = parse_allowed_token_addresses_env( + " 0xC2132D05D31c914a87C6611C10748AEb04B58e8F , 0xdead000000000000000000000000000000000001 , , ".to_string(), + ) + .unwrap(); + + assert_eq!(set.len(), 2); + assert!(set.contains("0xc2132d05d31c914a87c6611c10748aeb04b58e8f")); + assert!(set.contains("0xdead000000000000000000000000000000000001")); + + assert!(parse_allowed_token_addresses_env(String::new()) + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn test_monitor_skips_non_allowlisted_erc20_token() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let spam_token = "0xdead000000000000000000000000000000000001"; + + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + let amount_data = "0x00000000000000000000000000000000000000000000000000000000000f4240"; + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xA" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [{ + "address": spam_token, + "topics": [transfer_topic, from_topic, to_topic], + "data": amount_data, + "blockNumber": "0xA", + "transactionHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }] + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_call")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x" + }))) + .expect(0) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(1500)).await; + handle.abort(); + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); +} + +#[tokio::test] +async fn test_monitor_records_allowlisted_erc20_token() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.store_token_metadata(TEST_CHAIN, allowed_token, "USDT", 6, "Tether USD") + .unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + let amount_data = "0x00000000000000000000000000000000000000000000000000000000000f4240"; + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xA" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [{ + "address": allowed_token, + "topics": [transfer_topic, from_topic, to_topic], + "data": amount_data, + "blockNumber": "0xA", + "transactionHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }] + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(1500)).await; + handle.abort(); + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 1); +} + +#[tokio::test] +async fn test_sweeper_marks_non_allowlisted_deposit_failed_without_rpc() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let spam_token = "0xdead000000000000000000000000000000000001"; + + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, "0xspam", 1, "user_1", "1000000", spam_token, "USDC", + ) + .unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_call")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x" + }))) + .expect(0) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Arc::new( + Faucet::new( + config.faucet_mnemonic.clone(), + provider.clone(), + &test_support::chain_existential(&config), + ) + .unwrap(), + ); + let deliverer = test_webhook_deliverer(db.clone()); + let webhook_worker = WebhookRetryService::new(Arc::clone(&deliverer)); + tokio::spawn(async move { + webhook_worker.run().await; + }); + let sweeper = Sweeper::new( + config.chains[0].clone(), + deliverer, + db.clone(), + wallet, + provider.clone(), + faucet, + ); + let handle = tokio::spawn(async move { + sweeper.run().await; + }); + + for _ in 0..10 { + if db + .get_detected_erc20_deposits(TEST_CHAIN) + .unwrap() + .is_empty() + { + break; + } + sleep(Duration::from_millis(300)).await; + } + handle.abort(); + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); +} + +#[tokio::test] +async fn test_erc20_faucet_failure_keeps_deposit_detected() { + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + let token_address = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.record_erc20_deposit( + TEST_CHAIN, + "0xabc", + 1, + "user_1", + "1000000", + token_address, + "USDT", + ) + .unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_call")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": "0x00000000000000000000000000000000000000000000000000000000000f4240" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_estimateGas")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x186a0" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_feeHistory")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], + "gasUsedRatio": [0.5], + "oldestBlock": "0x9", + "reward": [["0x3B9ACA00"]] + } + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Arc::new( + Faucet::new( + config.faucet_mnemonic.clone(), + provider.clone(), + &test_support::chain_existential(&config), + ) + .unwrap(), + ); + let deliverer = test_webhook_deliverer(db.clone()); + let sweeper = Sweeper::new( + config.chains[0].clone(), + deliverer, + db.clone(), + wallet, + provider, + faucet, + ); + + for _ in 0..12 { + sweeper.process_deposits_once().await.unwrap(); + } + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 1); + assert_eq!( + db.get_sweep_failure_count(TEST_CHAIN, "0xabc:1").unwrap(), + 0 + ); +} + +#[test] +fn test_retry_sweep_service_requeues_failed_erc20_deposit() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + db.record_erc20_deposit(TEST_CHAIN, "0xabc", 120, "user_1", "100", "0xtoken", "USDC") + .unwrap(); + db.mark_erc20_deposit_failed(TEST_CHAIN, "0xabc:120") + .unwrap(); + + let config = test_config(tmp.path().to_str().unwrap(), "http://localhost:0"); + let rt = tokio::runtime::Runtime::new().unwrap(); + let service = rt.block_on(HotWalletService::new(config)).unwrap(); + + let response = rt + .block_on(service.retry_sweep(RetrySweepRequest { + chain: TEST_CHAIN.to_string(), + tx_hash: "0xabc".to_string(), + log_index: Some(120), + })) + .unwrap(); + + assert!(response.retried); + assert_eq!(response.token_type, "erc20"); + assert_eq!( + service + .db() + .get_detected_erc20_deposits(TEST_CHAIN) + .unwrap() + .len(), + 1 + ); +} + +// ========== Faucet Tests ========== + +async fn mount_faucet_gas_mocks(server: &MockServer) { + Mock::given(method("POST")) + .and(body_json_contains("eth_estimateGas")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x5208" + }))) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_feeHistory")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "baseFeePerGas": ["0x3B9ACA00", "0x3B9ACA00"], + "gasUsedRatio": [0.5], + "oldestBlock": "0x9", + "reward": [["0x3B9ACA00"]] + } + }))) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_maxPriorityFeePerGas")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x3B9ACA00" + }))) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_gasPrice")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x3B9ACA00" + }))) + .mount(server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_chainId")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x89" + }))) + .mount(server) + .await; +} + +fn faucet_send_raw_tx_nonce(body: &[u8]) -> Option { + use alloy::consensus::{transaction::Transaction, TxEnvelope}; + use alloy::eips::eip2718::Decodable2718; + let body_str = String::from_utf8_lossy(body); + let v: serde_json::Value = serde_json::from_str(&body_str).ok()?; + let raw = v["params"][0].as_str()?; + let bytes = alloy::hex::decode(raw.trim_start_matches("0x")).ok()?; + let mut buf = bytes.as_slice(); + TxEnvelope::decode_2718(&mut buf).ok().map(|tx| tx.nonce()) +} + +fn faucet_receipt_response(tx_hash: &str) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", "id": 1, + "result": { + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000b", + "blockNumber": "0xb", + "from": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "to": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "cumulativeGasUsed": "0x5208", + "gasUsed": "0x5208", + "contractAddress": null, + "logs": [], + "status": "0x1", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "effectiveGasPrice": "0x3B9ACA00" + } + }) +} + +#[tokio::test] +async fn test_faucet_insufficient_balance() { + let rpc_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + }))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Faucet::new( + "test test test test test test test test test test test junk".to_string(), + provider, + "10000000000000000", + ) + .unwrap(); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let to = wallet.derive_address(1).unwrap().to_string(); + + let err = faucet.fund_new_address(&to).await.unwrap_err(); + assert!(err + .to_string() + .contains("Faucet has insufficient balance to fund new address")); +} + +#[tokio::test] +async fn test_faucet_happy_path_single_fund() { + let rpc_server = MockServer::start().await; + mount_faucet_gas_mocks(&rpc_server).await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0DE0B6B3A7640000" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionCount")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + }))) + .mount(&rpc_server) + .await; + + let tx_hash = "0x0000000000000000000000000000000000000000000000000000000000000001"; + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": tx_hash + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(ResponseTemplate::new(200).set_body_json(faucet_receipt_response(tx_hash))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Faucet::new( + "test test test test test test test test test test test junk".to_string(), + provider, + "10000000000000000", + ) + .unwrap(); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let to = wallet.derive_address(1).unwrap().to_string(); + + let result = faucet.fund_new_address(&to).await.unwrap(); + assert_eq!(result, tx_hash); +} + +#[tokio::test] +async fn test_faucet_concurrent_funds_use_distinct_nonces() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc as StdArc, Mutex as StdMutex}; + + let rpc_server = MockServer::start().await; + mount_faucet_gas_mocks(&rpc_server).await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0DE0B6B3A7640000" + }))) + .mount(&rpc_server) + .await; + + let nonce_rpc_calls = StdArc::new(AtomicUsize::new(0)); + let nonce_calls_for_mock = nonce_rpc_calls.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionCount")) + .respond_with(move |_: &wiremock::Request| { + nonce_calls_for_mock.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + })) + }) + .mount(&rpc_server) + .await; + + let captured_nonces = StdArc::new(StdMutex::new(Vec::::new())); + let nonces_for_mock = captured_nonces.clone(); + let send_count = StdArc::new(AtomicUsize::new(0)); + let send_count_for_mock = send_count.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(move |req: &wiremock::Request| { + if let Some(n) = faucet_send_raw_tx_nonce(&req.body) { + nonces_for_mock.lock().unwrap().push(n); + } + let idx = send_count_for_mock.fetch_add(1, Ordering::SeqCst); + let hash = format!("0x{:064x}", idx + 1); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": hash + })) + }) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(move |req: &wiremock::Request| { + let body_str = String::from_utf8_lossy(&req.body); + let v: serde_json::Value = + serde_json::from_str(&body_str).unwrap_or(json!({"params": []})); + let tx_hash = v["params"][0] + .as_str() + .unwrap_or("0x0000000000000000000000000000000000000000000000000000000000000001"); + ResponseTemplate::new(200).set_body_json(faucet_receipt_response(tx_hash)) + }) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = StdArc::new( + Faucet::new( + "test test test test test test test test test test test junk".to_string(), + provider, + "10000000000000000", + ) + .unwrap(), + ); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let to1 = wallet.derive_address(1).unwrap().to_string(); + let to2 = wallet.derive_address(2).unwrap().to_string(); + + let faucet_a = faucet.clone(); + let faucet_b = faucet.clone(); + let (r1, r2) = tokio::join!( + faucet_a.fund_new_address(&to1), + faucet_b.fund_new_address(&to2), + ); + + assert!(r1.is_ok(), "first concurrent fund failed: {r1:?}"); + assert!(r2.is_ok(), "second concurrent fund failed: {r2:?}"); + assert_ne!(r1.unwrap(), r2.unwrap(), "expected distinct tx hashes"); + + let mut nonces = captured_nonces.lock().unwrap().clone(); + nonces.sort_unstable(); + assert_eq!(nonces, vec![0, 1], "expected sequential nonces 0 and 1"); + assert_eq!( + nonce_rpc_calls.load(Ordering::SeqCst), + 1, + "expected a single eth_getTransactionCount (cached nonce reused)" + ); +} + +#[tokio::test] +async fn test_faucet_send_failure_resets_nonce_cache() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + + let rpc_server = MockServer::start().await; + mount_faucet_gas_mocks(&rpc_server).await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBalance")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0DE0B6B3A7640000" + }))) + .mount(&rpc_server) + .await; + + let nonce_rpc_calls = StdArc::new(AtomicUsize::new(0)); + let nonce_calls_for_mock = nonce_rpc_calls.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionCount")) + .respond_with(move |_: &wiremock::Request| { + nonce_calls_for_mock.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x0" + })) + }) + .mount(&rpc_server) + .await; + + let send_attempts = StdArc::new(AtomicUsize::new(0)); + let send_attempts_for_mock = send_attempts.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(move |_: &wiremock::Request| { + let attempt = send_attempts_for_mock.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + ResponseTemplate::new(500) + } else { + let tx_hash = "0x00000000000000000000000000000000000000000000000000000000000000ab"; + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": tx_hash + })) + } + }) + .mount(&rpc_server) + .await; + + let tx_hash = "0x00000000000000000000000000000000000000000000000000000000000000ab"; + Mock::given(method("POST")) + .and(body_json_contains("eth_getTransactionReceipt")) + .respond_with(ResponseTemplate::new(200).set_body_json(faucet_receipt_response(tx_hash))) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let faucet = Faucet::new( + "test test test test test test test test test test test junk".to_string(), + provider, + "10000000000000000", + ) + .unwrap(); + + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let to = wallet.derive_address(1).unwrap().to_string(); + + assert!(faucet.fund_new_address(&to).await.is_err()); + assert_eq!(nonce_rpc_calls.load(Ordering::SeqCst), 1); + + let result = faucet.fund_new_address(&to).await.unwrap(); + assert_eq!(result, tx_hash); + assert_eq!( + nonce_rpc_calls.load(Ordering::SeqCst), + 2, + "expected nonce cache reset to re-fetch eth_getTransactionCount" + ); +} + +#[tokio::test] +async fn test_register_does_not_fund_at_registration() { + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + + Mock::given(method("POST")) + .and(body_json_contains("eth_sendRawTransaction")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xdead" + }))) + .expect(0) + .mount(&rpc_server) + .await; + + let service = HotWalletService::new(config).await.unwrap(); + let response = service + .register(RegisterRequest { + id: "lazy_user".to_string(), + webhook_url: "http://localhost/webhook".to_string(), + }) + .await + .unwrap(); + + assert!(response.funding_tx.is_none()); +} + +/// P0 collision fix: registers allocate sequential derivation indices from +/// the persisted counter (no more hash-derived indices), every account gets +/// a distinct address, and re-registering returns the existing address. +#[tokio::test] +async fn test_register_allocates_sequential_indices_and_is_idempotent() { + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + let wallet = Wallet::new(config.mnemonic.clone()); + let service = HotWalletService::new(config).await.unwrap(); + + let mut addresses = std::collections::HashSet::new(); + for i in 0..5 { + let response = service + .register(RegisterRequest { + id: format!("seq_user_{i}"), + webhook_url: "http://localhost/webhook".to_string(), + }) + .await + .unwrap(); + // Sequential allocation: account i gets derivation index i. + assert_eq!(response.address, wallet.derive_address(i).unwrap().to_string()); + assert!(addresses.insert(response.address)); + } + + // Re-register returns the existing address without burning an index. + let again = service + .register(RegisterRequest { + id: "seq_user_2".to_string(), + webhook_url: "http://localhost/webhook".to_string(), + }) + .await + .unwrap(); + assert_eq!( + again.address, + wallet.derive_address(2).unwrap().to_string() + ); + + let next = service + .register(RegisterRequest { + id: "seq_user_next".to_string(), + webhook_url: "http://localhost/webhook".to_string(), + }) + .await + .unwrap(); + assert_eq!(next.address, wallet.derive_address(5).unwrap().to_string()); +} + +#[test] +fn test_same_tx_hash_isolated_per_chain() { + let tmp = NamedTempFile::new().unwrap(); + let db = Db::new(tmp.path().to_str().unwrap()).unwrap(); + + db.record_deposit("base", "0xsame", "user1", "100").unwrap(); + db.record_deposit("polygon", "0xsame", "user2", "200") + .unwrap(); + + let base = db.get_detected_deposits("base").unwrap(); + let polygon = db.get_detected_deposits("polygon").unwrap(); + + assert_eq!(base[0].1, "user1"); + assert_eq!(polygon[0].1, "user2"); +} + +#[tokio::test] +async fn test_verify_transfer_unknown_chain() { + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + let service = HotWalletService::new(config).await.unwrap(); + + let result = service + .verify_transfer(VerifyTransferRequest { + chain: "ethereum".to_string(), + tx_hash: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + .to_string(), + to_address: "0x742d35Cc6634C0532925a3b844Bc454e4438f44e".to_string(), + amount: "1".to_string(), + token_type: "native".to_string(), + token_address: None, + token_symbol: None, + }) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown chain")); +} + +#[tokio::test] +async fn test_block_number_unknown_chain() { + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + let service = HotWalletService::new(config).await.unwrap(); + + let err = service.get_block_number("unknown").unwrap_err(); + assert!(err.to_string().contains("Unknown chain")); +} + +// ========== Monitor Catch-Up Acceleration Tests ========== +// +// Builds a native-transfer block RPC response by cloning the shared empty-block +// skeleton (so the many fixed-size hex fields like logsBloom stay valid) and +// overriding only the block number and transaction list. +fn native_tx_block_response(block_num: u64, to_addr: &str, tx_hash: &str) -> serde_json::Value { + let mut resp = empty_block_rpc_response(); + let block_hex = format!("0x{block_num:x}"); + resp["result"]["number"] = json!(block_hex); + resp["result"]["transactions"] = json!([{ + "hash": tx_hash, + "nonce": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "blockNumber": block_hex, + "transactionIndex": "0x0", + "from": "0x0000000000000000000000000000000000000001", + "to": to_addr, + "value": "0xDE0B6B3A7640000", + "gas": "0x5208", + "gasPrice": "0x3B9ACA00", + "input": "0x", + "v": "0x1b", + "r": "0x1", + "s": "0x1", + "type": "0x0", + "chainId": "0x1" + }]); + resp +} + +/// [REGRESSION] `get_logs_with_retry` used to treat an empty result as a failure and +/// retry up to `get_logs_max_retries` times with `get_logs_delay_ms` sleeps between +/// attempts β€” on a fast-moving chain with sparse matching transfers, this alone could +/// burn seconds per block. An empty result is valid; it must return immediately. +#[tokio::test] +async fn test_get_logs_empty_result_returns_without_retry() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let mut config = test_config(db_file.path().to_str().unwrap(), rpc_server.uri()); + config.chains[0].get_logs_max_retries = 30; + config.chains[0].get_logs_delay_ms = 50; + // Large enough that only one catch_up cycle runs during the test window, so the + // get_logs call count reflects a single scan attempt, not multiple poll loops. + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xA" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + let get_logs_calls = StdArc::new(AtomicUsize::new(0)); + let calls_for_mock = get_logs_calls.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(move |_: &wiremock::Request| { + calls_for_mock.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": [] + })) + }) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + // If the regression reappeared, a buggy retry loop (30 attempts x 50ms delay) + // would still be mid-retry at 800ms, producing well over 1 call. + sleep(Duration::from_millis(800)).await; + handle.abort(); + + assert_eq!( + get_logs_calls.load(Ordering::SeqCst), + 1, + "an empty get_logs result must not be retried" + ); +} + +/// The `eth_getLogs` filter must be narrowed to the allowlisted token contracts, +/// instead of matching every ERC20 Transfer event on the chain. +#[tokio::test] +async fn test_erc20_filter_includes_allowlisted_token_address() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0xA" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains_ci(&allowed_token.to_lowercase())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": [] + }))) + .expect(1) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(300)).await; + handle.abort(); + + // Verification of the `.expect(1)` mock above happens when `rpc_server` drops: + // if the allowlisted token address never appeared in the eth_getLogs request, + // this mock never matched and the drop panics. +} + +/// When the monitor is far enough behind head, `catch_up` must switch to the +/// batched path: one ranged `eth_getLogs` call covering the whole gap (in one +/// chunk, since it's under `catch_up_chunk_size`) instead of one call per block. +#[tokio::test] +async fn test_batch_catchup_ranged_get_logs_records_erc20_deposit() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.store_token_metadata(TEST_CHAIN, allowed_token, "USDT", 6, "Tether USD") + .unwrap(); + // last_processed = 1, head = 21 -> gap of 20 blocks, above the batch threshold. + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + let amount_data = "0x00000000000000000000000000000000000000000000000000000000000f4240"; + let tx_hash = format!("0x{}", "a".repeat(64)); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x15" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [{ + "address": allowed_token, + "topics": [transfer_topic, from_topic, to_topic], + "data": amount_data, + "blockNumber": "0x5", + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }] + }))) + .expect(1) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(500)).await; + handle.abort(); + + let deposits = db.get_detected_erc20_deposits(TEST_CHAIN).unwrap(); + assert_eq!( + deposits.len(), + 1, + "expected one ERC20 deposit from the ranged batch scan" + ); + assert_eq!(db.get_last_processed_block(TEST_CHAIN).unwrap(), 21); + + let deposit_id = format!("{TEST_CHAIN}:{tx_hash}:0"); + assert!( + db.get_webhook_delivery(&deposit_id, "deposit_detected") + .unwrap() + .is_some(), + "expected deposit_detected webhook enqueued exactly once" + ); +} + +/// Providers cap `eth_getLogs` responses (Alchemy: "Log response size exceeded" for +/// an oversized range/response, with no partial result). The batch path must bisect +/// the range on error and retry with the two halves rather than failing outright. +#[tokio::test] +async fn test_batch_catchup_bisects_on_provider_error() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.store_token_metadata(TEST_CHAIN, allowed_token, "USDT", 6, "Tether USD") + .unwrap(); + // last_processed = 1, head = 21 -> whole chunk is (1, 21); mid-bisect is (1,11) + (12,21). + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + let amount_data = "0x00000000000000000000000000000000000000000000000000000000000f4240"; + let tx_hash_left = format!("0x{}", "1".repeat(64)); + let tx_hash_right = format!("0x{}", "2".repeat(64)); + + fn transfer_log( + token: &str, + topics: [&str; 3], + data: &str, + block_hex: &str, + tx_hash: &str, + ) -> serde_json::Value { + json!({ + "address": token, + "topics": topics, + "data": data, + "blockNumber": block_hex, + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }) + } + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x15" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + // Whole-range call (1..=21): simulates a provider rejecting an oversized range. + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains(&field_hex("fromBlock", 1))) + .and(body_json_contains(&field_hex("toBlock", 21))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&rpc_server) + .await; + + // Left half (1..=11): succeeds. + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains(&field_hex("fromBlock", 1))) + .and(body_json_contains(&field_hex("toBlock", 11))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [transfer_log( + allowed_token, + [transfer_topic, from_topic, &to_topic], + amount_data, + "0x5", + &tx_hash_left, + )] + }))) + .expect(1) + .mount(&rpc_server) + .await; + + // Right half (12..=21): succeeds. + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains(&field_hex("fromBlock", 12))) + .and(body_json_contains(&field_hex("toBlock", 21))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [transfer_log( + allowed_token, + [transfer_topic, from_topic, &to_topic], + amount_data, + "0x10", + &tx_hash_right, + )] + }))) + .expect(1) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(500)).await; + handle.abort(); + + let deposits = db.get_detected_erc20_deposits(TEST_CHAIN).unwrap(); + assert_eq!( + deposits.len(), + 2, + "expected deposits from both bisected halves" + ); + assert_eq!(db.get_last_processed_block(TEST_CHAIN).unwrap(), 21); +} + +/// If a range has been bisected all the way down to a single block and that block +/// still errors, the error must surface (no infinite recursion) and the chunk must +/// not be checkpointed as processed. +#[tokio::test] +async fn test_batch_catchup_bisect_floor_surfaces_error() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + // Small chunk so the first (and only, for this test) chunk is just (1, 2), + // keeping the bisection tree to 3 nodes: (1,2) -> (1,1) + (2,2). + config.chains[0].catch_up_chunk_size = 2; + + let db = Db::new(&config.database_url).unwrap(); + // last_processed = 1, head = 21 -> gap of 20, above the batch threshold, even + // though the first chunk itself only spans 2 blocks. + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x15" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + // The (1,2) range errors, bisects into (1,1) and (2,2). Bisection short-circuits + // on the first failing half (fail-fast: once part of the chunk is unrecoverable, + // there's no point burning a call on the sibling), so only (1,2) and (1,1) are + // ever queried; (2,2) must not be. + for (from, to) in [(1, 2), (1, 1)] { + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains(&field_hex("fromBlock", from))) + .and(body_json_contains(&field_hex("toBlock", to))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&rpc_server) + .await; + } + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .and(body_json_contains(&field_hex("fromBlock", 2))) + .and(body_json_contains(&field_hex("toBlock", 2))) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(500)).await; + handle.abort(); + + assert_eq!( + db.get_last_processed_block(TEST_CHAIN).unwrap(), + 1, + "a chunk that errors all the way to the bisection floor must not be checkpointed" + ); + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 0); +} + +/// The batch path fetches native blocks concurrently; deposits from multiple blocks +/// within one chunk must all be recorded, not just the first or last. +#[tokio::test] +async fn test_batch_catchup_concurrent_native_fetch_records_multiple_blocks() { + use std::collections::HashSet; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + // last_processed = 1, head = 21 -> gap of 20, above the batch threshold. + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x15" + }))) + .mount(&rpc_server) + .await; + + // No ERC20 activity in this test; keep the batch path focused on native. + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": [] + }))) + .mount(&rpc_server) + .await; + + let addr_for_mock = addr.clone(); + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(move |req: &wiremock::Request| { + let body_str = String::from_utf8_lossy(&req.body); + let parsed: serde_json::Value = + serde_json::from_str(&body_str).unwrap_or(json!({"params": []})); + let block_hex = parsed["params"][0].as_str().unwrap_or("0x0"); + let block_num = + u64::from_str_radix(block_hex.trim_start_matches("0x"), 16).unwrap_or(0); + + if block_num == 3 || block_num == 7 { + let tx_hash = format!("0x{block_num:064x}"); + ResponseTemplate::new(200).set_body_json(native_tx_block_response( + block_num, + &addr_for_mock, + &tx_hash, + )) + } else { + ResponseTemplate::new(200).set_body_json(empty_block_rpc_response()) + } + }) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + + sleep(Duration::from_millis(800)).await; + handle.abort(); + + let deposits = db.get_detected_deposits(TEST_CHAIN).unwrap(); + assert_eq!( + deposits.len(), + 2, + "expected native deposits from both blocks fetched concurrently within the chunk" + ); + assert_eq!(db.get_last_processed_block(TEST_CHAIN).unwrap(), 21); +} + +/// Re-running a chunk (e.g. after a restart that re-reads a stale checkpoint) must +/// not record a duplicate deposit or re-trigger the webhook for one already detected. +#[tokio::test] +async fn test_batch_catchup_replay_is_idempotent() { + use std::collections::HashSet; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc as StdArc; + use std::time::Duration; + use tokio::time::sleep; + + let _ = tracing_subscriber::fmt::try_init(); + + let rpc_server = MockServer::start().await; + let db_file = NamedTempFile::new().unwrap(); + let wallet = + Wallet::new("test test test test test test test test test test test junk".to_string()); + let addr = wallet.derive_address(0).unwrap().to_string(); + + let allowed_token = "0xc2132D05D31c914a87C6611C10748AEb04B58e8F"; + let mut allowlist = HashSet::new(); + allowlist.insert(allowed_token.to_lowercase()); + + let mut config = test_config_with_allowlist(allowlist); + config.database_url = db_file.path().to_str().unwrap().to_string(); + config.chains[0].rpc_url = rpc_server.uri(); + config.chains[0].get_logs_max_retries = 1; + config.chains[0].get_logs_delay_ms = 1; + config.chains[0].poll_interval = 3600; + + let db = Db::new(&config.database_url).unwrap(); + db.register_account("user_1", 0, &addr, "http://localhost/webhook") + .unwrap(); + db.store_token_metadata(TEST_CHAIN, allowed_token, "USDT", 6, "Tether USD") + .unwrap(); + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + let from_topic = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let to_topic = format!("0x000000000000000000000000{}", &addr[2..].to_lowercase()); + let amount_data = "0x00000000000000000000000000000000000000000000000000000000000f4240"; + let tx_hash = format!("0x{}", "c".repeat(64)); + + Mock::given(method("POST")) + .and(body_json_contains("eth_blockNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": 1, "result": "0x15" + }))) + .mount(&rpc_server) + .await; + + Mock::given(method("POST")) + .and(body_json_contains("eth_getBlockByNumber")) + .respond_with(ResponseTemplate::new(200).set_body_json(empty_block_rpc_response())) + .mount(&rpc_server) + .await; + + let get_logs_calls = StdArc::new(AtomicUsize::new(0)); + let calls_for_mock = get_logs_calls.clone(); + let log_entry = json!({ + "address": allowed_token, + "topics": [transfer_topic, from_topic, to_topic], + "data": amount_data, + "blockNumber": "0x5", + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": "0x000000000000000000000000000000000000000000000000000000000000000a", + "logIndex": "0x0", + "removed": false + }); + Mock::given(method("POST")) + .and(body_json_contains("eth_getLogs")) + .respond_with(move |_: &wiremock::Request| { + calls_for_mock.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": [log_entry.clone()] + })) + }) + .mount(&rpc_server) + .await; + + let provider = http_provider_boxed(&rpc_server.uri()); + + // First run: drains the backlog, records the deposit, checkpoints to 21. + { + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + sleep(Duration::from_millis(500)).await; + handle.abort(); + } + + assert_eq!(db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), 1); + assert_eq!(db.get_last_processed_block(TEST_CHAIN).unwrap(), 21); + + // Simulate replaying the same chunk (e.g. a restart reading a stale checkpoint) + // by resetting last_processed back to the chunk start. + db.set_last_processed_block(TEST_CHAIN, 1).unwrap(); + + { + let deliverer = test_webhook_deliverer(db.clone()); + let monitor = Monitor::new( + config.chains[0].clone(), + deliverer, + db.clone(), + provider.clone(), + ); + let handle = tokio::spawn(async move { + monitor.run().await; + }); + sleep(Duration::from_millis(500)).await; + handle.abort(); + } + + assert_eq!( + get_logs_calls.load(Ordering::SeqCst), + 2, + "expected the ranged get_logs call to run again on replay" + ); + assert_eq!( + db.get_detected_erc20_deposits(TEST_CHAIN).unwrap().len(), + 1, + "replaying the chunk must not record a duplicate deposit" + ); + + let deposit_id = format!("{TEST_CHAIN}:{tx_hash}:0"); + let delivery = db + .get_webhook_delivery(&deposit_id, "deposit_detected") + .unwrap(); + assert!( + delivery.is_some(), + "webhook delivery row should still exist after replay" + ); + assert_eq!( + delivery.unwrap().attempt_count, + 0, + "replay must not re-enqueue/re-attempt the webhook for an already-detected deposit" + ); +} diff --git a/src/webhook.rs b/src/webhook.rs new file mode 100644 index 0000000..5759317 --- /dev/null +++ b/src/webhook.rs @@ -0,0 +1,324 @@ +use crate::config::Config; +use crate::db::Db; +use crate::traits::Service; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::Notify; +use tokio::time::sleep; +use tracing::{info, warn}; + +pub struct WebhookDeliverer { + db: Db, + jwt_token: Option, + max_retries: u32, + retry_delay_ms: u64, + poll_interval_secs: u64, + batch_size: u32, + lease_seconds: u64, + client: reqwest::Client, + notify: Arc, +} + +impl WebhookDeliverer { + pub fn new(db: Db, config: &Config) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + Ok(Self { + db, + jwt_token: config.webhook_jwt_token.clone(), + max_retries: config.webhook_max_retries, + retry_delay_ms: config.webhook_retry_delay_ms, + poll_interval_secs: config.webhook_retry_poll_interval_secs, + batch_size: config.webhook_retry_batch_size, + lease_seconds: config.webhook_lease_seconds, + client, + notify: Arc::new(Notify::new()), + }) + } + + #[cfg(test)] + pub fn new_for_test( + db: Db, + jwt_token: Option, + max_retries: u32, + retry_delay_ms: u64, + poll_interval_secs: u64, + batch_size: u32, + lease_seconds: u64, + ) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build()?; + Ok(Self { + db, + jwt_token, + max_retries, + retry_delay_ms, + poll_interval_secs, + batch_size, + lease_seconds, + client, + notify: Arc::new(Notify::new()), + }) + } + + pub fn notify_worker(&self) { + self.notify.notify_one(); + } + + pub fn poll_interval_secs(&self) -> u64 { + self.poll_interval_secs + } + + pub fn notify_handle(&self) -> Arc { + Arc::clone(&self.notify) + } + + /// Hot path: persist pending delivery and wake the worker. No HTTP. + pub async fn enqueue( + &self, + webhook_url: &str, + registration_id: &str, + payload: Value, + ) -> Result<()> { + let id = payload + .get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("webhook payload missing id"))?; + let event = payload + .get("event") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("webhook payload missing event"))?; + + let payload_str = payload.to_string(); + let should_notify = { + let id = id.to_string(); + let event = event.to_string(); + let registration_id = registration_id.to_string(); + let webhook_url = webhook_url.to_string(); + self.db + .blocking(move |db| { + db.upsert_webhook_delivery( + &id, + &event, + ®istration_id, + &webhook_url, + &payload_str, + ) + }) + .await? + }; + + if should_notify { + self.notify_worker(); + } + Ok(()) + } + + /// Worker path: claim lease, perform one POST, record outcome. + pub async fn attempt_stored(&self, id: &str, event: &str) -> Result<()> { + let record = { + let id = id.to_string(); + let event = event.to_string(); + self.db + .blocking(move |db| db.get_webhook_delivery(&id, &event)) + .await? + }; + let Some(record) = record else { + return Ok(()); + }; + + if record.status == "delivered" { + return Ok(()); + } + + if record.status != "pending" { + return Ok(()); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let lease_until = now + self.lease_seconds as i64; + let claimed = { + let id = id.to_string(); + let event = event.to_string(); + let max_retries = self.max_retries; + self.db + .blocking(move |db| db.claim_webhook_delivery(&id, &event, lease_until, max_retries)) + .await? + }; + if !claimed { + return Ok(()); + } + + let record = { + let id = id.to_string(); + let event = event.to_string(); + self.db + .blocking(move |db| db.get_webhook_delivery(&id, &event)) + .await? + .ok_or_else(|| anyhow!("webhook delivery disappeared after claim"))? + }; + + let payload: Value = serde_json::from_str(&record.payload)?; + match self.try_post(&record.webhook_url, &payload).await { + Ok(status) => { + let id_owned = id.to_string(); + let event_owned = event.to_string(); + self.db + .blocking(move |db| { + db.record_webhook_attempt(&id_owned, &event_owned, Some(status), None, "delivered") + }) + .await?; + info!( + "Webhook delivered: id={id}, event={event}, status={status}, registration_id={}", + record.registration_id + ); + } + Err(e) => { + let (http_status, err_msg) = delivery_error_parts(&e); + let next_status = if record.attempt_count + 1 >= self.max_retries as u64 { + "failed" + } else { + "pending" + }; + let attempts = { + let id_owned = id.to_string(); + let event_owned = event.to_string(); + let err_msg = err_msg.clone(); + let next_status_owned = next_status.to_string(); + self.db + .blocking(move |db| { + db.record_webhook_attempt( + &id_owned, + &event_owned, + http_status, + Some(&err_msg), + &next_status_owned, + ) + }) + .await? + }; + let final_status = next_status; + + if final_status == "failed" { + warn!( + "Webhook delivery failed permanently: id={id}, event={event}, attempts={attempts}, error={err_msg}" + ); + } else { + warn!( + "Webhook delivery attempt failed: id={id}, event={event}, attempts={attempts}, error={err_msg}" + ); + } + } + } + + Ok(()) + } + + pub async fn process_pending_batch(&self) -> Result { + let max_retries = self.max_retries; + let batch_size = self.batch_size; + let keys = self + .db + .blocking(move |db| db.get_pending_webhook_delivery_keys(max_retries, batch_size)) + .await?; + + if keys.is_empty() { + return Ok(0); + } + + let mut processed = 0usize; + for (id, event) in keys { + if let Err(e) = self.attempt_stored(&id, &event).await { + warn!("Webhook attempt_stored failed for {id}/{event}: {e:?}"); + } + processed += 1; + + if self.retry_delay_ms > 0 { + sleep(Duration::from_millis(self.retry_delay_ms)).await; + } + } + + info!("Webhook worker processed {processed} pending delivery(ies)"); + Ok(processed) + } + + async fn try_post( + &self, + url: &str, + payload: &Value, + ) -> std::result::Result { + let mut request = self.client.post(url).json(payload); + if let Some(ref token) = self.jwt_token { + request = request.header("Authorization", format!("Bearer {token}")); + } + + let response = request + .send() + .await + .map_err(|e| DeliveryError::Network(e.to_string()))?; + + let status = response.status().as_u16(); + if (200..300).contains(&status) { + Ok(status) + } else { + Err(DeliveryError::HttpStatus(status)) + } + } +} + +#[derive(Debug)] +enum DeliveryError { + Network(String), + HttpStatus(u16), +} + +impl std::fmt::Display for DeliveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeliveryError::Network(msg) => write!(f, "network error: {msg}"), + DeliveryError::HttpStatus(code) => write!(f, "HTTP status {code}"), + } + } +} + +fn delivery_error_parts(err: &DeliveryError) -> (Option, String) { + match err { + DeliveryError::Network(msg) => (None, msg.clone()), + DeliveryError::HttpStatus(code) => (Some(*code), format!("HTTP status {code}")), + } +} + +pub struct WebhookRetryService { + deliverer: Arc, +} + +impl WebhookRetryService { + pub fn new(deliverer: Arc) -> Self { + Self { deliverer } + } +} + +#[async_trait] +impl Service for WebhookRetryService { + async fn run(&self) { + let notify = self.deliverer.notify_handle(); + loop { + tokio::select! { + _ = notify.notified() => {} + _ = sleep(Duration::from_secs(self.deliverer.poll_interval_secs())) => {} + } + + if let Err(e) = self.deliverer.process_pending_batch().await { + warn!("Webhook retry worker error: {e:?}"); + } + } + } +}