diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index bad8dd3d..818255f1 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -1,8 +1,11 @@ name: Mutation Testing on: - push: - branches: [main] + # NOTE: intentionally NOT run on push to main. Full mutation testing is + # slow (one test-suite run per mutant) and `continue-on-error` here, so it + # gates nothing โ€” running it on every merge only burns CI minutes. It runs + # as a weekly audit (schedule) and on demand (workflow_dispatch), plus an + # opt-in per-PR job (the `run-mutation` label). pull_request: branches: [main] types: [opened, synchronize, reopened, labeled] @@ -38,7 +41,9 @@ jobs: cache-on-failure: true - name: Install cargo-mutants - run: cargo install cargo-mutants + uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants - name: Run mutation testing on changed files # Only test mutants in files changed in this PR @@ -185,7 +190,9 @@ jobs: cache-on-failure: true - name: Install cargo-mutants - run: cargo install cargo-mutants + uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants - name: Run full mutation testing run: cargo mutants diff --git a/CHANGELOG.md b/CHANGELOG.md index e8809d03..b7d2928c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,58 +27,40 @@ gpg: Good signature from "Catrya (github) <140891948+Catrya@users.noreply.github That will verify the signature of the manifest file, which ensures integrity and authenticity of the archive you've downloaded locally containing the binaries. Next, depending on your operating system, you should then re-compute the sha256 hash of the archive with `shasum -a 256 `, compare it with the corresponding one in the manifest file, and ensure they match exactly. -## What's Changed in 0.17.4 +## What's Changed in 0.17.5 ### ๐Ÿš€ Features -* feat(bond): Phase 3.5 โ€” payout confirmation to the winner by [@grunch](https://github.com/grunch) in [#743](https://github.com/MostroP2P/mostro/pull/743) -* feat(bond): Phase 3 โ€” payout flow for slashed bonds by [@grunch](https://github.com/grunch) in [#738](https://github.com/MostroP2P/mostro/pull/738) -* feat(bond): Phase 2 โ€” solver-directed dispute slash by [@grunch](https://github.com/grunch) in [#737](https://github.com/MostroP2P/mostro/pull/737) -* feat: support MOSTRO_NSEC_PRIVKEY env var for Nostr private key by [@AndreaDiazCorreia](https://github.com/AndreaDiazCorreia) in [#713](https://github.com/MostroP2P/mostro/pull/713) -* feat(bond): Phase 1.5 โ€” dedicated PayBondInvoice action + WaitingTakerBond status by [@grunch](https://github.com/grunch) in [#736](https://github.com/MostroP2P/mostro/pull/736) -* feat(bond): concurrent taker bonds, first-to-lock wins (Phase 0+1) by [@grunch](https://github.com/grunch) in [#733](https://github.com/MostroP2P/mostro/pull/733) -* feat(bond): align AntiAbuseBondSettings with spec โ€” slash split, claim window, drop unused dispute flag by [@grunch](https://github.com/grunch) in [#728](https://github.com/MostroP2P/mostro/pull/728) -* feat(bond): add Forfeited terminal state for long-stop bond payout by [@grunch](https://github.com/grunch) in [#727](https://github.com/MostroP2P/mostro/pull/727) -* feat(bond): add Phase 0 schema columns for split, forfeit window, and retry separation by [@grunch](https://github.com/grunch) in [#726](https://github.com/MostroP2P/mostro/pull/726) -* feat: added catrya key for manifest signature by [@Catrya](https://github.com/Catrya) in [#724](https://github.com/MostroP2P/mostro/pull/724) -* feat(bond): anti-abuse bond phase 1 โ€” taker lifecycle (lock + always release) by [@grunch](https://github.com/grunch) in [#719](https://github.com/MostroP2P/mostro/pull/719) -* feat(nip59): adopt mostro-core 0.10.0 dual-key gift wrap transport by [@grunch](https://github.com/grunch) in [#718](https://github.com/MostroP2P/mostro/pull/718) -* feat(bond): anti-abuse bond phase 0 foundation by [@grunch](https://github.com/grunch) in [#712](https://github.com/MostroP2P/mostro/pull/712) +* feat(price): Phase 2 โ€” direct backup quoters + multi-source aggregation by [@grunch](https://github.com/grunch) in [#773](https://github.com/MostroP2P/mostro/pull/773) +* feat(bond): Phase 7 โ€” maker timeout slash by [@grunch](https://github.com/grunch) in [#775](https://github.com/MostroP2P/mostro/pull/775) +* feat(bond): Phase 6 โ€” range-order maker bond with proportional slashes by [@grunch](https://github.com/grunch) in [#770](https://github.com/MostroP2P/mostro/pull/770) +* feat(price): Phase 1 โ€” Yadio provider + PriceManager wiring by [@grunch](https://github.com/grunch) in [#753](https://github.com/MostroP2P/mostro/pull/753) +* feat(bond): Phase 5 โ€” maker bond (non-range) + dispute slash by [@grunch](https://github.com/grunch) in [#767](https://github.com/MostroP2P/mostro/pull/767) +* feat(bond): Phase 4.5 โ€” re-prompt winner for payout invoice on payment failure by [@grunch](https://github.com/grunch) in [#755](https://github.com/MostroP2P/mostro/pull/755) +* feat(bond): Phase 4 โ€” timeout slash for the taker bond by [@grunch](https://github.com/grunch) in [#744](https://github.com/MostroP2P/mostro/pull/744) +* feat(price): Phase 0 โ€” multi-source price module foundation by [@grunch](https://github.com/grunch) in [#747](https://github.com/MostroP2P/mostro/pull/747) ### ๐Ÿ› Bug Fixes -* fix(price): tolerate null rates in Yadio /exrates/BTC response by [@grunch](https://github.com/grunch) in [#748](https://github.com/MostroP2P/mostro/pull/748) -* fix: include created_at on AddInvoice SmallOrder by [@arkanoider](https://github.com/arkanoider) in [#739](https://github.com/MostroP2P/mostro/pull/739) -* fix(bond): align bond invoice memo with spec ยง6.1 by [@grunch](https://github.com/grunch) in [#735](https://github.com/MostroP2P/mostro/pull/735) -* fix(restore-session): re-send AddInvoice for failed payments on session restore by [@codaMW](https://github.com/codaMW) in [#721](https://github.com/MostroP2P/mostro/pull/721) - -### ๐Ÿ’ผ Other - - -* Revert "fix(restore-session): re-send AddInvoice for failed payments on session restore" by [@grunch](https://github.com/grunch) in [#722](https://github.com/MostroP2P/mostro/pull/722) -* Add read and read-write dispute solver permissions by [@mostronatorcoder[bot]](https://github.com/mostronatorcoder[bot]) in [#708](https://github.com/MostroP2P/mostro/pull/708) +* fix(price): repair test-only price seeding broken by #753/#770 merge skew by [@grunch](https://github.com/grunch) in [#774](https://github.com/MostroP2P/mostro/pull/774) +* fix: let daemon finalize disputes without solver row by [@arkanoider](https://github.com/arkanoider) in [#746](https://github.com/MostroP2P/mostro/pull/746) ### ๐Ÿ“š Documentation -* docs: spec for multi-source price providers (remove Yadio single point of failure) by [@grunch](https://github.com/grunch) in [#745](https://github.com/MostroP2P/mostro/pull/745) -* docs(bond): add Phase 3.5 โ€” payout confirmation to the winner by [@grunch](https://github.com/grunch) in [#742](https://github.com/MostroP2P/mostro/pull/742) -* docs(bond): fold taker_* columns into Phase 0 schema by [@grunch](https://github.com/grunch) in [#734](https://github.com/MostroP2P/mostro/pull/734) -* docs(bond): switch Phase 1.5 to concurrent taker bonds, first-to-lock wins by [@grunch](https://github.com/grunch) in [#732](https://github.com/MostroP2P/mostro/pull/732) -* docs(bond): spec cancel_action handling for WaitingTakerBond status by [@grunch](https://github.com/grunch) in [#730](https://github.com/MostroP2P/mostro/pull/730) -* docs(bond): note that mostro-core 0.11.0 ships Phase 1.5 + Phase 2 variants by [@grunch](https://github.com/grunch) in [#729](https://github.com/MostroP2P/mostro/pull/729) -* docs(bond): decouple slash from trade outcome; clarify maker/taker vsโ€ฆ by [@grunch](https://github.com/grunch) in [#725](https://github.com/MostroP2P/mostro/pull/725) +* docs(bond): Phase 8 โ€” public config exposure + operator docs by [@grunch](https://github.com/grunch) in [#777](https://github.com/MostroP2P/mostro/pull/777) +* docs: document daemon event kinds by [@ermeme[bot]](https://github.com/ermeme[bot]) in [#769](https://github.com/MostroP2P/mostro/pull/769) +* docs: clarify Cashu escrow uses per-order trade keys by [@grunch](https://github.com/grunch) in [#757](https://github.com/MostroP2P/mostro/pull/757) +* docs: add Cashu 2-of-3 multisig escrow architecture spec by [@a1denvalu3](https://github.com/a1denvalu3) in [#756](https://github.com/MostroP2P/mostro/pull/756) ## Contributors -* [@grunch](https://github.com/grunch) made their contribution in [#748](https://github.com/MostroP2P/mostro/pull/748) -* [@arkanoider](https://github.com/arkanoider) made their contribution in [#739](https://github.com/MostroP2P/mostro/pull/739) -* [@AndreaDiazCorreia](https://github.com/AndreaDiazCorreia) made their contribution in [#713](https://github.com/MostroP2P/mostro/pull/713) -* [@Catrya](https://github.com/Catrya) made their contribution in [#724](https://github.com/MostroP2P/mostro/pull/724) -* [@codaMW](https://github.com/codaMW) made their contribution in [#721](https://github.com/MostroP2P/mostro/pull/721) -* [@mostronatorcoder[bot]](https://github.com/mostronatorcoder[bot]) made their contribution in [#708](https://github.com/MostroP2P/mostro/pull/708) - -**Full Changelog**: https://github.com/MostroP2P/mostro/compare/v0.17.3...0.17.4 +* [@grunch](https://github.com/grunch) made their contribution in [#773](https://github.com/MostroP2P/mostro/pull/773) +* [@ermeme[bot]](https://github.com/ermeme[bot]) made their contribution in [#769](https://github.com/MostroP2P/mostro/pull/769) +* [@a1denvalu3](https://github.com/a1denvalu3) made their contribution in [#756](https://github.com/MostroP2P/mostro/pull/756) +* [@arkanoider](https://github.com/arkanoider) made their contribution in [#746](https://github.com/MostroP2P/mostro/pull/746) + +**Full Changelog**: https://github.com/MostroP2P/mostro/compare/v0.17.4...0.17.5 diff --git a/Cargo.lock b/Cargo.lock index 5ddc2997..f37a3b1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -36,7 +36,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -45,7 +45,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.16", "once_cell", "version_check", ] @@ -79,9 +79,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -103,9 +103,9 @@ dependencies = [ [[package]] name = "anstream" -version = "1.0.0" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -118,44 +118,44 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.14" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "1.0.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.11" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" @@ -192,7 +192,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -209,9 +209,9 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" dependencies = [ "async-utility", "futures", @@ -249,15 +249,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.9" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ "axum-core", "bytes", @@ -288,9 +288,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.6" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", @@ -307,11 +307,12 @@ dependencies = [ [[package]] name = "base58ck" -version = "0.1.100" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin_hashes", + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.0", ] [[package]] @@ -328,24 +329,24 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bech32" -version = "0.11.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" [[package]] name = "bip39" -version = "2.2.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" dependencies = [ - "bitcoin_hashes", - "rand 0.8.6", + "bitcoin_hashes 0.13.0", + "rand 0.8.5", "rand_core 0.6.4", "serde", "unicode-normalization", @@ -353,27 +354,43 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.100" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39581299241111285f3268ba75ddf372746fd041620918b145c1af9d75e91b6c" +checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945" dependencies = [ "base58ck", "base64 0.21.7", "bech32", + "bitcoin-internals 0.3.0", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes", - "hex-conservative", + "bitcoin_hashes 0.14.0", + "hex-conservative 0.2.1", "hex_lit", "secp256k1", "serde", ] +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-io" -version = "0.1.100" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" [[package]] name = "bitcoin-payment-instructions" @@ -383,26 +400,37 @@ checksum = "c300c948b2ff78c965ea3a613372352125448a22f1acf49e95e3878149824091" dependencies = [ "bitcoin", "lightning", - "lightning-invoice 0.34.0", + "lightning-invoice 0.34.1", ] [[package]] name = "bitcoin-units" -version = "0.1.100" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57bad157b78d0d1b22c4cbb6a35a566211fc4d14866a37f2c780652b50f3b845" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" dependencies = [ + "bitcoin-internals 0.3.0", "serde", ] [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals 0.2.0", + "hex-conservative 0.1.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.1", "serde", ] @@ -414,9 +442,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -436,7 +464,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -454,14 +482,14 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -470,9 +498,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -495,9 +523,9 @@ checksum = "b4ae4235e6dac0694637c763029ecea1a2ec9e4e06ec2729bd21ba4d9c863eb7" [[package]] name = "bumpalo" -version = "3.20.3" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byte-tools" @@ -513,9 +541,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cashu" @@ -527,7 +555,7 @@ dependencies = [ "cbor-diag", "ciborium", "lightning", - "lightning-invoice 0.34.0", + "lightning-invoice 0.34.1", "nostr-sdk", "once_cell", "serde", @@ -535,7 +563,7 @@ dependencies = [ "serde_with", "strum", "strum_macros", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "unicode-normalization", "url", @@ -574,9 +602,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "jobserver", @@ -600,20 +628,20 @@ dependencies = [ "cdk-signatory", "ciborium", "futures", - "getrandom 0.2.17", + "getrandom 0.2.16", "gloo-timers", "hickory-resolver", "jsonwebtoken", "lightning", - "lightning-invoice 0.34.0", + "lightning-invoice 0.34.1", "nostr-sdk", "regex", "ring 0.17.14", - "rustls 0.23.40", + "rustls 0.23.35", "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -637,18 +665,18 @@ dependencies = [ "cdk-http-client", "ciborium", "futures", - "getrandom 0.2.17", + "getrandom 0.2.16", "jsonwebtoken", "lightning", - "lightning-invoice 0.34.0", + "lightning-invoice 0.34.1", "parking_lot 0.12.5", "paste", "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", - "tonic 0.14.6", + "tonic 0.14.2", "tracing", "url", "uuid", @@ -670,7 +698,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio-tungstenite", "tracing", "url", @@ -691,14 +719,14 @@ dependencies = [ "bitcoin", "cdk-common", "clap", - "getrandom 0.2.17", + "getrandom 0.2.16", "home", - "prost 0.14.3", - "rustls 0.23.40", - "thiserror 2.0.18", + "prost 0.14.1", + "rustls 0.23.35", + "thiserror 2.0.17", "tokio", "tokio-stream", - "tonic 0.14.6", + "tonic 0.14.2", "tonic-prost", "tonic-prost-build", "tracing", @@ -725,18 +753,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", ] [[package]] @@ -746,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", "zeroize", @@ -754,9 +771,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", "js-sys", @@ -806,9 +823,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -816,9 +833,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", @@ -828,40 +845,40 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clearscreen" -version = "4.0.6" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d669bb552908e336ad5681789752033b45566b7e591aeaac7a614e58e5d6d8f2" +checksum = "85a8ab73a1c02b0c15597b22e09c7dc36e63b2f601f9d1e83ac0c3decd38b1ae" dependencies = [ "nix", "terminfo", - "thiserror 2.0.18", + "thiserror 2.0.17", "which", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] name = "colorchoice" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compression-codecs" @@ -921,15 +938,6 @@ 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 = "crc" version = "1.8.1" @@ -941,18 +949,18 @@ dependencies = [ [[package]] name = "crc" -version = "3.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crc16" @@ -1022,11 +1030,11 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "rand_core 0.6.4", "typenum", ] @@ -1051,7 +1059,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -1062,14 +1070,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "deranged" @@ -1077,7 +1085,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1137,13 +1144,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -1152,7 +1159,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.0", ] [[package]] @@ -1186,9 +1193,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] @@ -1217,9 +1224,15 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -1250,9 +1263,9 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fedimint-tonic-lnd" @@ -1265,7 +1278,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "prost 0.13.5", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-pemfile", "tokio", "tokio-stream", @@ -1275,9 +1288,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "fixedbitset" @@ -1287,9 +1300,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -1313,12 +1326,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1330,9 +1337,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1345,9 +1352,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1355,15 +1362,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -1383,38 +1390,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -1424,6 +1431,7 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", + "pin-utils", "slab", ] @@ -1438,9 +1446,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1448,9 +1456,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.17" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", @@ -1468,25 +1476,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - [[package]] name = "gloo-timers" version = "0.3.0" @@ -1501,9 +1495,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1511,7 +1505,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -1553,18 +1547,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] name = "hashlink" @@ -1598,9 +1583,15 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + +[[package]] +name = "hex-conservative" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" dependencies = [ "arrayvec", ] @@ -1618,7 +1609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "data-encoding", "enum-as-inner", @@ -1628,10 +1619,10 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.4", + "rand 0.9.2", "ring 0.17.14", "rustls-pki-types", - "thiserror 2.0.18", + "thiserror 2.0.17", "time", "tinyvec", "tokio", @@ -1652,10 +1643,10 @@ dependencies = [ "moka", "once_cell", "parking_lot 0.12.5", - "rand 0.9.4", + "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -1680,11 +1671,12 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", + "fnv", "itoa", ] @@ -1725,9 +1717,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ "atomic-waker", "bytes", @@ -1740,6 +1732,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1747,19 +1740,20 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.4", ] [[package]] @@ -1777,13 +1771,14 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http", "http-body", @@ -1792,7 +1787,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1800,9 +1795,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.65" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1824,13 +1819,12 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1838,9 +1832,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1851,9 +1845,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1865,15 +1859,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ "icu_collections", "icu_locale_core", @@ -1885,15 +1879,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -1904,12 +1898,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1929,9 +1917,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1950,12 +1938,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown 0.16.0", "serde", "serde_core", ] @@ -1967,7 +1955,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding 0.3.3", - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -1988,7 +1976,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.4", + "socket2 0.6.1", "widestring", "windows-registry", "windows-result", @@ -1997,9 +1985,19 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -2018,9 +2016,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jobserver" @@ -2034,12 +2032,10 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ - "cfg-if", - "futures-util", "once_cell", "wasm-bindgen", ] @@ -2061,11 +2057,11 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -2074,17 +2070,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libm" @@ -2094,10 +2084,11 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ + "bitflags 2.13.0", "libc", ] @@ -2114,18 +2105,18 @@ dependencies = [ [[package]] name = "lightning" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c90397b635e3ece6b9a723fb470a46cb9b3592f217d72e40540a5fada00289d" +checksum = "45c421e68d603b989c8f59a95a1860f1f5de90a6305ed5bcb6d9b35a3a11f450" dependencies = [ "bech32", "bitcoin", "dnssec-prover", "hashbrown 0.13.2", "libm", - "lightning-invoice 0.34.0", + "lightning-invoice 0.34.1", "lightning-macros", - "lightning-types 0.3.1", + "lightning-types 0.3.2", "possiblyrandom", ] @@ -2142,13 +2133,13 @@ dependencies = [ [[package]] name = "lightning-invoice" -version = "0.34.0" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b85e5e14bcdb30d746e9785b04f27938292e8944f78f26517e01e91691f6b3f2" +checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444" dependencies = [ "bech32", "bitcoin", - "lightning-types 0.3.1", + "lightning-types 0.3.2", "serde", ] @@ -2160,7 +2151,7 @@ checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -2174,24 +2165,24 @@ dependencies = [ [[package]] name = "lightning-types" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb1aac93f22f2c2eac8a0ee83bb1a1ea58673caa2c82847302710b83364d04e6" +checksum = "c211dfcff95ca308247da8b1e0e81604bc9e568239967cd2c34572558511e869" dependencies = [ "bitcoin", ] [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lnurl-rs" @@ -2223,15 +2214,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.30" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru" -version = "0.16.4" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" [[package]] name = "lru-slab" @@ -2285,9 +2276,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.8.1" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mime" @@ -2313,9 +2304,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "wasi", @@ -2341,7 +2332,7 @@ dependencies = [ [[package]] name = "mostro" -version = "0.17.4" +version = "0.17.5" dependencies = [ "async-trait", "axum", @@ -2357,13 +2348,14 @@ dependencies = [ "dotenvy", "easy-hasher", "fedimint-tonic-lnd", + "futures", "lightning-invoice 0.33.2", "lnurl-rs", "mostro-core", "mutants", "nostr-sdk", "once_cell", - "prost 0.14.3", + "prost 0.14.1", "reqwest", "serde", "serde_json", @@ -2371,7 +2363,7 @@ dependencies = [ "sqlx-crud", "tokio", "toml", - "tonic 0.14.6", + "tonic 0.14.2", "tonic-prost", "tonic-prost-build", "tower-http", @@ -2382,9 +2374,9 @@ dependencies = [ [[package]] name = "mostro-core" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd51c0128fd40802dd7828b1d682bf63bda1cfb75630506a49ca630bd63a9088" +checksum = "d763fddd85d86033f78dacadb8b75041100af7dae4d50e5bea9dc91fc1a1d24c" dependencies = [ "bitcoin", "chrono", @@ -2417,11 +2409,11 @@ checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" [[package]] name = "nix" -version = "0.31.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2439,19 +2431,19 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.3" +version = "0.44.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8f0fe13526800300a36bf3b7c5f752e62e32ab81c74a8e5caa2865708625a" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" dependencies = [ "aes", "base64 0.22.1", "bech32", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.0", "cbc", - "chacha20 0.9.1", + "chacha20", "chacha20poly1305", - "getrandom 0.2.17", + "getrandom 0.2.16", "hex", "instant", "scrypt", @@ -2484,9 +2476,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" dependencies = [ "async-utility", "async-wsocket", @@ -2571,9 +2563,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.4" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" dependencies = [ "critical-section", "portable-atomic", @@ -2707,18 +2699,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.14.0", -] - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.12.0", ] [[package]] @@ -2747,7 +2728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -2761,35 +2742,41 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.13" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.13" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "pin-project-lite" -version = "0.2.17" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "poly1305" @@ -2797,7 +2784,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", "opaque-debug 0.3.1", "universal-hash", ] @@ -2810,18 +2797,18 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "possiblyrandom" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.16", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -2848,14 +2835,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -2872,12 +2859,12 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive 0.14.3", + "prost-derive 0.14.1", ] [[package]] @@ -2891,33 +2878,34 @@ dependencies = [ "log", "multimap", "once_cell", - "petgraph 0.7.1", + "petgraph", "prettyplease", "prost 0.13.5", "prost-types 0.13.5", "regex", - "syn 2.0.117", + "syn 2.0.110", "tempfile", ] [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck 0.5.0", "itertools", "log", "multimap", - "petgraph 0.8.3", + "once_cell", + "petgraph", "prettyplease", - "prost 0.14.3", - "prost-types 0.14.3", + "prost 0.14.1", + "prost-types 0.14.1", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.117", + "syn 2.0.110", "tempfile", ] @@ -2931,20 +2919,20 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -2958,29 +2946,29 @@ dependencies = [ [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost 0.14.3", + "prost 0.14.1", ] [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "memchr", "unicase", ] [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.0" +version = "21.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" dependencies = [ "pulldown-cmark", ] @@ -2997,9 +2985,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", - "socket2 0.6.4", - "thiserror 2.0.18", + "rustls 0.23.35", + "socket2 0.6.1", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -3007,20 +2995,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.4", + "rand 0.9.2", "ring 0.17.14", "rustc-hash", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -3035,16 +3023,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -3055,17 +3043,11 @@ 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 = "rand" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3074,23 +3056,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_core 0.9.3", ] [[package]] @@ -3110,7 +3081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core 0.9.3", ] [[package]] @@ -3119,24 +3090,18 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.16", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 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 = "redox_syscall" version = "0.2.16" @@ -3152,7 +3117,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3161,9 +3126,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.16", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -3183,14 +3148,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -3200,9 +3165,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -3211,19 +3176,21 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ + "async-compression", "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3235,7 +3202,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-native-certs", "rustls-pki-types", "serde", @@ -3244,6 +3211,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", @@ -3251,7 +3219,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.4", ] [[package]] @@ -3283,7 +3251,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom 0.2.16", "libc", "untrusted 0.9.0", "windows-sys 0.52.0", @@ -3291,17 +3259,17 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -3322,9 +3290,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", @@ -3337,9 +3305,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3358,9 +3326,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "web-time", "zeroize", @@ -3368,9 +3336,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring 0.17.14", "rustls-pki-types", @@ -3385,9 +3353,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.23" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "salsa20" @@ -3465,8 +3433,8 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes", - "rand 0.8.6", + "bitcoin_hashes 0.14.0", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -3486,7 +3454,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -3503,12 +3471,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "separator" version = "0.4.1" @@ -3542,20 +3504,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", + "ryu", "serde", "serde_core", - "zmij", ] [[package]] @@ -3571,9 +3533,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ "serde_core", ] @@ -3592,16 +3554,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -3612,14 +3574,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -3638,7 +3600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.10.7", ] @@ -3667,7 +3629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest 0.10.7", ] @@ -3701,25 +3663,24 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.8" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ - "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] name = "simple_asn1" @@ -3729,21 +3690,21 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.17", "time", ] [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -3763,12 +3724,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3818,7 +3779,7 @@ dependencies = [ "byteorder", "bytes", "chrono", - "crc 3.4.0", + "crc 3.3.0", "crossbeam-queue", "dotenvy", "either", @@ -3876,7 +3837,7 @@ dependencies = [ "Inflector", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -3950,7 +3911,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -3972,9 +3933,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -3998,7 +3959,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -4009,12 +3970,12 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.27.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4043,11 +4004,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.17", ] [[package]] @@ -4058,18 +4019,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -4083,12 +4044,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4098,15 +4058,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -4114,9 +4074,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4124,9 +4084,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -4139,9 +4099,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", @@ -4149,20 +4109,20 @@ dependencies = [ "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -4182,15 +4142,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls 0.23.35", "tokio", ] [[package]] name = "tokio-socks" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" dependencies = [ "either", "futures-util", @@ -4200,9 +4160,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -4217,7 +4177,7 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-native-certs", "rustls-pki-types", "tokio", @@ -4228,9 +4188,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -4241,42 +4201,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.12.0", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", "toml_writer", - "winnow 0.7.15", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow 1.0.3", + "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tonic" @@ -4309,9 +4269,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.6" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "axum", @@ -4326,7 +4286,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.4", + "socket2 0.6.1", "sync_wrapper", "tokio", "tokio-rustls 0.26.4", @@ -4348,57 +4308,57 @@ dependencies = [ "prost-build 0.13.5", "prost-types 0.13.5", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "tonic-build" -version = "0.14.6" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "tonic-prost" -version = "0.14.6" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" dependencies = [ "bytes", - "prost 0.14.3", - "tonic 0.14.6", + "prost 0.14.1", + "tonic 0.14.2", ] [[package]] name = "tonic-prost-build" -version = "0.14.6" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" dependencies = [ "prettyplease", "proc-macro2", - "prost-build 0.14.3", - "prost-types 0.14.3", + "prost-build 0.14.1", + "prost-types 0.14.1", "quote", - "syn 2.0.117", + "syn 2.0.110", "tempfile", - "tonic-build 0.14.6", + "tonic-build 0.14.2", ] [[package]] name = "tower" -version = "0.5.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", @@ -4411,25 +4371,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", + "iri-string", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "url", ] [[package]] @@ -4446,9 +4401,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.44" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -4458,20 +4413,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.31" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "tracing-core" -version = "0.1.36" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -4490,9 +4445,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.23" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", @@ -4523,25 +4478,25 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", - "rustls 0.23.40", + "rand 0.9.2", + "rustls 0.23.35", "rustls-pki-types", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.17", "utf-8", ] [[package]] name = "typenum" -version = "1.20.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicase" -version = "2.9.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-bidi" @@ -4551,9 +4506,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" @@ -4572,9 +4527,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -4582,12 +4537,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -4626,7 +4575,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls 0.23.40", + "rustls 0.23.35", "rustls-pki-types", "serde", "serde_json", @@ -4636,15 +4585,14 @@ dependencies = [ [[package]] name = "url" -version = "2.5.8" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -4667,17 +4615,29 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.3.4", "js-sys", - "rand 0.10.1", - "serde_core", + "rand 0.9.2", + "serde", + "uuid-macro-internal", "wasm-bindgen", ] +[[package]] +name = "uuid-macro-internal" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9384a660318abfbd7f8932c34d67e4d1ec511095f95972ddc01e19d7ba8413f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4713,27 +4673,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -4744,19 +4695,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ + "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4764,65 +4718,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -4863,25 +4783,27 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.4", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "8.0.2" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ - "libc", + "env_home", + "rustix", + "winsafe", ] [[package]] @@ -4933,7 +4855,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -4944,7 +4866,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] @@ -5149,121 +5071,33 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.3" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "winsafe" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.57.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5272,54 +5106,54 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] [[package]] name = "zerofrom" -version = "0.1.8" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", "synstructure", ] @@ -5331,9 +5165,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -5342,9 +5176,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5353,21 +5187,15 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.110", ] -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 939a0f6f..60e97912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mostro" -version = "0.17.4" +version = "0.17.5" edition = "2021" license = "MIT" authors = ["Francisco Calderรณn "] @@ -70,10 +70,11 @@ reqwest = { version = "0.12.1", default-features = false, features = [ "json", "rustls-tls", ] } -mostro-core = { version = "0.12.0", features = ["sqlx"] } +mostro-core = { version = "0.13.0", features = ["sqlx"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } async-trait = "0.1.83" +futures = "0.3.31" clap = { version = "4.5.45", features = ["derive"] } lnurl-rs = { version = "0.9.0", default-features = false, features = ["ureq"] } once_cell = "1.20.2" diff --git a/README.md b/README.md index af268686..01acf047 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ While @lnp2pBot works excellently, it relies on Telegramโ€”a platform potentiall - **User Reputation** - Peer rating system to build trust between traders - **Automatic Timeouts** - Configurable expiration for orders and payment windows - **Payment Retry Logic** - Automatic retry for failed Lightning payments with configurable attempts +- **Anti-Abuse Bond** - Optional, opt-in Lightning hold-invoice bond for makers and/or takers; released on honest trades, slashed only on a solver dispute directive or a waiting-state timeout (off by default โ€” see [docs/ANTI_ABUSE_BOND.md](docs/ANTI_ABUSE_BOND.md)) ### Advanced Features - **Development Fee System** - Transparent fee collection with Nostr audit events (kind 8383) @@ -66,6 +67,26 @@ While @lnp2pBot works excellently, it relies on Telegramโ€”a platform potentiall - **Nostr Protocol** - NIP-59 (GiftWrap), NIP-33 (replaceable events) compliance - **Observability** - Structured logging with `tracing`, configurable log levels +### Nostr Event Kinds Used by the Daemon + +Mostro uses a small set of Nostr event kinds. Some are part of the public protocol, and some are transport-only details that live inside NIP-59 GiftWrap envelopes. + +| Kind | Name / Constant | Used for | Notes | +| --- | --- | --- | --- | +| `0` | `Metadata` | Mostro profile metadata | Standard Nostr profile event published at startup when metadata is configured. | +| `1` | `TextNote` | Inner rumor in NIP-59 | Transport-only. Mostro creates and reads it *inside* GiftWrap; it is not published as a standalone public event. | +| `13` | `Seal` | Inner sealed envelope in NIP-59 | Transport-only. Mostro creates and reads it *inside* GiftWrap; it is not published as a standalone public event. | +| `1059` | `GiftWrap` | NIP-59 outer envelope | This is the relay-visible event kind that Mostro subscribes to and publishes for wrapped messages. | +| `10002` | `RelayList` | Relay metadata | Standard Nostr relay list event published periodically by the scheduler. | +| `8383` | `DEV_FEE_AUDIT_EVENT_KIND` | Dev fee audit event | Public audit event used for transparent fee accounting. | +| `30078` | `NOSTR_EXCHANGE_RATES_EVENT_KIND` | Exchange rates | NIP-33 replaceable event for BTC/fiat rate publishing. | +| `38383` | `NOSTR_ORDER_EVENT_KIND` | Orders | NIP-33 replaceable event for order publications. | +| `38384` | `NOSTR_RATING_EVENT_KIND` | Ratings | NIP-33 replaceable event for reputation snapshots. | +| `38385` | `NOSTR_INFO_EVENT_KIND` | Mostro info | NIP-33 replaceable event for operator / node metadata. | +| `38386` | `NOSTR_DISPUTE_EVENT_KIND` | Disputes | NIP-33 replaceable event for dispute publications. | + +> Note: `kind 1` and `kind 13` are *inside* the `kind 1059` GiftWrap transport. They are created and verified by the wrapping/unwrapping code, but they are not emitted or consumed as standalone public relay events by the daemon. + --- ## How It Works @@ -182,6 +203,48 @@ sequenceDiagram --- +### Anti-Abuse Bond Flow (optional) + +Operators can require an **opt-in** anti-abuse bond โ€” a *second* Lightning +hold invoice, separate from the trade escrow โ€” to discourage no-shows and +griefing. It is **off by default**; nodes that leave +`[anti_abuse_bond].enabled = false` behave exactly as before. Full design: +[docs/ANTI_ABUSE_BOND.md](docs/ANTI_ABUSE_BOND.md). + +The feature deliberately keeps two axes separate: + +- **Maker / taker โ€” *who posts the bond, and when.*** The bond is locked + at order-creation time (maker) or take time (taker). The `apply_to` + setting (`take` | `make` | `both`) selects which side(s) post one. +- **Buyer / seller โ€” *whose action can trigger a slash.*** All trade + duties (paying the hold invoice, sending the buyer invoice, sending + fiat, releasing) are buyer/seller duties, so a solver's slash directive + is expressed as `slash_seller` / `slash_buyer`. The order kind maps + these to the right bond row (sell โ†’ maker is seller; buy โ†’ maker is + buyer). + +Lifecycle: + +1. **Lock.** On take/create, Mostro sends the bonded party an + `Action::PayBondInvoice`; once the HTLC is held, the bond is `Locked` + and the trade proceeds. +2. **Release (the common case).** On normal completion, or on any cancel + before a waiting-state timeout, the bond hold invoice is cancelled and + the bond is `Released` โ€” nothing is captured. +3. **Slash (only when unambiguous).** Either a solver directs it via the + `BondResolution` payload on `admin-settle` / `admin-cancel`, or a + waiting-state timeout elapses (when `slash_on_waiting_timeout = true`). + The bond HTLC is settled into Mostro's wallet, then split per + `slash_node_share_pct`: the node keeps its share (funds solver + compensation) and the winning counterparty is paid the rest + asynchronously after submitting a payout invoice. + +Nodes advertise their bond policy in the kind-38385 info event +(`bond_enabled`, `bond_apply_to`, `bond_amount_pct`, โ€ฆ) so clients can +warn users before they trade. + +--- + ### Development Fee Distribution Mostro implements a transparent development sustainability model: @@ -521,7 +584,7 @@ For better separation of secrets from config, Mostro can read the nsec from the Three common ways to provide it: 1. **`~/.mostro/.env`** (auto-loaded at startup, `chmod 600` recommended): - ``` + ```bash MOSTRO_NSEC_PRIVKEY=nsec1... ``` The interactive setup wizard can create this file for you. @@ -670,8 +733,8 @@ sqlite3 ~/.mostro/mostro.db "SELECT id, dev_fee, dev_fee_paid, dev_fee_payment_h **Query Nostr for Mostro info event**: ```bash # Install nostr tools: cargo install nostreq nostcat -# Fetch Mostro settings (kind 38383) -nostreq --kinds 38383 --limit 1 --authors YOUR_MOSTRO_PUBKEY | nostcat --stream wss://relay.damus.io | jq +# Fetch Mostro settings (kind 38385) +nostreq --kinds 38385 --limit 1 --authors YOUR_MOSTRO_PUBKEY | nostcat --stream wss://relay.damus.io | jq ``` --- @@ -1057,21 +1120,21 @@ Mostro implements a reputation system where users rate their experience with eac 1. **Setup Infrastructure**: Follow [INSTALL.md](INSTALL.md) for production deployment 2. **Configure Settings**: Set fee structure, currencies, limits in `settings.toml` -3. **Announce Your Mostro**: Publish info event (kind 38383) - done automatically on startup +3. **Announce Your Mostro**: Publish info event (kind 38385) - done automatically on startup 4. **Add to Directories**: Submit your Mostro to community listings and websites 5. **Assign Arbiters**: Add trusted npubs as dispute solvers via RPC or admin actions ### Finding Other Mostros Users can discover Mostro operators by: -- Querying Nostr for kind 38383 events (Mostro info) +- Querying Nostr for kind 38385 events (Mostro info) - Checking community-maintained directories (e.g., mostro.network) - Word-of-mouth and local Bitcoin communities **Example Query**: ```bash # Find all Mostro instances on Nostr -nostreq --kinds 38383 | nostcat --stream wss://relay.damus.io | jq +nostreq --kinds 38385 | nostcat --stream wss://relay.damus.io | jq ``` ### Operator Support diff --git a/docker-compose.cashu.yml b/docker-compose.cashu.yml index 7d6789f6..14b4b4a1 100644 --- a/docker-compose.cashu.yml +++ b/docker-compose.cashu.yml @@ -17,6 +17,9 @@ services: MINT_BACKEND_BOLT11_SAT: FakeWallet MINT_LISTEN_HOST: 0.0.0.0 MINT_LISTEN_PORT: "3338" + # Non-sensitive fixture: this is a throwaway FakeWallet test mint used + # only by the local/CI Cashu integration tests. It holds no real funds and + # must never be reused for a production mint. MINT_PRIVATE_KEY: TEST_PRIVATE_KEY healthcheck: test: diff --git a/docs/ANTI_ABUSE_BOND.md b/docs/ANTI_ABUSE_BOND.md index c548dac8..5ffa4ba2 100644 --- a/docs/ANTI_ABUSE_BOND.md +++ b/docs/ANTI_ABUSE_BOND.md @@ -186,20 +186,42 @@ slash path. |------:|----------|------------|--------| | 0 | Foundation: config schema, `bonds` table, pure helpers, types | โ€” | โœ… shipped (PR #712) | | 1 | Taker bond lifecycle: **lock + always release** (no slashing yet) | 0 | โœ… shipped (PR #719) | -| 1.5 | Protocol cleanup: dedicated `Action::PayBondInvoice` + `Status::WaitingTakerBond` (retire the Phase 1 `PayInvoice` reuse) | 1 | pending | -| 2 | Solver-directed dispute slash via `BondResolution` payload (taker bond) | 1.5 | pending | -| 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | pending | -| 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | proposed | -| 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | โœ… shipped (mostro-core 0.11.5) | -| 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | pending | -| 6 | Maker bond for **range orders** with proportional slashes | 5 | pending | -| 7 | Timeout slash for maker bond | 5 | pending | -| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | pending | +| 1.5 | Protocol cleanup: dedicated `Action::PayBondInvoice` + `Status::WaitingTakerBond` (retire the Phase 1 `PayInvoice` reuse) | 1 | โœ… shipped (PR #736) | +| 2 | Solver-directed dispute slash via `BondResolution` payload (taker bond) | 1.5 | โœ… shipped (PR #737) | +| 2.5 | Forfeiture notice on dispute slash: send `Action::BondSlashed` to each slashed party, matching the timeout path ([issue #768](https://github.com/MostroP2P/mostro/issues/768)) | 2, 6 | ๐Ÿšง in progress | +| 3 | Payout flow: `Action::AddBondInvoice` to winner, routing-fee estimation, retries | 2 | โœ… shipped (PR #738) | +| 3.5 | Payout confirmation to the winner: `BondInvoiceAccepted` (receipt) + `BondPayoutCompleted` (paid) + explicit "already paid" refusal | 3 | โœ… shipped (PR #743) | +| 4 | Timeout slash for taker bond (`slash_on_waiting_timeout`) + `Action::BondSlashed` forfeiture notice | 3 | โœ… shipped (PR #744) | +| 4.5 | Re-prompt the winner for a fresh payout invoice after `send_payment` retries exhaust, instead of stranding the bond in `Failed` ([issue #750](https://github.com/MostroP2P/mostro/issues/750)) | 3 | โœ… shipped (PR #755) | +| 5 | Maker bond (non-range): lock + dispute slash reusing Phase 2/3 | 3 | โœ… shipped (PR #767) | +| 6 | Maker bond for **range orders** with proportional slashes | 5 | โœ… shipped (PR #770) | +| 7 | Timeout slash for maker bond | 5 | โœ… shipped (PR #775) | +| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | โœ… shipped (PR #777) | Phases 4, 5, 6, 7 can partially overlap in time but must land in this order on `main` to keep review scope honest. Phase 3.5 depends only on Phase 3 and is orthogonal to the slash-path phases (4โ€“7); it can land -any time after Phase 3. +any time after Phase 3. Phase 4.5 likewise depends only on Phase 3's +payout flow (it hardens the `send_payment`-exhaustion path) and is +orthogonal to the slash-direction phases โ€” it can land any time after +Phase 3, and is numbered 4.5 only because it was reported from field +testing after Phase 4 shipped. + +**Status as of this revision.** Phases 0 through 7 (including 4.5) are +merged on `main` (PRs #712, #719, #736, #737, #738, #743, #744, #755, +#767, #770, #775), and Phase 8 is implemented (PR #777). The +`mostro-core` pin in `Cargo.toml` is **0.12.1**, which carries every +protocol variant those phases need (`Status::WaitingTakerBond`, +`Status::WaitingMakerBond`, `Action::PayBondInvoice`, +`Payload::BondResolution`, `Action::AddBondInvoice`, +`Payload::BondPayoutRequest`, `Action::BondInvoiceAccepted`, +`Action::BondPayoutCompleted`, `Action::BondSlashed`). Phases 6, 7 and 8 +are daemon-only (no protocol/schema change) โ€” Phase 8 in particular adds +no code beyond the info-event tags already shipped in Phase 3 (ยง13.1); it +is documentation polish. The feature is **complete**; the one open +follow-up is Phase 2.5 (#768), a daemon-only hardening that sends the +already-existing `Action::BondSlashed` notice on the dispute-slash path too, +so it needs no protocol or schema change. --- @@ -519,7 +541,7 @@ instead of memo parsing. --- -## 6.5. Phase 1.5 โ€” Dedicated `PayBondInvoice` + `WaitingTakerBond` +## 6.5. Phase 1.5 โ€” Dedicated `PayBondInvoice` + `WaitingTakerBond` โœ… Completed Small, protocol-only phase. Lands the dedicated `Action` and `Status` variants that Phase 1 deferred (ยง6 implementation note) so clients can @@ -804,7 +826,7 @@ never has to lean on memo parsing in the wild. --- -## 7. Phase 2 โ€” Solver-directed dispute slash +## 7. Phase 2 โ€” Solver-directed dispute slash โœ… Completed Behaviour gate: `enabled && apply_to โˆˆ { take, both }` (Phase 5 extends to maker). @@ -938,6 +960,20 @@ or a legacy admin client): When both bonds are slashed in a single dispute, this loop runs `settle_hold_invoice` **once per bond** โ€” two HTLCs claimed before the slash step returns. +5. **Notify each slashed party** with a best-effort `Action::BondSlashed` + forfeiture notice carrying the slashed amount ([issue #768](https://github.com/MostroP2P/mostro/issues/768)). + This mirrors the timeout-slash path (ยง9): a settle/cancel resolution + otherwise produces the **same** order message whether or not a bond was + slashed, leaving the loser with no protocol signal that they forfeited a + bond. `apply_bond_resolution` returns the bond rows whose slash is + **confirmed** (via the durable `slashed_reason = LostDispute` witness, or + a freshly-inserted range slice child row); the handler sends one notice + per returned row. A transient settle failure (bond left `Locked`) and an + idempotent admin retry both yield no row, so the notice is never + untruthful and a winner is never re-notified. The amount is the full bond + for a taker / non-range maker bond, or the slice's proportional amount for + a range maker bond. Like the timeout notice it is fire-and-forget โ€” a + dropped message never rolls back the slash. The recipient payout (asking the winning counterparty for a bolt11, `send_payment`, retries, forfeiture on the long-stop window) is @@ -967,6 +1003,14 @@ sats are already in Mostro's wallet. - Both flags true with both bonds present (Phase 5 onward) โ†’ both rows in `PendingPayout`. - Non-admin sending `BondResolution` โ†’ rejected before processing. +- Confirmed slash โ†’ `apply_bond_resolution` returns the slashed row(s) and + the handler sends `Action::BondSlashed` to each slashed party (#768); + `null` payload / no slash โ†’ no row, no notice. +- Transient settle failure (bond left `Locked`) โ†’ no row returned, no + notice (never untruthful); idempotent admin retry โ†’ no row, no + re-notification of the winner. +- Range maker dispute slash โ†’ the returned row is the slice child carrying + the proportional amount, not the full parent bond. ### 7.6 Acceptance @@ -974,10 +1018,13 @@ sats are already in Mostro's wallet. one, or both bonds โ€” orthogonal decisions. - Phase 1 behaviour is preserved when the solver omits the payload. - The "Alice scenario" (ยง15.1) is expressible end-to-end. +- A slashed party receives an explicit `Action::BondSlashed` notice, so a + dispute slash is as transparent as a timeout slash (#768) โ€” no longer + indistinguishable from a no-slash resolution. --- -## 8. Phase 3 โ€” Payout flow +## 8. Phase 3 โ€” Payout flow โœ… Completed Shared infrastructure used by every slash path afterwards. Non-blocking: trade finalization must never wait on the **counterparty** payout @@ -1139,6 +1186,13 @@ must land hand-in-hand with the client adoption. See ยง14.3. to `PendingPayout`, overwrites `payout_invoice`, and resets `payout_attempts` to 0 (see `add_bond_invoice_action` below). Past the claim window, `Failed` requires operator attention. + **Superseded by Phase 4.5 (ยง9.5):** as shipped in Phase 3 this + recovery only fires if the winner *spontaneously* resubmits โ€” + Mostro never re-prompts โ€” which makes the in-window recovery + unreachable in practice ([issue #750](https://github.com/MostroP2P/mostro/issues/750)). + Phase 4.5 changes the in-window exhaustion transition to discard + the stale invoice and re-prompt the winner via the scheduler + instead of going straight to `Failed`. When `slash_node_share_pct = 1.0` the counterparty leg is skipped entirely (no `AddBondInvoice` message, no `send_payment`, no forfeit @@ -1230,7 +1284,13 @@ must land hand-in-hand with the client adoption. See ยง14.3. - **User-side recovery from `Failed`.** `Failed` is *not* a hard terminal state from the recipient's perspective. A fresh `AddBondInvoice` from the same recipient resurrects the bond via a - guarded CAS: + guarded CAS. **(Phase 4.5 ยง9.5 makes this Mostro-driven.** In Phase 3 + this resurrection depends on the winner spontaneously resubmitting; + Phase 4.5 has the daemon re-prompt the winner on in-window + retry-exhaustion so the recovery no longer hinges on a client guess โ€” + see [issue #750](https://github.com/MostroP2P/mostro/issues/750). The + CAS below is retained as belt-and-braces for rows that still reach + `Failed`.) `UPDATE bonds SET state='pending-payout', payout_invoice=?, payout_attempts=0, invoice_request_attempts=0 WHERE id=? AND state='failed'`, gated in Rust by `now - @@ -1355,7 +1415,7 @@ must land hand-in-hand with the client adoption. See ยง14.3. --- -## 8.5. Phase 3.5 โ€” Payout confirmation to the winning counterparty +## 8.5. Phase 3.5 โ€” Payout confirmation to the winning counterparty โœ… Completed Small, protocol-only follow-up to Phase 3. Phase 3 drives the payout but tells the winner **nothing** once they have submitted their bolt11: the @@ -1472,7 +1532,7 @@ the client to stop prompting. --- -## 9. Phase 4 โ€” Timeout slash (taker bond) +## 9. Phase 4 โ€” Timeout slash (taker bond) โœ… Completed Gate: `enabled && slash_on_waiting_timeout && apply_to โˆˆ { take, both }`. @@ -1574,6 +1634,207 @@ slash notice uses `Action::BondSlashed` (mostro-core **0.11.5**). --- +## 9.5. Phase 4.5 โ€” Re-prompt the winner after payout-payment failure + +Small, daemon-only follow-up to Phase 3. No `mostro-core` change, no +schema change, no new slashing โ€” it closes a hole in the Phase 3 payout +state machine that makes a slashed bond's counterparty share +unrecoverable in practice. Reported as +[issue #750](https://github.com/MostroP2P/mostro/issues/750) from field +testing of the bond rollout. + +Depends only on Phase 3 (it reuses `Action::AddBondInvoice` from Phase 3 +and `Action::BondInvoiceAccepted` from Phase 3.5); orthogonal to the +slash-direction phases (4โ€“7). + +### 9.5.1 The problem + +Once a bond is slashed and the winning counterparty has submitted a +payout bolt11, Phase 3's scheduler (`run_bond_payout_cycle` โ†’ +`process_one_bond` โ†’ `pay_counterparty`) tries `send_payment` against +that invoice and, on failure, bumps `payout_attempts` +(`on_send_payment_failure` in `src/app/bond/payout.rs`). When +`payout_attempts >= payout_max_retries` (default 5) the bond transitions +to `Failed` and **only an ERROR is logged โ€” no message is sent to the +winner**. + +From that point the counterparty share is stranded: + +1. The scheduler enumerates **only `PendingPayout` bonds**. A `Failed` + bond is invisible to it, so `Action::AddBondInvoice` is never + re-sent. +2. Throughout the retry phase the row keeps its original + `payout_invoice` set, so `process_one_bond` always routes to + `pay_counterparty`. All `payout_max_retries` attempts hit the **same** + bolt11 โ€” Mostro never asks the winner for a fresh one (e.g. routed via + a different path or a node with inbound liquidity). + +Phase 3 documented a recovery path (the "`Failed` resurrection" branch in +`apply_payout_invoice`, ยง8.2): a fresh `AddBondInvoice` from the winner, +inside the claim window, flips `Failed โ†’ PendingPayout`, overwrites +`payout_invoice`, and resets `payout_attempts`. But that branch only +fires if the **client spontaneously resubmits**. Mostro never prompts the +winner and never tells them the payment failed, so the winner has no +signal to act on โ€” the resurrection path is, in practice, unreachable. +Net effect: a payout that can't be routed on the first invoice silently +stalls forever and requires manual operator intervention, contradicting +the ยง8 "non-blocking, self-healing payout" intent. + +### 9.5.2 Expected behaviour (from the issue) + +After the final `send_payment` attempt against a given invoice fails, +and **while the claim window is still open**, Mostro should: + +- **Re-request an invoice from the winner** โ€” re-send + `Action::AddBondInvoice` โ€” instead of going silent. +- **Not reset the claim-window deadline.** The forfeit deadline stays + anchored on `slashed_at` (ยง8.1 / ยง15.4); re-prompting must never push + it forward, or a winner whose node is briefly unroutable could be kept + on the hook indefinitely. +- **Stop prompting once a valid invoice is submitted** and routed + successfully (the bond reaches `Slashed`). + +### 9.5.3 Scope + +All changes are in `src/app/bond/payout.rs` and the scheduler cadence; +no `mostro-core` variant, no migration. + +- **Only a *terminal* failure abandons the invoice (double-payout + guard).** Abandoning the current invoice โ€” whether by re-arming for a + fresh one or by flipping to `Failed` โ€” clears `payout_payment_hash`, + which disables the ยง8.1 reconciliation branch in `pay_counterparty` + (the branch that looks the payment up in LND before re-sending). If the + in-flight `send_payment` for the current invoice could still settle, + abandoning it would let a freshly-prompted invoice be paid while the + original later succeeds โ€” a **double payout**. So + `on_send_payment_failure` distinguishes two failure kinds: + - **Terminal** โ€” LND reported the payment `Failed` (via the status + stream or reconciliation), or the invoice is structurally unusable. + No payment is or will be in flight, so the invoice may be abandoned. + - **Indeterminate** โ€” status-stream timeout, stream EOF, or a + `send_payment` RPC error. The payment may still be in flight. The + invoice and its `payout_payment_hash` are **kept**; the row stays in + `PendingPayout` and the next tick's reconciliation branch polls LND + to a definitive `Succeeded` / `Failed` before anything new is paid. + `payout_attempts` saturates at `payout_max_retries` so a long LND + outage cannot grow it without bound. +- **Stale invoice on *terminal* retry exhaustion is discarded, not + terminal-`Failed`.** When `payout_attempts >= payout_max_retries` + after a **terminal** failure: + - **If `now - slashed_at < payout_claim_window_days * 86_400`** (claim + window still open): instead of transitioning to `Failed`, CAS the row + *back into the invoice-request phase* โ€” clear `payout_invoice`, + `payout_routing_fee_sats`, and `payout_payment_hash` to `NULL`, reset + `payout_attempts = 0`, clear `last_invoice_request_at` (so the + re-prompt fires immediately), and **leave `state = PendingPayout`** + and `slashed_at` untouched. The guard is `WHERE id = ? AND state = + 'pending-payout'` so a row that raced to another state in the + meantime is left alone. + - **Else** (claim window already elapsed): transition to `Failed` as + today. Past the deadline there is no point re-prompting; `Failed` + remains the terminal "we held a valid invoice but could not route it, + and the window is closed โ€” operator review required" state. It stays + distinct from `Forfeited` ("the winner never submitted an invoice at + all"). +- **The scheduler re-prompts on the next tick automatically.** With + `payout_invoice` now `NULL`, ยง8.1 step 1 fires on the next + `run_bond_payout_cycle` pass: subject to the existing + `payout_invoice_window_seconds` cadence guard, it enqueues a fresh + `Action::AddBondInvoice` (carrying the **unchanged** `slashed_at` in + `Payload::BondPayoutRequest`, so the client renders the *same* forfeit + deadline as before โ€” ยง8.1), bumps `invoice_request_attempts`, and sets + `last_invoice_request_at = now`. No new code path is needed for the + re-prompt itself โ€” clearing the invoice is what re-arms step 1. The + persist-first ordering invariant from ยง8.1 step 1 still holds. +- **Bounding the loop.** Re-prompting is bounded by the **forfeit + window**, not by `payout_max_retries`: the top-of-cycle forfeit check + (ยง8.1) keeps running, and once `now - slashed_at >= claim window` with + `payout_invoice IS NULL` the row CAS-transitions to `Forfeited` and the + node retains the full `amount_sats`. So the re-prompt/retry cycle + cannot run forever; it has exactly the same long-stop as the + never-claimed case. `invoice_request_attempts` continues to count + across re-prompts (it is bounded by the forfeit window, per ยง8.1, not + by the retry budget). +- **`payout_max_retries` keeps its meaning *per invoice*.** It still + caps `send_payment` attempts against a single submitted bolt11. The + change is only what happens *after* the cap is hit inside the window: + discard that bolt11 and ask for another, rather than giving up + silently. +- **Winner-facing signalling reuses Phase 3.5.** When the winner + responds to a re-prompt with a fresh bolt11, the existing + `add_bond_invoice_action` path persists it and enqueues + `Action::BondInvoiceAccepted` (Phase 3.5 ยง8.5) exactly as for the first + invoice โ€” so the client sees "invoice received, payout in progress" + again and stops prompting locally until the next failure-driven + re-request, if any. On eventual success the winner still receives + `Action::BondPayoutCompleted`. No new action variant is required. + +### 9.5.4 Interaction with the ยง8.2 `Failed` resurrection path + +Phase 4.5 makes the in-window resurrection path the **common** path +(now Mostro-driven) rather than relying on a spontaneous client resend. +The ยง8.2 resurrection CAS (`Failed โ†’ PendingPayout` on a fresh +`AddBondInvoice` within the window) is **retained** as a belt-and-braces +recovery for any row that still reaches `Failed` โ€” e.g. a row that was +already `Failed` before this phase shipped, or one that exhausted retries +exactly as the window closed. After Phase 4.5, the expected steady-state +is that an in-window payout never silently terminates; `Failed` is only +ever observed after the claim window has elapsed. + +### 9.5.5 Tests + +- **Re-request after *terminal* exhaustion, in window.** Bond in + `PendingPayout` with a submitted-but-unroutable `payout_invoice`, day 2 + of a 15-day window. Drive **terminal** `send_payment` failures up to + `payout_max_retries`. The row stays `PendingPayout`, `payout_invoice` / + `payout_routing_fee_sats` / `payout_payment_hash` / + `last_invoice_request_at` are cleared, `payout_attempts` resets to 0, + and `slashed_at` is unchanged. On the next scheduler tick a fresh + `Action::AddBondInvoice` is enqueued to the winner and + `invoice_request_attempts` increments. +- **Indeterminate exhaustion keeps the invoice (double-payout guard).** + Same setup, but the failures are **indeterminate** (timeout / EOF / + send RPC error). After `payout_max_retries` the row stays + `PendingPayout` with `payout_invoice` **and** `payout_payment_hash` + intact (so reconciliation can poll LND), is **not** re-armed and + **not** `Failed`, and `payout_attempts` saturates at + `payout_max_retries`. Further indeterminate failures keep it pinned. +- **Deadline does not move.** Across one or more re-request cycles, the + `slashed_at` field and the `slashed_at` shipped in + `Payload::BondPayoutRequest` are identical to the original slash + anchor; the forfeit deadline the client would compute is unchanged. +- **Successful re-payment closes the claim.** After a re-prompt the + winner submits a routable bolt11 โ†’ `BondInvoiceAccepted` is enqueued, + the next tick's `send_payment` succeeds, the row reaches `Slashed`, and + `BondPayoutCompleted` is enqueued. No further `AddBondInvoice` is sent. +- **Re-request loop is forfeit-bounded.** A winner whose every submitted + invoice keeps failing is re-prompted across the window; once + `now - slashed_at >= claim window` with `payout_invoice IS NULL`, the + row CAS-transitions to `Forfeited` (not an infinite loop), node retains + `amount_sats` in full. +- **Past-window exhaustion still yields `Failed`.** A late invoice that + arrives near the deadline and exhausts `payout_max_retries` *after* + `now - slashed_at >= claim window` transitions to `Failed`, not back to + the invoice-request phase โ€” preserving the operator-review terminal. +- **`slash_node_share_pct = 1.0`.** No counterparty leg exists, so no + invoice is ever requested and this path is never reached (regression + guard). +- **`enabled = false`.** No bond payouts run; no behaviour change. + +### 9.5.6 Acceptance + +- The issue #750 failure mode is gone: a payout whose first invoice + cannot be routed no longer strands silently in `Failed`. Mostro + re-prompts the winner for a fresh invoice within the claim window. +- The forfeit deadline stays anchored on `slashed_at` across every + re-request; re-prompting cannot extend a winner's exposure. +- `Failed` becomes an out-of-window-only terminal; the in-window payout + is self-healing without operator intervention. +- Phase 3's split math and accounting are untouched โ€” this phase only + changes the retry-exhaustion transition and reuses existing messages. + +--- + ## 10. Phase 5 โ€” Maker bond (non-range) + dispute slash Gate: `enabled && apply_to โˆˆ { make, both }`. @@ -1601,6 +1862,14 @@ buyer/seller resolution operates over: - Order completed (release path) โ†’ maker bond released. - Order cancelled before take, or expires `Pending` โ†’ maker bond released. +- **Take attempt times out and the order is republished** (the taker is + the responsible party โ€” `(WaitingBuyerInvoice, sell)` / + `(WaitingPayment, buy)`) โ†’ the maker bond **stays `Locked`**. The order + returns to the book with the maker still committed; only the abandoning + taker bond is resolved (slashed under ยง9.2, else released). The maker + bond is released only when the order itself terminates. Handled in + `slash_or_release_on_timeout` via the republish-aware release routing + (`release_taker_bonds_for_order_or_warn`). - Solver dispute resolution: `BondResolution { slash_seller, slash_buyer }` resolves to the maker bond when the maker is on the named side per ยง3.1 (sell-order โ†’ `slash_seller` targets maker; buy-order โ†’ @@ -1634,6 +1903,78 @@ publication time and is not repriced. Dependent on Phase 5. This is the only genuinely subtle phase; keep the review bar high. +**Implementation notes (as shipped).** Daemon-only โ€” no `mostro-core` +change (reuses the Phase 2 dispute, Phase 3 payout, and Phase 4/5 +mechanisms) and **no new migration** (the Phase 0 `bonds` schema already +carries `parent_bond_id` / `child_order_id` / `slashed_share_sats`). + +- **Payout timing: settle-at-close ("Option A").** The parent hold invoice + stays `Locked` for the whole range life. Each maker slice slash inserts a + child row (`PendingPayout`) and accumulates `slashed_share_sats` **without + settling**. The Phase 3 payout scheduler skips any child row whose parent + is still `Locked` (`child_payout_blocked_by_locked_parent`). At range + close the parent HTLC is settled **once**, the per-child counterparty + shares are paid, and the unslashed remainder is refunded to the maker. + Mostro never fronts liquidity. (The alternative โ€” eager per-child payout โ€” + was rejected to keep the "`PendingPayout` โ‡’ sats already claimable" + invariant.) At close, the parent HTLC is settled **first** (while the bond + is still `Locked`), and only then are the `Locked โ†’ Slashed` CAS, the + maker-refund row insert, and the child claim-window re-anchor written in one + atomic SQLite transaction โ€” so `Locked` is the sole in-flight state and the + `Locked`-keyed reconciliation sweep covers every crash window (a retry + re-settles harmlessly, since LND reports "already settled"). +- **Slash share is computed in fiat, not sats.** The literal ยง11.2 formula + divides sats by sats, but the slice sats and the bond-notional sats are + quoted at *different* prices (take time vs publication time), so that + ratio drifts with the BTC price. The daemon instead uses + `share_fraction = slice.fiat_amount / root.max_amount` (both fiat โ€” the + ratio is price-invariant and equals the sats formula when the price is + stable). This needs no `parent_max_sats` column. The cumulative slashed + share is clamped to the locked bond amount as a rounding guard. +- **The maker bond lives on the range *root*.** A slash on any slice walks + `range_parent_id` to the root (`find_maker_bond_for_order`) to find the + single maker bond. The child slash row's `order_id` and maker-side + `pubkey` are the *slice's*, so the Phase 3 recipient resolver pays the + slice's winning counterparty unchanged. The maker-refund row is marked by + `parent_bond_id IS NOT NULL AND child_order_id IS NULL` and pays + `bond.pubkey` (the maker) directly (`resolve_payout_recipient`). +- **Range close is detected at every terminal hook** *except* a successful + release that spawns a remainder (the range continues then โ€” the maker + bond stays `Locked`). `resolve_range_maker_bond_at_close[_or_warn]` is + invoked from `release_action` (no child spawned), `admin_settle` / + `admin_cancel` (a dispute ends the range), the three `cancel.rs` order- + termination paths, and the scheduler's `pending_expiry`. It is idempotent + (a CAS `Locked โ†’ Slashed`) and a no-op for non-range / already-resolved + bonds. Maker-responsible **timeout** slashes for range bonds shipped in + Phase 7 (a per-slice child slash via this same path; the scheduler's + terminal cancel branch then runs the close). +- **"One slash row per slice" is enforced at the schema level.** Besides the + atomic `INSERT ... WHERE NOT EXISTS` in `record_maker_slice_slash` (which + already wins/loses the TOCTOU race correctly), a partial UNIQUE index on + `bonds(parent_bond_id, child_order_id) WHERE parent_bond_id IS NOT NULL AND + child_order_id IS NOT NULL` (migration `20260611120000`) makes the invariant + hold for any future caller (e.g. the Phase 7 maker-timeout slash) and + survive a code regression that drops the guard. SQLite treats NULLs as + distinct, so parent rows, taker bonds, and the maker-refund row + (`child_order_id NULL`) are unconstrained. The insert path treats a + constraint violation as the same idempotent no-op as `rows_affected = 0`. +- **A reconciliation sweep retries a stranded close.** Because the order's + terminal-state commit is never gated on close success (best-effort, ยง8.2), + a transient LND/DB failure in `resolve_range_maker_bond_at_close` leaves the + parent `Locked` with no further retry from the terminal hooks โ€” blocking + every slashed slice's payout until the CLTV safety net. The scheduler job + `job_reconcile_stranded_maker_bonds` (every 5 min) scans for `Locked` maker + parent bonds whose entire range tree (root + every `range_parent_id` + descendant) is in a terminal status and re-invokes the (idempotent) close + for each. A legitimately-open range โ€” whose maker bond is `Locked` by + design โ€” is never touched, since at least one descendant is non-terminal. + So a close failure **no longer relies solely on the CLTV safety net**; the + sweep is the primary recovery and CLTV is the last-resort backstop. The + range-tree terminality check walks `range_parent_id` downward with a + recursive CTE that uses `UNION` (dedup) so a corrupt cycle can't hang the + tick, and the scan isolates per-root failures (log + `continue`) so one + bad chain never blocks reconciliation of the other stranded bonds. + ### 11.1 Data model Phase 0 already shipped the columns. The maker posts **one** hold @@ -1726,7 +2067,7 @@ the maker. --- -## 12. Phase 7 โ€” Maker timeout slash +## 12. Phase 7 โ€” Maker timeout slash โœ… Completed Gate: `enabled && slash_on_waiting_timeout && apply_to โˆˆ { make, both }`. @@ -1743,9 +2084,61 @@ Phase 6 partial-slash path. Tests mirror Phase 4 from the maker side; the "no slash" rows in the ยง9.2 table become "slash maker bond". +**Implementation notes (as shipped, PR #775).** Daemon-only โ€” no +`mostro-core` change, no migration (reuses `Action::BondSlashed` from +Phase 4 and the Phase 6 child-slash schema). + +- **The gate is per responsible role.** `slash_or_release_on_timeout` + maps the ยง9.2 responsible side to maker/taker via the ยง3.1 order-kind + mapping and checks `apply_to` against *that* posting role + (`applies_to_maker()` / `applies_to_taker()`). A leftover `Locked` + maker bond under `apply_to = take` therefore still releases โ€” the + gate is about who the node's policy covers, not "any side has a + bond". +- **Range-aware bond resolution.** The responsible bond resolves + through the Phase 2/6 `resolve_slash_target` primitive: pubkey match + on the order's own bonds first, then the range-root walk for the + maker side (the maker bond of a range order lives on the root, not + the slice). +- **Non-range maker slash** settles the HTLC inline via the Phase 2 + `slash_one` primitive and confirms through the durable + `slashed_reason = Timeout` witness, exactly like the taker path. +- **Range maker slash** records a proportional child row + (`record_maker_slice_slash`, `reason = Timeout`) and leaves the + parent HTLC `Locked` โ€” settle-at-close, per Phase 6. The dispatch + reports the *child* row, so the `Action::BondSlashed` notice carries + the slice's slashed amount. `record_maker_slice_slash` now returns + whether it actually inserted; the notice fires only on a fresh + insert, so a scheduler retry (order persist failed, next tick + re-runs) never re-notifies โ€” the range parent stays `Locked` by + design, so the Phase 4 "no `Locked` bond on re-entry" guarantee + cannot provide this and the insert flag does instead. +- **Range close on the terminal cancel.** A maker-responsible timeout + cancels the order outright (no remainder is spawned on a cancel), so + the range terminates. The scheduler's cancel branch runs + `resolve_range_maker_bond_at_close_or_warn` right after the + `Canceled` status persists: the parent settles once and the + per-slice counterparty shares + maker refund distribute promptly via + Phase 3, instead of waiting for the 5-minute reconciliation sweep + (which remains the backstop on transient failure). The republish + branch never closes โ€” the order returns to the book with the maker + still committed and its bond `Locked`. +- **The timeout release loop retains a range maker parent** alongside + the existing retain-on-republish carve-out: the parent spans the + whole range and is only ever resolved at range close. + --- -## 13. Phase 8 โ€” Public exposure + docs +## 13. Phase 8 โ€” Public exposure + docs โœ… Completed + +The info-event tags (the load-bearing code change) shipped early in +Phase 3 (PR #738). This phase lands the remaining documentation polish: +the `docs/ARCHITECTURE.md` bond flow + per-action entries + ยง3.1 axes +note, the `docs/LIGHTNING_OPS.md` operator runbook, and the README +overview. The upstream `admin_settle_order.html` / +`admin_cancel_order.html` updates live in the `mostro.network` protocol +docs repo, not here; the per-release `CHANGELOG.md` is generated by the +release tooling rather than hand-edited per PR. ### 13.1 Scope @@ -1820,12 +2213,16 @@ Tests mirror Phase 4 from the maker side; the "no slash" rows in the is. - New `Status` / `Action` / `Payload` variants in `mostro-core` must ship in that crate first and be pinned to a version in this repo's - `Cargo.toml`. As of `mostro-core` **0.11.0**, the variants for - Phases 1.5 and 2 (`Status::WaitingTakerBond`, - `Action::PayBondInvoice`, `Payload::BondResolution`) are released - and ready to pin. Phase 5's `Status::WaitingMakerBond` is still - pending in `mostro-core`. Clients must handle unknown statuses - gracefully โ€” this is already the case. + `Cargo.toml`. As of `mostro-core` **0.11.5** (the current pin on + `main`), every variant for Phases 1.5 through 4 is released and + pinned: `Status::WaitingTakerBond`, `Action::PayBondInvoice`, + `Payload::BondResolution` (0.11.0), `Action::AddBondInvoice` + (0.11.2), `Payload::BondPayoutRequest` (0.11.3), + `Action::BondInvoiceAccepted` / `Action::BondPayoutCompleted` + (0.11.4), and `Action::BondSlashed` (0.11.5). Phase 4.5 needs no new + variant. Phase 5's `Status::WaitingMakerBond` is still pending in + `mostro-core`. Clients must handle unknown statuses gracefully โ€” this + is already the case. - An admin/solver client that does not yet know about `BondResolution` sends `payload: null`, which the daemon interprets as "release-by-default". No silent slashes. @@ -1846,7 +2243,8 @@ these requires a compatibility statement: the buyer-invoice `Action::AddInvoice` so the daemon can route on action type alone. - `Payload::BondPayoutRequest` variant in mostro-core (Phase 3). - **Targets `mostro-core` 0.11.3** (not yet released). Carries + **Released in `mostro-core` 0.11.3** (pinned via the 0.11.5 bump on + `main`). Carries `{ order: SmallOrder, slashed_at: i64 }` on `Action::AddBondInvoice` so the client can compute the forfeit deadline from the slash anchor instead of from message receipt time. Without this anchor a @@ -1880,6 +2278,11 @@ these requires a compatibility statement: risk. `MessageKind::verify` accepts it like the other Mostro โ†’ user notifications (id required; `BondResolution` / `BondPayoutRequest` payloads rejected). No new `CantDoReason` is needed. +- Phase 4.5 (ยง9.5). **No upstream dependency โ€” daemon-side only.** + Reuses `Action::AddBondInvoice` (Phase 3) and + `Action::BondInvoiceAccepted` (Phase 3.5); it only changes the + `send_payment`-exhaustion transition in `src/app/bond/payout.rs`. No + new variant, no `mostro-core` bump, no migration. - `Status::WaitingMakerBond` (Phase 5). Not yet shipped upstream; needs a follow-up `mostro-core` minor release before Phase 5 can land here. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c2cc3d0e..a0046108 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,7 @@ flowchart LR - Orders: `order.rs`, `take_buy.rs`, `take_sell.rs`, `cancel.rs`, `release.rs`, `add_invoice.rs`, `fiat_sent.rs`, `orders.rs`, `restore_session.rs`, `trade_pubkey.rs`, `rate_user.rs`, `dispute.rs`. - Admin: `admin_cancel.rs`, `admin_settle.rs`, `admin_add_solver.rs`, `admin_take_dispute.rs`. +- Anti-abuse bond (`app/bond/*`): `flow.rs` (lock/release lifecycle), `slash.rs` (dispute + timeout slash), `payout.rs` (counterparty payout), `db.rs`/`model.rs` (the `bonds` table), `math.rs` (amount/split math), `types.rs` (`BondRole`, `BondState`, `BondSlashReason`). Opt-in and off by default; see `docs/ANTI_ABUSE_BOND.md`. - Router: `app.rs:handle_message_action` matches `mostro_core::message::Action` and calls module functions. ### Perโ€‘Action Summaries @@ -62,7 +63,17 @@ flowchart LR - `app/orders.rs` โ€“ queries and returns order listings/history. - `app/restore_session.rs` โ€“ rehydrates context for a client after reconnect. - `app/trade_pubkey.rs` โ€“ exchanges/updates trade pubkeys for secure comms. -- Admin modules โ€“ force cancel/settle, take disputes, add solvers; guarded, auditable, and permission-gated for solver capabilities. +- Admin modules โ€“ force cancel/settle, take disputes, add solvers; guarded, auditable, and permission-gated for solver capabilities. `admin_settle`/`admin_cancel` also carry the optional `BondResolution` payload that lets a solver slash a bond independently of the trade outcome. +- `app/bond/*` โ€“ optional anti-abuse bond: a *second* Lightning hold invoice the maker and/or taker locks when entering a trade. Released on normal completion and on cancels before a waiting-state timeout; slashed only on an explicit solver `BondResolution` directive or a waiting-state timeout (gated by `slash_on_waiting_timeout`). Wired into `take_buy`/`take_sell` (lock), every cancel/release/admin path (release), and the scheduler (timeout slash + payout). Disabled by default. + +### Two axes the bond keeps separate + +The bond feature deliberately distinguishes two axes (see `docs/ANTI_ABUSE_BOND.md` ยง3.1): + +- **Maker / taker โ€” *who posted the bond, and when.*** The bond is requested at order-creation time (maker) or take time (taker). The `apply_to` setting (`take` | `make` | `both`) is a maker/taker switch; `BondRole` is a maker/taker enum. +- **Buyer / seller โ€” *whose action triggers a slash.*** All trade-flow duties (paying the hold invoice, providing the buyer invoice, sending fiat, releasing) are buyer/seller duties, so the solver's `BondResolution` carries `slash_seller` / `slash_buyer`, never `slash_maker` / `slash_taker`. + +The order kind fixes the mapping: on a `sell` order the maker is the seller and the taker is the buyer; on a `buy` order the maker is the buyer and the taker is the seller. The daemon resolves a `slash_seller`/`slash_buyer` directive to the right bond row internally. ## Configuration Constants (src/config/constants.rs) @@ -268,6 +279,47 @@ sequenceDiagram AdminCancel->>DB: mark canceled; audit trail ``` +### Flow: Anti-Abuse Bond (Lock โ†’ Resolve) + +Opt-in (`[anti_abuse_bond].enabled = true`). The bond is a second hold +invoice, separate from the trade escrow. Shown for a taker on a buy order; +the maker side (Phase 5+) is symmetric. + +```mermaid +sequenceDiagram + participant Taker as Taker (Nostr) + participant Mostro as app.rs + participant Bond as app/bond/* + participant Sched as scheduler.rs + participant DB as db.rs + participant LND as lightning/mod.rs + + Taker->>Mostro: Message(Action=TakeBuy) + Mostro->>Bond: request_taker_bond(...) + Bond->>LND: create_hold_invoice(bond amount) + Bond->>DB: insert bonds row (state=Requested) + Bond-->>Taker: Action=PayBondInvoice (bolt11) + Taker->>LND: pay bond hold invoice (HTLC held) + LND-->>Bond: InvoiceState=Accepted + Bond->>DB: state=Locked; copy taker_* onto order; resume take + alt normal completion or pre-timeout cancel + Mostro->>Bond: release_bond(...) + Bond->>LND: cancel_hold_invoice(bond hash) + Bond->>DB: state=Released + else solver directive (BondResolution) or waiting-state timeout + Mostro->>Bond: slash (admin_settle/cancel) or + Sched->>Bond: slash_or_release_on_timeout(...) + Bond->>LND: settle_hold_invoice(preimage) %% claims bond into Mostro wallet + Bond->>DB: state=PendingPayout; freeze node_share_sats + Bond-->>Taker: Action=BondSlashed (timeout path) + Sched->>Bond: job_process_bond_payouts + Bond-->>Counterparty: Action=AddBondInvoice (asks for payout bolt11) + Counterparty-->>Bond: Action=AddBondInvoice (payout bolt11) + Bond->>LND: send_payment(counterparty share) + Bond->>DB: state=Slashed (or Forfeited if claim window lapses) + end +``` + ## Lightning Operations - `create_hold_invoice(description, amount)` โ†’ returns invoice, preimage, hash. diff --git a/docs/LIGHTNING_OPS.md b/docs/LIGHTNING_OPS.md index 054977e3..3924ddf4 100644 --- a/docs/LIGHTNING_OPS.md +++ b/docs/LIGHTNING_OPS.md @@ -176,6 +176,111 @@ Retrieves LND node information including: **Usage**: Called during startup to populate `config::LN_STATUS` (src/main.rs:86) +## Anti-Abuse Bond Operations + +The optional anti-abuse bond (`[anti_abuse_bond]`, off by default) puts a +**second** hold invoice on a trade, owned by the maker and/or taker. It is +released on normal completion and on cancels before a waiting-state +timeout; it is slashed only on an explicit solver `BondResolution` +directive or a waiting-state timeout (when `slash_on_waiting_timeout = +true`). Full design: `docs/ANTI_ABUSE_BOND.md`. This section is the +operator runbook. + +### Where the state lives + +Every bond is one row in the `bonds` table (`src/app/bond/db.rs`, +`model.rs`). Inspect it directly: + +```sql +SELECT id, order_id, role, state, amount_sats, + parent_bond_id, child_order_id, slashed_share_sats, + node_share_sats, slashed_reason, + payout_attempts, invoice_request_attempts, slashed_at + FROM bonds ORDER BY created_at; +``` + +`state` (string-backed, `src/app/bond/types.rs`) walks: + +```text +requested โ†’ locked โ”€โ”ฌโ†’ released (happy / cancel before timeout) + โ””โ†’ pending-payout โ”€โ”ฌโ†’ slashed (counterparty paid their share) + โ”œโ†’ forfeited (counterparty never claimed in window) + โ””โ†’ failed (send_payment exhausted) +``` + +- **`pending-payout`** โ€” a slash already fired. The bond HTLC was + **settled** (claimed into Mostro's wallet) at slash time; the scheduler + is now driving the counterparty payout. The split is frozen here: + `node_share_sats` is the node's retained share, `amount_sats - + node_share_sats` is owed to the winning counterparty. +- **`slashed`** โ€” terminal success; the counterparty share was paid. +- **`forfeited`** โ€” designed-in long-stop: the counterparty never sent a + payout invoice within `payout_claim_window_days`. The node keeps + `amount_sats` in full. **No operator action needed.** +- **`failed`** โ€” `send_payment` exhausted `payout_max_retries` against a + delivered invoice. **User-recoverable** while inside the claim window: a + fresh `Action::AddBondInvoice` from the recipient flips the row back to + `pending-payout`. Only past the window does it need operator attention + (see below). +- `slashed_reason` is `lost-dispute` (solver directive) or `timeout` + (waiting-state timeout). A cancel before the timeout is never a slash. + +For range-order maker bonds the parent row stays `locked` while child +rows (`parent_bond_id` set, `child_order_id` = the taken slice) carry the +proportional per-slice slashes; the single settle happens at range close. + +### Scheduler jobs + +Run from `src/scheduler.rs` (see `run_jobs`): + +- `job_process_bond_payouts` โ€” drives every `pending-payout` row: requests + a payout bolt11 from the winner (`Action::AddBondInvoice`, cadenced by + `payout_invoice_window_seconds`), runs `send_payment`, retries up to + `payout_max_retries`, and reconciles against LND on entry so a daemon + restart never double-pays. +- `job_reconcile_stranded_maker_bonds` โ€” settles and distributes a range + maker bond at range close (per-slice counterparty shares + maker + refund); the 5-minute sweep is the backstop if the inline close failed. + +### Reading what happened in the logs + +Bond transitions log through `tracing` (`bond payout: โ€ฆ` lines in +`src/app/bond/payout.rs`, plus slash/release lines in `flow.rs` / +`slash.rs`). To follow a solver decision, look for the `BondResolution` +on the inbound `admin-settle` / `admin-cancel` message โ€” its wire shape is: + +```json +{ "order": { "version": 1, "id": "", "action": "admin-cancel", + "payload": { "bond_resolution": { "slash_seller": true, "slash_buyer": false } } } } +``` + +`slash_seller` / `slash_buyer` are resolved to a maker- or taker-bond row +by the order kind (sell โ†’ maker is seller; buy โ†’ maker is buyer). A +`payload: null` (or absent) means **release both bonds** โ€” no slash. A +slash directed at a side with no `locked` bond is rejected with +`CantDo(InvalidPayload)` and the trade resolution does not run. + +### Resolving a `failed` bond manually + +A `failed` row means the bond was slashed (sats are already in Mostro's +wallet), but Mostro could not route the counterparty's share and the +claim window has since elapsed, so the auto-recovery path no longer +re-arms it. There is no slash to undo and no funds at risk on the +counterparty's side โ€” the value is held by the node. To make the +counterparty whole, pay them out-of-band (the amount owed is +`amount_sats - node_share_sats`) and keep the row as the audit record. +Before the window elapses, prefer the built-in path: have the +counterparty resend their payout invoice, which flips the row back to +`pending-payout` automatically. + +### Public exposure + +The node advertises its bond policy in the kind-38385 info event +(`src/nip33.rs::info_to_tags`) so clients can warn users before they +trade: `bond_enabled` (always emitted), and when enabled `bond_apply_to`, +`bond_amount_pct`, `bond_base_amount_sats`, `bond_slash_on_waiting_timeout`, +`bond_slash_node_share_pct`, and `bond_payout_claim_window_days`. + ## Diagrams ```mermaid flowchart TD diff --git a/docs/MUTATION_TESTING.md b/docs/MUTATION_TESTING.md index 2fa9bb29..e508f106 100644 --- a/docs/MUTATION_TESTING.md +++ b/docs/MUTATION_TESTING.md @@ -110,11 +110,21 @@ test_tool_options = ["--", "--test-threads=1"] ## CI/CD Integration -Mutation testing runs in CI on every PR and on main branch: - -1. **PR workflow**: Runs mutation testing on changed files only (faster feedback) -2. **Main branch**: Runs full mutation testing weekly (baseline tracking) -3. **Release gate**: Mutation score must not decrease from previous release +Mutation testing is **not** run on every push to `main`: a full run is slow +(one test-suite run per mutant) and the job is `continue-on-error`, so running +it on every merge would only burn CI minutes without gating anything. Instead +it runs as a periodic audit plus an opt-in per-PR job: + +1. **PR workflow (opt-in)**: Add the `run-mutation` label to a PR to test + mutants in the files it changed only (faster feedback). +2. **Weekly baseline** (`schedule`): Full mutation testing every Sunday for + baseline tracking, with the HTML report published to GitHub Pages. +3. **On demand** (`workflow_dispatch`): Trigger a full run manually from the + Actions tab whenever needed. + +For pre-push feedback, run it locally instead (see [Running Locally](#running-locally)) +โ€” `cargo mutants --in-diff` against `main` is the closest equivalent to the +opt-in PR job. ### Initial Setup (Non-blocking) @@ -224,6 +234,9 @@ fn test_order_validation_rejects_invalid() { # Or run cargo-mutants directly: cargo mutants --file src/flow.rs --output mutants.out + +# Pre-push: only mutants in your diff vs main (mirrors the opt-in PR job) +cargo mutants --in-diff <(git diff origin/main...HEAD) ``` ## Performance Considerations diff --git a/docs/PRICE_PROVIDERS.md b/docs/PRICE_PROVIDERS.md index 2c230a50..da7dab53 100644 --- a/docs/PRICE_PROVIDERS.md +++ b/docs/PRICE_PROVIDERS.md @@ -471,10 +471,10 @@ only = ["CUP", "MLC"] # El Toque is only meaningful for these (ยง6.6) | Phase | PR scope | Depends on | Status | |------:|----------|------------|--------| -| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | โ€” | pending | -| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | pending | -| 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) โ†’ real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | pending | -| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | pending | +| 0 | Foundation: `PriceProvider` trait, `Quote`, aggregation core (pure), store, `[price]` config types | โ€” | done (PR #753) | +| 1 | Yadio provider + registry + scheduler wiring (single-source parity); `get_bitcoin_price` reads new store | 0 | done (PR #753) | +| 2 | Direct backup quoters (CoinGecko, currency-api, Blockchain.com) โ†’ real multi-source aggregation; per-provider health/circuit-breaker; currency normalisation + fiat allowlist + per-provider scoping | 1 | in review | +| 3 | El Toque provider (fiat-cross CUP/MLC) via PerBase anchor resolution | 2 | in review (see ยง11.3) | | 4 | Unify `get_market_quote` onto the cache; staleness TTL enforcement (`PriceTooStale`) at create/take | 2 | pending | | 5 | Nostr aggregated publishing + token/paid-provider support polish + info-event exposure + retire `bitcoin_price.rs` + ops docs | 3, 4 | pending | @@ -731,6 +731,25 @@ a BTC price source**. Therefore: ever switched to official, we would scope its CUP out too and lean on El Toque. +> **Phase 3 shipped status.** The El Toque adapter +> (`src/price/providers/eltoque.rs`) is wired with **anchor = USD only** +> (Q2 above declined for this phase). The request and response are confirmed +> against the live API: +> - **Request:** `GET {url}/v1/trmi?date_from=โ€ฆ&date_to=โ€ฆ` with +> `Authorization: Bearer `. The endpoint requires a +> `[date_from, date_to]` range (`YYYY-MM-DD HH:MM:SS`, URL-encoded) and +> returns the most recent rate within it, so `fetch` queries a rolling +> 48h window ending "now". +> - **Response:** a CUP-denominated `tasas` object +> (`{"tasas":{"USD":490.0,"MLC":200.0,"ECU":540.0,โ€ฆ}}`, where El Toque uses +> `ECU` for the euro) plus the timestamp of the returned rate +> (`date`/`hour`/`minutes`/`seconds`, which the parser ignores). The parse +> path applies the ยง11.3 cross math and is fully unit-tested against a +> captured fixture (`tests/fixtures/price/eltoque_trmi.json`). +> +> Q1/Q3 remain open. Keep `enabled = false` in production until a token is +> provisioned and the operator opts in. + ### 11.4 Blockchain.com (direct, 28 major fiats, NO CUP/MLC) - `GET https://blockchain.info/ticker` โ†’ `{ "USD": { "15m":76273, "last":76273, "buy":โ€ฆ, "sell":โ€ฆ, "symbol":"USD" }, โ€ฆ }`. diff --git a/docs/README.md b/docs/README.md index 5d2cded2..f27895a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Quick links to architecture and feature guides. - Lightning Operations: LIGHTNING_OPS.md - Orders & Actions: ORDERS_AND_ACTIONS.md - Admin RPC & Disputes: ADMIN_RPC_AND_DISPUTES.md +- Anti-Abuse Bond: ANTI_ABUSE_BOND.md (opt-in maker/taker Lightning bond; off by default) - RPC Interface Reference: RPC.md - NIP-01 Kind 0 Metadata: NIP01_KIND0_METADATA.md diff --git a/docs/TRANSPORT_V2_SPEC.md b/docs/TRANSPORT_V2_SPEC.md new file mode 100644 index 00000000..7fbca5f8 --- /dev/null +++ b/docs/TRANSPORT_V2_SPEC.md @@ -0,0 +1,264 @@ +# Transport v2 โ€” NIP-44 Direct Messaging (Protocol v2) + +**Status:** Phases 1โ€“2 implemented ยท Phases 3โ€“4 pending +**Issue:** [#626 โ€” Messaging Transport Abstraction Layer](https://github.com/MostroP2P/mostro/issues/626) +**Full proposal:** [issue comment](https://github.com/MostroP2P/mostro/issues/626#issuecomment-4694164653) +**Core implementation:** [mostro-core#152](https://github.com/MostroP2P/mostro-core/pull/152), released in mostro-core **0.13.0** (`transport` module) + +## 1. Context and motivation + +Mostro historically used NIP-59 Gift Wrap (kind `1059`) as its only wire +transport. Gift wraps give strong metadata privacy, but they are *opaque*: +the outer event is signed by a random throwaway key, so neither relays nor +the daemon can tell legitimate traffic from garbage without paying the full +decrypt cost. That makes Mostro vulnerable to a "Gift Wrap Apocalypse" โ€” +spam floods that relays cannot rate-limit by sender and that force the +daemon to attempt NIP-44 decryption on every event (see the threat model in +issue #626). + +The accepted direction (issue discussion): trade abuse-resistance for a +bounded amount of metadata. Mostro already rotates trade keys per trade โ€” +the publicly exposed key for a given trade is short-lived, single-purpose +and never reused โ€” so a *visible, rate-limitable* envelope leaks little, +while enabling: + +- relay-side rate limiting by sender pubkey, and +- daemon-side cheap pre-validation **before** decrypting (Phase 2). + +Protocol **v2** is that envelope: a signed kind-`14` event whose content is +NIP-44 encrypted. Protocol **v1** (gift wrap) is frozen and DEPRECATED. + +## 2. Wire format (protocol v2) + +### 2.1 Visible envelope + +What relays and observers see: + +```json +{ + "kind": 14, + "pubkey": "", + "content": "", + "tags": [ + ["p", ""], + ["expiration", ""] + ], + "created_at": 1234567890, + "sig": "" +} +``` + +- **Author = trade key.** The event signature proves trade-key authorship + (unlike v1, where the outer event is signed by a throwaway ephemeral key). + This is what makes the transport rate-limitable and pre-filterable. +- **`expiration` (NIP-40):** trade messages are only relevant for the + lifetime of a trade plus a dispute window, so they always carry an + expiration tag (default 30 days, `dm_days` setting) instead of sitting on + relays forever. +- **Mostro โ†’ user direction:** Mostro authors the event with its own + well-known key, `p`-tagged to the user's trade key. Clients can subscribe + with `authors=[mostro] AND #p=[trade keys]`. +- **NIP-17 deviation (deliberate):** NIP-17 defines kind 14 as an *unsigned* + rumor that only travels inside a gift wrap. Mostro publishes it *signed*, + because the author is an ephemeral single-trade key โ€” the association the + NIP-17 rule protects against is intentional and bounded. These events are + not standard NIP-17 chats. + +### 2.2 Encrypted content + +The NIP-44 conversation key is derived from (trade key โ†” counterparty), so +only the two parties can decrypt. The plaintext is a JSON 3-element tuple โ€” +v1's 2-tuple plus an identity proof: + +```json +[ + { "order": { "version": 2, "...": "..." } }, + "", + ["", ""] // or null +] +``` + +| element | meaning | +|---|---| +| 1 | the logical `Message` (unchanged from v1, but `version: 2`) | +| 2 | trade key's `Message::sign` over the serialized first element, or `null` (Mostro's own messages are unsigned, as in v1) | +| 3 | identity proof `[identity_pubkey, identity_sig]`, or `null` for **full-privacy mode** (identity = trade key, mirroring v1's unsigned-rumor convention) | + +### 2.3 Identity proof + +In v1 the long-lived identity key is carried *authenticated* by the seal +(`identity = seal.pubkey`, hidden inside the wrap). v2 has no seal, so the +identity travels **inside the ciphertext** โ€” never visible at the event +level, exactly as private as before โ€” proven by a signature over the +domain-tagged payload: + +```text +mostro-transport-v2-identity:: +``` + +Including the trade pubkey binds the proof to the *specific trade key* +authoring the event (the binding v1 gets from the seal signature covering +the encrypted rumor). Signing the message JSON alone would let any party +that sees a plaintext tuple โ€” the receiving node, or a compromised one โ€” +graft the `(identity_pubkey, identity_sig)` pair onto an event authored by +a different trade key and have the identity misattributed. The receiver +recomputes the payload from `event.pubkey`, so a grafted proof fails +verification. (Found by review on mostro-core#152; regression-tested there.) + +The signature scheme is the existing `Message::sign` / +`Message::verify_signature` (Schnorr over sha256). The identity key signs +once per message โ€” the same custody model as v1, where it signs every seal. + +## 3. Versioning + +- `Message.version` is **2** (mostro-core `PROTOCOL_VER`, since 0.13.0). +- **v1** = gift wrap + 2-tuple, frozen. **v2** = kind-14 direct + 3-tuple. +- Which parser applies is keyed off the **event kind** (`1059` vs `14`), + not the version field. mostro-core's `unwrap_incoming()` dispatches and + returns the same `UnwrappedMessage` for both, which is why daemon + handlers needed no changes. + +## 4. Operator configuration โ€” one transport per node + +There is **no dual mode**: a node speaks exactly one protocol version. + +```toml +[mostro] +# "gift-wrap" (protocol v1, DEPRECATED) | "nip44" (protocol v2) +transport = "gift-wrap" + +[expiration] +# kind-14 direct messages +dm_days = 30 +``` + +| `transport` | event kind | who can trade on this node | +|---|---|---| +| `gift-wrap` *(default in 0.18.x)* | 1059 (v1) | every current client โ€” wire behavior identical to pre-v2 daemons | +| `nip44` | 14 (v2) | v2-capable clients only โ€” the only mode from v0.19.0 | + +**Capability discovery:** the node advertises its protocol in the kind +`38385` instance-info event with a `protocol_version` tag (`"1"` or +`"2"`, derived from `transport`). Old clients ignore the unknown tag; +v2-capable clients check it and use the matching wire format โ€” a client +implementation should keep both wrap paths (mostro-core ships both) to +talk to v1 and v2 nodes during the transition. + +Switching a community to v2 is a deliberate operator decision, coordinated +with the clients that community uses. + +## 5. Release timeline + +- **v0.18.0** โ€” protocol v2 ships. Default `transport = "gift-wrap"` + (nothing changes for existing clients). **Protocol v1 is DEPRECATED**: + announced in release notes, protocol docs and the `protocol_version` + tag. Client developers have the 0.18.x cycle to ship v2. +- **v0.19.0** โ€” protocol v2 becomes the default and only protocol. + Everything v1-related is removed from mostrod (gift-wrap path, + `"gift-wrap"` setting value, v1 acceptance). mostro-core keeps its + gift-wrap helpers for clients' own migration needs. + +## 6. Implementation phases + +### Phase 0 โ€” mostro-core (DONE โ€” mostro-core#152, released 0.13.0) + +The bulk of the work, all additive, in mostro-core's `transport` module: + +- `wrap_message_nip44` / `unwrap_message_nip44` โ€” the v2 wrap/unwrap pair + (`Ok(None)` keeps its "not addressed to me" meaning). +- `unwrap_incoming` โ€” kind dispatch returning the same `UnwrappedMessage` + for both transports. +- `wrap_message_with` โ€” send-side dispatcher. +- `Transport` enum โ€” serde/`FromStr` for the config values, `event_kind()`, + `protocol_version()`. Default `GiftWrap`. +- `PROTOCOL_VER` 1 โ†’ 2; v1 fixtures kept as parse-regression tests. +- Identity proof bound to the trade key via the domain-tagged payload + (ยง2.3), with a grafting regression test. + +### Phase 1 โ€” mostrod wiring (DONE โ€” this change) + +Minimal daemon integration; **zero handler changes** by design: + +- `mostro-core` 0.12.1 โ†’ **0.13.0**. +- `[mostro] transport` setting (`Transport`, serde default = `gift-wrap`) + in `src/config/types.rs` + `settings.tpl.toml`. +- `[expiration] dm_days` knob (default 30) in `ExpirationSettings` and the + `get_expiration_timestamp_for_kind` fallback (`DM_EVENT_KIND = 14` in + `src/config/constants.rs`). +- `src/main.rs` โ€” subscription filter uses `transport.event_kind()`. +- `src/app.rs` โ€” event loop accepts only the configured kind and unwraps + via `unwrap_incoming()`. +- `src/util.rs send_dm()` โ€” wraps via `wrap_message_with(transport, โ€ฆ)`; + on the nip44 transport, fills a default NIP-40 expiration from `dm_days` + when the caller didn't pass one. +- `src/nip33.rs` โ€” `protocol_version` tag in the kind-38385 info event. + +### Phase 2 โ€” anti-spam gates (DONE โ€” this change; daemon-only, the payoff) + +The reason v2 exists: reject junk *before* paying decrypt/parse costs. All of +the following are **v2-only** โ€” the gate is skipped on the `gift-wrap` +transport, whose outer key is a throwaway with no pre-validatable signal. + +- **Active-trade-pubkey cache** (`src/spam_gate.rs`, `SpamGate`): the trade + keys that may legitimately message Mostro now โ€” buyer/seller/creator of + every non-terminal order, plus the solver of every active dispute. Built by + `db::find_active_trade_pubkeys` (terminal set = the restore-session + `EXCLUDED_ORDER_STATUSES` **minus `'dispute'`**, so disputed orders stay + active). Warmed at startup in `main.rs` and rebuilt every + `active_pubkeys_refresh_interval` seconds (default 60) by + `scheduler::job_refresh_active_pubkeys` โ€” a periodic full reload, chosen + because status mutations are scattered across handlers with no single + choke-point. Global-singleton (`OnceLock`), mirroring `PriceManager`. +- **Cheap pre-validation in the event loop** (`src/app.rs`), for kind 14, + **before** `unwrap_incoming` decrypts: check `event.pubkey` against the + cache. +- **Two lanes** โ€” the necessary nuance to "only accept known keys": brand-new + orders and takes arrive from keys Mostro has never seen. + - *Known-keys lane:* sender in the cache โ†’ fast-path; only the base `pow` + (already checked at the top of the loop) applies. + - *First-contact lane:* sender unseen โ†’ must clear `pow_first_contact` + (`[mostro]`, defaults to `pow` so existing configs are unchanged) before + the daemon decrypts. This is where spam concentrates; PoW here plus + relay-side rate limiting are the toll. +- **Dedup as defense in depth:** a `REPLAY_WINDOW_SECS` (60 s) guard drops a + re-sent identical event id before decryption. The existing 10-second + freshness window (post-decrypt, on the inner `created_at`) still applies as + the precise stale-event check. + +New config (`[mostro]`): `pow_first_contact` (`Option`, default = `pow`) +and `active_pubkeys_refresh_interval` (default 60). Both `#[serde(default)]`, +so pre-Phase-2 `settings.toml` files are wire-identical. Zero handler changes; +the gate sits entirely in the event-loop preamble. + +### Phase 3 โ€” protocol docs + client migration (PENDING) + +- Update the protocol repo (`MostroP2P/protocol`): `overview.md` ("The + Message": both transports, the v2 tuple, `version: 2`), + `key_management.md` (v2 examples mirroring the existing unencrypted + gift-wrap walkthroughs), migration guide for client developers. +- mostro-cli / client support via the same mostro-core 0.13.0 APIs: + clients keep both wrap paths and pick per node from `protocol_version`. + +### Phase 4 โ€” the v0.19.0 cutover (PENDING) + +- Default `transport = "nip44"`; remove the v1 path from mostrod entirely + (per ยง5). Metrics: `messages_received_total`, decrypt failures as a spam + indicator. + +## 7. Security notes + +- **Identity privacy is unchanged from v1:** the identity pubkey only ever + exists inside NIP-44 ciphertext readable by the two parties. What v2 + newly exposes is *activity* of an ephemeral trade key (who talks to + Mostro, when, how much) โ€” accepted, bounded by per-trade key rotation. +- **Identity proof grafting** is prevented by the trade-pubkey binding + (ยง2.3). The trade signature (element 2) needs no domain tag because it is + verified against `event.pubkey` โ€” a foreign trade_sig under a different + author fails by construction. +- **Event signature is load-bearing in v2** (it proves the visible sender): + `unwrap_message_nip44` verifies it and hard-errors, unlike v1 where the + outer signature is from a throwaway key and the seal carries the trust. +- The daemon's existing checks (PoW, 10-second freshness window, trade + index, `identity != sender && signature.is_none()` bail-out) apply + unchanged to both transports because both yield the same + `UnwrappedMessage`. diff --git a/migrations/20260530120000_cashu_escrow_fields.sql b/migrations/20260530120000_cashu_escrow_fields.sql index 0eb20383..0ff148b9 100644 --- a/migrations/20260530120000_cashu_escrow_fields.sql +++ b/migrations/20260530120000_cashu_escrow_fields.sql @@ -1,4 +1,4 @@ --- mostro-core 0.12.0 adds three Cashu escrow fields to the `Order` model. +-- mostro-core 0.12.1 adds three Cashu escrow fields to the `Order` model. -- These columns back the Cashu 2-of-3 multisig escrow mode (see the escrow -- architecture spec). They are `NULL` for Lightning orders. -- diff --git a/migrations/20260611120000_bond_slice_slash_unique.sql b/migrations/20260611120000_bond_slice_slash_unique.sql new file mode 100644 index 00000000..41b99c9e --- /dev/null +++ b/migrations/20260611120000_bond_slice_slash_unique.sql @@ -0,0 +1,18 @@ +-- Phase 6 hardening: enforce the "one slash row per slice" invariant at the +-- schema level, not just in `record_maker_slice_slash`'s atomic +-- `INSERT ... WHERE NOT EXISTS`. +-- +-- The application insert already wins/loses the TOCTOU race correctly (the +-- loser sees `rows_affected = 0`), but a unique index makes the invariant +-- hold for ANY future caller (e.g. the Phase 7 maker-timeout slash) and +-- survives a code regression that drops the existence check. +-- +-- Partial so it constrains ONLY child slash rows: SQLite treats NULLs as +-- distinct, so a plain unique index on (parent_bond_id, child_order_id) would +-- already exempt parent rows (both NULL), taker bonds (both NULL), and +-- maker-refund rows (child_order_id NULL). The explicit `WHERE โ€ฆ IS NOT NULL` +-- predicate keeps the index small (only the child rows it governs) and makes +-- the intent unmistakable. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bonds_parent_child_unique + ON bonds (parent_bond_id, child_order_id) + WHERE parent_bond_id IS NOT NULL AND child_order_id IS NOT NULL; diff --git a/settings.tpl.toml b/settings.tpl.toml index bc910573..d93f144c 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -52,9 +52,40 @@ user_rates_sent_interval_seconds = 3600 publish_relays_interval = 60 # Requested POW pow = 0 +# Wire transport for protocol messages. A node speaks exactly one: +# "gift-wrap" - protocol v1, NIP-59 gift wraps (kind 1059). DEPRECATED, +# will be removed in v0.19.0. +# "nip44" - protocol v2, signed kind-14 events with NIP-44 encrypted +# content. Rate-limitable by relays; switch once the clients +# your community uses support protocol v2. +# See docs/TRANSPORT_V2_SPEC.md +transport = "gift-wrap" +# Anti-spam gate for the "nip44" transport (docs/TRANSPORT_V2_SPEC.md ยง6 +# Phase 2). Proof-of-work (leading-zero bits) demanded of a *first-contact* +# event โ€” one whose visible sender (trade key) is not part of an active +# order/dispute โ€” checked BEFORE decryption. Ongoing trades (known keys) need +# only `pow`; brand-new orders/takes from unseen keys must clear this stiffer +# toll. Omit (or leave commented) to fall back to `pow`. No effect on +# "gift-wrap". Example: keep pow = 0 for cheap ongoing trades but require work +# from first-contact senders. +# pow_first_contact = 16 +# How often (seconds) to rebuild the active-trade-pubkey cache the gate +# consults. Lower = a just-taken order's keys fast-path sooner; higher = less +# DB load. Default 60. +# active_pubkeys_refresh_interval = 60 # Publish mostro info interval publish_mostro_info_interval = 300 -# Bitcoin price API base URL +# Bitcoin price API base URL. +# DEPRECATED: prefer `[price.providers.yadio].url` in the new multi-source +# `[price]` block below (see docs/PRICE_PROVIDERS.md ยง10.1). Lifecycle: +# (a) Now OPTIONAL โ€” once you configure a `[price]` block you may delete this +# key. When it is absent AND `[price]` is also absent, the default +# "https://api.yadio.io" is used for the legacy single-source synthesis. +# (b) Still read by the live market-quote path (/convert) until Phase 4. To +# avoid the live and cached paths hitting different Yadio URLs, the live +# path prefers `[price.providers.yadio].url` when a `[price]` block is +# present and only falls back to this key otherwise. +# (c) Fully removed in Phase 5. bitcoin_price_api_url = "https://api.yadio.io" # Fiat currencies accepted for orders - leave empty [] to accept all fiat currencies fiat_currencies_accepted = ['USD', 'EUR', 'ARS', 'CUP'] @@ -77,6 +108,8 @@ rating_days = 90 dispute_days = 90 # Fee audit events (kind 8383) - annual transparency fee_audit_days = 365 +# Protocol-v2 direct messages (kind 14) - trade lifetime plus dispute window +dm_days = 30 [rpc] # Enable RPC server for direct admin communication @@ -88,6 +121,64 @@ port = 50051 # Duration in seconds after which inactive rate-limiter entries are evicted # rate_limiter_stale_duration = 3600 +# Multi-source price providers (see docs/PRICE_PROVIDERS.md). +# Absent section โ‰ก legacy single-source behaviour synthesised from +# `[mostro].bitcoin_price_api_url` + `exchange_rates_update_interval_seconds` +# + `publish_exchange_rates_to_nostr`. Uncomment the block to take control. +# +# [price] +# # Poll cadence and freshness budget. +# update_interval_seconds = 300 +# max_price_staleness_seconds = 1800 +# # Discard a source whose value deviates more than this % from the median +# # (only applies with >= 3 sources for a currency). +# outlier_threshold_pct = 5.0 +# # Per-provider request timeout and circuit-breaker (cooldown caps at 30 min). +# provider_timeout_seconds = 10 +# provider_failure_threshold = 3 +# provider_failure_cooldown_seconds = 120 +# # Publish the aggregated rates to Nostr (kind 30078). Replaces the legacy +# # `publish_exchange_rates_to_nostr`. +# publish_to_nostr = true +# +# [price.providers.yadio] +# enabled = true +# url = "https://api.yadio.io" +# +# [price.providers.coingecko] +# enabled = true +# url = "https://api.coingecko.com/api/v3" +# # api_key = "CG-xxxx" # optional demo/pro key; raises rate limits. Pro +# # plans use url = "https://pro-api.coingecko.com/api/v3" (the adapter picks +# # the matching auth header from the host). +# +# # Keyless, CDN-hosted, 300+ currencies incl. CUP at the OFFICIAL rate โ€” so +# # CUP/MLC are excluded to avoid mixing official with the informal-market +# # sources (docs/PRICE_PROVIDERS.md ยง6.6). +# [price.providers.currency_api] +# enabled = true +# url = "https://currency-api.pages.dev/v1" +# # Ordered mirrors tried in sequence when `url` fails this tick. +# fallback_urls = ["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"] +# except = ["CUP", "MLC"] +# +# # Keyless, ~28 major fiats, no CUP/MLC. Mid-market (`last`) only. +# [price.providers.blockchain] +# enabled = true +# url = "https://blockchain.info" +# +# # El Toque โ€” informal-market CUP/MLC (fiat-cross, resolved against the +# # aggregated USD/BTC anchor; ยง6.3, ยง11.3). Opt-in: requires a free Bearer +# # token. Scoped to CUP/MLC only โ€” that is all this source contributes. +# # NOTE: the request wiring is PROVISIONAL pending a confirmed payload from +# # the token-gated API โ€” keep `enabled = false` in production until then +# # (see docs/PRICE_PROVIDERS.md ยง11.3). +# [price.providers.eltoque] +# enabled = false +# url = "https://tasas.eltoque.com" +# # token = "xxxx" # REQUIRED when enabled; provider refuses to start otherwise +# only = ["CUP", "MLC"] + # Anti-abuse bond (issue #711). Opt-in, disabled by default. Uncomment to # require a Lightning hold-invoice bond from takers and/or makers. See # docs/ANTI_ABUSE_BOND.md for the full phased rollout. diff --git a/sqlx-data.json b/sqlx-data.json index 00e1a01d..5ff35e0d 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -60,4 +60,4 @@ }, "query": "\n UPDATE orders\n SET\n cashu_mint_url = ?1,\n cashu_escrow_token = ?2,\n cashu_escrow_locked_at = ?3,\n status = ?4\n WHERE id = ?5 AND status = ?6 AND cashu_escrow_locked_at IS NULL\n " } -} \ No newline at end of file +} diff --git a/src/app.rs b/src/app.rs index 1efa4bb5..8e4619ec 100644 --- a/src/app.rs +++ b/src/app.rs @@ -56,7 +56,8 @@ use mostro_core::error::CantDoReason; use mostro_core::error::MostroError; use mostro_core::error::ServiceError; use mostro_core::message::{Action, Message}; -use mostro_core::nip59::{unwrap_message, UnwrappedMessage}; +use mostro_core::nip59::UnwrappedMessage; +use mostro_core::transport::unwrap_incoming; use mostro_core::user::User; use nostr_sdk::prelude::*; @@ -300,6 +301,17 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { let my_keys = ctx.keys(); let client = ctx.nostr_client(); let pow = ctx.settings().mostro.pow; + // The node speaks exactly one transport (protocol v1 gift wrap or v2 + // NIP-44 direct); events of any other kind are dropped before any + // decryption work. See docs/TRANSPORT_V2_SPEC.md. + let accepted_kind = ctx.settings().mostro.transport.event_kind(); + // Phase 2 anti-spam gate (docs/TRANSPORT_V2_SPEC.md ยง6): on the v2 (kind + // 14) transport the visible author is the trade key, so the daemon can + // pre-validate before decrypting. Unknown (first-contact) senders must + // clear `pow_first_contact`; known active-trade keys need only `pow`. The + // gate is meaningless for v1 (gift wraps are signed by throwaway keys). + let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact(); + let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND; loop { let mut notifications = client.notifications(); @@ -312,22 +324,56 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> { tracing::info!("Not POW verified event!"); continue; } - if let Kind::GiftWrap = event.kind { + if event.kind == accepted_kind { + // Phase 2 anti-spam gate (protocol v2 / kind 14 only): + // cheap pre-validation BEFORE paying the NIP-44 decrypt + // cost. v1 gift wraps skip this โ€” their outer key is a + // throwaway with no pre-validatable signal. + if is_v2 { + if let Some(gate) = crate::spam_gate::SpamGate::global() { + let now = chrono::Utc::now().timestamp(); + // Dedup: drop a re-sent identical event (defense in + // depth against replay floods). + if gate.is_replay(event.id, now) { + tracing::debug!("Dropping replayed event {}", event.id); + continue; + } + // Two lanes: a sender already in an active trade is + // fast-pathed (only the base `pow` already checked + // above applies); an unseen first-contact sender + // must clear the stiffer `pow_first_contact` before + // we decrypt. New orders/takes legitimately arrive + // here โ€” so does spam, hence the PoW toll. + if !gate.is_known(&event.pubkey.to_string()) + && !event.check_pow(pow_first_contact) + { + tracing::info!( + "Dropping first-contact kind-14 event from unknown key {} below pow_first_contact ({} bits)", + event.pubkey, + pow_first_contact + ); + continue; + } + } + } + // Validate event signature if event.verify().is_err() { tracing::warn!("Error in event verification") }; - // Mostro-core's NIP-59 transport handles the dual-key layout - // (identity key signs seal, trade key authors rumor) plus inner - // tuple (message, signature) decoding and signature verification - // in one shot. - let unwrapped = match unwrap_message(&event, my_keys).await { + // Mostro-core dispatches on the event kind: the gift wrap + // path handles the dual-key layout (identity key signs + // seal, trade key authors rumor), the kind-14 path the + // 3-element tuple with its in-ciphertext identity proof. + // Both decode and verify signatures in one shot and yield + // the same transport-agnostic `UnwrappedMessage`. + let unwrapped = match unwrap_incoming(&event, my_keys).await { Ok(Some(u)) => u, - // Outer NIP-44 decrypt failed: not addressed to this node. + // NIP-44 decrypt failed: not addressed to this node. Ok(None) => continue, Err(e) => { - tracing::warn!("Error unwrapping NIP-59 message: {}", e); + tracing::warn!("Error unwrapping incoming message: {}", e); continue; } }; @@ -419,11 +465,11 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> { tracing::warn!("Error in event verification") }; - let unwrapped = match unwrap_message(&event, my_keys).await { + let unwrapped = match unwrap_incoming(&event, my_keys).await { Ok(Some(u)) => u, Ok(None) => continue, Err(e) => { - tracing::warn!("Error unwrapping NIP-59 message: {}", e); + tracing::warn!("Error unwrapping incoming message: {}", e); continue; } }; diff --git a/src/app/admin_cancel.rs b/src/app/admin_cancel.rs index b652de9b..6dbb9f4a 100644 --- a/src/app/admin_cancel.rs +++ b/src/app/admin_cancel.rs @@ -223,7 +223,11 @@ pub async fn admin_cancel_action( // sides to bonds. Slashed bonds have their hold invoices settled // immediately; the recipient payout to the winning counterparty // is still Phase 3's job. - if let Err(e) = bond::apply_bond_resolution( + // #768: notify each slashed party with a best-effort `BondSlashed` + // forfeiture notice, mirroring the timeout-slash path. Only confirmed + // slashes are returned, so a dropped settle never produces an untruthful + // notice and an idempotent retry never re-notifies. + match bond::apply_bond_resolution( pool, ln_client, &order, @@ -232,9 +236,28 @@ pub async fn admin_cancel_action( ) .await { + Ok(slashed_rows) => { + for slashed in &slashed_rows { + bond::notify_bond_slashed(&order, slashed).await; + } + } + Err(e) => { + tracing::warn!( + order_id = %order.id, + "admin_cancel: bond resolution apply failed: {}", e + ); + } + } + + // Phase 6: a dispute resolution ends the range (no remainder is + // republished), so resolve the maker bond at close โ€” settle the parent + // HTLC once and refund the unslashed remainder if any slice was slashed, + // otherwise release. A no-op for non-range maker bonds and for orders + // with no maker bond. + if let Err(e) = bond::resolve_range_maker_bond_at_close(pool, ln_client, &order).await { tracing::warn!( order_id = %order.id, - "admin_cancel: bond resolution apply failed: {}", e + "admin_cancel: maker bond close failed: {}", e ); } diff --git a/src/app/admin_settle.rs b/src/app/admin_settle.rs index 49d5ccdc..f95065aa 100644 --- a/src/app/admin_settle.rs +++ b/src/app/admin_settle.rs @@ -214,7 +214,11 @@ pub async fn admin_settle_action( // payout (asking the winning counterparty for a bolt11, // `send_payment`, retries, forfeiture on the long-stop window) is // still Phase 3's job. - if let Err(e) = bond::apply_bond_resolution( + // #768: notify each slashed party with a best-effort `BondSlashed` + // forfeiture notice, mirroring the timeout-slash path. Only confirmed + // slashes are returned, so a dropped settle never produces an untruthful + // notice and an idempotent retry never re-notifies. + match bond::apply_bond_resolution( pool, ln_client, &order_updated, @@ -223,9 +227,28 @@ pub async fn admin_settle_action( ) .await { + Ok(slashed_rows) => { + for slashed in &slashed_rows { + bond::notify_bond_slashed(&order_updated, slashed).await; + } + } + Err(e) => { + tracing::warn!( + order_id = %order_updated.id, + "admin_settle: bond resolution apply failed: {}", e + ); + } + } + + // Phase 6: a dispute resolution ends the range (no remainder is + // republished), so resolve the maker bond at close โ€” settle the parent + // HTLC once and refund the unslashed remainder if any slice was slashed, + // otherwise release. A no-op for non-range maker bonds (already handled + // inline by `apply_bond_resolution`) and for orders with no maker bond. + if let Err(e) = bond::resolve_range_maker_bond_at_close(pool, ln_client, &order_updated).await { tracing::warn!( order_id = %order_updated.id, - "admin_settle: bond resolution apply failed: {}", e + "admin_settle: maker bond close failed: {}", e ); } diff --git a/src/app/admin_take_dispute.rs b/src/app/admin_take_dispute.rs index 16803ce8..50e0b0f4 100644 --- a/src/app/admin_take_dispute.rs +++ b/src/app/admin_take_dispute.rs @@ -180,11 +180,11 @@ pub async fn admin_take_dispute_action( // Get order from db using the dispute order id let order = if let Some(order) = Order::by_id(pool, dispute.order_id) .await - .map_err(|_| MostroInternalErr(ServiceError::InvalidOrderId))? + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? { order } else { - return Err(MostroInternalErr(ServiceError::InvalidOrderId)); + return Err(MostroCantDo(CantDoReason::NotFound)); }; // Update dispute fields diff --git a/src/app/bond/db.rs b/src/app/bond/db.rs index d7d356fa..e197cd5f 100644 --- a/src/app/bond/db.rs +++ b/src/app/bond/db.rs @@ -3,7 +3,8 @@ //! Phase 0 exposes the CRUD surface later phases will need. Nothing in //! this module hits LND or the Nostr client โ€” it's purely storage. -use mostro_core::error::{MostroError::MostroInternalErr, ServiceError}; +use mostro_core::error::{MostroError, MostroError::MostroInternalErr, ServiceError}; +use mostro_core::order::{Order, Status}; use sqlx::{Pool, Sqlite}; use sqlx_crud::Crud; use uuid::Uuid; @@ -11,6 +12,12 @@ use uuid::Uuid; use super::model::Bond; use super::types::{BondRole, BondState}; +/// Defensive upper bound on a `range_parent_id` walk. The real chain +/// length is bounded by how many slices a range can be split into +/// (`max_amount / min_amount`), always small; this cap exists only so a +/// corrupt cycle in the DB can never hang the daemon. +const MAX_RANGE_CHAIN_DEPTH: usize = 1024; + /// Insert a new bond row. Returns the persisted `Bond`. pub async fn create_bond( pool: &Pool, @@ -61,6 +68,20 @@ pub async fn find_bonds_by_state( .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) } +/// Look up a bond row by its primary key. Used by the Phase 3 payout +/// scheduler to check a child slash row's parent state (skip while the +/// parent HTLC is still `Locked`, i.e. before range close). +pub async fn find_bond_by_id( + pool: &Pool, + id: Uuid, +) -> Result, mostro_core::error::MostroError> { + sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ? LIMIT 1") + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + /// Look up a bond row by its Lightning payment hash. The hash uniquely /// identifies the bond hold invoice, so this is what the LND subscriber /// uses to correlate incoming invoice events back to a `Bond`. @@ -148,6 +169,149 @@ pub async fn find_active_bond_by_taker( .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) } +/// Walk the `range_parent_id` chain from `order` up to the range root โ€” +/// the order that owns the maker bond (Phase 6). +/// +/// `range_parent_id` is a linked list to the *immediate* parent slice, not +/// a star to the root (see `create_base_order` in `app::release`), so the +/// maker bond lives on whichever order has `range_parent_id IS NULL`. A +/// non-range order or an already-root order returns itself. The walk is +/// bounded by [`MAX_RANGE_CHAIN_DEPTH`]; a missing parent row (should never +/// happen) terminates the walk at the deepest order we could load. +pub async fn find_range_root_order( + pool: &Pool, + order: Order, +) -> Result { + let mut current = order; + for _ in 0..MAX_RANGE_CHAIN_DEPTH { + let Some(parent_id) = current.range_parent_id else { + return Ok(current); + }; + match Order::by_id(pool, parent_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? + { + Some(parent) => current = parent, + None => return Ok(current), + } + } + Err(MostroInternalErr(ServiceError::UnexpectedError( + "range_parent_id chain exceeded max depth (possible cycle)".to_string(), + ))) +} + +/// Find the parent **maker** bond governing `order`, walking the range +/// chain to its root first. +/// +/// A maker slash (or release) can land on any slice in a range chain, but +/// there is only ever one maker bond and it lives on the root order. This +/// resolves that single bond from any slice. Returns `None` when no maker +/// bond exists (feature off, `apply_to` excludes the maker, or the bond +/// was already released). +pub async fn find_maker_bond_for_order( + pool: &Pool, + order: &Order, +) -> Result, MostroError> { + let root = find_range_root_order(pool, order.clone()).await?; + find_bond_by_order_and_role(pool, root.id, BondRole::Maker).await +} + +/// Every child slash row that belongs to `parent_bond_id` (Phase 6 +/// range-order accounting). Ordered oldest-first for deterministic +/// iteration at parent-close. +pub async fn find_child_slashes_for_parent( + pool: &Pool, + parent_bond_id: Uuid, +) -> Result, MostroError> { + sqlx::query_as::<_, Bond>( + "SELECT * FROM bonds WHERE parent_bond_id = ? ORDER BY created_at ASC", + ) + .bind(parent_bond_id) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Order statuses from which a range slice can never re-activate. Once +/// every order in a range tree has reached one of these, the maker bond is +/// safe to close โ€” no descendant can still draw against it. Kept as the +/// complement of the in-flight states (`Pending`, `Active`, the `Waiting*` +/// states, `FiatSent`, `SettledHoldInvoice`, `Dispute`, `InProgress`) so a +/// new in-flight status defaults to "not terminal" (conservative: the sweep +/// won't prematurely close a bond it doesn't understand). +const TERMINAL_ORDER_STATUSES: [Status; 7] = [ + Status::Success, + Status::Canceled, + Status::CanceledByAdmin, + Status::SettledByAdmin, + Status::CompletedByAdmin, + Status::CooperativelyCanceled, + Status::Expired, +]; + +/// Every `Locked` **parent** maker bond (`role = 'maker'`, +/// `parent_bond_id IS NULL`). The reconciliation sweep +/// (`reconcile_stranded_range_maker_bonds`) starts from this set and then +/// filters to the ones whose whole range tree has terminated. Child slash +/// rows and refund rows (both `parent_bond_id IS NOT NULL`) are excluded. +pub async fn find_locked_maker_parent_bonds(pool: &Pool) -> Result, MostroError> { + let locked = BondState::Locked.to_string(); + let maker = BondRole::Maker.to_string(); + sqlx::query_as::<_, Bond>( + "SELECT * FROM bonds \ + WHERE state = ? AND role = ? AND parent_bond_id IS NULL \ + ORDER BY created_at ASC", + ) + .bind(locked) + .bind(maker) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Is the whole range tree rooted at `root_id` fully terminal โ€” i.e. does no +/// order reachable from the root via `range_parent_id` sit in a non-terminal +/// status? +/// +/// Walks the `range_parent_id` linked list **downwards** with a recursive +/// CTE (the inverse of [`find_range_root_order`], which walks up). Returns +/// `true` only when every order in the tree โ€” the root included โ€” is in a +/// [`TERMINAL_ORDER_STATUSES`] state, so the maker bond can be safely closed. +/// A non-range or already-root order tree is just the single root row. +pub async fn range_tree_fully_terminal( + pool: &Pool, + root_id: Uuid, +) -> Result { + let placeholders = TERMINAL_ORDER_STATUSES + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + // `UNION` (not `UNION ALL`) is deliberate: it deduplicates, so a corrupt + // `range_parent_id` cycle (e.g. Aโ†”B) terminates the recursion instead of + // looping unbounded and hanging the scheduler tick. This mirrors the + // `MAX_RANGE_CHAIN_DEPTH` guard on the upward walk in + // `find_range_root_order`. Dedup is safe here because the result is only + // reduced to `COUNT(*) โ€ฆ == 0` below โ€” the exact count is never used. + let sql = format!( + "WITH RECURSIVE tree(id, status) AS ( \ + SELECT id, status FROM orders WHERE id = ? \ + UNION \ + SELECT o.id, o.status FROM orders o JOIN tree t ON o.range_parent_id = t.id \ + ) \ + SELECT COUNT(*) FROM tree WHERE status NOT IN ({placeholders})" + ); + let mut query = sqlx::query_scalar::<_, i64>(&sql).bind(root_id); + for status in TERMINAL_ORDER_STATUSES { + query = query.bind(status.to_string()); + } + let non_terminal = query + .fetch_one(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + Ok(non_terminal == 0) +} + /// Update a bond row by primary key. Returns the persisted `Bond`. pub async fn update_bond( pool: &Pool, @@ -190,6 +354,12 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); + sqlx::query(include_str!( + "../../../migrations/20260611120000_bond_slice_slash_unique.sql" + )) + .execute(&pool) + .await + .expect("bond_slice_slash_unique migration"); // SQLite doesn't enforce FKs unless asked. Turn them on so the FK to // `orders` is a real constraint in tests (mirrors production). sqlx::query("PRAGMA foreign_keys = ON") @@ -213,10 +383,133 @@ mod tests { .expect("insert parent order"); } + /// Insert an order with an explicit status and optional `range_parent_id`, + /// so the range-tree-terminal walk can be exercised over a real chain. + async fn insert_order_with( + pool: &Pool, + id: Uuid, + status: Status, + range_parent_id: Option, + ) { + sqlx::query( + r#"INSERT INTO orders ( + id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, range_parent_id, created_at, expires_at + ) VALUES (?, 'sell', ?, ?, 0, 'ln', 1000, 'USD', 10, ?, 0, 0)"#, + ) + .bind(id) + .bind(id.simple().to_string()) + .bind(status.to_string()) + .bind(range_parent_id) + .execute(pool) + .await + .expect("insert order with status"); + } + fn dummy_bond(order_id: Uuid, role: BondRole) -> Bond { Bond::new_requested(order_id, "a".repeat(64), role, 1_500) } + #[tokio::test] + async fn range_tree_terminal_walks_descendants() { + // root <- child (range_parent_id chain). The tree is "fully terminal" + // only when BOTH the root and every descendant are terminal. + let pool = setup_pool().await; + let root = Uuid::new_v4(); + let child = Uuid::new_v4(); + insert_order_with(&pool, root, Status::CooperativelyCanceled, None).await; + insert_order_with(&pool, child, Status::Active, Some(root)).await; + + // A still-Active descendant keeps the tree non-terminal. + assert!( + !range_tree_fully_terminal(&pool, root).await.unwrap(), + "an Active descendant must make the tree non-terminal" + ); + + // Terminate the descendant โ†’ the whole tree is terminal. + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::Expired.to_string()) + .bind(child) + .execute(&pool) + .await + .unwrap(); + assert!( + range_tree_fully_terminal(&pool, root).await.unwrap(), + "root + descendant both terminal โ†’ tree terminal" + ); + + // A non-terminal root alone also blocks (single-row tree). + let lone = Uuid::new_v4(); + insert_order_with(&pool, lone, Status::Pending, None).await; + assert!(!range_tree_fully_terminal(&pool, lone).await.unwrap()); + } + + #[tokio::test] + async fn range_tree_terminal_is_cycle_safe() { + // A corrupt `range_parent_id` cycle (Aโ†”B) must NOT hang the query: + // the CTE uses `UNION` (dedup), so the walk terminates. This test + // completing at all proves there is no unbounded loop. + let pool = setup_pool().await; + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + // Insert both first (no parent), then point them at each other. + insert_order_with(&pool, a, Status::Active, None).await; + insert_order_with(&pool, b, Status::Active, None).await; + sqlx::query("UPDATE orders SET range_parent_id = ? WHERE id = ?") + .bind(b) + .bind(a) + .execute(&pool) + .await + .unwrap(); + sqlx::query("UPDATE orders SET range_parent_id = ? WHERE id = ?") + .bind(a) + .bind(b) + .execute(&pool) + .await + .unwrap(); + + // Returns promptly without error; both nodes Active โ†’ non-terminal. + assert!(!range_tree_fully_terminal(&pool, a).await.unwrap()); + + // Terminate both โ†’ the cyclic tree reads as terminal (still no hang). + sqlx::query("UPDATE orders SET status = ?") + .bind(Status::Expired.to_string()) + .execute(&pool) + .await + .unwrap(); + assert!(range_tree_fully_terminal(&pool, a).await.unwrap()); + } + + #[tokio::test] + async fn locked_maker_parent_bonds_excludes_children() { + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + let child_order_id = Uuid::new_v4(); + insert_parent_order(&pool, order_id).await; + insert_parent_order(&pool, child_order_id).await; + + // A Locked maker parent. + let mut parent = dummy_bond(order_id, BondRole::Maker); + parent.state = BondState::Locked.to_string(); + let parent = create_bond(&pool, parent).await.unwrap(); + + // A child slash row (PendingPayout) โ€” must be excluded. + let mut child = dummy_bond(order_id, BondRole::Maker); + child.parent_bond_id = Some(parent.id); + child.child_order_id = Some(child_order_id); + child.state = BondState::PendingPayout.to_string(); + create_bond(&pool, child).await.unwrap(); + + // A Locked taker bond โ€” wrong role, excluded. + let mut taker = dummy_bond(child_order_id, BondRole::Taker); + taker.state = BondState::Locked.to_string(); + create_bond(&pool, taker).await.unwrap(); + + let locked = find_locked_maker_parent_bonds(&pool).await.unwrap(); + assert_eq!(locked.len(), 1); + assert_eq!(locked[0].id, parent.id); + } + #[tokio::test] async fn insert_and_fetch_by_order_and_role() { let pool = setup_pool().await; diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index 6d3b01ad..b24305e4 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -75,6 +75,37 @@ pub fn taker_bond_required() -> bool { .is_some_and(|cfg| cfg.apply_to.applies_to_taker()) } +/// True when the configuration requires the **maker** to post a bond. +/// +/// Phase 5/6 gate, symmetric to [`taker_bond_required`]. `publish_order` +/// asks this question before publishing a new order to Nostr: when it is +/// true the order is parked at [`Status::WaitingMakerBond`] and no NIP-33 +/// event is emitted until the maker locks the bond. Both fixed-amount +/// (Phase 5) and range (Phase 6) makers take this path; range makers size +/// the bond against `max_amount` and slash proportionally per slice. +pub fn maker_bond_required() -> bool { + Settings::get_bond() + .filter(|cfg| cfg.enabled) + .is_some_and(|cfg| cfg.apply_to.applies_to_maker()) +} + +/// True when a `Locked` **taker** bond already exists among `bonds` โ€” the +/// signal that the order's trade is committed and no further take may +/// begin (it must be rejected with `PendingOrderExists`). +/// +/// The role scoping is load-bearing for Phase 5. Under `apply_to = both` +/// the maker's own bond is `Locked` on *every* published order โ€” that is +/// the steady state, not a committed trade. Counting it here would reject +/// every taker. Only a `Locked` *taker* bond marks the +/// first-to-lock-wins race as decided (ยง6.5), so the take handlers +/// (`take_buy_action` / `take_sell_action`) gate on this predicate +/// instead of "any Locked bond". +pub fn trade_committed_by_locked_taker_bond(bonds: &[Bond]) -> bool { + let locked = BondState::Locked.to_string(); + let taker = BondRole::Taker.to_string(); + bonds.iter().any(|b| b.state == locked && b.role == taker) +} + /// Per-take context that the take handler computed locally and now /// stashes on the bond row instead of mutating the order. /// @@ -280,6 +311,119 @@ pub async fn request_taker_bond( Ok(bond) } +/// Create a hold invoice for the **maker's** bond, persist a `Bond` row +/// in `Requested`, ship the bolt11 to the maker, and arm the LND +/// subscriber that flips the row to `Locked` once the maker pays. +/// +/// Phase 5 counterpart of [`request_taker_bond`]. Unlike the taker side +/// there is exactly one maker bond per order (no concurrent-bonds race), +/// and the order has already been persisted at +/// [`Status::WaitingMakerBond`] by `publish_order` with **no** NIP-33 +/// event emitted โ€” the order stays invisible in the book until the bond +/// locks. On `Accepted`, [`on_bond_invoice_accepted`] resumes the +/// deferred publication (see `crate::util::resume_publish_after_maker_bond`). +/// +/// `notional_sats` is the sats notional the bond is sized against: the +/// fixed order amount for a fixed-price order, or the price-converted +/// fiat amount for a market-priced single order. Range orders never +/// reach this function in Phase 5 (deferred to Phase 6). +/// +/// On any failure the bond row may exist in `Requested` with no LND +/// counterpart; the order will be reaped (and the bond released) by the +/// `WaitingMakerBond` expiry path, mirroring the taker "always release" +/// contract. +pub async fn request_maker_bond( + pool: &Pool, + order: &Order, + maker_pubkey: PublicKey, + notional_sats: i64, + request_id: Option, + trade_index: Option, +) -> Result { + let cfg = Settings::get_bond().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "anti_abuse_bond block is missing while maker bond was deemed required".into(), + )) + })?; + + let amount = compute_bond_amount(notional_sats, cfg); + let memo = format!("mostro bond order_id={}", order.id); + + let mut ln_client = LndConnector::new().await?; + let (invoice_resp, preimage, hash) = ln_client + .create_hold_invoice(&memo, amount) + .await + .map_err(|e| MostroInternalErr(ServiceError::HoldInvoiceError(e.to_string())))?; + + let mut bond = Bond::new_requested(order.id, maker_pubkey.to_string(), BondRole::Maker, amount); + bond.hash = Some(bytes_to_string(&hash)); + bond.preimage = Some(bytes_to_string(&preimage)); + bond.payment_request = Some(invoice_resp.payment_request.clone()); + // No `taker_*` context on a maker bond: those columns describe the + // deferred take snapshot of a concurrent taker bond and stay NULL here. + + let bond = create_bond(pool, bond).await?; + + info!( + "Maker bond requested: bond_id={} order_id={} amount_sats={}", + bond.id, order.id, bond.amount_sats + ); + + // The bond bolt11 ships as a dedicated `Action::PayBondInvoice`, same + // as the taker side. The order is not on the wire yet (no NIP-33 + // event), so the `SmallOrder` carries `Status::Pending` purely as a + // neutral placeholder for the client. + let order_kind = order.get_order_kind().map_err(MostroInternalErr)?; + let bond_small = SmallOrder::new( + Some(order.id), + Some(order_kind), + Some(Status::Pending), + amount, + order.fiat_code.clone(), + order.min_amount, + order.max_amount, + order.fiat_amount, + order.payment_method.clone(), + order.premium, + None, + None, + None, + None, + None, + ); + + // Arm the subscriber BEFORE shipping the bolt11 (same ordering + // rationale as the taker side: a fast payer must not race ahead of + // the listener). On subscribe failure, release the row so we don't + // strand a `Requested` bond with no listener. + if let Err(e) = bond_invoice_subscribe(hash, request_id).await { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + "request_maker_bond: subscribe failed ({}); rolling back bond row", + e + ); + let _ = release_bond(pool, &bond).await; + return Err(e); + } + + enqueue_order_msg( + request_id, + Some(order.id), + Action::PayBondInvoice, + Some(Payload::PaymentRequest( + Some(bond_small), + invoice_resp.payment_request, + None, + )), + maker_pubkey, + trade_index, + ) + .await; + + Ok(bond) +} + /// Outcome of a `cancel_hold_invoice` attempt against LND, classified /// from the structured gRPC error so the caller can decide whether the /// HTLC is verifiably no longer encumbered. @@ -449,9 +593,30 @@ pub async fn release_bond(pool: &Pool, bond: &Bond) -> Result<(), Mostro pub async fn release_bonds_for_order( pool: &Pool, order_id: Uuid, +) -> Result<(), MostroError> { + release_active_bonds(pool, order_id, false).await +} + +/// Release every active bond on `order_id`, optionally **retaining** the +/// maker's bond. +/// +/// `retain_makers = true` is the waiting-timeout **republish** path: the +/// order returns to the book, the maker is still committed to it, so its +/// `Locked` bond must stay put and be resolved only when the order itself +/// terminates (completed, cancelled, or expired `Pending`). Only the +/// abandoning taker side is released. Every other path releases all bonds +/// (`retain_makers = false`). +async fn release_active_bonds( + pool: &Pool, + order_id: Uuid, + retain_makers: bool, ) -> Result<(), MostroError> { let bonds = find_active_bonds_for_order(pool, order_id).await?; + let maker = BondRole::Maker.to_string(); for bond in bonds.iter() { + if retain_makers && bond.role == maker { + continue; + } if let Err(e) = release_bond(pool, bond).await { warn!("Failed to release bond {}: {}", bond.id, e); } @@ -476,6 +641,21 @@ pub async fn release_bonds_for_order_or_warn( } } +/// Like [`release_bonds_for_order_or_warn`] but **retains the maker's +/// bond** โ€” the waiting-timeout republish path (see [`release_active_bonds`]). +/// The maker's `Locked` bond stays put because the order returns to the +/// book with the maker still committed; only the abandoning taker side is +/// released. +pub async fn release_taker_bonds_for_order_or_warn( + pool: &Pool, + order_id: Uuid, + context: &'static str, +) { + if let Err(e) = release_active_bonds(pool, order_id, true).await { + warn!("{context}: bond release failed for {}: {}", order_id, e); + } +} + /// Spawn the LND subscriber for a bond hold invoice. The subscriber /// transitions the bond row through `Locked` / `Released` based on the /// invoice state and, on `Locked`, resumes the original take flow. @@ -600,16 +780,33 @@ async fn on_bond_invoice_accepted( } }; + // Phase 5: a maker bond is a singleton (one per order, posted at + // order-creation time), so it never participates in the taker + // first-to-lock-wins race below. Route it to its own lock + resume + // path, which finishes the deferred order publication. + if bond.role == BondRole::Maker.to_string() { + return on_maker_bond_accepted(&bond, hash, pool, request_id).await; + } + // Atomic Requested โ†’ Locked with concurrent-bonds guard. Exactly // one bond can win per order โ€” if two `Accepted` events arrive in // the same window, the loser's UPDATE returns `rows_affected = 0`. + // + // Phase 5: the guard counts only OTHER `Locked` *taker* bonds. The + // first-to-lock-wins race is purely a taker concern; under + // `apply_to = both` the maker's own bond is already `Locked` on every + // published order (that is the steady state, not a competitor). Without + // the `role = 'taker'` filter the `NOT EXISTS` subquery would see the + // maker bond, this UPDATE would affect zero rows, and the first taker + // to pay would be wrongly treated as a race loser โ€” rejecting every + // taker on a maker-bonded order. let now = Utc::now().timestamp(); let result = sqlx::query( "UPDATE bonds SET state = ?, locked_at = ? \ WHERE id = ? AND state = ? \ AND NOT EXISTS ( \ SELECT 1 FROM bonds b2 \ - WHERE b2.order_id = ? AND b2.state = ? AND b2.id != ? \ + WHERE b2.order_id = ? AND b2.state = ? AND b2.role = ? AND b2.id != ? \ )", ) .bind(BondState::Locked.to_string()) @@ -618,6 +815,7 @@ async fn on_bond_invoice_accepted( .bind(BondState::Requested.to_string()) .bind(bond.order_id) .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) .bind(bond.id) .execute(pool) .await @@ -736,6 +934,90 @@ async fn on_bond_invoice_accepted( resume_take_after_bond(pool, order, &my_keys, request_id).await } +/// Subscriber callback path for a **maker** bond reaching `Accepted`. +/// +/// The maker bond is a singleton, so there is no first-to-lock-wins race +/// and no loser to cancel. We atomically flip `Requested โ†’ Locked`, then +/// โ€” if the order is still parked at `WaitingMakerBond` โ€” resume the +/// deferred order publication that `publish_order` skipped. +/// +/// Idempotent across redeliveries and the restart resubscriber: a +/// duplicate firing for an already-`Locked` bond re-reads `Locked` and +/// falls through to the resume, which itself no-ops once the order has +/// moved on to `Pending` (already published). +async fn on_maker_bond_accepted( + bond: &Bond, + hash: &str, + pool: &Pool, + request_id: Option, +) -> Result<(), MostroError> { + let now = Utc::now().timestamp(); + let result = + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(now) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Re-read so a concurrent release (e.g. the order expired and its + // bond was cancelled) is visible before we try to publish. + let current = match find_bond_by_hash(pool, hash).await? { + Some(b) => b, + None => return Ok(()), + }; + let current_state = match BondState::from_str(¤t.state) { + Ok(s) => s, + Err(e) => { + warn!( + "Maker bond {} has unparseable state {:?}: {} โ€” skipping publish", + current.id, current.state, e + ); + return Ok(()); + } + }; + if current_state != BondState::Locked { + info!( + "Maker bond {} no longer Locked (state={}) โ€” skipping publish", + current.id, current.state + ); + return Ok(()); + } + if result.rows_affected() == 1 { + info!( + "Maker bond {} locked for order {}", + current.id, current.order_id + ); + } + + let order = Order::by_id(pool, current.order_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))? + .ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Maker bond {} references missing order {}", + current.id, current.order_id + ))) + })?; + + // Only resume the deferred publication while the order is still + // parked at `WaitingMakerBond`. A previous firing (or the restart + // resubscriber) may already have published it (status `Pending`), + // in which case we must not re-publish or re-ack the maker. + if order.status != Status::WaitingMakerBond.to_string() { + info!( + "Maker bond {} accepted but order {} is in status {} โ€” skipping publish", + current.id, order.id, order.status + ); + return Ok(()); + } + + let my_keys = get_keys()?; + crate::util::resume_publish_after_maker_bond(pool, &my_keys, order, request_id).await +} + /// Message the taker of a losing concurrent bond that their take was /// cancelled because another taker locked their bond first. async fn notify_loser(bond: &Bond) { @@ -855,15 +1137,15 @@ async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), Ok(()) } -/// If `order_id` is currently in `Status::WaitingTakerBond` and has no -/// remaining active bond rows, transition it back to `Status::Pending` -/// and republish the NIP-33 event. No-op otherwise. +/// Atomically transition `order_id` from `WaitingTakerBond` back to +/// `Pending` **iff** no active taker bond remains on it. Returns `true` +/// when the transition was applied, `false` when it was a no-op. /// -/// Used by `on_bond_invoice_canceled` (when LND cancels the only -/// outstanding bond's hold invoice) and by the taker self-cancel path -/// in `cancel.rs` (when the sender was the last bonded taker). Both -/// call sites need the same "drop back to Pending if empty" logic; -/// extracting it keeps them consistent. +/// This is the load-bearing, side-effect-free core of +/// [`maybe_drop_waiting_taker_bond`] (the latter adds the NIP-33 +/// republish, which needs process-wide keys). Splitting it out lets the +/// CAS semantics be unit-tested directly instead of via an inlined SQL +/// copy that could drift from production. /// /// Race-free: the status check, active-bond check, and status update /// run in a single conditional `UPDATE โ€ฆ WHERE โ€ฆ AND NOT EXISTS (โ€ฆ)` @@ -874,19 +1156,23 @@ async fn on_bond_invoice_canceled(hash: &str, pool: &Pool) -> Result<(), /// `WaitingTakerBond` (winner promotes to `WaitingPayment`, maker /// cancels, etc.) is caught by the `status = 'waiting-taker-bond'` /// predicate. -pub(crate) async fn maybe_drop_waiting_taker_bond( +/// +/// Phase 5: the active-bond check is scoped to `role = 'taker'`. Under +/// `apply_to = both` the order carries a `Locked` *maker* bond for the +/// whole trade; counting it here would make `NOT EXISTS` permanently +/// false, so a `WaitingTakerBond` order whose last taker bond was just +/// cancelled would never drop back to `Pending`. The drop-to-Pending +/// decision depends solely on whether any *taker* is still racing. +pub(crate) async fn drop_waiting_taker_bond_to_pending( pool: &Pool, order_id: Uuid, -) -> Result<(), MostroError> { - // Atomic compare-and-swap on (status, no active bonds). A single - // statement so SQLite snapshots both predicates at the same point - // in time. +) -> Result { let cas = sqlx::query( "UPDATE orders SET status = ? \ WHERE id = ? AND status = ? \ AND NOT EXISTS ( \ SELECT 1 FROM bonds \ - WHERE order_id = ? AND state IN (?, ?) \ + WHERE order_id = ? AND state IN (?, ?) AND role = ? \ )", ) .bind(Status::Pending.to_string()) @@ -895,14 +1181,33 @@ pub(crate) async fn maybe_drop_waiting_taker_bond( .bind(order_id) .bind(BondState::Requested.to_string()) .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) .execute(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - if cas.rows_affected() == 0 { + Ok(cas.rows_affected() == 1) +} + +/// If `order_id` is currently in `Status::WaitingTakerBond` and has no +/// remaining active taker bond, transition it back to `Status::Pending` +/// and republish the NIP-33 event. No-op otherwise. +/// +/// Used by `on_bond_invoice_canceled` (when LND cancels the only +/// outstanding bond's hold invoice) and by the taker self-cancel path +/// in `cancel.rs` (when the sender was the last bonded taker). Both +/// call sites need the same "drop back to Pending if empty" logic; +/// extracting it keeps them consistent. The CAS itself lives in +/// [`drop_waiting_taker_bond_to_pending`]; this wrapper adds the +/// NIP-33 republish. +pub(crate) async fn maybe_drop_waiting_taker_bond( + pool: &Pool, + order_id: Uuid, +) -> Result<(), MostroError> { + if !drop_waiting_taker_bond_to_pending(pool, order_id).await? { // Either the order is no longer in `WaitingTakerBond` // (winner / maker-cancel / admin already moved it on), or a - // concurrent bond is still racing. Either way we have no + // concurrent taker bond is still racing. Either way we have no // status transition to publish. return Ok(()); } @@ -1058,7 +1363,7 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); - // cashu escrow columns (mostro-core 0.12.0) โ€” `Order::by_id` SELECTs + // cashu escrow columns (mostro-core 0.12.1) โ€” `Order::by_id` SELECTs // them. Apply each ALTER separately for the same reason as dev_fee. for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") .split(';') @@ -1094,6 +1399,33 @@ mod tests { b } + /// Mirror of the production taker first-to-lock-wins CAS in + /// `on_bond_invoice_accepted` (including the `role = 'taker'` filter on + /// the `NOT EXISTS` guard). Returns `rows_affected`. Kept in lockstep + /// with the real query so the race tests verify the actual semantics. + async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { + sqlx::query( + "UPDATE bonds SET state = ?, locked_at = ? \ + WHERE id = ? AND state = ? \ + AND NOT EXISTS ( \ + SELECT 1 FROM bonds b2 \ + WHERE b2.order_id = ? AND b2.state = ? AND b2.role = ? AND b2.id != ? \ + )", + ) + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .bind(bond.order_id) + .bind(BondState::Locked.to_string()) + .bind(BondRole::Taker.to_string()) + .bind(bond.id) + .execute(pool) + .await + .unwrap() + .rows_affected() + } + #[tokio::test] async fn release_bond_is_idempotent_for_terminal_states() { let pool = setup_pool().await; @@ -1165,6 +1497,131 @@ mod tests { assert!(!taker_bond_required()); } + #[test] + fn maker_bond_required_is_false_without_config() { + // Phase 5: same inertness guarantee for the maker gate. With no + // `[anti_abuse_bond]` block, `publish_order` must never park an + // order at `WaitingMakerBond` or request a maker bond. + assert!(!maker_bond_required()); + } + + #[test] + fn locked_maker_bond_does_not_commit_the_trade() { + // Phase 5 (load-bearing): under `apply_to = both` every published + // order already carries a `Locked` maker bond. The take handlers' + // committed-trade gate must NOT count it โ€” otherwise the first + // taker is wrongly rejected with `PendingOrderExists` on every + // bond-enabled order. Only a `Locked` *taker* bond commits the + // trade (first-to-lock-wins, ยง6.5). + let order_id = Uuid::new_v4(); + let mut maker = Bond::new_requested(order_id, "a".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + // A still-racing taker bond (Requested) must also not commit. + let taker_requested = Bond::new_requested(order_id, "b".repeat(64), BondRole::Taker, 1_000); + + assert!( + !trade_committed_by_locked_taker_bond(&[maker.clone(), taker_requested.clone()]), + "a Locked maker bond + a Requested taker bond must not commit the trade" + ); + + // Once a taker bond reaches Locked, the trade IS committed. + let mut taker_locked = taker_requested; + taker_locked.state = BondState::Locked.to_string(); + assert!( + trade_committed_by_locked_taker_bond(&[maker, taker_locked]), + "a Locked taker bond commits the trade" + ); + } + + #[test] + fn empty_bond_set_does_not_commit_the_trade() { + // Defensive: the no-bonds case (feature just enabled, or all + // bonds released) must read as "not committed" so takes proceed. + assert!(!trade_committed_by_locked_taker_bond(&[])); + } + + #[tokio::test] + async fn maker_bond_lock_is_singleton_and_idempotent() { + // Phase 5: the maker bond is a singleton, so `on_maker_bond_accepted` + // uses a plain `Requested โ†’ Locked` CAS (no concurrent-bonds + // `NOT EXISTS` guard). The first firing locks it; a duplicate + // firing (LND redelivery / restart resubscriber) affects zero + // rows because the row is no longer `Requested`, and the bond + // stays `Locked` exactly once. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + let mut bond = Bond::new_requested(order_id, "d".repeat(64), BondRole::Maker, 1_000); + bond.hash = Some("e".repeat(64)); + let bond = create_bond(&pool, bond).await.unwrap(); + + async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(bond.id) + .bind(BondState::Requested.to_string()) + .execute(pool) + .await + .unwrap() + .rows_affected() + } + + assert_eq!(try_lock(&pool, &bond).await, 1, "first lock wins"); + assert_eq!( + try_lock(&pool, &bond).await, + 0, + "duplicate firing is a no-op" + ); + + let after = find_bond_by_hash(&pool, &"e".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(after.state, BondState::Locked.to_string()); + assert_eq!(after.role, BondRole::Maker.to_string()); + } + + #[tokio::test] + async fn maker_bond_lock_does_not_touch_other_bonds() { + // The maker lock CAS is keyed by `id`, so it must flip only the + // maker row even when an unrelated bond row exists on the same + // order. Guards against a future refactor accidentally widening + // the predicate. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + let mut maker = Bond::new_requested(order_id, "d".repeat(64), BondRole::Maker, 1_000); + maker.hash = Some("e".repeat(64)); + let maker = create_bond(&pool, maker).await.unwrap(); + + let mut other = Bond::new_requested(order_id, "f".repeat(64), BondRole::Taker, 1_000); + other.hash = Some("0".repeat(64)); + let other = create_bond(&pool, other).await.unwrap(); + + sqlx::query("UPDATE bonds SET state = ?, locked_at = ? WHERE id = ? AND state = ?") + .bind(BondState::Locked.to_string()) + .bind(Utc::now().timestamp()) + .bind(maker.id) + .bind(BondState::Requested.to_string()) + .execute(&pool) + .await + .unwrap(); + + let other_after = find_bond_by_hash(&pool, &"0".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!( + other_after.state, + BondState::Requested.to_string(), + "unrelated bond must stay Requested" + ); + assert_eq!(other_after.id, other.id); + } + #[tokio::test] async fn lock_race_guard_admits_only_one_winner() { // With bonds A and B both Requested on the same order, the @@ -1187,29 +1644,6 @@ mod tests { b.pubkey = "b".repeat(64); let bond_b = create_bond(&pool, b).await.unwrap(); - // Helper that runs the same SQL as `on_bond_invoice_accepted`. - async fn try_lock(pool: &Pool, bond: &Bond) -> u64 { - sqlx::query( - "UPDATE bonds SET state = ?, locked_at = ? \ - WHERE id = ? AND state = ? \ - AND NOT EXISTS ( \ - SELECT 1 FROM bonds b2 \ - WHERE b2.order_id = ? AND b2.state = ? AND b2.id != ? \ - )", - ) - .bind(BondState::Locked.to_string()) - .bind(Utc::now().timestamp()) - .bind(bond.id) - .bind(BondState::Requested.to_string()) - .bind(bond.order_id) - .bind(BondState::Locked.to_string()) - .bind(bond.id) - .execute(pool) - .await - .unwrap() - .rows_affected() - } - // A goes first and wins. assert_eq!(try_lock(&pool, &bond_a).await, 1); // B's UPDATE sees A already Locked โ†’ guarded out. @@ -1225,6 +1659,129 @@ mod tests { .any(|(id, s)| *id == bond_b.id && s == &BondState::Requested.to_string())); } + #[tokio::test] + async fn locked_maker_bond_does_not_block_taker_lock_race() { + // Phase 5 regression (apply_to = both): a `Locked` maker bond is + // present on every published order. The taker first-to-lock-wins + // CAS must IGNORE it (its `NOT EXISTS` guard is scoped to + // `role = 'taker'`), so the first taker to pay still wins. Without + // the role filter the maker bond would satisfy the guard, the + // taker's UPDATE would affect zero rows, and every taker would be + // wrongly rejected as a race loser. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + + // Maker bond already Locked (the steady state after publication). + let mut maker = Bond::new_requested(order_id, "m".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + maker.hash = Some("d".repeat(64)); + let maker = create_bond(&pool, maker).await.unwrap(); + + // A taker now pays their bond. + let mut taker = make_bond(order_id, BondState::Requested); + taker.pubkey = "t".repeat(64); + taker.hash = Some("e".repeat(64)); + let taker = create_bond(&pool, taker).await.unwrap(); + + assert_eq!( + try_lock(&pool, &taker).await, + 1, + "taker must win the lock race despite the Locked maker bond" + ); + + // Maker bond untouched; taker bond now Locked. + let maker_after = find_bond_by_hash(&pool, &"d".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(maker_after.id, maker.id); + assert_eq!(maker_after.state, BondState::Locked.to_string()); + let taker_after = find_bond_by_hash(&pool, &"e".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!(taker_after.id, taker.id); + assert_eq!(taker_after.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn maybe_drop_waiting_taker_bond_ignores_locked_maker_bond() { + // Phase 5 regression (apply_to = both): when the last taker bond is + // cancelled, the order must drop from `WaitingTakerBond` back to + // `Pending` even though the maker's `Locked` bond is still on the + // order. The CAS's active-bond check is scoped to `role = 'taker'`, + // so the lingering maker bond does not pin the order in + // `WaitingTakerBond`. Exercises the real CAS helper + // (`drop_waiting_taker_bond_to_pending`) โ€” not an inlined SQL copy + // โ€” so the test tracks production semantics. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::WaitingTakerBond.to_string()) + .bind(order_id) + .execute(&pool) + .await + .unwrap(); + + // Only a Locked maker bond remains (the taker bond was already + // released by the caller before maybe_drop runs). + let mut maker = Bond::new_requested(order_id, "m".repeat(64), BondRole::Maker, 1_000); + maker.state = BondState::Locked.to_string(); + maker.hash = Some("d".repeat(64)); + create_bond(&pool, maker).await.unwrap(); + + let dropped = drop_waiting_taker_bond_to_pending(&pool, order_id) + .await + .unwrap(); + assert!( + dropped, + "order must drop to Pending: the Locked maker bond must not count as an active taker bond" + ); + + let status: String = sqlx::query_scalar("SELECT status FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, Status::Pending.to_string()); + } + + #[tokio::test] + async fn drop_waiting_taker_bond_held_by_active_taker_bond() { + // Counterpart to the maker-bond test: a still-active *taker* bond + // (Requested) MUST pin the order in `WaitingTakerBond` โ€” the CAS + // returns false and the status is unchanged. Confirms the role + // filter does not over-drop while another taker is still racing. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(Status::WaitingTakerBond.to_string()) + .bind(order_id) + .execute(&pool) + .await + .unwrap(); + + let mut taker = make_bond(order_id, BondState::Requested); + taker.pubkey = "t".repeat(64); + taker.hash = Some("e".repeat(64)); + create_bond(&pool, taker).await.unwrap(); + + let dropped = drop_waiting_taker_bond_to_pending(&pool, order_id) + .await + .unwrap(); + assert!(!dropped, "an active taker bond must keep the order parked"); + + let status: String = sqlx::query_scalar("SELECT status FROM orders WHERE id = ?") + .bind(order_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, Status::WaitingTakerBond.to_string()); + } + #[tokio::test] async fn concurrent_requested_bonds_coexist() { // Multiple Requested bonds on the same order coexist โ€” they diff --git a/src/app/bond/mod.rs b/src/app/bond/mod.rs index ce7f7a07..daf8b81f 100644 --- a/src/app/bond/mod.rs +++ b/src/app/bond/mod.rs @@ -18,14 +18,18 @@ pub mod slash; pub mod types; pub use flow::{ - release_bond, release_bonds_for_order, release_bonds_for_order_or_warn, request_taker_bond, - resubscribe_active_bonds, taker_bond_required, TakerContext, + maker_bond_required, release_bond, release_bonds_for_order, release_bonds_for_order_or_warn, + release_taker_bonds_for_order_or_warn, request_maker_bond, request_taker_bond, + resubscribe_active_bonds, taker_bond_required, trade_committed_by_locked_taker_bond, + TakerContext, }; pub use math::{compute_bond_amount, compute_node_share}; pub use model::Bond; pub use payout::{add_bond_invoice_action, run_bond_payout_cycle}; pub use slash::{ apply_bond_resolution, extract_bond_resolution, notify_bond_slashed, - slash_or_release_on_timeout, validate_bond_resolution, + reconcile_stranded_range_maker_bonds, resolve_range_maker_bond_at_close, + resolve_range_maker_bond_at_close_or_warn, slash_or_release_on_timeout, + validate_bond_resolution, }; pub use types::{BondRole, BondSlashReason, BondState}; diff --git a/src/app/bond/payout.rs b/src/app/bond/payout.rs index 476ab467..1a2a6031 100644 --- a/src/app/bond/payout.rs +++ b/src/app/bond/payout.rs @@ -75,7 +75,7 @@ use crate::lightning::invoice::{decode_invoice, is_valid_invoice}; use crate::lightning::{routing_fee_cap_sats, LndConnector}; use crate::util::{bytes_to_string, enqueue_order_msg}; -use super::db::find_bonds_by_state; +use super::db::{find_bond_by_id, find_bonds_by_state}; use super::model::Bond; use super::types::{BondSlashReason, BondState}; @@ -137,16 +137,70 @@ pub async fn run_bond_payout_cycle(pool: &Pool, ln_client: &mut LndConne /// โ”‚ โ”‚ /// โ””โ”€โ”€โ”€โ”€ send_payment success โ”€โ–บ Slashed โ”‚ /// โ”‚ -/// send_payment failure โ”€โ–บ retry (or Failed) โ—„โ”€โ”€โ”˜ +/// retries left โ”€โ–บ retry next tick โ—„โ”€โ”€ send_payment failure โ—„โ”€โ”€โ”€โ”€โ”€โ”ค +/// โ”‚ +/// keep invoice (reconcile) โ—„โ”€โ”€ retries exhausted, indeterminate failure โ—„โ”€โ”€โ”€โ”ค +/// โ”‚ +/// re-arm (clear invoice, re-prompt) โ—„โ”€โ”€ exhausted, terminal, in claim window โ—„โ”€โ”€โ”ค +/// โ”‚ +/// Failed โ—„โ”€โ”€ exhausted, terminal, past claim window โ—„โ”€โ”˜ /// ``` /// /// Each call advances by at most one of these arms; the scheduler /// reruns the row on the next tick until a terminal state is reached. +/// The re-arm arm (Phase 4.5, [issue #750]) discards an unroutable +/// invoice and loops the row back to the invoice-request sub-phase so +/// the winner is re-prompted; it is bounded by the same forfeit window +/// as the never-claimed case. An *indeterminate* failure (timeout / EOF +/// / send RPC error) never abandons the invoice โ€” see +/// [`PaymentFailureKind`]. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 +/// Phase 6 โ€” should the payout scheduler **skip** `bond` this tick because +/// it is a child payout row (slice slash or maker refund) whose parent +/// range bond is still `Locked`? +/// +/// The parent HTLC is settled only at range close +/// (`resolve_range_maker_bond_at_close` โ†’ parent `Slashed`); until then the +/// sats backing the child are not in Mostro's wallet, so the child must not +/// request an invoice or attempt `send_payment`. A missing parent (should +/// never happen) is treated as "skip" defensively. Non-child rows +/// (`parent_bond_id IS NULL`) are never blocked. +async fn child_payout_blocked_by_locked_parent( + pool: &Pool, + bond: &Bond, +) -> Result { + let Some(parent_id) = bond.parent_bond_id else { + return Ok(false); + }; + match find_bond_by_id(pool, parent_id).await? { + Some(parent) => Ok(parent.state == BondState::Locked.to_string()), + None => { + warn!( + bond_id = %bond.id, + parent_bond_id = %parent_id, + "bond payout: child row's parent bond is missing; skipping this tick" + ); + Ok(true) + } + } +} + async fn process_one_bond( pool: &Pool, ln_client: &mut LndConnector, bond: &Bond, ) -> Result<(), MostroError> { + // Phase 6 โ€” a child payout row (slice slash or maker refund) must not + // be driven while its parent range bond is still `Locked`: the parent + // HTLC has not been settled, so the sats are not yet in Mostro's wallet + // and there is nothing to pay out from. `resolve_range_maker_bond_at_close` + // settles the parent (โ†’ `Slashed`) at range close, which unblocks every + // child row on the next scheduler tick. Skip silently until then. + if child_payout_blocked_by_locked_parent(pool, bond).await? { + return Ok(()); + } + let cfg = Settings::get_bond(); let claim_window_seconds = cfg .map(|c| c.payout_claim_window_days as i64 * 86_400) @@ -190,7 +244,17 @@ async fn process_one_bond( match bond.payout_invoice.as_deref() { None => request_payout_invoice(pool, bond, invoice_window_seconds).await, - Some(invoice) => pay_counterparty(pool, ln_client, bond, invoice, max_retries).await, + Some(invoice) => { + pay_counterparty( + pool, + ln_client, + bond, + invoice, + max_retries, + claim_window_seconds, + ) + .await + } } } @@ -328,7 +392,7 @@ async fn request_payout_invoice( ))) })?; - let recipient_pubkey = match resolve_recipient(&order, bond, reason)? { + let recipient_pubkey = match resolve_payout_recipient(&order, bond, reason)? { Some(pk) => pk, None => { warn!( @@ -476,6 +540,7 @@ async fn pay_counterparty( bond: &Bond, invoice: &str, max_retries: i64, + claim_window_seconds: i64, ) -> Result<(), MostroError> { let counterparty_share = counterparty_share_sats(bond)?; @@ -486,10 +551,15 @@ async fn pay_counterparty( let decoded = match decode_invoice(invoice) { Ok(d) => d, Err(e) => { + // Structurally unusable invoice โ€” no payment is or will be in + // flight for it, so this is a terminal failure (safe to + // abandon and re-prompt for a usable one). return on_send_payment_failure( pool, bond, max_retries, + claim_window_seconds, + PaymentFailureKind::Terminal, &format!("payout invoice decode failed: {e}"), ) .await; @@ -518,10 +588,14 @@ async fn pay_counterparty( return slash_after_success(pool, bond, counterparty_share).await; } Ok(Some(PaymentStatus::Failed)) => { + // LND confirms the prior payment for this invoice + // failed โ€” terminal, safe to abandon the invoice. return on_send_payment_failure( pool, bond, max_retries, + claim_window_seconds, + PaymentFailureKind::Terminal, "tracked payment reported Failed on reconciliation", ) .await; @@ -595,24 +669,41 @@ async fn pay_counterparty( .send_payment(invoice, counterparty_share, tx) .await; if let Err(e) = send_outcome { - return on_send_payment_failure(pool, bond, max_retries, &format!("{e}")).await; + // The RPC call itself errored. We cannot be sure the payment did + // not partially enter LND, so treat it as indeterminate: keep + // the invoice + hash for reconciliation rather than risk a + // double payout by re-prompting. + return on_send_payment_failure( + pool, + bond, + max_retries, + claim_window_seconds, + PaymentFailureKind::Indeterminate, + &format!("{e}"), + ) + .await; } // Collect the first terminal status from the stream. Mirrors // dev_fee::send_dev_fee_payment, but each recv is bounded by // `PAYMENT_STATUS_RECV_TIMEOUT` so a wedged LND stream (no terminal // update, no EOF, no InFlight churn) does not pin the scheduler - // task forever. A timeout and a clean EOF are both routed through - // `on_send_payment_failure` so the retry budget governs the - // recovery path uniformly. + // task forever. We track *why* the stream ended: only an explicit + // `PaymentStatus::Failed` is terminal. A timeout or clean EOF leaves + // the payment outcome unknown (it may still be in flight), so it is + // routed as `Indeterminate` โ€” `on_send_payment_failure` then keeps + // the invoice + hash for reconciliation instead of re-prompting. let mut succeeded = false; - let mut failure: Option = None; + let mut failure: Option<(PaymentFailureKind, String)> = None; loop { match timeout(PAYMENT_STATUS_RECV_TIMEOUT, rx.recv()).await { Err(_) => { - failure = Some(format!( - "payment status stream timed out after {}s without a terminal update", - PAYMENT_STATUS_RECV_TIMEOUT.as_secs() + failure = Some(( + PaymentFailureKind::Indeterminate, + format!( + "payment status stream timed out after {}s without a terminal update", + PAYMENT_STATUS_RECV_TIMEOUT.as_secs() + ), )); break; } @@ -625,9 +716,9 @@ async fn pay_counterparty( break; } PaymentStatus::Failed => { - failure = Some(format!( - "payment failed: reason {}", - msg.payment.failure_reason + failure = Some(( + PaymentFailureKind::Terminal, + format!("payment failed: reason {}", msg.payment.failure_reason), )); break; } @@ -642,8 +733,13 @@ async fn pay_counterparty( return slash_after_success(pool, bond, counterparty_share).await; } - let msg = failure.unwrap_or_else(|| "payment stream ended without terminal status".to_string()); - on_send_payment_failure(pool, bond, max_retries, &msg).await + // EOF with no terminal status (the `Ok(None)` break above) is also + // indeterminate: the stream closed without telling us the outcome. + let (kind, msg) = failure.unwrap_or(( + PaymentFailureKind::Indeterminate, + "payment stream ended without terminal status".to_string(), + )); + on_send_payment_failure(pool, bond, max_retries, claim_window_seconds, kind, &msg).await } /// Flip a `PendingPayout` row to `Slashed` after a confirmed payment. @@ -717,16 +813,82 @@ async fn slash_after_success( Err(MostroInternalErr(ServiceError::DbAccessError(cause))) } -/// Bump `payout_attempts`; on `payout_max_retries` reached, transition -/// the bond to `Failed`. This counter only increments on real -/// `send_payment` failures, not on invoice-request messages. +/// Whether a `send_payment` attempt failed in a way LND has confirmed +/// is terminal (the payment will not settle) or in a way that leaves the +/// outcome unknown (the payment may still be in flight). +/// +/// This distinction is load-bearing for the Phase 4.5 re-prompt path +/// ([issue #750] review). Abandoning the current invoice โ€” either by +/// re-arming for a fresh one or by giving up to `Failed` โ€” clears +/// `payout_payment_hash`, which disables [`pay_counterparty`]'s +/// reconciliation branch. Doing that while a payment is still in flight +/// would let a freshly-prompted invoice be paid while the original later +/// settles: a **double payout**. So an invoice may only be abandoned on +/// a `Terminal` failure. An `Indeterminate` one keeps the invoice and +/// its hash so reconciliation can poll LND to a definitive answer on a +/// later tick. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum PaymentFailureKind { + /// LND reported the payment as `Failed` (via the status stream or + /// reconciliation), or the invoice is structurally unusable โ€” no + /// payment is or will be in flight for it. + Terminal, + /// Outcome unknown: status-stream timeout, stream EOF, or a + /// `send_payment` RPC error. The payment may still be in flight. + Indeterminate, +} + +/// Bump `payout_attempts` after a failed `send_payment`. This counter +/// only increments on real `send_payment` failures, not on +/// invoice-request messages. +/// +/// What happens once `payout_max_retries` is reached against a single +/// invoice depends on **both** the failure kind and the forfeit window +/// (Phase 4.5, [issue #750]): +/// +/// - **Indeterminate failure** (timeout / stream EOF / send RPC error): +/// the payment may still be in flight, so the invoice is **never +/// abandoned** here. The row keeps its `payout_invoice` + +/// `payout_payment_hash` and `pay_counterparty`'s reconciliation +/// branch drives it to a definitive Succeeded / Failed on a later +/// tick. This is the guard against the double-payout the review +/// flagged. +/// - **Terminal failure, inside the claim window** +/// (`now - slashed_at < claim_window`): the unroutable invoice is +/// discarded and the row is re-armed back into the invoice-request +/// sub-phase (`payout_invoice`, `payout_routing_fee_sats`, +/// `payout_payment_hash`, and `last_invoice_request_at` cleared; +/// `payout_attempts` reset to 0; `state` stays `PendingPayout`). The +/// next scheduler tick sees `payout_invoice IS NULL` and re-prompts +/// the winner via `request_payout_invoice`. The `slashed_at` anchor is +/// **never touched**, so re-prompting cannot extend the winner's +/// exposure โ€” the forfeit deadline stays fixed and the +/// re-prompt/retry cycle is bounded by `payout_claim_window_days` +/// (after which `process_one_bond` forfeits the row). This makes the +/// previously-unreachable ยง8.2 recovery Mostro-driven instead of +/// relying on the winner spontaneously resubmitting. +/// - **Terminal failure, outside the claim window**: there is no point +/// re-prompting past the deadline, so the row transitions to `Failed` +/// โ€” the terminal "we held a valid invoice but could not route it and +/// the window is closed" state, distinct from `Forfeited` (the winner +/// never submitted an invoice at all). `Failed` requires operator +/// attention. +/// +/// [issue #750]: https://github.com/MostroP2P/mostro/issues/750 async fn on_send_payment_failure( pool: &Pool, bond: &Bond, max_retries: i64, + claim_window_seconds: i64, + kind: PaymentFailureKind, cause: &str, ) -> Result<(), MostroError> { - let new_attempts = bond.payout_attempts + 1; + // Saturate at `max_retries`: an indeterminate failure never abandons + // the invoice (see below), so without a cap a long LND outage could + // grow the counter without bound. + let new_attempts = std::cmp::min(bond.payout_attempts + 1, max_retries); sqlx::query("UPDATE bonds SET payout_attempts = ? WHERE id = ? AND state = ?") .bind(new_attempts) .bind(bond.id) @@ -735,29 +897,111 @@ async fn on_send_payment_failure( .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - if new_attempts >= max_retries { - sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") - .bind(BondState::Failed.to_string()) - .bind(bond.id) - .bind(BondState::PendingPayout.to_string()) - .execute(pool) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - error!( + if new_attempts < max_retries { + warn!( bond_id = %bond.id, order_id = %bond.order_id, attempts = new_attempts, - "bond payout: send_payment exhausted retries โ€” transitioning to Failed; node share retained, counterparty share stranded (operator review required). last error: {cause}" + max_retries, + "bond payout: send_payment failure ({cause}); will retry on next tick" ); - } else { + return Ok(()); + } + + // Budget exhausted for the current invoice. An *indeterminate* + // failure means the payment may still be in flight, so we must NOT + // abandon the invoice: clearing `payout_invoice` / + // `payout_payment_hash` would let a freshly-prompted invoice be paid + // while the original later settles (double payout). Keep the invoice + // + hash so `pay_counterparty`'s reconciliation branch resolves the + // payment to a definitive Succeeded / Failed on a later tick; only a + // Terminal failure (handled below) ever abandons the invoice. + if kind == PaymentFailureKind::Indeterminate { warn!( bond_id = %bond.id, order_id = %bond.order_id, attempts = new_attempts, - max_retries, - "bond payout: send_payment failure ({cause}); will retry on next tick" + "bond payout: retry budget exhausted on an indeterminate failure ({cause}); keeping the current invoice for LND reconciliation โ€” not re-prompting (payment may still be in flight)" ); + return Ok(()); + } + + // Terminal failure: no payment is in flight for this invoice, so it + // is safe to abandon it. Decide between re-prompting the winner + // (in-window) and giving up (out-of-window) based on the forfeit + // deadline anchored on `slashed_at`. A missing `slashed_at` is an + // invariant violation (Phase 2's slash CAS writes it atomically with + // the transition to `PendingPayout`); treat it conservatively as + // out-of-window so a corrupted row terminates in `Failed` for + // operator review rather than looping forever. + let now = Utc::now().timestamp(); + let within_window = bond + .slashed_at + .is_some_and(|slashed_at| now - slashed_at < claim_window_seconds); + + if within_window { + // Phase 4.5: discard the unroutable invoice and re-arm the + // invoice-request sub-phase. `request_payout_invoice` gates on + // `payout_invoice IS NULL`, so clearing it is what re-prompts + // the winner on the next tick. `last_invoice_request_at` is + // cleared so the re-prompt fires immediately (the winner has + // already waited through a full retry budget). `payout_attempts` + // resets to 0 so the next invoice gets a fresh budget; + // `payout_routing_fee_sats` / `payout_payment_hash` are cleared + // because they describe the discarded attempt. `slashed_at` and + // `invoice_request_attempts` are intentionally left untouched โ€” + // the deadline must not move, and the nudge counter is bounded + // by the forfeit window (not the retry budget) so it keeps + // accumulating across re-prompts for operator visibility. + let result = sqlx::query( + "UPDATE bonds \ + SET payout_invoice = NULL, \ + payout_routing_fee_sats = NULL, \ + payout_payment_hash = NULL, \ + payout_attempts = 0, \ + last_invoice_request_at = NULL \ + WHERE id = ? AND state = ?", + ) + .bind(bond.id) + .bind(BondState::PendingPayout.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 1 { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + attempts = new_attempts, + "bond payout: send_payment exhausted retries for this invoice ({cause}); re-requesting a fresh invoice from the winner (still inside claim window, deadline unchanged)" + ); + } else { + // The row moved off `PendingPayout` between the attempts + // bump and this re-arm CAS (e.g. a concurrent forfeit at + // the window edge). Nothing to do โ€” the winning transition + // already decided this row's fate. + info!( + bond_id = %bond.id, + order_id = %bond.order_id, + "bond payout: re-arm CAS missed (concurrent transition); leaving row as-is" + ); + } + return Ok(()); } + + sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") + .bind(BondState::Failed.to_string()) + .bind(bond.id) + .bind(BondState::PendingPayout.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + error!( + bond_id = %bond.id, + order_id = %bond.order_id, + attempts = new_attempts, + "bond payout: send_payment exhausted retries past the claim window โ€” transitioning to Failed; node share retained, counterparty share stranded (operator review required). last error: {cause}" + ); Ok(()) } @@ -793,6 +1037,29 @@ fn resolve_recipient( Ok(pk) } +/// Payout recipient for any `PendingPayout` row, Phase-6 aware. +/// +/// - **Maker-refund row** (Phase 6: `parent_bond_id` set, `child_order_id` +/// NULL) โ†’ the recipient is the **maker themselves** (`bond.pubkey`), not +/// a trade counterparty. This is the unslashed remainder being returned +/// after a partial range slash. +/// - **Everything else** โ€” a normal slash row, or a Phase 6 *slice-slash* +/// child (whose `order_id` is the slice order and whose `pubkey` is the +/// maker's slice-side key) โ€” resolves via [`resolve_recipient`] to the +/// non-`bond.pubkey` side of the order, i.e. the winning counterparty. +fn resolve_payout_recipient( + order: &Order, + bond: &Bond, + reason: BondSlashReason, +) -> Result, MostroError> { + if bond.parent_bond_id.is_some() && bond.child_order_id.is_none() { + let pk = PublicKey::from_str(&bond.pubkey) + .map_err(|e| MostroInternalErr(ServiceError::UnexpectedError(e.to_string())))?; + return Ok(Some(pk)); + } + resolve_recipient(order, bond, reason) +} + /// Build the `SmallOrder` carried by bond-payout messages /// (`AddBondInvoice`, `BondInvoiceAccepted`, `BondPayoutCompleted`). /// `order.amount` carries the **counterparty share** โ€” the figure the @@ -934,7 +1201,7 @@ async fn notify_payout_completed(pool: &Pool, bond: &Bond, counterparty_ return; } }; - match resolve_recipient(&order, bond, reason) { + match resolve_payout_recipient(&order, bond, reason) { Ok(Some(recipient)) => { enqueue_payout_ack( &order, @@ -1019,7 +1286,24 @@ pub async fn add_bond_invoice_action( }; let sender = event.sender; - let bond = find_recoverable_bond_for_recipient(pool, order_id, &sender.to_string()).await?; + // Phase 6: a single order can carry more than one payout debt to the + // *same* recipient (e.g. under `apply_to = both`, a range root may owe + // the maker both a taker-slash counterparty share and an unslashed + // refund). Decode the submitted invoice's amount so the finder can + // disambiguate by `counterparty_share` when several candidates share a + // recipient. `None` (amountless invoice / decode failure) falls back to + // the legacy recipient-only match. + let invoice_share_sats = decode_invoice(&payment_request) + .ok() + .and_then(|inv| inv.amount_milli_satoshis()) + .map(|msat| (msat / 1000) as i64); + let bond = find_recoverable_bond_for_recipient( + pool, + order_id, + &sender.to_string(), + invoice_share_sats, + ) + .await?; let bond = match bond { Some(b) => b, None => { @@ -1235,6 +1519,7 @@ async fn find_recoverable_bond_for_recipient( pool: &Pool, order_id: Uuid, sender_pubkey: &str, + expected_share_sats: Option, ) -> Result, MostroError> { let bonds: Vec = sqlx::query_as::<_, Bond>( "SELECT * FROM bonds \ @@ -1260,6 +1545,10 @@ async fn find_recoverable_bond_for_recipient( None => return Ok(None), }; + // Collect every recoverable row whose recipient matches the sender. + // Usually there is exactly one; Phase 6 can produce two debts to the + // same recipient on one order (see caller). + let mut matches: Vec = Vec::new(); for bond in bonds { let reason = match bond .slashed_reason @@ -1269,13 +1558,30 @@ async fn find_recoverable_bond_for_recipient( Some(r) => r, None => continue, }; - if let Some(recipient) = resolve_recipient(&order, &bond, reason)? { + if let Some(recipient) = resolve_payout_recipient(&order, &bond, reason)? { if recipient.to_string() == sender_pubkey { - return Ok(Some(bond)); + matches.push(bond); + } + } + } + + // Disambiguate by the submitted invoice amount when more than one debt + // shares the recipient: prefer the row whose counterparty share equals + // the invoice's amount. Fall back to the first (most recently slashed) + // match for the single-candidate case or an amountless invoice โ€” this + // preserves the pre-Phase-6 behaviour exactly. + if matches.len() > 1 { + if let Some(target) = expected_share_sats { + if let Some(exact) = matches.iter().find(|b| { + counterparty_share_sats(b) + .map(|s| s == target) + .unwrap_or(false) + }) { + return Ok(Some(exact.clone())); } } } - Ok(None) + Ok(matches.into_iter().next()) } #[cfg(test)] @@ -1320,7 +1626,7 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); - // cashu escrow columns (mostro-core 0.12.0) โ€” `Order::by_id` SELECTs + // cashu escrow columns (mostro-core 0.12.1) โ€” `Order::by_id` SELECTs // them. Apply each ALTER separately for the same reason as dev_fee. for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") .split(';') @@ -1420,6 +1726,89 @@ mod tests { assert!(r.is_none()); } + #[test] + fn resolve_payout_recipient_refund_row_pays_the_maker() { + // Phase 6 maker-refund row: `parent_bond_id` set, `child_order_id` + // NULL, `pubkey` = the maker. The recipient is the maker themselves, + // not the trade counterparty. + let order = Order { + kind: Kind::Sell.to_string(), + seller_pubkey: Some(maker_pk().to_string()), + buyer_pubkey: Some(taker_pk().to_string()), + ..Order::default() + }; + let mut refund = pending_payout_bond(Uuid::new_v4(), maker_pk(), 600, 0, 0, None, None); + refund.parent_bond_id = Some(Uuid::new_v4()); + refund.child_order_id = None; + let r = resolve_payout_recipient(&order, &refund, BondSlashReason::LostDispute).unwrap(); + assert_eq!( + r.unwrap().to_string(), + maker_pk(), + "the unslashed-remainder refund is paid back to the maker" + ); + } + + #[test] + fn resolve_payout_recipient_slice_slash_pays_the_winner() { + // Phase 6 slice-slash child: `child_order_id` set, `pubkey` = the + // maker's slice-side key โ†’ recipient = the slice's other side (the + // winner), exactly as a normal slash resolves. + let order = Order { + kind: Kind::Sell.to_string(), + seller_pubkey: Some(maker_pk().to_string()), + buyer_pubkey: Some(taker_pk().to_string()), + ..Order::default() + }; + let mut child = pending_payout_bond(Uuid::new_v4(), maker_pk(), 400, 200, 0, None, None); + child.parent_bond_id = Some(Uuid::new_v4()); + child.child_order_id = Some(Uuid::new_v4()); + let r = resolve_payout_recipient(&order, &child, BondSlashReason::LostDispute).unwrap(); + assert_eq!( + r.unwrap().to_string(), + taker_pk(), + "a slice slash pays the non-maker winner" + ); + } + + #[tokio::test] + async fn child_payout_blocked_while_parent_locked() { + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + + let mut parent = + Bond::new_requested(order_id, maker_pk().to_string(), BondRole::Maker, 1_000); + parent.state = BondState::Locked.to_string(); + let parent = create_bond(&pool, parent).await.unwrap(); + + let mut child = pending_payout_bond(order_id, maker_pk(), 400, 0, 0, None, None); + child.parent_bond_id = Some(parent.id); + child.child_order_id = Some(order_id); + let child = create_bond(&pool, child).await.unwrap(); + + // Parent Locked โ†’ child must be skipped. + assert!(child_payout_blocked_by_locked_parent(&pool, &child) + .await + .unwrap()); + + // Settle the parent (range close) โ†’ child is unblocked. + sqlx::query("UPDATE bonds SET state = ? WHERE id = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + assert!(!child_payout_blocked_by_locked_parent(&pool, &child) + .await + .unwrap()); + + // A normal (non-child) row is never blocked. + let normal = pending_payout_bond(order_id, taker_pk(), 1_000, 0, 0, None, None); + assert!(!child_payout_blocked_by_locked_parent(&pool, &normal) + .await + .unwrap()); + } + #[tokio::test] async fn request_payout_invoice_respects_cadence_window() { // A request issued within `invoice_window_seconds` of the @@ -1611,28 +2000,39 @@ mod tests { } #[tokio::test] - async fn send_payment_failure_increments_attempts_and_flips_to_failed() { - // After `max_retries` consecutive `send_payment` failures the - // row must transition `PendingPayout -> Failed`. Exercises - // `on_send_payment_failure` with a tight retry budget. + async fn send_payment_failure_past_window_flips_to_failed() { + // After `max_retries` consecutive `send_payment` failures, a bond + // whose claim window has already elapsed must transition + // `PendingPayout -> Failed` (Phase 4.5: re-prompting is pointless + // past the deadline). Exercises `on_send_payment_failure` with a + // tight retry budget and an out-of-window `slashed_at`. let pool = setup_pool().await; let order_id = Uuid::new_v4(); insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + // slashed_at older than the claim window โ†’ out of window. + let slashed_at = Utc::now().timestamp() - (CLAIM_WINDOW_SECONDS + 86_400); let bond = pending_payout_bond( order_id, taker_pk(), 10_000, 5_000, - Utc::now().timestamp(), + slashed_at, Some("lnbc1pSOMETHING"), None, ); let bond = create_bond(&pool, bond).await.unwrap(); // First failure: attempts 0 -> 1, still PendingPayout. - on_send_payment_failure(&pool, &bond, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_1: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1643,9 +2043,16 @@ mod tests { // Second + third failures use the *fresh* row each time so the // counter math is exercised end-to-end. Third must flip Failed. - on_send_payment_failure(&pool, &bond_after_1, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond_after_1, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_2: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1653,9 +2060,16 @@ mod tests { .unwrap(); assert_eq!(bond_after_2.payout_attempts, 2); - on_send_payment_failure(&pool, &bond_after_2, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + &bond_after_2, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); let bond_after_3: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) @@ -1665,6 +2079,181 @@ mod tests { assert_eq!(bond_after_3.state, BondState::Failed.to_string()); } + #[tokio::test] + async fn send_payment_failure_within_window_reprompts_winner() { + // Phase 4.5 / issue #750: after `max_retries` send_payment + // failures against a submitted invoice, a bond still inside its + // claim window must NOT terminate in `Failed`. Instead it + // re-arms the invoice-request sub-phase: `payout_invoice` and + // the per-attempt columns are cleared, `payout_attempts` resets + // to 0, the row stays `PendingPayout`, and `slashed_at` (the + // forfeit anchor) is left untouched so the deadline never moves. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + let slashed_at = Utc::now().timestamp(); + let mut bond = pending_payout_bond( + order_id, + taker_pk(), + 10_000, + 5_000, + slashed_at, + Some("lnbc1pSTALE"), + Some(slashed_at), // last_invoice_request_at set + ); + // Simulate the per-attempt state accumulated during the failed + // send: a routing-fee cap and a payment hash from the stale + // invoice, plus one prior nudge to the winner. + bond.payout_routing_fee_sats = Some(50); + bond.payout_payment_hash = Some("deadbeef".to_string()); + bond.invoice_request_attempts = 1; + let bond = create_bond(&pool, bond).await.unwrap(); + + // First two failures: budget not exhausted, row stays put with + // its invoice and counters intact. + on_send_payment_failure( + &pool, + &bond, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_1: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after_1.payout_attempts, 1); + assert_eq!(after_1.state, BondState::PendingPayout.to_string()); + assert_eq!(after_1.payout_invoice.as_deref(), Some("lnbc1pSTALE")); + + on_send_payment_failure( + &pool, + &after_1, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_2: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after_2.payout_attempts, 2); + + // Third failure hits `max_retries` (3) inside the window โ†’ re-arm. + on_send_payment_failure( + &pool, + &after_2, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); + let after_3: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(bond.id) + .fetch_one(&pool) + .await + .unwrap(); + + // Re-armed back into the invoice-request sub-phase, NOT Failed. + assert_eq!(after_3.state, BondState::PendingPayout.to_string()); + assert!(after_3.payout_invoice.is_none()); + assert!(after_3.payout_routing_fee_sats.is_none()); + assert!(after_3.payout_payment_hash.is_none()); + assert!(after_3.last_invoice_request_at.is_none()); + assert_eq!(after_3.payout_attempts, 0); + // Forfeit anchor untouched: the deadline must not move. + assert_eq!(after_3.slashed_at, Some(slashed_at)); + // Nudge counter is bounded by the forfeit window, not the retry + // budget, so it is preserved across the re-prompt. + assert_eq!(after_3.invoice_request_attempts, 1); + } + + #[tokio::test] + async fn send_payment_indeterminate_failure_keeps_invoice() { + // Double-payout guard (issue #750 review): when the retry budget + // is exhausted by *indeterminate* failures (status-stream + // timeout / EOF / send RPC error), the payment for the current + // invoice may still be in flight. The row must keep its invoice + // AND `payout_payment_hash` so `pay_counterparty`'s + // reconciliation branch can poll LND before any new invoice is + // paid โ€” it must NOT re-arm (clear) or flip to `Failed`, even + // well inside the claim window. + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, maker_pk(), taker_pk()).await; + let slashed_at = Utc::now().timestamp(); + let mut bond = pending_payout_bond( + order_id, + taker_pk(), + 10_000, + 5_000, + slashed_at, + Some("lnbc1pINFLIGHT"), + Some(slashed_at), + ); + bond.payout_routing_fee_sats = Some(50); + bond.payout_payment_hash = Some("cafebabe".to_string()); + let bond = create_bond(&pool, bond).await.unwrap(); + + // Exhaust the budget with indeterminate failures. + let mut current = bond; + for _ in 0..3 { + on_send_payment_failure( + &pool, + ¤t, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Indeterminate, + "stream timed out", + ) + .await + .unwrap(); + current = sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ?") + .bind(current.id) + .fetch_one(&pool) + .await + .unwrap(); + } + + // Still PendingPayout, invoice + hash intact for reconciliation. + assert_eq!(current.state, BondState::PendingPayout.to_string()); + assert_eq!(current.payout_invoice.as_deref(), Some("lnbc1pINFLIGHT")); + assert_eq!(current.payout_payment_hash.as_deref(), Some("cafebabe")); + // Counter saturates at max_retries rather than growing unbounded. + assert_eq!(current.payout_attempts, 3); + + // A further indeterminate failure keeps it pinned, never Failed. + on_send_payment_failure( + &pool, + ¤t, + 3, + CLAIM_WINDOW_SECONDS, + PaymentFailureKind::Indeterminate, + "stream eof", + ) + .await + .unwrap(); + let after: Bond = sqlx::query_as("SELECT * FROM bonds WHERE id = ?") + .bind(current.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(after.state, BondState::PendingPayout.to_string()); + assert_eq!(after.payout_attempts, 3); + assert_eq!(after.payout_invoice.as_deref(), Some("lnbc1pINFLIGHT")); + assert_eq!(after.payout_payment_hash.as_deref(), Some("cafebabe")); + } + #[tokio::test] async fn finalize_node_only_transitions_to_slashed() { // `slash_node_share_pct = 1.0` style row: counterparty share is @@ -1993,11 +2582,22 @@ mod tests { // Drive back to Failed via three consecutive send_payment // failures with retry budget = 3. Each call re-reads the row // so the counter math is exercised against fresh state. + // `claim_window_seconds = 0` forces the out-of-window branch so + // exhaustion terminates in `Failed` (Phase 4.5: an in-window + // exhaustion would re-arm instead) โ€” here we want a `Failed` row + // to feed the second resurrection. let mut current = bond_after_b; for _ in 0..3 { - on_send_payment_failure(&pool, ¤t, 3, "transient") - .await - .unwrap(); + on_send_payment_failure( + &pool, + ¤t, + 3, + 0, + PaymentFailureKind::Terminal, + "transient", + ) + .await + .unwrap(); current = sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE id = ?") .bind(bond.id) .fetch_one(&pool) diff --git a/src/app/bond/slash.rs b/src/app/bond/slash.rs index fb7bd3d3..13e29778 100644 --- a/src/app/bond/slash.rs +++ b/src/app/bond/slash.rs @@ -48,17 +48,23 @@ use mostro_core::error::{ ServiceError, }; use mostro_core::message::{Action, BondResolution, Message, Payload}; -use mostro_core::order::{Order, SmallOrder, Status}; +use mostro_core::order::{Kind, Order, SmallOrder, Status}; use nostr_sdk::prelude::PublicKey; use sqlx::{Pool, Sqlite}; +use sqlx_crud::Crud; use tracing::{info, warn}; use uuid::Uuid; -use super::db::find_active_bonds_for_order; -use super::flow::{release_bond, release_bonds_for_order_or_warn}; +use super::db::{ + find_active_bonds_for_order, find_child_slashes_for_parent, find_maker_bond_for_order, + find_range_root_order, +}; +use super::flow::{ + release_bond, release_bonds_for_order_or_warn, release_taker_bonds_for_order_or_warn, +}; use super::math::compute_node_share; use super::model::Bond; -use super::types::{BondSlashReason, BondState}; +use super::types::{BondRole, BondSlashReason, BondState}; use crate::config::settings::Settings; use crate::config::types::AntiAbuseBondSettings; use crate::lightning::LndConnector; @@ -98,6 +104,21 @@ pub(super) fn is_already_settled_error(err: &MostroError) -> bool { || s.contains("code=alreadyexists") } +/// Classify a SQLite error as a UNIQUE-constraint violation. sqlx 0.6's +/// `DatabaseError` exposes the extended result code (2067 = +/// `SQLITE_CONSTRAINT_UNIQUE`) and the driver message; either is sufficient. +/// Used so the partial UNIQUE index on `(parent_bond_id, child_order_id)` +/// (migration `20260611120000`) collapses a forced duplicate slice slash into +/// the same idempotent no-op as the `INSERT ... WHERE NOT EXISTS` guard. +pub(super) fn is_unique_violation(err: &sqlx::Error) -> bool { + err.as_database_error().is_some_and(|d| { + d.code().as_deref() == Some("2067") + || d.message() + .to_lowercase() + .contains("unique constraint failed") + }) +} + /// Which trade-flow side a slash flag is targeting. Internal helper โ€” /// callers think in `BondResolution::slash_seller` / `slash_buyer` /// terms. @@ -153,15 +174,68 @@ pub async fn validate_bond_resolution( return Ok(()); } let bonds = find_active_bonds_for_order(pool, order.id).await?; - if resolution.slash_seller && resolve_locked_bond(order, &bonds, Side::Seller).is_none() { + let is_range = order_has_range_maker_bond(pool, order).await?; + if resolution.slash_seller + && resolve_slash_target(pool, order, &bonds, Side::Seller, is_range) + .await? + .is_none() + { return Err(MostroCantDo(CantDoReason::InvalidPayload)); } - if resolution.slash_buyer && resolve_locked_bond(order, &bonds, Side::Buyer).is_none() { + if resolution.slash_buyer + && resolve_slash_target(pool, order, &bonds, Side::Buyer, is_range) + .await? + .is_none() + { return Err(MostroCantDo(CantDoReason::InvalidPayload)); } Ok(()) } +/// Is the bonded party on `side` the **maker** (vs the taker) for this +/// order? ยง3.1: a `sell` order's maker is the seller; a `buy` order's +/// maker is the buyer. +fn side_is_maker(order: &Order, side: Side) -> Result { + let kind = order.get_order_kind().map_err(MostroInternalErr)?; + Ok(matches!( + (kind, side), + (Kind::Sell, Side::Seller) | (Kind::Buy, Side::Buyer) + )) +} + +/// Resolve the `Locked` bond a slash flag targets, owned so callers don't +/// juggle borrow lifetimes across the range-root fallback. +/// +/// Resolution order: +/// 1. **Pubkey match on this order's active bonds** ([`resolve_locked_bond`] +/// via the ยง3.1 buyer/seller โ†’ trade-pubkey lookup). This covers taker +/// bonds and a non-range / first-slice maker bond (which lives on the +/// order itself). +/// 2. **Range-root fallback** โ€” only for the *maker* side of a range order +/// whose slash landed on a descendant slice (the maker bond lives on the +/// range root, not the slice). Walks `range_parent_id` via +/// [`find_maker_bond_for_order`]. +async fn resolve_slash_target( + pool: &Pool, + order: &Order, + bonds: &[Bond], + side: Side, + is_range: bool, +) -> Result, MostroError> { + if let Some(b) = resolve_locked_bond(order, bonds, side) { + return Ok(Some(b.clone())); + } + if is_range && side_is_maker(order, side)? { + let locked = BondState::Locked.to_string(); + if let Some(b) = find_maker_bond_for_order(pool, order).await? { + if b.state == locked { + return Ok(Some(b)); + } + } + } + Ok(None) +} + /// Apply a validated [`BondResolution`] to every active bond on the order. /// /// For each currently active bond: @@ -191,58 +265,131 @@ pub async fn validate_bond_resolution( /// /// `reason` is `LostDispute` in Phase 2 (called from admin handlers); /// Phase 4 (timeout slash) will reuse this helper with `Timeout`. +/// +/// Returns the bond rows whose slash is *confirmed*, so the caller can send +/// each a best-effort `Action::BondSlashed` forfeiture notice (#768). A +/// transient settle failure (bond left `Locked`) yields no row, so the +/// notice is never untruthful; an idempotent admin retry yields none either, +/// so a winner is never re-notified. Range rows carry the slice amount, all +/// others the full bond amount. pub async fn apply_bond_resolution( pool: &Pool, ln_client: &mut L, order: &Order, resolution: &BondResolution, reason: BondSlashReason, -) -> Result<(), MostroError> { - let bonds = find_active_bonds_for_order(pool, order.id).await?; - if bonds.is_empty() { - return Ok(()); - } - - let mut slashed_ids: HashSet = HashSet::new(); - if resolution.slash_seller { - if let Some(bond) = resolve_locked_bond(order, &bonds, Side::Seller) { - slashed_ids.insert(bond.id); - } - // No-op if a Locked bond is missing: validation should have run - // before any trade-side mutation. Reaching here with a missing - // bond means a concurrent path (release, slash, expiry) raced - // between validate and apply โ€” letting the loop fall through to - // the release branch on whatever remains is the safe outcome. - } - if resolution.slash_buyer { - if let Some(bond) = resolve_locked_bond(order, &bonds, Side::Buyer) { - slashed_ids.insert(bond.id); - } - } +) -> Result, MostroError> { + // Active bonds attached to *this* order โ€” i.e. the taker bond(s) on + // this slice. The maker bond may live on a range root elsewhere and is + // resolved separately via `find_maker_bond_for_order`. + let active = find_active_bonds_for_order(pool, order.id).await?; // Snapshot the split percentage *once* per call. Phase 3 will read // `node_share_sats` off each row; we never recompute it after the // transition. let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct); - for bond in bonds.iter() { - if slashed_ids.contains(&bond.id) { - slash_one(pool, ln_client, bond, reason, node_share_pct).await; + // Is the maker bond governing this order a *range* bond (one HTLC sized + // against `max_amount`, slashed proportionally per slice and settled + // only at range close)? If so, the maker bond must never be settled or + // released inline here โ€” `resolve_range_maker_bond_at_close` owns its + // HTLC. We still record a proportional child slash row below. + let is_range = order_has_range_maker_bond(pool, order).await?; + + // Track the bonds we slashed inline (via `slash_one`) so the release + // sweep at the end skips them. Range maker slashes don't go here โ€” the + // parent HTLC stays `Locked` and is excluded from the sweep by the + // `is_range` guard instead. + let mut slashed_ids: HashSet = HashSet::new(); + + // Rows worth a `BondSlashed` forfeiture notice โ€” only the slashes that + // are *confirmed* to have landed, so the caller never tells a user + // their bond was forfeited while the HTLC is still theirs (a transient + // settle failure leaves the bond `Locked`). Non-range rows carry the + // full bond amount; range rows carry the slice's proportional amount. + // Mirrors `slash_or_release_on_timeout`'s `slashed_row` (#768). + let mut notify_rows: Vec = Vec::new(); + + for (flag, side) in [ + (resolution.slash_seller, Side::Seller), + (resolution.slash_buyer, Side::Buyer), + ] { + if !flag { + continue; + } + // No-op if no Locked bond resolves: validation should have run + // before any trade-side mutation. Reaching here with a missing bond + // means a concurrent path raced between validate and apply โ€” the + // release sweep below handles whatever remains safely. + let Some(target) = resolve_slash_target(pool, order, &active, side, is_range).await? else { + continue; + }; + if is_range && side_is_maker(order, side)? { + // Phase 6: the maker bond on a range order is slashed + // proportionally per slice โ€” record a child row and leave the + // parent HTLC `Locked`. The single settle happens at range close + // (`resolve_range_maker_bond_at_close`, called by the admin + // handler right after this returns). Only notify when this call + // actually inserted the child row โ€” an idempotent re-run must + // not re-notify the maker for a slice already slashed. + let root = find_range_root_order(pool, order.clone()).await?; + let inserted = + record_maker_slice_slash(pool, order, &root, &target, reason, node_share_pct) + .await?; + if inserted { + if let Some(child) = find_slice_slash_child(pool, target.id, order.id).await? { + notify_rows.push(child); + } + } } else { - // Non-slashed bonds on the same order: release with the - // Phase 1 contract. `release_bond` is best-effort and - // tolerant of transient LND failures. - if let Err(e) = release_bond(pool, bond).await { - warn!( - bond_id = %bond.id, - order_id = %order.id, - "apply_bond_resolution: release_bond failed: {}", e - ); + // Taker bond, or a non-range maker bond (Phase 2/5): settle the + // HTLC inline. Skip the release sweep regardless of outcome โ€” + // a transient settle failure leaves the bond `Locked` for an + // admin retry, never released. Confirm the slash via the durable + // `slashed_reason` witness before queueing a notice. + slash_one(pool, ln_client, &target, reason, node_share_pct).await; + slashed_ids.insert(target.id); + if slash_reason_recorded(pool, target.id, reason).await? { + notify_rows.push(target.clone()); } } } - Ok(()) + // Release the non-slashed bonds attached to this order. For a range + // order the maker bond is deliberately retained: its HTLC spans the + // whole range and is resolved (settled-at-close or released) by + // `resolve_range_maker_bond_at_close` once the range terminates โ€” the + // admin handler calls that right after this function returns. + let maker_role = BondRole::Maker.to_string(); + for bond in active.iter() { + if slashed_ids.contains(&bond.id) { + continue; + } + if is_range && bond.role == maker_role { + continue; + } + if let Err(e) = release_bond(pool, bond).await { + warn!( + bond_id = %bond.id, + order_id = %order.id, + "apply_bond_resolution: release_bond failed: {}", e + ); + } + } + + Ok(notify_rows) +} + +/// True when the maker bond governing `order` is a **range** bond โ€” i.e. +/// the order's range root carries a `max_amount`. Range maker bonds use +/// the Phase 6 accumulate-and-settle-at-close path; everything else uses +/// the Phase 2/5 inline settle. +async fn order_has_range_maker_bond( + pool: &Pool, + order: &Order, +) -> Result { + let root = find_range_root_order(pool, order.clone()).await?; + Ok(root.max_amount.is_some()) } /// Phase 4 โ€” timeout-slash dispatch for the scheduler's @@ -258,18 +405,26 @@ pub async fn apply_bond_resolution( /// Responsibility maps directly from the waiting state: /// `WaitingBuyerInvoice โ†’ buyer`, `WaitingPayment โ†’ seller`. The /// buyer/seller โ†’ bond-row resolution then reuses the ยง3.1 order-kind -/// mapping baked into [`apply_bond_resolution`] (a slash flag is matched -/// against `order.buyer_pubkey` / `order.seller_pubkey`, which equal the -/// bonded taker's trade pubkey). So under `apply_to = "take"` only the -/// taker side ever carries a bond, and the maker-responsible rows of the -/// ยง9.2 table fall through to release. +/// mapping (a slash side is matched against `order.buyer_pubkey` / +/// `order.seller_pubkey`, with the Phase 6 range-root fallback for a +/// maker bond living on the range root). Phase 4 armed the taker side of +/// the ยง9.2 table; Phase 7 fills the maker-responsible rows โ€” the gate +/// checks `apply_to` against the *responsible* party's posting role. /// /// A slash happens **only** when all of the following hold: /// - the feature is enabled, `slash_on_waiting_timeout = true`, and -/// `apply_to` covers the taker; +/// `apply_to` covers the responsible party's posting role (taker since +/// Phase 4, maker since Phase 7); /// - the order is in a waiting state; /// - the responsible party holds a `Locked` bond. /// +/// A maker-responsible slash on a **range** order goes through the Phase 6 +/// partial-slash path: a proportional child row is recorded and the parent +/// HTLC stays `Locked` (the single settle happens at range close โ€” the +/// scheduler's cancel branch runs the close right after the order +/// terminates, with the reconciliation sweep as backstop). The returned +/// bond is then the *child* row, carrying the slice's slashed amount. +/// /// Otherwise every active bond is released. This preserves today's /// behaviour when the feature is off, when the bond belongs to the /// non-responsible party, or when no bond exists โ€” and it is the path @@ -293,6 +448,36 @@ pub async fn apply_bond_resolution( /// config (the scheduler passes `Settings::get_bond()`); it is taken as a /// parameter rather than read from the global so the gate is unit-testable /// without mutating process-wide state. +/// Does a waiting-state timeout on this order **republish** it (return it +/// to the book in `Pending`) rather than cancel it outright? +/// +/// Mirrors the republish branch of `scheduler::job_cancel_orders`: +/// `(WaitingBuyerInvoice, Sell)` and `(WaitingPayment, Buy)` republish โ€” +/// in both the responsible party is the *taker*, so the maker stays +/// committed and the order goes back to the book. The complementary +/// `(WaitingBuyerInvoice, Buy)` / `(WaitingPayment, Sell)` cases cancel the +/// order (the responsible party is the *maker*). Keep this in sync with the +/// scheduler's match. +fn order_republishes_on_timeout(order: &Order) -> bool { + matches!( + (order.get_order_status(), order.get_order_kind()), + (Ok(Status::WaitingBuyerInvoice), Ok(Kind::Sell)) + | (Ok(Status::WaitingPayment), Ok(Kind::Buy)) + ) +} + +/// Release the still-active bonds on a timed-out order, honouring the +/// republish-vs-cancel distinction: on a republish the maker's `Locked` +/// bond is retained (it follows the order's lifecycle), otherwise every +/// bond is released. +async fn release_on_timeout(pool: &Pool, order_id: Uuid, republishes: bool) { + if republishes { + release_taker_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await; + } else { + release_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await; + } +} + pub async fn slash_or_release_on_timeout( pool: &Pool, ln_client: &mut L, @@ -311,81 +496,178 @@ pub async fn slash_or_release_on_timeout( } }; - // Gate the slash. `apply_to` is a posting-timing switch; Phase 4 is - // taker-only, so we check `applies_to_taker` (Phase 7 widens this to - // the maker). When the gate is closed we still release โ€” bonds left - // over from a prior enabled period must drain regardless. - let slash_armed = bond_cfg - .is_some_and(|c| c.enabled && c.slash_on_waiting_timeout && c.apply_to.applies_to_taker()); + // Will this timeout **republish** the order (return it to the book) or + // **terminate** it? When the taker is the responsible party the order + // goes back to `Pending` and is republished, so the maker's commitment + // survives โ€” its bond stays `Locked` and is resolved only when the + // order itself terminates (completed / cancelled / `Pending` expiry). + // Only the abandoning taker side is settled here. When the maker is + // responsible the order is cancelled outright, so every bond is + // released. This mirrors `scheduler::job_cancel_orders`' own + // republish-vs-cancel split (keep the two in sync). + let republishes = order_republishes_on_timeout(order); + + // Gate the slash per responsible role. `apply_to` is a posting-timing + // switch: Phase 4 armed the taker side, Phase 7 widens to the maker โ€” + // the slash only arms when `apply_to` covers the *responsible* party's + // posting role. When the gate is closed we still release โ€” bonds left + // over from a prior enabled period must drain regardless (but a + // republish still retains the maker bond). + let responsible_is_maker = side_is_maker(order, side)?; + let slash_armed = bond_cfg.is_some_and(|c| { + c.enabled + && c.slash_on_waiting_timeout + && if responsible_is_maker { + c.apply_to.applies_to_maker() + } else { + c.apply_to.applies_to_taker() + } + }); if !slash_armed { - release_bonds_for_order_or_warn(pool, order.id, "scheduler_timeout").await; + release_on_timeout(pool, order.id, republishes).await; return Ok(None); } - // Does the responsible party hold a `Locked` bond? + // Does the responsible party hold a `Locked` bond? Phase 7: a range + // order's maker bond lives on the range *root*, so the resolver must be + // range-aware (`resolve_slash_target` falls back to the root walk for + // the maker side; the taker side resolves on this order's bonds alone, + // exactly as Phase 4 did). let bonds = find_active_bonds_for_order(pool, order.id).await?; - let Some(responsible) = resolve_locked_bond(order, &bonds, side).cloned() else { + let is_range = order_has_range_maker_bond(pool, order).await?; + let Some(responsible) = resolve_slash_target(pool, order, &bonds, side, is_range).await? else { // Responsible party has no bond (e.g. the maker under // `apply_to = take`), or the bond already moved out of `Locked`. - // No slash; release whatever is still active on the order. - release_bonds_for_order_or_warn(pool, order.id, "scheduler_timeout").await; + // No slash; release whatever is still active on the order + // (retaining the maker bond on a republish). + release_on_timeout(pool, order.id, republishes).await; return Ok(None); }; - // Reuse the Phase 2 primitive. The `BondResolution` names the - // responsible side; `apply_bond_resolution` settles that bond's HTLC - // + CAS โ†’ PendingPayout(Timeout) and releases every other active bond - // on the order (the Phase 1 cancel path). - let resolution = match side { - Side::Buyer => BondResolution { - slash_seller: false, - slash_buyer: true, - }, - Side::Seller => BondResolution { - slash_seller: true, - slash_buyer: false, - }, + // Slash the responsible bond, then resolve the remaining active bonds: + // release them โ€” but on a republish retain the maker's still-`Locked` + // bond, which the abandoning taker's timeout must not disturb. (We + // intentionally do **not** route this through `apply_bond_resolution`, + // which always releases every non-slashed bond โ€” that is correct for a + // terminal dispute resolution but would wrongly release the maker on a + // republish.) + // + // Two slash shapes (mirroring `apply_bond_resolution`): + // - **Range maker** (Phase 7 ร— Phase 6): record a proportional child + // slash row and leave the parent HTLC `Locked` โ€” the single settle + // happens at range close (`resolve_range_maker_bond_at_close`, run + // by the scheduler's cancel branch / reconciliation sweep once the + // order terminates). The notice-worthy row is the *child* (it carries + // the slice's slashed amount), and only when this call actually + // inserted it โ€” an idempotent re-run must not re-notify. + // - **Everything else** (taker, non-range maker): settle the HTLC + // inline via the Phase 2 `slash_one` primitive and confirm through + // the durable `slashed_reason = Timeout` witness. + let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct); + let slashed_row: Option = if responsible_is_maker && is_range { + let root = find_range_root_order(pool, order.clone()).await?; + let inserted = record_maker_slice_slash( + pool, + order, + &root, + &responsible, + BondSlashReason::Timeout, + node_share_pct, + ) + .await?; + if inserted { + find_slice_slash_child(pool, responsible.id, order.id).await? + } else { + None + } + } else { + slash_one( + pool, + ln_client, + &responsible, + BondSlashReason::Timeout, + node_share_pct, + ) + .await; + // Confirm the slash actually landed before claiming it: a transient + // settle failure leaves the bond `Locked` (`slash_one` is + // best-effort), and we must never tell a user their bond was + // forfeited while the HTLC is still theirs. + if timeout_slash_confirmed(pool, responsible.id).await? { + Some(responsible.clone()) + } else { + None + } }; - apply_bond_resolution( - pool, - ln_client, - order, - &resolution, - BondSlashReason::Timeout, - ) - .await?; - // Confirm the slash actually landed before claiming it: a transient - // settle failure leaves the bond `Locked` (apply_bond_resolution is - // best-effort), and we must never tell a user their bond was forfeited - // while the HTLC is still theirs. - if timeout_slash_confirmed(pool, responsible.id).await? { - info!( - bond_id = %responsible.id, - order_id = %order.id, - role = %responsible.role, - "Bond slashed on waiting-state timeout" - ); - Ok(Some(responsible)) - } else { - warn!( - bond_id = %responsible.id, - order_id = %order.id, - "timeout slash did not land (bond still Locked); no forfeiture notice sent" - ); - Ok(None) + let maker = BondRole::Maker.to_string(); + for bond in bonds.iter() { + if bond.id == responsible.id { + continue; + } + // Retain the maker bond when the order survives (republish) or when + // it is a range parent โ€” the latter spans the whole range and is + // resolved only at range close, never inline here. + if bond.role == maker && (republishes || is_range) { + continue; + } + if let Err(e) = release_bond(pool, bond).await { + warn!( + bond_id = %bond.id, + order_id = %order.id, + "scheduler_timeout: release_bond failed: {}", e + ); + } + } + + match slashed_row { + Some(slashed) => { + info!( + bond_id = %slashed.id, + order_id = %order.id, + role = %slashed.role, + "Bond slashed on waiting-state timeout" + ); + Ok(Some(slashed)) + } + None => { + warn!( + bond_id = %responsible.id, + order_id = %order.id, + "timeout slash did not land (still Locked / already slashed); no forfeiture notice sent" + ); + Ok(None) + } } } -/// Confirm a timeout slash actually landed on `bond_id`, regardless of -/// where the concurrent payout scheduler has since moved the row. +/// Fetch the Phase 6 child slash row recorded for `(parent_bond_id, +/// slice_order_id)` โ€” the row the Phase 7 range-maker timeout path returns +/// to the caller for the `BondSlashed` notice (it carries the slice's +/// proportional slashed amount, not the whole parent bond). +async fn find_slice_slash_child( + pool: &Pool, + parent_bond_id: Uuid, + slice_order_id: Uuid, +) -> Result, MostroError> { + sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?") + .bind(parent_bond_id) + .bind(slice_order_id) + .fetch_optional(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string()))) +} + +/// Confirm a slash with the `expected` reason actually landed on `bond_id` +/// by checking the durable `slashed_reason` witness, regardless of where the +/// concurrent payout scheduler has since moved the row. /// -/// The slash CAS in [`slash_one`] writes `slashed_reason = Timeout` -/// atomically with the `Locked โ†’ PendingPayout` transition, and **no** -/// later transition clears it: the payout job's state changes +/// The slash CAS in [`slash_one`] writes `slashed_reason` atomically with +/// the `Locked โ†’ PendingPayout` transition, and **no** later transition +/// clears it: the payout job's state changes /// (`PendingPayout โ†’ Slashed | Forfeited | Failed`, and the /// `Failed โ†’ PendingPayout` resurrection) only ever rewrite `state`, never -/// `slashed_reason` / `slashed_at`. So `slashed_reason = Timeout` is a +/// `slashed_reason` / `slashed_at`. So the recorded `slashed_reason` is a /// stable witness that *this* slash succeeded. /// /// A point-in-time `state = PendingPayout` check would be racy: the payout @@ -396,20 +678,31 @@ pub async fn slash_or_release_on_timeout( /// forfeiture notice is never lost to that race. /// /// A transient settle failure leaves the bond `Locked` with -/// `slashed_reason` NULL, so this still returns `false` and no false -/// forfeiture notice is sent. Dispute slashes write -/// `slashed_reason = LostDispute`, so a (vanishingly unlikely) concurrent -/// dispute slash that won the CAS first does not trigger a *timeout* -/// notice here โ€” the dispute path owns its own messaging. -async fn timeout_slash_confirmed(pool: &Pool, bond_id: Uuid) -> Result { +/// `slashed_reason` NULL, so this returns `false` and no false forfeiture +/// notice is sent. The `expected` reason is matched exactly, so a timeout +/// caller never confirms on a dispute slash's `LostDispute` witness and +/// vice-versa โ€” each slash path owns its own messaging. +async fn slash_reason_recorded( + pool: &Pool, + bond_id: Uuid, + expected: BondSlashReason, +) -> Result { let row: Option<(Option,)> = sqlx::query_as("SELECT slashed_reason FROM bonds WHERE id = ?") .bind(bond_id) .fetch_optional(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - let timeout = BondSlashReason::Timeout.to_string(); - Ok(row.and_then(|(reason,)| reason).as_deref() == Some(timeout.as_str())) + let expected = expected.to_string(); + Ok(row.and_then(|(reason,)| reason).as_deref() == Some(expected.as_str())) +} + +/// Confirm a *timeout* slash landed on `bond_id`. Thin wrapper over +/// [`slash_reason_recorded`] keyed on `BondSlashReason::Timeout`; see that +/// function for why the durable `slashed_reason` witness is used instead of +/// a transient `state = PendingPayout` check. +async fn timeout_slash_confirmed(pool: &Pool, bond_id: Uuid) -> Result { + slash_reason_recorded(pool, bond_id, BondSlashReason::Timeout).await } /// Phase 4 โ€” best-effort forfeiture notice to the slashed user @@ -568,95 +861,637 @@ async fn slash_one( } } -/// Resolve a buyer/seller slash flag to the matching `Locked` bond row, -/// if any. The mapping uses the ยง3.1 buyer-side โ†’ trade-pubkey lookup -/// on the order, then filters bonds by `pubkey` and `state = Locked`. +/// Phase 6 โ€” record a proportional slash of a range maker bond against the +/// taken slice `slice`, **without settling the parent HTLC**. /// -/// Returns `None` when the side has no `Locked` bond row โ€” either no -/// bond exists on this order for that pubkey, the bond already moved -/// out of `Locked` (e.g. into `Released` or `PendingPayout`), or the -/// side's pubkey is unset on the order. Validation treats `None` as -/// "InvalidPayload"; `apply` treats it as a benign skip. -fn resolve_locked_bond<'a>(order: &Order, bonds: &'a [Bond], side: Side) -> Option<&'a Bond> { - let target_pubkey = match side { - Side::Seller => order.seller_pubkey.as_deref()?, - Side::Buyer => order.buyer_pubkey.as_deref()?, +/// A range maker posts a single hold invoice (the `parent_bond`) sized +/// against `max_amount`. A BOLT11 hold invoice is all-or-nothing, so we +/// cannot claim only one slice's share mid-range. Instead we insert a child +/// row recording the share and leave the parent `Locked`; the actual settle +/// happens once, at range close +/// ([`resolve_range_maker_bond_at_close`], Option A / "accumulate and +/// settle-at-close"). +/// +/// The share is **price-invariant**: `slice.fiat_amount / root.max_amount` +/// (both fiat โ€” the ratio is independent of any BTC price drift between +/// publication and take), times the locked bond amount. The child row's +/// `order_id` and maker-side `pubkey` are the slice's, so the Phase 3 +/// recipient resolver pays the slice's *other* side (the winner). +/// +/// Returns `Ok(true)` when a child row was actually inserted by this call, +/// `Ok(false)` on every idempotent skip (slice already slashed, parent no +/// longer `Locked`, degenerate share). The Phase 7 timeout path keys the +/// one-shot `BondSlashed` notice on this so a scheduler retry never +/// re-notifies the maker for a slash that already landed. +async fn record_maker_slice_slash( + pool: &Pool, + slice: &Order, + root: &Order, + parent_bond: &Bond, + reason: BondSlashReason, + node_share_pct: f64, +) -> Result { + let kind = slice.get_order_kind().map_err(MostroInternalErr)?; + let maker_slice_pubkey = match kind { + Kind::Sell => slice.seller_pubkey.as_deref(), + Kind::Buy => slice.buyer_pubkey.as_deref(), + }; + let Some(maker_slice_pubkey) = maker_slice_pubkey else { + warn!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice has no maker-side pubkey; skipping" + ); + return Ok(false); + }; + let Some(max_fiat) = root.max_amount.filter(|m| *m > 0) else { + warn!( + bond_id = %parent_bond.id, + root_order_id = %root.id, + "record_maker_slice_slash: range root missing positive max_amount; skipping" + ); + return Ok(false); }; - let locked = BondState::Locked.to_string(); - bonds - .iter() - .find(|b| b.pubkey == target_pubkey && b.state == locked) -} -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; + // Price-invariant proportional share, clamped so the cumulative slashed + // share can never exceed the locked bond (rounding guard). + let raw = (parent_bond.amount_sats as f64 * slice.fiat_amount as f64 / max_fiat as f64).round() + as i64; + let remaining = (parent_bond.amount_sats - parent_bond.slashed_share_sats).max(0); + let slash_amount = raw.clamp(0, remaining); + if slash_amount <= 0 { + warn!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + raw, remaining, + "record_maker_slice_slash: computed non-positive / over-allocated share; skipping" + ); + return Ok(false); + } - use mostro_core::error::{MostroError, ServiceError}; - use mostro_core::message::{Action, Message, Payload}; - use mostro_core::order::{Kind, Order, Status}; - use sqlx::sqlite::SqlitePoolOptions; - use sqlx::{Pool, Sqlite}; + let now = Utc::now().timestamp(); + let node_share = compute_node_share(slash_amount, node_share_pct); - use super::*; - use crate::app::bond::model::Bond; - use crate::app::bond::types::{BondRole, BondSlashReason, BondState}; + // Insert the child slash row **atomically** with two guards, so a slice is + // slashed at most once under a given parent *and* only while that parent is + // still open. This must be a single statement, not a read-then-`create_bond`: + // admin settle/cancel has two independent entry points (the serial Nostr + // loop and the RPC service, each with its own LND client), and `admin_cancel` + // has no order-status CAS, so two concurrent duplicate cancels could + // otherwise both pass a separate existence check and both allocate against + // the same (single) HTLC. + // + // The guards: + // * `NOT EXISTS (child for this slice)` โ€” at-most-once per slice. + // * `EXISTS (parent still Locked)` โ€” lock the child out once the parent + // close wins its `Locked โ†’ Slashed` CAS (see + // `resolve_range_maker_bond_at_close`). Without this a slash that lands + // after the close already settled + refunded the HTLC would insert a + // `PendingPayout` child that the scheduler pays out, pushing the total + // distributed past the single settled HTLC. A slice that misses this + // window is dropped (logged below): the safe direction, since the HTLC + // amount is already fixed. + // + // Both run under SQLite's single write lock, so they observe the same + // committed parent state as the close CAS; the loser sees `rows_affected = 0`. + // `order_id` is the slice's and there is no `preimage`/`hash`/`payment_request` + // โ€” the child shares the parent HTLC. Unset columns take their schema + // defaults (`slashed_share_sats`/`payout_attempts`/`invoice_request_attempts` + // = 0, the rest NULL), matching `Bond::new_requested`. + let insert = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \ + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? \ + WHERE NOT EXISTS ( \ + SELECT 1 FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?) \ + AND EXISTS ( \ + SELECT 1 FROM bonds WHERE id = ? AND state = ?)", + ) + .bind(Uuid::new_v4()) + .bind(slice.id) + .bind(parent_bond.id) + .bind(slice.id) + .bind(maker_slice_pubkey) + .bind(BondRole::Maker.to_string()) + .bind(slash_amount) + .bind(BondState::PendingPayout.to_string()) + .bind(reason.to_string()) + .bind(node_share) + .bind(now) + .bind(now) + .bind(parent_bond.id) + .bind(slice.id) + .bind(parent_bond.id) + .bind(BondState::Locked.to_string()) + .execute(pool) + .await; + // The `WHERE NOT EXISTS` guard makes the loser of a race insert 0 rows. + // The partial UNIQUE index on `(parent_bond_id, child_order_id)` + // (migration `20260611120000`) is defence-in-depth for any future caller + // that forgets the guard: treat a constraint violation as the same + // idempotent no-op, never an error. + let inserted = match insert { + Ok(r) => r, + Err(e) if is_unique_violation(&e) => { + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice already slashed (unique index); skipping duplicate" + ); + return Ok(false); + } + Err(e) => { + return Err(MostroInternalErr(ServiceError::DbAccessError( + e.to_string(), + ))) + } + }; + if inserted.rows_affected() == 0 { + // Either the slice was already slashed (duplicate) or the parent bond + // is no longer `Locked` โ€” its close already settled the HTLC, so this + // slice missed the slash window and is intentionally dropped. + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + "record_maker_slice_slash: slice already slashed or parent no longer Locked; skipping" + ); + return Ok(false); + } - /// Recording stub for `SettleLightning`. Captures each preimage the - /// caller asked LND to settle, and can be primed to return either - /// success, an "already settled" error, or a transient transport - /// failure. Used end-to-end to verify the slash path settles at - /// slash time (one HTLC per slashed bond) and that it skips - /// non-slashed bonds entirely. - #[derive(Default)] - struct StubSettle { - calls: Mutex>, - // When set, force every settle to return this canned error. - fail_with: Mutex>, + // Recompute the parent's running total from the authoritative slice + // child rows (self-healing: a crash between the insert above and this + // update is repaired by the next slash or by the close recompute). Only + // touch a still-`Locked` parent. The refund row (`child_order_id NULL`) + // is excluded so it never inflates the slashed total. + sqlx::query( + "UPDATE bonds SET slashed_share_sats = \ + (SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL) \ + WHERE id = ? AND state = ?", + ) + .bind(parent_bond.id) + .bind(parent_bond.id) + .bind(BondState::Locked.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + info!( + bond_id = %parent_bond.id, + slice_order_id = %slice.id, + reason = %reason, + slash_amount, + "Phase 6: recorded proportional maker slice slash (parent HTLC stays Locked)" + ); + Ok(true) +} + +/// Phase 6 โ€” resolve a maker bond when its order (range chain) terminates +/// (settle-at-close, "Option A"). +/// +/// Called from every terminal hook for an order *except* a successful +/// release that spawns a range remainder (the range continues then, so the +/// maker stays committed). Idempotent and best-effort: +/// +/// - **Non-range maker bond** โ†’ released inline (`cancel_hold_invoice`). +/// Unifies the Phase 5 completion/cancel behaviour so hooks call one +/// function for both fixed and range makers. +/// - **Range, no slice ever slashed** โ†’ released: the maker gets the whole +/// bond back (the HTLC is cancelled, never charged). +/// - **Range, โ‰ฅ1 slice slashed** โ†’ the single parent HTLC is settled +/// **once** (claiming the full bond into Mostro's wallet), the parent row +/// moves `Locked โ†’ Slashed`, and an unslashed-remainder **refund row** +/// (`child_order_id = NULL`, recipient = the maker) is created. The +/// per-slice child rows and the refund row โ€” all `PendingPayout` โ€” are +/// then driven by the Phase 3 payout scheduler, which skipped them while +/// the parent was `Locked`. +pub async fn resolve_range_maker_bond_at_close( + pool: &Pool, + ln_client: &mut L, + order: &Order, +) -> Result<(), MostroError> { + let Some(parent) = find_maker_bond_for_order(pool, order).await? else { + return Ok(()); + }; + // Idempotent: act only on a still-`Locked` parent. A prior close (or a + // Phase 5 inline release/slash) already moved it on. + if parent.state != BondState::Locked.to_string() { + return Ok(()); } - impl StubSettle { - fn new() -> Arc { - Arc::new(Self::default()) - } - fn calls(&self) -> Vec { - self.calls.lock().unwrap().clone() - } - fn fail_next_with(&self, msg: &str) { - *self.fail_with.lock().unwrap() = Some(msg.to_string()); - } + let root = find_range_root_order(pool, order.clone()).await?; + let slice_children: Vec = if root.max_amount.is_some() { + find_child_slashes_for_parent(pool, parent.id) + .await? + .into_iter() + .filter(|c| c.child_order_id.is_some()) + .collect() + } else { + // Non-range maker bond: no child rows exist; fall through to release. + Vec::new() + }; + + // Snapshot total, used only as a fast-path hint to choose release vs. + // settle. The refund below is recomputed authoritatively *inside* the + // close transaction โ€” this read can be stale (a concurrent slice slash may + // commit between here and the CAS). + let snapshot_slashed: i64 = slice_children.iter().map(|c| c.amount_sats).sum(); + if snapshot_slashed == 0 { + // Nothing was ever slashed across the whole range (or non-range + // happy path): release the bond back to the maker. + return release_bond(pool, &parent).await; } - impl SettleLightning for Arc { - fn settle_hold_invoice<'a>( - &'a mut self, - preimage: &'a str, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - self.calls.lock().unwrap().push(preimage.to_string()); - if let Some(msg) = self.fail_with.lock().unwrap().take() { - return Err(MostroError::MostroInternalErr(ServiceError::LnNodeError( - msg, - ))); - } - Ok(()) - }) + // โ‰ฅ1 slice slashed: settle the whole HTLC once. Settle BEFORE the CAS so + // a transient failure leaves the parent retryably `Locked`. + let Some(preimage) = parent.preimage.as_deref() else { + warn!( + bond_id = %parent.id, + "range close: parent bond has no preimage; cannot settle โ€” left Locked" + ); + return Ok(()); + }; + if let Err(e) = ln_client.settle_hold_invoice(preimage).await { + if is_already_settled_error(&e) { + info!( + bond_id = %parent.id, + "range close: parent HTLC already settled (idempotent); proceeding" + ); + } else { + warn!( + bond_id = %parent.id, + "range close: settle_hold_invoice failed: {e} โ€” leaving Locked for retry" + ); + return Ok(()); } } - async fn setup_pool() -> Pool { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect(":memory:") - .await - .expect("open in-memory sqlite"); - sqlx::query(include_str!( - "../../../migrations/20221222153301_orders.sql" - )) - .execute(&pool) + // The HTLC is now settled while the parent is still `Locked`. Perform ALL + // the DB-side close work โ€” the CAS, the child claim-window re-anchor, and + // the maker-refund row โ€” in ONE transaction, so `Slashed` only ever + // becomes visible once the dependent rows are durably written together. + // A crash anywhere in here leaves a `Locked` parent that the + // reconciliation sweep retries; the retry re-settles harmlessly (LND + // returns "already settled", classified as success above). `Locked` is + // thus the sole in-flight state and there is no partially-closed `Slashed` + // window to repair. + let now = Utc::now().timestamp(); + + let mut tx = pool + .begin() .await - .expect("orders migration"); - for stmt in include_str!("../../../migrations/20251126120000_dev_fee.sql") + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + // Move the parent `Locked โ†’ Slashed`. The CAS โ€” kept inside the tx โ€” + // ensures exactly one close wins if two terminal hooks race; the loser + // sees `rows_affected = 0`, rolls back, and returns with no side effects + // (so it never writes a second refund row). + let cas = sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .bind(BondState::Locked.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + if cas.rows_affected() != 1 { + let _ = tx.rollback().await; + info!( + bond_id = %parent.id, + "range close: parent already closed concurrently; skipping refund row" + ); + return Ok(()); + } + + // Recompute the slashed total authoritatively from the child rows *inside* + // the transaction. The CAS above now holds the write lock and the parent is + // `Slashed`, so the gated slice-slash INSERT (see `record_maker_slice_slash`) + // can no longer add a child: this SUM is the final, consistent set. Deriving + // the refund from the pre-transaction `snapshot_slashed` instead would + // over-refund the maker whenever a slice slash committed between the snapshot + // read and the CAS โ€” the late child would still be paid out, pushing the + // total distributed (children + refund) past the single settled HTLC. + let total_slashed: i64 = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(parent.id) + .fetch_one(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + let refund_amount = (parent.amount_sats - total_slashed).max(0); + + // Phase 6: anchor each slice child's claim window at *close* time, not at + // slice-slash time. A child can only be paid out once the parent HTLC is + // settled (now); leaving its `slashed_at` at slice-slash time would let + // the `payout_claim_window_days` countdown run while the bond was still + // unpayable, so a range that stayed open past the window could forfeit a + // child the instant it became processable, before the counterparty was + // ever asked for an invoice. (In the dispute path close follows the slash + // almost immediately, but the timeout-slash path in Phase 7 may not.) + if !slice_children.is_empty() { + sqlx::query( + "UPDATE bonds SET slashed_at = ? \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL AND state = ?", + ) + .bind(now) + .bind(parent.id) + .bind(BondState::PendingPayout.to_string()) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + + if refund_amount > 0 { + // Inherit a parseable reason from a slice child so the Phase 3 + // `slashed_reason` invariant holds; the refund recipient is resolved + // directly from the row (the maker), not via the reason. Raw INSERT + // (mirroring the slice-slash insert, `child_order_id = NULL` marks the + // maker-refund row) so it runs on the same transaction as the CAS. + // Unset columns take their schema defaults, matching `Bond::new_requested`. + let reason = slice_children + .first() + .and_then(|c| c.slashed_reason.clone()) + .unwrap_or_else(|| BondSlashReason::LostDispute.to_string()); + sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \ + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(parent.pubkey.clone()) + .bind(BondRole::Maker.to_string()) + .bind(refund_amount) + .bind(BondState::PendingPayout.to_string()) + .bind(reason) + .bind(0_i64) // node_share_sats = 0: full refund to the maker + .bind(now) + .bind(now) + .execute(&mut *tx) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + + tx.commit() + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + info!( + bond_id = %parent.id, + order_id = %root.id, + amount_sats = parent.amount_sats, + total_slashed, + refund_amount, + children = slice_children.len(), + "Phase 6: range maker bond settled at close; distributing child shares + maker refund" + ); + + Ok(()) +} + +/// Open an `LndConnector` and run [`resolve_range_maker_bond_at_close`], +/// logging on failure. For the non-admin terminal hooks (completion, +/// cancel, scheduler expiry) that don't already hold an LND client. A cheap +/// pre-check skips opening LND entirely when there is no `Locked` maker bond +/// to resolve โ€” the common case. The admin handlers pass their own +/// `ln_client` to the generic function directly. +pub async fn resolve_range_maker_bond_at_close_or_warn( + pool: &Pool, + order: &Order, + context: &'static str, +) { + let locked = BondState::Locked.to_string(); + match find_maker_bond_for_order(pool, order).await { + Ok(Some(b)) if b.state == locked => {} + Ok(_) => return, + Err(e) => { + warn!("{context}: maker bond lookup failed for {}: {e}", order.id); + return; + } + } + let mut ln = match LndConnector::new().await { + Ok(l) => l, + Err(e) => { + warn!("{context}: cannot connect to LND to resolve maker bond at close: {e}"); + return; + } + }; + if let Err(e) = resolve_range_maker_bond_at_close(pool, &mut ln, order).await { + warn!( + order_id = %order.id, + "{context}: resolve_range_maker_bond_at_close failed: {e}" + ); + } +} + +/// Collect the range-root orders whose maker bond is still `Locked` even +/// though the whole range tree has terminated โ€” the stranded set the +/// reconciliation sweep retries. +/// +/// `resolve_range_maker_bond_at_close[_or_warn]` is best-effort (ยง8.2): on a +/// transient LND/DB failure it logs and leaves the parent `Locked`, and once +/// the order is terminal nothing re-invokes it, so the HTLC would sit +/// `Locked` until the LND CLTV safety net (and any slashed slice's payout +/// stays blocked the whole time). This is the scan side of the periodic +/// retry: a parent maker bond is "stranded" when it is still `Locked` and +/// every order in its range tree (root + every `range_parent_id` descendant) +/// is in a terminal status, so a legitimately-open range โ€” whose maker bond +/// is `Locked` by design โ€” is never touched. +async fn collect_stranded_range_maker_roots( + pool: &Pool, +) -> Result, MostroError> { + let mut stranded = Vec::new(); + for bond in super::db::find_locked_maker_parent_bonds(pool).await? { + // Best-effort, per-root isolation (ยง8.2): a transient failure on one + // root (DB busy, a missing/corrupt order row) must only skip that + // root, never abort the whole tick and starve retries for every other + // stranded bond. So per-root errors log and `continue` rather than + // propagate via `?`. + // + // The parent maker bond lives on the range root, so `bond.order_id` + // is the root id. + let root = match Order::by_id(pool, bond.order_id).await { + Ok(Some(root)) => root, + Ok(None) => continue, // missing order row (should never happen) + Err(e) => { + warn!( + order_id = %bond.order_id, + error = %e, + "reconcile_sweep: range-root order lookup failed; skipping this root" + ); + continue; + } + }; + match super::db::range_tree_fully_terminal(pool, bond.order_id).await { + Ok(true) => stranded.push(root), + Ok(false) => {} + Err(e) => { + warn!( + order_id = %bond.order_id, + error = %e, + "reconcile_sweep: range-tree terminal check failed; skipping this root" + ); + continue; + } + } + } + Ok(stranded) +} + +/// Reconciliation sweep (testable core): retry the settle-at-close for every +/// stranded range maker bond, returning how many resolved without error. +/// +/// `resolve_range_maker_bond_at_close` is idempotent (a CAS `Locked โ†’ +/// Slashed`), so re-invoking it is safe whether the prior close half-finished +/// or never ran. A per-bond failure is logged and the sweep moves on โ€” the +/// next tick (and ultimately the CLTV safety net) retries. +pub(crate) async fn reconcile_stranded_range_maker_bonds_with( + pool: &Pool, + ln_client: &mut L, +) -> usize { + let roots = match collect_stranded_range_maker_roots(pool).await { + Ok(r) => r, + Err(e) => { + warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}"); + return 0; + } + }; + let mut resolved = 0; + for order in &roots { + match resolve_range_maker_bond_at_close(pool, ln_client, order).await { + Ok(()) => resolved += 1, + Err(e) => warn!( + order_id = %order.id, + "reconcile_sweep: resolve_range_maker_bond_at_close failed: {e}" + ), + } + } + resolved +} + +/// Reconciliation sweep (scheduler entry point): scan for range maker bonds +/// stranded `Locked` after a failed close and retry each one. Opens a single +/// `LndConnector` for the batch, and only when there is at least one stranded +/// bond โ€” the common case (no stranded bond) costs one indexed query and +/// never touches LND. +pub async fn reconcile_stranded_range_maker_bonds(pool: &Pool) { + // Cheap pre-check so an idle node never opens LND just to find nothing. + match collect_stranded_range_maker_roots(pool).await { + Ok(roots) if roots.is_empty() => return, + Ok(_) => {} + Err(e) => { + warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}"); + return; + } + } + let mut ln = match LndConnector::new().await { + Ok(l) => l, + Err(e) => { + warn!("reconcile_sweep: cannot connect to LND to retry stranded maker bonds: {e}"); + return; + } + }; + let resolved = reconcile_stranded_range_maker_bonds_with(pool, &mut ln).await; + if resolved > 0 { + info!( + resolved, + "reconcile_sweep: retried stranded range maker bond(s) at close" + ); + } +} + +/// Resolve a buyer/seller slash flag to the matching `Locked` bond row, +/// if any. The mapping uses the ยง3.1 buyer-side โ†’ trade-pubkey lookup +/// on the order, then filters bonds by `pubkey` and `state = Locked`. +/// +/// Returns `None` when the side has no `Locked` bond row โ€” either no +/// bond exists on this order for that pubkey, the bond already moved +/// out of `Locked` (e.g. into `Released` or `PendingPayout`), or the +/// side's pubkey is unset on the order. Validation treats `None` as +/// "InvalidPayload"; `apply` treats it as a benign skip. +fn resolve_locked_bond<'a>(order: &Order, bonds: &'a [Bond], side: Side) -> Option<&'a Bond> { + let target_pubkey = match side { + Side::Seller => order.seller_pubkey.as_deref()?, + Side::Buyer => order.buyer_pubkey.as_deref()?, + }; + let locked = BondState::Locked.to_string(); + bonds + .iter() + .find(|b| b.pubkey == target_pubkey && b.state == locked) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use mostro_core::error::{MostroError, ServiceError}; + use mostro_core::message::{Action, Message, Payload}; + use mostro_core::order::{Kind, Order, Status}; + use sqlx::sqlite::SqlitePoolOptions; + use sqlx::{Pool, Sqlite}; + + use super::*; + use crate::app::bond::model::Bond; + use crate::app::bond::types::{BondRole, BondSlashReason, BondState}; + + /// Recording stub for `SettleLightning`. Captures each preimage the + /// caller asked LND to settle, and can be primed to return either + /// success, an "already settled" error, or a transient transport + /// failure. Used end-to-end to verify the slash path settles at + /// slash time (one HTLC per slashed bond) and that it skips + /// non-slashed bonds entirely. + #[derive(Default)] + struct StubSettle { + calls: Mutex>, + // When set, force every settle to return this canned error. + fail_with: Mutex>, + } + + impl StubSettle { + fn new() -> Arc { + Arc::new(Self::default()) + } + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + fn fail_next_with(&self, msg: &str) { + *self.fail_with.lock().unwrap() = Some(msg.to_string()); + } + } + + impl SettleLightning for Arc { + fn settle_hold_invoice<'a>( + &'a mut self, + preimage: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + self.calls.lock().unwrap().push(preimage.to_string()); + if let Some(msg) = self.fail_with.lock().unwrap().take() { + return Err(MostroError::MostroInternalErr(ServiceError::LnNodeError( + msg, + ))); + } + Ok(()) + }) + } + } + + async fn setup_pool() -> Pool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect(":memory:") + .await + .expect("open in-memory sqlite"); + sqlx::query(include_str!( + "../../../migrations/20221222153301_orders.sql" + )) + .execute(&pool) + .await + .expect("orders migration"); + for stmt in include_str!("../../../migrations/20251126120000_dev_fee.sql") .split(';') .map(str::trim) .filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--"))) @@ -678,8 +1513,9 @@ mod tests { .execute(&pool) .await .expect("bond_payout_payment_hash migration"); - // cashu escrow columns (mostro-core 0.12.0) โ€” `Order::by_id` SELECTs - // them. Apply each ALTER separately for the same reason as dev_fee. + // Phase 6 chain-walk tests load full `Order` rows via `Order::by_id`, + // which selects every column the model declares โ€” so the orders + // table must carry the later Cashu columns too. for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql") .split(';') .map(str::trim) @@ -688,8 +1524,14 @@ mod tests { sqlx::query(stmt) .execute(&pool) .await - .expect("cashu escrow migration"); + .expect("cashu_escrow_fields migration"); } + sqlx::query(include_str!( + "../../../migrations/20260611120000_bond_slice_slash_unique.sql" + )) + .execute(&pool) + .await + .expect("bond_slice_slash_unique migration"); pool } @@ -749,7 +1591,21 @@ mod tests { pubkey: &str, state: BondState, ) -> Bond { - let mut b = Bond::new_requested(order_id, pubkey.to_string(), BondRole::Taker, 10_000); + insert_bond_with_role(pool, order_id, pubkey, BondRole::Taker, state).await + } + + /// Phase 5: same fixture as [`insert_bond`] but parameterised on the + /// posting role, so maker-bond dispute-slash tests can assert the + /// buyer/seller โ†’ bond-row resolution resolves to the maker row when + /// the maker is on the named side (ยง3.1). + async fn insert_bond_with_role( + pool: &Pool, + order_id: Uuid, + pubkey: &str, + role: BondRole, + state: BondState, + ) -> Bond { + let mut b = Bond::new_requested(order_id, pubkey.to_string(), role, 10_000); b.state = state.to_string(); b.preimage = Some(stub_preimage()); // No hash โ†’ release_bond skips the LND cancel branch entirely @@ -957,6 +1813,120 @@ mod tests { assert_eq!(row.3, Some(0)); } + #[tokio::test] + async fn apply_slash_seller_on_sell_order_transitions_maker_bond() { + // Phase 5 (ยง10.2 / ยง10.4 acceptance bullet 3): on a sell-order + // the maker IS the seller, so `slash_seller=true` must resolve to + // the maker's bond row via the ยง3.1 pubkey mapping โ€” even though + // the resolver is role-agnostic and matches on + // `order.seller_pubkey`. With both a maker bond (seller) and a + // taker bond (buyer) posted, slashing only the seller transitions + // the maker bond to PendingPayout and releases the taker bond, + // proving the two sides resolve orthogonally. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + + // Pre-flight validation must accept the directive: the seller + // (maker) holds a Locked bond. + validate_bond_resolution(&pool, &order, &res).await.unwrap(); + + apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + let maker_row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + maker_row.0, + BondState::PendingPayout.to_string(), + "maker bond must be slashed on slash_seller for a sell order" + ); + assert_eq!(maker_row.1.as_deref(), Some("lost-dispute")); + + let taker_state: String = sqlx::query_scalar("SELECT state FROM bonds WHERE id = ?") + .bind(taker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + taker_state, + BondState::Released.to_string(), + "the non-slashed taker bond must be released, not settled" + ); + } + + #[tokio::test] + async fn apply_slash_buyer_on_buy_order_transitions_maker_bond() { + // Phase 5 (ยง3.1 mirror): on a buy-order the maker IS the buyer, + // so `slash_buyer=true` resolves to the maker's bond row. This is + // the buy-order counterpart of the sell-order test above and + // completes the ยง10.4 acceptance bullet 3 matrix. + let pool = setup_pool().await; + // Buy order: maker is the buyer, taker is the seller. + let order = fixture_order(Kind::Buy, taker_pk(), maker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + validate_bond_resolution(&pool, &order, &res).await.unwrap(); + apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + let row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + row.0, + BondState::PendingPayout.to_string(), + "maker bond must be slashed on slash_buyer for a buy order" + ); + assert_eq!(row.1.as_deref(), Some("lost-dispute")); + } + #[tokio::test] async fn apply_is_idempotent_on_already_pending_payout() { // A duplicate admin call (or a slash CAS racing with itself) @@ -1336,10 +2306,152 @@ mod tests { } #[tokio::test] - async fn timeout_slash_buy_seller_silent_slashes_taker_bond() { - // buy order, WaitingPayment: the seller is responsible and on a - // buy order the seller is the taker. (For a buy order the maker is - // the buyer, so the taker's trade pubkey lives in `seller_pubkey`.) + async fn timeout_republish_retains_maker_bond_sell_order() { + // Regression (PR #767 review): sell order, WaitingBuyerInvoice. The + // buyer (taker) times out, so the order is **republished** to the + // book โ€” the maker (seller) is still committed to it. The taker + // bond must be slashed, but the maker bond must stay `Locked`: + // releasing it would put a takeable order back in the book with no + // maker bond backing it. It is only released when the order itself + // terminates. + let pool = setup_pool().await; + let order = waiting_order( + Kind::Sell, + maker_pk(), + taker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + // apply_to=both so a maker bond is in play alongside the taker bond. + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!( + result.map(|b| b.id), + Some(taker_bond.id), + "the abandoning taker's bond is the one slashed" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::PendingPayout.to_string(), + "taker bond is slashed on the republish path" + ); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Locked.to_string(), + "maker bond must stay Locked when the order is republished" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "only the slashed taker HTLC is settled; the maker HTLC is untouched" + ); + } + + #[tokio::test] + async fn timeout_republish_with_no_slash_still_retains_maker_bond() { + // Same republish scenario but with the slash gate closed + // (slash_on_waiting_timeout = false). The taker bond drains via the + // Phase 1 release, but the maker bond must still be retained โ€” the + // order goes back to the book with the maker committed. + let pool = setup_pool().await; + let order = waiting_order( + Kind::Sell, + maker_pk(), + taker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, false, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none(), "gate closed โ†’ no slash reported"); + assert!(ln.calls().is_empty(), "release path never settles an HTLC"); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond is released when the slash gate is closed" + ); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Locked.to_string(), + "maker bond is retained on republish even when no slash happens" + ); + } + + #[tokio::test] + async fn timeout_cancel_releases_maker_bond_sell_order() { + // Counterpart to the republish case: sell order, WaitingPayment. + // The seller (maker) is responsible, so the order is **cancelled** + // outright (not republished). Because the order terminates, the + // maker bond must be released โ€” the retain-on-republish carve-out + // must NOT leak into the terminal cancel path. Gate closed + // (slash_on_waiting_timeout = false) keeps this purely about the + // release routing, mirroring the republish/no-slash test above. + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, false, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none()); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Released.to_string(), + "maker bond is released when the order is cancelled (terminal)" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond is released too on a terminal cancel" + ); + } + + #[tokio::test] + async fn timeout_slash_buy_seller_silent_slashes_taker_bond() { + // buy order, WaitingPayment: the seller is responsible and on a + // buy order the seller is the taker. (For a buy order the maker is + // the buyer, so the taker's trade pubkey lives in `seller_pubkey`.) // Gate armed โ†’ taker bond slashed with reason=Timeout. let pool = setup_pool().await; let order = waiting_order(Kind::Buy, taker_pk(), maker_pk(), Status::WaitingPayment); @@ -1447,6 +2559,256 @@ mod tests { ); } + // โ”€โ”€ Phase 7 โ€” maker timeout slash โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn timeout_maker_responsible_slashes_maker_bond_sell_order() { + // Phase 7: sell order, WaitingPayment โ€” the seller is responsible + // and on a sell order the seller is the *maker*. With apply_to=make + // armed, the maker's bond is slashed with reason=Timeout (the ยง9.2 + // "no slash" row filled in by Phase 7). + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!( + result.map(|b| b.id), + Some(maker_bond.id), + "must report the slashed maker bond for notification" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "the non-range maker slash settles the HTLC inline" + ); + let row: (String, Option) = + sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?") + .bind(maker_bond.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(row.0, BondState::PendingPayout.to_string()); + assert_eq!(row.1.as_deref(), Some("timeout")); + } + + #[tokio::test] + async fn timeout_maker_responsible_buy_order_slashes_maker_and_releases_taker() { + // Phase 7 buy-order mirror: WaitingBuyerInvoice โ†’ buyer responsible, + // and on a buy order the buyer is the maker. Under apply_to=both the + // taker (seller) also holds a bond โ€” it belongs to the + // non-responsible party, so it must be released, never slashed, and + // never retained (the timeout cancels the order outright). + let pool = setup_pool().await; + let order = waiting_order( + Kind::Buy, + taker_pk(), + maker_pk(), + Status::WaitingBuyerInvoice, + ); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert_eq!(result.map(|b| b.id), Some(maker_bond.id)); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::PendingPayout.to_string(), + "maker bond slashed on maker-responsible timeout" + ); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "the non-responsible taker's bond is released on the terminal cancel" + ); + assert_eq!( + ln.calls(), + vec![stub_preimage()], + "only the slashed maker HTLC is settled" + ); + } + + #[tokio::test] + async fn timeout_maker_slash_skipped_when_apply_to_take_only() { + // Per-role gate: a maker-responsible timeout with a leftover Locked + // maker bond (posted during a prior apply_to=make period) must NOT + // slash under apply_to=take โ€” the gate checks the responsible + // party's posting role, not "any side is covered". The bond drains + // via release instead. + let pool = setup_pool().await; + let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Take); + let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg)) + .await + .unwrap(); + + assert!(result.is_none()); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, maker_bond.id).await, + BondState::Released.to_string() + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_records_child_and_keeps_parent_locked() { + // Phase 7 ร— Phase 6: a maker-responsible timeout on a *range* order + // goes through the partial-slash path โ€” a proportional child row is + // recorded (slice fiat 40 / max 100 ร— bond 1000 = 400, reason + // timeout) and the parent HTLC stays Locked; the single settle + // happens at range close, never here. The reported bond is the + // child (it carries the slice's slashed amount for the notice). + // The taker's own bond is released on the terminal cancel. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::WaitingPayment.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let taker_bond = insert_bond(&pool, root.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Both); + let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg)) + .await + .unwrap(); + + let child = result.expect("range maker timeout must report the child slash row"); + assert_eq!(child.parent_bond_id, Some(parent.id)); + assert_eq!(child.child_order_id, Some(root.id)); + assert_eq!(child.amount_sats, 400, "proportional slice share"); + assert_eq!(child.state, BondState::PendingPayout.to_string()); + assert_eq!(child.slashed_reason.as_deref(), Some("timeout")); + assert_eq!(child.pubkey, maker_pk(), "the maker is the slashed party"); + + assert!( + ln.calls().is_empty(), + "the parent HTLC must NOT be settled mid-range (settle-at-close)" + ); + let parent_row = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(parent_row.state, BondState::Locked.to_string()); + assert_eq!(parent_row.slashed_share_sats, 400); + assert_eq!( + read_bond_state(&pool, taker_bond.id).await, + BondState::Released.to_string(), + "taker bond released on the terminal cancel" + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_resolves_root_bond_from_descendant_slice() { + // The maker bond lives on the range *root*; a timeout on a + // descendant slice (range remainder spawned by an earlier release) + // must walk `range_parent_id` to find it and record the child slash + // against the slice's own order id. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 0, 10, 100); + insert_range_order_row(&pool, &root).await; + let mut slice = range_slice(Kind::Sell, maker_pk(), taker_pk(), 25, 10, 100); + slice.status = Status::WaitingPayment.to_string(); + slice.range_parent_id = Some(root.id); + insert_range_order_row(&pool, &slice).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &slice, Some(&cfg)) + .await + .unwrap(); + + let child = result.expect("slice timeout must resolve the root's maker bond"); + assert_eq!(child.parent_bond_id, Some(parent.id)); + assert_eq!(child.child_order_id, Some(slice.id)); + assert_eq!(child.amount_sats, 250, "25/100 of the 1000-sat bond"); + assert!(ln.calls().is_empty()); + assert_eq!( + read_bond_state(&pool, parent.id).await, + BondState::Locked.to_string() + ); + } + + #[tokio::test] + async fn timeout_range_maker_slash_is_idempotent_per_slice() { + // A slice whose share was already slashed (e.g. by a dispute, or by + // a prior tick whose order-persist failed) must not be slashed + // twice, and โ€” crucially for the notice โ€” the dispatch must report + // None so the maker is never re-notified for the same slash. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::WaitingPayment.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + // Pre-existing child slash for this same slice. + let inserted = record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + assert!(inserted, "fixture insert must land"); + let mut ln = StubSettle::new(); + + let cfg = timeout_cfg(true, true, BondApplyTo::Make); + let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg)) + .await + .unwrap(); + + assert!( + result.is_none(), + "an already-slashed slice must not re-notify" + ); + assert!(ln.calls().is_empty()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "no duplicate child row"); + assert_eq!( + children[0].slashed_reason.as_deref(), + Some("lost-dispute"), + "the original slash row is untouched" + ); + } + #[tokio::test] async fn timeout_no_config_releases_bond() { // No [anti_abuse_bond] block (cfg = None): the dispatch still @@ -1607,4 +2969,1137 @@ mod tests { "BondSlashed must be enqueued to the slashed taker only" ); } + + // โ”€โ”€ Phase 6: range-order maker bond โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + use crate::app::bond::db::{ + find_bond_by_id, find_child_slashes_for_parent, find_maker_bond_for_order, + find_range_root_order, + }; + + /// Insert an order row including the range columns (`min_amount`, + /// `max_amount`, `range_parent_id`) that `insert_order_row` omits. + async fn insert_range_order_row(pool: &Pool, order: &Order) { + sqlx::query( + r#"INSERT INTO orders ( + id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, min_amount, max_amount, + range_parent_id, created_at, expires_at, seller_pubkey, buyer_pubkey + ) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(order.id) + .bind(&order.kind) + .bind(order.id.simple().to_string()) + .bind(&order.status) + .bind(&order.payment_method) + .bind(order.amount) + .bind(&order.fiat_code) + .bind(order.fiat_amount) + .bind(order.min_amount) + .bind(order.max_amount) + .bind(order.range_parent_id) + .bind(order.created_at) + .bind(order.expires_at) + .bind(order.seller_pubkey.as_deref()) + .bind(order.buyer_pubkey.as_deref()) + .execute(pool) + .await + .expect("insert range order"); + } + + /// A range-order slice: `amount = 0`, `min`/`max` set (so + /// `is_range_order()` and the root-`max_amount` check hold). + fn range_slice( + kind: Kind, + seller_pk: &str, + buyer_pk: &str, + fiat_amount: i64, + min: i64, + max: i64, + ) -> Order { + let mut o = fixture_order(kind, seller_pk, buyer_pk); + o.amount = 0; + o.fiat_amount = fiat_amount; + o.min_amount = Some(min); + o.max_amount = Some(max); + o + } + + /// A `Locked` parent maker bond with a settleable preimage and **no** + /// `hash` (so the release path skips the live LND `cancel_hold_invoice` + /// and the settle path is exercised only via the `StubSettle`). + async fn insert_parent_maker_bond( + pool: &Pool, + order_id: Uuid, + pubkey: &str, + amount_sats: i64, + ) -> Bond { + let mut b = Bond::new_requested(order_id, pubkey.to_string(), BondRole::Maker, amount_sats); + b.state = BondState::Locked.to_string(); + b.preimage = Some(stub_preimage()); + b.hash = None; + sqlx_crud::Crud::create(b.clone(), pool).await.unwrap(); + b + } + + #[tokio::test] + async fn record_maker_slice_slash_is_proportional() { + // sell range order: maker = seller. max fiat = 100, slice fiat = 40, + // bond = 1000 โ†’ slash = round(1000 * 40/100) = 400; node 50% = 200. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1); + let c = &children[0]; + assert_eq!(c.amount_sats, 400, "proportional slice share"); + assert_eq!(c.node_share_sats, Some(200)); + assert_eq!(c.state, BondState::PendingPayout.to_string()); + assert_eq!(c.parent_bond_id, Some(parent.id)); + assert_eq!(c.child_order_id, Some(root.id)); + assert_eq!(c.order_id, root.id); + // Maker's slice-side (seller) key, so Phase 3 pays the buyer winner. + assert_eq!(c.pubkey, maker_pk()); + assert!(c.slashed_at.is_some()); + assert!(c.preimage.is_none(), "child shares the parent HTLC"); + + // Parent accumulates the running total but stays Locked (no settle). + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.slashed_share_sats, 400); + assert_eq!(p.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn record_maker_slice_slash_clamps_cumulative_to_bond() { + // Two slices that together exceed the bond must clamp so the + // cumulative slashed share never exceeds `amount_sats`. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // First slice 80/100 โ†’ 800. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + // Reload parent (slashed_share_sats now 800) and slash a *second*, + // distinct slice (its own order row) of another 80/100 โ†’ raw 800, but + // only 200 remaining โ†’ clamp to 200. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100); + insert_range_order_row(&pool, &slice2).await; + record_maker_slice_slash( + &pool, + &slice2, + &root, + &parent, + BondSlashReason::LostDispute, + 0.0, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + let total: i64 = children.iter().map(|c| c.amount_sats).sum(); + assert_eq!(total, 1000, "cumulative slash clamped to the bond amount"); + } + + #[tokio::test] + async fn range_close_no_slashes_releases_maker_bond() { + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let stub = StubSettle::new(); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + assert!( + stub.calls().is_empty(), + "no slice was ever slashed โ†’ the HTLC must be cancelled, not settled" + ); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Released.to_string()); + assert!(find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn range_close_with_one_slash_settles_once_and_refunds_remainder() { + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + // Settled exactly once, with the parent preimage. + assert_eq!(stub.calls(), vec![stub_preimage()]); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "slice slash + maker refund row"); + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("a maker-refund row"); + assert_eq!(refund.amount_sats, 600, "1000 bond - 400 slashed"); + assert_eq!(refund.node_share_sats, Some(0), "full refund to the maker"); + assert_eq!(refund.pubkey, maker_pk()); + assert_eq!(refund.order_id, root.id); + assert_eq!(refund.state, BondState::PendingPayout.to_string()); + + // Idempotent: a second close (parent now Slashed) is a no-op. + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + assert_eq!(stub.calls().len(), 1, "no second settle"); + assert_eq!( + find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .len(), + 2, + "no duplicate refund row" + ); + } + + #[tokio::test] + async fn range_close_crash_after_settle_is_resumed() { + // The settle precedes one atomic state+rows transaction. If the HTLC + // settles but the transaction fails (crash / DB error before commit), + // the parent must stay `Locked` with NO refund row and NO re-anchor โ€” + // so the Locked-keyed reconciliation sweep covers the window โ€” and a + // later retry (mock reports "already settled") completes the close. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + // Snapshot the slice child's pre-close slashed_at to prove it is only + // re-anchored on a committed close. + let slice_before = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .into_iter() + .find(|c| c.child_order_id.is_some()) + .expect("slice child"); + let original_slashed_at = slice_before.slashed_at; + + // Inject a DB failure mid-transaction: a trigger that aborts the + // maker-refund INSERT (the row with parent_bond_id set, child_order_id + // NULL). The slice-slash insert and the CAS/UPDATE are unaffected. + sqlx::query( + "CREATE TRIGGER bond_refund_fail BEFORE INSERT ON bonds \ + WHEN NEW.parent_bond_id IS NOT NULL AND NEW.child_order_id IS NULL \ + BEGIN SELECT RAISE(ABORT, 'injected refund insert failure'); END", + ) + .execute(&pool) + .await + .unwrap(); + + // Settle succeeds, then the transaction aborts โ†’ the whole close errors. + let stub = StubSettle::new(); + let err = resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root).await; + assert!( + err.is_err(), + "a failed close transaction must surface as Err" + ); + assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once"); + + // Parent rolled back to Locked; no refund row; slice child NOT re-anchored. + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Locked.to_string(), + "parent must remain Locked so the sweep retries it" + ); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "no refund row was written"); + assert!(children.iter().all(|c| c.child_order_id.is_some())); + let slice_mid = children + .iter() + .find(|c| c.child_order_id.is_some()) + .unwrap(); + assert_eq!( + slice_mid.slashed_at, original_slashed_at, + "the slice claim window must not re-anchor until the close commits" + ); + + // Remove the injected failure and re-run the close (the sweep's retry). + // The HTLC is already settled, so LND reports "already settled". + sqlx::query("DROP TRIGGER bond_refund_fail") + .execute(&pool) + .await + .unwrap(); + // Backdate the slice child so the close-time re-anchor is observable + // despite 1-second timestamp granularity. + sqlx::query( + "UPDATE bonds SET slashed_at = 1000000 \ + WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + let before_close = Utc::now().timestamp(); + stub.fail_next_with("invoice already settled"); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + // Now fully closed: settle attempted twice, but rows written once. + assert_eq!(stub.calls().len(), 2, "settle re-attempted on retry"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "exactly one refund row after the retry"); + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.amount_sats, 600); + assert_eq!(refund.node_share_sats, Some(0)); + let slice_after = children + .iter() + .find(|c| c.child_order_id.is_some()) + .unwrap(); + assert!( + slice_after.slashed_at.unwrap() >= before_close, + "slice claim window re-anchored at close time once committed (got {:?})", + slice_after.slashed_at + ); + } + + #[tokio::test] + async fn settle_already_settled_is_treated_as_success() { + // A double-settle (LND returns "already settled") is classified as + // success, so a close after a crashed settle still completes normally. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + stub.fail_next_with("invoice already settled"); + resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root) + .await + .unwrap(); + + assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Slashed.to_string()); + let refund = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap() + .into_iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.amount_sats, 600); + } + + #[tokio::test] + async fn apply_range_maker_slash_records_child_without_settling() { + let pool = setup_pool().await; + // sell range order: `slash_seller` targets the maker (seller). + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let stub = StubSettle::new(); + apply_bond_resolution( + &pool, + &mut stub.clone(), + &root, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert!( + stub.calls().is_empty(), + "a range maker slash records a child row but must NOT settle inline" + ); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Locked.to_string()); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0].amount_sats, 400); + assert_eq!(children[0].child_order_id, Some(root.id)); + } + + #[tokio::test] + async fn maker_bond_resolves_from_descendant_slice() { + // The maker bond lives on the range root; a slash on a descendant + // slice must still find it by walking `range_parent_id`. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 30, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let mut c1 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 70); + c1.range_parent_id = Some(root.id); + insert_range_order_row(&pool, &c1).await; + + let found = find_maker_bond_for_order(&pool, &c1) + .await + .unwrap() + .expect("maker bond resolved via the range root"); + assert_eq!(found.id, parent.id); + + let resolved_root = find_range_root_order(&pool, c1.clone()).await.unwrap(); + assert_eq!(resolved_root.id, root.id); + } + + #[tokio::test] + async fn range_close_reanchors_slice_child_claim_window() { + // A child's payout claim window must start at *close* time, not at + // slice-slash time โ€” otherwise a long-open range could forfeit the + // child the instant it becomes payable. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // Backdate the slice child's slashed_at to simulate a range that + // stayed open well past the claim window before closing. + sqlx::query( + "UPDATE bonds SET slashed_at = ? WHERE parent_bond_id = ? AND child_order_id IS NOT NULL", + ) + .bind(1_000_000i64) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + + let before_close = Utc::now().timestamp(); + resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + let slice = children + .iter() + .find(|c| c.child_order_id.is_some()) + .expect("slice child"); + assert!( + slice.slashed_at.unwrap() >= before_close, + "slice child claim window must re-anchor at close time, got {:?}", + slice.slashed_at + ); + } + + #[tokio::test] + async fn record_maker_slice_slash_is_idempotent_per_slice() { + // Recording the same slice twice (e.g. a retry while the parent HTLC + // is still Locked) must NOT insert a second child row or double the + // accumulated share. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + // Reload parent (slashed_share_sats now 400) and replay the slash. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 1, "the slice must be slashed exactly once"); + assert_eq!(children[0].amount_sats, 400); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.slashed_share_sats, 400, "share must not double on replay"); + } + + #[tokio::test] + async fn record_maker_slice_slash_skipped_once_parent_left_locked() { + // Race guard: once the parent close wins its `Locked โ†’ Slashed` CAS and + // settles + refunds the single HTLC, a slice slash that lands afterwards + // must NOT insert a child row โ€” otherwise the scheduler would pay that + // orphan out on top of the already-distributed HTLC. The INSERT's + // `EXISTS (parent still Locked)` guard makes it a no-op (rows_affected = + // 0), independent of the per-slice uniqueness guard. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // Simulate the close having already moved the parent off `Locked`. + sqlx::query("UPDATE bonds SET state = ? WHERE id = ?") + .bind(BondState::Slashed.to_string()) + .bind(parent.id) + .execute(&pool) + .await + .unwrap(); + + // A slice slash arriving after the close is dropped silently. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert!( + children.is_empty(), + "no child row may be inserted once the parent has left Locked" + ); + } + + #[tokio::test] + async fn slice_slash_unique_index_rejects_forced_duplicate() { + // Item 1: the partial UNIQUE index on (parent_bond_id, child_order_id) + // enforces "one slash row per slice" at the schema level, even for a + // caller that bypasses the `INSERT ... WHERE NOT EXISTS` guard. A + // forced raw duplicate insert must fail with a unique violation. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + // First child row via the normal path. + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // A raw insert of a second child row for the same (parent, child) โ€” + // no `WHERE NOT EXISTS` guard โ€” must be rejected by the index. + let now = Utc::now().timestamp(); + let forced = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(root.id) + .bind(maker_pk()) + .bind(BondRole::Maker.to_string()) + .bind(123) + .bind(BondState::PendingPayout.to_string()) + .bind(now) + .execute(&pool) + .await; + let err = forced.expect_err("duplicate child slash must violate the unique index"); + assert!( + is_unique_violation(&err), + "expected a unique-constraint violation, got {err:?}" + ); + + // The maker-refund shape (child_order_id NULL) and parent rows are + // unconstrained: SQLite treats NULLs as distinct, so this succeeds. + let refund = sqlx::query( + "INSERT INTO bonds \ + (id, order_id, parent_bond_id, child_order_id, pubkey, role, \ + amount_sats, state, created_at) \ + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4()) + .bind(root.id) + .bind(parent.id) + .bind(maker_pk()) + .bind(BondRole::Maker.to_string()) + .bind(456) + .bind(BondState::PendingPayout.to_string()) + .bind(now) + .execute(&pool) + .await; + assert!( + refund.is_ok(), + "a maker-refund row (child_order_id NULL) must be unconstrained: {refund:?}" + ); + } + + #[tokio::test] + async fn reconcile_sweep_retries_stranded_locked_parent_after_failed_close() { + // Item 2: a transient settle failure on the first close leaves the + // parent `Locked` even though the range is terminal. The + // reconciliation sweep must retry and drive it to `Slashed`, unblocking + // the slice child rows. + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + // Whole range terminated (cooperatively canceled). + root.status = Status::CooperativelyCanceled.to_string(); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + // First close: settle fails transiently โ†’ parent stays Locked. + let failing = StubSettle::new(); + failing.fail_next_with("transient lnd transport error"); + resolve_range_maker_bond_at_close(&pool, &mut failing.clone(), &root) + .await + .unwrap(); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Locked.to_string(), + "a failed settle must leave the parent retryably Locked" + ); + + // The sweep finds the stranded parent (range tree fully terminal) and + // retries the close with a healthy LND stub. + let healthy = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut healthy.clone()).await; + assert_eq!(resolved, 1, "exactly one stranded parent resolved"); + assert_eq!(healthy.calls(), vec![stub_preimage()], "HTLC settled once"); + + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!( + p.state, + BondState::Slashed.to_string(), + "the sweep drives the parent to Slashed" + ); + // The slice child is now unblocked (parent no longer Locked) and a + // maker-refund row exists. + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + assert_eq!(children.len(), 2, "slice slash + maker refund row"); + assert!(children + .iter() + .all(|c| c.state == BondState::PendingPayout.to_string())); + } + + #[tokio::test] + async fn reconcile_sweep_skips_open_range() { + // The sweep must never disturb a legitimately-open range whose maker + // bond is `Locked` by design (a slice can still be taken). + let pool = setup_pool().await; + let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + root.status = Status::Active.to_string(); // still on the book + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + + let stub = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await; + assert_eq!(resolved, 0, "an open range must not be swept"); + assert!(stub.calls().is_empty(), "no HTLC touched"); + let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + assert_eq!(p.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn reconcile_sweep_isolates_a_bad_root_and_processes_the_rest() { + // Per-root isolation (ยง8.2): a `Locked` maker parent whose range-root + // order can't be resolved (here: a missing order row โ†’ `Order::by_id` + // returns None) must only skip that root, never abort the tick and + // starve every other stranded bond. The tree-check error path shares + // the same `continue`. + let pool = setup_pool().await; + + // Bad root: a Locked maker parent bond whose order_id has no order row + // (a corrupt/partially-deleted state). FK enforcement is on by + // default, so drop it just for this orphan insert to simulate that. + let orphan_order_id = Uuid::new_v4(); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&pool) + .await + .unwrap(); + let bad = insert_parent_maker_bond(&pool, orphan_order_id, maker_pk(), 1000).await; + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await + .unwrap(); + + // Good root: a fully-terminal range with one slashed slice โ€” stranded + // and genuinely resolvable. + let mut good = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + good.status = Status::CooperativelyCanceled.to_string(); + insert_range_order_row(&pool, &good).await; + let good_parent = insert_parent_maker_bond(&pool, good.id, maker_pk(), 1000).await; + record_maker_slice_slash( + &pool, + &good, + &good, + &good_parent, + BondSlashReason::LostDispute, + 0.5, + ) + .await + .unwrap(); + + let stub = StubSettle::new(); + let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await; + + // The good root was still processed despite the bad root. + assert_eq!(resolved, 1, "the valid stranded root must still resolve"); + let gp = find_bond_by_id(&pool, good_parent.id) + .await + .unwrap() + .unwrap(); + assert_eq!(gp.state, BondState::Slashed.to_string()); + // The bad root's bond is untouched (still Locked, never settled). + let bp = find_bond_by_id(&pool, bad.id).await.unwrap().unwrap(); + assert_eq!(bp.state, BondState::Locked.to_string()); + } + + #[tokio::test] + async fn range_close_conserves_sats_across_two_slices() { + // Item 3: wallet-accounting invariant. With 2 distinct slices slashed + // (exercising accumulation + rounding), the sum of every child row's + // amount_sats (slice slashes + maker refund) equals the parent bond + // exactly, and node retention (ฮฃ node_share_sats) + counterparty + // payouts (ฮฃ amount - node_share) == the parent bond. The maker refund + // absorbs any rounding remainder by construction (refund = bond - + // ฮฃ slice_slashes), so no sat is created or lost. + let pool = setup_pool().await; + // max fiat 7, bond 1000: slice fiat 2 โ†’ round(1000*2/7)=286, + // slice fiat 3 โ†’ round(1000*3/7)=429. Both round (285.71 / 428.57), + // so the refund (1000-715=285) must absorb the remainder. + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 2, 1, 7); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let node_pct = 0.3; + + record_maker_slice_slash( + &pool, + &root, + &root, + &parent, + BondSlashReason::LostDispute, + node_pct, + ) + .await + .unwrap(); + // Second, distinct slice (own order row); reload parent for the + // updated running total. + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 3, 1, 7); + insert_range_order_row(&pool, &slice2).await; + record_maker_slice_slash( + &pool, + &slice2, + &root, + &parent, + BondSlashReason::LostDispute, + node_pct, + ) + .await + .unwrap(); + + resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root) + .await + .unwrap(); + + let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap(); + let children = find_child_slashes_for_parent(&pool, parent.id) + .await + .unwrap(); + // 2 slice slashes + 1 maker refund. + assert_eq!(children.len(), 3, "two slices + maker refund"); + + let total_child_sats: i64 = children.iter().map(|c| c.amount_sats).sum(); + assert_eq!( + total_child_sats, parent.amount_sats, + "ฮฃ child amount_sats (slices + refund) must equal the parent bond exactly" + ); + + let node_retention: i64 = children + .iter() + .map(|c| c.node_share_sats.unwrap_or(0)) + .sum(); + let counterparty_total: i64 = children + .iter() + .map(|c| c.amount_sats - c.node_share_sats.unwrap_or(0)) + .sum(); + assert_eq!( + node_retention + counterparty_total, + parent.amount_sats, + "no sat created or lost: node retention + counterparty payouts == bond" + ); + // The refund row carries the unslashed remainder (bond minus the two + // slice slashes), full value to the maker โ€” this is where the rounding + // remainder lands. + let refund = children + .iter() + .find(|c| c.child_order_id.is_none()) + .expect("maker refund row"); + assert_eq!(refund.node_share_sats, Some(0)); + let slice_slash_total: i64 = children + .iter() + .filter(|c| c.child_order_id.is_some()) + .map(|c| c.amount_sats) + .sum(); + assert_eq!( + refund.amount_sats, + parent.amount_sats - slice_slash_total, + "refund = bond - ฮฃ slice slashes (absorbs the rounding remainder)" + ); + } + + // โ”€โ”€ #768 โ€” dispute-slash forfeiture notice (apply return value) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[tokio::test] + async fn apply_slash_buyer_returns_confirmed_taker_row() { + // A confirmed taker-bond slash is returned so the caller can send a + // BondSlashed notice carrying the full bond amount. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert_eq!(slashed.len(), 1, "exactly the buyer's bond is returned"); + assert_eq!(slashed[0].id, bond.id); + assert_eq!( + slashed[0].amount_sats, bond.amount_sats, + "full bond amount carried for the notice" + ); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::PendingPayout.to_string() + ); + } + + #[tokio::test] + async fn apply_slash_both_returns_two_confirmed_rows() { + // Both bonds slashed โ†’ both returned, so both parties are notified. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let maker_bond = insert_bond_with_role( + &pool, + order.id, + maker_pk(), + BondRole::Maker, + BondState::Locked, + ) + .await; + let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: true, + }; + + let mut slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + slashed.sort_by_key(|b| b.id); + let mut expected = vec![maker_bond.id, taker_bond.id]; + expected.sort(); + assert_eq!( + slashed.iter().map(|b| b.id).collect::>(), + expected, + "both slashed bonds are returned" + ); + } + + #[tokio::test] + async fn apply_null_payload_returns_no_rows() { + // No slash directive โ†’ nothing to notify; the bond is released. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: false, + }; + + let slashed = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + + assert!(slashed.is_empty(), "no slash โ†’ no notice"); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::Released.to_string() + ); + } + + #[tokio::test] + async fn apply_transient_settle_failure_returns_no_row() { + // Load-bearing: a transient settle failure leaves the bond Locked, + // so the slash is unconfirmed and MUST NOT be returned โ€” the caller + // never tells a user their bond was forfeited while the HTLC is + // still theirs. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let mut ln = StubSettle::new(); + ln.fail_next_with("code=Unavailable: connection refused"); + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let slashed = + apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + + assert!(slashed.is_empty(), "unconfirmed slash yields no notice"); + assert_eq!( + read_bond_state(&pool, bond.id).await, + BondState::Locked.to_string(), + "bond stays Locked for an admin retry" + ); + } + + #[tokio::test] + async fn apply_idempotent_retry_returns_no_row() { + // Non-range admin retry: the first apply slashes and reports the + // bond; a second apply finds it already PendingPayout (no longer + // active), so nothing is returned and the winner is never + // re-notified. + let pool = setup_pool().await; + let order = fixture_order(Kind::Sell, maker_pk(), taker_pk()); + insert_order_row(&pool, &order).await; + insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await; + let res = BondResolution { + slash_seller: false, + slash_buyer: true, + }; + + let first = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + assert_eq!(first.len(), 1); + + let second = apply_bond_resolution( + &pool, + &mut StubSettle::new(), + &order, + &res, + BondSlashReason::LostDispute, + ) + .await + .unwrap(); + assert!( + second.is_empty(), + "retry on a PendingPayout bond yields no notice" + ); + } + + #[tokio::test] + async fn apply_range_maker_slash_returns_child_with_slice_amount() { + // Phase 6 ร— #768: a dispute slash of a range maker bond returns the + // child slice row (slice fiat 40 / max 100 ร— 1000 = 400), not the + // full parent bond, and leaves the parent HTLC Locked. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let mut ln = StubSettle::new(); + + let slashed = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + + assert_eq!(slashed.len(), 1); + assert_eq!(slashed[0].parent_bond_id, Some(parent.id)); + assert_eq!(slashed[0].child_order_id, Some(root.id)); + assert_eq!(slashed[0].amount_sats, 400, "slice share, not full bond"); + assert_eq!( + slashed[0].pubkey, + maker_pk(), + "the maker is the slashed party" + ); + assert!( + ln.calls().is_empty(), + "parent HTLC is settled only at range close, never inline" + ); + assert_eq!( + read_bond_state(&pool, parent.id).await, + BondState::Locked.to_string() + ); + } + + #[tokio::test] + async fn apply_range_maker_slash_idempotent_retry_returns_no_row() { + // A re-applied dispute resolution (admin retry) must not re-notify: + // the slice child already exists, so record_maker_slice_slash inserts + // nothing and no row is returned. + let pool = setup_pool().await; + let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100); + insert_range_order_row(&pool, &root).await; + insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await; + let res = BondResolution { + slash_seller: true, + slash_buyer: false, + }; + let mut ln = StubSettle::new(); + + let first = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + assert_eq!(first.len(), 1, "first apply records and reports the slice"); + + let second = + apply_bond_resolution(&pool, &mut ln, &root, &res, BondSlashReason::LostDispute) + .await + .unwrap(); + assert!(second.is_empty(), "retry must not re-notify the maker"); + } } diff --git a/src/app/cancel.rs b/src/app/cancel.rs index f1199ce6..f79c4f36 100644 --- a/src/app/cancel.rs +++ b/src/app/cancel.rs @@ -146,9 +146,12 @@ async fn cancel_cooperative_execution_step_2( ) .await; - // Phase 1: cooperative cancel always releases any taker bond. The - // dispute slash path lands in Phase 2. - bond::release_bonds_for_order_or_warn(pool, order.id, "cooperative_cancel").await; + // Phase 1/6: cooperative cancel releases any taker bond and resolves + // the maker bond at range close (Phase 6 settle-at-close if earlier + // slices were slashed, else release; the close helper also covers the + // non-range maker bond via its non-range branch). + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "cooperative_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "cooperative_cancel").await; Ok(()) } @@ -235,8 +238,16 @@ async fn cancel_order_by_taker( // Look at what's left on the order. If other concurrent takers // still have active bonds, do NOT reset the order โ€” they are // still racing. Just message the sender that their take is cancelled. + // + // Phase 5: scope this to *taker* bonds. Under `apply_to = both` the + // order also carries a `Locked` maker bond (pubkey != the cancelling + // taker), which must not count as "another taker still racing" โ€” that + // would wrongly keep the order in `WaitingTakerBond` and prevent it + // from dropping back to `Pending` when the last taker backs out. let remaining = crate::app::bond::db::find_active_bonds_for_order(pool, order_id).await?; - let others_remain = remaining.iter().any(|b| b.pubkey != sender_str); + let others_remain = remaining + .iter() + .any(|b| b.pubkey != sender_str && b.role == crate::app::bond::BondRole::Taker.to_string()); if others_remain { enqueue_order_msg( request_id, @@ -366,9 +377,12 @@ async fn cancel_order_by_maker( ) .await; - // Phase 1: maker cancelled before the trade went active โ€” release any - // taker bond that had already been locked. - bond::release_bonds_for_order_or_warn(pool, order.id, "maker_cancel").await; + // Phase 1/6: maker cancelled before the trade went active โ€” release any + // taker bond that had already been locked, and resolve the maker bond at + // range close (release when no slice was slashed; settle-at-close + // otherwise). + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "maker_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "maker_cancel").await; Ok(()) } @@ -449,7 +463,8 @@ async fn cancel_pending_order_from_maker( ); } } - bond::release_bonds_for_order_or_warn(pool, order.id, "pending_maker_cancel").await; + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "pending_maker_cancel").await; + bond::resolve_range_maker_bond_at_close_or_warn(pool, order, "pending_maker_cancel").await; Ok(()) } diff --git a/src/app/dispute.rs b/src/app/dispute.rs index 51c4a76d..27b62a4d 100644 --- a/src/app/dispute.rs +++ b/src/app/dispute.rs @@ -159,7 +159,7 @@ pub async fn dispute_action( let order_id = if let Some(order_id) = msg.get_inner_message_kind().id { order_id } else { - return Err(MostroInternalErr(ServiceError::InvalidOrderId)); + return Err(MostroCantDo(CantDoReason::NotFound)); }; // Check dispute for this order id is yet present. if find_dispute_by_order_id(pool, order_id).await.is_ok() { diff --git a/src/app/release.rs b/src/app/release.rs index f52ac3d9..22be63b5 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -1,6 +1,7 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dispute::close_dispute_after_user_resolution; +use crate::config::settings::Settings; use crate::escrow::EscrowBackend; use crate::lightning::LndConnector; use crate::lnurl::resolv_ln_address; @@ -189,35 +190,156 @@ pub async fn release_action( .get_next_trade_key() .map_err(MostroInternalErr)?; - // Settle seller hold invoice - settle_seller_hold_invoice(event, ln_client, Action::Released, false, &order).await?; - // Update order event with status SettledHoldInvoice - order = update_order_event(my_keys, Status::SettledHoldInvoice, &order) + let is_cashu = Settings::is_cashu_enabled() && order.cashu_escrow_token.is_some(); + + if is_cashu { + // Cashu flow: there is no Lightning hold invoice to settle. Mostro only + // coordinates the 2-of-3 escrow, so "release" means handing the buyer + // the P_M (Mostro) signatures they need to redeem the escrowed ecash and + // advancing the order to Success. + // + // Compute the signatures *before* mutating any state. If anything here + // fails we return early and leave the order in FiatSent/Dispute so the + // seller can retry โ€” rather than marking the order Success and notifying + // the buyer of a completed purchase they cannot actually claim. + let token_str = order.cashu_escrow_token.as_ref().ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "cashu_escrow_token missing on order in Cashu mode".to_string(), + )) + })?; + + let token = cdk::nuts::Token::from_str(token_str).map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to parse Cashu escrow token: {e}" + ))) + })?; + + // The node's Nostr secret key doubles as the Cashu P_M signing key. + let p_m_secret = cdk::nuts::nut01::SecretKey::from_str( + &my_keys.secret_key().to_secret_hex(), + ) + .map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to derive Cashu P_M signing key from node key: {e}" + ))) + })?; + + let mut pm_signatures = Vec::new(); + for secret in token.token_secrets() { + let msg = secret.to_bytes(); + let sig = p_m_secret.sign(&msg).map_err(|e| { + MostroInternalErr(ServiceError::UnexpectedError(format!( + "Failed to sign Cashu proof secret for order {}: {e}", + order.id + ))) + })?; + pm_signatures.push(mostro_core::message::CashuProofSignature::new( + secret.to_string(), + sig.to_string(), + )); + } + + // A token with no proofs to sign means the buyer would receive a + // PurchaseCompleted with no way to redeem the escrow โ€” treat it as a + // hard error so the order stays retryable instead of silently stuck. + if pm_signatures.is_empty() { + return Err(MostroInternalErr(ServiceError::UnexpectedError(format!( + "Cashu escrow token for order {} contained no proof secrets to sign", + order.id + )))); + } + + // Signatures are ready โ€” now it is safe to commit the Success transition. + order = update_order_event(my_keys, Status::Success, &order) + .await + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + let result = sqlx::query( + "UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)", + ) + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) .await - .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; - - // Persist the status change to DB before calling do_payment. - // do_payment spawns async tasks that capture an Order copy; without this - // explicit write the settled-hold-invoice status only lived in memory and - // was persisted as a side-effect of the full-row writes in - // check_failure_retries / payment_success (now replaced by targeted updates). - let result = - sqlx::query("UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)") - .bind(&order.status) - .bind(&order.event_id) - .bind(order.id) - .bind(Status::FiatSent.to_string()) - .bind(Status::Dispute.to_string()) - .execute(pool) + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 0 { + tracing::warn!( + "Order {} not transitioned to success: status changed concurrently", + order.id + ); + return Ok(()); + } + + // Notify the buyer the purchase completed, then deliver the P_M + // signatures "just in case" the seller forgot to DM their own signature + // to the buyer via NIP-59. + enqueue_order_msg( + None, + Some(order.id), + Action::PurchaseCompleted, + None, + buyer_pubkey, + None, + ) + .await; + + enqueue_order_msg( + request_id, + Some(order.id), + Action::CashuPmSignature, + Some(Payload::CashuSignatures(pm_signatures)), + buyer_pubkey, + None, + ) + .await; + + // Send dm to buyer to rate counterpart + enqueue_order_msg( + request_id, + Some(order.id), + Action::Rate, + None, + buyer_pubkey, + None, + ) + .await; + } else { + // Lightning flow + // Settle seller hold invoice + settle_seller_hold_invoice(event, ln_client, Action::Released, false, &order).await?; + // Update order event with status SettledHoldInvoice + order = update_order_event(my_keys, Status::SettledHoldInvoice, &order) .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - - if result.rows_affected() == 0 { - tracing::warn!( - "Order {} not transitioned to settled-hold-invoice: status changed concurrently", - order.id - ); - return Ok(()); + .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?; + + // Persist the status change to DB before calling do_payment. + // do_payment spawns async tasks that capture an Order copy; without this + // explicit write the settled-hold-invoice status only lived in memory and + // was persisted as a side-effect of the full-row writes in + // check_failure_retries / payment_success (now replaced by targeted updates). + let result = sqlx::query( + "UPDATE orders SET status = ?, event_id = ? WHERE id = ? AND status IN (?, ?)", + ) + .bind(&order.status) + .bind(&order.event_id) + .bind(order.id) + .bind(Status::FiatSent.to_string()) + .bind(Status::Dispute.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + + if result.rows_affected() == 0 { + tracing::warn!( + "Order {} not transitioned to settled-hold-invoice: status changed concurrently", + order.id + ); + return Ok(()); + } } // If there was an active dispute on this order, close it since the seller @@ -225,39 +347,70 @@ pub async fn release_action( close_dispute_after_user_resolution(ctx, &order, DisputeStatus::Settled, my_keys, "release") .await; - enqueue_order_msg( - None, - Some(order.id), - Action::Released, - None, - buyer_pubkey, - None, - ) - .await; + // In the Cashu flow the buyer was already told the purchase completed + // (PurchaseCompleted) and given the redeem signatures; the Lightning-only + // "Released" notification would be a redundant/conflicting message. + if !is_cashu { + enqueue_order_msg( + None, + Some(order.id), + Action::Released, + None, + buyer_pubkey, + None, + ) + .await; + } - // Handle child order for range orders - if let Ok((Some(child_order), Some(event))) = get_child_order(ctx, order.clone(), my_keys).await - { - let client = ctx.nostr_client(); - if client.send_event(&event).await.is_err() { - tracing::warn!("Failed sending child order event for order id: {}. This may affect order synchronization", child_order.id) + // Handle child order for range orders. A spawned remainder means the + // range continues, so the maker stays committed and its bond stays + // `Locked`. No remainder means the range is fully consumed (or this was + // a fixed-amount order) โ€” resolve the maker bond at close (Phase 6 + // settle-at-close, or the Phase 5 release for a non-range maker bond). + match get_child_order(ctx, order.clone(), my_keys).await { + Ok((Some(child_order), Some(event))) => { + let client = ctx.nostr_client(); + if client.send_event(&event).await.is_err() { + tracing::warn!("Failed sending child order event for order id: {}. This may affect order synchronization", child_order.id) + } + handle_child_order(child_order, &order, next_trade, ctx.pool(), request_id) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + } + Ok(_) => { + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "release_action").await; + } + Err(e) => { + // `get_child_order` only *computes* the remainder (it neither + // persists nor publishes a child), so on error no remainder + // exists on the book โ€” the range has effectively ended. Resolve + // the maker bond at close rather than leaving it Locked until + // the LND CLTV safety net. (mostro does not retry child-order + // creation anywhere; the lost remainder is a pre-existing + // limitation, logged here.) + tracing::warn!( + order_id = %order.id, + error = %e, + "get_child_order failed; resolving maker bond at close (no remainder was created)" + ); + bond::resolve_range_maker_bond_at_close_or_warn(pool, &order, "release_action").await; } - handle_child_order(child_order, &order, next_trade, ctx.pool(), request_id) - .await - .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; } // We send a HoldInvoicePaymentSettled message to seller, the client should - // indicate *funds released* message to seller - enqueue_order_msg( - request_id, - Some(order.id), - Action::HoldInvoicePaymentSettled, - None, - seller_pubkey, - None, - ) - .await; + // indicate *funds released* message to seller. This is Lightning-specific + // (there is no hold invoice in the Cashu flow), so skip it for Cashu. + if !is_cashu { + enqueue_order_msg( + request_id, + Some(order.id), + Action::HoldInvoicePaymentSettled, + None, + seller_pubkey, + None, + ) + .await; + } // We send a message to seller indicating seller released funds enqueue_order_msg( @@ -270,14 +423,17 @@ pub async fn release_action( ) .await; - // Phase 1: release any taker bond attached to this order before we - // hand off to the buyer payment task. Slashing is intentionally not - // wired in yet โ€” that's Phase 2+. A failed bond release is logged but - // does not block trade finalization. - bond::release_bonds_for_order_or_warn(pool, order.id, "release_action").await; + // Phase 1/6: release the taker bond(s) on this slice. The maker bond is + // handled by `resolve_range_maker_bond_at_close_or_warn` above โ€” its + // single HTLC may span a whole range, so releasing it here would + // wrongly cancel a bond still committed to a continuing range. A failed + // bond release is logged but does not block trade finalization. + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "release_action").await; - // Finally we try to pay buyer's invoice - let _ = do_payment(ctx, order, request_id).await; + if !is_cashu { + // Finally we try to pay buyer's invoice + let _ = do_payment(ctx, order, request_id).await; + } Ok(()) } diff --git a/src/app/take_buy.rs b/src/app/take_buy.rs index 0ce04b8b..638377a5 100644 --- a/src/app/take_buy.rs +++ b/src/app/take_buy.rs @@ -53,8 +53,8 @@ pub async fn take_buy_action( // handler doesn't release prior bonds at retake-time anymore โ€” // multiple `Requested` taker bonds coexist on the order and the // first to reach `Locked` wins. We still need three guards here: - // 1. A `Locked` bond already on the order means the trade is - // committed; reject with `PendingOrderExists`. + // 1. A `Locked` *taker* bond already on the order means the + // trade is committed; reject with `PendingOrderExists`. // 2. The sender's own pubkey already has a `Requested` bond on // this order โ†’ idempotent retry: re-send the same // `PayInvoice` message and return. @@ -63,13 +63,16 @@ pub async fn take_buy_action( // path; that context lives on the bond row's `taker_*` columns // until the winning bond locks and // `on_bond_invoice_accepted` promotes it onto the order. + // + // Phase 5: with `apply_to = both` the maker's own bond is already + // `Locked` on every published order โ€” that is the normal state, not + // a committed trade. The committed-trade gate must therefore only + // count `Locked` *taker* bonds; otherwise a locked maker bond would + // wrongly block every taker with `PendingOrderExists`. let bond_required = bond::taker_bond_required(); if bond_required { let active = crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await?; - if active - .iter() - .any(|b| b.state == crate::app::bond::BondState::Locked.to_string()) - { + if bond::trade_committed_by_locked_taker_bond(&active) { return Err(MostroCantDo(CantDoReason::PendingOrderExists)); } let sender_str = event.sender.to_string(); diff --git a/src/app/take_sell.rs b/src/app/take_sell.rs index 804915e1..62b059a4 100644 --- a/src/app/take_sell.rs +++ b/src/app/take_sell.rs @@ -77,8 +77,8 @@ pub async fn take_sell_action( // multiple `Requested` taker bonds coexist on the order and the // first to reach `Locked` wins. Three guards before this take // proceeds: - // 1. A `Locked` bond already on the order means the trade is - // committed; reject with `PendingOrderExists`. + // 1. A `Locked` *taker* bond already on the order means the + // trade is committed; reject with `PendingOrderExists`. // 2. The sender's own pubkey already has a `Requested` bond on // this order โ†’ idempotent retry: re-send the same // `PayInvoice` message and return. @@ -87,13 +87,16 @@ pub async fn take_sell_action( // The order's taker fields are not mutated under the bond path โ€” // they're stashed on the bond row's `taker_*` columns until the // winning bond locks. + // + // Phase 5: with `apply_to = both` the maker's own bond is already + // `Locked` on every published order โ€” that is the normal state, not + // a committed trade. The committed-trade gate must therefore only + // count `Locked` *taker* bonds; otherwise a locked maker bond would + // wrongly block every taker with `PendingOrderExists`. let bond_required = bond::taker_bond_required(); if bond_required { let active = crate::app::bond::db::find_active_bonds_for_order(pool, order.id).await?; - if active - .iter() - .any(|b| b.state == crate::app::bond::BondState::Locked.to_string()) - { + if bond::trade_committed_by_locked_taker_bond(&active) { return Err(MostroCantDo(CantDoReason::PendingOrderExists)); } let sender_str = event.sender.to_string(); diff --git a/src/bitcoin_price.rs b/src/bitcoin_price.rs index 5460888f..200e25ce 100644 --- a/src/bitcoin_price.rs +++ b/src/bitcoin_price.rs @@ -1,423 +1,54 @@ -use crate::config::settings::Settings; -use crate::lnurl::HTTP_CLIENT; -use crate::nip33::new_exchange_rates_event; -use crate::util::{get_keys, get_nostr_client}; -use chrono::Utc; -use mostro_core::prelude::*; -use nostr_sdk::prelude::*; -use once_cell::sync::Lazy; -use serde::Deserialize; -use std::collections::HashMap; -use std::sync::RwLock; -use tracing::{error, info, warn}; - -#[derive(Debug, Deserialize)] -struct YadioResponse { - // Yadio reports `null` for currencies it currently has no rate for - // (observed: `"BGN": null`). A strict `HashMap` makes serde - // reject the *entire* response on the first null, taking every currency - // down with it. Parse leniently as `Option` and drop the bad - // entries in `update_prices`. - #[serde(rename = "BTC")] - btc: HashMap>, -} +//! Legacy `BitcoinPriceManager` shim (spec ยง9 Phase 1 / ยง10.1). +//! +//! The real price logic lives in [`crate::price`] from Phase 1 onward. +//! This module survives only as a thin `get_price` delegate so any +//! downstream caller still referring to `BitcoinPriceManager` keeps +//! compiling; the type itself is scheduled for removal in Phase 5 once +//! every consumer reads through `PriceManager` directly. +#![allow(dead_code)] -static BITCOIN_PRICES: Lazy>> = - Lazy::new(|| RwLock::new(HashMap::new())); +use mostro_core::prelude::*; pub struct BitcoinPriceManager; impl BitcoinPriceManager { - pub async fn update_prices() -> Result<(), MostroError> { - let mostro_settings = Settings::get_mostro(); - let api_url = format!("{}/exrates/BTC", mostro_settings.bitcoin_price_api_url); - let response = HTTP_CLIENT - .get(&api_url) - .send() - .await - .map_err(|_| MostroInternalErr(ServiceError::NoAPIResponse))?; - let yadio_response: YadioResponse = response - .json() - .await - .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; - - // Keep only currencies with a usable rate: drop `null` (currencies - // Yadio has no price for) and any non-finite / non-positive value. - let rates_clone: HashMap = yadio_response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - - // A response with zero usable rates (everything null/invalid, or an - // empty BTC object) must NOT overwrite the cache โ€” that would turn a - // transient provider hiccup into total price unavailability for every - // currency. Keep the last known prices and try again next tick. - if rates_clone.is_empty() { - warn!("Yadio returned no usable BTC rates; keeping previously cached prices"); - return Ok(()); - } - - info!( - "Bitcoin prices updated. Got BTC price in {} fiat currencies", - rates_clone.len() - ); - - { - let mut prices_write = BITCOIN_PRICES - .write() - .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - *prices_write = rates_clone.clone(); - } // Lock is dropped here - - // Publish rates to Nostr if enabled (after releasing the lock) - if mostro_settings.publish_exchange_rates_to_nostr { - if let Err(e) = Self::publish_rates_to_nostr(&rates_clone).await { - error!("Failed to publish exchange rates to Nostr: {}", e); - // Don't fail the entire update if Nostr publishing fails - } - } - - Ok(()) - } - - /// Publishes exchange rates to Nostr as a NIP-33 addressable event (kind 30078) - async fn publish_rates_to_nostr(rates: &HashMap) -> Result<(), MostroError> { - let keys = get_keys().map_err(|e| { - error!("Failed to get Mostro keys: {}", e); - MostroInternalErr(ServiceError::IOError(e.to_string())) - })?; - - // Publish in Yadio's exact format: {"BTC": {"USD": 50000.0, "EUR": 45000.0, ...}} - // This matches their API response structure - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), rates.clone()); - let formatted_rates = wrapper; - - let content = serde_json::to_string(&formatted_rates) - .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; - - let timestamp = Utc::now().timestamp(); - - // Expiration should be at least 2x the update interval to allow for delays - // Cap at 1 hour to prevent stale data - // Note: We read settings here (instead of passing from scheduler) to ensure - // expiration stays aligned with interval if config is reloaded at runtime - let mostro_settings = Settings::get_mostro(); - let update_interval = mostro_settings.exchange_rates_update_interval_seconds; - let expiration_seconds = std::cmp::min(update_interval * 2, 3600); - let expiration = timestamp + expiration_seconds as i64; - - let tags = Tags::from_list(vec![ - Tag::custom( - TagKind::Custom("published_at".into()), - vec![timestamp.to_string()], - ), - Tag::custom(TagKind::Custom("source".into()), vec!["yadio".to_string()]), - Tag::expiration(Timestamp::from(expiration as u64)), - ]); - - let event = new_exchange_rates_event(&keys, &content, tags).map_err(|e| { - error!("Failed to create exchange rates event: {}", e); - MostroInternalErr(ServiceError::MessageSerializationError) - })?; - - let client = get_nostr_client().map_err(|e| { - error!("Failed to get Nostr client: {}", e); - e - })?; - - // Publish with timeout to avoid blocking the scheduler - // Best-effort: log errors but don't fail the update job - let timeout_duration = std::time::Duration::from_secs(30); - match tokio::time::timeout(timeout_duration, client.send_event(&event)).await { - Ok(Ok(output)) => { - info!( - "Exchange rates published to Nostr ({} currencies). Output: {:?}", - rates.len(), - output - ); - } - Ok(Err(e)) => { - error!("Failed to send exchange rates event to relays: {}", e); - } - Err(_) => { - error!("Timeout publishing exchange rates to Nostr (30s exceeded)"); - } - } - - // Always return Ok - publishing is best-effort - Ok(()) - } - + /// Delegates to [`crate::price::get_bitcoin_price`]. Behaviour is + /// identical to the legacy implementation: an uppercase ISO-4217 code + /// returns the per-BTC value if the global [`crate::price::PriceManager`] + /// has it, otherwise `Err(NoAPIResponse)` (matching "no data yet" in + /// the pre-Phase-1 world). pub fn get_price(currency: &str) -> Result { - let prices_read: std::sync::RwLockReadGuard<'_, HashMap> = BITCOIN_PRICES - .read() - .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - prices_read - .get(currency) - .cloned() - .ok_or(MostroInternalErr(ServiceError::NoAPIResponse)) + crate::price::get_bitcoin_price(currency) + } + + /// Test-only: seed the price-override map consulted by + /// [`crate::price::get_bitcoin_price`] so unit tests in other modules + /// can exercise price-dependent paths (e.g. range-order bond sizing) + /// deterministically without hitting the network or installing the + /// global `PriceManager`. Use a unique `currency` per test to avoid + /// cross-test interference on the shared map. + #[cfg(test)] + pub(crate) fn set_price_for_test(currency: &str, price: f64) { + crate::price::test_price_overrides() + .write() + .expect("price override write lock") + .insert(currency.to_string(), price); } } #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; - - #[test] - fn test_rates_structure() { - // Test that Yadio rates are wrapped correctly - let mut input_rates = HashMap::new(); - input_rates.insert("USD".to_string(), 50000.0); - input_rates.insert("EUR".to_string(), 45000.0); - - // Wrap in Yadio format: {"BTC": {...}} - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), input_rates.clone()); - - assert_eq!(wrapper.len(), 1); - assert!(wrapper.contains_key("BTC")); - assert_eq!(wrapper.get("BTC").unwrap().get("USD"), Some(&50000.0)); - assert_eq!(wrapper.get("BTC").unwrap().get("EUR"), Some(&45000.0)); - } #[test] - fn test_rates_json_serialization() { - // Test that rates can be serialized to Yadio format - // Use only fiat currencies (Yadio includes BTC in the wrapper, not in the rates map) - let mut input_rates = HashMap::new(); - input_rates.insert("USD".to_string(), 50000.0); - input_rates.insert("EUR".to_string(), 45000.0); - - let mut wrapper = HashMap::new(); - wrapper.insert("BTC".to_string(), input_rates); - - let json = serde_json::to_string(&wrapper).unwrap(); - assert!(json.contains("\"BTC\"")); - assert!(json.contains("\"USD\"")); - assert!(json.contains("50000")); - assert!(json.contains("\"EUR\"")); - assert!(json.contains("45000")); - // Ensure we don't have nested BTC key (would be invalid) - assert!(!json.contains("\"BTC\":1")); - } - - #[test] - fn test_yadio_response_deserialization() { - // Test that we can deserialize the expected API response format - let json_response = r#" - { - "BTC": { - "USD": 50000.0, - "EUR": 45000.0, - "GBP": 40000.0 - } - } - "#; - - let result: Result = serde_json::from_str(json_response); - assert!(result.is_ok()); - - let response = result.unwrap(); - assert_eq!(response.btc.get("USD"), Some(&Some(50000.0))); - assert_eq!(response.btc.get("EUR"), Some(&Some(45000.0))); - assert_eq!(response.btc.get("GBP"), Some(&Some(40000.0))); - assert_eq!(response.btc.len(), 3); - } - - #[test] - fn test_yadio_response_with_null_rate_is_parsed_and_filtered() { - // Regression: Yadio now returns `null` for currencies it has no rate - // for (e.g. "BGN": null). The lenient `Option` parse must accept - // the whole payload, and the same filter `update_prices` applies must - // drop the null while keeping the good currencies. - let json_response = r#" - { - "BTC": { "USD": 75899.55, "EUR": 65393.99, "BGN": null }, - "base": "BTC", - "timestamp": 1779480604069 - } - "#; - - let response: YadioResponse = serde_json::from_str(json_response).expect("must parse"); - assert_eq!(response.btc.get("BGN"), Some(&None)); - - let rates: HashMap = response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - - assert_eq!(rates.len(), 2, "null BGN must be dropped"); - assert_eq!(rates.get("USD"), Some(&75899.55)); - assert_eq!(rates.get("EUR"), Some(&65393.99)); - assert!(!rates.contains_key("BGN")); - } - - #[test] - fn test_yadio_response_all_null_filters_to_empty() { - // When every rate is null/invalid the payload still parses, but the - // filter yields an empty map โ€” the condition under which - // `update_prices` preserves the previously cached prices instead of - // overwriting them with nothing. - let json_response = r#"{ "BTC": { "BGN": null, "ZZZ": null }, "base": "BTC" }"#; - let response: YadioResponse = serde_json::from_str(json_response).expect("must parse"); - let rates: HashMap = response - .btc - .into_iter() - .filter_map(|(code, value)| match value { - Some(v) if v.is_finite() && v > 0.0 => Some((code, v)), - _ => None, - }) - .collect(); - assert!( - rates.is_empty(), - "all-null response must filter to an empty rate set" - ); - } - - #[test] - fn test_yadio_response_invalid_json() { - // Test deserialization with invalid JSON - let invalid_json = r#"{"invalid": "structure"}"#; - - let result: Result = serde_json::from_str(invalid_json); - assert!(result.is_err()); - } - - #[test] - fn test_yadio_response_empty_btc() { - // Test deserialization with empty BTC object - let json_response = r#"{"BTC": {}}"#; - - let result: Result = serde_json::from_str(json_response); - assert!(result.is_ok()); - - let response = result.unwrap(); - assert_eq!(response.btc.len(), 0); - } - - #[test] - fn test_currency_code_validation() { - // Test various currency code formats - let valid_currencies = vec!["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF"]; - let invalid_currencies = vec!["", "us", "USDD", "123", "usd"]; - - // Test valid currencies (should not panic) - for currency in valid_currencies { - let _result = BitcoinPriceManager::get_price(currency); - // No assertion needed; this ensures no panic for valid input - } - - // Test invalid currencies (should not panic) - for currency in invalid_currencies { - let _result = BitcoinPriceManager::get_price(currency); - // No assertion needed; this ensures no panic for invalid input - } - } - - #[test] - fn test_bitcoin_price_manager_api_url() { - // Test that API URL configuration is properly handled - let expected_base = "https://api.yadio.io"; - assert!(expected_base.starts_with("https://")); - assert!(expected_base.contains("yadio.io")); - } - - mod error_handling_tests { - use super::*; - - #[test] - fn test_json_parsing_errors() { - // Test various JSON parsing error scenarios - let invalid_responses = vec![ - "", // Empty response - "{", // Incomplete JSON - "null", // Null response - "[]", // Array instead of object - r#"{"BTC": null}"#, // Null BTC field - r#"{"BTC": []}"#, // Array instead of object for BTC - r#"{"BTC": {"USD": "invalid"}}"#, // Invalid number format - ]; - - for invalid_json in invalid_responses { - let result: Result = serde_json::from_str(invalid_json); - // All should fail to deserialize - assert!(result.is_err()); - } - } - } - - mod price_cache_tests { - use super::*; - - #[test] - fn test_price_cache_operations() { - // Test the logical flow of price caching - - // Test that we can conceptually store and retrieve prices - let test_currencies = HashMap::from([ - ("USD".to_string(), 50000.0), - ("EUR".to_string(), 45000.0), - ("GBP".to_string(), 40000.0), - ]); - - // Verify our test data is valid - assert_eq!(test_currencies.len(), 3); - assert!(test_currencies.contains_key("USD")); - assert_eq!(test_currencies.get("USD"), Some(&50000.0)); - - // Test currency code normalization (uppercase) - for currency in test_currencies.keys() { - assert_eq!(currency, ¤cy.to_uppercase()); - assert!(currency.len() == 3); // Standard currency code length - } - } - - #[test] - fn test_concurrent_access_safety() { - // Test that the static BITCOIN_PRICES can handle concurrent access - // This tests the thread safety of our RwLock usage - - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - use std::thread; - - let success = Arc::new(AtomicBool::new(true)); - let mut handles = vec![]; - - // Spawn multiple threads trying to read prices - for _ in 0..5 { - let success_clone = Arc::clone(&success); - let handle = thread::spawn(move || { - for _ in 0..10 { - match BitcoinPriceManager::get_price("USD") { - Ok(_) | Err(_) => { - // Both outcomes are acceptable for this test - // We're just testing that it doesn't panic - } - } - } - success_clone.store(true, Ordering::Relaxed); - }); - handles.push(handle); - } - - // Wait for all threads to complete - for handle in handles { - handle.join().expect("Thread should not panic"); - } - - // All threads should have completed successfully - assert!(success.load(Ordering::Relaxed)); - } + fn unset_manager_returns_no_api_response() { + // Unit tests never install the global PriceManager. The shim must + // surface the same error the legacy `BITCOIN_PRICES.get` empty-map + // path used to surface, so callers behave identically. + let err = BitcoinPriceManager::get_price("USD").unwrap_err(); + assert!(matches!( + err, + MostroError::MostroInternalErr(ServiceError::NoAPIResponse) + )); } } diff --git a/src/cashu/mod.rs b/src/cashu/mod.rs index 4d1e42dc..8cd158d3 100644 --- a/src/cashu/mod.rs +++ b/src/cashu/mod.rs @@ -1,11 +1,10 @@ use cdk::error::Error as CdkClientError; use cdk::mint_url::MintUrl; +use cdk::nuts::{nut00::Proofs, nut01::SecretKey as NutSecretKey, nut10::SpendingConditions}; +use cdk::nuts::{nut02::ShortKeysetId, CheckStateRequest, CheckStateResponse, PublicKey, Token}; use cdk::wallet::MintConnector; -use cdk::nuts::{nut01::SecretKey as NutSecretKey, nut00::Proofs, nut10::SpendingConditions}; -use cdk::nuts::{CheckStateRequest, CheckStateResponse, PublicKey, Token, nut02::ShortKeysetId}; use std::str::FromStr; -use std::sync::OnceLock; /// Error type for Cashu client operations #[derive(Debug)] pub enum Error { @@ -28,6 +27,15 @@ impl std::fmt::Display for Error { } } +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Client(e) => Some(e), + _ => None, + } + } +} + impl From for Error { fn from(e: CdkClientError) -> Self { Error::Client(e) @@ -37,39 +45,33 @@ impl From for Error { /// A client for communicating with a Cashu mint. #[derive(Clone)] pub struct CashuClient { - mint_url: MintUrl, client: cdk::HttpClient, } -pub static CASHU_STATUS: OnceLock = OnceLock::new(); - impl CashuClient { - /// Connects to a mint URL and verifies it is reachable. + /// Connects to a mint URL and verifies it is reachable and that it + /// supports the NUT-11 P2PK spending conditions the 2-of-3 escrow needs. + /// A connection failure is surfaced to the caller via `Err` rather than a + /// process-global flag, so the daemon can decide how to react at startup. pub async fn connect(mint_url: &str) -> Result { - let url = MintUrl::from_str(mint_url) - .map_err(|e| Error::InvalidMintUrl(e.to_string()))?; - - let client = cdk::HttpClient::new(url.clone(), None); - let cashu_client = Self { - mint_url: url.clone(), - client, - }; - - match cashu_client.client.get_mint_info().await { - Ok(info) => { - if !info.nuts.nut11.supported { - CASHU_STATUS.get_or_init(|| false); - return Err(Error::MintConnection("Mint does not support NUT-11 P2PK".into())); - } - CASHU_STATUS.get_or_init(|| true); - Ok(cashu_client) - } - Err(e) => { - CASHU_STATUS.get_or_init(|| false); - let err: cdk::error::Error = e.into(); - Err(Error::MintConnection(err.to_string())) - } + let url = MintUrl::from_str(mint_url).map_err(|e| Error::InvalidMintUrl(e.to_string()))?; + + let client = cdk::HttpClient::new(url, None); + let cashu_client = Self { client }; + + let info = cashu_client + .client + .get_mint_info() + .await + .map_err(|e| Error::MintConnection(e.to_string()))?; + + if !info.nuts.nut11.supported { + return Err(Error::MintConnection( + "Mint does not support NUT-11 P2PK".into(), + )); } + + Ok(cashu_client) } /// Verifies the 2-of-3 condition embedded in a token matches the expected pubkeys. @@ -80,8 +82,7 @@ impl CashuClient { p_s: PublicKey, p_m: PublicKey, ) -> Result { - let token = Token::from_str(token) - .map_err(|e| Error::Token(e.to_string()))?; + let token = Token::from_str(token).map_err(|e| Error::Token(e.to_string()))?; let secrets = token.token_secrets(); if secrets.is_empty() { @@ -89,23 +90,36 @@ impl CashuClient { } for secret in secrets { - let spending_conditions = SpendingConditions::try_from(secret).map_err(|e| Error::Condition(e.to_string()))?; - + let spending_conditions = SpendingConditions::try_from(secret) + .map_err(|e| Error::Condition(e.to_string()))?; + if spending_conditions.num_sigs() != Some(2) { - return Err(Error::Condition("Spending condition must require exactly 2 signatures".into())); + return Err(Error::Condition( + "Spending condition must require exactly 2 signatures".into(), + )); } if spending_conditions.locktime().is_some() { - return Err(Error::Condition("Spending condition cannot have a locktime".into())); + return Err(Error::Condition( + "Spending condition cannot have a locktime".into(), + )); } if spending_conditions.refund_keys().is_some() { - return Err(Error::Condition("Spending condition cannot have refund keys".into())); + return Err(Error::Condition( + "Spending condition cannot have refund keys".into(), + )); } let pubkeys = spending_conditions.pubkeys().unwrap_or_default(); - if pubkeys.len() != 3 || !pubkeys.contains(&p_b) || !pubkeys.contains(&p_s) || !pubkeys.contains(&p_m) { - return Err(Error::Condition("Missing expected pubkeys in spending condition".into())); + if pubkeys.len() != 3 + || !pubkeys.contains(&p_b) + || !pubkeys.contains(&p_s) + || !pubkeys.contains(&p_m) + { + return Err(Error::Condition( + "Missing expected pubkeys in spending condition".into(), + )); } } @@ -117,39 +131,56 @@ impl CashuClient { /// that the proofs were signed by the mint. Use `verify_token_dleq` for that. pub async fn check_state(&self, ys: Vec) -> Result { let request = CheckStateRequest { ys }; - let response = self.client.post_check_state(request).await - .map_err(|e| { - Error::Client(cdk::error::Error::from(e)) - })?; + let response = self + .client + .post_check_state(request) + .await + .map_err(Error::Client)?; Ok(response) } /// Verifies the DLEQ proofs for all proofs in a token. /// This authenticates that the token was actually issued by the mint. pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { - let keysets = self.client.get_mint_keys().await.map_err(|e| Error::Client(cdk::error::Error::from(e)))?; - + let keysets = self.client.get_mint_keys().await.map_err(Error::Client)?; + match token { Token::TokenV3(token_v3) => { - let proofs = token_v3.token.iter().flat_map(|t| t.proofs.clone()).collect::>(); + let proofs = token_v3 + .token + .iter() + .flat_map(|t| t.proofs.clone()) + .collect::>(); for proof in proofs { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == proof.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - let mint_pubkey = keyset.keys.get(&proof.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; - + let mint_pubkey = keyset + .keys + .get(&proof.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let p = proof.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } - }, + } Token::TokenV4(token_v4) => { for token_entry in &token_v4.token { - let keyset = keysets.iter().find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) + let keyset = keysets + .iter() + .find(|k| ShortKeysetId::from(k.id) == token_entry.keyset_id) .ok_or_else(|| Error::Token("Unknown keyset".into()))?; - + for proof_v4 in &token_entry.proofs { - let mint_pubkey = keyset.keys.get(&proof_v4.amount).ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; + let mint_pubkey = keyset + .keys + .get(&proof_v4.amount) + .ok_or_else(|| Error::Token("Unknown amount for keyset".into()))?; let p = proof_v4.into_proof(&keyset.id); - p.verify_dleq(*mint_pubkey).map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; + p.verify_dleq(*mint_pubkey) + .map_err(|_| Error::Token("Invalid DLEQ proof".into()))?; } } } @@ -161,7 +192,9 @@ impl CashuClient { /// Signs proofs using the arbitrator's (Mostro) secret key. pub fn sign_with_pm(proofs: &mut Proofs, p_m_secret: NutSecretKey) -> Result<(), Error> { for proof in proofs.iter_mut() { - proof.sign_p2pk(p_m_secret.clone()).map_err(|e| Error::Client(cdk::error::Error::from(e)))?; + proof + .sign_p2pk(p_m_secret.clone()) + .map_err(|e| Error::Client(cdk::error::Error::from(e)))?; } Ok(()) } diff --git a/src/config/constants.rs b/src/config/constants.rs index a33ec105..ce5d3b5f 100644 --- a/src/config/constants.rs +++ b/src/config/constants.rs @@ -13,6 +13,11 @@ pub const DEV_FEE_LIGHTNING_ADDRESS: &str = "pivotaldeborah52@walletofsatoshi.co /// This ensures events are NOT replaceable, maintaining complete audit history pub const DEV_FEE_AUDIT_EVENT_KIND: u16 = 8383; +/// Nostr event kind for protocol-v2 direct messages (NIP-44 direct transport) +/// Kind 14 carries Mostro protocol messages as signed events with NIP-44 +/// encrypted content when `transport = "nip44"` (see docs/TRANSPORT_V2_SPEC.md) +pub const DM_EVENT_KIND: u16 = 14; + /// Nostr event kind for exchange rates (NIP-33 addressable event) /// Kind 30078 is in the replaceable events range (30000-39999) per NIP-33 /// This allows the same Mostro instance to publish updated rates that replace previous events diff --git a/src/config/mod.rs b/src/config/mod.rs index db3e4fc9..8292b08a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -233,4 +233,72 @@ mod tests { ); assert_eq!(mostro_settings.mostro.max_orders_per_response, 10); } + + // Same as MOSTRO_SETTINGS but with `bitcoin_price_api_url` omitted โ€” the + // shape an operator who has migrated to a `[price]` block and deleted the + // deprecated key would have (spec ยง10.1). + const MOSTRO_SETTINGS_NO_PRICE_URL: &str = r#"[mostro] + fee = 0 + max_routing_fee = 0.002 + max_order_amount = 1000000 + min_payment_amount = 100 + expiration_hours = 24 + max_expiration_days = 15 + expiration_seconds = 900 + user_rates_sent_interval_seconds = 3600 + publish_relays_interval = 60 + pow = 0 + publish_mostro_info_interval = 300 + fiat_currencies_accepted = ['USD', 'EUR', 'ARS', 'CUP'] + max_orders_per_response = 10 + dev_fee_percentage = 0.30"#; + + #[test] + fn test_mostro_settings_without_bitcoin_price_api_url_defaults() { + // A settings.toml that omits the deprecated key must still + // deserialize (it is `#[serde(default)]`), falling back to the same + // URL the `Default` impl uses. + let parsed: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS_NO_PRICE_URL).expect("must deserialize without the key"); + assert_eq!( + parsed.mostro.bitcoin_price_api_url, "https://api.yadio.io", + "omitted legacy key must fall back to the default URL" + ); + } + + #[test] + fn test_legacy_synthesis_same_whether_key_present_or_defaulted() { + // Legacy synthesis (`[price]` absent) must produce the same + // single-yadio config whether the deprecated key was present at its + // default value or omitted entirely (spec ยง10.1 byte-for-byte + // compatibility). + let omitted: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS_NO_PRICE_URL).expect("deserialize (omitted)"); + let present: StubSettingsMostro = + toml::from_str(MOSTRO_SETTINGS).expect("deserialize (present)"); + + let synth = |m: &MostroSettings| { + crate::price::synthesise_legacy_price_settings( + &m.bitcoin_price_api_url, + m.exchange_rates_update_interval_seconds, + m.publish_exchange_rates_to_nostr, + ) + }; + let from_omitted = synth(&omitted.mostro); + let from_present = synth(&present.mostro); + + // Exactly one provider (yadio) in both, with the default URL. + for cfg in [&from_omitted, &from_present] { + assert_eq!(cfg.providers.len(), 1); + let yadio = cfg.providers.get("yadio").expect("yadio provider present"); + assert!(yadio.enabled); + assert_eq!(yadio.url, "https://api.yadio.io"); + } + // And the synthesised cadence / publish flag match across the two. + assert_eq!( + from_omitted.update_interval_seconds, + from_present.update_interval_seconds + ); + assert_eq!(from_omitted.publish_to_nostr, from_present.publish_to_nostr); + } } diff --git a/src/config/settings.rs b/src/config/settings.rs index 4c2505d5..effece78 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,6 +4,7 @@ use crate::config::types::{ LightningSettings, MostroSettings, NostrSettings, RpcSettings, }; use crate::price::PriceSettings; +use mostro_core::transport::Transport; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -104,6 +105,18 @@ impl Settings { MOSTRO_CONFIG.get()?.anti_abuse_bond.as_ref() } + /// Wire transport for protocol messages. Falls back to the default + /// (`gift-wrap`, protocol v1) when the global settings haven't been + /// initialized yet โ€” `send_dm()` sits on every reply path and must + /// degrade to v1 behavior rather than panic in unit tests that don't + /// bring up the full configuration, mirroring [`Settings::get_bond`]. + pub fn get_transport() -> Transport { + MOSTRO_CONFIG + .get() + .map(|s| s.mostro.transport) + .unwrap_or_default() + } + /// Retrieve the multi-source price configuration from the global /// `MOSTRO_CONFIG`. Returns `None` when the `[price]` block is absent /// (Phase 1 synthesises a legacy default in that case) and also when the diff --git a/src/config/types.rs b/src/config/types.rs index fcbb6d35..a4c5d22c 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1,6 +1,6 @@ // File with the types for the configuration settings // Initialize the types for the configuration settings -use crate::config::constants::DEV_FEE_AUDIT_EVENT_KIND; +use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DM_EVENT_KIND}; use crate::config::MOSTRO_CONFIG; use mostro_core::prelude::*; use serde::{Deserialize, Serialize}; @@ -194,6 +194,8 @@ pub struct ExpirationSettings { pub dispute_days: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fee_audit_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dm_days: Option, } impl ExpirationSettings { @@ -204,6 +206,7 @@ impl ExpirationSettings { NOSTR_RATING_EVENT_KIND => self.rating_days.or(Some(90)), // ratings NOSTR_DISPUTE_EVENT_KIND => self.dispute_days.or(Some(90)), // disputes DEV_FEE_AUDIT_EVENT_KIND => self.fee_audit_days.or(Some(365)), // fee audits + DM_EVENT_KIND => self.dm_days.or(Some(30)), // protocol-v2 direct messages _ => None, // unknown kinds don't get expiration } } @@ -243,6 +246,80 @@ mod tests { ); } + #[test] + fn dm_kind_respects_configured_days_and_falls_back_to_30() { + let settings = ExpirationSettings { + dm_days: Some(7), + ..Default::default() + }; + assert_eq!(settings.get_expiration_for_kind(DM_EVENT_KIND), Some(7)); + let settings = ExpirationSettings::default(); + assert_eq!(settings.get_expiration_for_kind(DM_EVENT_KIND), Some(30)); + } + + #[test] + fn transport_defaults_to_gift_wrap() { + // v0.18.x default: wire-identical to pre-v2 daemons. The default + // flips to nip44 in v0.19.0 (docs/TRANSPORT_V2_SPEC.md ยง5). + assert_eq!(MostroSettings::default().transport, Transport::GiftWrap); + } + + #[test] + fn pow_first_contact_defaults_to_base_pow() { + // Unset โ‡’ first-contact gate uses the base `pow`, so a config that + // predates Phase 2 behaves exactly as before (spec ยง6 Phase 2). + let s = MostroSettings { + pow: 8, + ..MostroSettings::default() + }; + assert_eq!(s.pow_first_contact, None); + assert_eq!(s.effective_pow_first_contact(), 8); + } + + #[test] + fn pow_first_contact_override_takes_precedence() { + let s = MostroSettings { + pow: 4, + pow_first_contact: Some(20), + ..MostroSettings::default() + }; + assert_eq!(s.effective_pow_first_contact(), 20); + } + + #[test] + fn active_pubkeys_refresh_interval_defaults_to_60() { + assert_eq!( + MostroSettings::default().active_pubkeys_refresh_interval, + 60 + ); + } + + #[test] + fn mostro_settings_omitting_phase2_keys_parses() { + // An existing settings.toml without the Phase 2 keys must still + // deserialize (both fields are `#[serde(default)]`). + let toml_str = r#" +fee = 0 +max_routing_fee = 0.002 +max_order_amount = 1000000 +min_payment_amount = 100 +expiration_hours = 24 +expiration_seconds = 900 +user_rates_sent_interval_seconds = 3600 +max_expiration_days = 15 +publish_relays_interval = 60 +pow = 0 +publish_mostro_info_interval = 300 +fiat_currencies_accepted = ["USD"] +max_orders_per_response = 10 +dev_fee_percentage = 0.30 +"#; + let s: MostroSettings = toml::from_str(toml_str).expect("legacy config parses"); + assert_eq!(s.pow_first_contact, None); + assert_eq!(s.active_pubkeys_refresh_interval, 60); + assert_eq!(s.effective_pow_first_contact(), 0); + } + #[test] fn dispute_kind_falls_back_to_90_when_unconfigured() { let settings = ExpirationSettings::default(); @@ -365,7 +442,15 @@ pub struct MostroSettings { pub pow: u8, /// Publish mostro info interval pub publish_mostro_info_interval: u32, - /// Bitcoin price API base URL + /// Bitcoin price API base URL. + /// + /// DEPRECATED (spec ยง10.1): superseded by `[price.providers.yadio].url`. + /// `#[serde(default)]` so a `settings.toml` that has migrated to a + /// `[price]` block may omit this key entirely without failing + /// deserialization. Still read by the live `/convert` path + /// (`src/util.rs`) and by `install_price_manager()` legacy synthesis when + /// `[price]` is absent, so the field itself stays until Phase 4/5. + #[serde(default = "default_bitcoin_price_api_url")] pub bitcoin_price_api_url: String, /// Fiat currencies accepted for orders (empty list accepts all) pub fiat_currencies_accepted: Vec, @@ -388,6 +473,44 @@ pub struct MostroSettings { /// Exchange rates update interval in seconds (default: 300 = 5 minutes) #[serde(default = "default_exchange_rates_update_interval")] pub exchange_rates_update_interval_seconds: u64, + /// Wire transport for protocol messages: `"gift-wrap"` (protocol v1, + /// NIP-59, DEPRECATED) or `"nip44"` (protocol v2, kind-14 direct). + /// A node speaks exactly one. See docs/TRANSPORT_V2_SPEC.md. + #[serde(default)] + pub transport: Transport, + /// Proof-of-work difficulty (leading-zero bits) demanded of a + /// *first-contact* event on the protocol-v2 (`nip44`) transport โ€” one + /// whose visible sender (trade key) is **not** in the active-trade + /// cache โ€” checked BEFORE the daemon pays the NIP-44 decrypt cost. This + /// is the Phase 2 anti-spam lane: ongoing trades (known keys) need only + /// `pow`, while brand-new orders/takes from unseen keys must grind this + /// harder toll (see docs/TRANSPORT_V2_SPEC.md ยง6 Phase 2). + /// + /// `None` โ‡’ falls back to `pow`, so existing configs and the v1 + /// transport are wire-identical to before. Has no effect on `gift-wrap` + /// (v1 senders are throwaway keys that can't be pre-validated). + #[serde(default)] + pub pow_first_contact: Option, + /// How often (seconds) to rebuild the active-trade-pubkey cache that the + /// Phase 2 anti-spam gate consults. Lower = fresher known-keys set (a + /// just-taken order's keys fast-path sooner); higher = less DB load. + #[serde(default = "default_active_pubkeys_refresh_interval")] + pub active_pubkeys_refresh_interval: u64, +} + +impl MostroSettings { + /// Effective first-contact PoW difficulty: the explicit + /// `pow_first_contact` when set, otherwise the base `pow`. Centralised so + /// the event loop and tests agree on the fallback (spec ยง6 Phase 2). + pub fn effective_pow_first_contact(&self) -> u8 { + self.pow_first_contact.unwrap_or(self.pow) + } +} + +fn default_bitcoin_price_api_url() -> String { + // Matches the `Default` impl below so an omitted legacy key and an + // explicit one behave identically for legacy synthesis (spec ยง10.1). + "https://api.yadio.io".to_string() } fn default_publish_exchange_rates() -> bool { @@ -398,6 +521,10 @@ fn default_exchange_rates_update_interval() -> u64 { 300 // 5 minutes } +fn default_active_pubkeys_refresh_interval() -> u64 { + 60 // 1 minute โ€” keeps a just-taken order's keys fast-pathing promptly +} + impl Default for MostroSettings { fn default() -> Self { Self { @@ -427,6 +554,9 @@ impl Default for MostroSettings { website: None, publish_exchange_rates_to_nostr: default_publish_exchange_rates(), exchange_rates_update_interval_seconds: default_exchange_rates_update_interval(), + transport: Transport::default(), + pow_first_contact: None, + active_pubkeys_refresh_interval: default_active_pubkeys_refresh_interval(), } } } diff --git a/src/db.rs b/src/db.rs index 07e9e6d2..1b573436 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,6 +5,7 @@ use nostr_sdk::prelude::*; use sqlx::pool::Pool; use sqlx::sqlite::SqliteRow; use sqlx::{Row, Sqlite, SqlitePool}; +use std::collections::HashSet; use std::fs::{set_permissions, Permissions}; use std::path::Path; use std::sync::Arc; @@ -14,9 +15,69 @@ use uuid::Uuid; const EXCLUDED_ORDER_STATUSES: &str = "'expired','success','canceled','dispute','canceledbyadmin','completedbyadmin','settledbyadmin','cooperativelycanceled'"; const ACTIVE_DISPUTE_STATUSES: &str = "'initiated','in-progress'"; +/// Terminal order statuses for the Phase 2 active-trade-pubkey cache: an +/// order in any of these will never legitimately originate further trade-key +/// messages, so its participants drop out of the "known keys" set. +/// +/// This is deliberately [`EXCLUDED_ORDER_STATUSES`] **minus `'dispute'`** โ€” a +/// disputed order is still active (buyer, seller and the assigned solver keep +/// messaging), so its trade keys must stay fast-pathed. See +/// `find_active_trade_pubkeys` and docs/TRANSPORT_V2_SPEC.md ยง6 Phase 2. +const TERMINAL_ORDER_STATUSES: &str = "'expired','success','canceled','canceledbyadmin','completedbyadmin','settledbyadmin','cooperativelycanceled'"; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +/// Collect the "known keys" the Phase 2 anti-spam gate fast-paths: the trade +/// pubkeys (buyer / seller / creator) of every **non-terminal** order, plus +/// the solver pubkey of every **active** dispute (spec ยง6 Phase 2). +/// +/// Status lists are compile-time constants ([`TERMINAL_ORDER_STATUSES`], +/// [`ACTIVE_DISPUTE_STATUSES`]), never user input, so the inline +/// interpolation carries no injection risk โ€” same pattern the restore-session +/// queries already use. The result is deduplicated. +pub async fn find_active_trade_pubkeys(pool: &SqlitePool) -> Result, MostroError> { + let mut keys: HashSet = HashSet::new(); + + // Order participants of every still-active order (disputed orders + // included โ€” `TERMINAL_ORDER_STATUSES` excludes `'dispute'`). + let order_query = format!( + "SELECT buyer_pubkey, seller_pubkey, creator_pubkey FROM orders WHERE status NOT IN ({TERMINAL_ORDER_STATUSES})" + ); + let order_rows = sqlx::query(&order_query) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + for row in order_rows { + for col in ["buyer_pubkey", "seller_pubkey", "creator_pubkey"] { + if let Ok(Some(pk)) = row.try_get::, _>(col) { + if !pk.is_empty() { + keys.insert(pk); + } + } + } + } + + // Assigned solvers of active disputes (so admin-settle/cancel/take from + // the solver's key fast-paths instead of hitting the first-contact lane). + let dispute_query = format!( + "SELECT solver_pubkey FROM disputes WHERE status IN ({ACTIVE_DISPUTE_STATUSES}) AND solver_pubkey IS NOT NULL" + ); + let dispute_rows = sqlx::query(&dispute_query) + .fetch_all(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + for row in dispute_rows { + if let Ok(Some(pk)) = row.try_get::, _>("solver_pubkey") { + if !pk.is_empty() { + keys.insert(pk); + } + } + } + + Ok(keys.into_iter().collect()) +} + /// Helper function to rebuild disputes table without token columns when DROP COLUMN is unsupported. async fn rebuild_disputes_table_without_tokens(pool: &SqlitePool) -> Result<(), MostroError> { tracing::info!("Rebuilding disputes table without token columns (SQLite compatibility mode)"); @@ -593,12 +654,19 @@ pub async fn find_order_by_date(pool: &SqlitePool) -> Result, MostroE // `waiting-taker-bond` here, an order parked at that status past its // `expires_at` would never expire and the bond HTLCs would tie up // taker funds in LND until CLTV expiry. + // + // Phase 5: `waiting-maker-bond` is the maker-side analogue โ€” an order + // whose maker never paid the bond, so it was never published to + // Nostr at all. It must also expire here, otherwise the abandoned + // order row and its bond HTLC linger until CLTV. Unlike the other two + // buckets this status has no NIP-33 event, so the expiry job skips the + // Nostr republish for it (see `job_expire_pending_older_orders`). let order = sqlx::query_as::<_, Order>( r#" SELECT * FROM orders WHERE expires_at < ?1 - AND status IN ('pending', 'waiting-taker-bond') + AND status IN ('pending', 'waiting-taker-bond', 'waiting-maker-bond') "#, ) .bind(expire_time.as_secs() as i64) @@ -804,9 +872,10 @@ pub async fn find_locked_cashu_orders(pool: &SqlitePool) -> Result, M SELECT * FROM orders WHERE cashu_escrow_locked_at IS NOT NULL - AND status = 'active' + AND status = ? "#, ) + .bind(Status::Active.to_string()) .fetch_all(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; @@ -1477,6 +1546,156 @@ mod tests { .unwrap(); } + /// Insert an order carrying explicit trade pubkeys, for the Phase 2 + /// active-trade-pubkey cache query. + async fn insert_order_with_pubkeys( + pool: &SqlitePool, + id: uuid::Uuid, + status: &str, + creator: Option<&str>, + buyer: Option<&str>, + seller: Option<&str>, + ) { + sqlx::query( + r#" + INSERT INTO orders (id, kind, event_id, status, premium, payment_method, + amount, fiat_code, fiat_amount, created_at, expires_at, + creator_pubkey, buyer_pubkey, seller_pubkey) + VALUES (?1, 'buy', 'event123', ?2, 0, 'lightning', + 100000, 'USD', 100, 1700000000, 1700086400, ?3, ?4, ?5) + "#, + ) + .bind(id) + .bind(status) + .bind(creator) + .bind(buyer) + .bind(seller) + .execute(pool) + .await + .unwrap(); + } + + async fn setup_disputes_table(pool: &SqlitePool) { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS disputes ( + id char(36) primary key not null, + order_id char(36) unique not null, + status varchar(10) not null, + order_previous_status varchar(10) not null, + solver_pubkey char(64), + created_at integer not null, + taken_at integer default 0 + ) + "#, + ) + .execute(pool) + .await + .unwrap(); + } + + /// Phase 2 (docs/TRANSPORT_V2_SPEC.md ยง6): the active-trade-pubkey cache + /// must include participants of every non-terminal order โ€” **including + /// disputed ones** โ€” and the solvers of active disputes, while excluding + /// terminal orders and resolved disputes. + #[tokio::test] + async fn find_active_trade_pubkeys_covers_active_and_disputed_excludes_terminal() { + let pool = setup_orders_db().await.unwrap(); + setup_disputes_table(&pool).await; + + // Active order โ€” all three keys are "known". + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "waiting-payment", + Some("creator_active"), + Some("buyer_active"), + Some("seller_active"), + ) + .await; + // Disputed order โ€” still active (the load-bearing nuance: 'dispute' is + // NOT in TERMINAL_ORDER_STATUSES), so its keys must be included. + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "dispute", + Some("creator_disp"), + Some("buyer_disp"), + Some("seller_disp"), + ) + .await; + // Terminal orders โ€” excluded. + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "success", + Some("creator_succ"), + Some("buyer_succ"), + Some("seller_succ"), + ) + .await; + insert_order_with_pubkeys( + &pool, + uuid::Uuid::new_v4(), + "canceled", + Some("creator_canc"), + None, + None, + ) + .await; + + // Active dispute with an assigned solver โ†’ solver key included. + sqlx::query( + "INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at) \ + VALUES (?1, ?2, 'in-progress', 'fiat-sent', 'solver_active', 1700000000)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + // Resolved dispute โ†’ its solver is NOT included. + sqlx::query( + "INSERT INTO disputes (id, order_id, status, order_previous_status, solver_pubkey, created_at) \ + VALUES (?1, ?2, 'settled', 'fiat-sent', 'solver_settled', 1700000000)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + + let keys: HashSet = super::find_active_trade_pubkeys(&pool) + .await + .unwrap() + .into_iter() + .collect(); + + for k in [ + "creator_active", + "buyer_active", + "seller_active", + "creator_disp", + "buyer_disp", + "seller_disp", + "solver_active", + ] { + assert!(keys.contains(k), "{k} should be a known active key"); + } + for k in [ + "creator_succ", + "buyer_succ", + "seller_succ", + "creator_canc", + "solver_settled", + ] { + assert!( + !keys.contains(k), + "{k} must NOT be known (terminal/resolved)" + ); + } + } + #[tokio::test] async fn test_fetch_string_column_scalar() { let pool = setup_db().await.unwrap(); @@ -2017,8 +2236,10 @@ mod tests { let pending_expired = uuid::Uuid::new_v4(); let waiting_taker_bond_expired = uuid::Uuid::new_v4(); + let waiting_maker_bond_expired = uuid::Uuid::new_v4(); let pending_fresh = uuid::Uuid::new_v4(); let waiting_taker_bond_fresh = uuid::Uuid::new_v4(); + let waiting_maker_bond_fresh = uuid::Uuid::new_v4(); let active_expired = uuid::Uuid::new_v4(); // out-of-bucket; must not match insert(&pool, pending_expired, "pending", past).await; @@ -2029,6 +2250,13 @@ mod tests { past, ) .await; + insert( + &pool, + waiting_maker_bond_expired, + "waiting-maker-bond", + past, + ) + .await; insert(&pool, pending_fresh, "pending", future).await; insert( &pool, @@ -2037,6 +2265,13 @@ mod tests { future, ) .await; + insert( + &pool, + waiting_maker_bond_fresh, + "waiting-maker-bond", + future, + ) + .await; insert(&pool, active_expired, "active", past).await; let expired = super::find_order_by_date(&pool).await.unwrap(); @@ -2050,6 +2285,10 @@ mod tests { ids.contains(&waiting_taker_bond_expired), "expired WaitingTakerBond must be returned (Phase 1.5)" ); + assert!( + ids.contains(&waiting_maker_bond_expired), + "expired WaitingMakerBond must be returned (Phase 5)" + ); assert!( !ids.contains(&pending_fresh), "non-expired Pending must NOT be returned" @@ -2058,6 +2297,10 @@ mod tests { !ids.contains(&waiting_taker_bond_fresh), "non-expired WaitingTakerBond must NOT be returned" ); + assert!( + !ids.contains(&waiting_maker_bond_fresh), + "non-expired WaitingMakerBond must NOT be returned" + ); assert!( !ids.contains(&active_expired), "expired but non-pre-trade orders (e.g. active) must NOT be returned" diff --git a/src/escrow.rs b/src/escrow.rs index 453ed52e..c4c8a231 100644 --- a/src/escrow.rs +++ b/src/escrow.rs @@ -104,11 +104,22 @@ impl EscrowBackend for LndConnector { /// A placeholder for the opt-in Cashu mode. In Cashu mode Mostro is only a /// coordinator and never takes custody, so these hold-invoice primitives do not /// map onto Cashu directly โ€” the feature tracks replace the escrow paths that -/// call them. Until then every method is `unimplemented!()` and the backend is -/// never instantiated (the daemon defaults to Lightning). +/// call them. Until then every method returns a typed error (never `panic!`) +/// and the backend is never instantiated (the daemon defaults to Lightning). +/// Returning `Err` rather than `unimplemented!()` keeps an accidental future +/// instantiation from crashing the daemon in the middle of a trade. #[derive(Debug, Default, Clone, Copy)] pub struct CashuBackend; +impl CashuBackend { + /// Error returned by every not-yet-implemented Cashu escrow primitive. + fn not_implemented(primitive: &str) -> MostroError { + MostroError::MostroInternalErr(ServiceError::UnexpectedError(format!( + "Cashu escrow {primitive} is not implemented yet" + ))) + } +} + #[async_trait] impl EscrowBackend for CashuBackend { async fn create_hold_invoice( @@ -116,14 +127,14 @@ impl EscrowBackend for CashuBackend { _description: &str, _amount: i64, ) -> Result { - unimplemented!("Cashu escrow lock is implemented in the Cashu lock track") + Err(Self::not_implemented("lock")) } async fn settle_hold_invoice(&mut self, _preimage: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow release is implemented in the Cashu release track") + Err(Self::not_implemented("release")) } async fn cancel_hold_invoice(&mut self, _hash: &str) -> Result<(), MostroError> { - unimplemented!("Cashu escrow cancel is implemented in the Cashu cancel track") + Err(Self::not_implemented("cancel")) } } diff --git a/src/main.rs b/src/main.rs index 87f9b85c..63ac1ac1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ pub mod nip33; pub mod price; pub mod rpc; pub mod scheduler; +pub mod spam_gate; pub mod util; use crate::app::context::AppContext; @@ -56,6 +57,11 @@ async fn main() -> Result<()> { // Init MOSTRO_SETTINGS oncelock with all settings variables from TOML file settings_init()?; + // Build and install the multi-source price manager (spec ยง9 Phase 1). + // Done immediately after settings load so every later subsystem + // (scheduler, util::get_bitcoin_price, RPC) can read prices through it. + install_price_manager()?; + // Connect to database if DB_POOL.set(db::connect().await?).is_err() { tracing::error!("No connection to database - closing Mostro!"); @@ -71,9 +77,18 @@ async fn main() -> Result<()> { // Get mostro keys let mostro_keys = util::get_keys()?; + // Subscribe only to the configured transport's kind: 1059 (protocol v1 + // gift wrap) or 14 (protocol v2 NIP-44 direct). See docs/TRANSPORT_V2_SPEC.md. + let transport = Settings::get_mostro().transport; + tracing::info!( + "Transport: {} (protocol v{}, event kind {})", + transport, + transport.protocol_version(), + transport.event_kind().as_u16() + ); let subscription = Filter::new() .pubkey(mostro_keys.public_key()) - .kind(Kind::GiftWrap) + .kind(transport.event_kind()) .limit(0); let client = match get_nostr_client() { @@ -190,9 +205,88 @@ async fn main() -> Result<()> { }); } + // Install the protocol-v2 anti-spam gate and warm its active-trade-pubkey + // cache before the event loop starts, so the very first kind-14 events are + // already pre-filtered against known keys (spec ยง6 Phase 2). The cache is + // kept fresh afterwards by `job_refresh_active_pubkeys`. Inert on the v1 + // (gift-wrap) transport, which never consults the gate. + { + use crate::spam_gate::{SpamGate, REPLAY_WINDOW_SECS}; + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + match db::find_active_trade_pubkeys(get_db_pool().as_ref()).await { + Ok(keys) => { + tracing::info!( + "SpamGate: warming active-trade-pubkey cache ({} keys)", + keys.len() + ); + gate.set_known(keys); + } + Err(e) => tracing::warn!("SpamGate: initial cache warm failed: {e}"), + } + if gate.install_global().is_err() { + tracing::warn!("SpamGate already installed"); + } + } + + // ctx was built and the scheduler started above (shared by the Lightning + // and Cashu paths); the Cashu path already returned via `run_cashu`, so + // here we run the Lightning event loop. run(ctx, &mut ln_client).await } +/// Build the multi-source [`crate::price::PriceManager`] from settings and +/// install it as the process-wide global. When `[price]` is absent in the +/// settings file we synthesise it from the legacy `[mostro]` keys +/// (`bitcoin_price_api_url`, `exchange_rates_update_interval_seconds`, +/// `publish_exchange_rates_to_nostr`) so existing `settings.toml` files keep +/// working byte-for-byte (spec ยง10.1). +fn install_price_manager() -> std::result::Result<(), Box> { + use crate::price::{synthesise_legacy_price_settings, PriceManager}; + + let mostro_settings = Settings::get_mostro(); + let price_settings = match Settings::get_price() { + Some(p) => { + // Multi-source mode: the `[price.providers.*]` tables drive + // aggregation, so the legacy `[mostro].bitcoin_price_api_url` is + // not consulted here. Surface that explicitly so an operator who + // still has the legacy key set isn't misled into thinking it + // takes effect โ€” name the providers actually in play instead. + let mut enabled: Vec<&str> = p + .providers + .iter() + .filter(|(_, cfg)| cfg.enabled) + .map(|(id, _)| id.as_str()) + .collect(); + enabled.sort_unstable(); + let enabled = if enabled.is_empty() { + "".to_string() + } else { + enabled.join(", ") + }; + tracing::warn!( + "price: legacy `bitcoin_price_api_url` = \"{}\" is ignored for price \ + aggregation because `[price]` is configured; using enabled providers: {}", + mostro_settings.bitcoin_price_api_url, + enabled, + ); + p.clone() + } + None => synthesise_legacy_price_settings( + &mostro_settings.bitcoin_price_api_url, + mostro_settings.exchange_rates_update_interval_seconds, + mostro_settings.publish_exchange_rates_to_nostr, + ), + }; + + let manager = PriceManager::from_settings(price_settings) + .map_err(|e| -> Box { format!("price: {e}").into() })?; + manager + .install_global() + .map_err(|e| -> Box { format!("price: {e}").into() })?; + tracing::info!("PriceManager installed"); + Ok(()) +} + #[cfg(test)] mod tests { use mostro_core::message::Message; diff --git a/src/nip33.rs b/src/nip33.rs index 63b349dc..fbff4bc4 100644 --- a/src/nip33.rs +++ b/src/nip33.rs @@ -531,6 +531,14 @@ pub fn info_to_tags(ln_status: &LnStatus) -> Tags { TagKind::Custom(Cow::Borrowed("pow")), vec![mostro_settings.pow.to_string()], ), + // Capability advertisement: which Mostro protocol version this node + // speaks ("1" = gift wrap, "2" = NIP-44 direct), derived from the + // `transport` setting so clients pick the right wire format before + // sending anything. See docs/TRANSPORT_V2_SPEC.md. + Tag::custom( + TagKind::Custom(Cow::Borrowed("protocol_version")), + vec![mostro_settings.transport.protocol_version().to_string()], + ), Tag::custom( TagKind::Custom(Cow::Borrowed("hold_invoice_expiration_window")), vec![ln_settings.hold_invoice_expiration_window.to_string()], @@ -1074,6 +1082,26 @@ mod tests { ); } + /// Phase 5 (`docs/ANTI_ABUSE_BOND.md` ยง10.1 / ยง10.4): an order whose + /// daemon-internal status is `WaitingMakerBond` has **not** been + /// published to Nostr yet โ€” the maker's bond is still outstanding. + /// `create_status_tags` must therefore signal "do not emit an event" + /// (`create_event == false`), so the order never appears in the book + /// until the bond locks and the order transitions to `Pending`. This + /// is the opposite of `WaitingTakerBond`, which is already advertised + /// and must keep emitting. + #[test] + fn waiting_maker_bond_is_not_published_on_wire() { + let mut order = make_pending_order(); + order.status = Status::WaitingMakerBond.to_string(); + + let (emit, _mapped) = create_status_tags(&order).expect("status tags"); + assert!( + !emit, + "WaitingMakerBond must NOT emit an order event โ€” the order is invisible until the bond locks" + ); + } + /// Sanity: the existing `Pending` mapping behaves identically. If /// somebody refactors `create_status_tags` the bucket-equivalence /// between `Pending` and `WaitingTakerBond` must not drift. diff --git a/src/price/aggregate.rs b/src/price/aggregate.rs index fb59d125..89809c79 100644 --- a/src/price/aggregate.rs +++ b/src/price/aggregate.rs @@ -4,14 +4,21 @@ use std::collections::HashMap; -use super::provider::{ProviderQuotes, Quote}; +use super::provider::{ProviderId, ProviderQuotes, Quote}; -/// One currency's aggregated result for a tick: the price and how many -/// sources contributed it (before outlier removal). -#[derive(Debug, Clone, Copy, PartialEq)] +/// One currency's aggregated result for a tick. +/// +/// `sources` is the count of clean, candidate values that fed into +/// [`combine`] (the spec ยง6.4 "source_count" the store also stores). +/// `contributors` is the sorted, deduplicated list of providers whose +/// value actually **survived** [`combine`]'s outlier filter โ€” what the +/// Nostr `source` tag really means. The two can differ: with three +/// providers and one outlier the count is 3 but contributors is 2. +#[derive(Debug, Clone, PartialEq)] pub struct AggregateResult { pub value: f64, pub sources: u8, + pub contributors: Vec, } /// Combine a currency's candidate per-BTC prices into one figure (spec ยง6.2). @@ -95,72 +102,155 @@ pub fn resolve_per_base( /// Run steps 1โ€“3 of the ยง5.3 pipeline over one tick's provider results. /// -/// `provider_results` holds the `ProviderQuotes` of each provider that -/// succeeded this tick (failed providers contribute nothing โ€” they are -/// simply absent). Currency codes are upper-cased so providers that -/// disagree on casing still combine (spec ยง6.6). +/// `provider_results` holds the `(ProviderId, ProviderQuotes)` pairs of +/// every provider that succeeded this tick (failed providers contribute +/// nothing โ€” they are simply absent). Currency codes are upper-cased so +/// providers that disagree on casing still combine (spec ยง6.6). /// -/// 1. Direct (`PerBtc`) quotes are grouped per currency. +/// 1. Direct (`PerBtc`) quotes are grouped per currency, **paired with +/// their provider id**. /// 2. Per-currency **anchors** are the [`combine`]d direct quotes; fiat-cross -/// (`PerBase`) quotes are resolved against those anchors. +/// (`PerBase`) quotes are resolved against those anchors and attributed +/// to the fiat-cross provider (the anchor's own contributors are an +/// intermediate, not a contributor to the resolved currency). /// 3. Each currency's final value is the [`combine`] of its direct **and** -/// resolved candidates. -/// -/// Returns the per-currency [`AggregateResult`] (value + contributing -/// source count). The caller stamps these into the store with the tick -/// timestamp. +/// resolved candidates. `contributors` lists the providers whose value +/// survived the outlier filter ([`kept_contributors`]) โ€” what the Nostr +/// `source` tag actually represents (spec ยง9 Phase 1). pub fn aggregate_tick( - provider_results: &[ProviderQuotes], + provider_results: &[(ProviderId, ProviderQuotes)], outlier_pct: f64, ) -> HashMap { - let mut direct: HashMap> = HashMap::new(); - let mut per_base: Vec<(String, String, f64)> = Vec::new(); + // Per-currency direct (PerBtc) quotes paired with their source id. + let mut direct: HashMap> = HashMap::new(); + // PerBase quotes paired with their source id; resolved in step 2. + let mut per_base: Vec<(ProviderId, String, String, f64)> = Vec::new(); - for quotes in provider_results { + for (id, quotes) in provider_results { for (currency, quote) in quotes { let currency = currency.to_uppercase(); match quote { - Quote::PerBtc(v) => direct.entry(currency).or_default().push(*v), + Quote::PerBtc(v) => direct.entry(currency).or_default().push((*id, *v)), Quote::PerBase { base, value } => { - per_base.push((currency, base.to_uppercase(), *value)) + per_base.push((*id, currency, base.to_uppercase(), *value)) } } } } - // Step 2: anchors = aggregated direct quotes, then resolve cross quotes. + // Step 2: anchors = aggregated direct quotes (values only), then + // resolve cross quotes โ€” attributing each resolved candidate to the + // fiat-cross provider that emitted it. Anchor contributors are *not* + // propagated into resolved-currency contributors: they are an + // intermediate of the cross math, not an upstream of the cross + // currency. let mut anchors: HashMap = HashMap::new(); - for (currency, candidates) in &direct { - if let Some(v) = combine(candidates, outlier_pct) { + for (currency, pairs) in &direct { + let values: Vec = pairs.iter().map(|(_, v)| *v).collect(); + if let Some(v) = combine(&values, outlier_pct) { anchors.insert(currency.clone(), v); } } - let resolved = resolve_per_base(&per_base, &anchors); + let mut resolved: HashMap> = HashMap::new(); + for (id, currency, base, value) in &per_base { + if let Some(anchor) = anchors.get(base) { + let candidate = value * anchor; + if candidate.is_finite() && candidate > 0.0 { + resolved + .entry(currency.clone()) + .or_default() + .push((*id, candidate)); + } + } + } - // Step 3: combine direct + resolved candidates per currency. + // Step 3: combine direct + resolved candidates per currency, and + // attach the actual surviving contributors. let mut out: HashMap = HashMap::new(); let currencies: std::collections::HashSet<&String> = direct.keys().chain(resolved.keys()).collect(); for currency in currencies { - let mut candidates: Vec = Vec::new(); + let mut pairs: Vec<(ProviderId, f64)> = Vec::new(); if let Some(d) = direct.get(currency) { - candidates.extend_from_slice(d); + pairs.extend_from_slice(d); } if let Some(r) = resolved.get(currency) { - candidates.extend_from_slice(r); + pairs.extend_from_slice(r); } - let sources = candidates - .iter() - .filter(|x| x.is_finite() && **x > 0.0) - .count() - .min(u8::MAX as usize) as u8; + let candidates: Vec = pairs.iter().map(|(_, v)| *v).collect(); if let Some(value) = combine(&candidates, outlier_pct) { - out.insert(currency.clone(), AggregateResult { value, sources }); + let contributors = kept_contributors(&pairs, outlier_pct); + let sources = candidates + .iter() + .filter(|x| x.is_finite() && **x > 0.0) + .count() + .min(u8::MAX as usize) as u8; + out.insert( + currency.clone(), + AggregateResult { + value, + sources, + contributors, + }, + ); } } out } +/// Return the provider ids whose value actually survives [`combine`]'s +/// "kept" predicate for one currency's candidate pairs โ€” i.e. those +/// providers the Nostr `source` tag should advertise (spec ยง9 Phase 1 +/// "contributing-source list"). The predicate mirrors [`combine`]: +/// +/// - clean: drop non-finite and non-positive, +/// - `n <= 2`: every clean provider contributes, +/// - `n >= 3`: only providers whose value lies within +/// `outlier_pct` percent of the median contribute, +/// - `n >= 3` with no value inside the outlier band (the +/// even-length bimodal fallback in [`combine`]): every clean provider +/// contributes, since no single source is demonstrably the outlier and +/// `combine` falls back to the median itself. +/// +/// Multiple paths from the same provider for the same currency (a direct +/// + a fiat-cross resolution, for example) are deduplicated. +fn kept_contributors(pairs: &[(ProviderId, f64)], outlier_pct: f64) -> Vec { + let clean: Vec<(ProviderId, f64)> = pairs + .iter() + .copied() + .filter(|(_, x)| x.is_finite() && *x > 0.0) + .collect(); + if clean.is_empty() { + return Vec::new(); + } + if clean.len() <= 2 { + return dedup_sort(clean.into_iter().map(|(id, _)| id).collect()); + } + let mut values: Vec = clean.iter().map(|(_, v)| *v).collect(); + values.sort_by(|a, b| a.partial_cmp(b).expect("finite values sort")); + let m = median_sorted(&values); + let tol = m * (outlier_pct / 100.0); + let kept: Vec = clean + .iter() + .copied() + .filter(|(_, x)| (x - m).abs() <= tol) + .map(|(id, _)| id) + .collect(); + if kept.is_empty() { + // Bimodal fallback: combine returns the median (a synthetic + // midpoint matching no value). No single provider is the outlier + // here, so every clean provider stays a contributor. + return dedup_sort(clean.into_iter().map(|(id, _)| id).collect()); + } + dedup_sort(kept) +} + +fn dedup_sort(mut ids: Vec) -> Vec { + ids.sort(); + ids.dedup(); + ids +} + fn mean(xs: &[f64]) -> f64 { xs.iter().sum::() / xs.len() as f64 } @@ -269,15 +359,33 @@ mod tests { }, ); - let out = aggregate_tick(&[yadio, coingecko, eltoque], PCT); + let out = aggregate_tick( + &[ + (ProviderId::Yadio, yadio), + (ProviderId::CoinGecko, coingecko), + (ProviderId::ElToque, eltoque), + ], + PCT, + ); approx(out["USD"].value, 50_000.0); assert_eq!(out["USD"].sources, 2); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CoinGecko], + "USD: both direct quoters survived" + ); approx(out["EUR"].value, 45_000.0); assert_eq!(out["EUR"].sources, 2); - // CUP = combine(Yadio 20M [direct], El Toque 400ร—50_000 = 20M [resolved]). approx(out["CUP"].value, 20_000_000.0); assert_eq!(out["CUP"].sources, 2); + // CUP contributors: Yadio (direct) + El Toque (fiat-cross + // resolved via USD anchor). The USD anchor's own contributors + // (Yadio, CoinGecko) are NOT propagated โ€” they're an intermediate. + assert_eq!( + out["CUP"].contributors, + vec![ProviderId::Yadio, ProviderId::ElToque] + ); } #[test] @@ -286,9 +394,10 @@ mod tests { // was never added). USD has a single source. let mut yadio = ProviderQuotes::new(); yadio.insert("USD".into(), Quote::PerBtc(50_000.0)); - let out = aggregate_tick(&[yadio], PCT); + let out = aggregate_tick(&[(ProviderId::Yadio, yadio)], PCT); approx(out["USD"].value, 50_000.0); assert_eq!(out["USD"].sources, 1); + assert_eq!(out["USD"].contributors, vec![ProviderId::Yadio]); } #[test] @@ -298,10 +407,15 @@ mod tests { a.insert("usd".into(), Quote::PerBtc(50_000.0)); let mut b = ProviderQuotes::new(); b.insert("USD".into(), Quote::PerBtc(50_200.0)); - let out = aggregate_tick(&[a, b], PCT); + let out = aggregate_tick(&[(ProviderId::CurrencyApi, a), (ProviderId::Yadio, b)], PCT); assert_eq!(out.len(), 1, "lowercase and uppercase must merge"); approx(out["USD"].value, 50_100.0); assert_eq!(out["USD"].sources, 2); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CurrencyApi], + "n=2: both contributors keep their seat" + ); } #[test] @@ -315,7 +429,100 @@ mod tests { value: 400.0, }, ); - let out = aggregate_tick(&[eltoque], PCT); + let out = aggregate_tick(&[(ProviderId::ElToque, eltoque)], PCT); assert!(out.is_empty(), "no USD anchor โ†’ CUP cannot resolve"); } + + #[test] + fn aggregate_tick_outlier_drops_provider_from_contributors() { + // The motivating case for the review: three providers, one is a + // wild outlier. `combine` drops it from the value, so the Nostr + // `source` tag must NOT advertise it. With median = 50_100 and + // tol = 5% = 2505, the outlier 75_000 sits way outside the band. + let mut yadio = ProviderQuotes::new(); + yadio.insert("USD".into(), Quote::PerBtc(50_000.0)); + let mut coingecko = ProviderQuotes::new(); + coingecko.insert("USD".into(), Quote::PerBtc(50_200.0)); + let mut blockchain = ProviderQuotes::new(); + blockchain.insert("USD".into(), Quote::PerBtc(75_000.0)); + + let out = aggregate_tick( + &[ + (ProviderId::Yadio, yadio), + (ProviderId::CoinGecko, coingecko), + (ProviderId::Blockchain, blockchain), + ], + PCT, + ); + + // Value: mean of the in-band pair (Yadio 50_000 + CoinGecko 50_200). + approx(out["USD"].value, 50_100.0); + // `sources` still counts all three clean candidates (pre-outlier). + assert_eq!(out["USD"].sources, 3); + // `contributors` is the in-band set โ€” Blockchain dropped. + assert_eq!( + out["USD"].contributors, + vec![ProviderId::Yadio, ProviderId::CoinGecko], + "outlier provider must not be advertised in the source tag" + ); + } + + #[test] + fn aggregate_tick_bimodal_fallback_keeps_all_clean_contributors() { + // Even-length bimodal: combine falls back to the synthetic median. + // No single provider is demonstrably the outlier, so every clean + // provider stays a contributor โ€” matching the comment in + // `kept_contributors`. + let mk = |v: f64| { + let mut q = ProviderQuotes::new(); + q.insert("USD".into(), Quote::PerBtc(v)); + q + }; + let out = aggregate_tick( + &[ + (ProviderId::Yadio, mk(1.0)), + (ProviderId::CoinGecko, mk(2.0)), + (ProviderId::CurrencyApi, mk(100.0)), + (ProviderId::Blockchain, mk(101.0)), + ], + PCT, + ); + approx(out["USD"].value, 51.0); // (2+100)/2 + // All four clean providers survive the bimodal fallback. + // `kept_contributors` -> `dedup_sort` sorts by the derived `Ord` + // on `ProviderId`, which follows enum-variant declaration order + // (Yadio, CoinGecko, CurrencyApi, Blockchain, ElToque). Pin the + // exact list so a future refactor that perturbs ordering โ€” or + // accidentally drops a contributor โ€” is caught by this test + // rather than slipping past a loose `.len()` check. + assert_eq!( + out["USD"].contributors, + vec![ + ProviderId::Yadio, + ProviderId::CoinGecko, + ProviderId::CurrencyApi, + ProviderId::Blockchain, + ] + ); + } + + #[test] + fn aggregate_tick_non_finite_value_drops_provider_from_contributors() { + // A provider returning `0` or `NaN` for the only currency it + // reports must not be claimed as a contributor. + let mut bad = ProviderQuotes::new(); + bad.insert("USD".into(), Quote::PerBtc(f64::NAN)); + let mut good = ProviderQuotes::new(); + good.insert("USD".into(), Quote::PerBtc(50_000.0)); + let out = aggregate_tick( + &[(ProviderId::Yadio, bad), (ProviderId::CoinGecko, good)], + PCT, + ); + approx(out["USD"].value, 50_000.0); + assert_eq!( + out["USD"].contributors, + vec![ProviderId::CoinGecko], + "NaN must drop the provider from contributors, not silently survive" + ); + } } diff --git a/src/price/config.rs b/src/price/config.rs index df37dbe1..6e77e76e 100644 --- a/src/price/config.rs +++ b/src/price/config.rs @@ -74,12 +74,14 @@ impl ProviderConfig { pub fn validate(&self, id: &str) -> Result<(), String> { if self.only.is_some() && self.except.is_some() { return Err(format!( - "price provider '{id}': `only` and `except` are mutually exclusive" + "price provider '{id}': `only` and `except` are mutually exclusive \ + (see docs/PRICE_PROVIDERS.md ยง7)" )); } if self.enabled && self.url.trim().is_empty() { return Err(format!( - "price provider '{id}': enabled provider must have a non-empty `url`" + "price provider '{id}': enabled provider must have a non-empty `url` \ + (see docs/PRICE_PROVIDERS.md ยง7)" )); } Ok(()) diff --git a/src/price/fiat.rs b/src/price/fiat.rs new file mode 100644 index 00000000..68af18ca --- /dev/null +++ b/src/price/fiat.rs @@ -0,0 +1,117 @@ +//! The ยง6.6 fiat allowlist. +//! +//! Providers return junk for our purposes โ€” `currency-api` ships **324+** +//! entries including crypto (`eth`, `bnb`, `ada`) and non-ISO codes, and +//! Yadio quotes metals (`XAU`, `XAG`, `XPT`) and BTC itself. The aggregator +//! restricts to the known fiat set below; everything else is dropped before +//! aggregation and before the Nostr publish, keeping the store and the +//! kind-30078 event lean (spec ยง6.6). +//! +//! The set is **ISO-4217 active codes** plus the non-ISO codes that are +//! nonetheless real, Mostro-traded fiat โ€” all of them present in Yadio's +//! live feed today: +//! +//! - `IRT` โ€” Iranian toman (the everyday unit; `IRR` is the official rial), +//! - `GGP` / `IMP` / `JEP` โ€” Guernsey / Manx / Jersey pounds (GBP-pegged), +//! - `MLC` โ€” Cuban MLC (the spec's motivating fiat-cross case, ยง11.3). +//! +//! Deliberately **excluded**: `VEF` (the pre-redenomination Venezuelan +//! code some APIs still report) โ€” its scale diverges from the ISO `VES` by +//! orders of magnitude, so letting it in could price an order with a +//! garbage rate. Same reasoning as scoping out `currency-api`'s official +//! CUP: a different unit is worse than no data. +//! +//! Against the live Yadio feed this drops exactly `BTC`, `XAU`, `XAG`, +//! `XPT` โ€” the non-fiat tail โ€” and nothing else, so Phase 2 does not +//! silently remove a currency a node was publishing yesterday. + +/// Sorted (binary-searchable) allowlist. Keep alphabetical โ€” there is a +/// test asserting the order so `binary_search` stays correct. +const KNOWN_FIAT: &[&str] = &[ + "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", + "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", + "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", "DZD", + "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", "GNF", + "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "IMP", "INR", "IQD", "IRR", "IRT", + "ISK", "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", + "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MLC", "MMK", + "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", + "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", + "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", + "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", + "UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "VUV", "WST", "XAF", "XCD", "XCG", "XOF", + "XPF", "YER", "ZAR", "ZMW", "ZWL", +]; + +/// Whether `code` (any casing) is a known fiat currency. Non-fiat codes are +/// dropped at the manager boundary before aggregation (spec ยง6.6). +pub fn is_known_fiat(code: &str) -> bool { + let upper = code.to_uppercase(); + KNOWN_FIAT.binary_search(&upper.as_str()).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlist_is_sorted_and_deduped() { + // binary_search precondition; a mis-sorted insert would silently + // make some legitimate currency "unknown". + let mut sorted = KNOWN_FIAT.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(KNOWN_FIAT, sorted.as_slice()); + } + + #[test] + fn accepts_major_and_motivating_codes() { + for code in ["USD", "EUR", "ARS", "CUP", "MLC", "IRT", "VES", "JPY"] { + assert!(is_known_fiat(code), "{code} must be known fiat"); + } + // Case-insensitive: currency-api ships lowercase. + assert!(is_known_fiat("usd")); + assert!(is_known_fiat("cup")); + } + + #[test] + fn rejects_crypto_metals_and_junk() { + // The ยง6.6 motivating junk: crypto from currency-api, metals and + // the BTC self-quote from Yadio. + // `VEF` is rejected on purpose: pre-redenomination unit, not `VES`. + for code in [ + "BTC", "ETH", "BNB", "ADA", "XAU", "XAG", "XPT", "1INCH", "VEF", "", + ] { + assert!(!is_known_fiat(code), "{code} must be rejected"); + } + } + + #[test] + fn live_yadio_codes_survive_except_non_fiat() { + // Captured from the live Yadio feed 2026-06-11 (128 codes). The + // allowlist must pass every one of them except the four non-fiat + // entries โ€” Phase 2 must not silently drop a currency nodes were + // publishing yesterday. + let yadio_live = [ + "AED", "ALL", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", + "BHD", "BIF", "BMD", "BOB", "BRL", "BSD", "BTC", "BTN", "BWP", "BYN", "BZD", "CAD", + "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DJF", "DKK", "DOP", + "DZD", "EGP", "ERN", "ETB", "EUR", "FKP", "GBP", "GEL", "GGP", "GHS", "GIP", "GMD", + "GNF", "GTQ", "HKD", "HNL", "HUF", "IDR", "ILS", "IMP", "INR", "IRR", "IRT", "ISK", + "JEP", "JMD", "JOD", "JPY", "KES", "KGS", "KMF", "KRW", "KYD", "KZT", "LBP", "LKR", + "LSL", "MAD", "MGA", "MLC", "MOP", "MRU", "MWK", "MXN", "MYR", "NAD", "NGN", "NIO", + "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", + "RSD", "RUB", "RWF", "SAR", "SEK", "SGD", "SHP", "SYP", "SZL", "THB", "TMT", "TND", + "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "USD", "UYU", "UZS", "VES", "VND", "XAF", + "XAG", "XAU", "XCD", "XCG", "XOF", "XPT", "ZAR", "ZMW", + ]; + let non_fiat = ["BTC", "XAU", "XAG", "XPT"]; + for code in yadio_live { + if non_fiat.contains(&code) { + assert!(!is_known_fiat(code), "{code} is non-fiat, must drop"); + } else { + assert!(is_known_fiat(code), "{code} from live Yadio must pass"); + } + } + } +} diff --git a/src/price/manager.rs b/src/price/manager.rs new file mode 100644 index 00000000..5df28612 --- /dev/null +++ b/src/price/manager.rs @@ -0,0 +1,1283 @@ +//! [`PriceManager`]: the registry + scheduler tick + read surface +//! (spec ยง5.3, ยง6.4). +//! +//! `PriceManager` owns one [`Box`] per enabled provider +//! plus the aggregated-price [`PriceStore`]. The scheduler calls +//! [`PriceManager::update_all`] every `update_interval_seconds` to poll the +//! providers, aggregate, and write the store; consumers (`get_bitcoin_price`, +//! `BitcoinPriceManager::get_price`) read through [`PriceManager::get_price`]. +//! +//! ## Phase 1 / 2 / 3 invariants (spec ยง9) +//! - The registry is built from `[price]`; the direct quoters (Yadio, +//! CoinGecko, currency-api, Blockchain) and the El Toque fiat-cross +//! quoter (Phase 3, CUP/MLC) are all wired. +//! - Staleness is **logged, not enforced**: a value older than one +//! `update_interval` emits a `warn!` but still returns to the caller, so +//! Phases 1โ€“3 never refuse an order that would have priced today. +//! Enforcement turns on in Phase 4. +//! - Per-provider failures are isolated: a failed poll contributes nothing +//! this tick and the store's last-known-good value is preserved (spec +//! ยง6.4). Providers are polled **concurrently**, each bounded by +//! `provider_timeout_seconds`, and repeated failures open a per-provider +//! circuit breaker with exponential-backoff cooldown (spec ยง6.5). +//! - The ยง6.6 pipeline glue (fiat allowlist + per-provider `only`/`except` +//! scoping) runs at the manager boundary, keeping `aggregate_tick` +//! purely numeric. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::time::Duration; + +use chrono::Utc; +use mostro_core::error::{MostroError, ServiceError}; +use nostr_sdk::prelude::*; +use tracing::{debug, error, info, warn}; + +use super::aggregate::{aggregate_tick, AggregateResult}; +use super::config::{PriceSettings, ProviderConfig}; +use super::fiat::is_known_fiat; +use super::provider::{PriceProvider, ProviderError, ProviderHealth, ProviderId, ProviderQuotes}; +use super::providers::blockchain::BlockchainProvider; +use super::providers::coingecko::CoinGeckoProvider; +use super::providers::currency_api::CurrencyApiProvider; +use super::providers::eltoque::ElToqueProvider; +use super::providers::yadio::YadioProvider; +use super::store::{PriceError, PriceStore}; + +/// Hard cap on the circuit breaker's exponential-backoff cooldown +/// (spec ยง6.5: backs off from `provider_failure_cooldown_seconds` "up to a +/// cap (default 1800)"). Not configurable โ€” a provider that has been down +/// for a while should still be re-probed at least every 30 minutes. +const PROVIDER_COOLDOWN_CAP_SECONDS: u64 = 1800; + +/// Process-wide singleton. Initialized once in `main` after settings load, +/// then read by the scheduler (`update_all`) and consumers (`get_price`). +/// Modelled on `MOSTRO_CONFIG`: `OnceLock` so initialization is panic-free +/// and tests that never call `init_global` see `None`. +static PRICE_MANAGER: OnceLock = OnceLock::new(); + +/// One enabled provider plus its registry metadata and circuit-breaker +/// state (spec ยง6.5). `health` is a `Mutex` (not `RwLock`) because every +/// access mutates; contention is nil โ€” one scheduler tick at a time. +struct EnabledProvider { + id: ProviderId, + provider: Box, + health: Mutex, +} + +/// Outer `Result` is from [`tokio::time::timeout`] (Elapsed = timed out), +/// inner is the adapter's `fetch` outcome. +type TimeoutResult = Result, tokio::time::error::Elapsed>; + +/// Runtime state of the multi-source price module. +pub struct PriceManager { + providers: Vec, + store: Arc, + settings: PriceSettings, + http: reqwest::Client, + /// One-shot guards for the two transient log conditions (spec ยง10.4 + /// asks for transitions, not per-poll spam). Kept as two independent + /// sets so a `Stale` flag never clobbers a `SingleSource` flag (and + /// vice versa) for the same currency โ€” both can hold simultaneously. + warned_stale: RwLock>, + warned_single_source: RwLock>, +} + +impl PriceManager { + /// Build the manager from a `[price]` settings block. + /// + /// Validation runs first so an enabled provider with a missing required + /// secret or an empty `url` fails fast at startup rather than silently + /// returning no quotes (spec ยง7). Disabled providers are skipped; an + /// unknown id is logged but ignored, so adding a provider in a newer + /// release is forward-compatible with an older `mostrod` (the unknown + /// adapter is simply absent until the binary catches up). + pub fn from_settings(settings: PriceSettings) -> Result { + settings.validate()?; + + let mut providers: Vec = Vec::new(); + for (id_str, cfg) in &settings.providers { + if !cfg.enabled { + continue; + } + match id_str.parse::() { + Ok(id) => { + let provider = build_provider(id, cfg)?; + providers.push(EnabledProvider { + id, + provider, + health: Mutex::new(ProviderHealth::new()), + }); + } + Err(_) => { + warn!( + "price: unknown provider id `{id_str}` โ€” ignoring (binary is older than the config?)" + ); + } + } + } + + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(settings.provider_timeout_seconds)) + .user_agent(concat!("mostro/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("price: building HTTP client: {e}"))?; + + Ok(Self { + providers, + store: Arc::new(PriceStore::new()), + settings, + http, + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + }) + } + + /// Install the global manager. Panic-free: subsequent calls return + /// `Err(AlreadyInstalled)` so `main` can detect (and the test suite + /// never collides with) double-initialization. + pub fn install_global(self) -> Result<(), InstallError> { + PRICE_MANAGER + .set(self) + .map_err(|_| InstallError::AlreadyInstalled) + } + + /// Borrow the global manager, if installed. `None` in unit tests that + /// don't bring up the full configuration โ€” every consumer treats that + /// case as "no price available" rather than panicking. + pub fn global() -> Option<&'static PriceManager> { + PRICE_MANAGER.get() + } + + /// Read-only view of the active settings (used by the scheduler to size + /// its sleep and by tests). + pub fn settings(&self) -> &PriceSettings { + &self.settings + } + + /// One scheduler tick: poll all enabled, breaker-available providers + /// **concurrently** โ€” each fetch bounded by `provider_timeout_seconds` + /// โ€” then aggregate and write the store (spec ยง5.3 steps 1โ€“3). A + /// failed/timed-out provider contributes nothing โ€” the store's prior + /// values for its currencies survive as last-known-good (spec ยง6.4) โ€” + /// and counts against its circuit breaker (spec ยง6.5). + /// + /// Returns the per-provider outcome so the scheduler can log outage + /// transitions. + pub async fn update_all(&self) -> TickReport { + let mut report = TickReport::default(); + if self.providers.is_empty() { + warn!("price: no providers enabled โ€” skipping tick"); + return report; + } + + // Circuit breaker (spec ยง6.5): a provider in cooldown is skipped + // outright โ€” no request, no log spam, no tick slow-down. A poisoned + // health lock degrades to "available": polling a sick provider too + // often is the safer failure mode (worst case: log noise), whereas + // never polling again would silently amputate a source. + let now = Utc::now().timestamp(); + let mut pollable: Vec<&EnabledProvider> = Vec::with_capacity(self.providers.len()); + for p in &self.providers { + let available = p.health.lock().map(|h| h.is_available(now)).unwrap_or(true); + if available { + pollable.push(p); + } else { + info!("price: {} skipped: cooldown (circuit breaker open)", p.id); + report.skipped.push(p.id); + } + } + + // Poll concurrently (spec ยง5.3 "poll all healthy providers, in + // parallel"): the tick's wall-clock is the slowest single provider, + // never the sum. Each fetch carries its own [`tokio::time::timeout`] + // sized by [`Self::poll_budget`] โ€” the *per-attempt* bound is the + // shared `reqwest` client's request timeout (`from_settings`), while + // this outer budget covers the provider's whole mirror sequence, so + // a hanging primary cannot starve its `fallback_urls` (they'd be + // dead code in exactly the hung case otherwise). + let outcomes: Vec<(ProviderId, TimeoutResult)> = + futures::future::join_all(pollable.iter().map(|p| async move { + let res = + tokio::time::timeout(self.poll_budget(p.id), p.provider.fetch(&self.http)) + .await; + (p.id, res) + })) + .await; + + let mut quotes_by_provider: Vec<(ProviderId, ProviderQuotes)> = + Vec::with_capacity(pollable.len()); + // Re-stamp the clock: the polls above may have consumed up to a full + // poll budget, and a breaker cooldown anchored at the *pre-poll* + // `now` would be born already partially expired โ€” weakening the + // skip exactly when a slow-failing provider needs it most. + let failed_at = Utc::now().timestamp(); + // `join_all` preserves input order, so `pollable[i]` is the provider + // behind `outcomes[i]` โ€” zip them to feed the breaker. + for (p, (id, outcome)) in pollable.iter().zip(outcomes) { + let ok = match outcome { + Ok(Ok(quotes)) => { + info!("price: {} ok ({} currencies)", id, quotes.len()); + quotes_by_provider.push((id, quotes)); + report.successes.push(id); + true + } + Ok(Err(e)) => { + warn!("price: {} error: {}", id, e); + report.failures.push((id, e.to_string())); + false + } + Err(_) => { + warn!( + "price: {} timed out after {}s (full mirror budget)", + id, + self.poll_budget(id).as_secs() + ); + report.failures.push((id, "timeout".to_string())); + false + } + }; + if let Ok(mut health) = p.health.lock() { + if ok { + health.record_success(); + } else { + health.record_failure( + failed_at, + self.settings.provider_failure_threshold, + self.settings.provider_failure_cooldown_seconds, + PROVIDER_COOLDOWN_CAP_SECONDS, + ); + } + } + } + + // ยง6.6 pipeline glue, at the manager boundary so `aggregate_tick` + // stays purely numeric: + // 1. fiat allowlist โ€” drop crypto/metals/non-ISO junk (e.g. + // currency-api's `eth`, Yadio's `XAU`) before they can form + // single-source aggregates or bloat the Nostr event; + // 2. per-provider `only`/`except` scoping โ€” a mis-marketed source + // (currency-api's official-rate CUP) never enters the median. + let filtered_with_ids: Vec<(ProviderId, ProviderQuotes)> = quotes_by_provider + .into_iter() + .map(|(id, quotes)| { + let before = quotes.len(); + let fiat_only: ProviderQuotes = quotes + .into_iter() + .filter(|(code, _)| is_known_fiat(code)) + .collect(); + let dropped = before - fiat_only.len(); + if dropped > 0 { + debug!( + "price: {} dropped {} non-fiat codes (allowlist)", + id, dropped + ); + } + (id, self.scope_quotes(id, fiat_only)) + }) + .collect(); + + let aggregates = aggregate_tick(&filtered_with_ids, self.settings.outlier_threshold_pct); + if aggregates.is_empty() { + warn!("price: tick produced no fresh aggregates โ€” keeping last-known-good"); + return report; + } + + // Tick-wide Nostr contributors = union of every per-currency + // contributor list (spec ยง9 Phase 1 "contributing-source list"). + // Built from `aggregate_tick`'s provenance output so the tag + // reflects the providers whose quotes actually **survived** + // `combine`'s outlier filter, not merely those whose post-scope + // map was non-empty. + let mut contributor_set: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for agg in aggregates.values() { + contributor_set.extend(agg.contributors.iter().copied()); + } + let contributors: Vec = contributor_set.into_iter().collect(); + + let now = Utc::now().timestamp(); + self.observe_warnings(&aggregates); + self.store.update(aggregates.clone(), now); + report.fresh_currencies = aggregates.len(); + report.contributors = contributors; + + if self.settings.publish_to_nostr { + self.publish_rates_to_nostr(&aggregates, &report.contributors) + .await; + } + + report + } + + /// Wall-clock budget for one provider's poll: `provider_timeout_seconds` + /// times the number of URLs the provider may try (primary + + /// `fallback_urls`), plus one second of slack for inter-attempt + /// overhead. + /// + /// Layering: the **per-attempt** bound is enforced by the shared + /// `reqwest` client (`from_settings` sets + /// `.timeout(provider_timeout_seconds)`), so a hung mirror burns one + /// slot of this budget, not all of it. Sizing the outer + /// [`tokio::time::timeout`] to the whole sequence keeps the ยง7 + /// "mirrors tried in sequence" promise alive in the hung-primary case โ€” + /// with a flat budget the fallbacks were dead code precisely when the + /// primary hung rather than refused (Codex review on PR #773). + fn poll_budget(&self, id: ProviderId) -> Duration { + let attempts = self + .settings + .providers + .get(&id.to_string()) + .map(|c| 1 + c.fallback_urls.len() as u64) + .unwrap_or(1) + .max(1); + Duration::from_secs( + self.settings + .provider_timeout_seconds + .saturating_mul(attempts) + .saturating_add(1), + ) + } + + /// Apply this provider's `only`/`except` filter (spec ยง6.6). Done at the + /// manager boundary so the aggregator stays provider-agnostic. + fn scope_quotes(&self, id: ProviderId, quotes: ProviderQuotes) -> ProviderQuotes { + let cfg = match self.settings.providers.get(&id.to_string()) { + Some(c) => c, + None => return quotes, + }; + if cfg.only.is_none() && cfg.except.is_none() { + return quotes; + } + quotes + .into_iter() + .filter(|(currency, _)| cfg.allows_currency(currency)) + .collect() + } + + /// Emit one-shot warnings on the single-source transition: a currency + /// with one contributor warns once; gaining a second contributor + /// clears the flag so a later regression warns again (spec ยง10.4). + fn observe_warnings(&self, aggregates: &HashMap) { + for (currency, agg) in aggregates { + let key = currency.to_uppercase(); + if agg.sources <= 1 { + if self.mark_warned(&self.warned_single_source, &key) { + warn!("price: {} now has a single source", currency); + } + } else { + self.clear_warned(&self.warned_single_source, &key); + } + } + } + + /// Read a currency's per-BTC price. + /// + /// Phase 1 behaviour (spec ยง9 Phase 1): the staleness window is checked + /// and a `warn!` is logged on the **transition** into a stale state, + /// but the price is still returned. The next call after a fresh tick + /// clears the flag so future regressions warn again. Phase 4 turns + /// this into `Err(PriceTooStale)`; doing it now would refuse orders + /// that today's code happily prices, which is explicitly out of scope. + pub fn get_price(&self, currency: &str) -> Result { + let now = Utc::now().timestamp(); + let key = currency.to_uppercase(); + match self + .store + .get(currency, self.settings.max_price_staleness_seconds, now) + { + Ok(value) => { + self.observe_freshness(currency, &key, now); + Ok(value) + } + Err(PriceError::TooStale) => { + // Phase 1: log but still return the value โ€” preserve the + // legacy "never refuse" behaviour. Phase 4 will turn this + // into a hard error. + let snap = self.store.snapshot(currency); + if let Some(entry) = snap { + let age = now.saturating_sub(entry.as_of); + if self.mark_warned(&self.warned_stale, &key) { + warn!( + "price: {} is past staleness window ({}s old) โ€” Phase 1 still serves it", + currency, age + ); + } + Ok(entry.value) + } else { + // Should not happen: TooStale means an entry exists, + // but tolerate the race in case the entry was wiped + // between get and snapshot. + Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)) + } + } + Err(PriceError::NoCurrency) => { + Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)) + } + } + } + + /// Test-only escape hatch so unit tests can drive the manager without + /// the global lock. Avoid in production code; use [`Self::global`]. + #[cfg(test)] + pub fn store(&self) -> &PriceStore { + &self.store + } + + /// Inspect the entry served by the `Ok` branch of [`Self::get_price`] + /// to emit the "stale but within TTL" warning at most once, and to + /// clear the past-TTL flag once a fresh enough value lands so the + /// next slide past the TTL warns again. + fn observe_freshness(&self, currency: &str, key: &str, now: i64) { + let Some(entry) = self.store.snapshot(currency) else { + return; + }; + let age = now.saturating_sub(entry.as_of); + let one_interval = self.settings.update_interval_seconds as i64; + if age <= one_interval { + // Fully fresh โ€” also wipe any past-TTL flag so a future slide + // past `max_price_staleness_seconds` warns once more. + self.clear_warned(&self.warned_stale, key); + return; + } + if self.mark_warned(&self.warned_stale, key) { + warn!( + "price: {} is stale ({}s old, > {}s interval)", + currency, age, one_interval + ); + } + } + + /// Insert `key` into `set`; return `true` if this is the first time + /// (the caller should warn) and `false` if the flag was already there. + /// A poisoned lock is treated as "already warned" so callers stay + /// quiet on lock failure rather than spamming after a panic. + fn mark_warned(&self, set: &RwLock>, key: &str) -> bool { + match set.write() { + Ok(mut w) => w.insert(key.to_string()), + Err(_) => false, + } + } + + fn clear_warned(&self, set: &RwLock>, key: &str) { + if let Ok(mut w) = set.write() { + w.remove(key); + } + } + + /// Publish the aggregated map to Nostr (NIP-33 kind 30078). Phase 1 + /// preserves the legacy Yadio-shaped wrapper so downstream consumers + /// keep working byte-for-byte; the `source` tag becomes the list of + /// **contributing** provider ids (spec ยง9 Phase 1: still effectively + /// one source, but the multi-source shape is in place). Publishing is + /// best-effort and never fails the tick. + async fn publish_rates_to_nostr( + &self, + aggregates: &HashMap, + successes: &[ProviderId], + ) { + // Build the `{"BTC": {ccy: value}}` body the legacy format used. + let rates: HashMap = aggregates + .iter() + .map(|(c, a)| (c.clone(), a.value)) + .collect(); + let mut wrapper: HashMap> = HashMap::new(); + wrapper.insert("BTC".to_string(), rates); + + let content = match serde_json::to_string(&wrapper) { + Ok(c) => c, + Err(e) => { + error!("price: failed to serialise rates for Nostr: {e}"); + return; + } + }; + + let keys = match crate::util::get_keys() { + Ok(k) => k, + Err(e) => { + error!("price: failed to get Mostro keys for Nostr publish: {e}"); + return; + } + }; + + let timestamp = Utc::now().timestamp(); + // Match legacy bitcoin_price.rs: 2ร— the interval, capped at 1h. + let expiration_seconds = std::cmp::min(self.settings.update_interval_seconds * 2, 3600); + let expiration = timestamp + expiration_seconds as i64; + let source_tag = sources_to_tag(successes); + let tags = Tags::from_list(vec![ + Tag::custom( + TagKind::Custom("published_at".into()), + vec![timestamp.to_string()], + ), + Tag::custom(TagKind::Custom("source".into()), vec![source_tag]), + Tag::expiration(Timestamp::from(expiration as u64)), + ]); + + let event = match crate::nip33::new_exchange_rates_event(&keys, &content, tags) { + Ok(e) => e, + Err(e) => { + error!("price: failed to build exchange-rates event: {e}"); + return; + } + }; + + let client = match crate::util::get_nostr_client() { + Ok(c) => c, + Err(e) => { + error!("price: failed to get Nostr client: {e}"); + return; + } + }; + + let timeout_duration = Duration::from_secs(30); + match tokio::time::timeout(timeout_duration, client.send_event(&event)).await { + Ok(Ok(output)) => info!( + "price: published exchange rates to Nostr ({} currencies). Output: {:?}", + aggregates.len(), + output + ), + Ok(Err(e)) => error!("price: send_event to relays failed: {e}"), + Err(_) => error!("price: timeout publishing exchange rates to Nostr (30s exceeded)"), + } + } +} + +/// Joined list of contributing provider ids for the Nostr `source` tag. +/// Sorted so the tag is deterministic across ticks with the same provider +/// set, regardless of map-iteration order. +fn sources_to_tag(ids: &[ProviderId]) -> String { + let mut names: Vec = ids.iter().map(|i| i.to_string()).collect(); + names.sort(); + names.join(",") +} + +/// Single designated extension point (spec ยง5.4 Step 3). Adding a new +/// provider adds exactly one match arm here โ€” the aggregation core, the +/// store, the scheduler, and every order handler stay untouched. +fn build_provider(id: ProviderId, cfg: &ProviderConfig) -> Result, String> { + match id { + ProviderId::Yadio => Ok(Box::new(YadioProvider::new(cfg))), + ProviderId::CoinGecko => Ok(Box::new(CoinGeckoProvider::new(cfg))), + ProviderId::CurrencyApi => Ok(Box::new(CurrencyApiProvider::new(cfg))), + ProviderId::Blockchain => Ok(Box::new(BlockchainProvider::new(cfg))), + // El Toque (fiat-cross CUP/MLC). `new` returns `Err` when the + // required Bearer token is missing, so an enabled-but-unconfigured + // provider fails fast at startup (spec ยง7). + ProviderId::ElToque => Ok(Box::new(ElToqueProvider::new(cfg)?)), + } +} + +/// Reason [`PriceManager::install_global`] refused โ€” currently just one +/// variant, but exposed as an enum so the surface stays forward-compatible +/// without breaking callers (Phase 5 may grow shutdown/restart cases). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallError { + /// Another `PriceManager` is already in place. + AlreadyInstalled, +} + +impl std::fmt::Display for InstallError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InstallError::AlreadyInstalled => f.write_str("PriceManager already installed"), + } + } +} + +impl std::error::Error for InstallError {} + +/// Per-tick outcome used by the scheduler (for outage logging) and tests. +#[derive(Debug, Default)] +pub struct TickReport { + /// Providers whose [`PriceProvider::fetch`] returned `Ok` this tick. + pub successes: Vec, + /// Providers that failed or timed out, with the stringified error. + pub failures: Vec<(ProviderId, String)>, + /// Providers not polled because their circuit breaker is in cooldown + /// (spec ยง6.5). Neither a success nor a failure: the breaker state + /// carries over to the next tick. + pub skipped: Vec, + /// Providers whose post-scope quote map was non-empty โ€” i.e. those + /// that actually contributed at least one currency to the aggregate. + /// Distinct from `successes`: a scoped-out provider lands in + /// `successes` (it did poll OK) but **not** in `contributors`. + pub contributors: Vec, + /// Number of currencies the tick produced a fresh aggregate for. + pub fresh_currencies: usize, +} + +/// Synthesise the legacy single-source config from the top-level +/// `[mostro]` block (spec ยง10.1). Used when `[price]` is absent so existing +/// `settings.toml` files keep working byte-for-byte. +pub fn synthesise_legacy_price_settings( + bitcoin_price_api_url: &str, + exchange_rates_update_interval_seconds: u64, + publish_exchange_rates_to_nostr: bool, +) -> PriceSettings { + let mut providers = HashMap::new(); + providers.insert( + ProviderId::Yadio.to_string(), + ProviderConfig { + enabled: true, + url: bitcoin_price_api_url.to_string(), + fallback_urls: Vec::new(), + api_key: None, + token: None, + only: None, + except: None, + }, + ); + PriceSettings { + // Honour the legacy interval setting verbatim so an upgrade doesn't + // change a node's polling cadence behind the operator's back. + update_interval_seconds: exchange_rates_update_interval_seconds, + publish_to_nostr: publish_exchange_rates_to_nostr, + providers, + ..PriceSettings::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::price::provider::{ProviderQuotes, Quote}; + use async_trait::async_trait; + + /// In-process double for the registry's `Box` โ€” + /// drives the manager end-to-end (poll โ†’ aggregate โ†’ store) with no + /// HTTP. Mirrors the unit-test mock in `provider.rs` but lives here so + /// we can swap it directly into `PriceManager.providers`. + struct ScriptedProvider { + id: ProviderId, + outcomes: std::sync::Mutex>>, + } + + impl ScriptedProvider { + fn new(id: ProviderId, outcomes: Vec>) -> Self { + Self { + id, + outcomes: std::sync::Mutex::new(outcomes), + } + } + } + + #[async_trait] + impl PriceProvider for ScriptedProvider { + fn id(&self) -> ProviderId { + self.id + } + async fn fetch(&self, _http: &reqwest::Client) -> Result { + let mut q = self.outcomes.lock().unwrap(); + if q.is_empty() { + // Once the script runs out, behave as a healthy noop. + return Ok(ProviderQuotes::new()); + } + q.remove(0) + } + } + + fn manager_with_many(scripted: Vec) -> PriceManager { + // Disable Nostr publishing so tests don't reach the global Nostr + // client (which isn't installed in unit tests); short timeout so a + // hanging mock can't blow the test runner. + let mut settings = PriceSettings { + publish_to_nostr: false, + provider_timeout_seconds: 5, + ..PriceSettings::default() + }; + let mut providers = Vec::new(); + for s in scripted { + settings.providers.insert( + s.id.to_string(), + ProviderConfig { + enabled: true, + url: "http://test".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + providers.push(EnabledProvider { + id: s.id, + provider: Box::new(s), + health: Mutex::new(ProviderHealth::new()), + }); + } + PriceManager { + providers, + store: Arc::new(PriceStore::new()), + settings, + http: reqwest::Client::new(), + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + } + } + + fn manager_with(scripted: ScriptedProvider) -> PriceManager { + manager_with_many(vec![scripted]) + } + + #[tokio::test] + async fn single_yadio_tick_matches_today() { + // Spec ยง9 Phase 1 acceptance: with only Yadio enabled, the manager + // produces the same values as the legacy single-source path for a + // captured sample payload. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(75_899.55)); + quotes.insert("EUR".into(), Quote::PerBtc(65_393.99)); + quotes.insert("ARS".into(), Quote::PerBtc(75_899_550.0)); + + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let manager = manager_with(scripted); + + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert_eq!( + report.contributors, + vec![ProviderId::Yadio], + "yadio's quotes all survived scoping, so it contributes" + ); + assert!(report.failures.is_empty()); + assert_eq!(report.fresh_currencies, 3); + + assert!( + (manager.get_price("USD").unwrap() - 75_899.55).abs() < 1e-6, + "USD matches Yadio's value verbatim" + ); + assert!((manager.get_price("eur").unwrap() - 65_393.99).abs() < 1e-6); + } + + #[tokio::test] + async fn yadio_down_keeps_prior_values() { + // Spec ยง9 Phase 1 acceptance: a failed tick must leave the store + // intact, not wipe it. Two ticks: first succeeds, second errors. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes), Err(ProviderError::Http("down".into()))], + ); + let manager = manager_with(scripted); + + manager.update_all().await; + let r = manager.update_all().await; + + assert_eq!(r.successes, Vec::::new()); + assert_eq!(r.failures.len(), 1); + // Store still serves the prior tick's value โ€” no panic, no wipe. + assert!((manager.get_price("USD").unwrap() - 50_000.0).abs() < 1e-6); + } + + #[tokio::test] + async fn no_providers_returns_no_currency() { + // Spec ยง9 Phase 1 acceptance: enabled=false on every provider โ†’ + // empty store; reads return an error matching "no data yet" today. + let settings = PriceSettings { + publish_to_nostr: false, + ..PriceSettings::default() + }; + let manager = PriceManager { + providers: vec![], + store: Arc::new(PriceStore::new()), + settings, + http: reqwest::Client::new(), + warned_stale: RwLock::new(HashSet::new()), + warned_single_source: RwLock::new(HashSet::new()), + }; + let r = manager.update_all().await; + assert_eq!(r.fresh_currencies, 0); + assert!(manager.get_price("USD").is_err()); + } + + #[tokio::test] + async fn scoping_only_keeps_in_scope_currencies() { + // The El-Toque-style `only` filter is implemented here even though + // Phase 3 brings the adapter, so the Phase 1 manager already + // honours per-provider scoping. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + quotes.insert("CUP".into(), Quote::PerBtc(20_000_000.0)); + + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let mut manager = manager_with(scripted); + manager + .settings + .providers + .get_mut(&ProviderId::Yadio.to_string()) + .unwrap() + .only = Some(vec!["CUP".into()]); + + manager.update_all().await; + assert!(manager.get_price("CUP").is_ok()); + assert!(manager.get_price("USD").is_err()); + } + + #[test] + fn synthesise_legacy_builds_single_yadio_provider() { + let cfg = synthesise_legacy_price_settings("https://api.yadio.io", 600, false); + assert_eq!(cfg.update_interval_seconds, 600); + assert!(!cfg.publish_to_nostr); + let yadio = cfg + .providers + .get("yadio") + .expect("legacy migration must enable yadio"); + assert!(yadio.enabled); + assert_eq!(yadio.url, "https://api.yadio.io"); + cfg.validate().expect("synthesised config must validate"); + } + + fn eltoque_cfg(token: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: "https://tasas.eltoque.com".into(), + fallback_urls: vec![], + api_key: None, + token: token.map(String::from), + only: Some(vec!["CUP".into(), "MLC".into()]), + except: None, + } + } + + #[test] + fn from_settings_builds_eltoque_with_token() { + // Phase 3: El Toque is now wired. With its required Bearer token it + // builds into the registry like any other provider. + let mut settings = PriceSettings::default(); + settings + .providers + .insert(ProviderId::ElToque.to_string(), eltoque_cfg(Some("tok"))); + let m = PriceManager::from_settings(settings).expect("eltoque builds with a token"); + assert_eq!(m.providers.len(), 1); + assert_eq!(m.providers[0].id, ProviderId::ElToque); + } + + #[test] + fn from_settings_rejects_eltoque_without_token() { + // Spec ยง7: an enabled El Toque missing its required Bearer token must + // fail fast at startup, not silently produce nothing. + let mut settings = PriceSettings::default(); + settings + .providers + .insert(ProviderId::ElToque.to_string(), eltoque_cfg(None)); + assert!(PriceManager::from_settings(settings).is_err()); + } + + #[test] + fn from_settings_builds_all_phase2_providers() { + // Spec ยง9 Phase 2: the three keyless backups join Yadio in the + // registry โ€” the ยง7 example config must now build cleanly. + let mut settings = PriceSettings::default(); + for (id, url) in [ + (ProviderId::Yadio, "https://api.yadio.io"), + (ProviderId::CoinGecko, "https://api.coingecko.com/api/v3"), + (ProviderId::CurrencyApi, "https://currency-api.pages.dev/v1"), + (ProviderId::Blockchain, "https://blockchain.info"), + ] { + settings.providers.insert( + id.to_string(), + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + } + let m = PriceManager::from_settings(settings).expect("phase 2 registry builds"); + assert_eq!(m.providers.len(), 4); + } + + #[test] + fn from_settings_ignores_unknown_id() { + // Adding a new provider in a newer release should not break an + // older mostrod reading the same config โ€” unknown ids are logged + // and ignored. + let mut settings = PriceSettings::default(); + settings.providers.insert( + "future_provider".to_string(), + ProviderConfig { + enabled: true, + url: "http://x".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + let m = PriceManager::from_settings(settings).expect("unknown id is non-fatal"); + assert!(m.providers.is_empty()); + } + + #[test] + fn from_settings_skips_disabled_providers() { + let mut settings = PriceSettings::default(); + settings.providers.insert( + ProviderId::Yadio.to_string(), + ProviderConfig { + enabled: false, + url: "https://api.yadio.io".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }, + ); + let m = PriceManager::from_settings(settings).unwrap(); + assert!(m.providers.is_empty()); + } + + #[test] + fn sources_to_tag_is_deterministic() { + let tag = sources_to_tag(&[ProviderId::CoinGecko, ProviderId::Yadio]); + assert_eq!(tag, "coingecko,yadio"); + } + + #[tokio::test] + async fn scoped_out_provider_is_success_but_not_contributor() { + // A successful poll whose every currency is filtered by `only` + // contributes nothing to the aggregate โ€” it must land in + // `report.successes` (it did poll OK and circuit breaker stays + // happy in Phase 2) but **not** in `report.contributors`, so the + // Nostr `source` tag never names a provider that didn't move the + // aggregate (spec ยง9 Phase 1: "contributing-source list"). + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes)]); + let mut manager = manager_with(scripted); + // Yadio is restricted to MLC โ€” none of its quotes match. + manager + .settings + .providers + .get_mut(&ProviderId::Yadio.to_string()) + .unwrap() + .only = Some(vec!["MLC".into()]); + + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert!( + report.contributors.is_empty(), + "scoped-out provider must not appear in the Nostr source tag" + ); + assert_eq!(report.fresh_currencies, 0); + } + + fn quotes_of(pairs: &[(&str, f64)]) -> ProviderQuotes { + pairs + .iter() + .map(|(c, v)| (c.to_string(), Quote::PerBtc(*v))) + .collect() + } + + #[tokio::test] + async fn multi_source_aggregate_is_median_plus_outlier_mean() { + // Spec ยง9 Phase 2 acceptance: EUR/USD aggregate = median + outlier + // across all live direct quoters, and a wild outlier with โ‰ฅ3 + // sources is discarded. USD candidates {49_500, 50_000, 50_500, + // 80_000}: median 50_250, the 5% band keeps the first three, the + // 80_000 outlier is dropped โ†’ mean = 50_000. + let providers = vec![ + ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes_of(&[("USD", 50_000.0), ("EUR", 43_000.0)]))], + ), + ScriptedProvider::new( + ProviderId::CoinGecko, + vec![Ok(quotes_of(&[("USD", 50_500.0), ("EUR", 43_200.0)]))], + ), + ScriptedProvider::new( + ProviderId::Blockchain, + vec![Ok(quotes_of(&[("USD", 49_500.0), ("EUR", 42_800.0)]))], + ), + ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[("USD", 80_000.0)]))], // wild outlier + ), + ]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!(report.successes.len(), 4); + + let usd = manager.get_price("USD").unwrap(); + assert!( + (usd - 50_000.0).abs() < 1e-6, + "outlier must be discarded before the mean, got {usd}" + ); + let eur = manager.get_price("EUR").unwrap(); + assert!( + (eur - 43_000.0).abs() < 1e-6, + "median-anchored mean, got {eur}" + ); + // The outlier provider polled OK but its value did not survive โ€” + // it must not appear in the contributing-source list. + assert!(report.contributors.contains(&ProviderId::Yadio)); + assert!(!report.contributors.contains(&ProviderId::CurrencyApi)); + } + + #[tokio::test] + async fn lowercase_and_uppercase_codes_combine() { + // Spec ยง9 Phase 2 acceptance: lowercase currency-api codes combine + // with uppercase Yadio codes โ€” the normalisation test that would + // silently fail without ยง6.6. (Adapters canonicalise; this guards + // the aggregator-side uppercase against a future adapter that + // forgets.) + let providers = vec![ + ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes_of(&[("USD", 50_000.0)]))]), + ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[("usd", 51_000.0)]))], + ), + ]; + let manager = manager_with_many(providers); + manager.update_all().await; + let usd = manager.get_price("USD").unwrap(); + assert!( + (usd - 50_500.0).abs() < 1e-6, + "two casings must form ONE two-source aggregate (mean), got {usd}" + ); + } + + #[tokio::test] + async fn non_fiat_codes_are_dropped_by_allowlist() { + // Spec ยง9 Phase 2 acceptance: non-fiat codes (`eth`, `bnb`) from + // currency-api are dropped by the allowlist โ€” as are Yadio's + // metals/BTC self-quote. + let providers = vec![ScriptedProvider::new( + ProviderId::CurrencyApi, + vec![Ok(quotes_of(&[ + ("usd", 50_000.0), + ("eth", 37.8), + ("bnb", 150.0), + ("xau", 25.0), + ("btc", 1.0), + ]))], + )]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!( + report.fresh_currencies, 1, + "only USD survives the allowlist" + ); + assert!(manager.get_price("USD").is_ok()); + assert!(manager.get_price("ETH").is_err()); + assert!(manager.get_price("BTC").is_err()); + } + + #[tokio::test] + async fn official_cup_is_scoped_out_by_except() { + // Spec ยง9 Phase 2 acceptance: currency-api's official-rate CUP is + // scoped out by the shipped `except = ["CUP","MLC"]`, so it never + // enters the CUP aggregate โ€” Yadio's informal rate stands alone. + let providers = vec![ + ScriptedProvider::new( + ProviderId::Yadio, + vec![Ok(quotes_of(&[("USD", 50_000.0), ("CUP", 20_000_000.0)]))], + ), + ScriptedProvider::new( + ProviderId::CurrencyApi, + // Official rate: ~26 CUP/USD โ†’ 1.3M CUP/BTC โ€” 15ร— off. + vec![Ok(quotes_of(&[("usd", 50_100.0), ("cup", 1_300_000.0)]))], + ), + ]; + let mut manager = manager_with_many(providers); + manager + .settings + .providers + .get_mut(&ProviderId::CurrencyApi.to_string()) + .unwrap() + .except = Some(vec!["CUP".into(), "MLC".into()]); + + manager.update_all().await; + let cup = manager.get_price("CUP").unwrap(); + assert!( + (cup - 20_000_000.0).abs() < 1e-6, + "official-rate CUP must never enter the aggregate (got {cup}); \ + with only 2 sources the outlier guard cannot save us โ€” scoping must" + ); + // USD still combines from both. + let usd = manager.get_price("USD").unwrap(); + assert!((usd - 50_050.0).abs() < 1e-6); + } + + #[tokio::test] + async fn provider_down_falls_back_to_remaining_sources() { + // Spec ยง9 Phase 2 acceptance: a provider down โ†’ currencies fall + // back to the remaining sources, same tick. + let providers = vec![ + ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes_of(&[("USD", 50_000.0)]))]), + ScriptedProvider::new( + ProviderId::CoinGecko, + vec![Err(ProviderError::Http("down".into()))], + ), + ]; + let manager = manager_with_many(providers); + let report = manager.update_all().await; + assert_eq!(report.successes, vec![ProviderId::Yadio]); + assert_eq!(report.failures.len(), 1); + assert!((manager.get_price("USD").unwrap() - 50_000.0).abs() < 1e-6); + } + + #[tokio::test] + async fn circuit_breaker_skips_after_threshold_failures() { + // Spec ยง9 Phase 2 acceptance: the breaker opens after N consecutive + // failures, and an open breaker means the provider is not even + // polled next tick (skipped, not failed). The cooldown/half-open + // timing math is covered by the pure ProviderHealth unit tests. + let scripted = ScriptedProvider::new( + ProviderId::CoinGecko, + vec![ + Err(ProviderError::Http("down".into())), + // Would succeed if (wrongly) polled while the breaker is open: + Ok(quotes_of(&[("USD", 50_000.0)])), + ], + ); + let mut manager = manager_with(scripted); + manager.settings.provider_failure_threshold = 1; + manager.settings.provider_failure_cooldown_seconds = 3_600; // โ‰ซ test runtime + + let first = manager.update_all().await; + assert_eq!( + first.failures.len(), + 1, + "tick 1: the failure trips the breaker" + ); + assert!(first.skipped.is_empty()); + + let second = manager.update_all().await; + assert_eq!( + second.skipped, + vec![ProviderId::CoinGecko], + "tick 2: open breaker โ†’ skipped without polling" + ); + assert!( + second.successes.is_empty(), + "the scripted Ok was never consumed" + ); + assert!(second.failures.is_empty(), "skipped โ‰  failed"); + } + + #[test] + fn poll_budget_scales_with_fallback_urls() { + // The outer per-provider timeout must cover the whole mirror + // sequence (primary + fallbacks), or a hung primary starves the + // mirrors (Codex review on PR #773). Per-attempt bounding is the + // shared reqwest client's job. + let scripted = ScriptedProvider::new(ProviderId::CurrencyApi, vec![]); + let mut manager = manager_with(scripted); + manager.settings.provider_timeout_seconds = 10; + + // No fallbacks: one attempt + 1s slack. + assert_eq!( + manager.poll_budget(ProviderId::CurrencyApi), + Duration::from_secs(11) + ); + // Two mirrors: three attempts + slack. + manager + .settings + .providers + .get_mut(&ProviderId::CurrencyApi.to_string()) + .unwrap() + .fallback_urls = vec!["http://m1".into(), "http://m2".into()]; + assert_eq!( + manager.poll_budget(ProviderId::CurrencyApi), + Duration::from_secs(31) + ); + // Unknown id (defensive): single-attempt budget. + assert_eq!( + manager.poll_budget(ProviderId::Blockchain), + Duration::from_secs(11) + ); + } + + #[tokio::test] + async fn breaker_success_after_cooldown_resets() { + // With a zero-second cooldown the breaker re-probes immediately; + // a success must reset it (no skip on the following tick). + let scripted = ScriptedProvider::new( + ProviderId::CoinGecko, + vec![ + Err(ProviderError::Http("down".into())), + Ok(quotes_of(&[("USD", 50_000.0)])), + Ok(quotes_of(&[("USD", 50_100.0)])), + ], + ); + let mut manager = manager_with(scripted); + manager.settings.provider_failure_threshold = 1; + manager.settings.provider_failure_cooldown_seconds = 0; // immediate re-probe + + manager.update_all().await; // fails, opens (0s cooldown) + let second = manager.update_all().await; // re-probe succeeds โ†’ reset + assert_eq!(second.successes, vec![ProviderId::CoinGecko]); + let third = manager.update_all().await; + assert_eq!(third.successes, vec![ProviderId::CoinGecko]); + assert!(third.skipped.is_empty()); + } + + #[tokio::test] + async fn stale_warning_is_one_shot_then_re_arms_on_fresh_read() { + // Build a manager whose only stored value is intentionally past + // the TTL, then call get_price() many times: the warned_stale set + // must grow by at most one entry. A fresh tick clears the flag + // so a future regression past TTL warns again. + let mut quotes = ProviderQuotes::new(); + quotes.insert("USD".into(), Quote::PerBtc(50_000.0)); + let scripted = ScriptedProvider::new(ProviderId::Yadio, vec![Ok(quotes.clone())]); + let mut manager = manager_with(scripted); + // Force the TTL low so the manually-written `as_of` is past it. + manager.settings.max_price_staleness_seconds = 1; + manager.settings.update_interval_seconds = 1; + + // Seed an explicitly-stale entry. + let mut agg = HashMap::new(); + agg.insert( + "USD".to_string(), + AggregateResult { + value: 50_000.0, + sources: 1, + contributors: vec![ProviderId::Yadio], + }, + ); + // 1_000_000s ago: well past any plausible TTL. + let now = Utc::now().timestamp(); + manager.store.update(agg, now - 1_000_000); + + // 10 reads against a stale value: warned_stale must end with + // exactly one entry, regardless of how many times the legacy + // code would have logged. + for _ in 0..10 { + let _ = manager.get_price("USD"); + } + assert_eq!( + manager.warned_stale.read().unwrap().len(), + 1, + "TooStale must warn at most once between fresh reads" + ); + + // Inject a fresh tick: the `Ok` branch fires and clears the flag, + // so a subsequent regression past TTL warns once more. + let mut fresh = HashMap::new(); + fresh.insert( + "USD".to_string(), + AggregateResult { + value: 50_000.0, + sources: 1, + contributors: vec![ProviderId::Yadio], + }, + ); + let fresh_now = Utc::now().timestamp(); + manager.store.update(fresh, fresh_now); + // Read once at fresh time so observe_freshness clears the flag. + let _ = manager.get_price("USD"); + assert!( + manager.warned_stale.read().unwrap().is_empty(), + "fresh read must re-arm the stale guard" + ); + } +} diff --git a/src/price/mod.rs b/src/price/mod.rs index 8c51a91a..2ff1e677 100644 --- a/src/price/mod.rs +++ b/src/price/mod.rs @@ -1,28 +1,76 @@ //! Multi-source BTC/fiat price module (see `docs/PRICE_PROVIDERS.md`). //! -//! Phase 0 โ€” **foundation only**. This delivers the building blocks every -//! later phase reuses, with **no wiring**: the [`PriceProvider`] trait and -//! its data types ([`provider`]), the pure aggregation core -//! ([`aggregate`]), the in-memory aggregated-price store ([`store`]), and -//! the typed `[price]` configuration ([`config`]). Nothing here makes an -//! HTTP request, touches the scheduler, or is read by an order handler; -//! that begins in Phase 1. +//! ## Phase 1 +//! The module is wired into the daemon: [`PriceManager`] builds the +//! provider registry from `[price]` (or a legacy migration when the +//! section is absent, spec ยง10.1), the scheduler drives +//! [`PriceManager::update_all`] every `update_interval_seconds`, and +//! [`get_bitcoin_price`] / `BitcoinPriceManager::get_price` read through +//! the manager. //! -//! Because the module is not yet referenced from `main`/handlers, it would -//! otherwise trip the `dead_code` lint in a plain `cargo build`. The -//! allow below is scoped to this module and is **removed in Phase 1**, -//! when `PriceManager` wires the registry into the scheduler and -//! `get_bitcoin_price`. -#![allow(dead_code)] +//! ## Phase 2 +//! The system is genuinely multi-source: the keyless direct backups +//! ([`providers::coingecko`], [`providers::currency_api`], +//! [`providers::blockchain`]) join [`providers::yadio`]; providers are +//! polled **concurrently** with a per-provider timeout and circuit +//! breaker (spec ยง6.5), and the ยง6.6 pipeline glue (code canonicalisation, +//! the [`fiat`] allowlist, per-provider `only`/`except` scoping) sits +//! between the adapters and the aggregation core. El Toque lands in +//! Phase 3. pub mod aggregate; pub mod config; +pub mod fiat; +pub mod manager; pub mod provider; +pub mod providers; pub mod store; pub use aggregate::{aggregate_tick, combine, resolve_per_base, AggregateResult}; pub use config::{PriceSettings, ProviderConfig}; +pub use manager::{synthesise_legacy_price_settings, PriceManager, TickReport}; pub use provider::{ PriceProvider, ProviderError, ProviderHealth, ProviderId, ProviderQuotes, Quote, }; pub use store::{AggregatedPrice, PriceError, PriceStore}; + +use mostro_core::error::{MostroError, ServiceError}; + +/// Test-only per-currency price overrides consulted by +/// [`get_bitcoin_price`] before the global manager. Unit tests in other +/// modules (e.g. range-order bond sizing in `util.rs`) seed this via +/// `BitcoinPriceManager::set_price_for_test` instead of installing the +/// global [`PriceManager`] โ€” its `OnceLock` would leak one test's +/// configuration into every other test in the binary. Tests use a unique +/// currency code each to avoid cross-test interference on the shared map. +#[cfg(test)] +pub(crate) fn test_price_overrides( +) -> &'static std::sync::RwLock> { + static OVERRIDES: std::sync::OnceLock< + std::sync::RwLock>, + > = std::sync::OnceLock::new(); + OVERRIDES.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new())) +} + +/// Read a currency's per-BTC price from the global [`PriceManager`]. +/// +/// This is the Phase 1 entry point for consumers (`util::get_bitcoin_price` +/// and the `BitcoinPriceManager::get_price` shim). When the global manager +/// has not been initialised (e.g. unit tests that don't bring up the full +/// configuration), it returns `Err(NoAPIResponse)` โ€” the same error the +/// legacy code returned when `BITCOIN_PRICES` was empty, so callers behave +/// identically. +pub fn get_bitcoin_price(currency: &str) -> Result { + #[cfg(test)] + if let Some(price) = test_price_overrides() + .read() + .expect("price override read lock") + .get(currency) + { + return Ok(*price); + } + match PriceManager::global() { + Some(m) => m.get_price(currency), + None => Err(MostroError::MostroInternalErr(ServiceError::NoAPIResponse)), + } +} diff --git a/src/price/provider.rs b/src/price/provider.rs index 3e4084d0..80de63b8 100644 --- a/src/price/provider.rs +++ b/src/price/provider.rs @@ -32,7 +32,7 @@ pub type ProviderQuotes = HashMap; /// tracking, and the Nostr `source` metadata. The string form (via /// [`fmt::Display`] / [`FromStr`]) matches the `[price.providers.]` /// config sub-table key. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum ProviderId { Yadio, CoinGecko, diff --git a/src/price/providers/blockchain.rs b/src/price/providers/blockchain.rs new file mode 100644 index 00000000..db41f02c --- /dev/null +++ b/src/price/providers/blockchain.rs @@ -0,0 +1,152 @@ +//! Blockchain.com direct BTC quoter (spec ยง11.4). +//! +//! Calls `GET {url}/ticker` and maps the +//! `{ "USD": { "15m": โ€ฆ, "last": โ€ฆ, "buy": โ€ฆ, "sell": โ€ฆ, "symbol": "USD" } }` +//! body into per-currency [`Quote::PerBtc`] entries. The adapter takes +//! **`last`** (mid-market) and discards `buy`/`sell` โ€” Mostro prices at +//! mid-market and never bakes in an exchange spread (ยง6.6, ยง11.6); the +//! order premium/fee is the only markup, applied downstream. +//! +//! Keyless, ~28 major fiats, no CUP/MLC โ€” a redundancy anchor for +//! USD/EUR/GBP/JPY, not a long-tail source. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// One ticker entry. Only `last` (mid-market) is used; the bid/ask fields +/// are intentionally not even deserialised so a future refactor cannot +/// accidentally reach for them (ยง6.6 mid-market rule). +#[derive(Debug, Deserialize)] +struct TickerEntry { + last: Option, +} + +/// Direct BTC quoter against the Blockchain.com ticker. +#[derive(Debug)] +pub struct BlockchainProvider { + url: String, +} + +impl BlockchainProvider { + /// Build the provider from its `[price.providers.blockchain]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + } + } + + /// Parse a `/ticker` payload into [`ProviderQuotes`]. Split out from + /// [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec ยง10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: HashMap = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("blockchain: {e}")))?; + Ok(parsed + .into_iter() + .filter_map(|(code, entry)| match entry.last { + Some(v) if v.is_finite() && v > 0.0 => { + // Codes arrive uppercase already; canonicalise anyway so + // the adapter honours ยง6.6 even if the API drifts. + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for BlockchainProvider { + fn id(&self) -> ProviderId { + ProviderId::Blockchain + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/ticker", self.url); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("blockchain GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "blockchain GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("blockchain read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/blockchain_ticker.json"); + + #[test] + fn parses_captured_payload_taking_last() { + let quotes = BlockchainProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11: ~28 majors, uppercase codes. + assert!(quotes.len() >= 20, "expected the major-fiat set"); + assert!(quotes.contains_key("USD")); + assert!(quotes.contains_key("EUR")); + assert!(quotes.contains_key("JPY")); + // No CUP/MLC (ยง11.4). + assert!(!quotes.contains_key("CUP")); + assert!(!quotes.contains_key("MLC")); + } + + #[test] + fn takes_last_not_buy_or_sell() { + // `last` is mid-market; buy/sell carry the exchange spread and must + // be ignored (ยง6.6 / ยง11.6 โ€” the BTCPay contrast). + let body = r#"{"USD": {"15m": 1.0, "last": 50000.0, "buy": 49000.0, "sell": 51000.0, "symbol": "USD"}}"#; + let quotes = BlockchainProvider::parse(body).unwrap(); + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(50_000.0))); + } + + #[test] + fn drops_missing_and_non_positive_last() { + let body = r#"{ + "AAA": {"15m": 1.0, "buy": 1.0, "sell": 1.0, "symbol": "AAA"}, + "BBB": {"last": 0, "symbol": "BBB"}, + "GBP": {"last": 47293.0, "symbol": "GBP"} + }"#; + let quotes = BlockchainProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1, "only GBP has a usable `last`"); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_293.0))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + BlockchainProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn new_strips_trailing_slash() { + let cfg = ProviderConfig { + enabled: true, + url: "https://blockchain.info/".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }; + assert_eq!(BlockchainProvider::new(&cfg).url, "https://blockchain.info"); + } +} diff --git a/src/price/providers/coingecko.rs b/src/price/providers/coingecko.rs new file mode 100644 index 00000000..66dec104 --- /dev/null +++ b/src/price/providers/coingecko.rs @@ -0,0 +1,205 @@ +//! CoinGecko direct BTC quoter (spec ยง11.2). +//! +//! Calls `GET {url}/simple/price?ids=bitcoin&vs_currencies=` and maps +//! the `{ "bitcoin": { ccy: price } }` body into per-currency +//! [`Quote::PerBtc`] entries. CoinGecko ships lowercase codes; the adapter +//! upper-cases them so they combine with Yadio/Blockchain quotes (spec ยง6.6). +//! +//! The keyless tier is rate-limited; an optional `api_key` (demo or pro) +//! raises the limits. The key is sent as the appropriate header โ€” CoinGecko +//! pro keys go to `pro-api.coingecko.com` with `x-cg-pro-api-key`, demo keys +//! to the public host with `x-cg-demo-api-key`; we pick the header from the +//! configured URL so one config field serves both plans. Per spec ยง10.3 the +//! key never appears in logs or `Debug` output. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// The fiat subset of CoinGecko's `supported_vs_currencies`, baked so one +/// request covers everything we can use (the endpoint requires an explicit +/// list). Codes CoinGecko does not recognise are silently omitted from the +/// response, so this list ages safely; CUP/MLC are absent because CoinGecko +/// does not list them (spec ยง11.2). `vef` (which CoinGecko still supports) +/// is deliberately not requested โ€” it is the pre-redenomination Venezuelan +/// code, a different unit from the ISO `VES` (see `price::fiat`). +const VS_CURRENCIES: &str = "usd,eur,gbp,jpy,ars,aud,bdt,bhd,bmd,brl,cad,chf,\ + clp,cny,czk,dkk,gel,hkd,huf,idr,ils,inr,krw,kwd,\ + lkr,mmk,mxn,myr,ngn,nok,nzd,php,pkr,pln,rub,sar,\ + sek,sgd,thb,try,twd,uah,vnd,zar"; + +/// Response shape: `{ "bitcoin": { "usd": 63410, ... } }`. Lenient on the +/// value (`Option`) so one `null` rate cannot fail the whole poll. +#[derive(Debug, Deserialize)] +struct CoinGeckoResponse { + bitcoin: HashMap>, +} + +/// Direct BTC quoter against the CoinGecko API. +pub struct CoinGeckoProvider { + url: String, + api_key: Option, +} + +// Manual impl so the API key can never leak through `{:?}` logging +// (spec ยง10.3 redaction requirement). +impl std::fmt::Debug for CoinGeckoProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoinGeckoProvider") + .field("url", &self.url) + .field("api_key", &self.api_key.as_ref().map(|_| "")) + .finish() + } +} + +impl CoinGeckoProvider { + /// Build the provider from its `[price.providers.coingecko]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + api_key: cfg.api_key.clone(), + } + } + + /// Header name for the configured key: pro keys are only valid against + /// the `pro-api` host, demo keys against the public one. + fn api_key_header(&self) -> &'static str { + if self.url.contains("pro-api") { + "x-cg-pro-api-key" + } else { + "x-cg-demo-api-key" + } + } + + /// Parse a `/simple/price` payload into [`ProviderQuotes`]. Split out + /// from [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec ยง10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: CoinGeckoResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("coingecko: {e}")))?; + Ok(parsed + .bitcoin + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => { + // CoinGecko ships lowercase codes โ€” canonicalise (ยง6.6). + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for CoinGeckoProvider { + fn id(&self) -> ProviderId { + ProviderId::CoinGecko + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!( + "{}/simple/price?ids=bitcoin&vs_currencies={}", + self.url, VS_CURRENCIES + ); + let mut req = http.get(&url); + if let Some(key) = &self.api_key { + req = req.header(self.api_key_header(), key); + } + let res = req + .send() + .await + .map_err(|e| ProviderError::Http(format!("coingecko GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "coingecko GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("coingecko read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/coingecko_simple_price.json"); + + fn cfg(url: &str, api_key: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: api_key.map(String::from), + token: None, + only: None, + except: None, + } + } + + #[test] + fn parses_captured_payload_and_uppercases_codes() { + let quotes = CoinGeckoProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11; codes arrive lowercase and must be + // canonicalised so they combine with Yadio's uppercase ones (ยง6.6). + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(63410.0))); + assert_eq!(quotes.get("EUR"), Some(&Quote::PerBtc(54815.0))); + assert_eq!(quotes.get("JPY"), Some(&Quote::PerBtc(10143476.0))); + assert!(!quotes.contains_key("usd"), "no lowercase keys may leak"); + // CoinGecko does not list CUP/MLC (ยง11.2). + assert!(!quotes.contains_key("CUP")); + } + + #[test] + fn drops_null_and_non_positive() { + let body = r#"{"bitcoin": {"usd": null, "eur": -5, "gbp": 47293.0}}"#; + let quotes = CoinGeckoProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_293.0))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + CoinGeckoProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn api_key_header_matches_host() { + let demo = CoinGeckoProvider::new(&cfg("https://api.coingecko.com/api/v3", Some("CG-x"))); + assert_eq!(demo.api_key_header(), "x-cg-demo-api-key"); + let pro = + CoinGeckoProvider::new(&cfg("https://pro-api.coingecko.com/api/v3", Some("CG-x"))); + assert_eq!(pro.api_key_header(), "x-cg-pro-api-key"); + } + + #[test] + fn debug_redacts_api_key() { + // Spec ยง10.3: the key must never appear in `Debug` output (logs). + let p = CoinGeckoProvider::new(&cfg( + "https://api.coingecko.com/api/v3", + Some("CG-supersecret"), + )); + let dbg = format!("{p:?}"); + assert!(!dbg.contains("supersecret"), "api_key leaked: {dbg}"); + assert!(dbg.contains("")); + } + + #[test] + fn new_strips_trailing_slash() { + let p = CoinGeckoProvider::new(&cfg("https://api.coingecko.com/api/v3/", None)); + assert_eq!(p.url, "https://api.coingecko.com/api/v3"); + } +} diff --git a/src/price/providers/currency_api.rs b/src/price/providers/currency_api.rs new file mode 100644 index 00000000..6e3264f3 --- /dev/null +++ b/src/price/providers/currency_api.rs @@ -0,0 +1,311 @@ +//! currency-api / fawazahmed0 direct BTC quoter (spec ยง11.5). +//! +//! Calls `GET {url}/currencies/btc.min.json` and maps the +//! `{ "date": "โ€ฆ", "btc": { ccy: price } }` body into per-currency +//! [`Quote::PerBtc`] entries. Two ยง6.6 caveats are handled *outside* this +//! adapter, by design: +//! +//! - The payload ships **324+ entries including crypto** (`eth`, `bnb`, โ€ฆ) +//! and non-ISO codes โ€” the manager's fiat allowlist drops those before +//! aggregation. The adapter itself stays a faithful map of the API. +//! - Its CUP is the **official** rate (~26 CUP/USD), a different market +//! from Yadio/El Toque's informal rate (~400 CUP/USD) โ€” the shipped +//! config scopes it out with `except = ["CUP", "MLC"]`. +//! +//! Codes arrive **lowercase** and are canonicalised to uppercase here so +//! they combine with Yadio/Blockchain quotes (spec ยง6.6). +//! +//! The API is CDN-hosted (Cloudflare Pages + a jsdelivr mirror). The +//! adapter implements `fallback_urls` (spec ยง7): mirrors are tried in +//! order, and the provider only fails the tick when **every** URL fails. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// Response shape: `{ "date": "โ€ฆ", "btc": { "usd": 62519.29, โ€ฆ } }`. +/// Lenient on the value so one `null` cannot fail the whole poll. +#[derive(Debug, Deserialize)] +struct CurrencyApiResponse { + btc: HashMap>, +} + +/// Direct BTC quoter against currency-api with ordered mirror fallback. +#[derive(Debug)] +pub struct CurrencyApiProvider { + urls: Vec, +} + +impl CurrencyApiProvider { + /// Build the provider from its `[price.providers.currency_api]` + /// sub-table. The primary `url` plus every `fallback_urls` entry form + /// the ordered candidate list (spec ยง7). + pub fn new(cfg: &ProviderConfig) -> Self { + let urls = std::iter::once(&cfg.url) + .chain(cfg.fallback_urls.iter()) + .map(|u| u.trim_end_matches('/').to_string()) + .filter(|u| !u.is_empty()) + .collect(); + Self { urls } + } + + /// Parse a `btc.min.json` payload into [`ProviderQuotes`]. Split out + /// from [`PriceProvider::fetch`] so it is testable against the captured + /// fixture without HTTP (spec ยง10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: CurrencyApiResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("currency_api: {e}")))?; + Ok(parsed + .btc + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => { + // currency-api ships lowercase codes โ€” canonicalise (ยง6.6). + Some((code.to_uppercase(), Quote::PerBtc(v))) + } + _ => None, + }) + .collect()) + } + + /// One attempt against one base URL. + async fn fetch_one( + &self, + http: &reqwest::Client, + base: &str, + ) -> Result { + let url = format!("{base}/currencies/btc.min.json"); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("currency_api GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "currency_api GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("currency_api read body: {e}")))?; + Self::parse(&body) + } +} + +#[async_trait] +impl PriceProvider for CurrencyApiProvider { + fn id(&self) -> ProviderId { + ProviderId::CurrencyApi + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + // Try the primary, then each mirror in order; first success wins. + // Only when every URL fails does the provider fail the tick (and + // count against its circuit breaker) โ€” spec ยง7 "tried in sequence". + let mut last_err = + ProviderError::Misconfigured("currency_api: no usable url configured".into()); + for base in &self.urls { + match self.fetch_one(http, base).await { + Ok(quotes) => return Ok(quotes), + Err(e) => { + tracing::warn!("price: currency_api mirror {base} failed: {e}"); + last_err = e; + } + } + } + Err(last_err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = + include_str!("../../../tests/fixtures/price/currency_api_btc.json"); + + fn cfg(url: &str, fallbacks: Vec) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: fallbacks, + api_key: None, + token: None, + only: None, + except: None, + } + } + + #[test] + fn parses_captured_payload_and_uppercases_codes() { + let quotes = CurrencyApiProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Captured live 2026-06-11. Lowercase codes must be canonicalised. + assert!(quotes.contains_key("USD")); + assert!(quotes.contains_key("EUR")); + assert!(!quotes.contains_key("usd"), "no lowercase keys may leak"); + // The raw payload legitimately includes crypto junk (`eth`, `bnb`) + // and the OFFICIAL-rate CUP โ€” the adapter maps them faithfully; + // dropping them is the job of the manager's fiat allowlist and the + // shipped `except = ["CUP","MLC"]` scoping (ยง6.6). Asserting they + // are present here pins the layering: adapter = faithful map. + assert!(quotes.contains_key("ETH")); + assert!(quotes.contains_key("CUP")); + // The captured CUP is the official rate: CUP/BTC รท USD/BTC โ‰ˆ 26 + // CUP/USD (the informal market is ~400) โ€” the ยง11.5 hazard is real. + let cup = match quotes.get("CUP").unwrap() { + Quote::PerBtc(v) => *v, + _ => unreachable!(), + }; + let usd = match quotes.get("USD").unwrap() { + Quote::PerBtc(v) => *v, + _ => unreachable!(), + }; + let cup_per_usd = cup / usd; + assert!( + (20.0..40.0).contains(&cup_per_usd), + "captured CUP should be the official ~26 CUP/USD rate, got {cup_per_usd}" + ); + } + + #[test] + fn drops_null_and_non_positive() { + let body = r#"{"date":"2026-06-11","btc":{"usd":null,"eur":-1,"gbp":47000.5}}"#; + let quotes = CurrencyApiProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(47_000.5))); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + CurrencyApiProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn url_order_is_primary_then_fallbacks() { + let p = CurrencyApiProvider::new(&cfg( + "https://currency-api.pages.dev/v1/", + vec!["https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1".into()], + )); + assert_eq!( + p.urls, + vec![ + "https://currency-api.pages.dev/v1", + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1" + ] + ); + } + + /// Spec ยง9 Phase 2 acceptance: "a provider's `fallback_urls` is tried + /// before the provider is marked failed". The primary URL points at a + /// dead local port (instant connection-refused); the fallback is a real + /// local HTTP server returning the captured fixture. `fetch` must + /// succeed via the mirror. + #[tokio::test] + async fn fallback_url_is_tried_before_failing() { + use axum::{routing::get, Router}; + + let app = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { SAMPLE_PAYLOAD }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let p = CurrencyApiProvider::new(&cfg( + // Port 9 (discard) on localhost: nothing listens, fails fast. + "http://127.0.0.1:9/v1", + vec![format!("http://{addr}/v1")], + )); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let quotes = p.fetch(&http).await.expect("mirror must carry the fetch"); + assert!(quotes.contains_key("USD")); + } + + /// A *hanging* primary (vs the fast connection-refused above) must not + /// starve the mirror: the per-attempt bound is the HTTP client's + /// request timeout, so the hung attempt is cut at ~1s and the mirror + /// still answers within the manager's mirror-sequence budget + /// (`poll_budget`). Guards the Codex finding on PR #773. + #[tokio::test] + async fn hanging_primary_does_not_starve_the_mirror() { + use axum::{routing::get, Router}; + + // Primary: accepts the connection, then stalls far past the client + // request timeout. + let hang = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + SAMPLE_PAYLOAD + }), + ); + let hang_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let hang_addr = hang_listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(hang_listener, hang).await.unwrap(); + }); + + // Mirror: instant fixture. + let ok = Router::new().route( + "/v1/currencies/btc.min.json", + get(|| async { SAMPLE_PAYLOAD }), + ); + let ok_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let ok_addr = ok_listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(ok_listener, ok).await.unwrap(); + }); + + let p = CurrencyApiProvider::new(&cfg( + &format!("http://{hang_addr}/v1"), + vec![format!("http://{ok_addr}/v1")], + )); + // Mirrors `from_settings`: the client's request timeout IS the + // per-attempt bound. + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .build() + .unwrap(); + let started = std::time::Instant::now(); + let quotes = p + .fetch(&http) + .await + .expect("mirror must carry the fetch despite the hung primary"); + assert!(quotes.contains_key("USD")); + assert!( + started.elapsed() < std::time::Duration::from_secs(5), + "hung primary must be cut by the per-attempt timeout, not ride forever" + ); + } + + #[tokio::test] + async fn all_urls_failing_is_one_provider_error() { + let p = CurrencyApiProvider::new(&cfg( + "http://127.0.0.1:9/v1", + vec!["http://127.0.0.1:9/v2".into()], + )); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert!(matches!( + p.fetch(&http).await.unwrap_err(), + ProviderError::Http(_) + )); + } +} diff --git a/src/price/providers/eltoque.rs b/src/price/providers/eltoque.rs new file mode 100644 index 00000000..a1e87e80 --- /dev/null +++ b/src/price/providers/eltoque.rs @@ -0,0 +1,330 @@ +//! El Toque fiat-cross quoter for CUP/MLC (spec ยง11.3). +//! +//! El Toque publishes the **informal Cuban market rate** as *CUP per +//! foreign unit* โ€” it is **not** a BTC price source. So unlike the direct +//! quoters, this adapter emits [`Quote::PerBase`] quotes resolved against +//! the aggregated USD/BTC anchor (spec ยง6.3): CUP and MLC each need at +//! least one live direct USD source (Yadio/CoinGecko/โ€ฆ) to resolve. +//! +//! From a `tasas` payload denominated in CUP: +//! - **CUP** โ†’ `PerBase { base: "USD", value: cup_per_usd }` (CUP per USD, +//! taken straight from `tasas.USD`). +//! - **MLC** โ†’ `PerBase { base: "USD", value: cup_per_usd / cup_per_mlc }`. +//! The cross math `MLC_per_USD = (CUP per USD) / (CUP per MLC)` is done +//! **here**, inside the adapter, so the aggregator stays generic (ยง11.3). +//! +//! Anchor policy: **USD only** (the spec default; the EUR second-anchor +//! fallback in ยง11.3 Q2 was declined for this phase). If every direct USD +//! quoter is down for a tick, CUP/MLC simply fall back to last-known-good. +//! +//! The provider is scoped to `only = ["CUP", "MLC"]` in config (ยง6.6); the +//! adapter independently emits only CUP/MLC, so the two agree. +//! +//! Requires a Bearer **token** (free registration). An enabled El Toque +//! provider without a token is a startup error (spec ยง7); the token is +//! redacted from `Debug`/logs (spec ยง10.3). +//! +//! ## Request +//! +//! `GET {url}/v1/trmi?date_from=โ€ฆ&date_to=โ€ฆ` with `Authorization: Bearer +//! `. The endpoint requires a `[date_from, date_to]` range (wire +//! format `YYYY-MM-DD HH:MM:SS`, URL-encoded) and returns the most recent +//! rate published within it, so [`PriceProvider::fetch`] queries a rolling +//! window ending "now" (see [`LOOKBACK_HOURS`]). The response is a `tasas` +//! object mapping currency codes to CUP-denominated values, plus the +//! timestamp of the returned rate (`date`/`hour`/`minutes`/`seconds`, which +//! the parser ignores). El Toque uses `ECU` for the euro. Example: +//! +//! ```json +//! { "tasas": { "USD": 490.0, "MLC": 200.0, "ECU": 540.0, โ€ฆ }, +//! "date": "2022-10-27", "hour": 7, "minutes": 59, "seconds": 30 } +//! ``` + +use std::collections::HashMap; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// How far back the `[date_from, date_to]` window reaches from "now". +/// +/// `/v1/trmi` returns the most recent informal-market rate published inside +/// the requested range, so the window only needs to be wide enough that a +/// quiet day (no fresh publication) still falls within it โ€” El Toque's TRMI +/// updates roughly daily, so a ~day-wide window resolves a recent rate rather +/// than an empty `tasas`. +/// +/// **Hard cap:** the API rejects any range of 24h or more with `400 "El +/// intervalo de tiempo debe ser menor a 24 horas"`, so the window must stay +/// *strictly* under 24h. 23h leaves a 1h margin for request-construction skew +/// while still spanning roughly a full daily cycle. +const LOOKBACK_HOURS: i64 = 23; + +/// `date_from`/`date_to` wire format, e.g. `2022-10-27 00:00:01` +/// (sent URL-encoded by reqwest, matching the El Toque API). +const DATE_FMT: &str = "%Y-%m-%d %H:%M:%S"; + +/// Response shape: `{ "tasas": { "USD": 442.0, "MLC": 210.0, โ€ฆ } }`. +/// +/// Values are **CUP per unit** of the keyed currency. Lenient on the value +/// (`Option`) so one `null` rate cannot fail the whole poll; any other +/// top-level fields El Toque returns (date range, etc.) are ignored. +#[derive(Debug, Deserialize)] +struct ElToqueResponse { + tasas: HashMap>, +} + +/// Fiat-cross quoter against the El Toque tasas API. +pub struct ElToqueProvider { + url: String, + token: String, +} + +// Manual impl so the Bearer token can never leak through `{:?}` logging +// (spec ยง10.3 redaction requirement). +impl std::fmt::Debug for ElToqueProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ElToqueProvider") + .field("url", &self.url) + .field("token", &"") + .finish() + } +} + +impl ElToqueProvider { + /// Build the provider from its `[price.providers.eltoque]` sub-table. + /// + /// Returns `Err` when the required Bearer `token` is missing or blank so + /// an enabled-but-unconfigured El Toque fails fast at startup rather + /// than silently producing no quotes (spec ยง7). + pub fn new(cfg: &ProviderConfig) -> Result { + let token = cfg + .token + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .ok_or_else(|| { + "price provider 'eltoque': enabled provider requires a `token` (Bearer API \ + key) โ€” set it or disable the provider (see docs/PRICE_PROVIDERS.md ยง7)" + .to_string() + })?; + Ok(Self { + url: cfg.url.trim_end_matches('/').to_string(), + token: token.to_string(), + }) + } + + /// Parse a `tasas` payload into CUP/MLC [`Quote::PerBase`] entries. + /// + /// This is the grounded, testable core (spec ยง10.5): it targets the + /// confirmed CUP-denominated `tasas` shape and performs the ยง11.3 cross + /// math. Both outputs hang off the USD anchor: + /// + /// - CUP needs `tasas.USD` (CUP per USD). Absent โ†’ emit nothing + /// (without a CUP/USD figure nothing here is resolvable). + /// - MLC additionally needs `tasas.MLC` (CUP per MLC) to derive + /// `MLC_per_USD = cup_per_usd / cup_per_mlc`. + pub(crate) fn parse(body: &str) -> Result { + let parsed: ElToqueResponse = serde_json::from_str(body) + .map_err(|e| ProviderError::Parse(format!("eltoque: {e}")))?; + let tasas = parsed.tasas; + let mut out = ProviderQuotes::new(); + + // The CUP/USD figure anchors everything El Toque contributes. + let cup_per_usd = match tasas.get("USD") { + Some(Some(v)) if v.is_finite() && *v > 0.0 => *v, + _ => return Ok(out), + }; + out.insert( + "CUP".to_string(), + Quote::PerBase { + base: "USD".to_string(), + value: cup_per_usd, + }, + ); + + // MLC per USD = (CUP per USD) / (CUP per MLC) โ€” derived internally so + // the aggregator only ever sees a clean `PerBase { base: "USD" }`. + if let Some(Some(cup_per_mlc)) = tasas.get("MLC") { + if cup_per_mlc.is_finite() && *cup_per_mlc > 0.0 { + let mlc_per_usd = cup_per_usd / cup_per_mlc; + if mlc_per_usd.is_finite() && mlc_per_usd > 0.0 { + out.insert( + "MLC".to_string(), + Quote::PerBase { + base: "USD".to_string(), + value: mlc_per_usd, + }, + ); + } + } + } + + Ok(out) + } +} + +#[async_trait] +impl PriceProvider for ElToqueProvider { + fn id(&self) -> ProviderId { + ProviderId::ElToque + } + + /// `GET {url}/v1/trmi?date_from=โ€ฆ&date_to=โ€ฆ` with Bearer-token auth. + /// + /// The endpoint requires a `[date_from, date_to]` range and returns the + /// most recent rate published within it; we query a rolling window ending + /// "now" (see [`LOOKBACK_HOURS`]) so each poll resolves the latest TRMI. + /// reqwest URL-encodes the `YYYY-MM-DD HH:MM:SS` params. + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/v1/trmi", self.url); + let now = Utc::now(); + let from = now - Duration::hours(LOOKBACK_HOURS); + let res = http + .get(&url) + .bearer_auth(&self.token) + .query(&[ + ("date_from", from.format(DATE_FMT).to_string()), + ("date_to", now.format(DATE_FMT).to_string()), + ]) + .send() + .await + .map_err(|e| ProviderError::Http(format!("eltoque GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "eltoque GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("eltoque read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // A captured El Toque `/v1/trmi` response (CUP-denominated `tasas`). + const SAMPLE_PAYLOAD: &str = include_str!("../../../tests/fixtures/price/eltoque_trmi.json"); + + fn cfg(url: &str, token: Option<&str>) -> ProviderConfig { + ProviderConfig { + enabled: true, + url: url.into(), + fallback_urls: vec![], + api_key: None, + token: token.map(String::from), + only: Some(vec!["CUP".into(), "MLC".into()]), + except: None, + } + } + + /// Pull the `value` out of a `PerBase { base: "USD", .. }` quote, asserting + /// the base is USD (the only anchor this phase emits). + fn per_usd(q: &Quote) -> f64 { + match q { + Quote::PerBase { base, value } => { + assert_eq!(base, "USD", "El Toque must anchor on USD"); + *value + } + Quote::PerBtc(_) => panic!("El Toque must emit PerBase, not PerBtc"), + } + } + + #[test] + fn parses_sample_payload_into_cup_and_mlc_perbase() { + let quotes = ElToqueProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // Only CUP and MLC โ€” El Toque's other `tasas` entries (USD anchor, + // ECU=EUR, crypto) are not contributed by this fiat-cross adapter. + assert_eq!(quotes.len(), 2, "exactly CUP and MLC are emitted"); + + // CUP per USD is taken straight from `tasas.USD` (490 in the sample). + assert!((per_usd("es["CUP"]) - 490.0).abs() < 1e-9); + + // MLC per USD = cup_per_usd / cup_per_mlc = 490 / 200. + assert!((per_usd("es["MLC"]) - 490.0 / 200.0).abs() < 1e-9); + + // Resolved against a USD/BTC anchor this gives sane per-BTC figures: + // CUP/BTC = 490 ร— USD/BTC, MLC/BTC = (490/200) ร— USD/BTC โ€” i.e. 1 MLC + // is worth 200 CUP, matching the source. Cross-check the ratio. + let cup_per_btc = per_usd("es["CUP"]) * 50_000.0; // pretend USD/BTC + let mlc_per_btc = per_usd("es["MLC"]) * 50_000.0; + assert!( + (cup_per_btc / mlc_per_btc - 200.0).abs() < 1e-6, + "1 MLC must price at 200 CUP, matching tasas" + ); + } + + #[test] + fn mlc_cross_math_is_cup_per_usd_over_cup_per_mlc() { + let body = r#"{"tasas":{"USD":400.0,"MLC":250.0,"ECU":420.0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert!((per_usd("es["CUP"]) - 400.0).abs() < 1e-9); + assert!((per_usd("es["MLC"]) - 400.0 / 250.0).abs() < 1e-9); + // ECU (El Toque's EUR) is deliberately not emitted โ€” El Toque only + // contributes CUP/MLC (ยง11.3); EUR comes from the direct quoters. + assert!(!quotes.contains_key("EUR")); + assert!(!quotes.contains_key("ECU")); + } + + #[test] + fn no_usd_anchor_emits_nothing() { + // Without CUP/USD nothing El Toque reports can be resolved. + let body = r#"{"tasas":{"MLC":210.0,"ECU":500.0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert!(quotes.is_empty(), "no tasas.USD โ†’ no resolvable quotes"); + } + + #[test] + fn non_positive_rates_are_dropped() { + // USD present but MLC is junk โ†’ CUP still emitted, MLC dropped. + let body = r#"{"tasas":{"USD":442.0,"MLC":0}}"#; + let quotes = ElToqueProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1); + assert!(quotes.contains_key("CUP")); + assert!(!quotes.contains_key("MLC")); + + // USD itself non-positive โ†’ nothing at all. + let body = r#"{"tasas":{"USD":0,"MLC":210.0}}"#; + assert!(ElToqueProvider::parse(body).unwrap().is_empty()); + } + + #[test] + fn parse_error_is_returned() { + assert!(matches!( + ElToqueProvider::parse("not json").unwrap_err(), + ProviderError::Parse(_) + )); + } + + #[test] + fn new_requires_a_token() { + // Spec ยง7: an enabled El Toque without a token fails fast. + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", None)).is_err()); + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some(" "))).is_err()); + assert!(ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("tok"))).is_ok()); + } + + #[test] + fn debug_redacts_token() { + // Spec ยง10.3: the Bearer token must never appear in `Debug` (logs). + let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com", Some("super-secret-key"))) + .unwrap(); + let dbg = format!("{p:?}"); + assert!(!dbg.contains("super-secret-key"), "token leaked: {dbg}"); + assert!(dbg.contains("")); + } + + #[test] + fn new_strips_trailing_slash() { + let p = ElToqueProvider::new(&cfg("https://tasas.eltoque.com/", Some("tok"))).unwrap(); + assert_eq!(p.url, "https://tasas.eltoque.com"); + } +} diff --git a/src/price/providers/mod.rs b/src/price/providers/mod.rs new file mode 100644 index 00000000..d1a796f7 --- /dev/null +++ b/src/price/providers/mod.rs @@ -0,0 +1,13 @@ +//! Per-provider adapters (spec ยง5.1, ยง5.4). +//! +//! Each file under this module implements [`super::PriceProvider`] for one +//! external price API. Adding a provider is one new adapter file + one +//! [`super::ProviderId`] variant + one registry arm in +//! [`super::PriceManager::from_settings`] + one config sub-table (see spec +//! ยง5.4). The aggregation core, store and scheduler are never touched. + +pub mod blockchain; +pub mod coingecko; +pub mod currency_api; +pub mod eltoque; +pub mod yadio; diff --git a/src/price/providers/yadio.rs b/src/price/providers/yadio.rs new file mode 100644 index 00000000..2b99546c --- /dev/null +++ b/src/price/providers/yadio.rs @@ -0,0 +1,136 @@ +//! Yadio direct BTC quoter (spec ยง11.1). +//! +//! Calls `GET {url}/exrates/BTC` and maps the `{"BTC": { ccy: price }}` +//! body into per-currency [`Quote::PerBtc`] entries. Yadio occasionally +//! reports `null` for currencies it currently has no rate for (e.g. +//! `"BGN": null`); the parse is lenient (`Option`) and those entries +//! are filtered out before they leave the adapter, matching the behaviour +//! of the legacy `BitcoinPriceManager`. + +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; + +use crate::price::config::ProviderConfig; +use crate::price::provider::{PriceProvider, ProviderError, ProviderId, ProviderQuotes, Quote}; + +/// Lenient response shape โ€” `null` rates are dropped before aggregation. +#[derive(Debug, Deserialize)] +struct YadioResponse { + #[serde(rename = "BTC")] + btc: HashMap>, +} + +/// Direct BTC quoter against the Yadio API. +pub struct YadioProvider { + url: String, +} + +impl YadioProvider { + /// Build the provider from its `[price.providers.yadio]` sub-table. + pub fn new(cfg: &ProviderConfig) -> Self { + Self { + url: cfg.url.trim_end_matches('/').to_string(), + } + } + + /// Parse a Yadio `/exrates/BTC` payload into [`ProviderQuotes`]. + /// + /// Split out from [`PriceProvider::fetch`] so the parsing path can be + /// unit-tested against captured fixtures without standing up an HTTP + /// server (spec ยง10.5). + pub(crate) fn parse(body: &str) -> Result { + let parsed: YadioResponse = + serde_json::from_str(body).map_err(|e| ProviderError::Parse(format!("yadio: {e}")))?; + Ok(parsed + .btc + .into_iter() + .filter_map(|(code, value)| match value { + Some(v) if v.is_finite() && v > 0.0 => Some((code, Quote::PerBtc(v))), + _ => None, + }) + .collect()) + } +} + +#[async_trait] +impl PriceProvider for YadioProvider { + fn id(&self) -> ProviderId { + ProviderId::Yadio + } + + async fn fetch(&self, http: &reqwest::Client) -> Result { + let url = format!("{}/exrates/BTC", self.url); + let res = http + .get(&url) + .send() + .await + .map_err(|e| ProviderError::Http(format!("yadio GET {url}: {e}")))?; + if !res.status().is_success() { + return Err(ProviderError::Http(format!( + "yadio GET {url}: status {}", + res.status() + ))); + } + let body = res + .text() + .await + .map_err(|e| ProviderError::Http(format!("yadio read body: {e}")))?; + Self::parse(&body) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_PAYLOAD: &str = include_str!("../../../tests/fixtures/price/yadio_btc.json"); + + #[test] + fn parses_captured_payload() { + let quotes = YadioProvider::parse(SAMPLE_PAYLOAD).expect("fixture must parse"); + // The captured payload contains USD, EUR, ARS, CUP plus a `null` + // BGN to exercise the lenient path (regression for the live Yadio + // behaviour fixed in db99f94). + assert_eq!(quotes.get("USD"), Some(&Quote::PerBtc(75899.55))); + assert_eq!(quotes.get("EUR"), Some(&Quote::PerBtc(65393.99))); + assert_eq!(quotes.get("ARS"), Some(&Quote::PerBtc(75899550.0))); + assert_eq!(quotes.get("CUP"), Some(&Quote::PerBtc(28000000.0))); + assert!( + !quotes.contains_key("BGN"), + "null rates must be dropped, not surfaced as zero" + ); + } + + #[test] + fn drops_non_finite_and_non_positive() { + let body = r#"{"BTC": {"USD": 0, "EUR": -1, "GBP": 50000.0}}"#; + let quotes = YadioProvider::parse(body).unwrap(); + assert_eq!(quotes.len(), 1, "only GBP is a usable rate"); + assert_eq!(quotes.get("GBP"), Some(&Quote::PerBtc(50_000.0))); + } + + #[test] + fn parse_error_is_returned() { + let err = YadioProvider::parse("not json").unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); + } + + #[test] + fn new_strips_trailing_slash() { + let cfg = ProviderConfig { + enabled: true, + url: "https://api.yadio.io/".into(), + fallback_urls: vec![], + api_key: None, + token: None, + only: None, + except: None, + }; + let p = YadioProvider::new(&cfg); + // We rebuild the request URL by appending `/exrates/BTC`; without + // stripping the trailing slash we'd hit `//exrates/BTC`. + assert_eq!(p.url, "https://api.yadio.io"); + } +} diff --git a/src/price/store.rs b/src/price/store.rs index 319ff4ac..8ae5906f 100644 --- a/src/price/store.rs +++ b/src/price/store.rs @@ -127,6 +127,10 @@ mod tests { AggregateResult { value: *v, sources: *s, + // Store tests are agnostic to contributors โ€” they + // exercise TTL/last-known-good semantics. Empty + // is fine since the store never reads this field. + contributors: Vec::new(), }, ) }) diff --git a/src/scheduler.rs b/src/scheduler.rs index 83f1919f..f790a81b 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -2,10 +2,10 @@ use crate::app::bond; use crate::app::context::AppContext; use crate::app::dev_fee::run_dev_fee_cycle; use crate::app::release::do_payment; -use crate::bitcoin_price::BitcoinPriceManager; use crate::config; use crate::db::*; use crate::lightning::LndConnector; +use crate::price::PriceManager; use crate::util; use crate::LN_STATUS; use crate::{Keys, PublicKey}; @@ -19,7 +19,7 @@ use sqlx_crud::Crud; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::{error, info}; +use tracing::{error, info, warn}; use util::{enqueue_order_msg, get_nostr_relays, send_dm, update_order_event}; pub async fn start_scheduler(ctx: AppContext) { @@ -31,14 +31,45 @@ pub async fn start_scheduler(ctx: AppContext) { job_retry_failed_payments(ctx.clone()).await; job_process_dev_fee_payment(ctx.clone()).await; job_process_bond_payouts(ctx.clone()).await; + job_reconcile_stranded_maker_bonds(ctx.clone()).await; job_info_event_send(ctx.clone()).await; job_relay_list(ctx.clone()).await; job_update_bitcoin_prices().await; job_flush_messages_queue(ctx.clone()).await; + job_refresh_active_pubkeys(ctx.clone()).await; info!("Scheduler Started"); } +/// Periodically rebuild the protocol-v2 anti-spam gate's active-trade-pubkey +/// cache from the DB (spec ยง6 Phase 2). Status mutations are scattered across +/// many handlers with no single choke-point, so a periodic full reload is the +/// robust, low-coupling refresh strategy: a just-taken order's keys begin +/// fast-pathing within one `active_pubkeys_refresh_interval`. Inert on the v1 +/// transport (the event loop only consults the gate for kind-14 events). +async fn job_refresh_active_pubkeys(ctx: AppContext) { + let interval = ctx.settings().mostro.active_pubkeys_refresh_interval.max(1); + tokio::spawn(async move { + loop { + match find_active_trade_pubkeys(ctx.pool()).await { + Ok(keys) => { + if let Some(gate) = crate::spam_gate::SpamGate::global() { + let n = keys.len(); + gate.set_known(keys); + tracing::debug!( + "spam_gate: refreshed active-trade-pubkey cache ({n} keys)" + ); + } + } + Err(e) => { + warn!("spam_gate: failed to refresh active-trade-pubkey cache: {e}") + } + } + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + } + }); +} + async fn job_flush_messages_queue(ctx: AppContext) { // Clone for closure owning with Arc let order_msg_list = MESSAGE_QUEUES.queue_order_msg.clone(); @@ -489,13 +520,39 @@ async fn job_cancel_orders(ctx: AppContext) { // that strips eligibility) โ€” on persist failure // the next tick retries this branch only; the // slash is durable in `bonds.slashed_reason` and - // a re-entry sees no `Locked` bond, so it is a + // a re-entry sees no `Locked` bond (or an + // already-recorded slice child), so it is a // no-op (no duplicate notify). - if let Err(e) = order_updated.update(pool).await { - tracing::warn!( - "scheduler_timeout: persist failed for order {} ({}); will retry next tick", - order_id, e - ); + match order_updated.update(pool).await { + Ok(_) => { + // Phase 7: a maker-responsible timeout + // cancels the order outright, terminating + // its range chain โ€” resolve the range + // maker bond at close (settle + per-slice + // payouts + maker refund when a slice was + // slashed; plain release otherwise). The + // close helper is idempotent and a cheap + // no-op for non-range / already-resolved + // bonds; on transient failure the + // reconciliation sweep retries. The + // republish branch must NOT close: the + // order returns to the book with the + // maker still committed. + if matches!(new_status, Status::Canceled) { + bond::resolve_range_maker_bond_at_close_or_warn( + pool, + &order, + "scheduler_timeout", + ) + .await; + } + } + Err(e) => { + tracing::warn!( + "scheduler_timeout: persist failed for order {} ({}); will retry next tick", + order_id, e + ); + } } } } @@ -529,11 +586,46 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { order.id, order.created_at ); + + // Phase 5: a `WaitingMakerBond` order was never + // published to Nostr (the maker abandoned the bond + // invoice), so there is no NIP-33 event to replace. + // Going through `update_order_event` would publish a + // brand-new Expired/Canceled event for an order that + // never appeared in the book โ€” a ghost entry the + // ยง10.4 acceptance forbids. Mark it Expired directly + // in the DB and release any bond row instead. + if order.status == Status::WaitingMakerBond.to_string() { + let order_id = order.id; + let mut expired = order.clone(); + expired.status = Status::Expired.to_string(); + match expired.update(pool).await { + Ok(_) => { + bond::release_bonds_for_order_or_warn( + pool, + order_id, + "maker_bond_expiry", + ) + .await; + } + Err(e) => { + tracing::warn!( + "maker_bond_expiry: persist failed for order {} ({}); skipping bond release โ€” will retry next tick", + order_id, e + ); + } + } + continue; + } + // We update the order id with the new event_id if let Ok(order_updated) = crate::util::update_order_event(&keys, Status::Expired, order).await { let order_id = order_updated.id; + // Snapshot before `update` consumes the row โ€” the + // Phase 6 close hook below needs an `&Order`. + let order_snapshot = order_updated.clone(); // Same gate as the timeout job: only release // bonds when the Expired status was actually // persisted. On persist failure the next tick @@ -551,12 +643,24 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { // expiry โ€” Phase 1 promises "always // release" on every exit path, // expiry included. - bond::release_bonds_for_order_or_warn( + bond::release_taker_bonds_for_order_or_warn( pool, order_id, "pending_expiry", ) .await; + // Phase 6: an expiring Pending order may be a + // range remainder (or the range root) โ€” resolve + // the maker bond at range close (release when no + // slice was slashed; settle-at-close otherwise). + // Also covers the non-range maker bond via the + // close helper's non-range release branch. + bond::resolve_range_maker_bond_at_close_or_warn( + pool, + &order_snapshot, + "pending_expiry", + ) + .await; } Err(e) => { tracing::warn!( @@ -582,16 +686,47 @@ async fn job_expire_pending_older_orders(ctx: AppContext) { }); } +/// Phase 6 hardening: periodically retry the settle-at-close for any range +/// maker bond left `Locked` after a terminal hook's close failed (transient +/// LND/DB error). The order's terminal-state commit is never gated on close +/// success (best-effort bond design, ยง8.2), so without this sweep a stranded +/// parent HTLC would sit `Locked` โ€” blocking every slashed slice's payout โ€” +/// until the LND CLTV safety net. The close is idempotent (CAS), so the +/// retry is safe; a parent is only touched once its whole range tree is +/// terminal, so a legitimately-open range is never disturbed. Runs every +/// 5 minutes โ€” far below the CLTV horizon, far above any useful churn. +async fn job_reconcile_stranded_maker_bonds(ctx: AppContext) { + let interval = 300u64; + + tokio::spawn(async move { + let pool = ctx.pool(); + loop { + bond::reconcile_stranded_range_maker_bonds(pool).await; + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + } + }); +} + async fn job_update_bitcoin_prices() { tokio::spawn(async { - let mostro_settings = Settings::get_mostro(); - let configured_interval = mostro_settings.exchange_rates_update_interval_seconds; + let Some(manager) = PriceManager::global() else { + // Defensive: `main` installs the manager before the scheduler + // is started. If that ever changes (or an embedding binary + // skips installation) this job must not panic โ€” every other + // job keeps running. + error!("price: PriceManager not installed; skipping bitcoin price job"); + return; + }; + let configured_interval = manager.settings().update_interval_seconds; - // Validate interval: minimum 60 seconds to avoid API rate limits + // Validate interval: minimum 60 seconds to avoid API rate limits. + // Keeps the legacy guard's behaviour now that the interval moves + // from `[mostro].exchange_rates_update_interval_seconds` to + // `[price].update_interval_seconds` (spec ยง10.1). const MIN_INTERVAL: u64 = 60; let update_interval = if configured_interval < MIN_INTERVAL { error!( - "exchange_rates_update_interval_seconds too low: {}s (minimum: {}s). Using minimum.", + "price: update_interval_seconds too low: {}s (minimum: {}s). Using minimum.", configured_interval, MIN_INTERVAL ); MIN_INTERVAL @@ -606,8 +741,32 @@ async fn job_update_bitcoin_prices() { loop { info!("Updating Bitcoin prices"); - if let Err(e) = BitcoinPriceManager::update_prices().await { - error!("Failed to update Bitcoin prices: {}", e); + let report = manager.update_all().await; + // PriceManager already logs each provider's outcome per tick. + // The scheduler only surfaces the **outage** condition โ€” every + // provider failed โ€” because that's the moment ops cares about: + // the store is now reading last-known-good across the board. + if report.successes.is_empty() && !report.failures.is_empty() { + let failed: Vec = report + .failures + .iter() + .map(|(id, msg)| format!("{id}={msg}")) + .collect(); + error!( + "price: all {} providers failed this tick โ€” serving last-known-good [{}]", + report.failures.len(), + failed.join(", ") + ); + } else if !report.failures.is_empty() { + // Partial outage: at least one provider failed but others + // covered. A summary at warn is enough; per-provider info + // is already in the manager's per-provider logs. + warn!( + "price: {}/{} providers failed this tick (still {} fresh currencies)", + report.failures.len(), + report.failures.len() + report.successes.len(), + report.fresh_currencies + ); } tokio::time::sleep(tokio::time::Duration::from_secs(update_interval)).await; } diff --git a/src/spam_gate.rs b/src/spam_gate.rs new file mode 100644 index 00000000..5be5b841 --- /dev/null +++ b/src/spam_gate.rs @@ -0,0 +1,234 @@ +//! Protocol-v2 anti-spam gate (spec ยง6 Phase 2, docs/TRANSPORT_V2_SPEC.md). +//! +//! The whole point of the kind-14 transport is that the visible sender is the +//! authoring **trade key**, so the daemon can pre-validate cheaply *before* +//! paying the NIP-44 decrypt cost. This module holds the two in-memory pieces +//! that make that work: +//! +//! 1. **Active-trade-pubkey cache** โ€” the set of trade keys that legitimately +//! message Mostro right now (participants of non-terminal orders + active +//! dispute solvers). Rebuilt periodically from the DB by a scheduler job +//! (`job_refresh_active_pubkeys`) and warmed once at startup. +//! 2. **Replay guard** โ€” a short-window dedup of seen event ids, so a flood of +//! re-sent identical events is dropped before decryption (defense in depth). +//! +//! The gate has **two lanes**: a *known-keys lane* (sender in the cache โ†’ +//! fast-path, only the base `pow` applies) and a *first-contact lane* (sender +//! unseen โ†’ must clear the stiffer `pow_first_contact` before the daemon +//! decrypts). Brand-new orders and takes legitimately arrive on the +//! first-contact lane; that is also where spam concentrates, so PoW (plus +//! relay-side rate limiting) is the toll there. +//! +//! Only the v2 (`nip44`) transport uses this gate โ€” v1 gift wraps are authored +//! by throwaway keys that carry no pre-validatable signal. +//! +//! Follows the established global-singleton pattern (`OnceLock`, like +//! `PRICE_MANAGER` / `MOSTRO_CONFIG`); the cache is an inner `RwLock` and the +//! replay guard a `Mutex`, matching the daemon's single-consumer event loop. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock, RwLock}; + +use nostr_sdk::EventId; + +/// How long (seconds) a seen event id is remembered for replay dedup. Must be +/// โ‰ฅ the event loop's 10-second freshness window so a duplicate can never slip +/// past the guard while the original is still acceptable; 60s adds margin for +/// clock skew without unbounded memory (entries are pruned past this age). +pub const REPLAY_WINDOW_SECS: i64 = 60; + +/// Process-wide gate. `None` until [`SpamGate::install_global`] runs in `main`; +/// the event loop treats an absent gate as fail-open (no pre-filtering), so +/// unit tests that never install it are unaffected. +static SPAM_GATE: OnceLock = OnceLock::new(); + +/// Why [`SpamGate::install_global`] refused. A zero-size enum (not the bulky +/// `SpamGate`) so the `Result` stays small; mirrors `price::InstallError`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallError { + /// A gate is already installed. + AlreadyInstalled, +} + +/// Short-window dedup of event ids (defense in depth, ยง6 Phase 2). +struct ReplayGuard { + seen: HashMap, + window_secs: i64, +} + +impl ReplayGuard { + fn new(window_secs: i64) -> Self { + Self { + seen: HashMap::new(), + window_secs, + } + } + + /// Record `id` as seen at `now` and report whether it was **already** + /// present within the window (i.e. a replay the caller should drop). + /// Expired entries are pruned on the way through so the map stays bounded + /// by the in-window event rate. + fn check_and_record(&mut self, id: EventId, now: i64) -> bool { + let cutoff = now - self.window_secs; + self.seen.retain(|_, &mut seen_at| seen_at >= cutoff); + // `insert` returns the previous value if the key was present โ€” a + // non-expired prior sighting means this is a replay. + self.seen.insert(id, now).is_some() + } +} + +/// The anti-spam gate: known-keys cache + replay guard. +pub struct SpamGate { + known: RwLock>, + replay: Mutex, +} + +impl SpamGate { + /// Build an empty gate with the given replay window. + pub fn new(replay_window_secs: i64) -> Self { + Self { + known: RwLock::new(HashSet::new()), + replay: Mutex::new(ReplayGuard::new(replay_window_secs)), + } + } + + /// Install as the process-wide gate. Mirrors `PriceManager::install_global`: + /// a second call returns `Err(AlreadyInstalled)` rather than panicking. + /// (The large `SpamGate` is dropped on the error path rather than returned, + /// keeping the `Result` small โ€” clippy `result_large_err`.) + pub fn install_global(self) -> Result<(), InstallError> { + SPAM_GATE + .set(self) + .map_err(|_| InstallError::AlreadyInstalled) + } + + /// Borrow the installed gate, if any. `None` โ‡’ not installed (fail-open). + pub fn global() -> Option<&'static SpamGate> { + SPAM_GATE.get() + } + + /// Replace the known-keys set wholesale with the latest snapshot from the + /// DB. A poisoned lock is logged and skipped โ€” a stale cache only costs a + /// few legitimate keys a trip through the first-contact lane, never a + /// crash. + pub fn set_known>(&self, keys: I) { + match self.known.write() { + Ok(mut set) => { + *set = keys.into_iter().collect(); + } + Err(_) => tracing::error!("spam_gate: known-keys lock poisoned; skipping refresh"), + } + } + + /// Is `pubkey` (hex trade key) a currently-active participant? A poisoned + /// lock degrades to `false` (treat as first-contact) โ€” the safe direction: + /// the sender just pays the PoW toll instead of being waved through. + pub fn is_known(&self, pubkey: &str) -> bool { + self.known + .read() + .map(|set| set.contains(pubkey)) + .unwrap_or(false) + } + + /// Number of cached active keys (diagnostics / tests). + pub fn known_count(&self) -> usize { + self.known.read().map(|set| set.len()).unwrap_or(0) + } + + /// Record `id` and report whether it is a replay to drop. A poisoned lock + /// degrades to `false` (never drop a real message because dedup state was + /// lost). + pub fn is_replay(&self, id: EventId, now: i64) -> bool { + match self.replay.lock() { + Ok(mut guard) => guard.check_and_record(id, now), + Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::{EventBuilder, Keys}; + + fn an_event_id(note: &str) -> EventId { + EventBuilder::text_note(note) + .sign_with_keys(&Keys::generate()) + .expect("sign test event") + .id + } + + #[test] + fn known_set_membership_and_replace() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + assert!(!gate.is_known("a")); + gate.set_known(["a".to_string(), "b".to_string()]); + assert!(gate.is_known("a")); + assert!(gate.is_known("b")); + assert!(!gate.is_known("c")); + assert_eq!(gate.known_count(), 2); + + // set_known replaces wholesale (a refreshed DB snapshot, not a merge): + // a key no longer active drops out. + gate.set_known(["c".to_string()]); + assert!(gate.is_known("c")); + assert!(!gate.is_known("a")); + assert_eq!(gate.known_count(), 1); + } + + #[test] + fn replay_first_seen_then_duplicate() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let id = an_event_id("dup"); + let now = 1_000_000; + assert!(!gate.is_replay(id, now), "first sighting is not a replay"); + assert!( + gate.is_replay(id, now + 1), + "second sighting within window is a replay" + ); + assert!( + gate.is_replay(id, now + 30), + "still a replay later in the window" + ); + } + + #[test] + fn distinct_ids_are_independent() { + let gate = SpamGate::new(REPLAY_WINDOW_SECS); + let a = an_event_id("a"); + let b = an_event_id("b"); + let now = 500; + assert!(!gate.is_replay(a, now)); + assert!(!gate.is_replay(b, now), "a different id is not a replay"); + assert!(gate.is_replay(a, now), "but re-seeing a is"); + } + + #[test] + fn entry_expires_after_window() { + let mut guard = ReplayGuard::new(60); + let id = an_event_id("expire"); + assert!(!guard.check_and_record(id, 1_000)); + // Past the window the prior sighting is pruned, so it reads as fresh. + assert!(!guard.check_and_record(id, 1_000 + 61)); + // ...and is tracked again from the new timestamp. + assert!(guard.check_and_record(id, 1_000 + 61)); + } + + #[test] + fn prune_keeps_map_bounded_to_window() { + let mut guard = ReplayGuard::new(60); + for i in 0..100 { + // Each a distinct id at a distinct, steadily-advancing time. + guard.check_and_record(an_event_id(&format!("e{i}")), 10_000 + i); + } + // Advancing well past the window and touching the guard prunes every + // stale entry, leaving only the one just recorded. + let fresh = an_event_id("fresh"); + guard.check_and_record(fresh, 10_000 + 100 + 61); + assert_eq!( + guard.seen.len(), + 1, + "stale entries must be pruned, leaving only the fresh sighting" + ); + } +} diff --git a/src/util.rs b/src/util.rs index 39050002..c16e41f0 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,6 @@ -use crate::bitcoin_price::BitcoinPriceManager; -use crate::config::constants::{DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS}; +use crate::config::constants::{ + DEV_FEE_AUDIT_EVENT_KIND, DEV_FEE_LIGHTNING_ADDRESS, DM_EVENT_KIND, +}; use crate::config::settings::{get_db_pool, Settings}; use crate::config::*; use crate::db; @@ -37,13 +38,63 @@ const MAX_RETRY: u16 = 4; // Redefined for convenience type OrderKind = mostro_core::order::Kind; +/// Resolve the Yadio base URL for the live market-quote path (`/convert`, +/// `/currencies`). +/// +/// Phase 1 transition (spec ยง10.1): the cached aggregate path reads +/// `[price.providers.yadio].url`, but this live path historically read the +/// legacy `[mostro].bitcoin_price_api_url`. If an operator customises only +/// the new key, the two paths would silently hit different Yadio bases. +/// Prefer the configured Yadio provider URL when it is *usable* โ€” the provider +/// is enabled and has a non-empty URL โ€” otherwise fall back to the legacy key. +/// A disabled or blank provider entry must not suppress that fallback. The +/// chosen URL is normalized exactly as [`YadioProvider::new`] does +/// (`trim_end_matches('/')`) so appending `/convert` / `/currencies` can never +/// produce a `//`, keeping the live and aggregate paths on an identical base. +/// Phase 4 removes this live HTTP path entirely, at which point the legacy key +/// only feeds legacy synthesis. +fn yadio_base_url() -> String { + let provider = Settings::get_price().and_then(|price| { + price + .providers + .get(&crate::price::ProviderId::Yadio.to_string()) + .map(|yadio| (yadio.url.as_str(), yadio.enabled)) + }); + let legacy = Settings::get_mostro().bitcoin_price_api_url.clone(); + select_yadio_base_url(provider, &legacy) +} + +/// Drop surrounding whitespace and any trailing slash, matching +/// [`crate::price::providers::yadio::YadioProvider::new`]. A URL that is only +/// slashes/whitespace normalizes to empty and is treated as "not configured". +fn normalize_base_url(raw: &str) -> String { + raw.trim().trim_end_matches('/').to_string() +} + +/// Pure selection logic behind [`yadio_base_url`], split out so it is unit +/// testable without the write-once global `Settings`. `provider` is +/// `(url, enabled)` for the `[price.providers.yadio]` entry when present. +/// Prefer that URL only when it is *usable* โ€” the provider is enabled and the +/// URL is non-empty after normalization โ€” otherwise fall back to the +/// (normalized) legacy `bitcoin_price_api_url`. +fn select_yadio_base_url(provider: Option<(&str, bool)>, legacy: &str) -> String { + if let Some((url, enabled)) = provider { + if enabled { + let url = normalize_base_url(url); + if !url.is_empty() { + return url; + } + } + } + normalize_base_url(legacy) +} + pub async fn retries_yadio_request( req_string: &str, fiat_code: &str, ) -> Result<(Option, bool), MostroError> { // Get Fiat list and check if currency exchange is available - let mostro_settings = Settings::get_mostro(); - let api_req_string = format!("{}/currencies", mostro_settings.bitcoin_price_api_url); + let api_req_string = format!("{}/currencies", yadio_base_url()); let fiat_list_check = HTTP_CLIENT .get(api_req_string) .send() @@ -69,7 +120,7 @@ pub async fn retries_yadio_request( } pub fn get_bitcoin_price(fiat_code: &str) -> Result { - BitcoinPriceManager::get_price(fiat_code) + crate::price::get_bitcoin_price(fiat_code) } /// Request market quote from Yadio to have sats amount at actual market price @@ -79,10 +130,11 @@ pub async fn get_market_quote( premium: i64, ) -> Result { // Add here check for market price - let mostro_settings = Settings::get_mostro(); let req_string = format!( "{}/convert/{}/{}/BTC", - mostro_settings.bitcoin_price_api_url, fiat_amount, fiat_code + yadio_base_url(), + fiat_amount, + fiat_code ); info!("Requesting API price: {}", req_string); @@ -260,6 +312,9 @@ pub fn get_expiration_timestamp_for_kind(kind: u16) -> Option { let mostro_settings = Settings::get_mostro(); Some(now + Duration::days(mostro_settings.max_expiration_days.into()).num_seconds()) } + // Protocol-v2 direct messages: same 30-day default as + // `ExpirationSettings::get_expiration_for_kind`. + DM_EVENT_KIND => Some(now + Duration::days(30).num_seconds()), _ => None, } } @@ -363,7 +418,7 @@ pub async fn publish_order( trade_index: Option, ) -> Result<(), MostroError> { // Prepare a new default order - let new_order_db = match prepare_new_order( + let mut new_order_db = match prepare_new_order( new_order, initiator_pubkey, trade_index, @@ -378,19 +433,150 @@ pub async fn publish_order( } }; + // Phase 5/6: when the maker side is bonded, the order must NOT hit the + // order book until the maker locks an anti-abuse bond. Park it at + // `WaitingMakerBond` (no NIP-33 event emitted), request the bond, and + // defer the publication to `resume_publish_after_maker_bond`, which + // the bond subscriber calls on `Accepted`. Both fixed-amount (Phase 5) + // and range (Phase 6) orders take this path; range orders size the + // bond against `max_amount` (worst-case exposure) and resolve slashes + // proportionally per taken slice โ€” see `maker_bond_notional_sats`. + let maker_bond_required = crate::app::bond::maker_bond_required(); + if maker_bond_required { + let notional = maker_bond_notional_sats(&new_order_db)?; + new_order_db.status = Status::WaitingMakerBond.to_string(); + let order = new_order_db + .create(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + info!("New order saved (awaiting maker bond) Id: {}", order.id); + if let Err(e) = crate::app::bond::request_maker_bond( + pool, + &order, + trade_pubkey, + notional, + request_id, + trade_index, + ) + .await + { + // The order was parked at `WaitingMakerBond` but never emitted + // a NIP-33 event, and `request_maker_bond` already released any + // bond row it managed to create. Without cleanup the row would + // sit hidden in `WaitingMakerBond` until the order-expiry job + // reaps it hours later. Delete the stranded row now (scoped to + // the parked status so we never touch one that has since + // advanced) and surface the error to the maker. + tracing::warn!( + order_id = %order.id, + "publish_order: request_maker_bond failed ({}); deleting stranded WaitingMakerBond order", + e + ); + if let Err(del) = sqlx::query("DELETE FROM orders WHERE id = ? AND status = ?") + .bind(order.id) + .bind(Status::WaitingMakerBond.to_string()) + .execute(pool) + .await + { + tracing::warn!( + order_id = %order.id, + "publish_order: failed to delete stranded order: {}", del + ); + } + return Err(e); + } + return Ok(()); + } + // CRUD order creation - let mut order = new_order_db - .clone() + let order = new_order_db .create(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + info!("New order saved Id: {}", order.id); + + finalize_order_publication( + pool, + keys, + order, + identity_pubkey, + trade_pubkey, + request_id, + trade_index, + ) + .await +} + +/// Sats notional a maker bond is sized against. +/// +/// - **Range orders (Phase 6).** Sized against `max_amount` โ€” the +/// worst-case fiat exposure the maker is advertising โ€” converted at the +/// current cached price. Each taken slice later slashes a proportional +/// share of the resulting bond (`slice.fiat_amount / max_amount`), so +/// the notional must be the range ceiling, not any single slice. +/// - **Fixed-price orders.** Carry their sats `amount` directly. +/// - **Market-priced single orders.** `amount == 0` at creation, so we +/// convert the fiat amount at the current cached price โ€” the same quote +/// `calculate_and_check_quote` validates against at order time. +/// +/// The bond is a one-time snapshot and is not repriced if the market moves +/// before the order is taken (spec ยง10.3). +fn maker_bond_notional_sats(order: &Order) -> Result { + // Range orders: size against the fiat ceiling (`max_amount`). + if order.is_range_order() { + // `is_range_order()` only checks that `min`/`max` are `Some`, not + // that they are positive, so guard against a zero/negative ceiling + // here โ€” a non-positive `max_amount` would otherwise size the bond + // at the floor and later divide-by-zero in the proportional slash + // (`record_maker_slice_slash`, which carries the matching guard). + let max_fiat = order.max_amount.filter(|m| *m > 0).ok_or_else(|| { + MostroInternalErr(ServiceError::UnexpectedError( + "range order missing positive max_amount".to_string(), + )) + })?; + let price = get_bitcoin_price(&order.fiat_code)?; + if price <= 0.0 { + return Err(MostroInternalErr(ServiceError::NoAPIResponse)); + } + let sats = (max_fiat as f64 / price) * 1E8; + return Ok(sats as i64); + } + if order.amount > 0 { + return Ok(order.amount); + } + let price = get_bitcoin_price(&order.fiat_code)?; + if price <= 0.0 { + return Err(MostroInternalErr(ServiceError::NoAPIResponse)); + } + let sats = (order.fiat_amount as f64 / price) * 1E8; + Ok(sats as i64) +} + +/// Publish the NIP-33 event for a freshly-persisted order, persist its +/// `event_id`, ack the maker with [`Action::NewOrder`], and broadcast. +/// +/// Shared by the inline `publish_order` path (no maker bond) and the +/// deferred [`resume_publish_after_maker_bond`] path (maker bond locked). +/// The order row must already exist in the DB; on success it is in +/// `Status::Pending` with its `event_id` set. +async fn finalize_order_publication( + pool: &SqlitePool, + keys: &Keys, + mut order: Order, + identity_pubkey: PublicKey, + trade_pubkey: PublicKey, + request_id: Option, + trade_index: Option, +) -> Result<(), MostroError> { let order_id = order.id; - info!("New order saved Id: {}", order_id); + // The maker-bond path parked the order at `WaitingMakerBond`; the + // no-bond path created it at `Pending`. Either way it goes live now. + order.status = Status::Pending.to_string(); // Get tags for new order in case of full privacy or normal order // nip33 kind with order fields as tags and order id as identifier (kind 38383 for orders) let event = if let Some(tags) = - get_tags_for_new_order(&new_order_db, pool, &identity_pubkey, &trade_pubkey, keys).await? + get_tags_for_new_order(&order, pool, &identity_pubkey, &trade_pubkey, keys).await? { new_order_event(keys, "", order_id.to_string(), tags) .map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))? @@ -401,21 +587,22 @@ pub async fn publish_order( info!("Order event to be published: {event:#?}"); let event_id = event.id.to_string(); info!("Publishing Event Id: {event_id} for Order Id: {order_id}"); - // We update the order with the new event_id + // We update the order with the new event_id (and Pending status) order.event_id = event_id; + // Build the ack payload before `update` consumes the order row. + let mut small = order.as_new_order(); + small.id = Some(order_id); order .update(pool) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; - let mut order = new_order_db.as_new_order(); - order.id = Some(order_id); // Send message as ack with small order enqueue_order_msg( request_id, Some(order_id), Action::NewOrder, - Some(Payload::Order(order)), + Some(Payload::Order(small)), trade_pubkey, trade_index, ) @@ -430,6 +617,70 @@ pub async fn publish_order( .map_err(|err| MostroInternalErr(ServiceError::NostrError(err.to_string()))) } +/// Finish publishing an order whose maker bond has just locked. +/// +/// Called from the bond subscriber (`bond::flow::on_maker_bond_accepted`). +/// Derives the maker's identity and trade pubkeys from the order row โ€” +/// the maker is the seller on a sell order, the buyer on a buy order +/// (ยง3.1) โ€” and hands off to [`finalize_order_publication`]. Idempotency +/// across redeliveries is enforced by the caller, which only invokes this +/// while the order is still in `WaitingMakerBond`. +pub async fn resume_publish_after_maker_bond( + pool: &SqlitePool, + keys: &Keys, + order: Order, + request_id: Option, +) -> Result<(), MostroError> { + // Atomically claim the deferred `WaitingMakerBond โ†’ Pending` + // transition. The bond subscriber already re-read the row and saw + // `WaitingMakerBond`, but that check is not atomic with the publish + // below: the order-expiry job (`job_expire_pending_older_orders`) can + // flip the row `WaitingMakerBond โ†’ Expired` (and cancel the just-locked + // bond) in between. Without a CAS the full-row write inside + // `finalize_order_publication` would blindly resurrect the dead order + // back to `Pending` and emit a NIP-33 event for it. If the CAS affects + // 0 rows another path already owns the status, so we skip cleanly. + let cas = sqlx::query("UPDATE orders SET status = ? WHERE id = ? AND status = ?") + .bind(Status::Pending.to_string()) + .bind(order.id) + .bind(Status::WaitingMakerBond.to_string()) + .execute(pool) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + if cas.rows_affected() != 1 { + info!( + "resume_publish_after_maker_bond: order {} no longer WaitingMakerBond โ€” skipping deferred publish", + order.id + ); + return Ok(()); + } + let kind = order.get_order_kind().map_err(MostroInternalErr)?; + let (trade_pubkey, identity_pubkey, trade_index) = match kind { + OrderKind::Sell => ( + order.get_seller_pubkey().map_err(MostroInternalErr)?, + order + .get_master_seller_pubkey() + .map_err(MostroInternalErr)?, + order.trade_index_seller, + ), + OrderKind::Buy => ( + order.get_buyer_pubkey().map_err(MostroInternalErr)?, + order.get_master_buyer_pubkey().map_err(MostroInternalErr)?, + order.trade_index_buyer, + ), + }; + finalize_order_publication( + pool, + keys, + order, + identity_pubkey, + trade_pubkey, + request_id, + trade_index, + ) + .await +} + async fn prepare_new_order( new_order: &SmallOrder, initiator_pubkey: PublicKey, @@ -495,6 +746,25 @@ async fn prepare_new_order( Ok(new_order_db) } +/// Overwrite the inner protocol version of `message` so it matches the wire +/// `transport` (`gift-wrap` -> v1, `nip44` -> v2). +/// +/// `MessageKind::new` always stamps the crate-wide `PROTOCOL_VER`, so without +/// this every reply would advertise v2 even when it is served over the v1 +/// gift-wrap transport. Keeping the inner version aligned with the transport +/// lets the protocol version follow the negotiated wire format. +fn stamp_protocol_version(message: &mut Message, transport: Transport) { + let version = transport.protocol_version(); + match message { + Message::Order(k) + | Message::Dispute(k) + | Message::CantDo(k) + | Message::Rate(k) + | Message::Dm(k) + | Message::Restore(k) => k.version = version, + } +} + pub async fn send_dm( receiver_pubkey: PublicKey, sender_keys: &Keys, @@ -506,13 +776,32 @@ pub async fn send_dm( sender_keys.public_key().to_hex(), receiver_pubkey.to_hex() ); - let message = Message::from_json(payload) + let mut message = Message::from_json(payload) .map_err(|_| MostroInternalErr(ServiceError::MessageSerializationError))?; + // Non-panicking accessor: send_dm sits on every reply path and is + // exercised by unit tests that don't initialize the global config. + let transport = Settings::get_transport(); + + // Stamp the inner protocol version to match the active wire transport. + // Done before wrapping so the version is covered by the message/trade + // signatures. + stamp_protocol_version(&mut message, transport); + + // Kind-14 events are visible to relays, so they always carry a NIP-40 + // expiration tag (default 30 days via `dm_days`) instead of lingering + // forever. Callers that pass an explicit expiration keep it. + let expiration = match (transport, expiration) { + (Transport::Nip44Direct, None) => get_expiration_timestamp_for_kind(DM_EVENT_KIND) + .map(|secs| Timestamp::from_secs(secs as u64)), + (_, exp) => exp, + }; + // Mostro node holds a single keypair: it doubles as identity and trade key. // Server-originated messages are unsigned because clients don't track a // trade_index for the node. - let event = wrap_message( + let event = wrap_message_with( + transport, &message, sender_keys, sender_keys, @@ -1168,16 +1457,14 @@ pub async fn get_dispute(msg: &Message, pool: &Pool) -> Result) -> Result { let order_msg = msg.get_inner_message_kind(); - let order_id = order_msg - .id - .ok_or(MostroInternalErr(ServiceError::InvalidOrderId))?; + let order_id = order_msg.id.ok_or(MostroCantDo(CantDoReason::NotFound))?; let order = Order::by_id(pool, order_id) .await .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; if let Some(order) = order { Ok(order) } else { - Err(MostroInternalErr(ServiceError::InvalidOrderId)) + Err(MostroCantDo(CantDoReason::NotFound)) } } @@ -1352,6 +1639,7 @@ pub async fn notify_taker_reputation( #[cfg(test)] mod tests { use super::*; + use crate::bitcoin_price::BitcoinPriceManager; use mostro_core::message::{Message, MessageKind}; use mostro_core::order::Order; use sqlx::sqlite::SqlitePoolOptions; @@ -1367,6 +1655,95 @@ mod tests { }); } + #[test] + fn stamp_protocol_version_follows_transport() { + use mostro_core::message::Action; + + // A v2-stamped message (the `MessageKind::new` default) must be + // downgraded to v1 when served over the gift-wrap transport... + let mut msg = Message::new_order( + Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")), + Some(1), + None, + Action::NewOrder, + None, + ); + stamp_protocol_version(&mut msg, Transport::GiftWrap); + assert_eq!(msg.get_inner_message_kind().version, 1); + + // ...and stamped back to v2 over the nip44 direct transport. + stamp_protocol_version(&mut msg, Transport::Nip44Direct); + assert_eq!(msg.get_inner_message_kind().version, 2); + } + + #[test] + fn stamp_protocol_version_covers_all_variants() { + use mostro_core::message::Action; + + let kind = MessageKind::new(None, Some(1), None, Action::CantDo, None); + for mut msg in [ + Message::Order(kind.clone()), + Message::Dispute(kind.clone()), + Message::CantDo(kind.clone()), + Message::Rate(kind.clone()), + Message::Dm(kind.clone()), + Message::Restore(kind.clone()), + ] { + stamp_protocol_version(&mut msg, Transport::GiftWrap); + assert_eq!(msg.get_inner_message_kind().version, 1); + } + } + + #[test] + fn select_yadio_base_url_prefers_enabled_provider() { + // Enabled provider with a usable URL wins over the legacy key. + assert_eq!( + select_yadio_base_url( + Some(("https://provider.example", true)), + "https://legacy.example" + ), + "https://provider.example" + ); + } + + #[test] + fn select_yadio_base_url_strips_trailing_slash_like_provider_new() { + // A configured trailing slash must not survive โ€” otherwise appending + // `/convert` yields `//convert`. Matches `YadioProvider::new`. + assert_eq!( + select_yadio_base_url( + Some(("https://api.yadio.io/", true)), + "https://legacy.example" + ), + "https://api.yadio.io" + ); + // Whitespace + multiple trailing slashes both normalized away. + assert_eq!( + select_yadio_base_url(Some((" https://api.yadio.io// ", true)), "ignored"), + "https://api.yadio.io" + ); + // The legacy fallback is normalized the same way. + assert_eq!( + select_yadio_base_url(None, "https://legacy.example/"), + "https://legacy.example" + ); + } + + #[test] + fn select_yadio_base_url_falls_back_when_provider_unusable() { + let legacy = "https://legacy.example"; + // Disabled provider โ†’ fall back to legacy even with a URL set. + assert_eq!( + select_yadio_base_url(Some(("https://provider.example", false)), legacy), + legacy + ); + // Enabled but blank / slash-only URL โ†’ fall back to legacy. + assert_eq!(select_yadio_base_url(Some((" ", true)), legacy), legacy); + assert_eq!(select_yadio_base_url(Some(("/", true)), legacy), legacy); + // No provider entry at all โ†’ legacy. + assert_eq!(select_yadio_base_url(None, legacy), legacy); + } + async fn setup_orders_pool() -> SqlitePool { let pool = SqlitePoolOptions::new() .max_connections(1) @@ -1591,6 +1968,65 @@ mod tests { assert!(orders.is_empty()); } + #[tokio::test] + async fn test_get_order_returns_not_found_when_id_missing() { + initialize(); + let pool = setup_orders_pool().await; + let message = Message::Order(MessageKind::new( + None, + None, + None, + Action::AdminSettle, + None, + )); + + let err = get_order(&message, &pool).await.unwrap_err(); + assert!(matches!( + err, + MostroError::MostroCantDo(CantDoReason::NotFound) + )); + } + + #[tokio::test] + async fn test_get_order_returns_not_found_when_order_absent() { + initialize(); + let pool = setup_orders_pool().await; + let missing_id = Uuid::new_v4(); + let message = Message::Order(MessageKind::new( + Some(missing_id), + None, + None, + Action::AdminSettle, + None, + )); + + let err = get_order(&message, &pool).await.unwrap_err(); + assert!(matches!( + err, + MostroError::MostroCantDo(CantDoReason::NotFound) + )); + } + + #[tokio::test] + async fn test_get_order_returns_order_when_found() { + initialize(); + let pool = setup_orders_pool().await; + let user_pubkey = "a".repeat(64); + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id, Some(&user_pubkey), None, &user_pubkey).await; + + let message = Message::Order(MessageKind::new( + Some(order_id), + None, + None, + Action::AdminSettle, + None, + )); + + let order = get_order(&message, &pool).await.unwrap(); + assert_eq!(order.id, order_id); + } + #[test] fn test_get_dev_fee_basic() { // 1000 sats Mostro fee at 30% -> 300 sats @@ -1617,4 +2053,58 @@ mod tests { let fee = calculate_dev_fee(1, 0.30); assert_eq!(fee, 0); } + + #[test] + fn maker_bond_notional_uses_fixed_amount_directly() { + // Phase 5: a fixed-price order carries its sats `amount`, so the + // maker-bond notional is exactly that โ€” no price lookup, no API + // dependency in this path. + let order = Order { + amount: 50_000, + fiat_code: "USD".to_string(), + fiat_amount: 25, + ..Default::default() + }; + assert_eq!(maker_bond_notional_sats(&order).unwrap(), 50_000); + } + + #[test] + fn maker_bond_notional_range_sizes_against_max_at_price() { + // Phase 6: a range order sizes the notional against `max_amount` + // converted at the cached price: 100 fiat / 60_000 * 1e8 โ‰ˆ 166_666 + // sats. Unique fiat_code avoids clobbering the shared price cache. + BitcoinPriceManager::set_price_for_test("T6RANGE", 60_000.0); + let order = Order { + amount: 0, + min_amount: Some(10), + max_amount: Some(100), + fiat_code: "T6RANGE".to_string(), + fiat_amount: 0, + ..Default::default() + }; + assert!(order.is_range_order()); + assert_eq!(maker_bond_notional_sats(&order).unwrap(), 166_666); + } + + #[test] + fn maker_bond_notional_range_rejects_non_positive_max() { + // `is_range_order()` only checks `Some`-ness, so a `max_amount` of 0 + // still enters the range branch and must be rejected before bond + // sizing (it would divide-by-zero in the proportional slash). A + // `None` max can't reach here โ€” `is_range_order()` would be false. + let order = Order { + amount: 0, + min_amount: Some(10), + max_amount: Some(0), + fiat_code: "T6ZERO".to_string(), + fiat_amount: 0, + ..Default::default() + }; + assert!(order.is_range_order()); + let err = maker_bond_notional_sats(&order).unwrap_err(); + assert!( + matches!(err, MostroInternalErr(ServiceError::UnexpectedError(_))), + "expected UnexpectedError for non-positive max_amount, got {err:?}" + ); + } } diff --git a/tests/fixtures/price/blockchain_ticker.json b/tests/fixtures/price/blockchain_ticker.json new file mode 100644 index 00000000..9bbd098b --- /dev/null +++ b/tests/fixtures/price/blockchain_ticker.json @@ -0,0 +1 @@ +{"ARS":{"15m":9.083198811E7,"last":9.083198811E7,"buy":9.083198811E7,"sell":9.083198811E7,"symbol":"ARS"},"AUD":{"15m":90098.69,"last":90098.69,"buy":90098.69,"sell":90098.69,"symbol":"AUD"},"BRL":{"15m":325344.93,"last":325344.93,"buy":325344.93,"sell":325344.93,"symbol":"BRL"},"CAD":{"15m":88608.43,"last":88608.43,"buy":88608.43,"sell":88608.43,"symbol":"CAD"},"CHF":{"15m":50480.71,"last":50480.71,"buy":50480.71,"sell":50480.71,"symbol":"CHF"},"CLP":{"15m":5.742869351E7,"last":5.742869351E7,"buy":5.742869351E7,"sell":5.742869351E7,"symbol":"CLP"},"CNY":{"15m":429686.37,"last":429686.37,"buy":429686.37,"sell":429686.37,"symbol":"CNY"},"CZK":{"15m":1325545.33,"last":1325545.33,"buy":1325545.33,"sell":1325545.33,"symbol":"CZK"},"DKK":{"15m":409665.43,"last":409665.43,"buy":409665.43,"sell":409665.43,"symbol":"DKK"},"EUR":{"15m":54815.31,"last":54815.31,"buy":54815.31,"sell":54815.31,"symbol":"EUR"},"GBP":{"15m":47293.02,"last":47293.02,"buy":47293.02,"sell":47293.02,"symbol":"GBP"},"GHS":{"15m":764497.37,"last":764497.37,"buy":764497.37,"sell":764497.37,"symbol":"GHS"},"HKD":{"15m":496968.37,"last":496968.37,"buy":496968.37,"sell":496968.37,"symbol":"HKD"},"HRK":{"15m":292489.89,"last":292489.89,"buy":292489.89,"sell":292489.89,"symbol":"HRK"},"HUF":{"15m":1.936378179E7,"last":1.936378179E7,"buy":1.936378179E7,"sell":1.936378179E7,"symbol":"HUF"},"INR":{"15m":6044349.35,"last":6044349.35,"buy":6044349.35,"sell":6044349.35,"symbol":"INR"},"ISK":{"15m":7884422.35,"last":7884422.35,"buy":7884422.35,"sell":7884422.35,"symbol":"ISK"},"JPY":{"15m":1.014347211E7,"last":1.014347211E7,"buy":1.014347211E7,"sell":1.014347211E7,"symbol":"JPY"},"KRW":{"15m":9.634745798E7,"last":9.634745798E7,"buy":9.634745798E7,"sell":9.634745798E7,"symbol":"KRW"},"NGN":{"15m":8.823351891E7,"last":8.823351891E7,"buy":8.823351891E7,"sell":8.823351891E7,"symbol":"NGN"},"NZD":{"15m":108824.86,"last":108824.86,"buy":108824.86,"sell":108824.86,"symbol":"NZD"},"PLN":{"15m":233235.43,"last":233235.43,"buy":233235.43,"sell":233235.43,"symbol":"PLN"},"RON":{"15m":287197.37,"last":287197.37,"buy":287197.37,"sell":287197.37,"symbol":"RON"},"RUB":{"15m":4563928.66,"last":4563928.66,"buy":4563928.66,"sell":4563928.66,"symbol":"RUB"},"SEK":{"15m":599979.15,"last":599979.15,"buy":599979.15,"sell":599979.15,"symbol":"SEK"},"SGD":{"15m":81472.88,"last":81472.88,"buy":81472.88,"sell":81472.88,"symbol":"SGD"},"THB":{"15m":2080535.41,"last":2080535.41,"buy":2080535.41,"sell":2080535.41,"symbol":"THB"},"TRY":{"15m":2926544.59,"last":2926544.59,"buy":2926544.59,"sell":2926544.59,"symbol":"TRY"},"TWD":{"15m":2001567.55,"last":2001567.55,"buy":2001567.55,"sell":2001567.55,"symbol":"TWD"},"USD":{"15m":63410.18,"last":63410.18,"buy":63410.18,"sell":63410.18,"symbol":"USD"}} \ No newline at end of file diff --git a/tests/fixtures/price/coingecko_simple_price.json b/tests/fixtures/price/coingecko_simple_price.json new file mode 100644 index 00000000..3edee159 --- /dev/null +++ b/tests/fixtures/price/coingecko_simple_price.json @@ -0,0 +1 @@ +{"bitcoin":{"usd":63410,"eur":54815,"ars":90832019,"jpy":10143476,"gbp":47293,"brl":325345,"cad":88608,"aud":90099,"chf":50481,"cny":429687,"inr":6044351,"mxn":1096060,"clp":57428713,"cop":222196940,"pen":215612,"vnd":1669348380,"krw":96347491,"sek":599979,"nok":601084,"dkk":409666,"pln":233236,"czk":1325546,"huf":19363788,"try":2926546,"zar":1035644,"ngn":86298742,"idr":1137904565,"myr":257908,"php":3881370,"sgd":81473,"thb":2080536,"twd":2001568,"hkd":496969,"nzd":108825,"ils":187917,"aed":232915,"sar":238085,"uah":2847949,"rub":4563930,"pkr":17636481,"bdt":7795676,"lkr":21120528,"mmk":133149366,"kwd":19562.43,"bhd":23915}} \ No newline at end of file diff --git a/tests/fixtures/price/currency_api_btc.json b/tests/fixtures/price/currency_api_btc.json new file mode 100644 index 00000000..c6181d85 --- /dev/null +++ b/tests/fixtures/price/currency_api_btc.json @@ -0,0 +1 @@ +{"date":"2026-06-11","btc":{"1inch":873358.64177631,"aave":990.48704348,"ada":377334.41311854,"aed":229602.12070884,"afn":3911290.60712325,"agix":753590.65429234,"akt":105343.38852263,"algo":704236.31952613,"all":5144157.04781142,"amd":23033933.41025167,"amp":121143618.34485012,"ang":112653.47298278,"aoa":57312397.31945596,"ape":512866.9438841,"apt":98205.15611174,"ar":33864.78874137,"arb":780120.25669436,"ars":89606381.47398803,"atom":33812.69445086,"ats":744999.11494588,"aud":89246.73945399,"avax":9517.98602555,"awg":111909.54283698,"axs":67837.55987532,"azm":531414018.33468133,"azn":106282.80366431,"bake":84576231.63619502,"bam":105890.97759534,"bat":672614.09838708,"bbd":125038.59534858,"bch":312.19129046,"bdt":7675738.40493356,"bef":2184050.47833405,"bgn":105890.97759534,"bhd":23507.25592553,"bif":186834436.44468045,"bmd":62519.29767429,"bnb":105.28179776,"bnd":80463.99389977,"bob":432212.34736016,"brl":324564.99389119,"bsd":62519.29767429,"bsv":5552.22653165,"bsw":195943041.2558728,"btc":1,"btcb":855363.38703476,"btg":312898.37412022,"btn":5979464.19264713,"btt":235195707122.3899,"busd":62527.46298862,"bwp":848765.59095166,"byn":172140.20376712,"byr":1225221994.4030302,"bzd":125889.11900258,"cad":87141.13544125,"cake":47517.28413312,"cdf":144381562.0083922,"celo":1032545.53242265,"cfx":1410030.27504059,"chf":49927.58065193,"chz":2313576.33277798,"clp":57256886.08306731,"cnh":423782.40572379,"cny":423560.45771565,"comp":3471.40119233,"cop":222383340.28531274,"crc":28676284.41049925,"cro":1043704.28029752,"crv":259104.31280755,"cspr":30024800.4138461,"cuc":62511.31792775,"cup":1656550.14165014,"cve":5970149.80814951,"cvx":49032.12220155,"cyp":31687.43501208,"czk":1309274.22318612,"dai":62538.42114929,"dash":1800.36806634,"dcr":5188.57568863,"dem":105890.97759534,"dfi":76710230.01826873,"djf":11124706.08597759,"dkk":404685.08428914,"doge":737358.14037136,"dop":3648953.26235268,"dot":66319.42780728,"dydx":526139.5693646,"dzd":8353735.4267307,"eek":847127.82075026,"egld":21663.89201863,"egp":3239771.50771887,"enj":2164373.40872287,"eos":1009917.81526764,"ern":937789.46511438,"esp":9008337.22661481,"etb":10049260.56984133,"etc":8816.24373224,"eth":37.89596074,"eur":54141.19713333,"eurc":53351.71889082,"fei":62463.27691515,"fil":82593.99327532,"fim":321908.94004749,"fjd":139034.15896149,"fkp":46721.61238003,"flow":2134573.77649122,"flr":8942881.26674085,"frax":62930.45600818,"frf":355142.9724939,"ftt":225026.45490236,"gala":23729935.30769002,"gbp":46721.61238003,"gel":165834.9390846,"ggp":46721.61238003,"ghc":7202917903.884712,"ghs":720291.79038568,"gip":46721.61238003,"gmd":4604455.64368084,"gmx":11325.96662517,"gnf":547951029.0995998,"gno":654.77339407,"grd":18448612.92397797,"grt":3276517.15547917,"gt":9786.54806105,"gtq":476584.08983675,"gusd":62657.70421816,"gyd":13077492.70747889,"hbar":791552.8671025,"hkd":489919.35349278,"hnl":1671649.83914938,"hnt":206928.67306697,"hot":202730100.1664373,"hrk":407926.84981674,"ht":1585575.15194167,"htg":8181572.38055271,"huf":19304983.25824161,"icp":27513.19450141,"idr":1123490534.3556936,"iep":42639.65778212,"ils":185815.35406401,"imp":46721.61238003,"imx":446390.54532456,"inj":11953.91554089,"inr":5979464.19264713,"iqd":81933197.64105527,"irr":85964013068.94534,"isk":7764287.66409181,"itl":104831975.77787504,"jep":46721.61238003,"jmd":9874765.66048675,"jod":44326.18205107,"jpy":10035022.64326886,"kas":2033712.829375,"kava":1453995.34060947,"kcs":9474.17059992,"kda":9102559.67190589,"kes":8094311.89510324,"kgs":5467690.58863284,"khr":251704835.07802707,"klay":1753734.51659107,"kmf":26635722.9371428,"knc":514060.68673805,"kpw":56251145.04232385,"krw":95420038.25515994,"ksm":17409.6811154,"kwd":19335.25104454,"kyd":51994.66801334,"kzt":30510010.76539265,"lak":1375985297.4501765,"lbp":5607006058.40907,"ldo":238622.919796,"leo":6586.30878231,"link":8044.65245672,"lkr":20782420.45140602,"lrc":4876280.02286568,"lrd":11408246.30269154,"lsl":1033729.39789577,"ltc":1465.8735035,"ltl":186938.72546849,"luf":2184050.47833405,"luna":1224624.90965731,"lunc":897779356.4535635,"lvl":38050.43334401,"lyd":399261.26749575,"mad":579214.33518133,"mana":951211.88314565,"mbx":2309812.48553102,"mdl":1087973.63612203,"mga":262361235.68266144,"mgf":1311806178.4133008,"mina":1512182.16162979,"mkd":3338001.21054656,"mkr":44.01064499,"mmk":131231111.02398433,"mnt":223777323.0381863,"mop":504616.93410175,"mro":25054330.36033797,"mru":2505433.03603567,"mtl":23242.81593276,"mur":2992495.9090133,"mvr":966521.01510962,"mwk":108439040.25186457,"mxn":1087296.70990817,"mxv":123463.73423392,"myr":254294.0447404,"mzm":3989977193.779763,"mzn":3989977.1937827,"nad":1033729.39789577,"near":31070.23507929,"neo":28878.16644353,"nexo":78381.37613005,"nft":234521595019.30496,"ngn":85051540.9231668,"nio":2297446.91799648,"nlg":119311.49754222,"nok":591023.51648536,"npr":9571627.30638379,"nzd":107861.63227862,"okb":879.05863672,"omr":24057.12055086,"one":41877252.45205434,"op":672548.91864423,"ordi":19706.53192386,"pab":62519.29767429,"paxg":15.32789416,"pen":212690.53370183,"pepe":22632405244.876316,"pgk":275547.6056298,"php":3828106.44130736,"pi":498103.44398502,"pkr":17402243.76048506,"pln":230137.14984243,"pol":855281.22229699,"pte":10854335.48415177,"pyg":383210352.67218447,"qar":227570.24353442,"qnt":978.79405823,"qtum":89673.45365905,"rol":2834402454.948441,"ron":283440.24549359,"rpl":47663.47317754,"rsd":6354215.51386945,"rub":4516563.91534967,"rune":158755.74253618,"rvn":15038675.35983772,"rwf":91712084.63317722,"sand":1221062.6165324,"sar":234447.3662786,"sbd":502207.12133341,"scr":879804.79822692,"sdd":3752581803.2481327,"sdg":37525818.03248002,"sek":593729.148551,"sgd":80463.99389977,"shib":13299003855.704914,"shp":46721.61238003,"sit":12974396.48158877,"skk":1631057.7049087,"sle":1426998.3562793,"sll":1426998356.2777028,"snx":256243.31339183,"sol":962.43596647,"sos":35730668.3360342,"spl":10419.88290404,"srd":2336618.9759683,"srg":2336618975.9705224,"ssp":295270645.4711315,"std":1339006143.2297022,"stn":1339006.14323182,"stx":342463.56815727,"sui":83314.32373188,"svc":547043.85465006,"syp":6909508.02418241,"szl":1033729.39789577,"thb":2058796.21814174,"theta":398583.42949801,"tjs":583084.05355006,"tmm":1093949735.7849903,"tmt":218789.94715493,"tnd":182890.15609426,"ton":37675.50167217,"top":149091.61544627,"trl":2885389531946.79,"trx":194461.68246369,"try":2885389.53194491,"ttd":424533.66142449,"tusd":62599.0249936,"tvd":89246.73945399,"twd":1981940.54920831,"twt":161739.16014782,"tzs":163794549.7612469,"uah":2815695.2721056,"ugx":235343890.5800151,"uni":25216.87700229,"usd":62519.29767429,"usdc":62510.693627,"usdd":62589.02866001,"usdp":62594.04640871,"usdt":62584.63145902,"uyu":2526387.30073583,"uzs":754294186.7357095,"val":104831975.77787504,"veb":3533584624258374,"ved":35333173.45048812,"vef":3533317345048.6,"ves":35333173.45048812,"vet":13011459.38870935,"vnd":1645601083.4610512,"vuv":7477922.76216438,"waves":245609.79777127,"wemix":242150.34255118,"woo":4833022.52343392,"wst":170231.56268559,"xaf":35514297.24952164,"xag":980.72086664,"xau":15.33615877,"xaut":15.35045814,"xbt":0.99996491,"xcd":169259.27377162,"xcg":112653.47298278,"xch":31311.07249041,"xdc":2023318.00985592,"xdr":45816.48735361,"xec":11753178500.95859,"xem":115360933.60514471,"xlm":327721.28878698,"xmr":183.90915621,"xof":35514297.24952164,"xpd":50.1990073,"xpf":6460763.38131908,"xpt":37.50915286,"xrp":56094.07510802,"xtz":268988.30905786,"yer":14919549.60724687,"zar":1033729.39789577,"zec":149.18755169,"zil":20385995.02912681,"zmk":1097221982.8932648,"zmw":1097221.98289221,"zwd":22625733.82832639,"zwg":1674340.24944781,"zwl":4183714500.3348413}} \ No newline at end of file diff --git a/tests/fixtures/price/eltoque_trmi.json b/tests/fixtures/price/eltoque_trmi.json new file mode 100644 index 00000000..837a24aa --- /dev/null +++ b/tests/fixtures/price/eltoque_trmi.json @@ -0,0 +1,14 @@ +{ + "tasas": { + "ECU": 540.0, + "TRX": 150.0, + "MLC": 200.0, + "USD": 490.0, + "BTC": 490.0, + "USDT_TRC20": 525.0 + }, + "date": "2022-10-27", + "hour": 7, + "minutes": 59, + "seconds": 30 +} diff --git a/tests/fixtures/price/yadio_btc.json b/tests/fixtures/price/yadio_btc.json new file mode 100644 index 00000000..383de44a --- /dev/null +++ b/tests/fixtures/price/yadio_btc.json @@ -0,0 +1,11 @@ +{ + "BTC": { + "USD": 75899.55, + "EUR": 65393.99, + "ARS": 75899550.0, + "CUP": 28000000.0, + "BGN": null + }, + "base": "BTC", + "timestamp": 1779480604069 +}