diff --git a/Cargo.lock b/Cargo.lock index 9449bd6..6cba593 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,6 +1046,15 @@ dependencies = [ "rustc_version 0.4.1", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1190,6 +1199,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -1452,6 +1464,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-hex" version = "1.19.0" @@ -1535,6 +1556,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1599,6 +1635,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1660,6 +1705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1776,6 +1822,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" @@ -1813,6 +1865,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -1878,6 +1933,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1944,6 +2021,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2034,6 +2122,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -2224,6 +2323,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -2242,6 +2350,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2251,6 +2368,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -2693,6 +2819,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2712,6 +2841,28 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2774,6 +2925,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2899,6 +3060,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2914,6 +3091,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3057,6 +3245,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3075,7 +3269,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -3096,6 +3290,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3175,6 +3378,7 @@ dependencies = [ "serde_json", "serial_test", "sha3", + "sqlx", "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.23.1", @@ -3290,6 +3494,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3306,6 +3521,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -3658,6 +3879,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -3799,6 +4029,26 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "ruint" version = "1.18.0" @@ -4373,6 +4623,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -4383,6 +4642,201 @@ dependencies = [ "der", ] +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4395,6 +4849,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5021,12 +5486,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -5200,6 +5686,12 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -5342,6 +5834,16 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "widestring" version = "1.2.1" @@ -5449,13 +5951,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -5467,34 +5978,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5507,24 +6051,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/crates/perplex-edge/Cargo.toml b/crates/perplex-edge/Cargo.toml index 777937d..2c5af44 100644 --- a/crates/perplex-edge/Cargo.toml +++ b/crates/perplex-edge/Cargo.toml @@ -18,6 +18,7 @@ hex = "0.4" k256 = { version = "0.13", features = ["ecdsa"] } redis = { workspace = true } sha3 = "0.10" +sqlx = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } tower = { workspace = true } diff --git a/crates/perplex-edge/migrations/0001_init.sql b/crates/perplex-edge/migrations/0001_init.sql new file mode 100644 index 0000000..d7c1b98 --- /dev/null +++ b/crates/perplex-edge/migrations/0001_init.sql @@ -0,0 +1,78 @@ +-- Perplex edge durable state. Mirrors the in-memory AppState stores 1:1 so a +-- restart rehydrates orders, positions, fills, balances, the public tape, and +-- funding history. Numeric values are stored as the exact decimal strings the +-- API serves (never floats) so the round-trip is lossless. Ephemeral/derived +-- state (orderbook snapshots, live oracle prices, SIWE nonces) is intentionally +-- NOT persisted: the book is rebuilt from open_orders on boot, prices come from +-- the live oracle, and nonces expire in seconds. + +CREATE TABLE IF NOT EXISTS open_orders ( + id TEXT PRIMARY KEY, + address TEXT NOT NULL, + market_id TEXT NOT NULL, + side TEXT NOT NULL, + order_type TEXT NOT NULL, + price TEXT NOT NULL, + qty TEXT NOT NULL, + remaining TEXT NOT NULL, + ts_ns TEXT NOT NULL, + client_order_id TEXT +); +CREATE INDEX IF NOT EXISTS open_orders_address_idx ON open_orders (address); + +CREATE TABLE IF NOT EXISTS positions ( + address TEXT NOT NULL, + market_id TEXT NOT NULL, + size TEXT NOT NULL, + side TEXT NOT NULL, + entry_price_x18 TEXT NOT NULL, + mark_price_x18 TEXT NOT NULL, + notional_usdc TEXT NOT NULL, + unrealised_pnl_usdc TEXT NOT NULL, + realised_pnl_usdc TEXT NOT NULL, + leverage TEXT NOT NULL, + liquidation_price_x18 TEXT NOT NULL, + funding_paid_usdc TEXT NOT NULL, + last_updated_ts_ns TEXT NOT NULL, + PRIMARY KEY (address, market_id) +); + +-- seq preserves insertion order across rehydrate; ts_ns alone can collide. +CREATE TABLE IF NOT EXISTS fills ( + id TEXT PRIMARY KEY, + address TEXT NOT NULL, + order_id TEXT NOT NULL, + market_id TEXT NOT NULL, + side TEXT NOT NULL, + price TEXT NOT NULL, + qty TEXT NOT NULL, + fee_usdc TEXT NOT NULL, + role TEXT NOT NULL, + ts_ns TEXT NOT NULL, + tx_hash TEXT NOT NULL, + seq BIGSERIAL +); +CREATE INDEX IF NOT EXISTS fills_address_seq_idx ON fills (address, seq); + +CREATE TABLE IF NOT EXISTS vault_balances ( + address TEXT PRIMARY KEY, + amount TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS public_trades ( + id TEXT PRIMARY KEY, + market_id TEXT NOT NULL, + price TEXT NOT NULL, + qty TEXT NOT NULL, + side TEXT NOT NULL, + ts_ns TEXT NOT NULL, + seq BIGSERIAL +); +CREATE INDEX IF NOT EXISTS public_trades_market_seq_idx ON public_trades (market_id, seq); + +CREATE TABLE IF NOT EXISTS funding_history ( + market_id TEXT NOT NULL, + ts_ns BIGINT NOT NULL, + rate_bps DOUBLE PRECISION NOT NULL, + PRIMARY KEY (market_id, ts_ns) +); diff --git a/crates/perplex-edge/src/db.rs b/crates/perplex-edge/src/db.rs new file mode 100644 index 0000000..3096e59 --- /dev/null +++ b/crates/perplex-edge/src/db.rs @@ -0,0 +1,360 @@ +//! Postgres-backed durability for `AppState`. The in-memory stores stay the +//! authoritative read path (hot, lock-guarded, sync); this module mirrors every +//! mutation to Postgres so a restart rehydrates instead of starting empty. +//! +//! Mutators are sync (they run under `parking_lot` locks and from background +//! tickers) while sqlx is async, so we cannot `.await` inside them. Instead each +//! mutator emits a [`PersistEvent`] onto an unbounded channel and a single +//! background writer task ([`spawn_writer`]) drains it and applies the SQL in +//! order. Reads never touch Postgres — only boot (`load_snapshot`) and the +//! writer do. + +use std::collections::HashMap; + +use sqlx::postgres::{PgPool, PgPoolOptions}; +use sqlx::Row; + +use crate::types::{FillInfo, OpenOrder, PositionInfo, PublicTrade}; + +/// A single durable mutation. Emitted by `AppState` mutators, applied by the +/// writer task. Variants map to the in-memory stores that must survive restart. +#[derive(Debug, Clone)] +pub enum PersistEvent { + /// A new resting order (the leftover after matching) was added. + UpsertOrder { address: String, order: OpenOrder }, + /// A single order was cancelled. + DeleteOrder { id: String }, + /// An address's full order list changed (matching decremented/reaped + /// several makers at once); replace its rows wholesale. + SyncOrders { + address: String, + orders: Vec, + }, + /// Append a fill to an address's history. + InsertFill { address: String, fill: FillInfo }, + /// An address's position book changed; replace its rows wholesale (a flip + /// or close mutates multiple fields, so a full sync is simplest + correct). + SyncPositions { + address: String, + positions: Vec, + }, + /// Set an address's vault balance. + UpsertVault { address: String, amount: String }, + /// Append a trade to the public tape. + InsertTrade { trade: PublicTrade }, + /// Append a funding-rate sample for a market. + InsertFunding { + market_id: String, + ts_ns: u64, + rate_bps: f64, + }, +} + +/// Everything loaded from Postgres at boot, grouped to match the in-memory maps. +#[derive(Default)] +pub struct DbSnapshot { + pub open_orders: HashMap>, + pub positions: HashMap>, + pub fills: HashMap>, + pub vault_balances: HashMap, + pub public_trades: HashMap>, + pub funding_history: HashMap>, +} + +#[derive(Clone)] +pub struct Db { + pool: PgPool, +} + +impl Db { + /// Open a pool and apply the schema. The schema is idempotent + /// (`CREATE TABLE IF NOT EXISTS`) so this is safe to run on every boot. + pub async fn connect(url: &str) -> anyhow::Result { + let pool = PgPoolOptions::new().max_connections(8).connect(url).await?; + sqlx::raw_sql(include_str!("../migrations/0001_init.sql")) + .execute(&pool) + .await?; + Ok(Self { pool }) + } + + /// Load the whole durable state into memory, grouped by the in-memory key + /// (address for user state, market for tape/funding). Insertion order is + /// preserved via the `seq` columns so fills/trades rehydrate in the same + /// order they were recorded. + pub async fn load_snapshot(&self) -> anyhow::Result { + let mut snap = DbSnapshot::default(); + + let rows = sqlx::query( + "SELECT id, address, market_id, side, order_type, price, qty, remaining, ts_ns, client_order_id FROM open_orders", + ) + .fetch_all(&self.pool) + .await?; + for r in rows { + let address: String = r.get("address"); + snap.open_orders + .entry(address) + .or_default() + .push(OpenOrder { + id: r.get("id"), + market_id: r.get("market_id"), + side: r.get("side"), + order_type: r.get("order_type"), + price: r.get("price"), + qty: r.get("qty"), + remaining: r.get("remaining"), + ts_ns: r.get("ts_ns"), + client_order_id: r.get("client_order_id"), + }); + } + + let rows = sqlx::query( + "SELECT address, market_id, size, side, entry_price_x18, mark_price_x18, notional_usdc, unrealised_pnl_usdc, realised_pnl_usdc, leverage, liquidation_price_x18, funding_paid_usdc, last_updated_ts_ns FROM positions", + ) + .fetch_all(&self.pool) + .await?; + for r in rows { + let address: String = r.get("address"); + snap.positions + .entry(address) + .or_default() + .push(PositionInfo { + market_id: r.get("market_id"), + size: r.get("size"), + side: r.get("side"), + entry_price_x18: r.get("entry_price_x18"), + mark_price_x18: r.get("mark_price_x18"), + notional_usdc: r.get("notional_usdc"), + unrealised_pnl_usdc: r.get("unrealised_pnl_usdc"), + realised_pnl_usdc: r.get("realised_pnl_usdc"), + leverage: r.get("leverage"), + liquidation_price_x18: r.get("liquidation_price_x18"), + funding_paid_usdc: r.get("funding_paid_usdc"), + last_updated_ts_ns: r.get("last_updated_ts_ns"), + }); + } + + let rows = sqlx::query( + "SELECT id, address, order_id, market_id, side, price, qty, fee_usdc, role, ts_ns, tx_hash FROM fills ORDER BY seq ASC", + ) + .fetch_all(&self.pool) + .await?; + for r in rows { + let address: String = r.get("address"); + snap.fills.entry(address).or_default().push(FillInfo { + id: r.get("id"), + order_id: r.get("order_id"), + market_id: r.get("market_id"), + side: r.get("side"), + price: r.get("price"), + qty: r.get("qty"), + fee_usdc: r.get("fee_usdc"), + role: r.get("role"), + ts_ns: r.get("ts_ns"), + tx_hash: r.get("tx_hash"), + }); + } + + let rows = sqlx::query("SELECT address, amount FROM vault_balances") + .fetch_all(&self.pool) + .await?; + for r in rows { + snap.vault_balances + .insert(r.get("address"), r.get("amount")); + } + + let rows = sqlx::query( + "SELECT id, market_id, price, qty, side, ts_ns FROM public_trades ORDER BY seq ASC", + ) + .fetch_all(&self.pool) + .await?; + for r in rows { + let market_id: String = r.get("market_id"); + snap.public_trades + .entry(market_id.clone()) + .or_default() + .push(PublicTrade { + id: r.get("id"), + market_id, + price: r.get("price"), + qty: r.get("qty"), + side: r.get("side"), + ts_ns: r.get("ts_ns"), + }); + } + + let rows = sqlx::query( + "SELECT market_id, ts_ns, rate_bps FROM funding_history ORDER BY ts_ns ASC", + ) + .fetch_all(&self.pool) + .await?; + for r in rows { + let market_id: String = r.get("market_id"); + let ts_ns: i64 = r.get("ts_ns"); + let rate_bps: f64 = r.get("rate_bps"); + snap.funding_history + .entry(market_id) + .or_default() + .push((ts_ns as u64, rate_bps)); + } + + Ok(snap) + } + + /// Apply one durable mutation. Idempotent where it can be: upserts use + /// `ON CONFLICT DO UPDATE`, the sync variants delete-then-insert under a + /// transaction so a partially-applied sync can't leave stale rows. + pub async fn apply(&self, ev: &PersistEvent) -> anyhow::Result<()> { + match ev { + PersistEvent::UpsertOrder { address, order } => { + upsert_order(&self.pool, address, order).await?; + } + PersistEvent::DeleteOrder { id } => { + sqlx::query("DELETE FROM open_orders WHERE id = $1") + .bind(id) + .execute(&self.pool) + .await?; + } + PersistEvent::SyncOrders { address, orders } => { + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM open_orders WHERE address = $1") + .bind(address) + .execute(&mut *tx) + .await?; + for order in orders { + sqlx::query( + "INSERT INTO open_orders (id, address, market_id, side, order_type, price, qty, remaining, ts_ns, client_order_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", + ) + .bind(&order.id) + .bind(address) + .bind(&order.market_id) + .bind(&order.side) + .bind(&order.order_type) + .bind(&order.price) + .bind(&order.qty) + .bind(&order.remaining) + .bind(&order.ts_ns) + .bind(&order.client_order_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + } + PersistEvent::InsertFill { address, fill } => { + sqlx::query( + "INSERT INTO fills (id, address, order_id, market_id, side, price, qty, fee_usdc, role, ts_ns, tx_hash) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (id) DO NOTHING", + ) + .bind(&fill.id) + .bind(address) + .bind(&fill.order_id) + .bind(&fill.market_id) + .bind(&fill.side) + .bind(&fill.price) + .bind(&fill.qty) + .bind(&fill.fee_usdc) + .bind(&fill.role) + .bind(&fill.ts_ns) + .bind(&fill.tx_hash) + .execute(&self.pool) + .await?; + } + PersistEvent::SyncPositions { address, positions } => { + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM positions WHERE address = $1") + .bind(address) + .execute(&mut *tx) + .await?; + for p in positions { + sqlx::query( + "INSERT INTO positions (address, market_id, size, side, entry_price_x18, mark_price_x18, notional_usdc, unrealised_pnl_usdc, realised_pnl_usdc, leverage, liquidation_price_x18, funding_paid_usdc, last_updated_ts_ns) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", + ) + .bind(address) + .bind(&p.market_id) + .bind(&p.size) + .bind(&p.side) + .bind(&p.entry_price_x18) + .bind(&p.mark_price_x18) + .bind(&p.notional_usdc) + .bind(&p.unrealised_pnl_usdc) + .bind(&p.realised_pnl_usdc) + .bind(&p.leverage) + .bind(&p.liquidation_price_x18) + .bind(&p.funding_paid_usdc) + .bind(&p.last_updated_ts_ns) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + } + PersistEvent::UpsertVault { address, amount } => { + sqlx::query( + "INSERT INTO vault_balances (address, amount) VALUES ($1,$2) ON CONFLICT (address) DO UPDATE SET amount = EXCLUDED.amount", + ) + .bind(address) + .bind(amount) + .execute(&self.pool) + .await?; + } + PersistEvent::InsertTrade { trade } => { + sqlx::query( + "INSERT INTO public_trades (id, market_id, price, qty, side, ts_ns) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (id) DO NOTHING", + ) + .bind(&trade.id) + .bind(&trade.market_id) + .bind(&trade.price) + .bind(&trade.qty) + .bind(&trade.side) + .bind(&trade.ts_ns) + .execute(&self.pool) + .await?; + } + PersistEvent::InsertFunding { + market_id, + ts_ns, + rate_bps, + } => { + sqlx::query( + "INSERT INTO funding_history (market_id, ts_ns, rate_bps) VALUES ($1,$2,$3) ON CONFLICT (market_id, ts_ns) DO UPDATE SET rate_bps = EXCLUDED.rate_bps", + ) + .bind(market_id) + .bind(*ts_ns as i64) + .bind(*rate_bps) + .execute(&self.pool) + .await?; + } + } + Ok(()) + } +} + +async fn upsert_order(pool: &PgPool, address: &str, order: &OpenOrder) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO open_orders (id, address, market_id, side, order_type, price, qty, remaining, ts_ns, client_order_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (id) DO UPDATE SET remaining = EXCLUDED.remaining", + ) + .bind(&order.id) + .bind(address) + .bind(&order.market_id) + .bind(&order.side) + .bind(&order.order_type) + .bind(&order.price) + .bind(&order.qty) + .bind(&order.remaining) + .bind(&order.ts_ns) + .bind(&order.client_order_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Spawn the background writer. Owns a pool clone, drains `rx` until every +/// sender (i.e. `AppState`) is dropped, and applies each event in order. A +/// failed write is logged and skipped — one bad row must not wedge the queue. +pub fn spawn_writer(db: Db, mut rx: tokio::sync::mpsc::UnboundedReceiver) { + tokio::spawn(async move { + while let Some(ev) = rx.recv().await { + if let Err(e) = db.apply(&ev).await { + tracing::error!(error = %e, "persist write failed"); + } + } + tracing::info!("persistence writer stopped (channel closed)"); + }); +} diff --git a/crates/perplex-edge/src/lib.rs b/crates/perplex-edge/src/lib.rs index e542839..62a3108 100644 --- a/crates/perplex-edge/src/lib.rs +++ b/crates/perplex-edge/src/lib.rs @@ -7,6 +7,7 @@ //! tests can swap them out trivially. pub mod auth; +pub mod db; pub mod error; pub mod handlers; pub mod market_stats; @@ -19,6 +20,7 @@ pub mod state; pub mod types; pub mod ws; +pub use db::{spawn_writer, Db, DbSnapshot, PersistEvent}; pub use error::ApiError; pub use router::{build_router, build_router_with_dev_token}; pub use state::AppState; diff --git a/crates/perplex-edge/src/main.rs b/crates/perplex-edge/src/main.rs index 38a7207..1979d2c 100644 --- a/crates/perplex-edge/src/main.rs +++ b/crates/perplex-edge/src/main.rs @@ -6,7 +6,7 @@ use std::time::Duration; use perplex_edge::market_stats::spawn_market_stats_ticker; use perplex_edge::oracle::{default_feeds, spawn_pyth_relayer}; use perplex_edge::ws::{serve_ws, Hub, WsConfig}; -use perplex_edge::{build_router, build_router_with_dev_token, AppState}; +use perplex_edge::{build_router, build_router_with_dev_token, spawn_writer, AppState, Db}; use tracing_subscriber::EnvFilter; #[derive(Parser, Debug)] @@ -56,11 +56,24 @@ async fn main() -> anyhow::Result<()> { .jwt_secret .unwrap_or_else(|| ulid::Ulid::new().to_string()) .into_bytes(); - let state = if args.dev_routes { - AppState::new_dev(secret) - } else { - AppState::new(secret) - }; + + // Postgres is required: every order/position/fill/balance is mirrored to it + // so a restart rehydrates instead of starting empty. Connect, apply the + // schema, load the existing state, then route future mutations to the + // background writer task. Unit tests bypass this via AppState::new_dev. + let db_url = std::env::var("DATABASE_URL").map_err(|_| { + anyhow::anyhow!( + "DATABASE_URL must be set — Perplex persists all state to Postgres. \ + Start the bundled Postgres (`docker compose up -d postgres`) or point \ + DATABASE_URL at your own instance." + ) + })?; + let db = Db::connect(&db_url).await?; + let snapshot = db.load_snapshot().await?; + let (persist_tx, persist_rx) = tokio::sync::mpsc::unbounded_channel(); + spawn_writer(db, persist_rx); + tracing::info!("postgres persistence enabled; state rehydrated from db"); + let state = AppState::new_persisted(secret, args.dev_routes, snapshot, persist_tx); let hub = Hub::new(); let app = if args.dev_routes { tracing::warn!("dev routes enabled: GET /__dev/token/:address is exposed"); diff --git a/crates/perplex-edge/src/market_stats.rs b/crates/perplex-edge/src/market_stats.rs index 30ac51b..45a8dee 100644 --- a/crates/perplex-edge/src/market_stats.rs +++ b/crates/perplex-edge/src/market_stats.rs @@ -25,7 +25,10 @@ use crate::ws::{Hub, Message, Topic}; pub fn spawn_market_stats_ticker(state: AppState, hub: Hub, period: Duration) { tokio::spawn(async move { let mut tick = interval(period); - info!(period_ms = period.as_millis() as u64, "market-stats ticker started"); + info!( + period_ms = period.as_millis() as u64, + "market-stats ticker started" + ); loop { tick.tick().await; let markets = state.list_markets(); @@ -45,4 +48,3 @@ pub fn spawn_market_stats_ticker(state: AppState, hub: Hub, period: Duration) { } }); } - diff --git a/crates/perplex-edge/src/oracle.rs b/crates/perplex-edge/src/oracle.rs index 018cab6..829709c 100644 --- a/crates/perplex-edge/src/oracle.rs +++ b/crates/perplex-edge/src/oracle.rs @@ -14,9 +14,7 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; -use perplex_oracle::{ - HermesSource, OracleError, PriceSample, Relayer, RelayerConfig, Submitter, -}; +use perplex_oracle::{HermesSource, OracleError, PriceSample, Relayer, RelayerConfig, Submitter}; use rust_decimal::Decimal; use serde_json::json; use tracing::{debug, info}; @@ -73,8 +71,7 @@ impl Submitter for EdgeSubmitter { price_x18 = %price_x18, "oracle submit" ); - self.state - .set_oracle_price(market_id, price_x18.clone()); + self.state.set_oracle_price(market_id, price_x18.clone()); // Fan out to subscribers of oracle.{marketId}. Confidence isn't // wired through PriceSample today (production would carry it); diff --git a/crates/perplex-edge/src/state.rs b/crates/perplex-edge/src/state.rs index fa68123..3fe703d 100644 --- a/crates/perplex-edge/src/state.rs +++ b/crates/perplex-edge/src/state.rs @@ -8,10 +8,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use parking_lot::RwLock; use rust_decimal::Decimal; +use tokio::sync::mpsc::UnboundedSender; -use crate::types::{ - FillInfo, MarketInfo, OpenOrder, PositionInfo, PositionsResponse, PublicTrade, -}; +use crate::db::{DbSnapshot, PersistEvent}; +use crate::types::{FillInfo, MarketInfo, OpenOrder, PositionInfo, PositionsResponse, PublicTrade}; /// Result of crossing a taker order against the existing resting book. #[derive(Debug, Clone)] @@ -78,6 +78,10 @@ struct Inner { /// the on-chain vault path — but on locally so traders can place orders /// without spinning up Anvil + the deposit relayer just to smoke-test. dev_seed_vault: bool, + /// Sink for durable mutations. `Some` when Postgres persistence is wired + /// (the running binary); `None` in unit tests, where state lives only in + /// memory. Mutators emit onto this; the writer task drains it. See `db.rs`. + persist_tx: Option>, } #[derive(Default, Clone)] @@ -94,32 +98,67 @@ const DEV_SEED_USDC_RAW: &str = "100000000000"; impl AppState { pub fn new(jwt_secret: Vec) -> Self { - Self::with_options(jwt_secret, false) + Self::with_options(jwt_secret, false, DbSnapshot::default(), None) } pub fn new_dev(jwt_secret: Vec) -> Self { - Self::with_options(jwt_secret, true) + Self::with_options(jwt_secret, true, DbSnapshot::default(), None) } - fn with_options(jwt_secret: Vec, dev_seed_vault: bool) -> Self { + /// Build a persistence-backed state: seed the in-memory stores from a + /// `DbSnapshot` loaded at boot, and route every future mutation to `tx` so + /// the writer task mirrors it back to Postgres. Used by the binary; tests + /// use `new`/`new_dev` and stay memory-only. + pub fn new_persisted( + jwt_secret: Vec, + dev_seed_vault: bool, + snapshot: DbSnapshot, + tx: UnboundedSender, + ) -> Self { + let state = Self::with_options(jwt_secret, dev_seed_vault, snapshot, Some(tx)); + // Rebuild the public orderbook for every market from the rehydrated + // resting orders so the book isn't empty until the next mutation. + let market_ids: Vec = state.inner.markets.keys().cloned().collect(); + for mid in market_ids { + state.rebuild_orderbook(&mid); + } + state + } + + fn with_options( + jwt_secret: Vec, + dev_seed_vault: bool, + snapshot: DbSnapshot, + persist_tx: Option>, + ) -> Self { Self { inner: Arc::new(Inner { markets: default_markets(), orderbooks: RwLock::new(default_books()), - public_trades: RwLock::new(HashMap::new()), - open_orders: RwLock::new(HashMap::new()), - positions: RwLock::new(HashMap::new()), - fills: RwLock::new(HashMap::new()), - vault_balances: RwLock::new(HashMap::new()), + public_trades: RwLock::new(snapshot.public_trades), + open_orders: RwLock::new(snapshot.open_orders), + positions: RwLock::new(snapshot.positions), + fills: RwLock::new(snapshot.fills), + vault_balances: RwLock::new(snapshot.vault_balances), siwe_nonces: RwLock::new(HashMap::new()), jwt_secret, - funding_history: RwLock::new(HashMap::new()), + funding_history: RwLock::new(snapshot.funding_history), oracle_prices: RwLock::new(HashMap::new()), dev_seed_vault, + persist_tx, }), } } + /// Emit a durable mutation to the writer task. No-op when persistence is + /// off (unit tests). Never blocks — the channel is unbounded; a closed + /// channel (writer gone) is logged once by the caller path, not here. + fn emit(&self, ev: PersistEvent) { + if let Some(tx) = &self.inner.persist_tx { + let _ = tx.send(ev); + } + } + /// Idempotent — seeds the address with `DEV_SEED_USDC_RAW` USDC the first /// time it's seen, only when `dev_seed_vault` was opted in at construction. pub fn seed_dev_vault(&self, address: &str) { @@ -127,9 +166,14 @@ impl AppState { return; } let mut vaults = self.inner.vault_balances.write(); - vaults - .entry(address.to_string()) - .or_insert_with(|| DEV_SEED_USDC_RAW.to_string()); + if !vaults.contains_key(address) { + vaults.insert(address.to_string(), DEV_SEED_USDC_RAW.to_string()); + drop(vaults); + self.emit(PersistEvent::UpsertVault { + address: address.to_string(), + amount: DEV_SEED_USDC_RAW.to_string(), + }); + } } pub fn jwt_secret(&self) -> &[u8] { @@ -304,6 +348,9 @@ impl AppState { } pub fn record_trade(&self, market_id: &str, trade: PublicTrade) { + self.emit(PersistEvent::InsertTrade { + trade: trade.clone(), + }); self.inner .public_trades .write() @@ -323,6 +370,10 @@ impl AppState { pub fn add_open_order(&self, address: &str, order: OpenOrder) { let market_id = order.market_id.clone(); + self.emit(PersistEvent::UpsertOrder { + address: address.to_string(), + order: order.clone(), + }); self.inner .open_orders .write() @@ -346,6 +397,11 @@ impl AppState { false }; drop(g); + if changed { + self.emit(PersistEvent::DeleteOrder { + id: order_id.to_string(), + }); + } if let Some(mid) = market_id { self.rebuild_orderbook(&mid); } @@ -596,6 +652,27 @@ impl AppState { // Phase 3 — refresh the public orderbook snapshot. self.rebuild_orderbook(market_id); + // Phase 4 — persist the maker books that changed. Matching decremented + // (and possibly reaped) one or more maker orders; only the addresses in + // `filled` were touched, so re-sync just those lists rather than the + // whole map. No-op when persistence is off. + if self.inner.persist_tx.is_some() && !filled.is_empty() { + let mut seen: Vec = Vec::new(); + for f in &filled { + if !seen.contains(&f.maker_address) { + seen.push(f.maker_address.clone()); + } + } + let orders = self.inner.open_orders.read(); + for addr in seen { + let list = orders.get(&addr).cloned().unwrap_or_default(); + self.emit(PersistEvent::SyncOrders { + address: addr, + orders: list, + }); + } + } + filled } @@ -611,6 +688,24 @@ impl AppState { order_side: &str, qty: Decimal, price: Decimal, + ) { + self.apply_position_fill(address, market_id, order_side, qty, price); + if self.inner.persist_tx.is_some() { + let positions = self.positions_for(address); + self.emit(PersistEvent::SyncPositions { + address: address.to_string(), + positions, + }); + } + } + + fn apply_position_fill( + &self, + address: &str, + market_id: &str, + order_side: &str, + qty: Decimal, + price: Decimal, ) { let new_side = if order_side == "buy" { "long" } else { "short" }; tracing::info!( @@ -698,6 +793,10 @@ impl AppState { } pub fn set_positions(&self, address: &str, positions: Vec) { + self.emit(PersistEvent::SyncPositions { + address: address.to_string(), + positions: positions.clone(), + }); self.inner .positions .write() @@ -718,6 +817,10 @@ impl AppState { } pub fn record_fill(&self, address: &str, fill: FillInfo) { + self.emit(PersistEvent::InsertFill { + address: address.to_string(), + fill: fill.clone(), + }); self.inner .fills .write() @@ -738,10 +841,7 @@ impl AppState { let mut positions = self.positions_for(address); let collateral_str = self.vault_balance(address); - let collateral_dollars = collateral_str - .parse::() - .unwrap_or_default() - / usdc_scale; + let collateral_dollars = collateral_str.parse::().unwrap_or_default() / usdc_scale; let mut total_notional = Decimal::ZERO; let mut total_pnl = Decimal::ZERO; @@ -786,7 +886,11 @@ impl AppState { let free = { let raw = collateral_dollars + total_pnl - used_margin; - if raw < Decimal::ZERO { Decimal::ZERO } else { raw } + if raw < Decimal::ZERO { + Decimal::ZERO + } else { + raw + } }; PositionsResponse { @@ -808,6 +912,10 @@ impl AppState { } pub fn set_vault_balance(&self, address: &str, amount: String) { + self.emit(PersistEvent::UpsertVault { + address: address.to_string(), + amount: amount.clone(), + }); self.inner .vault_balances .write() @@ -828,6 +936,11 @@ impl AppState { } pub fn record_funding_point(&self, market_id: &str, ts_ns: u64, rate_bps: f64) { + self.emit(PersistEvent::InsertFunding { + market_id: market_id.to_string(), + ts_ns, + rate_bps, + }); self.inner .funding_history .write() @@ -1158,4 +1271,67 @@ mod tests { assert!(snap.bids.is_empty()); assert!(snap.asks.is_empty()); } + + // -- persistence wiring (no live Postgres; exercises the snapshot rehydrate + // path and the event channel that the writer task drains in prod) ------ + + #[test] + fn new_persisted_rehydrates_orders_balances_and_rebuilds_book() { + let mut snapshot = DbSnapshot::default(); + snapshot + .open_orders + .insert("0xmaker".into(), vec![order("o1", "buy", "99950", "0.3")]); + snapshot + .vault_balances + .insert("0xmaker".into(), "12345".into()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let state = AppState::new_persisted(b"test".to_vec(), false, snapshot, tx); + + // Stores came back populated... + assert_eq!(state.open_orders_for("0xmaker").len(), 1); + assert_eq!(state.vault_balance("0xmaker"), "12345"); + // ...and the public book was rebuilt from the resting order on boot. + let snap = state.orderbook("btc-usd").expect("book rebuilt"); + assert_eq!(snap.bids, vec![["99950".to_string(), "0.3".to_string()]]); + } + + #[test] + fn mutations_emit_persist_events() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let state = AppState::new_persisted(b"test".to_vec(), false, DbSnapshot::default(), tx); + + state.add_open_order("0xa", order("o1", "buy", "99000", "0.1")); + match rx.try_recv().expect("order event emitted") { + PersistEvent::UpsertOrder { address, order } => { + assert_eq!(address, "0xa"); + assert_eq!(order.id, "o1"); + } + other => panic!("expected UpsertOrder, got {other:?}"), + } + + state.set_vault_balance("0xa", "500".into()); + match rx.try_recv().expect("vault event emitted") { + PersistEvent::UpsertVault { address, amount } => { + assert_eq!(address, "0xa"); + assert_eq!(amount, "500"); + } + other => panic!("expected UpsertVault, got {other:?}"), + } + + assert!(state.cancel_order("0xa", "o1")); + match rx.try_recv().expect("cancel event emitted") { + PersistEvent::DeleteOrder { id } => assert_eq!(id, "o1"), + other => panic!("expected DeleteOrder, got {other:?}"), + } + } + + #[test] + fn memory_only_state_emits_nothing() { + // AppState::new has no persist channel; mutating must not panic and + // must stay a pure in-memory path (this is what every unit test and + // CI run without Postgres relies on). + let state = AppState::new(b"test".to_vec()); + state.add_open_order("0xa", order("o1", "buy", "99000", "0.1")); + assert_eq!(state.open_orders_for("0xa").len(), 1); + } }