From bb064385771c80dfda423e6cfaed2611e0ff15d7 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Tue, 8 Sep 2026 08:43:56 +1000 Subject: [PATCH 1/9] Repair payment isolation, replay protection, confirmation accounting and durable delivery --- .dockerignore | 5 + .github/workflows/audit.yml | 27 +- Cargo.lock | 1593 +++++++++++++------------- Cargo.toml | 4 +- Dockerfile | 25 +- docker-compose.yml | 24 +- scripts/check-dependency-exposure.py | 7 + src/addresses.rs | 11 +- src/api/auth.rs | 81 +- src/api/invoice_routes.rs | 94 +- src/api/mod.rs | 57 +- src/api/sessions.rs | 202 ++-- src/api/x402.rs | 683 ++++++++--- src/db.rs | 184 +-- src/db/bootstrap.rs | 399 +++---- src/db/maintenance.rs | 7 + src/invoices/matching.rs | 106 +- src/invoices/mod.rs | 18 - src/main.rs | 34 +- src/merchants/mod.rs | 97 +- src/repair_tests.rs | 212 ++++ src/scanner/blocks.rs | 52 +- src/scanner/decrypt.rs | 7 + src/scanner/invoice_detection.rs | 40 +- src/scanner/mod.rs | 266 +++-- src/sessions/mod.rs | 318 ++--- src/webhooks/mod.rs | 164 +-- 27 files changed, 2682 insertions(+), 2035 deletions(-) create mode 100644 .dockerignore create mode 100644 scripts/check-dependency-exposure.py create mode 100644 src/repair_tests.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c9c8eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env* +target +docs +*.db* diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 25ad2ac..a48532a 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -1,30 +1,19 @@ -name: Security Audit - +name: Security and regression checks on: push: - branches: [main] pull_request: schedule: - cron: '0 6 * * 1' - jobs: - cargo-audit: + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable + - run: cargo test --locked - run: cargo install cargo-audit --locked - # --deny unmaintained,unsound but NOT yanked: - # core2 is yanked but all zcash crates depend on it — upstream issue. - # Track: https://github.com/zcash/corez - # RUSTSEC-2023-0071: Marvin Attack on rsa (transitive, no direct exposure) - # RUSTSEC-2026-0097: (previous advisory, retained for safety) - # RUSTSEC-2026-0105: core2 unmaintained — zcash upstream dependency - # RUSTSEC-2026-0173: proc-macro-error2 unmaintained — transitive via getset (orchard/zcash_primitives) - - run: >- - cargo audit - --deny unmaintained --deny unsound - --ignore RUSTSEC-2023-0071 - --ignore RUSTSEC-2026-0097 - --ignore RUSTSEC-2026-0105 - --ignore RUSTSEC-2026-0173 + # Neither package is compiled for our supported server targets. SQLx keeps + # optional MySQL RSA dependencies in Cargo.lock; atomic-polyfill is for + # embedded AVR/ARM targets. Assert the deployment graph before exempting. + - run: python3 scripts/check-dependency-exposure.py + - run: cargo audit --deny unmaintained --deny unsound --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2023-0089 diff --git a/Cargo.lock b/Cargo.lock index 170bb65..44e1f66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" dependencies = [ "bitflags", "bytes", @@ -56,7 +56,6 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64 0.22.1", "bitflags", "brotli", "bytes", @@ -66,18 +65,14 @@ dependencies = [ "flate2", "foldhash", "futures-core", - "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", "itoa", "language-tags", - "local-channel", "mime", "percent-encoding", "pin-project-lite", - "rand 0.9.2", - "sha1", "smallvec", "tokio", "tokio-util", @@ -92,7 +87,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -112,9 +107,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" dependencies = [ "actix-macros", "futures-core", @@ -123,9 +118,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" dependencies = [ "actix-rt", "actix-service", @@ -133,7 +128,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -189,13 +184,12 @@ dependencies = [ "mime", "once_cell", "pin-project-lite", - "regex", "regex-lite", "serde", "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2", "time", "tracing", "url", @@ -210,7 +204,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -258,7 +252,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd80fa0bd6217e482112d9d87a05af8e0f8dec9e3aa51f34816f761c5cf7da7" dependencies = [ "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "addchain" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5665045de74fda906c0abfaec40cf2551f88c34dccea869f3fe950dde7209764" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", ] [[package]] @@ -317,9 +322,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -332,9 +337,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", ] @@ -347,39 +352,33 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -405,7 +404,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -417,7 +416,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -429,6 +428,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -437,9 +445,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -461,9 +475,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "base64urlsafedata" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f7f6be94fa637132933fd0a68b9140bcb60e3d46164cb68e82a2bb8d102b3a" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" dependencies = [ "base64 0.21.7", "pastey", @@ -494,18 +508,18 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -515,22 +529,20 @@ dependencies = [ [[package]] name = "blake2b_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq", ] [[package]] name = "blake2s_simd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee29928bad1e3f94c9d1528da29e07a1d3d04817ae8332de1e8b846c8439f4b3" +checksum = "2380c0236432f7b22a70229df30f218f5293870c056beb76f5ca068e3273a366" dependencies = [ - "arrayref", "arrayvec", "constant_time_eq", ] @@ -553,32 +565,20 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "bls12_381" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bc6d6292be3a19e6379786dac800f551e5865a5bb51ebbe3064ab80433f403" -dependencies = [ - "ff", - "group", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "bounded-vec" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dc0086e469182132244e9b8d313a0742e1132da43a08c24b9dd3c18e0faf3a" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -587,9 +587,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -607,15 +607,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -631,15 +631,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ "bytes", ] @@ -655,9 +655,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -697,9 +697,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -738,16 +738,15 @@ dependencies = [ "hex", "hmac 0.12.1", "image", - "orchard", "qrcode", - "rand 0.8.5", + "rand 0.8.8", "reqwest", "serde", "serde_json", "sha2 0.10.9", "sqlx", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tracing", @@ -755,12 +754,28 @@ dependencies = [ "url", "uuid", "webauthn-rs", + "zakura-orchard", + "zakura-primitives", "zcash_address", "zcash_note_encryption", - "zcash_primitives", "zcash_protocol", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + [[package]] name = "const-oid" version = "0.9.6" @@ -819,6 +834,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "corez" version = "0.1.1" @@ -845,33 +866,39 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -931,9 +958,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -945,9 +972,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "der" @@ -979,8 +1006,16 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" dependencies = [ - "powerfmt", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1002,7 +1037,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -1031,13 +1066,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -1057,20 +1092,38 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1131,9 +1184,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1146,29 +1199,46 @@ dependencies = [ [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ "bitvec", - "rand_core 0.6.4", + "ff_derive", + "rand_core 0.10.1", "subtle", ] +[[package]] +name = "ff_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241afe8c15ffa21933eaefe8a563af36fd9fc98a3e8a044e8f7db52eb1fae3d8" +dependencies = [ + "addchain", + "num-bigint", + "num-integer", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1232,6 +1302,41 @@ dependencies = [ "num-traits", ] +[[package]] +name = "frost-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81ef2787af391c7e8bedc037a3b9ea03dde803fbd93e778e6bb369547800e5cd" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "derive-getters", + "document-features", + "hex", + "itertools", + "postcard", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror 2.0.20", + "visibility", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "frost-rerandomized" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4c5cedd2426728adef2c0b1720f57676354c473836d1ccc50d0f0d1c91942b" +dependencies = [ + "derive-getters", + "document-features", + "frost-core", + "hex", + "rand_core 0.6.4", +] + [[package]] name = "funty" version = "2.0.0" @@ -1240,9 +1345,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1255,9 +1360,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1265,15 +1370,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1293,44 +1398,44 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1378,27 +1483,24 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1427,54 +1529,35 @@ dependencies = [ "parking_lot", "portable-atomic", "quanta", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "spinning_top", ] [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", "memuse", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] [[package]] name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.13" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -1494,15 +1577,12 @@ dependencies = [ ] [[package]] -name = "halo2_poseidon" -version = "0.1.0" +name = "hash32" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa3da60b81f02f9b33ebc6252d766f843291fb4d2247a07ae73d20b791fc56f" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" dependencies = [ - "bitvec", - "ff", - "group", - "pasta_curves", + "byteorder", ] [[package]] @@ -1524,9 +1604,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1537,6 +1617,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1598,9 +1692,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1608,23 +1702,23 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body", "pin-project-lite", ] @@ -1652,16 +1746,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.5.0", "http-body", "httparse", "itoa", @@ -1673,15 +1767,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", + "http 1.5.0", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1713,14 +1806,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2", "system-configuration", "tokio", "tower-service", @@ -1754,9 +1847,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1768,9 +1861,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1781,9 +1874,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1795,16 +1888,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1815,15 +1909,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1834,12 +1928,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 = "idna" version = "1.1.0" @@ -1853,9 +1941,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1891,14 +1979,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -1912,19 +1998,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itertools" @@ -1943,40 +2019,25 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "jubjub" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8499f7a74008aafbecb2a2e608a3e13e4dd3e84df198b604451efe93f2de6e61" -dependencies = [ - "bitvec", - "bls12_381", - "ff", - "group", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "language-tags" version = "0.3.2" @@ -1992,17 +2053,11 @@ dependencies = [ "spin", ] -[[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.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2012,14 +2067,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.9.4", ] [[package]] @@ -2041,9 +2096,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -2079,9 +2134,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchers" @@ -2104,9 +2159,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memuse" @@ -2136,11 +2191,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -2158,6 +2223,34 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -2214,9 +2307,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2233,33 +2326,32 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2297,15 +2389,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2318,7 +2409,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2329,9 +2420,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2339,40 +2430,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "orchard" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b67beade27dbebdbfee0819e23170b0b263dc7b4f558ee936151716baf6e1f" -dependencies = [ - "aes", - "bitvec", - "blake2b_simd", - "corez", - "ff", - "fpe", - "getset", - "group", - "halo2_poseidon", - "hex", - "incrementalmerkletree", - "lazy_static", - "memuse", - "nonempty", - "pasta_curves", - "rand 0.8.5", - "rand_core 0.6.4", - "reddsa", - "serde", - "sinsemilla", - "subtle", - "tracing", - "visibility", - "zcash_note_encryption", - "zcash_spec", - "zip32", -] - [[package]] name = "parking" version = "2.2.1" @@ -2402,21 +2459,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pasta_curves" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" -dependencies = [ - "blake2b_simd", - "ff", - "group", - "lazy_static", - "rand 0.8.5", - "static_assertions", - "subtle", -] - [[package]] name = "pastey" version = "0.1.1" @@ -2467,9 +2509,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -2487,7 +2529,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2515,15 +2557,28 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2543,52 +2598,20 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qrcode" @@ -2616,9 +2639,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2643,9 +2666,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2654,14 +2677,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2700,6 +2732,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -2709,35 +2747,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "reddsa" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" -dependencies = [ - "blake2b_simd", - "byteorder", - "group", - "hex", - "jubjub", - "pasta_curves", - "rand_core 0.6.4", - "serde", - "thiserror 1.0.69", - "zeroize", -] - -[[package]] -name = "redjubjub" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b0ac1bc6bb3696d2c6f52cff8fba57238b81da8c0214ee6cd146eb8fde364e" -dependencies = [ - "rand_core 0.6.4", - "reddsa", - "zeroize", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -2749,18 +2758,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ "bitflags", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2770,9 +2779,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2787,9 +2796,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2801,8 +2810,8 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.5.0", "http-body", "http-body-util", "hyper", @@ -2916,9 +2925,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "once_cell", "rustls-pki-types", @@ -2929,18 +2938,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2949,9 +2958,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2959,37 +2968,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "sapling-crypto" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d70756ede56b5e4dd417979777bd87ddb83dfcbd0815dbf8175a9920537f8a0" -dependencies = [ - "aes", - "bitvec", - "blake2b_simd", - "blake2s_simd", - "bls12_381", - "corez", - "ff", - "fpe", - "getset", - "group", - "hex", - "incrementalmerkletree", - "jubjub", - "lazy_static", - "memuse", - "rand 0.8.5", - "rand_core 0.6.4", - "redjubjub", - "subtle", - "tracing", - "zcash_note_encryption", - "zcash_spec", - "zip32", -] - [[package]] name = "schannel" version = "0.1.29" @@ -3054,9 +3032,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3074,22 +3052,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -3107,9 +3085,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3141,11 +3119,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures", @@ -3185,9 +3173,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3211,20 +3199,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] -name = "sinsemilla" -version = "0.1.0" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d268ae0ea06faafe1662e9967cd4f9022014f5eeb798e0c302c876df8b7af9c" -dependencies = [ - "group", - "pasta_curves", - "subtle", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" @@ -3234,28 +3217,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3330,7 +3303,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -3348,7 +3321,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.119", ] [[package]] @@ -3371,7 +3344,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.119", "tokio", "url", ] @@ -3406,7 +3379,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.8", "rsa", "serde", "sha1", @@ -3414,7 +3387,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -3446,14 +3419,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -3479,7 +3452,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", "uuid", @@ -3516,9 +3489,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -3542,7 +3526,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3572,6 +3556,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempfile" version = "3.27.0" @@ -3579,7 +3569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3596,11 +3586,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3611,37 +3601,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3651,15 +3640,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.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3667,9 +3656,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3677,9 +3666,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -3692,9 +3681,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3702,20 +3691,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -3730,9 +3719,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -3740,9 +3729,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -3765,13 +3754,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -3793,20 +3783,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3841,7 +3831,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3897,21 +3887,21 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.5.0", "httparse", "log", "native-tls", - "rand 0.9.2", + "rand 0.9.5", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", "utf-8", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -3942,9 +3932,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -3995,11 +3985,11 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4031,7 +4021,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4051,18 +4041,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] @@ -4075,9 +4056,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4088,9 +4069,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -4098,9 +4079,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4108,65 +4089,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" 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", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -4174,9 +4121,9 @@ dependencies = [ [[package]] name = "webauthn-attestation-ca" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fafcf13f7dc1fb292ed4aea22cdd3757c285d7559e9748950ee390249da4da6b" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" dependencies = [ "base64urlsafedata", "openssl", @@ -4188,9 +4135,9 @@ dependencies = [ [[package]] name = "webauthn-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b24d082d3360258fefb6ffe56123beef7d6868c765c779f97b7a2fcf06727f8" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" dependencies = [ "base64urlsafedata", "serde", @@ -4202,9 +4149,9 @@ dependencies = [ [[package]] name = "webauthn-rs-core" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" dependencies = [ "base64 0.21.7", "base64urlsafedata", @@ -4213,7 +4160,7 @@ dependencies = [ "nom", "openssl", "openssl-sys", - "rand 0.9.2", + "rand 0.9.5", "rand_chacha 0.9.0", "serde", "serde_cbor_2", @@ -4229,9 +4176,9 @@ dependencies = [ [[package]] name = "webauthn-rs-proto" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1fb2580ce73baa42d3011a24de2ceab0d428de1879ece06e02e8c416e497c" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" dependencies = [ "base64 0.21.7", "base64urlsafedata", @@ -4293,7 +4240,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4304,7 +4251,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4492,145 +4439,252 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" dependencies = [ - "wit-bindgen-rust-macro", + "tap", ] [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "x509-parser" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", ] [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "yoke" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ - "anyhow", - "prettyplease", "proc-macro2", "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", + "syn 2.0.119", + "synstructure", ] [[package]] -name = "wit-component" -version = "0.244.0" +name = "zakura-bls12-381" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "1d2c114620056f8c97ecc17d7333add6c818ff376434943c7ff4974320426887" dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "ff", + "group", + "rand_core 0.10.1", + "subtle", ] [[package]] -name = "wit-parser" -version = "0.244.0" +name = "zakura-halo2-poseidon" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "cdac6fc73528451edc4ee8cff55946ff2c75359c47937eb42e6d31b83b542d39" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", + "bitvec", + "ff", + "group", + "zakura-pasta-curves", +] + +[[package]] +name = "zakura-jubjub" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9915c2c7f2c021379453608c91548f2811d1ba59b7e8505bb8ba32f51e494db" +dependencies = [ + "bitvec", + "ff", + "group", + "rand_core 0.10.1", + "subtle", + "zakura-bls12-381", +] + +[[package]] +name = "zakura-orchard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7edaa754f9bafb4965559e38af6d337eb001f76de666fe20e510fb8e1cf631" +dependencies = [ + "aes", + "bitvec", + "blake2b_simd", + "corez", + "ff", + "fpe", + "getset", + "group", + "hex", + "incrementalmerkletree", + "memuse", + "nonempty", + "once_cell", + "rand 0.10.2", + "rand_core 0.10.1", + "rand_core 0.6.4", "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "subtle", + "tracing", + "zakura-halo2-poseidon", + "zakura-pasta-curves", + "zakura-reddsa", + "zakura-sinsemilla", + "zcash_note_encryption", + "zcash_spec", + "zip32", ] [[package]] -name = "writeable" -version = "0.6.3" +name = "zakura-pasta-curves" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "e878bab2110a70ed4496e377b4b3f3039d6a227c5d79e171b316f334857531b9" +dependencies = [ + "blake2b_simd", + "ff", + "group", + "once_cell", + "rand 0.10.2", + "static_assertions", + "subtle", +] [[package]] -name = "wyz" -version = "0.5.1" +name = "zakura-primitives" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +checksum = "799de508ecd41846dd0dfd06490e30ec6ee918008ceba19155e09f2545f3df73" dependencies = [ - "tap", + "blake2b_simd", + "block-buffer 0.11.0-rc.3", + "corez", + "crypto-common 0.2.0-rc.1", + "equihash", + "ff", + "hex", + "incrementalmerkletree", + "memuse", + "nonempty", + "rand_core 0.10.1", + "sha2 0.10.9", + "zakura-jubjub", + "zakura-orchard", + "zakura-redjubjub", + "zakura-sapling-crypto", + "zcash_encoding", + "zcash_note_encryption", + "zcash_protocol", + "zcash_script", + "zcash_transparent", ] [[package]] -name = "x509-parser" -version = "0.16.0" +name = "zakura-reddsa" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "3391ff6c3e6222f5176c046e1d3645748be5b211880747e279d0f17860d51526" dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 1.0.69", - "time", + "blake2b_simd", + "frost-rerandomized", + "group", + "hex", + "rand_core 0.10.1", + "serde", + "thiserror 2.0.20", + "zakura-jubjub", + "zakura-pasta-curves", + "zeroize", ] [[package]] -name = "yoke" -version = "0.8.2" +name = "zakura-redjubjub" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "174ffd325c22f5dbb4c4dfe6f825bd82c3ca88a91bbe603b5cdf0dd7590d0a9c" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "rand_core 0.10.1", + "zakura-reddsa", + "zeroize", ] [[package]] -name = "yoke-derive" -version = "0.8.2" +name = "zakura-sapling-crypto" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "eadf81594078e0e18f6c1744d978a9ee418fce988c293567131bc4870247ee85" dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", + "aes", + "bitvec", + "blake2b_simd", + "blake2s_simd", + "corez", + "ff", + "fpe", + "getset", + "group", + "hex", + "incrementalmerkletree", + "memuse", + "once_cell", + "rand 0.10.2", + "rand_core 0.10.1", + "rand_core 0.6.4", + "subtle", + "tracing", + "zakura-bls12-381", + "zakura-jubjub", + "zakura-redjubjub", + "zcash_note_encryption", + "zcash_spec", + "zip32", +] + +[[package]] +name = "zakura-sinsemilla" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "582ec66fe58b717034b8f5c8b54834d93abd515fc20a0ce2ab769d522432b8ca" +dependencies = [ + "group", + "once_cell", + "subtle", + "zakura-pasta-curves", ] [[package]] @@ -4671,40 +4725,11 @@ dependencies = [ "subtle", ] -[[package]] -name = "zcash_primitives" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdbeccc05bfe63b6dee9e989c6ff5027421741562a4853c36504dd621f6a81b1" -dependencies = [ - "blake2b_simd", - "block-buffer 0.11.0-rc.3", - "corez", - "crypto-common 0.2.0-rc.1", - "equihash", - "ff", - "hex", - "incrementalmerkletree", - "jubjub", - "memuse", - "nonempty", - "orchard", - "rand_core 0.6.4", - "redjubjub", - "sapling-crypto", - "sha2 0.10.9", - "zcash_encoding", - "zcash_note_encryption", - "zcash_protocol", - "zcash_script", - "zcash_transparent", -] - [[package]] name = "zcash_protocol" -version = "0.10.3" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f074493fff337207e28bcfa5bbdf0e2a125c4203a4bdd07e72067eea81e9e7b" +checksum = "314329b91ec4bbb517441840e47d0b2029bf0b946f086980c96c889c2d92dc5d" dependencies = [ "corez", "document-features", @@ -4715,9 +4740,9 @@ dependencies = [ [[package]] name = "zcash_script" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ef9d04e0434a80b62ad06c5a610557be358ef60a98afa5dbc8ecaf19ad72e7" +checksum = "2f872800287d118be71bdf6fe8c869c6a6ff6fb0a5762f68fb2af54c97edf0f2" dependencies = [ "bip32", "bitflags", @@ -4727,7 +4752,7 @@ dependencies = [ "secp256k1", "sha1", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4741,9 +4766,9 @@ dependencies = [ [[package]] name = "zcash_transparent" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72e0f8c142bed366ed5886dcfa8772ec90b5420cc127e6cdb76b6744c53a287b" +checksum = "547c012778bae17f58007731af074d638aa146ab0ecfc120adebf23d049aff6c" dependencies = [ "bip32", "bs58", @@ -4764,29 +4789,29 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -4799,35 +4824,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4836,9 +4861,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4847,13 +4872,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -4869,11 +4894,17 @@ dependencies = [ "zcash_spec", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" @@ -4886,18 +4917,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index cbe8dc1..347050b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" [dependencies] # Web framework -actix-web = "4" +actix-web = { version = "4", default-features = false, features = ["macros", "cookies", "compress-gzip", "compress-brotli", "compress-zstd"] } actix-cors = "0.7" actix-web-lab = "0.24" actix-governor = "0.7" @@ -16,7 +16,7 @@ actix-governor = "0.7" tokio = { version = "1", features = ["full"] } # Database (SQLite for local dev, Postgres for production) -sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive", "uuid", "chrono", "json"] } # Serialization serde = { version = "1", features = ["derive"] } diff --git a/Dockerfile b/Dockerfile index 184d6b0..1d04e89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,17 @@ -FROM rust:1.83-slim AS builder - +FROM rust:1.97.1-bookworm AS builder WORKDIR /app - RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* - -COPY Cargo.toml Cargo.lock* ./ -RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release 2>/dev/null || true -RUN rm -rf src - -COPY . . -RUN cargo build --release +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY migrations ./migrations +RUN cargo build --release --locked FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* - +RUN apt-get update && apt-get install -y ca-certificates libssl3 curl && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 cipherpay && mkdir /data && chown cipherpay /data COPY --from=builder /app/target/release/cipherpay /usr/local/bin/cipherpay - +USER cipherpay +WORKDIR /data +ENV API_HOST=0.0.0.0 DATABASE_URL=sqlite:/data/cipherpay.db EXPOSE 3080 - +HEALTHCHECK CMD curl --fail --silent http://127.0.0.1:3080/api/health || exit 1 CMD ["cipherpay"] diff --git a/docker-compose.yml b/docker-compose.yml index 0ca678e..a92216f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,14 @@ services: - db: - image: postgres:16-alpine + api: + build: . + restart: unless-stopped + env_file: .env environment: - POSTGRES_USER: cipherpay - POSTGRES_PASSWORD: cipherpay - POSTGRES_DB: cipherpay + API_HOST: 0.0.0.0 + DATABASE_URL: sqlite:/data/cipherpay.db ports: - - "5433:5432" + - "127.0.0.1:3080:3080" volumes: - - pgdata:/var/lib/postgresql/data - - ./migrations:/docker-entrypoint-initdb.d - healthcheck: - test: ["CMD-SHELL", "pg_isready -U cipherpay"] - interval: 5s - timeout: 5s - retries: 5 - + - cipherpay-data:/data volumes: - pgdata: + cipherpay-data: diff --git a/scripts/check-dependency-exposure.py b/scripts/check-dependency-exposure.py new file mode 100644 index 0000000..95573e7 --- /dev/null +++ b/scripts/check-dependency-exposure.py @@ -0,0 +1,7 @@ +import subprocess +for target in ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]: + graph = subprocess.check_output(["cargo", "tree", "--locked", "--target", target, "--edges", "normal,build", "--prefix", "none"], text=True) + forbidden = [line for line in graph.splitlines() if line.startswith(("rsa ", "atomic-polyfill ", "h2 v0.3."))] + if forbidden: + raise SystemExit(f"Unexpected vulnerable dependency in {target}: {forbidden}") +print("Supported deployment graphs exclude RSA, embedded atomic-polyfill, and old h2") diff --git a/src/addresses.rs b/src/addresses.rs index ba8bbac..fe11f05 100644 --- a/src/addresses.rs +++ b/src/addresses.rs @@ -37,10 +37,13 @@ mod tests { #[test] fn test_derive_different_indices_produce_different_addresses() { - let test_key = std::env::var("TEST_UFVK").unwrap_or_default(); - if test_key.is_empty() { - return; - } + use orchard::keys::{FullViewingKey, SpendingKey}; + use zcash_address::unified::{Fvk, Ufvk}; + let sk = SpendingKey::from_bytes([7u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let test_key = Ufvk::try_from_items(vec![Fvk::Orchard(fvk.to_bytes())]) + .unwrap() + .encode(&zcash_protocol::consensus::NetworkType::Main); let addr0 = derive_invoice_address(&test_key, 0).unwrap(); let addr1 = derive_invoice_address(&test_key, 1).unwrap(); diff --git a/src/api/auth.rs b/src/api/auth.rs index a68bee5..ba95882 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -152,14 +152,13 @@ pub async fn me(req: HttpRequest, pool: web::Data) -> HttpResponse { let has_passkeys = super::passkey::has_passkeys(pool.get_ref(), &merchant.id).await; - let last_token_login: Option = sqlx::query_scalar( - "SELECT last_token_login_at FROM merchants WHERE id = ?", - ) - .bind(&merchant.id) - .fetch_optional(pool.get_ref()) - .await - .ok() - .flatten(); + let last_token_login: Option = + sqlx::query_scalar("SELECT last_token_login_at FROM merchants WHERE id = ?") + .bind(&merchant.id) + .fetch_optional(pool.get_ref()) + .await + .ok() + .flatten(); HttpResponse::Ok().json(serde_json::json!({ "id": merchant.id, @@ -455,9 +454,7 @@ pub async fn retry_webhook( .json(serde_json::json!({"error": format!("Blocked: {reason}")})); } - let ts = chrono::Utc::now() - .format("%Y-%m-%dT%H:%M:%SZ") - .to_string(); + let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut body: serde_json::Value = match serde_json::from_str(&payload) { Ok(v) => v, Err(_) => { @@ -530,8 +527,7 @@ pub async fn retry_webhook( .bind(&delivery_id) .execute(pool.get_ref()) .await; - HttpResponse::Ok() - .json(serde_json::json!({"status": "failed", "error": error_text})) + HttpResponse::Ok().json(serde_json::json!({"status": "failed", "error": error_text})) } } } @@ -560,12 +556,15 @@ pub fn invalid_api_key_response() -> HttpResponse { pub async fn resolve_session(req: &HttpRequest, pool: &SqlitePool) -> Option { let config = req.app_data::>()?; if let Some(session_id) = extract_session_id(req) { - if let Ok(Some(m)) = merchants::get_by_session(pool, &session_id, &config.encryption_key).await { + if let Ok(Some(m)) = + merchants::get_by_session(pool, &session_id, &config.encryption_key).await + { return Some(m); } } if let Some(pos_id) = extract_pos_session_id(req) { - if let Ok(Some(m)) = merchants::get_by_session(pool, &pos_id, &config.encryption_key).await { + if let Ok(Some(m)) = merchants::get_by_session(pool, &pos_id, &config.encryption_key).await + { return Some(m); } } @@ -574,12 +573,16 @@ pub async fn resolve_session(req: &HttpRequest, pool: &SqlitePool) -> Option Option { +pub async fn resolve_full_session( + req: &HttpRequest, + pool: &SqlitePool, +) -> Option { let session_id = extract_session_id(req)?; let config = req.app_data::>()?; - let (merchant, pos_scoped) = merchants::get_by_session_with_scope(pool, &session_id, &config.encryption_key) - .await - .ok()??; + let (merchant, pos_scoped) = + merchants::get_by_session_with_scope(pool, &session_id, &config.encryption_key) + .await + .ok()??; if pos_scoped { return None; } @@ -597,7 +600,9 @@ pub async fn resolve_with_kind( if let Some(auth) = req.headers().get("Authorization") { if let Ok(auth_str) = auth.to_str() { let key = auth_str.strip_prefix("Bearer ").unwrap_or(auth_str).trim(); - if key.starts_with("cpay_sk_") || key.starts_with("cpay_rk_") || key.starts_with("cpay_") + if key.starts_with("cpay_sk_") + || key.starts_with("cpay_rk_") + || key.starts_with("cpay_") { return merchants::authenticate_with_kind(pool, key, &config.encryption_key) .await @@ -1126,3 +1131,39 @@ fn validate_update( } Ok(()) } + +/// Read the existing integration secret without rotating it as a setup side effect. +pub async fn webhook_config( + req: HttpRequest, + pool: web::Data, + config: web::Data, +) -> HttpResponse { + let merchant = match require_full_session(&req, pool.get_ref()).await { + Ok(m) => m, + Err(r) => return r, + }; + let secret = match crate::crypto::decrypt_webhook_secret( + &merchant.webhook_secret, + &config.encryption_key, + ) { + Ok(s) => s, + Err(_) => return HttpResponse::InternalServerError().finish(), + }; + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(serde_json::json!({"webhook_url":merchant.webhook_url,"webhook_secret":secret})) +} +/// Finalize setup only if it does not replace a different integration. +pub async fn connect_shopify(req: HttpRequest, pool: web::Data) -> HttpResponse { + let merchant = match require_full_session(&req, pool.get_ref()).await { + Ok(m) => m, + Err(r) => return r, + }; + let target = "https://connect.cipherpay.app/api/webhook/cipherpay"; + match sqlx::query("UPDATE merchants SET webhook_url = ? WHERE id = ? AND (webhook_url IS NULL OR webhook_url = '' OR webhook_url = ?)") + .bind(target).bind(&merchant.id).bind(target).execute(pool.get_ref()).await { + Ok(result) if result.rows_affected()==1 => HttpResponse::Ok().json(serde_json::json!({"ok":true})), + Ok(_) => HttpResponse::Conflict().json(serde_json::json!({"error":"A different webhook integration is already configured"})), + Err(_) => HttpResponse::InternalServerError().finish(), + } +} diff --git a/src/api/invoice_routes.rs b/src/api/invoice_routes.rs index b6296a5..881d87b 100644 --- a/src/api/invoice_routes.rs +++ b/src/api/invoice_routes.rs @@ -429,8 +429,7 @@ pub async fn export_csv( let received_zec = crate::invoices::zatoshis_to_zec(rz); let product = r .get::, _>("product_name") - .unwrap_or_default() - .replace(',', " "); + .unwrap_or_default(); let cur = r .get::, _>("currency") .unwrap_or_else(|| "EUR".to_string()); @@ -438,27 +437,39 @@ pub async fn export_csv( let conf_rate = r.get::, _>("confirmed_rate"); let conf_fiat = r.get::, _>("confirmed_fiat_amount"); - csv.push_str(&format!( - "{},{},{},{},{:.2},{:.8},{:.4},{},{},{:.8},{},{},{},{}\n", + let fields = [ r.get::("id"), r.get::("memo_code"), product, cur, - amt, - r.get::("price_zec"), - r.get::("zec_rate_at_creation"), - conf_rate.map_or(String::new(), |v| format!("{:.4}", v)), - conf_fiat.map_or(String::new(), |v| format!("{:.2}", v)), - received_zec, + format!("{amt:.2}"), + format!("{:.8}", r.get::("price_zec")), + format!("{:.4}", r.get::("zec_rate_at_creation")), + conf_rate.map_or(String::new(), |v| format!("{v:.4}")), + conf_fiat.map_or(String::new(), |v| format!("{v:.2}")), + format!("{received_zec:.8}"), r.get::("status"), - r.get::, _>("detected_txid").unwrap_or_default(), + r.get::, _>("detected_txid") + .unwrap_or_default(), r.get::("created_at"), - r.get::, _>("confirmed_at").unwrap_or_default(), - )); + r.get::, _>("confirmed_at") + .unwrap_or_default(), + ]; + csv.push_str( + &fields + .iter() + .map(|v| csv_cell(v)) + .collect::>() + .join(","), + ); + csv.push_str("\r\n"); } actix_web::HttpResponse::Ok() .content_type("text/csv; charset=utf-8") - .insert_header(("Content-Disposition", "attachment; filename=cipherpay-invoices.csv")) + .insert_header(( + "Content-Disposition", + "attachment; filename=cipherpay-invoices.csv", + )) .body(csv) } Err(e) => { @@ -477,15 +488,15 @@ pub async fn ledger_by_token( ) -> actix_web::HttpResponse { let token = path.into_inner(); - let token_row = sqlx::query( - "SELECT merchant_id, expires_at, revoked_at FROM ledger_tokens WHERE id = ?", - ) - .bind(&token) - .fetch_optional(pool.get_ref()) - .await; + let token_row = + sqlx::query("SELECT merchant_id, expires_at, revoked_at FROM ledger_tokens WHERE id = ?") + .bind(&token) + .fetch_optional(pool.get_ref()) + .await; let not_found = || { - actix_web::HttpResponse::NotFound().json(serde_json::json!({"error": "Invalid or expired token"})) + actix_web::HttpResponse::NotFound() + .json(serde_json::json!({"error": "Invalid or expired token"})) }; let merchant_id = match token_row { @@ -495,7 +506,9 @@ pub async fn ledger_by_token( return not_found(); } if let Some(exp) = row.get::, _>("expires_at") { - if let Ok(exp_dt) = chrono::NaiveDateTime::parse_from_str(&exp, "%Y-%m-%dT%H:%M:%SZ") { + if let Ok(exp_dt) = + chrono::NaiveDateTime::parse_from_str(&exp, "%Y-%m-%dT%H:%M:%SZ") + { if exp_dt < chrono::Utc::now().naive_utc() { return not_found(); } @@ -550,7 +563,8 @@ pub async fn ledger_by_token( } Err(e) => { tracing::error!(error = %e, "Ledger token query failed"); - actix_web::HttpResponse::InternalServerError().json(serde_json::json!({"error": "Internal error"})) + actix_web::HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Internal error"})) } } } @@ -613,7 +627,8 @@ pub async fn create_ledger_token( })), Err(e) => { tracing::error!(error = %e, "Failed to create ledger token"); - actix_web::HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})) + actix_web::HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to create token"})) } } } @@ -645,10 +660,12 @@ pub async fn revoke_ledger_token( Ok(r) if r.rows_affected() > 0 => { actix_web::HttpResponse::Ok().json(serde_json::json!({"status": "revoked"})) } - Ok(_) => actix_web::HttpResponse::NotFound().json(serde_json::json!({"error": "Token not found or already revoked"})), + Ok(_) => actix_web::HttpResponse::NotFound() + .json(serde_json::json!({"error": "Token not found or already revoked"})), Err(e) => { tracing::error!(error = %e, "Failed to revoke ledger token"); - actix_web::HttpResponse::InternalServerError().json(serde_json::json!({"error": "Internal error"})) + actix_web::HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Internal error"})) } } } @@ -689,7 +706,30 @@ pub async fn list_ledger_tokens( } Err(e) => { tracing::error!(error = %e, "Failed to list ledger tokens"); - actix_web::HttpResponse::InternalServerError().json(serde_json::json!({"error": "Internal error"})) + actix_web::HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Internal error"})) } } } + +/// Quote complete CSV cells and prevent spreadsheet formula evaluation. +fn csv_cell(value: &str) -> String { + let unsafe_prefix = value.trim_start().starts_with(['=', '+', '-', '@']) + || value.starts_with(['\t', '\r', '\n']); + let value = if unsafe_prefix { + format!("'{value}") + } else { + value.to_string() + }; + format!("\"{}\"", value.replace('"', "\"\"")) +} +#[cfg(test)] +mod export_tests { + use super::csv_cell; + #[test] + fn csv_preserves_quotes_newlines_and_neutralizes_formulas() { + assert_eq!(csv_cell("a,\"b\"\nc"), "\"a,\"\"b\"\"\nc\""); + assert_eq!(csv_cell(" =1+1"), "\"' =1+1\""); + assert_eq!(csv_cell("normal"), "\"normal\""); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 9b3b2fb..3168796 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,13 +1,13 @@ pub mod admin; pub mod auth; pub mod billing_routes; -pub mod passkey; pub mod events; pub mod invoice_routes; pub mod invoices; pub mod keys; pub mod luma; pub mod merchants; +pub mod passkey; pub mod payment_links; pub mod pos; pub mod prices; @@ -83,6 +83,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/me/regenerate-dashboard-token", web::post().to(auth::regenerate_dashboard_token), ) + .route("/me/webhook-config", web::get().to(auth::webhook_config)) + .route("/me/connect-shopify", web::post().to(auth::connect_shopify)) .route( "/me/regenerate-webhook-secret", web::post().to(auth::regenerate_webhook_secret), @@ -101,10 +103,22 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ) .route("/me/delete", web::post().to(billing_routes::delete_account)) .route("/me/webhooks", web::get().to(auth::my_webhooks)) - .route("/me/ledger-tokens", web::get().to(invoice_routes::list_ledger_tokens)) - .route("/me/ledger-tokens", web::post().to(invoice_routes::create_ledger_token)) - .route("/me/ledger-tokens/{token_id}", web::delete().to(invoice_routes::revoke_ledger_token)) - .route("/me/webhooks/{id}/retry", web::post().to(auth::retry_webhook)) + .route( + "/me/ledger-tokens", + web::get().to(invoice_routes::list_ledger_tokens), + ) + .route( + "/me/ledger-tokens", + web::post().to(invoice_routes::create_ledger_token), + ) + .route( + "/me/ledger-tokens/{token_id}", + web::delete().to(invoice_routes::revoke_ledger_token), + ) + .route( + "/me/webhooks/{id}/retry", + web::post().to(auth::retry_webhook), + ) .route("/me/x402/history", web::get().to(x402::history)) .route("/me/sessions", web::get().to(sessions::history)) .route("/me/keys", web::post().to(keys::create)) @@ -129,10 +143,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/passkey/register/complete", web::post().to(passkey::register_complete), ) - .route( - "/passkey/login/begin", - web::post().to(passkey::login_begin), - ) + .route("/passkey/login/begin", web::post().to(passkey::login_begin)) .route( "/passkey/login/complete", web::post().to(passkey::login_complete), @@ -223,7 +234,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { // Invoice endpoints (API key auth) .route("/invoices", web::post().to(invoices::create)) .route("/invoices", web::get().to(invoice_routes::list_invoices)) - .route("/invoices/export/csv", web::get().to(invoice_routes::export_csv)) + .route( + "/invoices/export/csv", + web::get().to(invoice_routes::export_csv), + ) .route( "/invoices/lookup/{memo_code}", web::get().to(invoice_routes::lookup_by_memo), @@ -252,7 +266,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ) .route("/invoices/{id}/qr", web::get().to(invoice_routes::qr_code)) // Shared ledger (public, token-auth) - .route("/ledger/{token}", web::get().to(invoice_routes::ledger_by_token)) + .route( + "/ledger/{token}", + web::get().to(invoice_routes::ledger_by_token), + ) // Ticket endpoints .route( "/tickets/invoice/{invoice_id}", @@ -286,8 +303,9 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .wrap(Governor::new(&session_rate_limit)) .route(web::post().to(sessions::open)), ) - .route("/validate", web::get().to(sessions::validate)) - .route("/deduct", web::post().to(sessions::deduct)) + .route("/validate", web::get().to(sessions::legacy_validate)) + .route("/validate", web::post().to(sessions::validate)) + .route("/deduct", web::post().to(sessions::validate)) .route("/{id}", web::get().to(sessions::get_status)) .route("/{id}/close", web::post().to(sessions::close)), ) @@ -300,8 +318,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route("/admin/system", web::get().to(admin::system)) .route("/admin/test-email", web::post().to(admin::test_email)) .route("/admin/rescan-fees", web::post().to(admin::rescan_fees)) - .route("/admin/scanner-metrics", web::get().to(admin::scanner_metrics)) - .route("/admin/backfill-campaign-addresses", web::post().to(admin::backfill_campaign_addresses)), + .route( + "/admin/scanner-metrics", + web::get().to(admin::scanner_metrics), + ) + .route( + "/admin/backfill-campaign-addresses", + web::post().to(admin::backfill_campaign_addresses), + ), ); cfg.route( @@ -571,7 +595,8 @@ async fn checkout( refund_address: body.refund_address.clone(), }; - let fee_config = crate::billing::fee_config_for_merchant(pool.get_ref(), &merchant.id, &config).await; + let fee_config = + crate::billing::fee_config_for_merchant(pool.get_ref(), &merchant.id, &config).await; match crate::invoices::create_invoice( pool.get_ref(), diff --git a/src/api/sessions.rs b/src/api/sessions.rs index 40a5b5b..5b93513 100644 --- a/src/api/sessions.rs +++ b/src/api/sessions.rs @@ -44,38 +44,32 @@ pub async fn open( } // Resolve merchant: either from session_request_id or merchant_id - let (merchant_id, expected_address, expected_receiver_hex, diversifier_index) = if let Some( - ref sr_id, - ) = - body.session_request_id - { - match crate::sessions::get_session_request(pool.get_ref(), sr_id).await { - Ok(Some(sr)) => ( - sr.merchant_id, - Some(sr.deposit_address), - None, - Some(sr.diversifier_index), - ), - Ok(None) => { - return HttpResponse::BadRequest().json(serde_json::json!({ - "error": "Session request not found, already used, or expired" - })); - } - Err(e) => { - tracing::error!(error = %e, "Failed to lookup session request"); - return HttpResponse::InternalServerError().json(serde_json::json!({ - "error": "Internal error" - })); + let (merchant_id, expected_address, expected_receiver_hex, diversifier_index) = + if let Some(ref sr_id) = body.session_request_id { + match crate::sessions::get_session_request(pool.get_ref(), sr_id).await { + Ok(Some(sr)) => ( + sr.merchant_id, + Some(sr.deposit_address), + None, + Some(sr.diversifier_index), + ), + Ok(None) => { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "Session request not found, already used, or expired" + })); + } + Err(e) => { + tracing::error!(error = %e, "Failed to lookup session request"); + return HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "Internal error" + })); + } } - } - } else if let Some(ref mid) = body.merchant_id { - tracing::warn!(merchant_id = %mid, "Deprecated: memo-based session opening. Use POST /api/sessions/prepare + session_request_id instead."); - (mid.clone(), None, None, None) - } else { - return HttpResponse::BadRequest().json(serde_json::json!({ + } else { + return HttpResponse::BadRequest().json(serde_json::json!({ "error": "session_request_id is required. Use POST /api/sessions/prepare to get one." })); - }; + }; let merchant = match crate::merchants::get_merchant_by_id( pool.get_ref(), @@ -118,6 +112,17 @@ pub async fn open( None => expected_receiver_hex, }; + if !crate::scanner::blocks::check_tx_confirmed( + &http_client, + &config.cipherscan_api_url, + &body.txid, + ) + .await + .unwrap_or(false) + { + return HttpResponse::Conflict() + .json(serde_json::json!({"error":"Deposit must have a blockchain confirmation"})); + } let raw_hex = match mempool::fetch_raw_tx(&http_client, &config.cipherscan_api_url, &body.txid).await { Ok(hex) => hex, @@ -187,16 +192,12 @@ pub async fn open( })); } - // Mark session request as used (if address-based) - if let Some(ref sr_id) = body.session_request_id { - if let Err(e) = crate::sessions::mark_session_request_used(pool.get_ref(), sr_id).await { - tracing::warn!(error = %e, "Failed to mark session request as used"); - } - } - - match crate::sessions::create_session( + match crate::sessions::create_prepared_session( pool.get_ref(), &merchant_id, + body.session_request_id + .as_deref() + .expect("prepared request checked above"), &body.txid, total_zatoshis, body.refund_address.as_deref(), @@ -452,114 +453,59 @@ pub struct PrepareRequest { } /// Deduct a variable amount from a session (for streaming metering). -pub async fn deduct( - req: HttpRequest, - pool: web::Data, - config: web::Data, - body: web::Json, -) -> HttpResponse { - let token = req - .headers() - .get("Authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - .map(|s| s.trim().to_string()) - .filter(|s| s.starts_with("cps_")); - - let token = match token { - Some(t) => t, - None => { - return HttpResponse::BadRequest().json(serde_json::json!({ - "error": "Bearer token required" - })); - } - }; - - if body.amount_zatoshis <= 0 { - return HttpResponse::BadRequest().json(serde_json::json!({ - "error": "amount_zatoshis must be positive" - })); - } - - if let Err(resp) = enforce_session_billing(&pool, &config, &token).await { - return resp; - } - - match crate::sessions::deduct(pool.get_ref(), &token, body.amount_zatoshis).await { - Ok(Some(session)) => HttpResponse::Ok() - .insert_header(("X-Session-Balance", session.balance_remaining.to_string())) - .json(serde_json::json!({ - "valid": true, - "session_id": session.id, - "balance_remaining": session.balance_remaining, - "deducted": body.amount_zatoshis, - })), - Ok(None) => HttpResponse::Ok().json(serde_json::json!({ - "valid": false, - "reason": "Insufficient balance, session expired, or depleted" - })), - Err(e) => { - tracing::error!(error = %e, "Session deduction error"); - HttpResponse::InternalServerError().json(serde_json::json!({ - "error": "Internal error" - })) - } - } -} - #[derive(Debug, Deserialize)] -pub struct DeductRequest { +pub struct ChargeRequest { + pub token: String, pub amount_zatoshis: i64, + pub resource: String, + pub request_id: String, } pub async fn validate( req: HttpRequest, pool: web::Data, config: web::Data, + body: web::Json, ) -> HttpResponse { - let token = req + let key = req .headers() .get("Authorization") .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - .map(|s| s.trim().to_string()) - .filter(|s| s.starts_with("cps_")); - - let token = match token { - Some(t) if !t.is_empty() => t, - _ => { - return HttpResponse::BadRequest().json(serde_json::json!({ - "error": "Bearer token required — use Authorization: Bearer cps_... header" - })); - } - }; - - if let Err(resp) = enforce_session_billing(&pool, &config, &token).await { + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + let merchant = + match crate::merchants::authenticate(pool.get_ref(), key, &config.encryption_key).await { + Ok(Some(m)) => m, + _ => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error":"Merchant authentication required"})) + } + }; + if body.amount_zatoshis <= 0 + || body.resource.is_empty() + || body.resource.len() > 2048 + || body.request_id.is_empty() + || body.request_id.len() > 128 + || !body.token.starts_with("cps_") + { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error":"Invalid session charge"})); + } + if let Err(resp) = enforce_session_billing(&pool, &config, &body.token).await { return resp; } - - match crate::sessions::validate_and_deduct(pool.get_ref(), &token).await { - Ok(Some(session)) => HttpResponse::Ok() - .insert_header(("X-Session-Balance", session.balance_remaining.to_string())) - .json(serde_json::json!({ - "valid": true, - "session_id": session.id, - "balance_remaining": session.balance_remaining, - "requests_made": session.requests_made, - })), - Ok(None) => HttpResponse::Ok().json(serde_json::json!({ - "valid": false, - "reason": "Session not found, expired, or depleted" - })), - Err(e) => { - tracing::error!(error = %e, "Session validation error"); - HttpResponse::InternalServerError().json(serde_json::json!({ - "error": "Internal error" - })) - } + match crate::sessions::charge(pool.get_ref(), &merchant.id, &body.token, body.amount_zatoshis, &body.resource, &body.request_id).await { + Ok(Some(s)) => HttpResponse::Ok().json(serde_json::json!({"valid":true,"session_id":s.id, + "merchant_id":s.merchant_id,"balance_remaining":s.balance_remaining,"requests_made":s.requests_made,"deducted":body.amount_zatoshis})), + Ok(None) => HttpResponse::Ok().json(serde_json::json!({"valid":false,"reason":"Session unavailable, wrong merchant, insufficient balance, or request already processed"})), + Err(e) => { tracing::error!(error = %e, "Session charge failed"); HttpResponse::InternalServerError().finish() } } } +pub async fn legacy_validate() -> HttpResponse { + HttpResponse::Gone().json(serde_json::json!({"error":"Use authenticated POST /api/sessions/validate with token, amount_zatoshis, resource and request_id"})) +} + async fn resolve_prepare_merchant( req: &HttpRequest, pool: &SqlitePool, diff --git a/src/api/x402.rs b/src/api/x402.rs index d708683..a68bc33 100644 --- a/src/api/x402.rs +++ b/src/api/x402.rs @@ -1,5 +1,6 @@ use actix_web::{web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use uuid::Uuid; @@ -106,15 +107,15 @@ pub async fn verify( })); } }; - let min_acceptable = (expected_zatoshis as f64 * SLIPPAGE_TOLERANCE) as u64; + let min_acceptable = expected_zatoshis; if received_zatoshis >= min_acceptable { return HttpResponse::Ok().json(VerifyResponse { - valid: true, + valid: false, received_zec: received_zatoshis as f64 / 100_000_000.0, received_zatoshis, previously_verified: true, - reason: None, + reason: Some("Payment already verified; use a new payment".to_string()), }); } else { let reason = format!( @@ -185,7 +186,15 @@ pub async fn verify( return HttpResponse::Ok().json(resp); } - let total_zatoshis: u64 = outputs.iter().map(|o| o.amount_zatoshis).sum(); + let receiver = match crate::invoices::matching::orchard_receiver(&merchant.payment_address) { + Some(r) => r, + None => return HttpResponse::BadRequest().finish(), + }; + let total_zatoshis: u64 = outputs + .iter() + .filter(|o| hex::encode(o.recipient_raw) == receiver) + .map(|o| o.amount_zatoshis) + .sum(); let total_zec = total_zatoshis as f64 / 100_000_000.0; let expected_zatoshis = match zec_to_zatoshis(body.expected_amount_zec) { Some(amount) => amount, @@ -195,9 +204,27 @@ pub async fn verify( })); } }; - let min_acceptable = (expected_zatoshis as f64 * SLIPPAGE_TOLERANCE) as u64; + let min_acceptable = expected_zatoshis; if total_zatoshis >= min_acceptable { + let consumed = match pool.acquire().await { + Ok(mut conn) => { + crate::sessions::consume_payment(&mut conn, &body.txid, "legacy-x402").await + } + Err(e) => Err(e.into()), + }; + match consumed { + Ok(true) => {} + Ok(false) => { + return HttpResponse::Conflict().json(problem_details( + 409, + "payment-replayed", + "Payment Replayed", + "Transaction already consumed", + )) + } + Err(_) => return HttpResponse::InternalServerError().finish(), + } log_verification( &pool, &merchant.id, @@ -431,7 +458,7 @@ fn zec_to_zatoshis(amount_zec: f64) -> Option { } let scaled = (amount_zec * 100_000_000.0).round(); - if scaled < 0.0 || scaled > u64::MAX as f64 { + if scaled < 1.0 || scaled > 2_100_000_000_000_000.0 { return None; } @@ -442,7 +469,7 @@ fn zec_to_zatoshis(amount_zec: f64) -> Option { // x402 V2 spec-compliant facilitator endpoints // --------------------------------------------------------------------------- -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct PaymentRequirementsV2 { scheme: Option, network: Option, @@ -455,12 +482,12 @@ struct PaymentRequirementsV2 { extra: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct ZcashPayload { txid: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct PaymentPayloadV2 { #[serde(rename = "x402Version")] x402_version: Option, @@ -470,7 +497,7 @@ struct PaymentPayloadV2 { extensions: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct VerifyRequestV2 { #[serde(rename = "x402Version")] pub x402_version: Option, @@ -481,7 +508,7 @@ pub struct VerifyRequestV2 { } /// Optional session config sent alongside settle to auto-create a session. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct SettleSessionConfig { #[serde(rename = "costPerRequest")] cost_per_request: Option, @@ -490,7 +517,7 @@ struct SettleSessionConfig { } /// Extended settle request: standard x402 V2 fields + optional session bridge. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SettleRequestV2 { #[serde(rename = "x402Version")] pub x402_version: Option, @@ -548,12 +575,7 @@ struct SettleResponseV2 { // RFC 9457 Problem Details (https://www.rfc-editor.org/rfc/rfc9457) // --------------------------------------------------------------------------- -fn problem_details( - status: u16, - error_type: &str, - title: &str, - detail: &str, -) -> serde_json::Value { +fn problem_details(status: u16, error_type: &str, title: &str, detail: &str) -> serde_json::Value { serde_json::json!({ "type": format!("https://cipherpay.app/errors/{}", error_type), "title": title, @@ -568,6 +590,32 @@ fn parse_v2_fields( payload: &PaymentPayloadV2, requirements: &PaymentRequirementsV2, ) -> Result<(String, u64, String), HttpResponse> { + if payload.x402_version != Some(2) + || requirements.scheme.as_deref() != Some("exact") + || requirements.asset.as_deref() != Some("ZEC") + || requirements.pay_to.as_deref().unwrap_or("").is_empty() + || requirements.max_timeout_seconds.unwrap_or(0) == 0 + { + return Err(HttpResponse::BadRequest().json(problem_details( + 400, + "invalid-payment-requirements", + "Invalid Payment Requirements", + "Require V2 exact ZEC payment, payTo and positive timeout", + ))); + } + if let Some(accepted) = &payload.accepted { + let expected = serde_json::to_value(requirements).unwrap_or_default(); + for field in ["scheme", "network", "amount", "payTo", "asset"] { + if accepted.get(field) != expected.get(field) { + return Err(HttpResponse::BadRequest().json(problem_details( + 400, + "requirements-mismatch", + "Requirements Mismatch", + "Accepted payment differs from resource requirements", + ))); + } + } + } let txid = payload .payload .as_ref() @@ -598,7 +646,7 @@ fn parse_v2_fields( )) })?; - if expected_zatoshis == 0 { + if expected_zatoshis == 0 || expected_zatoshis > 2_100_000_000_000_000 { return Err(HttpResponse::BadRequest() .insert_header(("Content-Type", "application/problem+json")) .json(problem_details( @@ -615,7 +663,7 @@ fn parse_v2_fields( .unwrap_or("zcash:mainnet") .to_string(); - Ok((txid.to_string(), expected_zatoshis, network)) + Ok((txid.to_ascii_lowercase(), expected_zatoshis, network)) } fn parse_v2_request(body: &VerifyRequestV2) -> Result<(String, u64, String), HttpResponse> { @@ -634,6 +682,7 @@ async fn verify_core_v2( txid: &str, expected_zatoshis: u64, network: &str, + pay_to: &str, api_key: &str, ) -> Result<(bool, Option, u64), HttpResponse> { let merchant = match merchants::authenticate(pool, api_key, &config.encryption_key).await { @@ -641,77 +690,155 @@ async fn verify_core_v2( Ok(None) => { return Err(HttpResponse::Unauthorized() .insert_header(("Content-Type", "application/problem+json")) - .json(problem_details(401, "unauthorized", "Unauthorized", "Invalid API key"))); + .json(problem_details( + 401, + "unauthorized", + "Unauthorized", + "Invalid API key", + ))); } Err(e) => { tracing::error!(error = %e, "x402 v2 auth error"); return Err(HttpResponse::InternalServerError() .insert_header(("Content-Type", "application/problem+json")) - .json(problem_details(500, "internal-error", "Internal Error", "Unexpected verification error"))); + .json(problem_details( + 500, + "internal-error", + "Internal Error", + "Unexpected verification error", + ))); } }; + let expected_network = if config.is_testnet() { + "zcash:testnet" + } else { + "zcash:mainnet" + }; + if network != expected_network || pay_to != merchant.payment_address { + return Err(HttpResponse::BadRequest().json(problem_details( + 400, + "requirements-mismatch", + "Requirements Mismatch", + "Network and payTo must match this merchant", + ))); + } + let receiver = crate::invoices::matching::orchard_receiver(pay_to).ok_or_else(|| { + HttpResponse::BadRequest().json(problem_details( + 400, + "invalid-pay-to", + "Invalid Recipient", + "Orchard receiver required", + )) + })?; if config.fee_enabled() { - if let Ok(status) = - crate::billing::get_merchant_billing_status(pool, &merchant.id).await - { + if let Ok(status) = crate::billing::get_merchant_billing_status(pool, &merchant.id).await { if merchant_billing_blocked(&status) { return Err(HttpResponse::PaymentRequired() .insert_header(("Content-Type", "application/problem+json")) - .json(problem_details(402, "merchant-billing-blocked", "Merchant Billing Blocked", "Merchant account has outstanding fees"))); + .json(problem_details( + 402, + "merchant-billing-blocked", + "Merchant Billing Blocked", + "Merchant account has outstanding fees", + ))); } } } let protocol = "x402"; - let min_acceptable = (expected_zatoshis as f64 * SLIPPAGE_TOLERANCE) as u64; + let min_acceptable = expected_zatoshis; - if let Some(received_zatoshis) = - get_existing_verified(pool, &merchant.id, txid, protocol).await + if !crate::scanner::blocks::check_tx_confirmed(http_client, &config.cipherscan_api_url, txid) + .await + .unwrap_or(false) { - if received_zatoshis >= min_acceptable { - return Ok((true, None, received_zatoshis)); - } else { - return Ok(( - false, - Some("insufficient_funds".to_string()), - received_zatoshis, - )); - } + return Ok((false, Some("payment_not_confirmed".to_string()), 0)); } - - let raw_hex = - match mempool::fetch_raw_tx(http_client, &config.cipherscan_api_url, txid).await { - Ok(hex) => hex, - Err(e) => { - tracing::warn!(txid = %txid, error = %e, "x402 v2: failed to fetch raw tx"); - log_verification(pool, &merchant.id, txid, 0, "rejected", Some("Transaction not found"), protocol).await; - return Ok((false, Some("invalid_transaction_state".to_string()), 0)); - } - }; + let raw_hex = match mempool::fetch_raw_tx(http_client, &config.cipherscan_api_url, txid).await { + Ok(hex) => hex, + Err(e) => { + tracing::warn!(txid = %txid, error = %e, "x402 v2: failed to fetch raw tx"); + log_verification( + pool, + &merchant.id, + txid, + 0, + "rejected", + Some("Transaction not found"), + protocol, + ) + .await; + return Ok((false, Some("invalid_transaction_state".to_string()), 0)); + } + }; let outputs = match decrypt::try_decrypt_all_outputs_ivk(&raw_hex, &merchant.ufvk) { Ok(o) => o, Err(e) => { tracing::warn!(txid = %txid, error = %e, "x402 v2: decryption error"); - log_verification(pool, &merchant.id, txid, 0, "rejected", Some("Decryption failed"), protocol).await; + log_verification( + pool, + &merchant.id, + txid, + 0, + "rejected", + Some("Decryption failed"), + protocol, + ) + .await; return Ok((false, Some("invalid_transaction_state".to_string()), 0)); } }; if outputs.is_empty() { - log_verification(pool, &merchant.id, txid, 0, "rejected", Some("No outputs addressed to this merchant"), protocol).await; + log_verification( + pool, + &merchant.id, + txid, + 0, + "rejected", + Some("No outputs addressed to this merchant"), + protocol, + ) + .await; return Ok((false, Some("invalid_payload".to_string()), 0)); } - let total_zatoshis: u64 = outputs.iter().map(|o| o.amount_zatoshis).sum(); + let total_zatoshis: u64 = outputs + .iter() + .filter(|o| hex::encode(o.recipient_raw) == receiver) + .map(|o| o.amount_zatoshis) + .sum(); if total_zatoshis >= min_acceptable { - log_verification(pool, &merchant.id, txid, total_zatoshis, "verified", None, protocol).await; + log_verification( + pool, + &merchant.id, + txid, + total_zatoshis, + "verified", + None, + protocol, + ) + .await; Ok((true, None, total_zatoshis)) } else { - log_verification(pool, &merchant.id, txid, total_zatoshis, "rejected", Some("Insufficient amount"), protocol).await; - Ok((false, Some("insufficient_funds".to_string()), total_zatoshis)) + log_verification( + pool, + &merchant.id, + txid, + total_zatoshis, + "rejected", + Some("Insufficient amount"), + protocol, + ) + .await; + Ok(( + false, + Some("insufficient_funds".to_string()), + total_zatoshis, + )) } } @@ -728,7 +855,12 @@ pub async fn verify_v2( None => { return HttpResponse::Unauthorized() .insert_header(("Content-Type", "application/problem+json")) - .json(problem_details(401, "unauthorized", "Unauthorized", "Missing or invalid Authorization header")); + .json(problem_details( + 401, + "unauthorized", + "Unauthorized", + "Missing or invalid Authorization header", + )); } }; @@ -737,14 +869,23 @@ pub async fn verify_v2( Err(resp) => return resp, }; - match verify_core_v2(pool.get_ref(), &config, &http_client, &txid, expected_zatoshis, &network, &api_key).await { - Ok((is_valid, invalid_reason, _)) => { - HttpResponse::Ok().json(VerifyResponseV2 { - is_valid, - invalid_reason, - payer: None, - }) - } + match verify_core_v2( + pool.get_ref(), + &config, + &http_client, + &txid, + expected_zatoshis, + &network, + body.payment_requirements.pay_to.as_deref().unwrap_or(""), + &api_key, + ) + .await + { + Ok((is_valid, invalid_reason, _)) => HttpResponse::Ok().json(VerifyResponseV2 { + is_valid, + invalid_reason, + payer: None, + }), Err(resp) => resp, } } @@ -767,154 +908,208 @@ pub async fn settle_v2( None => { return HttpResponse::Unauthorized() .insert_header(("Content-Type", "application/problem+json")) - .json(problem_details(401, "unauthorized", "Unauthorized", "Missing or invalid Authorization header")); + .json(problem_details( + 401, + "unauthorized", + "Unauthorized", + "Missing or invalid Authorization header", + )); } }; - // Idempotency-Key: check header first, fall back to body field - let idempotency_key = req + let merchant = + match merchants::authenticate(pool.get_ref(), &api_key, &config.encryption_key).await { + Ok(Some(m)) => m, + _ => { + return HttpResponse::Unauthorized().json(problem_details( + 401, + "unauthorized", + "Unauthorized", + "Invalid API key", + )) + } + }; + let key = req .headers() .get("Idempotency-Key") .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) + .map(str::to_string) .or_else(|| body.idempotency_key.clone()); - - if let Some(ref key) = idempotency_key { - if let Some(cached) = get_idempotent_response(pool.get_ref(), key).await { - return HttpResponse::Ok() - .insert_header(("Idempotency-Replayed", "true")) - .json(cached); + if key.as_ref().is_some_and(|k| k.is_empty() || k.len() > 128) { + return HttpResponse::BadRequest().json(problem_details( + 400, + "invalid-idempotency-key", + "Invalid Key", + "Use 1-128 characters", + )); + } + let digest = hex::encode(Sha256::digest( + serde_json::to_vec(&*body).unwrap_or_default(), + )); + if let Some(key) = &key { + match cached_settlement( + pool.get_ref(), + &merchant.id, + key, + &digest, + &config.encryption_key, + ) + .await + { + Ok(Some(response)) => { + return HttpResponse::Ok() + .insert_header(("Idempotency-Replayed", "true")) + .json(response) + } + Ok(None) => {} + Err(_) => { + return HttpResponse::Conflict().json(problem_details( + 409, + "idempotency-conflict", + "Idempotency Conflict", + "Key belongs to another request or cannot be recovered", + )) + } } } - - let (txid, expected_zatoshis, network) = match parse_settle_request(&body) { + let (txid, amount, network) = match parse_settle_request(&body) { Ok(v) => v, - Err(resp) => return resp, + Err(r) => return r, }; - - match verify_core_v2(pool.get_ref(), &config, &http_client, &txid, expected_zatoshis, &network, &api_key).await { - Ok((true, _, total_zatoshis)) => { - // Auto-session: if requested and payment verified, create a session - let session_info = if let Some(ref session_cfg) = body.session { - match try_auto_session(pool.get_ref(), &api_key, &config, &txid, total_zatoshis, session_cfg).await { - Ok(Some(info)) => Some(info), - Ok(None) => None, - Err(e) => { - tracing::warn!(error = %e, "Auto-session creation failed (settle still succeeds)"); - None - } + match verify_core_v2( + pool.get_ref(), + &config, + &http_client, + &txid, + amount, + &network, + body.payment_requirements.pay_to.as_deref().unwrap_or(""), + &api_key, + ) + .await + { + Ok((true, _, total)) => { + match commit_settlement( + pool.get_ref(), + &merchant.id, + &txid, + amount, + total, + &network, + body.session.as_ref(), + key.as_deref(), + &digest, + &config.encryption_key, + ) + .await + { + Ok(Some(response)) => HttpResponse::Ok().json(response), + Ok(None) => HttpResponse::Conflict().json(problem_details( + 409, + "payment-replayed", + "Payment Replayed", + "This transaction has already been consumed", + )), + Err(e) => { + tracing::error!(error = %e, "Settlement commit failed"); + HttpResponse::InternalServerError().finish() } - } else { - None - }; - - let response = SettleResponseV2 { - success: true, - transaction: txid, - network, - payer: None, - error_reason: None, - amount: Some(total_zatoshis.to_string()), - session: session_info, - }; - - if let Some(ref key) = idempotency_key { - store_idempotent_response(pool.get_ref(), key, &response).await; } - - HttpResponse::Ok().json(response) - } - Ok((false, reason, _)) => { - HttpResponse::Ok().json(SettleResponseV2 { - success: false, - transaction: String::new(), - network, - payer: None, - error_reason: reason, - amount: None, - session: None, - }) } - Err(resp) => resp, + Ok((false, reason, _)) => HttpResponse::Ok().json(SettleResponseV2 { + success: false, + transaction: String::new(), + network, + payer: None, + error_reason: reason, + amount: None, + session: None, + }), + Err(r) => r, } } -/// Try to auto-create a session from a settled payment. -async fn try_auto_session( +async fn cached_settlement( pool: &SqlitePool, - api_key: &str, - config: &Config, - txid: &str, - total_zatoshis: u64, - session_cfg: &SettleSessionConfig, -) -> Result, anyhow::Error> { - if crate::sessions::txid_already_used(pool, txid).await { - return Ok(None); + merchant: &str, + key: &str, + digest: &str, + encryption_key: &str, +) -> anyhow::Result> { + let row = sqlx::query_as::<_, (String, String)>("SELECT request_hash, response_json FROM x402_idempotency_v2 WHERE merchant_id = ? AND idempotency_key = ? AND created_at >= strftime('%Y-%m-%dT%H:%M:%SZ','now','-24 hours')") + .bind(merchant).bind(key).fetch_optional(pool).await?; + if let Some((hash, encrypted)) = row { + anyhow::ensure!(hash == digest, "Idempotency request mismatch"); + return Ok(Some(serde_json::from_str(&crate::crypto::decrypt( + &encrypted, + encryption_key, + )?)?)); } + Ok(None) +} - let merchant = match merchants::authenticate(pool, api_key, &config.encryption_key).await? { - Some(m) => m, - None => return Ok(None), - }; - - let cost_per_request = session_cfg.cost_per_request.unwrap_or(1_000); - if cost_per_request <= 0 || (total_zatoshis as i64) < cost_per_request { +async fn commit_settlement( + pool: &SqlitePool, + merchant: &str, + txid: &str, + price: u64, + total: u64, + network: &str, + session: Option<&SettleSessionConfig>, + key: Option<&str>, + digest: &str, + encryption_key: &str, +) -> anyhow::Result> { + let mut tx = pool.begin().await?; + if !crate::sessions::consume_payment(&mut tx, txid, "x402").await? { return Ok(None); } - - let session = crate::sessions::create_session_with_cost( - pool, - &merchant.id, - txid, - total_zatoshis as i64, - session_cfg.refund_address.as_deref(), - session_cfg.cost_per_request, - ) - .await?; - - Ok(Some(SessionInfo { - token: session.bearer_token, - session_id: session.id, - balance_remaining: session.balance_remaining, - cost_per_request: session.cost_per_request, - expires_at: session.expires_at, - })) -} - -// --------------------------------------------------------------------------- -// Idempotency-Key storage (x402_idempotency table) -// --------------------------------------------------------------------------- - -async fn get_idempotent_response(pool: &SqlitePool, key: &str) -> Option { - let row = sqlx::query_scalar::<_, String>( - "SELECT response_json FROM x402_idempotency - WHERE idempotency_key = ? AND created_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-24 hours')" - ) - .bind(key) - .fetch_optional(pool) - .await - .ok()?; - - row.and_then(|json_str| serde_json::from_str(&json_str).ok()) -} - -async fn store_idempotent_response(pool: &SqlitePool, key: &str, response: &SettleResponseV2) { - let json_str = match serde_json::to_string(response) { - Ok(s) => s, - Err(_) => return, + // Only the surplus funds a session; the current fulfilled request is already paid for. + let remaining = total.saturating_sub(price) as i64; + let session_info = if let Some(cfg) = session { + let cost = cfg.cost_per_request.unwrap_or(1000); + anyhow::ensure!(cost > 0, "Invalid session cost"); + if remaining >= cost { + let s = crate::sessions::insert_session( + &mut tx, + merchant, + txid, + remaining, + cfg.refund_address.as_deref(), + Some(cost), + ) + .await?; + Some(SessionInfo { + token: s.bearer_token, + session_id: s.id, + balance_remaining: s.balance_remaining, + cost_per_request: s.cost_per_request, + expires_at: s.expires_at, + }) + } else { + None + } + } else { + None }; - - let result = sqlx::query( - "INSERT OR IGNORE INTO x402_idempotency (idempotency_key, response_json) VALUES (?, ?)" - ) - .bind(key) - .bind(&json_str) - .execute(pool) - .await; - - if let Err(e) = result { - tracing::warn!(error = %e, "Failed to store idempotent response"); + let response = SettleResponseV2 { + success: true, + transaction: txid.to_string(), + network: network.to_string(), + payer: None, + error_reason: None, + amount: Some(total.to_string()), + session: session_info, + }; + if let Some(key) = key { + let encrypted = crate::crypto::encrypt(&serde_json::to_string(&response)?, encryption_key)?; + sqlx::query("DELETE FROM x402_idempotency_v2 WHERE merchant_id = ? AND idempotency_key = ? AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ','now','-24 hours')") + .bind(merchant).bind(key).execute(&mut *tx).await?; + sqlx::query("INSERT INTO x402_idempotency_v2 (merchant_id, idempotency_key, request_hash, response_json) VALUES (?, ?, ?, ?)") + .bind(merchant).bind(key).bind(digest).bind(encrypted).execute(&mut *tx).await?; } + tx.commit().await?; + Ok(Some(response)) } /// GET /api/x402/supported — x402 V2 spec-compliant discovery endpoint. @@ -937,3 +1132,117 @@ pub async fn supported(config: web::Data) -> HttpResponse { "signers": {}, })) } + +#[cfg(test)] +mod repair_tests { + use super::*; + #[tokio::test] + async fn settlement_consumption_and_cache_are_tenant_bound() { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "a").await; + crate::repair_tests::merchant(&pool, "b").await; + let encryption_key = "11".repeat(32); + let config = SettleSessionConfig { + cost_per_request: Some(1000), + refund_address: None, + }; + let first = commit_settlement( + &pool, + "a", + "tx", + 1000, + 5000, + "zcash:mainnet", + Some(&config), + Some("key"), + "digest", + &encryption_key, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(first.session.unwrap().balance_remaining, 4000); + assert!(commit_settlement( + &pool, + "a", + "tx", + 1000, + 5000, + "zcash:mainnet", + None, + None, + "digest", + &encryption_key + ) + .await + .unwrap() + .is_none()); + assert!( + cached_settlement(&pool, "b", "key", "digest", &encryption_key) + .await + .unwrap() + .is_none() + ); + assert!( + cached_settlement(&pool, "a", "key", "different-body", &encryption_key) + .await + .is_err() + ); + assert!( + cached_settlement(&pool, "a", "key", "digest", &encryption_key) + .await + .unwrap() + .is_some() + ); + let stored: String = sqlx::query_scalar("SELECT response_json FROM x402_idempotency_v2") + .fetch_one(&pool) + .await + .unwrap(); + assert!(!stored.contains("cps_")); + assert!( + crate::sessions::create_session(&pool, "a", "tx", 5000, None) + .await + .is_err() + ); + } + #[tokio::test] + async fn failed_settlement_rolls_back_consumption() { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "a").await; + // Invalid encryption config fails cache persistence; the payment must remain spendable. + assert!(commit_settlement( + &pool, + "a", + "tx", + 1000, + 1000, + "zcash:mainnet", + None, + Some("key"), + "digest", + "" + ) + .await + .is_err()); + assert!(!crate::sessions::txid_already_used(&pool, "tx").await); + } + #[actix_web::test] + async fn unauthenticated_settle_cannot_read_cached_credentials() { + let pool = crate::repair_tests::pool().await; + sqlx::query("INSERT INTO x402_idempotency_v2 VALUES ('a','known-key','hash','sensitive','2099-01-01T00:00:00Z')").execute(&pool).await.unwrap(); + let config = Config::from_env().unwrap(); + let app = actix_web::test::init_service( + actix_web::App::new() + .app_data(web::Data::new(pool)) + .app_data(web::Data::new(config)) + .app_data(web::Data::new(reqwest::Client::new())) + .route("/settle", web::post().to(settle_v2)), + ) + .await; + let req=actix_web::test::TestRequest::post().uri("/settle").insert_header(("Authorization","Bearer invalid")) + .insert_header(("Idempotency-Key","known-key")) + .set_json(serde_json::json!({"x402Version":2,"paymentPayload":{"x402Version":2,"payload":{"txid":"a".repeat(64)}},"paymentRequirements":{"scheme":"exact","network":"zcash:mainnet","asset":"ZEC","amount":"1000","payTo":"address","maxTimeoutSeconds":300}})).to_request(); + let response = actix_web::test::call_service(&app, req).await; + assert_eq!(response.status(), actix_web::http::StatusCode::UNAUTHORIZED); + } +} diff --git a/src/db.rs b/src/db.rs index f80e338..f527cbf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -115,17 +115,21 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { // never deployed; this migration only affects local dev DBs that ran the // earlier programmatic_registration_v2026_04_28 migration. Production never // had it, so this is a no-op there. SQLite's DROP COLUMN requires sqlite >= 3.35. - run_tracked_migration(&pool, "remove_programmatic_registration_v2026_05_01", || async { - sqlx::query("DROP TABLE IF EXISTS registration_requests") - .execute(&pool) - .await - .ok(); - sqlx::query("ALTER TABLE merchants DROP COLUMN merchant_type") - .execute(&pool) - .await - .ok(); - Ok(()) - }) + run_tracked_migration( + &pool, + "remove_programmatic_registration_v2026_05_01", + || async { + sqlx::query("DROP TABLE IF EXISTS registration_requests") + .execute(&pool) + .await + .ok(); + sqlx::query("ALTER TABLE merchants DROP COLUMN merchant_type") + .execute(&pool) + .await + .ok(); + Ok(()) + }, + ) .await?; // Restricted API keys: per-merchant scoped credentials. A row with key_type='full' @@ -184,21 +188,21 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { .await?; run_tracked_migration(&pool, "pos_pin_v2026_05_11", || async { - sqlx::query("ALTER TABLE merchants ADD COLUMN pos_pin_hash TEXT") - .execute(&pool) - .await - .ok(); - sqlx::query("ALTER TABLE sessions ADD COLUMN pos_scoped INTEGER NOT NULL DEFAULT 0") - .execute(&pool) - .await - .ok(); - Ok(()) - }) - .await?; - - run_tracked_migration(&pool, "invoice_payments_v2026_06_12", || async { - sqlx::query( - "CREATE TABLE IF NOT EXISTS invoice_payments ( + sqlx::query("ALTER TABLE merchants ADD COLUMN pos_pin_hash TEXT") + .execute(&pool) + .await + .ok(); + sqlx::query("ALTER TABLE sessions ADD COLUMN pos_scoped INTEGER NOT NULL DEFAULT 0") + .execute(&pool) + .await + .ok(); + Ok(()) + }) + .await?; + + run_tracked_migration(&pool, "invoice_payments_v2026_06_12", || async { + sqlx::query( + "CREATE TABLE IF NOT EXISTS invoice_payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id TEXT NOT NULL REFERENCES invoices(id), txid TEXT NOT NULL, @@ -206,34 +210,32 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(invoice_id, txid) )", - ) - .execute(&pool) - .await - .ok(); + ) + .execute(&pool) + .await + .ok(); - sqlx::query( + sqlx::query( "CREATE INDEX IF NOT EXISTS idx_invoice_payments_invoice ON invoice_payments(invoice_id)", ) .execute(&pool) .await .ok(); - Ok(()) -}) -.await?; - - + Ok(()) + }) + .await?; -run_tracked_migration(&pool, "accounting_v2026_06_21", || async { - for sql in &[ - "ALTER TABLE invoices ADD COLUMN confirmed_rate REAL", - "ALTER TABLE invoices ADD COLUMN confirmed_fiat_amount REAL", - ] { - sqlx::query(sql).execute(&pool).await.ok(); - } + run_tracked_migration(&pool, "accounting_v2026_06_21", || async { + for sql in &[ + "ALTER TABLE invoices ADD COLUMN confirmed_rate REAL", + "ALTER TABLE invoices ADD COLUMN confirmed_fiat_amount REAL", + ] { + sqlx::query(sql).execute(&pool).await.ok(); + } - sqlx::query( - "CREATE TABLE IF NOT EXISTS ledger_tokens ( + sqlx::query( + "CREATE TABLE IF NOT EXISTS ledger_tokens ( id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT '', @@ -241,23 +243,23 @@ run_tracked_migration(&pool, "accounting_v2026_06_21", || async { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), revoked_at TEXT )", - ) - .execute(&pool) - .await - .ok(); - - sqlx::query( - "CREATE INDEX IF NOT EXISTS idx_ledger_tokens_merchant ON ledger_tokens(merchant_id)", - ) - .execute(&pool) - .await - .ok(); - - Ok(()) -}) -.await?; + ) + .execute(&pool) + .await + .ok(); -run_tracked_migration(&pool, "campaign_catchall_addresses_v2026_06_30", || async { + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_ledger_tokens_merchant ON ledger_tokens(merchant_id)", + ) + .execute(&pool) + .await + .ok(); + + Ok(()) + }) + .await?; + + run_tracked_migration(&pool, "campaign_catchall_addresses_v2026_06_30", || async { for sql in &[ "ALTER TABLE payment_links ADD COLUMN campaign_address_hex TEXT", "ALTER TABLE payment_links ADD COLUMN campaign_diversifier_index INTEGER", @@ -290,23 +292,57 @@ run_tracked_migration(&pool, "campaign_catchall_addresses_v2026_06_30", || async }) .await?; -run_tracked_migration(&pool, "campaign_zec_tracking_v2026_06_30", || async { - sqlx::query("ALTER TABLE payment_links ADD COLUMN total_raised_zatoshis INTEGER NOT NULL DEFAULT 0") + run_tracked_migration(&pool, "campaign_zec_tracking_v2026_06_30", || async { + sqlx::query( + "ALTER TABLE payment_links ADD COLUMN total_raised_zatoshis INTEGER NOT NULL DEFAULT 0", + ) .execute(&pool) .await .ok(); - Ok(()) -}) -.await?; + Ok(()) + }) + .await?; -run_tracked_migration(&pool, "campaign_directory_v2026_07_04", || async { - sqlx::query("ALTER TABLE payment_links ADD COLUMN listed INTEGER NOT NULL DEFAULT 1") - .execute(&pool) - .await - .ok(); - Ok(()) -}) -.await?; + run_tracked_migration(&pool, "campaign_directory_v2026_07_04", || async { + sqlx::query("ALTER TABLE payment_links ADD COLUMN listed INTEGER NOT NULL DEFAULT 1") + .execute(&pool) + .await + .ok(); + Ok(()) + }) + .await?; + + run_tracked_migration(&pool, "audit_repairs_v2026_09_08", || async { + let mut tx = pool.begin().await?; + for sql in [ + "CREATE TABLE payment_consumptions (txid TEXT PRIMARY KEY, purpose TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))", + "INSERT OR IGNORE INTO payment_consumptions (txid, purpose) SELECT lower(deposit_txid), 'session' FROM agent_sessions", + "INSERT OR IGNORE INTO payment_consumptions (txid, purpose) SELECT lower(txid), 'legacy-x402' FROM x402_verifications WHERE status = 'verified'", + "CREATE TABLE session_charges (session_id TEXT NOT NULL, request_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), PRIMARY KEY(session_id, request_id))", + "CREATE TABLE x402_idempotency_v2 (merchant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash TEXT NOT NULL, response_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), PRIMARY KEY(merchant_id, idempotency_key))", + "DELETE FROM x402_idempotency", + ] { sqlx::query(sql).execute(&mut *tx).await?; } + tx.commit().await?; + Ok(()) + }).await?; + + run_tracked_migration(&pool, "payment_outbox_v2026_09_08", || async { + sqlx::query("CREATE TRIGGER invoice_payment_outbox AFTER UPDATE OF status ON invoices + WHEN NEW.status != OLD.status AND NEW.status IN ('detected','underpaid','confirmed','expired','refunded') + BEGIN + INSERT INTO webhook_deliveries (id, invoice_id, merchant_id, url, payload, status, attempts, next_retry_at, event_type) + SELECT lower(hex(randomblob(16))), NEW.id, m.id, m.webhook_url, + json_object('event',NEW.status,'invoice_id',NEW.id,'txid',NEW.detected_txid, + 'timestamp',strftime('%Y-%m-%dT%H:%M:%SZ','now'), + 'price_zec',NEW.price_zatoshis / 100000000.0, + 'received_zec',NEW.received_zatoshis / 100000000.0, + 'overpaid',json(CASE WHEN NEW.received_zatoshis > NEW.price_zatoshis + 1000 THEN 'true' ELSE 'false' END)), + 'pending', 0, strftime('%Y-%m-%dT%H:%M:%SZ','now'), NEW.status + FROM merchants m WHERE m.id = NEW.merchant_id AND m.webhook_url IS NOT NULL AND m.webhook_url != ''; + END") + .execute(&pool).await?; + Ok(()) + }).await?; Ok(pool) } diff --git a/src/db/bootstrap.rs b/src/db/bootstrap.rs index 0b370b2..6becb09 100644 --- a/src/db/bootstrap.rs +++ b/src/db/bootstrap.rs @@ -2,9 +2,11 @@ use super::schema_tracking::validate_schema_state; use sqlx::SqlitePool; pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::Result<()> { + // Connection-local PRAGMAs must stay on the same connection throughout bootstrap. + let mut conn = pool.acquire().await?; // Run migrations inline sqlx::query(include_str!("../../migrations/001_init.sql")) - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Ignore if tables already exist @@ -16,7 +18,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE merchants ADD COLUMN name TEXT NOT NULL DEFAULT ''", ]; for sql in &upgrades { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } sqlx::query( @@ -27,7 +29,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -37,7 +39,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE invoices ADD COLUMN zcash_uri TEXT NOT NULL DEFAULT ''", ]; for sql in &invoice_upgrades { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Products table (pricing is handled by the prices table via default_price_id) @@ -54,74 +56,74 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_products_merchant ON products(merchant_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Drop legacy UNIQUE constraint on slug (slug is now cosmetic, product ID is the identifier) sqlx::query("DROP INDEX IF EXISTS sqlite_autoindex_products_1") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP INDEX IF EXISTS idx_products_slug") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Add product_id and refund_address to invoices for existing databases sqlx::query("ALTER TABLE invoices ADD COLUMN product_id TEXT REFERENCES products(id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN refund_address TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN price_usd REAL") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN refunded_at TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN refund_txid TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE products ADD COLUMN default_price_id TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE products ADD COLUMN metadata TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN currency TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Disable FK checks and prevent SQLite from auto-rewriting FK references // in other tables during ALTER TABLE RENAME (requires legacy_alter_table). sqlx::query("PRAGMA foreign_keys = OFF") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("PRAGMA legacy_alter_table = ON") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -129,7 +131,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='invoices' AND sql LIKE '%CHECK%' AND (sql NOT LIKE '%refunded%' OR sql LIKE '%shipped%')", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; @@ -137,11 +139,11 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if needs_migrate { tracing::info!("Migrating invoices table (removing shipped status)..."); sqlx::query("UPDATE invoices SET status = 'confirmed' WHERE status = 'shipped'") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices RENAME TO invoices_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -171,7 +173,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -182,60 +184,60 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R confirmed_at, refunded_at, expires_at, purge_after, created_at FROM invoices_old", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE invoices_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo_code)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("Invoices table migration complete"); } sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_merchants_ufvk ON merchants(ufvk)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Diversified addresses: per-invoice unique address derivation sqlx::query("ALTER TABLE merchants ADD COLUMN diversifier_index INTEGER NOT NULL DEFAULT 0") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN diversifier_index INTEGER") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN orchard_receiver_hex TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_invoices_orchard_receiver ON invoices(orchard_receiver_hex)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Underpayment/overpayment: zatoshi-based amount tracking sqlx::query("ALTER TABLE invoices ADD COLUMN price_zatoshis INTEGER NOT NULL DEFAULT 0") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN received_zatoshis INTEGER NOT NULL DEFAULT 0") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("UPDATE invoices SET price_zatoshis = CAST(price_zec * 100000000 AS INTEGER) WHERE price_zatoshis = 0 AND price_zec > 0") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -244,7 +246,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='invoices' AND sql LIKE '%CHECK%' AND sql NOT LIKE '%underpaid%'", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; @@ -252,7 +254,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if needs_underpaid { tracing::info!("Migrating invoices table (adding underpaid status)..."); sqlx::query("ALTER TABLE invoices RENAME TO invoices_old2") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -286,7 +288,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R received_zatoshis INTEGER NOT NULL DEFAULT 0 )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -298,25 +300,25 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R diversifier_index, orchard_receiver_hex, price_zatoshis, received_zatoshis FROM invoices_old2", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE invoices_old2") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo_code)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_invoices_orchard_receiver ON invoices(orchard_receiver_hex)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("Invoices table migration (underpaid) complete"); @@ -324,11 +326,11 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R // Clean up leftover temp tables from migrations sqlx::query("DROP TABLE IF EXISTS invoices_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE IF EXISTS invoices_old2") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -336,7 +338,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R let products_has_price_eur: bool = sqlx::query_scalar::<_, i32>( "SELECT COUNT(*) FROM pragma_table_info('products') WHERE name = 'price_eur'", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; @@ -344,7 +346,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if products_has_price_eur { tracing::info!("Migrating products table (dropping legacy price_eur/currency columns)..."); sqlx::query("ALTER TABLE products RENAME TO products_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -360,7 +362,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -368,21 +370,21 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R SELECT id, merchant_id, slug, name, description, default_price_id, metadata, active, created_at FROM products_old", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE products_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_products_merchant ON products(merchant_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("Products table migration complete (price_eur/currency removed)"); } sqlx::query("DROP TABLE IF EXISTS products_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -390,7 +392,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R // by SQLite during products RENAME TABLE (pointing to products_old). let prices_schema: Option = sqlx::query_scalar("SELECT sql FROM sqlite_master WHERE type='table' AND name='prices'") - .fetch_optional(&pool) + .fetch_optional(&mut *conn) .await .ok() .flatten(); @@ -398,7 +400,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if schema.contains("products_old") { tracing::info!("Repairing prices table FK references..."); sqlx::query("ALTER TABLE prices RENAME TO _prices_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -414,33 +416,33 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R interval_count INTEGER )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("INSERT OR IGNORE INTO prices SELECT * FROM _prices_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _prices_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_prices_product ON prices(product_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("prices FK repair complete"); } } sqlx::query("DROP TABLE IF EXISTS _prices_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Repair FK references in invoices if they point to products_old let inv_schema: Option = sqlx::query_scalar("SELECT sql FROM sqlite_master WHERE type='table' AND name='invoices'") - .fetch_optional(&pool) + .fetch_optional(&mut *conn) .await .ok() .flatten(); @@ -449,37 +451,37 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R tracing::info!("Repairing invoices table FK references (products_old)..."); let inv_sql = schema.replace("products_old", "products"); sqlx::query("ALTER TABLE invoices RENAME TO _inv_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); - sqlx::query(&inv_sql).execute(&pool).await.ok(); + sqlx::query(&inv_sql).execute(&mut *conn).await.ok(); sqlx::query("INSERT OR IGNORE INTO invoices SELECT * FROM _inv_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _inv_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo_code)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_invoices_orchard_receiver ON invoices(orchard_receiver_hex)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("invoices FK repair (products_old) complete"); } } sqlx::query("DROP TABLE IF EXISTS _inv_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -491,7 +493,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R let wd_schema: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type='table' AND name='webhook_deliveries'", ) - .fetch_optional(&pool) + .fetch_optional(&mut *conn) .await .ok() .flatten(); @@ -499,7 +501,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if schema.contains("invoices_old") || schema.contains("_inv_repair") { tracing::info!("Repairing webhook_deliveries FK references..."); sqlx::query("ALTER TABLE webhook_deliveries RENAME TO _wd_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -520,7 +522,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -531,25 +533,25 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R id, invoice_id, url, payload, status, attempts, last_attempt_at, next_retry_at, created_at FROM _wd_repair", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _wd_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("webhook_deliveries FK repair complete"); } } sqlx::query("DROP TABLE IF EXISTS _wd_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); let fl_schema: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type='table' AND name='fee_ledger'", ) - .fetch_optional(&pool) + .fetch_optional(&mut *conn) .await .ok() .flatten(); @@ -557,7 +559,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if schema.contains("invoices_old") || schema.contains("_inv_repair") { tracing::info!("Repairing fee_ledger FK references..."); sqlx::query("ALTER TABLE fee_ledger RENAME TO _fl_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -573,7 +575,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -587,28 +589,28 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R auto_collected, collected_at, billing_cycle_id, created_at FROM _fl_repair", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _fl_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("fee_ledger FK repair complete"); } } sqlx::query("DROP TABLE IF EXISTS _fl_repair") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Re-enable FK enforcement and restore default alter-table behavior sqlx::query("PRAGMA legacy_alter_table = OFF") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("PRAGMA foreign_keys = ON") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -621,7 +623,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -634,7 +636,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE merchants ADD COLUMN fee_discount_until TEXT", ]; for sql in &billing_upgrades { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Fee ledger @@ -651,30 +653,30 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_fee_ledger_merchant ON fee_ledger(merchant_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_fee_ledger_cycle ON fee_ledger(billing_cycle_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_fee_ledger_invoice ON fee_ledger(invoice_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE fee_ledger ADD COLUMN fee_amount_zatoshis INTEGER NOT NULL DEFAULT 0") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE fee_ledger ADD COLUMN fee_rate_applied REAL") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -682,7 +684,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R SET fee_amount_zatoshis = ROUND(fee_amount_zec * 100000000.0) WHERE fee_amount_zatoshis = 0 AND fee_amount_zec > 0", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -706,14 +708,14 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_billing_cycles_merchant ON billing_cycles(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); for sql in &[ @@ -721,7 +723,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE billing_cycles ADD COLUMN auto_collected_zatoshis INTEGER NOT NULL DEFAULT 0", "ALTER TABLE billing_cycles ADD COLUMN outstanding_zatoshis INTEGER NOT NULL DEFAULT 0", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } sqlx::query( "UPDATE billing_cycles @@ -733,7 +735,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R AND outstanding_zatoshis = 0 AND (total_fees_zec > 0 OR auto_collected_zec > 0 OR outstanding_zec > 0)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -742,7 +744,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='billing_cycles' AND sql LIKE '%CHECK%' AND sql NOT LIKE '%carried_over%'", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; @@ -750,7 +752,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if bc_needs_migrate { tracing::info!("Migrating billing_cycles table (adding carried_over status)..."); sqlx::query("ALTER TABLE billing_cycles RENAME TO _bc_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -772,7 +774,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -791,17 +793,17 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R settlement_invoice_id, status, grace_until, created_at FROM _bc_migrate", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _bc_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_billing_cycles_merchant ON billing_cycles(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("billing_cycles migration complete"); @@ -818,7 +820,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R UNIQUE(merchant_id, template, entity_id) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -833,12 +835,12 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_prices_product ON prices(product_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -847,7 +849,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE prices ADD COLUMN label TEXT", "ALTER TABLE prices ADD COLUMN max_quantity INTEGER", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Seed prices from existing products that don't have any prices yet (legacy: price_eur/currency) @@ -859,7 +861,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R WHERE NOT EXISTS (SELECT 1 FROM prices pr WHERE pr.product_id = p.id) AND (p.price_eur IS NOT NULL AND p.price_eur > 0)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -869,17 +871,17 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R SELECT id FROM prices WHERE product_id = products.id AND active = 1 ORDER BY created_at ASC LIMIT 1 ) WHERE default_price_id IS NULL AND EXISTS (SELECT 1 FROM prices pr WHERE pr.product_id = products.id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Invoice schema additions for multi-currency pricing sqlx::query("ALTER TABLE invoices ADD COLUMN amount REAL") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices ADD COLUMN price_id TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -891,7 +893,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R END WHERE amount IS NULL", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -902,7 +904,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R value TEXT NOT NULL )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -919,17 +921,17 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_x402_merchant ON x402_verifications(merchant_id, created_at)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE x402_verifications ADD COLUMN protocol TEXT NOT NULL DEFAULT 'x402'") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -943,7 +945,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R GROUP BY merchant_id, txid, protocol )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -952,7 +954,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R ON x402_verifications(merchant_id, txid, protocol) WHERE status = 'verified'", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -974,22 +976,22 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R closed_at TEXT )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_agent_sessions_token ON agent_sessions(bearer_token)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_agent_sessions_merchant ON agent_sessions(merchant_id, status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_deposit_txid ON agent_sessions(deposit_txid)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1005,12 +1007,12 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_session_requests_merchant ON session_requests(merchant_id, status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1029,18 +1031,18 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_links_slug ON payment_links(slug)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_payment_links_merchant ON payment_links(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1050,7 +1052,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE prices ADD COLUMN billing_interval TEXT", "ALTER TABLE prices ADD COLUMN interval_count INTEGER", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Events are linked to products (composition model): billing stays product/price-based. @@ -1068,18 +1070,18 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_events_merchant_created ON events(merchant_id, created_at)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_status ON events(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1098,7 +1100,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1107,7 +1109,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "CREATE INDEX IF NOT EXISTS idx_tickets_product_status ON tickets(product_id, status)", "CREATE INDEX IF NOT EXISTS idx_tickets_merchant_created ON tickets(merchant_id, created_at)", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Subscriptions: recurring invoice schedules (no customer data -- privacy first) @@ -1126,23 +1128,23 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Migration: add label column for existing databases sqlx::query("ALTER TABLE subscriptions ADD COLUMN label TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Subscription lifecycle: link invoices to subscriptions sqlx::query("ALTER TABLE invoices ADD COLUMN subscription_id TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE subscriptions ADD COLUMN current_invoice_id TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1151,7 +1153,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='invoices' AND sql LIKE '%CHECK%' AND sql NOT LIKE '%draft%'", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; @@ -1159,15 +1161,15 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R if needs_draft { tracing::info!("Migrating invoices table (adding draft status)..."); sqlx::query("PRAGMA foreign_keys = OFF") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("PRAGMA legacy_alter_table = ON") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("ALTER TABLE invoices RENAME TO _inv_draft_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1205,7 +1207,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R subscription_id TEXT )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1218,39 +1220,39 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R amount, price_id, subscription_id FROM _inv_draft_migrate", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _inv_draft_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo_code)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_invoices_orchard_receiver ON invoices(orchard_receiver_hex)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("PRAGMA legacy_alter_table = OFF") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("PRAGMA foreign_keys = ON") - .execute(&pool) + .execute(&mut *conn) .await .ok(); tracing::info!("Invoices table migration (draft status) complete"); } sqlx::query("DROP TABLE IF EXISTS _inv_draft_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1267,14 +1269,14 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM pragma_table_info('{}') WHERE name = '{}'", table, col )) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; if !exists { tracing::info!("Adding missing column {}.{}", table, col); sqlx::query(&format!("ALTER TABLE {} ADD COLUMN {} TEXT", table, col)) - .execute(&pool) + .execute(&mut *conn) .await .ok(); } @@ -1283,11 +1285,11 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R sqlx::query( "CREATE INDEX IF NOT EXISTS idx_subscriptions_merchant ON subscriptions(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1298,13 +1300,13 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE webhook_deliveries ADD COLUMN response_status INTEGER", "ALTER TABLE webhook_deliveries ADD COLUMN response_error TEXT", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } let webhook_schema: Option = sqlx::query_scalar( "SELECT sql FROM sqlite_master WHERE type='table' AND name='webhook_deliveries'", ) - .fetch_optional(&pool) + .fetch_optional(&mut *conn) .await .ok() .flatten(); @@ -1315,7 +1317,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R { tracing::info!("Migrating webhook_deliveries for nullable invoice_id..."); sqlx::query("ALTER TABLE webhook_deliveries RENAME TO _webhook_deliveries_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1336,7 +1338,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1350,11 +1352,11 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R FROM _webhook_deliveries_migrate wd LEFT JOIN invoices i ON wd.invoice_id = i.id", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE _webhook_deliveries_migrate") - .execute(&pool) + .execute(&mut *conn) .await .ok(); } @@ -1365,19 +1367,19 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R ) WHERE merchant_id IS NULL AND invoice_id IS NOT NULL", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); // Recovery email encryption: add blind-index column sqlx::query("ALTER TABLE merchants ADD COLUMN recovery_email_hash TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_merchants_email_hash ON merchants(recovery_email_hash)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1394,10 +1396,10 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE invoices ADD COLUMN luma_retry_at TEXT", "ALTER TABLE invoices ADD COLUMN luma_retry_count INTEGER DEFAULT 0", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_luma ON events(luma_event_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1413,7 +1415,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "ALTER TABLE payment_links ADD COLUMN campaign_diversifier_index INTEGER", "ALTER TABLE payment_links ADD COLUMN campaign_address_ua TEXT", ] { - sqlx::query(sql).execute(&pool).await.ok(); + sqlx::query(sql).execute(&mut *conn).await.ok(); } // Direct (off-invoice) campaign donation tracking @@ -1426,14 +1428,14 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R fiat_cents INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'USD', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) - )" + )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_campaign_donations_txid_link ON campaign_donations(txid, link_id)") - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1443,15 +1445,21 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payment_links' AND sql LIKE '%price_id TEXT NOT NULL%'", ) - .fetch_one(&pool) + .fetch_one(&mut *conn) .await .unwrap_or(0) > 0; if needs_pl_migrate { + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *conn) + .await?; + sqlx::query("PRAGMA legacy_alter_table = ON") + .execute(&mut *conn) + .await?; tracing::info!("Migrating payment_links (making price_id nullable for donation mode)..."); sqlx::query("ALTER TABLE payment_links RENAME TO payment_links_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1471,7 +1479,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( @@ -1480,25 +1488,31 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R active, total_created, mode, donation_config, total_raised, created_at FROM payment_links_old", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query("DROP TABLE payment_links_old") - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_links_slug ON payment_links(slug)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); sqlx::query( "CREATE INDEX IF NOT EXISTS idx_payment_links_merchant ON payment_links(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); + sqlx::query("PRAGMA legacy_alter_table = OFF") + .execute(&mut *conn) + .await?; + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *conn) + .await?; tracing::info!("payment_links migration complete (price_id now nullable)"); } @@ -1513,7 +1527,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await { tracing::warn!(error = %e, "passkey_credentials table creation failed (may already exist)"); @@ -1522,7 +1536,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R sqlx::query( "CREATE INDEX IF NOT EXISTS idx_passkey_creds_merchant ON passkey_credentials(merchant_id)", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); @@ -1537,7 +1551,7 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await { tracing::warn!(error = %e, "passkey_challenges table creation failed (may already exist)"); @@ -1545,20 +1559,18 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R // Re-auth timestamp on sessions for privileged actions sqlx::query("ALTER TABLE sessions ADD COLUMN reauth_at TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); - - // Track last token-based login for auth metadata sqlx::query("ALTER TABLE merchants ADD COLUMN last_token_login_at TEXT") - .execute(&pool) + .execute(&mut *conn) .await .ok(); - sqlx::query( - "CREATE TABLE IF NOT EXISTS invoice_payments ( + sqlx::query( + "CREATE TABLE IF NOT EXISTS invoice_payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id TEXT NOT NULL REFERENCES invoices(id), txid TEXT NOT NULL, @@ -1566,15 +1578,17 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(invoice_id, txid) )", - ) - .execute(&pool) - .await - .ok(); - - sqlx::query("CREATE INDEX IF NOT EXISTS idx_invoice_payments_invoice ON invoice_payments(invoice_id)") - .execute(&pool) - .await - .ok(); + ) + .execute(&mut *conn) + .await + .ok(); + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_invoice_payments_invoice ON invoice_payments(invoice_id)", + ) + .execute(&mut *conn) + .await + .ok(); // x402 idempotency cache (Idempotency-Key header, 24h TTL) sqlx::query( @@ -1584,10 +1598,11 @@ pub(crate) async fn apply_inline_schema_migration(pool: SqlitePool) -> anyhow::R created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) )", ) - .execute(&pool) + .execute(&mut *conn) .await .ok(); + drop(conn); validate_schema_state(&pool).await?; tracing::info!("Database ready (SQLite)"); Ok(()) diff --git a/src/db/maintenance.rs b/src/db/maintenance.rs index 75ac4ed..b6b7f9c 100644 --- a/src/db/maintenance.rs +++ b/src/db/maintenance.rs @@ -3,6 +3,13 @@ use sqlx::SqlitePool; /// Periodic data purge: cleans up expired sessions, old webhook deliveries, /// expired recovery tokens, and optionally old expired/refunded invoices. pub async fn run_data_purge(pool: &SqlitePool, purge_days: i64) -> anyhow::Result<()> { + sqlx::query( + "DELETE FROM session_charges WHERE session_id NOT IN (SELECT id FROM agent_sessions)", + ) + .execute(pool) + .await?; + sqlx::query("DELETE FROM x402_idempotency_v2 WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-24 hours')").execute(pool).await?; + sqlx::query("UPDATE invoices SET attendee_name = NULL, attendee_email = NULL, luma_guest_data = NULL WHERE (status IN ('expired','refunded') AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-7 days')) OR created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-30 days')").execute(pool).await?; let cutoff = format!("-{} days", purge_days); let agent_sessions_purged = sqlx::query( diff --git a/src/invoices/matching.rs b/src/invoices/matching.rs index 5417408..bae2bf6 100644 --- a/src/invoices/matching.rs +++ b/src/invoices/matching.rs @@ -1,49 +1,103 @@ use super::Invoice; use std::collections::HashMap; +use zcash_address::unified::{Container, Encoding, Receiver}; -/// Pre-built index for O(1) invoice matching by Orchard receiver address. +/// Both the decrypting merchant and exact receiver are part of the payment identity. pub struct InvoiceIndex<'a> { - by_address: HashMap<&'a str, &'a Invoice>, - invoices: &'a [Invoice], + by_address: HashMap<(&'a str, &'a str), &'a Invoice>, + legacy: Vec<(&'a Invoice, String)>, +} + +pub fn orchard_receiver(address: &str) -> Option { + let (_, ua) = zcash_address::unified::Address::decode(address).ok()?; + ua.items().iter().find_map(|r| match r { + Receiver::Orchard(raw) => Some(hex::encode(raw)), + _ => None, + }) } impl<'a> InvoiceIndex<'a> { pub fn build(invoices: &'a [Invoice]) -> Self { let mut by_address = HashMap::with_capacity(invoices.len()); + let mut legacy = Vec::new(); for inv in invoices { if let Some(ref addr) = inv.orchard_receiver_hex { - by_address.insert(addr.as_str(), inv); + by_address.insert((inv.merchant_id.as_str(), addr.as_str()), inv); + } else if let Some(receiver) = orchard_receiver(&inv.payment_address) { + legacy.push((inv, receiver)); } } - Self { - by_address, - invoices, - } + Self { by_address, legacy } } - /// O(1) address lookup, then linear memo fallback for legacy invoices. - /// Security invariant: address match wins unconditionally over memo. - pub fn find(&self, recipient_hex: &str, memo_text: &str) -> Option<&'a Invoice> { - if let Some(inv) = self.by_address.get(recipient_hex) { + pub fn find( + &self, + merchant_id: &str, + recipient_hex: &str, + memo_text: &str, + ) -> Option<&'a Invoice> { + if let Some(inv) = self.by_address.get(&(merchant_id, recipient_hex)) { return Some(inv); } - find_by_memo(self.invoices, memo_text) + // Legacy shared addresses require an exact memo, never substring matching. + let memo = memo_text.trim(); + self.legacy.iter().find_map(|(inv, receiver)| { + (inv.merchant_id == merchant_id + && receiver == recipient_hex + && !memo.is_empty() + && inv.memo_code == memo) + .then_some(*inv) + }) } } -/// Fallback matching: find a pending invoice whose memo_code matches the decrypted memo text. -/// Only used for old invoices created before diversified addresses were enabled. -fn find_by_memo<'a>(invoices: &'a [Invoice], memo_text: &str) -> Option<&'a Invoice> { - let memo_trimmed = memo_text.trim(); - if memo_trimmed.is_empty() { - return None; +#[cfg(test)] +mod tests { + use super::*; + fn invoice(merchant: &str, receiver: Option<&str>) -> Invoice { + serde_json::from_value(serde_json::json!({ + "id":"invoice", "merchant_id":merchant, "memo_code":"CP-12345678", + "price_eur":1.0,"price_zec":0.01,"zec_rate_at_creation":100.0, + "payment_address":"invalid", "zcash_uri":"", "status":"pending", + "expires_at":"", "created_at":"", "price_zatoshis":1000000, + "received_zatoshis":0,"is_donation":0,"campaign_counted":0 + })) + .map(|mut i: Invoice| { + i.orchard_receiver_hex = receiver.map(str::to_string); + i + }) + .unwrap() } - - if let Some(inv) = invoices.iter().find(|i| i.memo_code == memo_trimmed) { - return Some(inv); + #[test] + fn diversified_invoice_requires_merchant_and_receiver() { + let invoices = vec![invoice("merchant-a", Some("receiver-a"))]; + let index = InvoiceIndex::build(&invoices); + assert!(index + .find("merchant-b", "receiver-b", "CP-12345678") + .is_none()); + assert!(index + .find("merchant-a", "receiver-b", "CP-12345678") + .is_none()); + assert!(index + .find("merchant-b", "receiver-a", "CP-12345678") + .is_none()); + assert!(index + .find("merchant-a", "receiver-a", "unrelated") + .is_some()); + } + #[test] + fn legacy_requires_exact_memo_and_valid_receiver() { + let raw = [7u8; 43]; + let ua = + zcash_address::unified::Address::try_from_items(vec![Receiver::Orchard(raw)]).unwrap(); + let mut inv = invoice("a", None); + inv.payment_address = ua.encode(&zcash_protocol::consensus::NetworkType::Main); + let invoices = vec![inv]; + let index = InvoiceIndex::build(&invoices); + let receiver = hex::encode(raw); + assert!(index.find("a", &receiver, "CP-12345678").is_some()); + assert!(index.find("b", &receiver, "CP-12345678").is_none()); + assert!(index.find("a", "wrong", "CP-12345678").is_none()); + assert!(index.find("a", &receiver, "prefix CP-12345678").is_none()); } - - invoices - .iter() - .find(|i| memo_trimmed.contains(&i.memo_code)) } diff --git a/src/invoices/mod.rs b/src/invoices/mod.rs index c6a78f0..f55569c 100644 --- a/src/invoices/mod.rs +++ b/src/invoices/mod.rs @@ -218,15 +218,6 @@ pub async fn mark_detected( txid: &str, received_zatoshis: i64, ) -> anyhow::Result { - sqlx::query( - "INSERT OR IGNORE INTO invoice_payments (invoice_id, txid, zatoshis) VALUES (?, ?, ?)", - ) - .bind(invoice_id) - .bind(txid) - .bind(received_zatoshis) - .execute(pool) - .await?; - let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let new_expires = (Utc::now() + Duration::minutes(30)) .format("%Y-%m-%dT%H:%M:%SZ") @@ -332,15 +323,6 @@ pub async fn mark_underpaid( received_zatoshis: i64, txid: &str, ) -> anyhow::Result<()> { - sqlx::query( - "INSERT OR IGNORE INTO invoice_payments (invoice_id, txid, zatoshis) VALUES (?, ?, ?)", - ) - .bind(invoice_id) - .bind(txid) - .bind(received_zatoshis) - .execute(pool) - .await?; - let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let new_expires = (Utc::now() + Duration::minutes(10)) .format("%Y-%m-%dT%H:%M:%SZ") diff --git a/src/main.rs b/src/main.rs index 5c1ac61..4b5dd01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,8 @@ mod merchants; mod payment_links; mod prices; mod products; +#[cfg(test)] +mod repair_tests; mod scanner; mod sessions; mod subscriptions; @@ -35,6 +37,25 @@ async fn main() -> anyhow::Result<()> { ) .init(); + if std::env::args().any(|arg| arg == "--check-database") { + let database_url = std::env::var("DATABASE_URL")?; + let pool = db::create_pool(&database_url).await?; + let integrity: String = sqlx::query_scalar("PRAGMA integrity_check") + .fetch_one(&pool) + .await?; + anyhow::ensure!(integrity == "ok", "Database integrity check failed"); + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&pool) + .await?; + anyhow::ensure!( + violations.is_empty(), + "Database has {} foreign-key violations", + violations.len() + ); + println!("Database migrations, integrity and foreign keys: OK"); + return Ok(()); + } + let config = config::Config::from_env()?; config.validate()?; let pool = db::create_pool(&config.database_url).await?; @@ -121,16 +142,23 @@ async fn main() -> anyhow::Result<()> { let scanner_webhook_http = http_client.clone(); let scanner_prices = price_service.clone(); tokio::spawn(async move { - scanner::run(scanner_config, scanner_pool, scanner_client, scanner_webhook_http, scanner_prices).await; + scanner::run( + scanner_config, + scanner_pool, + scanner_client, + scanner_webhook_http, + scanner_prices, + ) + .await; }); let retry_pool = pool.clone(); let retry_http = http_client.clone(); let retry_enc_key = config.encryption_key.clone(); tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); loop { - interval.tick().await; + tokio::select! { _ = interval.tick() => {}, _ = webhooks::notified() => {} } let _ = webhooks::retry_failed(&retry_pool, &retry_http, &retry_enc_key).await; } }); diff --git a/src/merchants/mod.rs b/src/merchants/mod.rs index 0b2205e..6d05636 100644 --- a/src/merchants/mod.rs +++ b/src/merchants/mod.rs @@ -525,74 +525,39 @@ pub async fn has_outstanding_balance(pool: &SqlitePool, merchant_id: &str) -> an } pub async fn delete_merchant(pool: &SqlitePool, merchant_id: &str) -> anyhow::Result<()> { - // Disable FK enforcement during cascade delete — we manage the order explicitly, - // and stale FK schemas from past table-repair migrations can cause spurious errors. - let mut conn = pool.acquire().await?; - sqlx::query("PRAGMA foreign_keys = OFF") - .execute(&mut *conn) + let mut tx = pool.begin().await?; + // Resolve cycles at commit without ever disabling FK enforcement on a pooled connection. + sqlx::query("PRAGMA defer_foreign_keys = ON") + .execute(&mut *tx) .await?; - - sqlx::query("DELETE FROM sessions WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - let _ = sqlx::query("DELETE FROM agent_sessions WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await; - sqlx::query("DELETE FROM recovery_tokens WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM fee_ledger WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query( - "DELETE FROM webhook_deliveries WHERE invoice_id IN (SELECT id FROM invoices WHERE merchant_id = ?)" - ).bind(merchant_id).execute(&mut *conn).await?; - sqlx::query("DELETE FROM tickets WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - let _ = sqlx::query("DELETE FROM payment_links WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await; - sqlx::query("DELETE FROM billing_cycles WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM subscriptions WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM invoices WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM events WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query( + for sql in [ + "DELETE FROM session_charges WHERE session_id IN (SELECT id FROM agent_sessions WHERE merchant_id = ?)", + "DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE merchant_id = ?)", + "DELETE FROM campaign_donations WHERE link_id IN (SELECT id FROM payment_links WHERE merchant_id = ?)", + "DELETE FROM webhook_deliveries WHERE merchant_id = ?", + "DELETE FROM tickets WHERE merchant_id = ?", + "DELETE FROM fee_ledger WHERE merchant_id = ?", + "DELETE FROM billing_cycles WHERE merchant_id = ?", + "DELETE FROM email_events WHERE merchant_id = ?", + "DELETE FROM session_requests WHERE merchant_id = ?", + "DELETE FROM agent_sessions WHERE merchant_id = ?", + "DELETE FROM sessions WHERE merchant_id = ?", + "DELETE FROM recovery_tokens WHERE merchant_id = ?", + "DELETE FROM merchant_api_keys WHERE merchant_id = ?", + "DELETE FROM passkey_credentials WHERE merchant_id = ?", + "DELETE FROM passkey_challenges WHERE merchant_id = ?", + "DELETE FROM ledger_tokens WHERE merchant_id = ?", + "DELETE FROM x402_verifications WHERE merchant_id = ?", + "DELETE FROM x402_idempotency_v2 WHERE merchant_id = ?", + "DELETE FROM payment_links WHERE merchant_id = ?", + "DELETE FROM subscriptions WHERE merchant_id = ?", + "DELETE FROM invoices WHERE merchant_id = ?", + "DELETE FROM events WHERE merchant_id = ?", "DELETE FROM prices WHERE product_id IN (SELECT id FROM products WHERE merchant_id = ?)", - ) - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM products WHERE merchant_id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - sqlx::query("DELETE FROM merchants WHERE id = ?") - .bind(merchant_id) - .execute(&mut *conn) - .await?; - - sqlx::query("PRAGMA foreign_keys = ON") - .execute(&mut *conn) - .await?; + "DELETE FROM products WHERE merchant_id = ?", + "DELETE FROM merchants WHERE id = ?", + ] { sqlx::query(sql).bind(merchant_id).execute(&mut *tx).await?; } + tx.commit().await?; tracing::info!(merchant_id, "Merchant account deleted"); Ok(()) diff --git a/src/repair_tests.rs b/src/repair_tests.rs new file mode 100644 index 0000000..7f46a56 --- /dev/null +++ b/src/repair_tests.rs @@ -0,0 +1,212 @@ +use sqlx::SqlitePool; + +pub async fn pool() -> SqlitePool { + crate::db::create_pool("sqlite::memory:").await.unwrap() +} +pub async fn merchant(pool: &SqlitePool, id: &str) { + sqlx::query("INSERT INTO merchants (id, api_key_hash, ufvk, payment_address, webhook_url) VALUES (?, ?, ?, 'test-address', 'https://example.com/webhook')") + .bind(id).bind(format!("hash-{id}")).bind(format!("key-{id}")).execute(pool).await.unwrap(); +} +pub async fn invoice(pool: &SqlitePool, id: &str, merchant: &str) { + sqlx::query("INSERT INTO invoices (id, merchant_id, memo_code, price_eur, price_zec, zec_rate_at_creation, price_zatoshis, expires_at) VALUES (?, ?, ?, 1, 0.00001, 100, 1000, '2099-01-01T00:00:00Z')") + .bind(id).bind(merchant).bind(format!("CP-{id}")).execute(pool).await.unwrap(); +} +#[tokio::test] +async fn session_spending_is_tenant_price_and_request_bound() { + let pool = pool().await; + merchant(&pool, "a").await; + merchant(&pool, "b").await; + let s = crate::sessions::create_session(&pool, "a", "deposit", 10_000, None) + .await + .unwrap(); + assert!( + crate::sessions::charge(&pool, "b", &s.bearer_token, 5000, "/expensive", "r1") + .await + .unwrap() + .is_none() + ); + assert!( + crate::sessions::charge(&pool, "a", &s.bearer_token, 20_000, "/expensive", "r1") + .await + .unwrap() + .is_none() + ); + let paid = crate::sessions::charge(&pool, "a", &s.bearer_token, 6000, "/expensive", "r1") + .await + .unwrap() + .unwrap(); + assert_eq!(paid.balance_remaining, 4000); + assert!( + crate::sessions::charge(&pool, "a", &s.bearer_token, 1000, "/cheap", "r1") + .await + .unwrap() + .is_none() + ); + assert_eq!( + crate::sessions::get_session(&pool, &s.id) + .await + .unwrap() + .unwrap() + .balance_remaining, + 4000 + ); + let (a, b) = tokio::join!( + crate::sessions::charge(&pool, "a", &s.bearer_token, 3000, "/x", "r2"), + crate::sessions::charge(&pool, "a", &s.bearer_token, 3000, "/x", "r3") + ); + assert_eq!(a.unwrap().is_some() as u8 + b.unwrap().is_some() as u8, 1); +} +#[tokio::test] +async fn consumed_deposit_survives_session_and_account_deletion() { + let pool = pool().await; + merchant(&pool, "a").await; + let s = crate::sessions::create_session(&pool, "a", "ABCDEF", 10_000, None) + .await + .unwrap(); + sqlx::query("DELETE FROM agent_sessions WHERE id = ?") + .bind(&s.id) + .execute(&pool) + .await + .unwrap(); + assert!( + crate::sessions::create_session(&pool, "a", "abcdef", 10_000, None) + .await + .is_err() + ); + crate::merchants::delete_merchant(&pool, "a").await.unwrap(); + assert!(crate::sessions::txid_already_used(&pool, "ABCDEF").await); + let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(fk, 1); +} +#[tokio::test] +async fn payment_ledger_and_outbox_are_idempotent_and_atomic() { + let pool = pool().await; + merchant(&pool, "a").await; + invoice(&pool, "one", "a").await; + assert_eq!( + crate::invoices::record_payment(&pool, "one", "part1", 400) + .await + .unwrap(), + 400 + ); + crate::invoices::mark_underpaid(&pool, "one", 400, "part1") + .await + .unwrap(); + assert_eq!( + crate::invoices::record_payment(&pool, "one", "part1", 400) + .await + .unwrap(), + 400 + ); + assert_eq!( + crate::invoices::record_payment(&pool, "one", "part2", 600) + .await + .unwrap(), + 1000 + ); + assert!(crate::invoices::mark_detected(&pool, "one", "part2", 1000) + .await + .unwrap()); + assert!(!crate::invoices::mark_detected(&pool, "one", "part2", 1000) + .await + .unwrap()); + let contributions: Vec = + sqlx::query_scalar("SELECT zatoshis FROM invoice_payments ORDER BY zatoshis") + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(contributions, vec![400, 600]); + let mut tx = pool.begin().await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='one'") + .execute(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM webhook_deliveries WHERE event_type='confirmed'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + assert!(crate::invoices::mark_confirmed(&pool, "one", None, None) + .await + .unwrap()); + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM webhook_deliveries WHERE event_type='confirmed' AND attempts=0", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} +#[tokio::test] +async fn account_deletion_rolls_back_on_failure() { + let pool = pool().await; + merchant(&pool, "a").await; + invoice(&pool, "one", "a").await; + crate::invoices::record_payment(&pool, "one", "tx", 1000) + .await + .unwrap(); + sqlx::query("CREATE TRIGGER deny_delete BEFORE DELETE ON merchants BEGIN SELECT RAISE(ABORT,'injected failure'); END").execute(&pool).await.unwrap(); + assert!(crate::merchants::delete_merchant(&pool, "a").await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM invoice_payments") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); + sqlx::query("DROP TRIGGER deny_delete") + .execute(&pool) + .await + .unwrap(); + crate::merchants::delete_merchant(&pool, "a").await.unwrap(); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM invoices") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); +} +#[tokio::test] +async fn abandoned_attendee_details_are_purged() { + let pool = pool().await; + merchant(&pool, "a").await; + invoice(&pool, "one", "a").await; + sqlx::query("UPDATE invoices SET status='expired', created_at='2020-01-01T00:00:00Z', attendee_name='encrypted',attendee_email='encrypted' WHERE id='one'").execute(&pool).await.unwrap(); + crate::db::run_data_purge(&pool, 30).await.unwrap(); + let pii: Option = + sqlx::query_scalar("SELECT attendee_email FROM invoices WHERE id='one'") + .fetch_one(&pool) + .await + .unwrap(); + assert!(pii.is_none()); +} + +#[tokio::test] +async fn prepared_session_consumption_rolls_back_and_cannot_be_reused() { + let pool = pool().await; + merchant(&pool, "a").await; + sqlx::query("INSERT INTO session_requests (id,merchant_id,deposit_address,diversifier_index,status,expires_at) VALUES ('request','a','address',1,'pending','2099-01-01T00:00:00Z')").execute(&pool).await.unwrap(); + assert!( + crate::sessions::create_prepared_session(&pool, "a", "request", "tx", 1, None) + .await + .is_err() + ); + let status: String = + sqlx::query_scalar("SELECT status FROM session_requests WHERE id='request'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "pending"); + assert!(!crate::sessions::txid_already_used(&pool, "tx").await); + crate::sessions::create_prepared_session(&pool, "a", "request", "tx", 10000, None) + .await + .unwrap(); + assert!( + crate::sessions::create_prepared_session(&pool, "a", "request", "other", 10000, None) + .await + .is_err() + ); + assert!(!crate::sessions::txid_already_used(&pool, "other").await); +} diff --git a/src/scanner/blocks.rs b/src/scanner/blocks.rs index 4eb8f25..c46741b 100644 --- a/src/scanner/blocks.rs +++ b/src/scanner/blocks.rs @@ -71,9 +71,10 @@ pub async fn get_chain_height(http: &reqwest::Client, api_url: &str) -> anyhow:: status.as_u16() )); } - let resp: BlockchainInfoResponse = response.json().await.map_err(|e| { - anyhow::anyhow!("Failed to parse blockchain-info JSON: {e}") - })?; + let resp: BlockchainInfoResponse = response + .json() + .await + .map_err(|e| anyhow::anyhow!("Failed to parse blockchain-info JSON: {e}"))?; resp.blocks .or(resp.headers) @@ -104,18 +105,23 @@ async fn fetch_single_block_txids( if !status.is_success() { last_err = Some(anyhow::anyhow!( "CipherScan API returned HTTP {} for block {}", - status.as_u16(), height + status.as_u16(), + height )); continue; } match r.json::().await { Ok(v) => v, Err(e) => { - last_err = Some(anyhow::anyhow!("Failed to parse block {} response: {}", height, e)); + last_err = Some(anyhow::anyhow!( + "Failed to parse block {} response: {}", + height, + e + )); continue; } } - }, + } Err(e) => { last_err = Some(anyhow::anyhow!("Failed to fetch block {}: {}", height, e)); continue; @@ -220,21 +226,45 @@ pub async fn check_tx_confirmed( if !status.is_success() { return Err(anyhow::anyhow!( "CipherScan API returned HTTP {} for tx {}", - status.as_u16(), txid + status.as_u16(), + txid )); } let resp: serde_json::Value = response.json().await?; - let confirmed = resp["block_height"].as_u64().is_some() - || resp["blockHeight"].as_u64().is_some() - || resp["confirmations"].as_u64().map_or(false, |c| c >= 1); + let confirmed = transaction_confirmed(&resp); Ok(confirmed) } +fn transaction_confirmed(resp: &serde_json::Value) -> bool { + if let Some(confirmations) = resp.get("confirmations") { + return confirmations.as_u64().is_some_and(|c| c >= 1); + } + resp["block_height"] + .as_u64() + .or_else(|| resp["blockHeight"].as_u64()) + .is_some_and(|h| h > 0) +} + #[cfg(test)] mod tests { - use super::extract_block_txids; + use super::{extract_block_txids, transaction_confirmed}; + #[test] + fn reorged_and_unconfirmed_transactions_do_not_count() { + assert!(!transaction_confirmed( + &serde_json::json!({"block_height":100,"confirmations":0}) + )); + assert!(!transaction_confirmed( + &serde_json::json!({"block_height":100,"confirmations":-1}) + )); + assert!(!transaction_confirmed( + &serde_json::json!({"block_height":0}) + )); + assert!(transaction_confirmed( + &serde_json::json!({"block_height":100,"confirmations":2}) + )); + } #[test] fn extracts_txids_from_transactions_shape() { diff --git a/src/scanner/decrypt.rs b/src/scanner/decrypt.rs index 21f3bb8..4381f72 100644 --- a/src/scanner/decrypt.rs +++ b/src/scanner/decrypt.rs @@ -112,6 +112,13 @@ pub fn derive_uivk_from_ufvk(ufvk_str: &str) -> Result { } /// Trial-decrypt all Orchard and Ironwood outputs using pre-computed keys (fast path). +pub fn validate_raw_transaction(raw_hex: &str) -> Result<()> { + let bytes = hex::decode(raw_hex)?; + anyhow::ensure!(bytes.len() >= 4, "Truncated raw transaction"); + Transaction::read(&mut Cursor::new(&bytes[..]), BranchId::Nu6_3)?; + Ok(()) +} + pub fn try_decrypt_with_keys(raw_hex: &str, keys: &CachedKeys) -> Result> { let tx_bytes = hex::decode(raw_hex)?; if tx_bytes.len() < 4 { diff --git a/src/scanner/invoice_detection.rs b/src/scanner/invoice_detection.rs index f44996c..cc888f2 100644 --- a/src/scanner/invoice_detection.rs +++ b/src/scanner/invoice_detection.rs @@ -22,11 +22,12 @@ pub(super) fn collect_mempool_invoice_totals( invoice_index: &matching::InvoiceIndex<'_>, campaign_addresses: &[crate::payment_links::CampaignAddress], source: MempoolSource, -) -> (InvoiceTotals, CampaignTotals) { +) -> anyhow::Result<(InvoiceTotals, CampaignTotals)> { + super::decrypt::validate_raw_transaction(raw_hex)?; let mut invoice_totals = HashMap::new(); let mut campaign_totals: CampaignTotals = HashMap::new(); - for (_merchant_id, keys) in cached_keys { + for (merchant_id, keys) in cached_keys { match super::decrypt::try_decrypt_with_keys(raw_hex, keys) { Ok(outputs) => { for output in &outputs { @@ -37,19 +38,17 @@ pub(super) fn collect_mempool_invoice_totals( tracing::debug!(txid = %txid, "[WS] Decrypted mempool output"); } } - tracing::debug!( - txid, - memo = %output.memo, - amount = output.amount_zec, - "Decrypted output details" - ); - - if let Some(invoice) = invoice_index.find(&recipient_hex, &output.memo) { + if let Some(invoice) = + invoice_index.find(merchant_id, &recipient_hex, &output.memo) + { let entry = invoice_totals .entry(invoice.id.clone()) .or_insert((invoice.clone(), 0)); entry.1 += output.amount_zatoshis as i64; - } else if let Some(campaign) = campaign_addresses.iter().find(|c| c.campaign_address_hex == recipient_hex) { + } else if let Some(campaign) = campaign_addresses + .iter() + .find(|c| c.campaign_address_hex == recipient_hex) + { let entry = campaign_totals .entry(campaign.link_id.clone()) .or_insert((campaign.clone(), 0)); @@ -61,7 +60,7 @@ pub(super) fn collect_mempool_invoice_totals( } } - (invoice_totals, campaign_totals) + Ok((invoice_totals, campaign_totals)) } /// Returns list of invoice IDs that were newly marked as detected (for fee scanning). @@ -84,11 +83,7 @@ pub(super) async fn apply_mempool_invoice_totals( continue; } - let new_received = if invoice.status == "underpaid" { - invoices::record_payment(pool, invoice_id, txid, *tx_total).await? - } else { - *tx_total - }; + let new_received = invoices::record_payment(pool, invoice_id, txid, *tx_total).await?; let min = (invoice.price_zatoshis as f64 * super::decrypt::SLIPPAGE_TOLERANCE) as i64; @@ -160,8 +155,15 @@ pub(super) async fn apply_campaign_totals( } }; match crate::payment_links::record_campaign_donation( - pool, link_id, txid, *zat_total, cents, &campaign.currency, - ).await { + pool, + link_id, + txid, + *zat_total, + cents, + &campaign.currency, + ) + .await + { Ok(true) => { tracing::info!( link_id, diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 4927bbf..d4c6eec 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -29,7 +29,13 @@ struct KeyCache { merchant_ids: Vec, } -pub async fn run(config: Config, pool: SqlitePool, scanner_http: reqwest::Client, webhook_http: reqwest::Client, price_service: PriceService) { +pub async fn run( + config: Config, + pool: SqlitePool, + scanner_http: reqwest::Client, + webhook_http: reqwest::Client, + price_service: PriceService, +) { // scanner_http: internal client for CipherScan API calls (carries X-Service-Key) // webhook_http: outbound client for merchant webhooks (no service keys, no redirects) let http = scanner_http; @@ -106,15 +112,13 @@ pub async fn run(config: Config, pool: SqlitePool, scanner_http: reqwest::Client } => { match result { Some(push) => { - { - let mut seen_set = mempool_seen.write().await; - seen_set.insert(push.txid.clone(), Instant::now()); - } if let Err(e) = process_ws_mempool_tx( &mempool_config, &mempool_pool, &mempool_http, &mempool_webhook_http, &push, &mut key_cache, &mempool_price_service, ).await { tracing::error!(error = %e, txid = %push.txid, "WS mempool tx error"); + } else { + mempool_seen.write().await.insert(push.txid.clone(), Instant::now()); } } None => { @@ -187,7 +191,8 @@ pub async fn run(config: Config, pool: SqlitePool, scanner_http: reqwest::Client { Ok(_) => { block_cb.record_success(); - metrics::global().set_last_block_scan_ms(block_start.elapsed().as_millis() as u64); + metrics::global() + .set_last_block_scan_ms(block_start.elapsed().as_millis() as u64); } Err(e) => { block_cb.record_failure(); @@ -276,41 +281,20 @@ fn refresh_key_cache<'a>( &cache.as_ref().unwrap().keys } -/// Fire a payment webhook without blocking the scan loop. +/// State transitions persist the webhook in the same database transaction. +/// Wake the worker only after the transition has committed. fn spawn_payment_webhook( - pool: &SqlitePool, - webhook_http: &reqwest::Client, - invoice_id: &str, - event: &str, - txid: &str, - price_zatoshis: i64, - received_zatoshis: i64, - overpaid: bool, - encryption_key: &str, + _pool: &SqlitePool, + _http: &reqwest::Client, + _invoice_id: &str, + _event: &str, + _txid: &str, + _price: i64, + _received: i64, + _overpaid: bool, + _key: &str, ) { - let pool = pool.clone(); - let http = webhook_http.clone(); - let invoice_id = invoice_id.to_string(); - let event = event.to_string(); - let txid = txid.to_string(); - let enc_key = encryption_key.to_string(); - tokio::spawn(async move { - if let Err(e) = webhooks::dispatch_payment( - &pool, - &http, - &invoice_id, - &event, - &txid, - price_zatoshis, - received_zatoshis, - overpaid, - &enc_key, - ) - .await - { - tracing::error!(invoice_id, event, error = %e, "Async payment webhook failed"); - } - }); + crate::webhooks::wake(); } async fn scan_mempool( @@ -352,14 +336,6 @@ async fn scan_mempool( metrics::global().record_mempool_txs(new_txids.len() as u64); tracing::debug!(count = new_txids.len(), "New mempool transactions"); - { - let mut seen_set = seen.write().await; - let now = Instant::now(); - for txid in &new_txids { - seen_set.insert(txid.clone(), now); - } - } - let raw_txs = mempool::fetch_raw_txs_batch(http, &config.cipherscan_api_url, &new_txids).await; tracing::debug!( fetched = raw_txs.len(), @@ -381,14 +357,20 @@ async fn scan_mempool( &invoice_index, &campaign_addresses, invoice_detection::MempoolSource::Polling, - ); - let detected = - invoice_detection::apply_mempool_invoice_totals(pool, webhook_http, config, txid, &invoice_totals) - .await?; + )?; + let detected = invoice_detection::apply_mempool_invoice_totals( + pool, + webhook_http, + config, + txid, + &invoice_totals, + ) + .await?; for invoice_id in &detected { try_detect_fee(pool, config, raw_hex, invoice_id).await; } invoice_detection::apply_campaign_totals(pool, txid, &campaign_totals, price_service).await; + seen.write().await.insert(txid.clone(), Instant::now()); } Ok(()) @@ -430,7 +412,7 @@ async fn process_ws_mempool_tx( &invoice_index, &campaign_addresses, invoice_detection::MempoolSource::WebSocket, - ); + )?; let detected = invoice_detection::apply_mempool_invoice_totals( pool, webhook_http, @@ -442,7 +424,8 @@ async fn process_ws_mempool_tx( for invoice_id in &detected { try_detect_fee(pool, config, &push.raw_hex, invoice_id).await; } - invoice_detection::apply_campaign_totals(pool, &push.txid, &campaign_totals, price_service).await; + invoice_detection::apply_campaign_totals(pool, &push.txid, &campaign_totals, price_service) + .await; Ok(()) } @@ -488,6 +471,36 @@ fn earliest_relevant_timestamp( } } +/// Recheck canonical confirmation of every contribution on every attempt. +/// A missing, dropped or reorged contribution never counts toward fulfillment. +async fn confirmed_payment_total( + pool: &SqlitePool, + http: &reqwest::Client, + api_url: &str, + invoice_id: &str, +) -> anyhow::Result { + let payments = sqlx::query_as::<_, (String, i64)>( + "SELECT txid, zatoshis FROM invoice_payments WHERE invoice_id = ?", + ) + .bind(invoice_id) + .fetch_all(pool) + .await?; + let mut total = 0i64; + for (txid, amount) in payments { + if blocks::check_tx_confirmed(http, api_url, &txid).await? { + total = total + .checked_add(amount) + .ok_or_else(|| anyhow::anyhow!("Payment total overflow"))?; + } + } + sqlx::query("UPDATE invoices SET received_zatoshis = ? WHERE id = ? AND status = 'detected'") + .bind(total) + .bind(invoice_id) + .execute(pool) + .await?; + Ok(total) +} + async fn scan_blocks( config: &Config, pool: &SqlitePool, @@ -509,10 +522,21 @@ async fn scan_blocks( .collect(); for invoice in &detected { if let Some(txid) = &invoice.detected_txid { - match blocks::check_tx_confirmed(http, &config.cipherscan_api_url, txid).await { - Ok(true) => { + match confirmed_payment_total(pool, http, &config.cipherscan_api_url, &invoice.id) + .await + { + Ok(total) + if total + >= (invoice.price_zatoshis as f64 * decrypt::SLIPPAGE_TOLERANCE) + as i64 => + { + let mut invoice = invoice.clone(); + invoice.received_zatoshis = total; + let invoice = &invoice; let (conf_rate, conf_fiat) = confirmed_fiat(price_service, invoice).await; - let changed = invoices::mark_confirmed(pool, &invoice.id, conf_rate, conf_fiat).await?; + let changed = + invoices::mark_confirmed(pool, &invoice.id, conf_rate, conf_fiat) + .await?; if changed { let overpaid = invoice.received_zatoshis > invoice.price_zatoshis + 1000; @@ -531,7 +555,9 @@ async fn scan_blocks( // Scan for ZIP 321 fee output that mempool path may have missed if config.fee_enabled() { - match mempool::fetch_raw_tx(http, &config.cipherscan_api_url, txid).await { + match mempool::fetch_raw_tx(http, &config.cipherscan_api_url, txid) + .await + { Ok(raw_hex) => { try_detect_fee(pool, config, &raw_hex, &invoice.id).await; } @@ -542,7 +568,7 @@ async fn scan_blocks( } } } - Ok(false) => {} + Ok(_) => {} Err(e) => tracing::debug!(txid, error = %e, "Confirmation check failed"), } } @@ -579,7 +605,9 @@ async fn scan_blocks( } *last_height.write().await = Some(current_height); metrics::global().set_last_block_height(current_height); - if let Err(e) = crate::db::set_scanner_state(pool, "last_height", ¤t_height.to_string()).await { + if let Err(e) = + crate::db::set_scanner_state(pool, "last_height", ¤t_height.to_string()).await + { tracing::warn!(error = %e, "Failed to persist last_height"); } return Ok(()); @@ -593,7 +621,9 @@ async fn scan_blocks( if let Some(ts) = oldest_ts { let now = chrono::Utc::now(); if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&ts) { - let secs_ago = (now - created.with_timezone(&chrono::Utc)).num_seconds().max(0) as u64; + let secs_ago = (now - created.with_timezone(&chrono::Utc)) + .num_seconds() + .max(0) as u64; let blocks_ago = secs_ago / 75 + 100; let earliest_block = current_height.saturating_sub(blocks_ago); if earliest_block > persisted_start { @@ -647,31 +677,30 @@ async fn scan_blocks( let campaign_addresses = active_campaigns; for txid in &block_txids { - if seen.read().await.contains_key(txid) { - continue; - } - - let raw_hex = match mempool::fetch_raw_tx(http, &config.cipherscan_api_url, txid).await - { - Ok(hex) => hex, - Err(_) => continue, - }; + // Block processing is independent of the mempool cache. On failure, + // leave the checkpoint unchanged so the entire batch can be retried. + let raw_hex = mempool::fetch_raw_tx(http, &config.cipherscan_api_url, txid).await?; + decrypt::validate_raw_transaction(&raw_hex)?; let mut invoice_totals: HashMap = HashMap::new(); - let mut campaign_totals: HashMap = HashMap::new(); + let mut campaign_totals: HashMap = + HashMap::new(); - for (_merchant_id, keys) in cached_keys.iter() { + for (merchant_id, keys) in cached_keys.iter() { if let Ok(outputs) = decrypt::try_decrypt_with_keys(&raw_hex, keys) { for output in &outputs { let recipient_hex = hex::encode(output.recipient_raw); if let Some(invoice) = - block_invoice_index.find(&recipient_hex, &output.memo) + block_invoice_index.find(merchant_id, &recipient_hex, &output.memo) { let entry = invoice_totals .entry(invoice.id.clone()) .or_insert((invoice.clone(), 0)); entry.1 += output.amount_zatoshis as i64; - } else if let Some(campaign) = campaign_addresses.iter().find(|c| c.campaign_address_hex == recipient_hex) { + } else if let Some(campaign) = campaign_addresses + .iter() + .find(|c| c.campaign_address_hex == recipient_hex) + { let entry = campaign_totals .entry(campaign.link_id.clone()) .or_insert((campaign.clone(), 0)); @@ -696,11 +725,8 @@ async fn scan_blocks( continue; } - let new_received = if invoice.status == "underpaid" { - invoices::record_payment(pool, invoice_id, txid, *tx_total).await? - } else { - *tx_total - }; + let new_received = + invoices::record_payment(pool, invoice_id, txid, *tx_total).await?; let min = (invoice.price_zatoshis as f64 * decrypt::SLIPPAGE_TOLERANCE) as i64; @@ -711,23 +737,8 @@ async fn scan_blocks( invoices::mark_detected(pool, invoice_id, txid, new_received).await?; if detected { metrics::global().record_payment_detected(); - let (conf_rate, conf_fiat) = confirmed_fiat(price_service, invoice).await; - let confirmed = invoices::mark_confirmed(pool, invoice_id, conf_rate, conf_fiat).await?; - if confirmed { - let overpaid = new_received > invoice.price_zatoshis + 1000; - spawn_payment_webhook( - pool, - webhook_http, - invoice_id, - "confirmed", - txid, - invoice.price_zatoshis, - new_received, - overpaid, - &config.encryption_key, - ); - on_invoice_confirmed(pool, webhook_http, config, invoice).await; - } + // Confirmation is performed on the next scan after checking + // every contributing transaction, including mempool top-ups. try_detect_fee(pool, config, &raw_hex, invoice_id).await; } } else if new_received < min && invoice.status == "pending" { @@ -746,7 +757,8 @@ async fn scan_blocks( } } - invoice_detection::apply_campaign_totals(pool, txid, &campaign_totals, price_service).await; + invoice_detection::apply_campaign_totals(pool, txid, &campaign_totals, price_service) + .await; seen.write().await.insert(txid.clone(), Instant::now()); } @@ -795,8 +807,13 @@ async fn on_invoice_confirmed( if let Ok(r) = marked { if r.rows_affected() > 0 { - if let Err(e) = - crate::payment_links::increment_raised(pool, link_id, amount_cents, invoice.received_zatoshis).await + if let Err(e) = crate::payment_links::increment_raised( + pool, + link_id, + amount_cents, + invoice.received_zatoshis, + ) + .await { tracing::error!(invoice_id = %invoice.id, error = %e, "Failed to increment campaign total_raised"); } @@ -1131,7 +1148,9 @@ async fn handle_luma_registration( } // Call Luma get_guest to retrieve check-in QR and full guest record - let guest_data = match crate::luma::get_guest(outbound_http, &api_key, luma_event_id, &email).await { + let guest_data = match crate::luma::get_guest(outbound_http, &api_key, luma_event_id, &email) + .await + { Ok(Some(g)) => serde_json::to_string(&g).unwrap_or_else(|_| "{}".into()), Ok(None) => { tracing::warn!(invoice_id = %invoice.id, "Luma get_guest returned empty after add"); @@ -1343,3 +1362,52 @@ async fn try_detect_fee(pool: &SqlitePool, config: &Config, raw_hex: &str, invoi } } } + +#[cfg(test)] +mod payment_confirmation_tests { + use super::*; + use std::io::{Read, Write}; + #[tokio::test] + async fn unconfirmed_partial_payment_never_counts_toward_fulfillment() { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "a").await; + crate::repair_tests::invoice(&pool, "one", "a").await; + invoices::record_payment(&pool, "one", "first", 400) + .await + .unwrap(); + invoices::record_payment(&pool, "one", "second", 600) + .await + .unwrap(); + invoices::mark_detected(&pool, "one", "second", 1000) + .await + .unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + for mut stream in listener.incoming().take(2).map(Result::unwrap) { + let mut buf = [0u8; 2048]; + let n = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..n]); + let body = if request.contains("/first ") { + "{\"confirmations\":0,\"block_height\":100}" + } else { + "{\"confirmations\":2}" + }; + write!(stream,"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",body.len(),body).unwrap(); + } + }); + let total = confirmed_payment_total(&pool, &reqwest::Client::new(), &url, "one") + .await + .unwrap(); + assert_eq!(total, 600); + assert_eq!( + invoices::get_invoice(&pool, "one") + .await + .unwrap() + .unwrap() + .status, + "detected" + ); + server.join().unwrap(); + } +} diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index ec69b53..076b270 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -52,7 +52,15 @@ pub async fn create_session( balance_zatoshis: i64, refund_address: Option<&str>, ) -> Result { - create_session_with_cost(pool, merchant_id, deposit_txid, balance_zatoshis, refund_address, None).await + create_session_with_cost( + pool, + merchant_id, + deposit_txid, + balance_zatoshis, + refund_address, + None, + ) + .await } pub async fn create_session_with_cost( @@ -63,48 +71,95 @@ pub async fn create_session_with_cost( refund_address: Option<&str>, cost_per_request: Option, ) -> Result { - let id = uuid::Uuid::new_v4().to_string(); - let bearer_token = generate_token(); - let cost = cost_per_request.unwrap_or(DEFAULT_COST_PER_REQUEST); - - match sqlx::query( - "INSERT INTO agent_sessions (id, merchant_id, deposit_txid, bearer_token, balance_zatoshis, balance_remaining, cost_per_request, requests_made, refund_address, status, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'active', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+' || ? || ' hours'))" - ) - .bind(&id) - .bind(merchant_id) - .bind(deposit_txid) - .bind(&bearer_token) - .bind(balance_zatoshis) - .bind(balance_zatoshis) - .bind(cost) - .bind(refund_address) - .bind(SESSION_EXPIRY_HOURS) - .execute(pool) - .await { - Ok(_) => {} - Err(sqlx::Error::Database(ref db_err)) if db_err.message().contains("UNIQUE constraint") => { - anyhow::bail!("This transaction has already been used to open a session"); - } - Err(e) => return Err(e.into()), + let mut tx = pool.begin().await?; + if !consume_payment(&mut tx, deposit_txid, "session").await? { + anyhow::bail!("This transaction has already been consumed"); } - - let session = get_session(pool, &id) - .await? - .ok_or_else(|| anyhow::anyhow!("Session creation failed"))?; - - tracing::info!( - session_id = %id, + let session = insert_session( + &mut tx, merchant_id, deposit_txid, - balance = balance_zatoshis, - cost_per_request = cost, - "Session created" - ); + balance_zatoshis, + refund_address, + cost_per_request, + ) + .await?; + tx.commit().await?; + Ok(session) +} +/// Consume the prepared address and its deposit in the same transaction. +pub async fn create_prepared_session( + pool: &SqlitePool, + merchant_id: &str, + request_id: &str, + deposit_txid: &str, + balance: i64, + refund_address: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + let used = sqlx::query("UPDATE session_requests SET status = 'used' WHERE id = ? AND merchant_id = ? AND status = 'pending' AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')") + .bind(request_id).bind(merchant_id).execute(&mut *tx).await?; + anyhow::ensure!( + used.rows_affected() == 1, + "Session request expired or already used" + ); + anyhow::ensure!( + consume_payment(&mut tx, deposit_txid, "session").await?, + "Payment already consumed" + ); + let session = insert_session( + &mut tx, + merchant_id, + deposit_txid, + balance, + refund_address, + None, + ) + .await?; + tx.commit().await?; Ok(session) } +/// Durable replay tombstone; deliberately contains no merchant or bearer credential. +pub async fn consume_payment( + conn: &mut sqlx::SqliteConnection, + txid: &str, + purpose: &str, +) -> Result { + let result = + sqlx::query("INSERT OR IGNORE INTO payment_consumptions (txid, purpose) VALUES (?, ?)") + .bind(txid.to_ascii_lowercase()) + .bind(purpose) + .execute(conn) + .await?; + Ok(result.rows_affected() == 1) +} + +pub async fn insert_session( + conn: &mut sqlx::SqliteConnection, + merchant_id: &str, + deposit_txid: &str, + balance: i64, + refund_address: Option<&str>, + cost: Option, +) -> Result { + let cost = cost.unwrap_or(DEFAULT_COST_PER_REQUEST); + anyhow::ensure!( + cost > 0 && balance >= cost, + "Invalid session balance or price" + ); + let id = uuid::Uuid::new_v4().to_string(); + let token = generate_token(); + sqlx::query("INSERT INTO agent_sessions (id, merchant_id, deposit_txid, bearer_token, balance_zatoshis, balance_remaining, cost_per_request, requests_made, refund_address, status, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'active', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+' || ? || ' hours'))") + .bind(&id).bind(merchant_id).bind(deposit_txid.to_ascii_lowercase()).bind(&token) + .bind(balance).bind(balance).bind(cost).bind(refund_address).bind(SESSION_EXPIRY_HOURS).execute(&mut *conn).await?; + let row = sqlx::query_as::<_, SessionRow>("SELECT id, merchant_id, deposit_txid, bearer_token, balance_zatoshis, balance_remaining, cost_per_request, requests_made, refund_address, status, expires_at, created_at, closed_at FROM agent_sessions WHERE id = ?") + .bind(id).fetch_one(&mut *conn).await?; + Ok(session_from_row(row)) +} + pub async fn get_session(pool: &SqlitePool, session_id: &str) -> Result> { let row = sqlx::query_as::<_, SessionRow>( "SELECT id, merchant_id, deposit_txid, bearer_token, balance_zatoshis, balance_remaining, cost_per_request, requests_made, refund_address, status, expires_at, created_at, closed_at @@ -132,73 +187,41 @@ pub async fn get_session_by_token( Ok(row.map(session_from_row)) } -pub async fn validate_and_deduct(pool: &SqlitePool, bearer_token: &str) -> Result> { - // Atomic deduction: single UPDATE with WHERE guards prevents race conditions. - // If balance < cost or session is expired/inactive, rows_affected == 0. - let result = sqlx::query( - "UPDATE agent_sessions SET - balance_remaining = balance_remaining - cost_per_request, - requests_made = requests_made + 1 - WHERE bearer_token = ? - AND status = 'active' - AND balance_remaining >= cost_per_request - AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", - ) - .bind(bearer_token) - .execute(pool) - .await?; - - if result.rows_affected() == 0 { - // Mark depleted/expired sessions so they don't linger - sqlx::query( - "UPDATE agent_sessions SET status = CASE - WHEN expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now') THEN 'expired' - WHEN balance_remaining < cost_per_request THEN 'depleted' - ELSE status END - WHERE bearer_token = ? AND status = 'active'", - ) - .bind(bearer_token) - .execute(pool) - .await - .ok(); - +/// A merchant may charge only its own session, at the requested integer price. +/// Reusing a request ID never authorizes another fulfillment or another debit. +pub async fn charge( + pool: &SqlitePool, + merchant_id: &str, + token: &str, + amount: i64, + resource: &str, + request_id: &str, +) -> Result> { + anyhow::ensure!( + amount > 0 + && !resource.is_empty() + && resource.len() <= 2048 + && !request_id.is_empty() + && request_id.len() <= 128, + "Invalid charge" + ); + let mut tx = pool.begin().await?; + let id: Option = sqlx::query_scalar("UPDATE agent_sessions SET balance_remaining = balance_remaining - ?, requests_made = requests_made + 1 + WHERE bearer_token = ? AND merchant_id = ? AND status = 'active' + AND balance_remaining >= ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now') RETURNING id") + .bind(amount).bind(token).bind(merchant_id).bind(amount).fetch_optional(&mut *tx).await?; + let Some(id) = id else { return Ok(None); - } - - // Read back the updated session state - let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option)>( - "SELECT id, merchant_id, balance_remaining, cost_per_request, requests_made, refund_address - FROM agent_sessions WHERE bearer_token = ?", - ) - .bind(bearer_token) - .fetch_optional(pool) - .await?; - - match row { - Some(( - id, - merchant_id, - balance_remaining, - cost_per_request, - requests_made, - refund_address, - )) => Ok(Some(Session { - id, - merchant_id, - deposit_txid: String::new(), - bearer_token: bearer_token.to_string(), - balance_zatoshis: 0, - balance_remaining, - cost_per_request, - requests_made, - refund_address, - status: "active".to_string(), - expires_at: String::new(), - created_at: String::new(), - closed_at: None, - })), - None => Ok(None), - } + }; + let inserted = sqlx::query("INSERT OR IGNORE INTO session_charges (session_id, request_id, resource, amount) VALUES (?, ?, ?, ?)") + .bind(&id).bind(request_id).bind(resource).bind(amount).execute(&mut *tx).await?; + if inserted.rows_affected() == 0 { + return Ok(None); + } // rollback the debit on duplicate + let row = sqlx::query_as::<_, SessionRow>("SELECT id, merchant_id, deposit_txid, bearer_token, balance_zatoshis, balance_remaining, cost_per_request, requests_made, refund_address, status, expires_at, created_at, closed_at FROM agent_sessions WHERE id = ?") + .bind(id).fetch_one(&mut *tx).await?; + tx.commit().await?; + Ok(Some(session_from_row(row))) } pub async fn close_session(pool: &SqlitePool, session_id: &str) -> Result> { @@ -272,92 +295,14 @@ pub async fn list_for_merchant(pool: &SqlitePool, merchant_id: &str) -> Result bool { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agent_sessions WHERE deposit_txid = ?") + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM payment_consumptions WHERE txid = lower(?)") .bind(txid) .fetch_one(pool) .await - .unwrap_or(0) + .unwrap_or(1) > 0 } -/// Deduct a variable amount from a session (used for streaming metering). -/// Returns the updated session if successful, None if insufficient balance or inactive. -pub async fn deduct( - pool: &SqlitePool, - bearer_token: &str, - amount_zatoshis: i64, -) -> Result> { - if amount_zatoshis <= 0 { - anyhow::bail!("Deduction amount must be positive"); - } - - let result = sqlx::query( - "UPDATE agent_sessions SET - balance_remaining = balance_remaining - ?, - requests_made = requests_made + 1 - WHERE bearer_token = ? - AND status = 'active' - AND balance_remaining >= ? - AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", - ) - .bind(amount_zatoshis) - .bind(bearer_token) - .bind(amount_zatoshis) - .execute(pool) - .await?; - - if result.rows_affected() == 0 { - sqlx::query( - "UPDATE agent_sessions SET status = CASE - WHEN expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now') THEN 'expired' - WHEN balance_remaining < ? THEN 'depleted' - ELSE status END - WHERE bearer_token = ? AND status = 'active'", - ) - .bind(amount_zatoshis) - .bind(bearer_token) - .execute(pool) - .await - .ok(); - - return Ok(None); - } - - let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option)>( - "SELECT id, merchant_id, balance_remaining, cost_per_request, requests_made, refund_address - FROM agent_sessions WHERE bearer_token = ?", - ) - .bind(bearer_token) - .fetch_optional(pool) - .await?; - - match row { - Some(( - id, - merchant_id, - balance_remaining, - cost_per_request, - requests_made, - refund_address, - )) => Ok(Some(Session { - id, - merchant_id, - deposit_txid: String::new(), - bearer_token: bearer_token.to_string(), - balance_zatoshis: 0, - balance_remaining, - cost_per_request, - requests_made, - refund_address, - status: "active".to_string(), - expires_at: String::new(), - created_at: String::new(), - closed_at: None, - })), - None => Ok(None), - } -} - /// Create a session deposit request with a unique address (memo-free flow). pub async fn create_session_request( pool: &SqlitePool, @@ -409,15 +354,6 @@ pub async fn get_session_request( })) } -/// Mark a session request as used (prevents reuse). -pub async fn mark_session_request_used(pool: &SqlitePool, request_id: &str) -> Result<()> { - sqlx::query("UPDATE session_requests SET status = 'used' WHERE id = ?") - .bind(request_id) - .execute(pool) - .await?; - Ok(()) -} - pub struct SessionRequest { pub id: String, pub merchant_id: String, diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs index 2da1947..8236735 100644 --- a/src/webhooks/mod.rs +++ b/src/webhooks/mod.rs @@ -4,6 +4,17 @@ use sha2::Sha256; use sqlx::SqlitePool; use uuid::Uuid; +static HOST_ATTEMPTS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); +static WAKE: tokio::sync::Notify = tokio::sync::Notify::const_new(); +pub fn wake() { + WAKE.notify_one(); +} +pub async fn notified() { + WAKE.notified().await; +} + type HmacSha256 = Hmac; pub fn sign_payload_public(secret: &str, timestamp: &str, payload: &str) -> String { @@ -41,116 +52,6 @@ fn jittered_retry_delay_secs(attempt: i64) -> i64 { base + nanos % (base / 4).max(1) } -pub async fn dispatch_payment( - pool: &SqlitePool, - http: &reqwest::Client, - invoice_id: &str, - event: &str, - txid: &str, - price_zatoshis: i64, - received_zatoshis: i64, - overpaid: bool, - encryption_key: &str, -) -> anyhow::Result<()> { - let merchant_row = sqlx::query_as::<_, (String, Option, String)>( - "SELECT m.id, m.webhook_url, m.webhook_secret FROM invoices i - JOIN merchants m ON i.merchant_id = m.id - WHERE i.id = ?", - ) - .bind(invoice_id) - .fetch_optional(pool) - .await?; - - let (merchant_id, webhook_url, raw_secret) = match merchant_row { - Some((mid, Some(url), secret)) if !url.is_empty() => (mid, url, secret), - _ => return Ok(()), - }; - let webhook_secret = crate::crypto::decrypt_webhook_secret(&raw_secret, encryption_key)?; - - if let Err(reason) = crate::validation::resolve_and_check_host(&webhook_url) { - tracing::warn!(invoice_id, url = %webhook_url, %reason, "Webhook blocked: SSRF protection"); - return Ok(()); - } - - let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - - let payload = serde_json::json!({ - "event": event, - "invoice_id": invoice_id, - "txid": txid, - "timestamp": ×tamp, - "price_zec": crate::invoices::zatoshis_to_zec(price_zatoshis), - "received_zec": crate::invoices::zatoshis_to_zec(received_zatoshis), - "overpaid": overpaid, - }); - - let payload_str = payload.to_string(); - let signature = sign_payload(&webhook_secret, ×tamp, &payload_str); - - let delivery_id = Uuid::new_v4().to_string(); - let next_retry = (Utc::now() + chrono::Duration::seconds(jittered_retry_delay_secs(1))) - .format("%Y-%m-%dT%H:%M:%SZ") - .to_string(); - - sqlx::query( - "INSERT INTO webhook_deliveries (id, invoice_id, url, payload, status, attempts, last_attempt_at, next_retry_at, event_type, merchant_id) - VALUES (?, ?, ?, ?, 'pending', 1, ?, ?, ?, ?)" - ) - .bind(&delivery_id) - .bind(invoice_id) - .bind(&webhook_url) - .bind(&payload_str) - .bind(×tamp) - .bind(&next_retry) - .bind(event) - .bind(&merchant_id) - .execute(pool) - .await?; - - match http - .post(&webhook_url) - .header("X-CipherPay-Signature", &signature) - .header("X-CipherPay-Timestamp", ×tamp) - .header("X-CipherPay-Delivery-Id", &delivery_id) - .json(&payload) - .timeout(std::time::Duration::from_secs(10)) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - let status_code = resp.status().as_u16() as i32; - sqlx::query("UPDATE webhook_deliveries SET status = 'delivered', response_status = ? WHERE id = ?") - .bind(status_code) - .bind(&delivery_id) - .execute(pool) - .await?; - tracing::info!(invoice_id, event, "Payment webhook delivered"); - } - Ok(resp) => { - let status_code = resp.status().as_u16() as i32; - let error_text = format!("HTTP {}", resp.status()); - sqlx::query("UPDATE webhook_deliveries SET response_status = ?, response_error = ? WHERE id = ?") - .bind(status_code) - .bind(&error_text) - .bind(&delivery_id) - .execute(pool) - .await?; - tracing::warn!(invoice_id, event, status = %resp.status(), "Payment webhook rejected, will retry"); - } - Err(e) => { - let error_text = e.to_string(); - sqlx::query("UPDATE webhook_deliveries SET response_status = 0, response_error = ? WHERE id = ?") - .bind(&error_text) - .bind(&delivery_id) - .execute(pool) - .await?; - tracing::warn!(invoice_id, event, error = %e, "Payment webhook failed, will retry"); - } - } - - Ok(()) -} - /// Dispatch a generic lifecycle event webhook (subscription/invoice events). /// Unlike dispatch() which is invoice-centric, this takes a merchant_id directly /// and accepts an arbitrary JSON payload. @@ -271,14 +172,13 @@ pub async fn retry_failed( let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let rows = sqlx::query_as::<_, (String, String, String, String, i64)>( - "SELECT wd.id, wd.url, wd.payload, m.webhook_secret, wd.attempts - FROM webhook_deliveries wd - JOIN merchants m ON wd.merchant_id = m.id - WHERE wd.status = 'pending' - AND wd.attempts < 5 - AND (wd.next_retry_at IS NULL OR wd.next_retry_at <= ?) - ORDER BY wd.next_retry_at ASC - LIMIT 200", + "WITH ready AS ( + SELECT wd.id, wd.url, wd.payload, m.webhook_secret, wd.attempts, + ROW_NUMBER() OVER (PARTITION BY wd.merchant_id ORDER BY wd.next_retry_at, wd.created_at) AS position + FROM webhook_deliveries wd JOIN merchants m ON wd.merchant_id = m.id + WHERE wd.status = 'pending' AND wd.attempts < 10 + AND (wd.next_retry_at IS NULL OR wd.next_retry_at <= ?) + ) SELECT id, url, payload, webhook_secret, attempts FROM ready WHERE position = 1 LIMIT 200", ) .bind(&now) .fetch_all(pool) @@ -296,9 +196,29 @@ pub async fn retry_failed( .ok() .and_then(|u| u.host_str().map(str::to_owned)) { - if !attempted_hosts.insert(host) { + if !attempted_hosts.insert(host.clone()) { continue; } + let interval = if host == "connect.cipherpay.app" { + 5 + } else { + 60 + }; + let mut attempts = HOST_ATTEMPTS.lock().unwrap_or_else(|e| e.into_inner()); + attempts.retain(|_, last| last.elapsed().as_secs() < 60); + if attempts + .get(&host) + .is_some_and(|last| last.elapsed().as_secs() < interval) + { + continue; + } + attempts.insert(host, std::time::Instant::now()); + } + let claimed = sqlx::query("UPDATE webhook_deliveries SET next_retry_at = strftime('%Y-%m-%dT%H:%M:%SZ','now','+60 seconds') + WHERE id = ? AND status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= ?)") + .bind(&id).bind(&now).execute(pool).await?; + if claimed.rows_affected() == 0 { + continue; } let secret = match crate::crypto::decrypt_webhook_secret(&raw_secret, encryption_key) { Ok(s) => s, @@ -364,7 +284,7 @@ pub async fn retry_failed( }; if success { - sqlx::query("UPDATE webhook_deliveries SET status = 'delivered', response_status = ?, response_error = NULL WHERE id = ?") + sqlx::query("UPDATE webhook_deliveries SET status = 'delivered', attempts = attempts + 1, last_attempt_at = strftime('%Y-%m-%dT%H:%M:%SZ','now'), response_status = ?, response_error = NULL WHERE id = ?") .bind(resp_status) .bind(&id) .execute(pool) @@ -372,7 +292,7 @@ pub async fn retry_failed( tracing::info!(delivery_id = %id, "Webhook retry delivered"); } else { let new_attempts = attempts + 1; - if new_attempts >= 5 { + if new_attempts >= 10 { sqlx::query( "UPDATE webhook_deliveries SET status = 'failed', attempts = ?, last_attempt_at = ?, response_status = ?, response_error = ? WHERE id = ?" ) @@ -383,7 +303,7 @@ pub async fn retry_failed( .bind(&id) .execute(pool) .await?; - tracing::warn!(delivery_id = %id, "Webhook permanently failed after 5 attempts"); + tracing::warn!(delivery_id = %id, "Webhook permanently failed after 10 attempts"); } else { let base_delay = jittered_retry_delay_secs(new_attempts); // Respect Retry-After up to the 10h max backoff, but never From e3107a4eabb829efa6bb6825b104370dccef619f Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Tue, 8 Sep 2026 08:48:13 +1000 Subject: [PATCH 2/9] Require confirmation for legacy payment proofs too --- src/api/x402.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/api/x402.rs b/src/api/x402.rs index a68bc33..aa0e082 100644 --- a/src/api/x402.rs +++ b/src/api/x402.rs @@ -135,6 +135,23 @@ pub async fn verify( let previously_verified = false; + if !crate::scanner::blocks::check_tx_confirmed( + &http_client, + &config.cipherscan_api_url, + &body.txid, + ) + .await + .unwrap_or(false) + { + return HttpResponse::Ok().json(VerifyResponse { + valid: false, + received_zec: 0.0, + received_zatoshis: 0, + previously_verified: false, + reason: Some("Payment is not confirmed".to_string()), + }); + } + let raw_hex = match mempool::fetch_raw_tx(&http_client, &config.cipherscan_api_url, &body.txid).await { Ok(hex) => hex, From 53db57852d087e55c4e7eb4fd907aa4a8ff37333 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Tue, 8 Sep 2026 08:51:55 +1000 Subject: [PATCH 3/9] Handle deployed schema variants and add verified backup and rollback operations --- scripts/backup.py | 15 +++++++++++++++ scripts/deploy-audit.sh | 40 +++++++++++++++++++++++++++++++++++++++ scripts/rollback-audit.sh | 18 ++++++++++++++++++ src/db.rs | 2 +- src/repair_tests.rs | 19 +++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 scripts/backup.py create mode 100644 scripts/deploy-audit.sh create mode 100644 scripts/rollback-audit.sh diff --git a/scripts/backup.py b/scripts/backup.py new file mode 100644 index 0000000..6d4d770 --- /dev/null +++ b/scripts/backup.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Consistent SQLite backup; verify every snapshot before retention cleanup.""" +import os, pathlib, sqlite3, datetime +os.umask(0o077) +root=pathlib.Path('/opt/cipherpay-api') +backup=root/'backups';backup.mkdir(mode=0o700,exist_ok=True) +now=datetime.datetime.now(datetime.timezone.utc) +file=backup/('cipherpay-'+now.strftime('%Y%m%dT%H%M%SZ')+'.db') +with sqlite3.connect(root/'cipherpay.db') as source, sqlite3.connect(file) as dest: + source.backup(dest) + if dest.execute('pragma integrity_check').fetchone()[0]!='ok': raise RuntimeError('Backup integrity check failed') +# 14 daily backups; an operator may archive a snapshot off-host separately. +for old in backup.glob('cipherpay-*.db'): + if now.timestamp()-old.stat().st_mtime>14*86400: old.unlink() +print('Verified SQLite backup created') diff --git a/scripts/deploy-audit.sh b/scripts/deploy-audit.sh new file mode 100644 index 0000000..db74de8 --- /dev/null +++ b/scripts/deploy-audit.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Run only after testing this candidate against a consistent production database copy. +set -euo pipefail +release=/opt/cipherpay-api/releases/audit-20260908 +test "$(id -u)" = 0 +test -f "$release/build-success" +test -x "$release/cipherpay" +test -f "$release/migration-verified" +python3 /opt/cipherpay-api/scripts/backup.py +chgrp cipherpay "$release" +chmod 750 "$release" +chmod 755 "$release/cipherpay" +install -d -m 755 /etc/systemd/system/cipherpay-api.service.d +cat > /etc/systemd/system/cipherpay-api.service.d/audit-release.conf < "$release/health-after.json"; then + if python3 - "$release/health-after.json" <<'PY' +import json,sys +data=json.load(open(sys.argv[1])) +sys.exit(0 if data.get('status') in ('ok','healthy','degraded') else 1) +PY + then + echo 'Candidate is serving health requests; verify scanner progress next.' + exit 0 + fi + fi + sleep 3 +done +bash "$release/source/scripts/rollback-audit.sh" +echo 'Candidate health check failed; previous version restored.' >&2 +exit 1 diff --git a/scripts/rollback-audit.sh b/scripts/rollback-audit.sh new file mode 100644 index 0000000..42c9955 --- /dev/null +++ b/scripts/rollback-audit.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Restore the pre-audit binary without discarding payments received since deployment. +set -euo pipefail +release=/opt/cipherpay-api/releases/audit-20260908 +test "$(id -u)" = 0 +test -x "$release/rollback/cipherpay" +systemctl stop cipherpay-api.service +python3 - <<'PY' +import sqlite3 +with sqlite3.connect('/opt/cipherpay-api/cipherpay.db') as db: + db.execute('DROP TRIGGER IF EXISTS invoice_payment_outbox') + db.execute("DELETE FROM schema_migrations WHERE name='payment_outbox_v2026_09_08'") +PY +install -m 755 "$release/rollback/cipherpay" /opt/cipherpay-api/target/release/cipherpay +rm -f /etc/systemd/system/cipherpay-api.service.d/audit-release.conf +systemctl daemon-reload +systemctl start cipherpay-api.service +echo 'Previous binary restored; current payment data retained.' diff --git a/src/db.rs b/src/db.rs index f527cbf..5365a30 100644 --- a/src/db.rs +++ b/src/db.rs @@ -320,7 +320,7 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { "INSERT OR IGNORE INTO payment_consumptions (txid, purpose) SELECT lower(txid), 'legacy-x402' FROM x402_verifications WHERE status = 'verified'", "CREATE TABLE session_charges (session_id TEXT NOT NULL, request_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), PRIMARY KEY(session_id, request_id))", "CREATE TABLE x402_idempotency_v2 (merchant_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash TEXT NOT NULL, response_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), PRIMARY KEY(merchant_id, idempotency_key))", - "DELETE FROM x402_idempotency", + "DROP TABLE IF EXISTS x402_idempotency", ] { sqlx::query(sql).execute(&mut *tx).await?; } tx.commit().await?; Ok(()) diff --git a/src/repair_tests.rs b/src/repair_tests.rs index 7f46a56..aaf4a84 100644 --- a/src/repair_tests.rs +++ b/src/repair_tests.rs @@ -210,3 +210,22 @@ async fn prepared_session_consumption_rolls_back_and_cannot_be_reused() { ); assert!(!crate::sessions::txid_already_used(&pool, "other").await); } + +#[tokio::test] +async fn upgrade_supports_deployments_without_legacy_idempotency_table() { + let file = std::env::temp_dir().join(format!("cipherpay-upgrade-{}.db", uuid::Uuid::new_v4())); + let url = format!("sqlite:{}", file.display()); + let pool = crate::db::create_pool(&url).await.unwrap(); + for statement in ["DROP TRIGGER invoice_payment_outbox", "DROP TABLE payment_consumptions", "DROP TABLE session_charges", "DROP TABLE x402_idempotency_v2", "DROP TABLE IF EXISTS x402_idempotency", "DELETE FROM schema_migrations WHERE name IN ('audit_repairs_v2026_09_08','payment_outbox_v2026_09_08')"] { + sqlx::query(statement).execute(&pool).await.unwrap(); + } + pool.close().await; + let upgraded = crate::db::create_pool(&url).await.unwrap(); + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&upgraded) + .await + .unwrap(); + assert!(violations.is_empty()); + upgraded.close().await; + std::fs::remove_file(file).unwrap(); +} From 6ddd2b66dee1f27462fb6b2d663950e5326ffbb3 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Tue, 8 Sep 2026 08:58:08 +1000 Subject: [PATCH 4/9] Archive legacy orphan ledger rows during checked rollout --- scripts/deploy-audit.sh | 13 ++++++++++ scripts/repair-orphan-payments.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 scripts/repair-orphan-payments.py diff --git a/scripts/deploy-audit.sh b/scripts/deploy-audit.sh index db74de8..0f5f392 100644 --- a/scripts/deploy-audit.sh +++ b/scripts/deploy-audit.sh @@ -7,6 +7,19 @@ test -f "$release/build-success" test -x "$release/cipherpay" test -f "$release/migration-verified" python3 /opt/cipherpay-api/scripts/backup.py +systemctl stop cipherpay-api.service +trap 'trap - ERR; bash "$release/source/scripts/rollback-audit.sh"' ERR +python3 "$release/source/scripts/repair-orphan-payments.py" archive-clean \ + /opt/cipherpay-api/cipherpay.db "$release/rollback/orphan-payments.json" +if ! (cd "$release" && DATABASE_URL=sqlite:/opt/cipherpay-api/cipherpay.db ./cipherpay --check-database > migration-live.log 2>&1); then + bash "$release/source/scripts/rollback-audit.sh" + exit 1 +fi +python3 "$release/source/scripts/repair-orphan-payments.py" record-consumption \ + /opt/cipherpay-api/cipherpay.db "$release/rollback/orphan-payments.json" +for database_file in /opt/cipherpay-api/cipherpay.db /opt/cipherpay-api/cipherpay.db-wal /opt/cipherpay-api/cipherpay.db-shm; do + if test -f "$database_file"; then chown cipherpay:cipherpay "$database_file"; fi +done chgrp cipherpay "$release" chmod 750 "$release" chmod 755 "$release/cipherpay" diff --git a/scripts/repair-orphan-payments.py b/scripts/repair-orphan-payments.py new file mode 100644 index 0000000..9da6fdc --- /dev/null +++ b/scripts/repair-orphan-payments.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Archive detached ledger rows, then retain their txids after schema migration. + +Run archive-clean only with the API stopped or against an isolated database copy. +The archive must be retained privately with the release backup. +""" +import json +import os +import pathlib +import sqlite3 +import sys + +mode, database, archive_name = sys.argv[1:] +archive = pathlib.Path(archive_name) +os.umask(0o077) +with sqlite3.connect(database) as db: + db.row_factory = sqlite3.Row + if mode == 'archive-clean': + db.execute('BEGIN IMMEDIATE') + rows = [dict(row) for row in db.execute( + 'SELECT p.* FROM invoice_payments p WHERE NOT EXISTS ' + '(SELECT 1 FROM invoices i WHERE i.id=p.invoice_id)')] + # Never overwrite an earlier archive during a retry. + if rows: + with archive.open('x') as output: + json.dump(rows, output) + output.flush() + os.fsync(output.fileno()) + deleted = db.execute('DELETE FROM invoice_payments WHERE NOT EXISTS ' + '(SELECT 1 FROM invoices i WHERE i.id=invoice_payments.invoice_id)').rowcount + assert deleted == len(rows) + print(f'Archived and removed {len(rows)} detached ledger rows') + elif mode == 'record-consumption': + rows = json.loads(archive.read_text()) if archive.exists() else [] + for row in rows: + db.execute('INSERT OR IGNORE INTO payment_consumptions (txid,purpose) VALUES (?,?)', + (row['txid'].lower(), 'orphaned-invoice')) + print(f'Retained anti-replay evidence for {len(rows)} detached payments') + else: + raise ValueError('Expected archive-clean or record-consumption') From 107ed009230d832d11d070fb7e0e7fcc9e400319 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Tue, 8 Sep 2026 09:04:03 +1000 Subject: [PATCH 5/9] Include embedded checkout assets and verify the self-hosted image --- .github/workflows/docker.yml | 19 +++++++++++++++++++ Dockerfile | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..56fa891 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,19 @@ +name: Self-hosted image verification +on: [push, pull_request] +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: cipherpay:audit + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Verify non-root startup and SQLite migrations + run: >- + docker run --rm --entrypoint sh cipherpay:audit -c + 'test "$(id -u)" = 10001 && DATABASE_URL=sqlite:/data/check.db cipherpay --check-database' diff --git a/Dockerfile b/Dockerfile index 1d04e89..e000f02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/li COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY migrations ./migrations +COPY ui ./ui +COPY widget ./widget RUN cargo build --release --locked FROM debian:bookworm-slim From 39e4ae36e5d1761fc9460f4cbf6b5bbaa62393e0 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Mon, 12 Oct 2026 22:15:08 +0900 Subject: [PATCH 6/9] Preserve paid subscription periods and apply confirmed renewals once --- src/api/invoices.rs | 1 + src/api/subscriptions.rs | 9 +- src/invoices/mod.rs | 10 +- src/scanner/mod.rs | 80 +++---- src/subscriptions/mod.rs | 450 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 465 insertions(+), 85 deletions(-) diff --git a/src/api/invoices.rs b/src/api/invoices.rs index b428b4f..a892863 100644 --- a/src/api/invoices.rs +++ b/src/api/invoices.rs @@ -314,6 +314,7 @@ pub async fn finalize( } else if msg.contains("draft or expired") || msg.contains("already detected") || msg.contains("period has ended") + || msg.contains("not eligible for renewal") { HttpResponse::Conflict().json(serde_json::json!({ "error": msg })) } else { diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 92108d6..0e4ce67 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -103,14 +103,7 @@ pub async fn status( match subscriptions::get_subscription(pool.get_ref(), &sub_id).await { Ok(Some(sub)) if sub.merchant_id == merchant.id => { - let now = chrono::Utc::now(); - let period_end = chrono::DateTime::parse_from_rfc3339(&sub.current_period_end) - .or_else(|_| chrono::NaiveDateTime::parse_from_str(&sub.current_period_end, "%Y-%m-%dT%H:%M:%SZ") - .map(|dt| dt.and_utc().fixed_offset())) - .ok(); - - let is_active = sub.status == "active" - && period_end.map_or(false, |end| now < end); + let is_active = sub.has_access_at(chrono::Utc::now()); HttpResponse::Ok().json(serde_json::json!({ "subscription_id": sub.id, diff --git a/src/invoices/mod.rs b/src/invoices/mod.rs index f55569c..eab81e0 100644 --- a/src/invoices/mod.rs +++ b/src/invoices/mod.rs @@ -501,9 +501,15 @@ pub async fn finalize_invoice( let sub = crate::subscriptions::get_subscription(pool, sub_id).await?; if let Some(s) = sub { let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - if now > s.current_period_end { + if now >= s.current_period_end { anyhow::bail!("Subscription billing period has ended"); } + if s.status != "active" || s.pause_at_period_end != 0 || s.cancel_at_period_end != 0 + || s.current_invoice_id.as_deref() != Some(invoice_id) { + anyhow::bail!("Subscription is not eligible for renewal"); + } + } else { + anyhow::bail!("Subscription not found"); } } @@ -571,7 +577,7 @@ pub async fn finalize_invoice( price_zec = ?, price_eur = ?, price_usd = ?, zec_rate_at_creation = ?, price_zatoshis = ?, zcash_uri = ?, expires_at = ? - WHERE id = ? AND status IN ('draft', 'expired')", + WHERE id = ? AND status IN ('draft', 'expired') AND received_zatoshis=0 AND detected_txid IS NULL", ) .bind(price_zec) .bind(price_eur) diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index d4c6eec..485c286 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -915,60 +915,38 @@ async fn on_invoice_confirmed( // Advance subscription period immediately on payment (with status guard) if let Some(ref sub_id) = invoice.subscription_id { - // Guard: re-check subscription status before advancing. A paused, canceled, - // or pause-scheduled subscription must not be renewed by a stale invoice. - let should_advance = match crate::subscriptions::get_subscription(pool, sub_id).await { - Ok(Some(s)) => s.status == "active" && s.pause_at_period_end == 0, + match crate::subscriptions::renew_from_confirmed_invoice(pool, &invoice.id).await { + Ok(Some(sub)) => { + tracing::info!( + sub_id, + invoice_id = %invoice.id, + new_period_end = %sub.current_period_end, + "Subscription advanced on payment confirmation" + ); + let payload = serde_json::json!({ + "subscription_id": sub.id, + "invoice_id": invoice.id, + "new_period_start": sub.current_period_start, + "new_period_end": sub.current_period_end, + }); + if let Err(e) = crate::webhooks::dispatch_event( + pool, + outbound_http, + &invoice.merchant_id, + "subscription.renewed", + payload, + &config.encryption_key, + ) + .await + { + tracing::error!(sub_id, error = %e, "Failed to dispatch subscription.renewed webhook"); + } + } Ok(None) => { - tracing::warn!(sub_id, "Subscription not found for confirmed invoice"); - false + tracing::debug!(sub_id, "Renewal invoice already applied or ineligible"); } Err(e) => { - tracing::error!(sub_id, error = %e, "Failed to load subscription for renewal guard"); - false - } - }; - - if !should_advance { - tracing::debug!( - sub_id, - invoice_id = %invoice.id, - "Skipping subscription advance — subscription is not active or is scheduled to pause" - ); - } else { - match crate::subscriptions::advance_subscription_period(pool, sub_id).await { - Ok(Some(sub)) => { - tracing::info!( - sub_id, - invoice_id = %invoice.id, - new_period_end = %sub.current_period_end, - "Subscription advanced on payment confirmation" - ); - let payload = serde_json::json!({ - "subscription_id": sub.id, - "invoice_id": invoice.id, - "new_period_start": sub.current_period_start, - "new_period_end": sub.current_period_end, - }); - if let Err(e) = crate::webhooks::dispatch_event( - pool, - outbound_http, - &invoice.merchant_id, - "subscription.renewed", - payload, - &config.encryption_key, - ) - .await - { - tracing::error!(sub_id, error = %e, "Failed to dispatch subscription.renewed webhook"); - } - } - Ok(None) => { - tracing::warn!(sub_id, "Subscription not found for confirmed invoice"); - } - Err(e) => { - tracing::error!(sub_id, error = %e, "Failed to advance subscription period"); - } + tracing::error!(sub_id, error = %e, "Failed to advance subscription period"); } } } diff --git a/src/subscriptions/mod.rs b/src/subscriptions/mod.rs index 19b1d5f..f149d7f 100644 --- a/src/subscriptions/mod.rs +++ b/src/subscriptions/mod.rs @@ -19,6 +19,14 @@ pub struct Subscription { pub pause_at_period_end: i32, } +impl Subscription { + pub fn has_access_at(&self, now: chrono::DateTime) -> bool { + self.status == "active" + && chrono::DateTime::parse_from_rfc3339(&self.current_period_end) + .is_ok_and(|end| now < end) + } +} + #[derive(Debug, Deserialize)] pub struct CreateSubscriptionRequest { pub price_id: String, @@ -215,7 +223,10 @@ pub async fn pause_subscription( }; if sub.status != "active" { - anyhow::bail!("Only active subscriptions can be paused (current: {})", sub.status); + anyhow::bail!( + "Only active subscriptions can be paused (current: {})", + sub.status + ); } if sub.cancel_at_period_end != 0 { anyhow::bail!("Cannot pause a subscription that is scheduled for cancellation"); @@ -514,10 +525,13 @@ pub async fn process_renewals( } // 3. Advance paid periods (subscriptions past period_end with confirmed invoice) - // Note: subscription.renewed webhook is dispatched by the scanner on payment confirmation. // This step is a fallback for edge cases (e.g., server restart during scan). + // Only the caller that consumes the invoice dispatches subscription.renewed. let q = format!( - "SELECT {} FROM subscriptions WHERE status = 'active' AND current_period_end <= ? AND cancel_at_period_end = 0 AND pause_at_period_end = 0 LIMIT 500", + "SELECT {} FROM subscriptions WHERE + (status = 'active' OR (status = 'past_due' AND EXISTS ( + SELECT 1 FROM invoices WHERE invoices.id = subscriptions.current_invoice_id AND invoices.status = 'confirmed' + ))) AND current_period_end <= ? AND cancel_at_period_end = 0 AND pause_at_period_end = 0 LIMIT 500", SUB_COLS ); let past_due_candidates: Vec = sqlx::query_as::<_, Subscription>(&q) @@ -536,8 +550,22 @@ pub async fn process_renewals( if let Some((ref status,)) = inv_status { if status == "confirmed" { - // Just advance the period — webhook already dispatched by scanner - if advance_subscription_period(pool, &sub.id).await?.is_some() { + if let Some(renewed) = renew_from_confirmed_invoice(pool, inv_id).await? { + let payload = serde_json::json!({ + "subscription_id": renewed.id, + "invoice_id": inv_id, + "new_period_start": renewed.current_period_start, + "new_period_end": renewed.current_period_end, + }); + let _ = crate::webhooks::dispatch_event( + pool, + http, + &sub.merchant_id, + "subscription.renewed", + payload, + encryption_key, + ) + .await; actions += 1; } continue; @@ -546,14 +574,23 @@ pub async fn process_renewals( } } + if sub.status == "past_due" { + continue; + } + // 4. Period ended without confirmed payment → past_due tracing::info!(sub_id = %sub.id, "Subscription past due (period ended without payment)"); - sqlx::query( - "UPDATE subscriptions SET status = 'past_due' WHERE id = ? AND status = 'active'", + let overdue = sqlx::query( + "UPDATE subscriptions SET status = 'past_due' + WHERE id = ? AND status = 'active' AND current_period_end = ?", ) .bind(&sub.id) + .bind(&sub.current_period_end) .execute(pool) .await?; + if overdue.rows_affected() == 0 { + continue; // A concurrent confirmed renewal already advanced this period. + } // Expire the draft invoice if it exists if let Some(ref inv_id) = sub.current_invoice_id { @@ -589,7 +626,74 @@ pub async fn process_renewals( Ok(actions) } -/// Advance a subscription to its next billing period. Called when invoice is confirmed. +/// Apply a confirmed renewal exactly once, including a quote paid after access expired. +/// An open quote never grants access; the scanner must confirm sufficient funds first. +pub async fn renew_from_confirmed_invoice( + pool: &SqlitePool, + invoice_id: &str, +) -> anyhow::Result> { + let invoice = match crate::invoices::get_invoice(pool, invoice_id).await? { + Some(i) + if i.status == "confirmed" + && i.price_zatoshis > 0 + && i.received_zatoshis + >= (i.price_zatoshis as f64 * crate::scanner::decrypt::SLIPPAGE_TOLERANCE) as i64 + && i.confirmed_at + .as_ref() + .is_some_and(|at| at <= &i.expires_at) => + { + i + } + _ => return Ok(None), + }; + let sub_id = match invoice.subscription_id.as_deref() { + Some(id) => id, + None => return Ok(None), + }; + let sub = match get_subscription(pool, sub_id).await? { + Some(s) + if (s.status == "active" || s.status == "past_due") + && s.pause_at_period_end == 0 + && s.cancel_at_period_end == 0 + && s.current_invoice_id.as_deref() == Some(invoice_id) + && s.merchant_id == invoice.merchant_id + && invoice.price_id.as_deref() == Some(s.price_id.as_str()) => + { + s + } + _ => return Ok(None), + }; + let price = crate::prices::get_price(pool, &sub.price_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Subscription price not found"))?; + let period_end = + chrono::DateTime::parse_from_rfc3339(&sub.current_period_end)?.with_timezone(&Utc); + let new_start = period_end.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let new_end = compute_period_end( + &period_end, + price.billing_interval.as_deref().unwrap_or("month"), + price.interval_count.unwrap_or(1), + ) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + + // Matching the invoice and old boundary makes concurrent/repeated application a no-op. + let result = sqlx::query( + "UPDATE subscriptions SET status = 'active', current_period_start = ?, + current_period_end = ?, current_invoice_id = NULL + WHERE id = ? AND current_invoice_id = ? AND current_period_end = ? + AND status IN ('active', 'past_due') AND pause_at_period_end = 0 AND cancel_at_period_end = 0 + AND EXISTS (SELECT 1 FROM invoices WHERE invoices.id = subscriptions.current_invoice_id AND invoices.status = 'confirmed')" + ) + .bind(&new_start).bind(&new_end).bind(sub_id).bind(invoice_id) + .bind(&sub.current_period_end).execute(pool).await?; + if result.rows_affected() == 0 { + return Ok(None); + } + get_subscription(pool, sub_id).await +} + +/// Advance without payment evidence for the testnet-only simulation endpoint. pub async fn advance_subscription_period( pool: &SqlitePool, sub_id: &str, @@ -610,9 +714,12 @@ pub async fn advance_subscription_period( let interval = price.billing_interval.as_deref().unwrap_or("month"); let count = price.interval_count.unwrap_or(1); - let now_dt = Utc::now(); - let new_start = now_dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let new_end = compute_period_end(&now_dt, interval, count) + // A renewal buys the next period, including when it is paid early. + // Keep the existing billing boundary instead of discarding paid time. + let period_end = + chrono::DateTime::parse_from_rfc3339(&sub.current_period_end)?.with_timezone(&Utc); + let new_start = period_end.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let new_end = compute_period_end(&period_end, interval, count) .format("%Y-%m-%dT%H:%M:%SZ") .to_string(); @@ -686,6 +793,288 @@ mod tests { create_subscription(pool, merchant_id, &req).await.unwrap() } + async fn seed_confirmed_renewal(pool: &SqlitePool, sub: &Subscription) -> String { + let invoice_id = format!("renewal_{}", sub.id); + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let expiry = (Utc::now() + ChronoDuration::minutes(10)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("INSERT INTO invoices (id, merchant_id, memo_code, price_eur, price_zec, zec_rate_at_creation, status, expires_at, confirmed_at, subscription_id, price_id, price_zatoshis, received_zatoshis) + VALUES (?, ?, ?, 9.99, 0.1, 100.0, 'confirmed', ?, ?, ?, ?, 10000000, 10000000)") + .bind(&invoice_id).bind(&sub.merchant_id).bind(&invoice_id) + .bind(&expiry).bind(&now).bind(&sub.id).bind(&sub.price_id) + .execute(pool).await.unwrap(); + sqlx::query("UPDATE subscriptions SET current_invoice_id = ? WHERE id = ?") + .bind(&invoice_id) + .bind(&sub.id) + .execute(pool) + .await + .unwrap(); + invoice_id + } + + #[tokio::test] + async fn early_renewal_preserves_remaining_paid_days() { + let pool = setup_pool().await; + seed_merchant(&pool, "renewal").await; + seed_product_and_price(&pool, "renewal", "renewal_product", "renewal_price").await; + let sub = create_test_sub(&pool, "renewal", "renewal_price").await; + let old_end = (Utc::now() + ChronoDuration::days(3)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE subscriptions SET current_period_end = ? WHERE id = ?") + .bind(&old_end) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + + let invoice_id = seed_confirmed_renewal(&pool, &sub).await; + let (first, second) = tokio::join!( + renew_from_confirmed_invoice(&pool, &invoice_id), + renew_from_confirmed_invoice(&pool, &invoice_id), + ); + let first = first.unwrap(); + let second = second.unwrap(); + assert_ne!(first.is_some(), second.is_some()); + let renewed = first.or(second).unwrap(); + let expected_end = (chrono::DateTime::parse_from_rfc3339(&old_end).unwrap() + + ChronoDuration::days(30)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + assert_eq!(renewed.current_period_start, old_end); + assert_eq!(renewed.current_period_end, expected_end); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn confirmed_renewal_keeps_billing_boundary_after_delayed_processing() { + let pool = setup_pool().await; + seed_merchant(&pool, "delayed").await; + seed_product_and_price(&pool, "delayed", "delayed_product", "delayed_price").await; + let sub = create_test_sub(&pool, "delayed", "delayed_price").await; + let old_end = (Utc::now() - ChronoDuration::minutes(5)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE subscriptions SET current_period_end = ? WHERE id = ?") + .bind(&old_end) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + + let invoice_id = seed_confirmed_renewal(&pool, &sub).await; + sqlx::query("UPDATE subscriptions SET status = 'past_due' WHERE id = ?") + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + let expired = get_subscription(&pool, &sub.id).await.unwrap().unwrap(); + assert!(!expired.has_access_at(Utc::now())); + let renewed = renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .unwrap(); + let expected_end = (chrono::DateTime::parse_from_rfc3339(&old_end).unwrap() + + ChronoDuration::days(30)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + assert_eq!(renewed.current_period_start, old_end); + assert_eq!(renewed.current_period_end, expected_end); + assert!(renewed.has_access_at(Utc::now())); + } + + #[tokio::test] + async fn renewal_rejects_unpaid_partial_stale_and_canceled_invoices() { + let pool = setup_pool().await; + seed_merchant(&pool, "guard").await; + seed_product_and_price(&pool, "guard", "guard_product", "guard_price").await; + let sub = create_test_sub(&pool, "guard", "guard_price").await; + let invoice_id = seed_confirmed_renewal(&pool, &sub).await; + + for status in ["draft", "pending", "detected", "underpaid", "expired"] { + sqlx::query("UPDATE invoices SET status = ? WHERE id = ?") + .bind(status) + .bind(&invoice_id) + .execute(&pool) + .await + .unwrap(); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + } + sqlx::query("UPDATE invoices SET status = 'confirmed', received_zatoshis = 1 WHERE id = ?") + .bind(&invoice_id) + .execute(&pool) + .await + .unwrap(); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + sqlx::query("UPDATE invoices SET received_zatoshis = price_zatoshis, expires_at = '2020-01-01T00:00:00Z' WHERE id = ?") + .bind(&invoice_id).execute(&pool).await.unwrap(); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + sqlx::query("UPDATE invoices SET expires_at = '2099-01-01T00:00:00Z' WHERE id = ?") + .bind(&invoice_id) + .execute(&pool) + .await + .unwrap(); + for status in ["paused", "canceled"] { + sqlx::query("UPDATE subscriptions SET status = ? WHERE id = ?") + .bind(status) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + } + sqlx::query( + "UPDATE subscriptions SET status = 'active', current_invoice_id = NULL WHERE id = ?", + ) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + let unchanged = get_subscription(&pool, &sub.id).await.unwrap().unwrap(); + assert_eq!(unchanged.current_period_end, sub.current_period_end); + } + + #[tokio::test] + async fn renewal_job_recovers_confirmed_quote_after_subscription_became_past_due() { + let pool = setup_pool().await; + seed_merchant(&pool, "recovery").await; + seed_product_and_price(&pool, "recovery", "recovery_product", "recovery_price").await; + let sub = create_test_sub(&pool, "recovery", "recovery_price").await; + let invoice_id = seed_confirmed_renewal(&pool, &sub).await; + let past = (Utc::now() - ChronoDuration::minutes(5)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query( + "UPDATE subscriptions SET status = 'past_due', current_period_end = ? WHERE id = ?", + ) + .bind(&past) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + let actions = process_renewals( + &pool, + &reqwest::Client::new(), + "test_key", + &std::collections::HashMap::new(), + None, + ) + .await + .unwrap(); + assert_eq!(actions, 1); + let renewed = get_subscription(&pool, &sub.id).await.unwrap().unwrap(); + assert_eq!(renewed.current_period_start, past); + assert!(renewed.has_access_at(Utc::now())); + assert!(renew_from_confirmed_invoice(&pool, &invoice_id) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn locking_and_relocking_unpaid_invoice_never_extends_subscription() { + let pool = setup_pool().await; + seed_merchant(&pool, "unpaid").await; + seed_product_and_price(&pool, "unpaid", "unpaid_product", "unpaid_price").await; + let sub = create_test_sub(&pool, "unpaid", "unpaid_price").await; + let old_end = (Utc::now() + ChronoDuration::minutes(5)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE subscriptions SET current_period_end = ?, current_invoice_id = 'unpaid_invoice' WHERE id = ?") + .bind(&old_end) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO invoices (id, merchant_id, memo_code, price_eur, price_zec, zec_rate_at_creation, amount, currency, status, expires_at, subscription_id) + VALUES ('unpaid_invoice', 'unpaid', 'unpaid_memo', 20.0, 0.0, 0.0, 20.0, 'USD', 'draft', ?, ?)") + .bind(&old_end) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + let rates = crate::invoices::pricing::ZecRates { + zec_eur: 100.0, + zec_usd: 100.0, + zec_brl: 100.0, + zec_gbp: 100.0, + zec_cad: 100.0, + zec_jpy: 100.0, + zec_mxn: 100.0, + zec_ars: 100.0, + zec_ngn: 100.0, + zec_chf: 100.0, + zec_inr: 100.0, + updated_at: Utc::now(), + }; + + for _ in 0..2 { + let invoice = crate::invoices::finalize_invoice(&pool, "unpaid_invoice", &rates, None) + .await + .unwrap(); + assert_eq!(invoice.status, "pending"); + assert!(invoice.expires_at > old_end); + let unchanged = get_subscription(&pool, &sub.id).await.unwrap().unwrap(); + assert_eq!(unchanged.current_period_end, old_end); + let boundary = chrono::DateTime::parse_from_rfc3339(&old_end) + .unwrap() + .with_timezone(&Utc); + assert!(unchanged.has_access_at(boundary - ChronoDuration::seconds(1))); + assert!(!unchanged.has_access_at(boundary)); + assert!(!unchanged.has_access_at(boundary + ChronoDuration::minutes(1))); + sqlx::query("UPDATE invoices SET status = 'expired' WHERE id = 'unpaid_invoice'") + .execute(&pool) + .await + .unwrap(); + } + + let past = (Utc::now() - ChronoDuration::seconds(1)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string(); + sqlx::query("UPDATE subscriptions SET current_period_end = ? WHERE id = ?") + .bind(&past) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); + let err = crate::invoices::finalize_invoice(&pool, "unpaid_invoice", &rates, None) + .await + .unwrap_err(); + assert!(err.to_string().contains("billing period has ended")); + process_renewals( + &pool, + &reqwest::Client::new(), + "test_key", + &std::collections::HashMap::new(), + None, + ) + .await + .unwrap(); + let ended = get_subscription(&pool, &sub.id).await.unwrap().unwrap(); + assert_eq!(ended.status, "past_due"); + assert_eq!(ended.current_period_end, past); + } + #[tokio::test] async fn pause_active_sub_sets_flag() { let pool = setup_pool().await; @@ -696,7 +1085,10 @@ mod tests { seed_product_and_price(&pool, m, prod, price).await; let sub = create_test_sub(&pool, m, price).await; - let paused = pause_subscription(&pool, &sub.id, m).await.unwrap().unwrap(); + let paused = pause_subscription(&pool, &sub.id, m) + .await + .unwrap() + .unwrap(); assert_eq!(paused.status, "active"); assert_eq!(paused.pause_at_period_end, 1); } @@ -740,7 +1132,10 @@ mod tests { cancel_subscription(&pool, &sub.id, m, true).await.unwrap(); let err = pause_subscription(&pool, &sub.id, m).await; assert!(err.is_err()); - assert!(err.unwrap_err().to_string().contains("scheduled for cancellation")); + assert!(err + .unwrap_err() + .to_string() + .contains("scheduled for cancellation")); } #[tokio::test] @@ -770,7 +1165,10 @@ mod tests { .await .unwrap(); - let paused = pause_subscription(&pool, &sub.id, m).await.unwrap().unwrap(); + let paused = pause_subscription(&pool, &sub.id, m) + .await + .unwrap() + .unwrap(); assert_eq!(paused.pause_at_period_end, 1); assert!(paused.current_invoice_id.is_none()); @@ -891,11 +1289,13 @@ mod tests { seed_product_and_price(&pool, m, "prod10", "price10").await; let sub = create_test_sub(&pool, m, "price10").await; - sqlx::query("UPDATE subscriptions SET status = 'paused', pause_at_period_end = 0 WHERE id = ?") - .bind(&sub.id) - .execute(&pool) - .await - .unwrap(); + sqlx::query( + "UPDATE subscriptions SET status = 'paused', pause_at_period_end = 0 WHERE id = ?", + ) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); // Deactivate the price sqlx::query("UPDATE prices SET active = 0 WHERE id = 'price10'") @@ -949,11 +1349,13 @@ mod tests { let sub = create_test_sub(&pool, m, "price12").await; // Set to paused - sqlx::query("UPDATE subscriptions SET status = 'paused', pause_at_period_end = 0 WHERE id = ?") - .bind(&sub.id) - .execute(&pool) - .await - .unwrap(); + sqlx::query( + "UPDATE subscriptions SET status = 'paused', pause_at_period_end = 0 WHERE id = ?", + ) + .bind(&sub.id) + .execute(&pool) + .await + .unwrap(); let http = reqwest::Client::new(); let ufvks = std::collections::HashMap::new(); From c39affd37ef1db36e6f3cd8bcecf049b8e306b4d Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Mon, 2 Nov 2026 07:52:19 -0500 Subject: [PATCH 7/9] Commit production snapshot crosschain-20261021 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mainnet release crosschain-20261021 was built from 39e4ae3 plus uncommitted changes (release-info.json: "dirty": true). This commit records that exact source so production code is in git: all 79 files match the release's backend-manifest.json SHA-256 hashes and the archive matches its recorded sha256 (1fe2763a…6cbe). Adds NEAR 1Click cross-chain payments, the email module, the scanner API and related billing/invoice/scanner changes. Credentials are read from the environment; no secrets are included. 121 tests pass with --locked. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 98 +++ Cargo.toml | 9 +- src/api/admin.rs | 7 +- src/api/auth.rs | 513 ++++++++++-- src/api/billing_routes.rs | 32 +- src/api/invoice_routes.rs | 2 + src/api/invoices.rs | 22 +- src/api/mod.rs | 31 + src/api/payment_links.rs | 5 +- src/api/system.rs | 7 + src/billing/fee_ledger.rs | 261 ++++-- src/billing/mod.rs | 60 +- src/config.rs | 37 +- src/crosschain.rs | 1058 ++++++++++++++++++++++++ src/crosschain/fees.rs | 1297 ++++++++++++++++++++++++++++++ src/crosschain/operations.rs | 55 ++ src/crosschain/security.rs | 93 +++ src/crosschain/signatures.rs | 124 +++ src/crosschain/tests.rs | 1142 ++++++++++++++++++++++++++ src/db.rs | 99 +++ src/email.rs | 3 + src/email/payments.rs | 660 +++++++++++++++ src/invoices/mod.rs | 16 +- src/invoices/queries.rs | 6 +- src/invoices/types.rs | 14 + src/main.rs | 12 +- src/merchants/mod.rs | 4 +- src/scanner/api.rs | 180 +++++ src/scanner/blocks.rs | 63 +- src/scanner/invoice_detection.rs | 3 +- src/scanner/mempool.rs | 33 +- src/scanner/mod.rs | 113 ++- src/scanner/ws.rs | 20 +- 33 files changed, 5800 insertions(+), 279 deletions(-) create mode 100644 src/crosschain.rs create mode 100644 src/crosschain/fees.rs create mode 100644 src/crosschain/operations.rs create mode 100644 src/crosschain/security.rs create mode 100644 src/crosschain/signatures.rs create mode 100644 src/crosschain/tests.rs create mode 100644 src/email/payments.rs create mode 100644 src/scanner/api.rs diff --git a/Cargo.lock b/Cargo.lock index 44e1f66..6efe5bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -732,8 +732,11 @@ dependencies = [ "aes-gcm", "anyhow", "base64 0.22.1", + "bech32", + "bs58", "chrono", "dotenvy", + "ed25519-dalek", "futures", "hex", "hmac 0.12.1", @@ -743,7 +746,9 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_json_canonicalizer", "sha2 0.10.9", + "sha3", "sqlx", "subtle", "thiserror 2.0.20", @@ -956,6 +961,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -1090,6 +1122,30 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -1224,6 +1280,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -2038,6 +2100,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -2968,6 +3039,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "schannel" version = "0.1.29" @@ -3096,6 +3173,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -3162,6 +3250,16 @@ dependencies = [ "digest 0.11.0-pre.9", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 347050b..c3b5b89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,10 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", serde = { version = "1", features = ["derive"] } serde_json = "1" -# HTTP client (for CipherScan API + webhooks) +# HTTP client (for ZecBlock API + webhooks) reqwest = { version = "0.12", features = ["json"] } -# WebSocket client (for CipherScan real-time mempool push) +# WebSocket client (for ZecBlock real-time mempool push) tokio-tungstenite = { version = "0.26", features = ["native-tls"] } # Zcash crates (NU6.3 / Ironwood support) @@ -43,6 +43,11 @@ webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] # Crypto / hashing sha2 = "0.10" +sha3 = "0.10" +bs58 = "0.5" +bech32 = "0.11" +ed25519-dalek = "2" +serde_json_canonicalizer = "0.3" hmac = "0.12" hex = "0.4" rand = "0.8" diff --git a/src/api/admin.rs b/src/api/admin.rs index 4b9eb3c..53fb1b8 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -790,6 +790,7 @@ pub async fn rescan_fees( } }; + if !matches!(crate::scanner::blocks::check_tx_confirmed(http.get_ref(),&config.cipherscan_api_url,txid).await,Ok(true)) { skipped+=1; continue; } let raw_hex = match crate::scanner::mempool::fetch_raw_tx( http.get_ref(), &config.cipherscan_api_url, @@ -808,9 +809,9 @@ pub async fn rescan_fees( let fee_memo_prefix = format!("FEE-{}", invoice_id); match crate::scanner::decrypt::try_decrypt_all_outputs(&raw_hex, &fee_ufvk) { Ok(outputs) => { - let found = outputs.iter().any(|o| o.memo.starts_with(&fee_memo_prefix)); - if found { - if let Ok(()) = crate::billing::mark_fee_collected(pool.get_ref(), invoice_id).await { + let units: i64 = outputs.iter().filter(|o| o.memo == fee_memo_prefix).map(|o|o.amount_zatoshis as i64).sum(); + if units>0 { + if let Ok(()) = crate::billing::record_direct_receipt(pool.get_ref(), invoice_id, txid, units).await { collected += 1; details.push(serde_json::json!({ "invoice_id": invoice_id, diff --git a/src/api/auth.rs b/src/api/auth.rs index ba95882..dda2f49 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -150,6 +150,21 @@ pub async fn me(req: HttpRequest, pool: web::Data) -> HttpResponse { .map(|k| !k.is_empty()) .unwrap_or(false); + let crosschain_enabled: bool = sqlx::query_scalar("SELECT crosschain_enabled FROM merchants WHERE id=?").bind(&merchant.id).fetch_one(pool.get_ref()).await.unwrap_or(false); + let payment_email_notifications: bool = match sqlx::query_scalar( + "SELECT payment_email_notifications FROM merchants WHERE id = ?", + ) + .bind(&merchant.id) + .fetch_one(pool.get_ref()) + .await + { + Ok(enabled) => enabled, + Err(e) => { + tracing::error!(error = %e, "Failed to load payment email preference"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Could not load payment settings"})); + } + }; let has_passkeys = super::passkey::has_passkeys(pool.get_ref(), &merchant.id).await; let last_token_login: Option = @@ -168,9 +183,11 @@ pub async fn me(req: HttpRequest, pool: web::Data) -> HttpResponse { "webhook_secret_preview": masked_secret, "has_recovery_email": merchant.recovery_email.is_some(), "recovery_email_preview": masked_email, + "payment_email_notifications": payment_email_notifications, "created_at": merchant.created_at, "stats": stats, "has_luma_key": has_luma_key, + "crosschain_enabled": crosschain_enabled, "has_passkeys": has_passkeys, "last_token_login_at": last_token_login, })) @@ -208,6 +225,15 @@ pub async fn my_invoices(req: HttpRequest, pool: web::Data) -> HttpR match rows { Ok(invoices) => { + let crosschain_ids: std::collections::HashSet = match sqlx::query_scalar::<_, String>( + "SELECT i.id FROM invoices i WHERE i.merchant_id = ? AND EXISTS (SELECT 1 FROM crosschain_attempts c WHERE c.invoice_id = i.id) ORDER BY i.created_at DESC LIMIT 100", + ).bind(&merchant.id).fetch_all(pool.get_ref()).await { + Ok(ids) => ids.into_iter().collect(), + Err(e) => { + tracing::error!(error = %e, "Failed to load invoice payment methods"); + return HttpResponse::InternalServerError().json(serde_json::json!({"error":"Internal error"})); + } + }; let event_product_ids: std::collections::HashSet = sqlx::query_scalar::<_, String>( "SELECT product_id FROM events WHERE merchant_id = ?", @@ -255,11 +281,14 @@ pub async fn my_invoices(req: HttpRequest, pool: web::Data) -> HttpR .collect() }; + let access: std::collections::HashMap = sqlx::query_as::<_,(String,String)>("SELECT a.invoice_id,a.token FROM checkout_access a JOIN invoices i ON i.id=a.invoice_id WHERE i.merchant_id=?") + .bind(&merchant.id).fetch_all(pool.get_ref()).await.unwrap_or_default().into_iter().collect(); let enriched: Vec = invoices .iter() .map(|inv| { let mut val = serde_json::to_value(inv).unwrap_or(serde_json::json!({})); if let serde_json::Value::Object(ref mut map) = val { + if let Some(token)=access.get(&inv.id) { map.insert("checkout_token".into(),serde_json::json!(token)); } let is_event = inv .product_id .as_ref() @@ -272,6 +301,10 @@ pub async fn my_invoices(req: HttpRequest, pool: web::Data) -> HttpR .unwrap_or(false); map.insert("is_event".into(), serde_json::json!(is_event)); map.insert("is_luma".into(), serde_json::json!(is_luma)); + map.insert( + "is_crosschain".into(), + serde_json::json!(crosschain_ids.contains(&inv.id)), + ); let label = inv.price_id.as_ref().and_then(|pid| price_labels.get(pid)); map.insert("price_label".into(), serde_json::json!(label)); } @@ -805,10 +838,12 @@ pub struct UpdateMerchantRequest { pub name: Option, pub webhook_url: Option, pub recovery_email: Option, + pub payment_email_notifications: Option, pub luma_api_key: Option, + pub crosschain_enabled: Option, } -/// PATCH /api/merchants/me -- update name, webhook URL, and/or recovery email. +/// PATCH /api/merchants/me -- update merchant settings and payment email preference. /// /// Payment address is intentionally NOT editable after registration. /// It is cryptographically tied to the UFVK used for trial decryption. @@ -835,93 +870,139 @@ pub async fn update_me( return HttpResponse::BadRequest().json(e.to_json()); } - if let Some(ref name) = body.name { - sqlx::query("UPDATE merchants SET name = ? WHERE id = ?") - .bind(name) - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); - tracing::info!(merchant_id = %merchant.id, "Merchant name updated"); - } + let encrypt = |value: &str| -> anyhow::Result { + if config.encryption_key.is_empty() { + Ok(value.to_string()) + } else { + crate::crypto::encrypt(value, &config.encryption_key) + } + }; + let encrypted_email = match body + .recovery_email + .as_deref() + .filter(|v| !v.is_empty()) + .map(encrypt) + .transpose() + { + Ok(email) => email, + Err(e) => { + tracing::error!(error = %e, "Failed to encrypt recovery email"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Internal error"})); + } + }; + let encrypted_luma_key = match body + .luma_api_key + .as_deref() + .filter(|v| !v.is_empty()) + .map(encrypt) + .transpose() + { + Ok(key) => key, + Err(e) => { + tracing::error!(error = %e, "Failed to encrypt Luma API key"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Internal error"})); + } + }; - if let Some(ref url) = body.webhook_url { - sqlx::query("UPDATE merchants SET webhook_url = ? WHERE id = ?") - .bind(if url.is_empty() { - None - } else { - Some(url.as_str()) - }) - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); - tracing::info!(merchant_id = %merchant.id, "Webhook URL updated"); - } + let mut tx = match pool.begin().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!(error = %e, "Failed to begin merchant settings update"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Could not save payment settings"})); + } + }; - if let Some(ref email) = body.recovery_email { - if email.is_empty() { - sqlx::query("UPDATE merchants SET recovery_email = NULL, recovery_email_hash = NULL WHERE id = ?") - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); + // Read inside the transaction so a concurrent email removal cannot leave + // notifications enabled without a recipient. Validate before any writes. + if body.payment_email_notifications == Some(true) { + let effective_email = if let Some(email) = body.recovery_email.as_deref() { + Some(email.to_string()) } else { - let encrypted = if config.encryption_key.is_empty() { - email.clone() - } else { - match crate::crypto::encrypt(email, &config.encryption_key) { - Ok(enc) => enc, + let stored_email: Option = + match sqlx::query_scalar("SELECT recovery_email FROM merchants WHERE id = ?") + .bind(&merchant.id) + .fetch_one(&mut *tx) + .await + { + Ok(email) => email, Err(e) => { - tracing::error!(error = %e, "Failed to encrypt recovery email"); + tracing::error!(error = %e, "Failed to load notification recipient"); return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Internal error"})); + .json(serde_json::json!({"error": "Could not save payment settings"})); } + }; + match stored_email + .map(|email| crate::crypto::decrypt_email(&email, &config.encryption_key)) + .transpose() + { + Ok(email) => email, + Err(e) => { + tracing::error!(error = %e, "Failed to decrypt notification recipient"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Could not save payment settings"})); } - }; - let hash = crate::crypto::blind_index(email, &config.encryption_key); - sqlx::query( - "UPDATE merchants SET recovery_email = ?, recovery_email_hash = ? WHERE id = ?", - ) - .bind(&encrypted) - .bind(&hash) - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); + } + }; + let Some(email) = effective_email.as_deref().filter(|email| !email.is_empty()) else { + return HttpResponse::BadRequest().json( + validation::ValidationError::invalid( + "payment_email_notifications", + "add a recovery email before enabling payment notifications", + ) + .to_json(), + ); + }; + if let Err(e) = validation::validate_email_format("recovery_email", email) { + return HttpResponse::BadRequest().json(e.to_json()); } - tracing::info!(merchant_id = %merchant.id, "Recovery email updated"); } - if let Some(ref key) = body.luma_api_key { - if key.is_empty() { - sqlx::query("UPDATE merchants SET luma_api_key = NULL WHERE id = ?") - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); - } else { - let encrypted = if config.encryption_key.is_empty() { - key.clone() - } else { - match crate::crypto::encrypt(key, &config.encryption_key) { - Ok(enc) => enc, - Err(e) => { - tracing::error!(error = %e, "Failed to encrypt Luma API key"); - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Internal error"})); - } - } + let result: Result<(), sqlx::Error> = async { + if let Some(enabled) = body.crosschain_enabled { + sqlx::query("UPDATE merchants SET crosschain_enabled = ? WHERE id = ?") + .bind(enabled).bind(&merchant.id).execute(&mut *tx).await?; + } + if let Some(ref name) = body.name { + sqlx::query("UPDATE merchants SET name = ? WHERE id = ?") + .bind(name).bind(&merchant.id).execute(&mut *tx).await?; + } + if let Some(ref url) = body.webhook_url { + sqlx::query("UPDATE merchants SET webhook_url = ? WHERE id = ?") + .bind(if url.is_empty() { None } else { Some(url.as_str()) }) + .bind(&merchant.id).execute(&mut *tx).await?; + } + if let Some(ref email) = body.recovery_email { + let hash = if email.is_empty() { None } else { + Some(crate::crypto::blind_index(email, &config.encryption_key)) }; + sqlx::query( + "UPDATE merchants SET recovery_email = ?, recovery_email_hash = ?, + payment_email_notifications = CASE WHEN ? THEN 0 ELSE payment_email_notifications END + WHERE id = ?", + ) + .bind(&encrypted_email).bind(hash).bind(email.is_empty()) + .bind(&merchant.id).execute(&mut *tx).await?; + } + if let Some(enabled) = body.payment_email_notifications { + sqlx::query("UPDATE merchants SET payment_email_notifications = ? WHERE id = ?") + .bind(enabled).bind(&merchant.id).execute(&mut *tx).await?; + } + if body.luma_api_key.is_some() { sqlx::query("UPDATE merchants SET luma_api_key = ? WHERE id = ?") - .bind(&encrypted) - .bind(&merchant.id) - .execute(pool.get_ref()) - .await - .ok(); + .bind(&encrypted_luma_key).bind(&merchant.id).execute(&mut *tx).await?; } - tracing::info!(merchant_id = %merchant.id, "Luma API key updated"); + tx.commit().await?; + Ok(()) + }.await; + if let Err(e) = result { + tracing::error!(error = %e, merchant_id = %merchant.id, "Failed to update merchant settings"); + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Could not save payment settings"})); } + tracing::info!(merchant_id = %merchant.id, "Merchant settings updated"); HttpResponse::Ok().json(serde_json::json!({ "status": "updated" })) } @@ -1106,6 +1187,7 @@ async fn get_merchant_stats(pool: &SqlitePool, merchant_id: &str) -> serde_json: .unwrap_or((0, 0, 0.0)); serde_json::json!({ + "payment_methods": crate::crosschain::fees::payment_methods(pool, merchant_id).await.ok(), "total_invoices": row.0, "confirmed": row.1, "total_zec": row.2, @@ -1167,3 +1249,286 @@ pub async fn connect_shopify(req: HttpRequest, pool: web::Data) -> H Err(_) => HttpResponse::InternalServerError().finish(), } } + +#[cfg(test)] +mod payment_email_tests { + use super::*; + use actix_web::{ + http::{Method, StatusCode}, + test, App, + }; + use serde_json::{json, Value}; + + async fn fixture() -> (SqlitePool, Config) { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "email-owner").await; + crate::repair_tests::merchant(&pool, "other-owner").await; + sqlx::query( + "INSERT INTO sessions (id, merchant_id, expires_at, pos_scoped) VALUES + ('owner-session', 'email-owner', '2099-01-01T00:00:00Z', 0), + ('pos-session', 'email-owner', '2099-01-01T00:00:00Z', 1), + ('other-session', 'other-owner', '2099-01-01T00:00:00Z', 0)", + ) + .execute(&pool) + .await + .unwrap(); + let mut config = Config::from_env().unwrap(); + config.encryption_key = "11".repeat(32); + config.network = "testnet".to_string(); + config.smtp_pass = None; + config.smtp_from = None; + (pool, config) + } + + async fn request( + pool: &SqlitePool, + config: &Config, + method: Method, + session: Option<&str>, + body: Option, + ) -> (StatusCode, Value) { + let app = test::init_service( + App::new() + .app_data(web::Data::new(pool.clone())) + .app_data(web::Data::new(config.clone())) + .service( + web::resource("/api/merchants/me") + .route(web::get().to(me)) + .route(web::patch().to(update_me)), + ), + ) + .await; + let mut req = test::TestRequest::default() + .uri("/api/merchants/me") + .method(method); + if let Some(session) = session { + req = req.cookie(Cookie::new(SESSION_COOKIE_LEGACY, session.to_string())); + } + if let Some(body) = body { + req = req.set_json(body); + } + let response = test::call_service(&app, req.to_request()).await; + let status = response.status(); + let body = test::read_body_json(response).await; + (status, body) + } + + #[actix_web::test] + async fn payment_email_requires_an_email_before_changing_any_settings() { + let (pool, config) = fixture().await; + let (status, body) = + request(&pool, &config, Method::GET, Some("owner-session"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["payment_email_notifications"], false); + let (status, body) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "name": "Should not be written", + "crosschain_enabled": false, + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["field"], "payment_email_notifications"); + let saved: (String, bool, bool) = sqlx::query_as( + "SELECT name, crosschain_enabled, payment_email_notifications FROM merchants WHERE id = 'email-owner'", + ).fetch_one(&pool).await.unwrap(); + assert_eq!(saved, (String::new(), true, false)); + } + + #[actix_web::test] + async fn payment_email_preference_can_enable_disable_and_use_encrypted_saved_email() { + let (pool, config) = fixture().await; + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "recovery_email": "owner@example.com", + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let encrypted: String = + sqlx::query_scalar("SELECT recovery_email FROM merchants WHERE id = 'email-owner'") + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(encrypted, "owner@example.com"); + assert_eq!( + crate::crypto::decrypt_email(&encrypted, &config.encryption_key).unwrap(), + "owner@example.com" + ); + let (status, body) = + request(&pool, &config, Method::GET, Some("owner-session"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["payment_email_notifications"], true); + assert_eq!(body["recovery_email_preview"], "ow***@example.com"); + for enabled in [false, true] { + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "payment_email_notifications": enabled, + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let saved: (String, bool) = sqlx::query_as("SELECT recovery_email, payment_email_notifications FROM merchants WHERE id = 'email-owner'") + .fetch_one(&pool).await.unwrap(); + assert_eq!(saved, (encrypted.clone(), enabled)); + } + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "name": "Changed name", + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, body) = + request(&pool, &config, Method::GET, Some("other-session"), None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["payment_email_notifications"], false); + let enabled: bool = sqlx::query_scalar( + "SELECT payment_email_notifications FROM merchants WHERE id = 'email-owner'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(enabled); + } + + #[actix_web::test] + async fn payment_email_removal_disables_notifications_and_conflicting_enable_is_rejected() { + let (pool, config) = fixture().await; + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "recovery_email": "owner@example.com", + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "recovery_email": "", + "payment_email_notifications": true, + "name": "Should not change", + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "recovery_email": "", + })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let saved: (Option, Option, bool, String) = sqlx::query_as( + "SELECT recovery_email, recovery_email_hash, payment_email_notifications, name FROM merchants WHERE id = 'email-owner'", + ).fetch_one(&pool).await.unwrap(); + assert_eq!(saved, (None, None, false, String::new())); + sqlx::query( + "UPDATE merchants SET recovery_email = 'owner@invalid' WHERE id = 'email-owner'", + ) + .execute(&pool) + .await + .unwrap(); + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + } + + #[actix_web::test] + async fn payment_email_settings_reject_unauthenticated_and_pos_sessions() { + let (pool, config) = fixture().await; + for session in [None, Some("pos-session")] { + let (status, _) = request( + &pool, + &config, + Method::PATCH, + session, + Some(json!({ + "recovery_email": "owner@example.com", + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + let enabled: bool = sqlx::query_scalar( + "SELECT payment_email_notifications FROM merchants WHERE id = 'email-owner'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!enabled); + } + + #[actix_web::test] + async fn payment_email_storage_errors_roll_back_other_settings_changes() { + let (pool, config) = fixture().await; + sqlx::query( + "UPDATE merchants SET recovery_email = 'owner@example.com' WHERE id = 'email-owner'", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER reject_payment_email_preference BEFORE UPDATE OF payment_email_notifications ON merchants + BEGIN SELECT RAISE(ABORT, 'simulated write failure'); END", + ).execute(&pool).await.unwrap(); + let (status, _) = request( + &pool, + &config, + Method::PATCH, + Some("owner-session"), + Some(json!({ + "name": "Should roll back", + "payment_email_notifications": true, + })), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + let saved: (String, bool) = sqlx::query_as( + "SELECT name, payment_email_notifications FROM merchants WHERE id = 'email-owner'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(saved, (String::new(), false)); + } +} diff --git a/src/api/billing_routes.rs b/src/api/billing_routes.rs index 22a591b..99a5290 100644 --- a/src/api/billing_routes.rs +++ b/src/api/billing_routes.rs @@ -7,14 +7,38 @@ pub async fn billing_summary( req: actix_web::HttpRequest, pool: web::Data, config: web::Data, + gateway: web::Data, ) -> actix_web::HttpResponse { let merchant = match auth::require_session(&req, pool.get_ref()).await { Ok(merchant) => merchant, Err(response) => return response, }; + let referrals = match crate::referrals::summary(&pool,&merchant.id,&config.encryption_key).await { + Ok(v)=>v, Err(_)=>return actix_web::HttpResponse::InternalServerError().finish(), + }; + let pending_fees: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deferred_payment_fees WHERE merchant_id=? AND state='verifying'") + .bind(&merchant.id).fetch_one(pool.get_ref()).await.unwrap_or(0); + let crosschain = match crate::crosschain::fees::merchant_summary( + pool.get_ref(), + &merchant.id, + &config.encryption_key, + gateway.collects_fees(), + ) + .await + { + Ok(summary) => summary, + Err(e) => { + tracing::error!(error=%e, "Failed to load cross-chain billing"); + return actix_web::HttpResponse::InternalServerError() + .json(serde_json::json!({"error":"Could not load billing"})); + } + }; if !config.fee_enabled() { return actix_web::HttpResponse::Ok().json(serde_json::json!({ + "crosschain": crosschain, + "referrals": referrals, + "verifying_payment_fees": pending_fees, "fee_enabled": false, "fee_rate": 0.0, "billing_status": "active", @@ -24,6 +48,9 @@ pub async fn billing_summary( match crate::billing::get_billing_summary(pool.get_ref(), &merchant.id, &config).await { Ok(summary) => actix_web::HttpResponse::Ok().json(serde_json::json!({ + "crosschain": crosschain, + "referrals": referrals, + "verifying_payment_fees": pending_fees, "fee_enabled": true, "fee_rate": summary.fee_rate, "trust_tier": summary.trust_tier, @@ -198,8 +225,11 @@ pub async fn delete_account( match crate::merchants::delete_merchant(pool.get_ref(), &merchant.id).await { Ok(()) => actix_web::HttpResponse::Ok().json(serde_json::json!({ "status": "deleted", - "message": "Your account and all associated data have been permanently deleted." + "message": "Your account has been deleted. Minimal referral eligibility and earned commission records are retained." })), + Err(e) if e.downcast_ref::().is_some() => { + actix_web::HttpResponse::Conflict().json(serde_json::json!({"error":e.to_string()})) + } Err(e) => { tracing::error!(error = %e, "Failed to delete merchant account"); actix_web::HttpResponse::InternalServerError().json(serde_json::json!({ diff --git a/src/api/invoice_routes.rs b/src/api/invoice_routes.rs index 881d87b..9840031 100644 --- a/src/api/invoice_routes.rs +++ b/src/api/invoice_routes.rs @@ -28,6 +28,7 @@ pub async fn list_invoices( refund_address, invoices.created_at, price_zatoshis, received_zatoshis, confirmed_rate, confirmed_fiat_amount, (EXISTS (SELECT 1 FROM events WHERE product_id = invoices.product_id)) AS is_event, + (EXISTS (SELECT 1 FROM crosschain_attempts WHERE invoice_id = invoices.id)) AS is_crosschain, pr.label AS price_label, invoices.is_donation, invoices.payment_link_id FROM invoices @@ -75,6 +76,7 @@ pub async fn list_invoices( "received_zatoshis": rz, "overpaid": rz > pz + 1000 && pz > 0, "is_event": r.get::("is_event"), + "is_crosschain": r.get::("is_crosschain"), "price_label": r.get::, _>("price_label"), "is_donation": r.get::("is_donation") == 1, "payment_link_id": r.get::, _>("payment_link_id"), diff --git a/src/api/invoices.rs b/src/api/invoices.rs index a892863..517fdb8 100644 --- a/src/api/invoices.rs +++ b/src/api/invoices.rs @@ -49,7 +49,8 @@ pub async fn create( } }; - let fee_config = crate::billing::fee_config_for_merchant(pool.get_ref(), &merchant.id, &config).await; + let fee_config = + crate::billing::fee_config_for_merchant(pool.get_ref(), &merchant.id, &config).await; match invoices::create_invoice( pool.get_ref(), @@ -62,7 +63,11 @@ pub async fn create( ) .await { - Ok(resp) => HttpResponse::Created().json(resp), + Ok(resp) => { + let url=format!("{}/pay/{}#checkout={}",config.frontend_url.as_deref().unwrap_or("https://cipherpay.app"),resp.invoice_id,resp.checkout_token); + let mut value=serde_json::to_value(resp).unwrap(); value["checkout_url"]=serde_json::json!(url); + HttpResponse::Created().insert_header(("Cache-Control","no-store")).json(value) + }, Err(e) => { tracing::error!(error = %e, "Failed to create invoice"); HttpResponse::InternalServerError().json(serde_json::json!({ @@ -97,6 +102,14 @@ pub async fn get(pool: web::Data, path: web::Path) -> HttpRe let overpaid = inv.received_zatoshis > inv.price_zatoshis + 1000 && inv.price_zatoshis > 0; + let is_crosschain = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM crosschain_attempts WHERE invoice_id=?)", + ) + .bind(&inv.id) + .fetch_one(pool.get_ref()) + .await + .unwrap_or(false); + let merchant_origin = get_merchant_webhook_origin(pool.get_ref(), &inv.merchant_id).await; @@ -179,6 +192,7 @@ pub async fn get(pool: web::Data, path: web::Path) -> HttpRe "price_zatoshis": inv.price_zatoshis, "received_zatoshis": inv.received_zatoshis, "overpaid": overpaid, + "is_crosschain": is_crosschain, "is_event": is_event, "is_luma": is_luma, "is_donation": inv.is_donation == 1, @@ -275,7 +289,9 @@ pub async fn finalize( }; let fee_config = match invoices::get_invoice(pool.get_ref(), &invoice_id).await { - Ok(Some(inv)) => crate::billing::fee_config_for_merchant(pool.get_ref(), &inv.merchant_id, &config).await, + Ok(Some(inv)) => { + crate::billing::fee_config_for_merchant(pool.get_ref(), &inv.merchant_id, &config).await + } _ => None, }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 3168796..aa528e5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -58,7 +58,36 @@ pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/api") + .route("/admin/crosschain", web::get().to(crate::crosschain::operations::admin_issues)) + .route("/admin/crosschain/{id}/retry", web::post().to(crate::crosschain::operations::retry)) .route("/health", web::get().to(system::health)) + .route("/invoices/{id}/checkout-access", web::get().to(crate::crosschain::security::access)) + .service( + web::resource("/crosschain/assets") + .wrap(Governor::new(&public_read_limit)) + .route(web::get().to(crate::crosschain::assets)), + ) + .service( + web::resource("/invoices/{id}/crosschain/send") + .wrap(Governor::new(&checkout_rate_limit)) + .route(web::post().to(crate::crosschain::wallet_send)), + ) + .service( + web::resource("/invoices/{id}/crosschain/eligibility") + .wrap(Governor::new(&checkout_rate_limit)) + .route(web::get().to(crate::crosschain::eligibility)), + ) + .service( + web::resource("/invoices/{id}/crosschain/preview") + .wrap(Governor::new(&checkout_rate_limit)) + .route(web::post().to(crate::crosschain::preview)), + ) + .service( + web::resource("/invoices/{id}/crosschain") + .wrap(Governor::new(&checkout_rate_limit)) + .route(web::get().to(crate::crosschain::status)) + .route(web::post().to(crate::crosschain::create)), + ) // Registration POST is a Resource (not a Route inside the parent // scope) so the rate limiter actually runs. actix-web 4's // `Route::wrap` is a no-op for short-circuiting middleware like @@ -73,6 +102,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service( web::scope("/merchants") .route("/me", web::get().to(auth::me)) + .route("/me/referral", web::post().to(crate::referrals::apply)) .route("/me", web::patch().to(auth::update_me)) .route("/me/invoices", web::get().to(auth::my_invoices)) .route( @@ -692,6 +722,7 @@ async fn checkout( .collect(); checkout_url = format!("{}?return_url={}", checkout_url, encoded); } + checkout_url.push_str(&format!("#checkout={}", resp.checkout_token)); obj.insert( "checkout_url".to_string(), serde_json::Value::String(checkout_url), diff --git a/src/api/payment_links.rs b/src/api/payment_links.rs index f55198c..feae950 100644 --- a/src/api/payment_links.rs +++ b/src/api/payment_links.rs @@ -424,9 +424,10 @@ async fn resolve_donation( .frontend_url .as_deref() .unwrap_or("https://cipherpay.app"); - let checkout_url = format!("{}/pay/{}", frontend_url, resp.invoice_id); + let checkout_url = format!("{}/pay/{}#checkout={}", frontend_url, resp.invoice_id, resp.checkout_token); HttpResponse::Created().json(serde_json::json!({ + "checkout_token": resp.checkout_token, "invoice_id": resp.invoice_id, "checkout_url": checkout_url, "payment_address": resp.payment_address, @@ -568,7 +569,9 @@ async fn resolve_payment( checkout_url = format!("{}?return_url={}", checkout_url, encoded); } + checkout_url.push_str(&format!("#checkout={}", resp.checkout_token)); HttpResponse::Created().json(serde_json::json!({ + "checkout_token": resp.checkout_token, "invoice_id": resp.invoice_id, "checkout_url": checkout_url, "payment_address": resp.payment_address, diff --git a/src/api/system.rs b/src/api/system.rs index e15fc7d..675ecdc 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -89,6 +89,13 @@ pub async fn health( }; let _ = price_ok; + match crate::crosschain::operations::issues(&pool).await { + Ok(issues) => { + if !issues.is_empty() { degraded = true; } + checks.insert("crosschain".into(), serde_json::json!({"status":if issues.is_empty(){"ok"}else{"needs_review"},"attention_count":issues.len()})); + } + Err(_) => { degraded = true; checks.insert("crosschain".into(),serde_json::json!({"status":"unavailable"})); } + } let status = if unhealthy { "unhealthy" } else if degraded { diff --git a/src/billing/fee_ledger.rs b/src/billing/fee_ledger.rs index 8434099..c016b87 100644 --- a/src/billing/fee_ledger.rs +++ b/src/billing/fee_ledger.rs @@ -16,7 +16,10 @@ pub async fn get_effective_fee_rate( .fetch_optional(pool) .await? .flatten(); - Ok(merchant_rate.unwrap_or(config.fee_rate)) + let discounted: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM merchant_referrals WHERE merchant_id=? AND discount_until>strftime('%Y-%m-%dT%H:%M:%SZ','now'))") + .bind(merchant_id).fetch_one(pool).await?; + let base = merchant_rate.unwrap_or(config.fee_rate); + Ok(if discounted { base.min(0.005) } else { base }) } pub async fn create_fee_entry( @@ -30,16 +33,34 @@ pub async fn create_fee_entry( let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let fee_amount_zatoshis = crate::invoices::zec_to_zatoshis(fee_amount_zec)?; - let cycle_id: Option = sqlx::query_scalar( - "SELECT id FROM billing_cycles WHERE merchant_id = ? AND status = 'open' LIMIT 1", + // The entry and aggregate must commit together. A duplicate scanner event + // must not increase the cycle balance when its invoice entry already exists. + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let settled: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM crosschain_settlements WHERE invoice_id=?)", ) - .bind(merchant_id) - .fetch_optional(pool) + .bind(invoice_id) + .fetch_one(&mut *tx) .await?; - - sqlx::query( - "INSERT OR IGNORE INTO fee_ledger (id, invoice_id, merchant_id, fee_amount_zec, fee_amount_zatoshis, fee_rate_applied, billing_cycle_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + if settled { + return Ok(()); + } + let unresolved: bool = sqlx::query_scalar("SELECT (EXISTS(SELECT 1 FROM crosschain_attempts WHERE invoice_id=?) OR EXISTS(SELECT 1 FROM crosschain_fees WHERE invoice_id=?)) AND NOT EXISTS(SELECT 1 FROM deferred_payment_fees WHERE invoice_id=? AND state='direct')") + .bind(invoice_id).bind(invoice_id).bind(invoice_id).fetch_one(&mut *tx).await?; + if unresolved { + // Preserve the obligation, but do not bill during provider outages or + // before we can distinguish a direct transfer from a delayed swap. + sqlx::query("INSERT INTO deferred_payment_fees(invoice_id,merchant_id,amount_zec,rate) VALUES (?,?,?,?) ON CONFLICT(invoice_id) DO NOTHING") + .bind(invoice_id).bind(merchant_id).bind(fee_amount_zec).bind(fee_rate_applied).execute(&mut *tx).await?; + tx.commit().await?; + return Ok(()); + } + let inserted: Option<(Option,)> = sqlx::query_as( + "INSERT INTO fee_ledger (id, invoice_id, merchant_id, fee_amount_zec, fee_amount_zatoshis, fee_rate_applied, billing_cycle_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, + (SELECT id FROM billing_cycles WHERE merchant_id = ? AND status = 'open' ORDER BY created_at DESC LIMIT 1), ?) + ON CONFLICT(invoice_id) DO NOTHING + RETURNING billing_cycle_id", ) .bind(&id) .bind(invoice_id) @@ -47,12 +68,12 @@ pub async fn create_fee_entry( .bind(fee_amount_zec) .bind(fee_amount_zatoshis) .bind(fee_rate_applied) - .bind(&cycle_id) + .bind(merchant_id) .bind(&now) - .execute(pool) + .fetch_optional(&mut *tx) .await?; - if let Some(cid) = &cycle_id { + if let Some((Some(cid),)) = inserted { sqlx::query( "UPDATE billing_cycles SET total_fees_zatoshis = total_fees_zatoshis + ?, @@ -66,54 +87,200 @@ pub async fn create_fee_entry( .bind(fee_amount_zatoshis) .bind(fee_amount_zatoshis) .bind(cid) - .execute(pool) + .execute(&mut *tx) .await?; } + tx.commit().await?; tracing::debug!(invoice_id, fee_amount_zec, "Fee entry created"); Ok(()) } +#[cfg(test)] pub async fn mark_fee_collected(pool: &SqlitePool, invoice_id: &str) -> anyhow::Result<()> { - let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let amount: Option = + sqlx::query_scalar("SELECT fee_amount_zatoshis FROM fee_ledger WHERE invoice_id=?") + .bind(invoice_id) + .fetch_optional(pool) + .await?; + if let Some(amount) = amount { + apply_collected(pool, invoice_id, amount).await?; + } + Ok(()) +} - let result = sqlx::query( - "UPDATE fee_ledger SET auto_collected = 1, collected_at = ? - WHERE invoice_id = ? AND auto_collected = 0", - ) - .bind(&now) - .bind(invoice_id) - .execute(pool) - .await?; +pub async fn record_direct_receipt( + pool: &SqlitePool, + invoice: &str, + txid: &str, + units: i64, +) -> anyhow::Result<()> { + anyhow::ensure!(units > 0, "Invalid fee receipt"); + // Caller has verified raw transaction outputs and all contributing ZEC + // transactions' confirmations. A mempool observation cannot earn rewards. + sqlx::query("INSERT INTO direct_fee_receipts(invoice_id,txid,units) VALUES (?,?,?) ON CONFLICT(invoice_id,txid) DO NOTHING") + .bind(invoice).bind(txid).bind(units).execute(pool).await?; + let total: i64 = + sqlx::query_scalar("SELECT SUM(units) FROM direct_fee_receipts WHERE invoice_id=?") + .bind(invoice) + .fetch_one(pool) + .await?; + apply_collected(pool, invoice, total).await?; + Ok(()) +} + +async fn apply_collected(pool: &SqlitePool, invoice: &str, received: i64) -> anyhow::Result<()> { + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let old: Option<(i64,i64,Option)>=sqlx::query_as("SELECT fee_amount_zatoshis,collected_zatoshis,billing_cycle_id FROM fee_ledger WHERE invoice_id=?") + .bind(invoice).fetch_optional(&mut *tx).await?; + if let Some((due, previous, cycle)) = old { + let collected = received.min(due).max(previous); + let delta = collected - previous; + sqlx::query("UPDATE fee_ledger SET collected_zatoshis=?,auto_collected=?,collected_at=CASE WHEN ?>=fee_amount_zatoshis THEN strftime('%Y-%m-%dT%H:%M:%SZ','now') ELSE collected_at END WHERE invoice_id=?") + .bind(collected).bind(collected>=due).bind(collected).bind(invoice).execute(&mut *tx).await?; + if let Some(cycle) = cycle { + sqlx::query("UPDATE billing_cycles SET auto_collected_zatoshis=auto_collected_zatoshis+?,outstanding_zatoshis=MAX(0,outstanding_zatoshis-?), + auto_collected_zec=(auto_collected_zatoshis+?)/100000000.0,outstanding_zec=MAX(0,outstanding_zatoshis-?)/100000000.0 WHERE id=?") + .bind(delta).bind(delta).bind(delta).bind(delta).bind(cycle).execute(&mut *tx).await?; + } + } + tx.commit().await?; + Ok(()) +} - if result.rows_affected() > 0 { - let entry: Option<(i64, Option)> = sqlx::query_as( - "SELECT fee_amount_zatoshis, billing_cycle_id FROM fee_ledger WHERE invoice_id = ?", +#[cfg(test)] +mod tests { + use super::*; + + async fn fixture() -> SqlitePool { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "fees").await; + crate::repair_tests::invoice(&pool, "payment", "fees").await; + sqlx::query( + "INSERT INTO billing_cycles (id, merchant_id, period_start, period_end, status) + VALUES ('cycle', 'fees', '2026-10-01', '2026-11-01', 'open')", ) - .bind(invoice_id) - .fetch_optional(pool) - .await?; + .execute(&pool) + .await + .unwrap(); + pool + } - if let Some((amount, Some(cycle_id))) = entry { - sqlx::query( - "UPDATE billing_cycles SET - auto_collected_zatoshis = auto_collected_zatoshis + ?, - outstanding_zatoshis = MAX(0, outstanding_zatoshis - ?), - auto_collected_zec = (auto_collected_zatoshis + ?) / 100000000.0, - outstanding_zec = MAX(0, outstanding_zatoshis - ?) / 100000000.0 - WHERE id = ?", - ) - .bind(amount) - .bind(amount) - .bind(amount) - .bind(amount) - .bind(&cycle_id) - .execute(pool) - .await?; - } + async fn totals(pool: &SqlitePool) -> (i64, i64, i64) { + sqlx::query_as( + "SELECT total_fees_zatoshis, auto_collected_zatoshis, outstanding_zatoshis + FROM billing_cycles WHERE id='cycle'", + ) + .fetch_one(pool) + .await + .unwrap() + } - tracing::info!(invoice_id, "Fee auto-collected"); + #[tokio::test] + async fn repeated_and_concurrent_fee_events_charge_once() { + let pool = fixture().await; + let (a, b) = tokio::join!( + create_fee_entry(&pool, "payment", "fees", 0.001, 0.01), + create_fee_entry(&pool, "payment", "fees", 0.001, 0.01) + ); + a.unwrap(); + b.unwrap(); + assert_eq!(totals(&pool).await, (100_000, 0, 100_000)); + let (a, b) = tokio::join!( + mark_fee_collected(&pool, "payment"), + mark_fee_collected(&pool, "payment") + ); + a.unwrap(); + b.unwrap(); + // A late retry cannot reopen a collected liability, even after a rate change. + create_fee_entry(&pool, "payment", "fees", 0.002, 0.02) + .await + .unwrap(); + assert_eq!(totals(&pool).await, (100_000, 100_000, 0)); } - Ok(()) + #[tokio::test] + async fn aggregate_failure_rolls_back_fee_insert_and_collection() { + let pool = fixture().await; + sqlx::query( + "CREATE TRIGGER fail_cycle_update BEFORE UPDATE ON billing_cycles + BEGIN SELECT RAISE(ABORT, 'test failure'); END", + ) + .execute(&pool) + .await + .unwrap(); + assert!(create_fee_entry(&pool, "payment", "fees", 0.001, 0.01) + .await + .is_err()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fee_ledger") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + sqlx::query("DROP TRIGGER fail_cycle_update") + .execute(&pool) + .await + .unwrap(); + create_fee_entry(&pool, "payment", "fees", 0.001, 0.01) + .await + .unwrap(); + sqlx::query( + "CREATE TRIGGER fail_cycle_update BEFORE UPDATE ON billing_cycles + BEGIN SELECT RAISE(ABORT, 'test failure'); END", + ) + .execute(&pool) + .await + .unwrap(); + assert!(mark_fee_collected(&pool, "payment").await.is_err()); + let collected: i64 = + sqlx::query_scalar("SELECT auto_collected FROM fee_ledger WHERE invoice_id='payment'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(collected, 0); + assert_eq!(totals(&pool).await, (100_000, 0, 100_000)); + sqlx::query("DROP TRIGGER fail_cycle_update") + .execute(&pool) + .await + .unwrap(); + mark_fee_collected(&pool, "payment").await.unwrap(); + assert_eq!(totals(&pool).await, (100_000, 100_000, 0)); + } +} + +#[cfg(test)] +mod actual_receipt_tests { + use super::*; + #[tokio::test] + async fn partial_receipts_reduce_only_actual_liability_and_replays_are_free() { + let p = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&p, "m").await; + crate::repair_tests::invoice(&p, "i", "m").await; + sqlx::query("INSERT INTO billing_cycles(id,merchant_id,period_start,period_end,status) VALUES ('c','m','2026-01-01','2026-02-01','open')").execute(&p).await.unwrap(); + create_fee_entry(&p, "i", "m", 0.001, 0.01).await.unwrap(); + for _ in 0..2 { + record_direct_receipt(&p, "i", "part", 1).await.unwrap(); + } + let balance: (i64, i64) = sqlx::query_as( + "SELECT outstanding_zatoshis,auto_collected_zatoshis FROM billing_cycles WHERE id='c'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(balance, (99999, 1)); + let fully: bool = + sqlx::query_scalar("SELECT auto_collected FROM fee_ledger WHERE invoice_id='i'") + .fetch_one(&p) + .await + .unwrap(); + assert!(!fully); + record_direct_receipt(&p, "i", "rest", 99999).await.unwrap(); + let balance: (i64, i64) = sqlx::query_as( + "SELECT outstanding_zatoshis,auto_collected_zatoshis FROM billing_cycles WHERE id='c'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(balance, (0, 100000)); + } } diff --git a/src/billing/mod.rs b/src/billing/mod.rs index 6b2b138..8f3cf4a 100644 --- a/src/billing/mod.rs +++ b/src/billing/mod.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::config::Config; -pub use fee_ledger::{create_fee_entry, get_effective_fee_rate, mark_fee_collected}; +pub use fee_ledger::{create_fee_entry, get_effective_fee_rate, record_direct_receipt}; pub use status::get_merchant_billing_status; pub use types::{BillingCycle, BillingSummary, MIN_SETTLEMENT_ZATOSHIS}; @@ -231,8 +231,8 @@ pub async fn create_settlement_invoice( sqlx::query( "INSERT INTO invoices (id, merchant_id, memo_code, product_name, price_eur, price_usd, currency, price_zec, - zec_rate_at_creation, payment_address, zcash_uri, status, expires_at, created_at, price_zatoshis) - VALUES (?, ?, ?, 'Fee Settlement', ?, ?, 'EUR', ?, ?, ?, ?, 'pending', ?, ?, ?)" + zec_rate_at_creation, payment_address, zcash_uri, status, expires_at, created_at, price_zatoshis, exact_payment) + VALUES (?, ?, ?, 'Fee Settlement', ?, ?, 'EUR', ?, ?, ?, ?, 'pending', ?, ?, ?, 1)" ) .bind(&id) .bind(merchant_id) @@ -595,11 +595,12 @@ pub async fn process_billing_cycles( /// Check if a settlement invoice was paid and restore merchant access. pub async fn check_settlement_payments(pool: &SqlitePool, config: &Config) -> anyhow::Result<()> { + crate::referrals::reconcile(pool).await?; let settled = sqlx::query_as::<_, BillingCycle>( "SELECT bc.* FROM billing_cycles bc JOIN invoices i ON i.id = bc.settlement_invoice_id WHERE bc.status IN ('invoiced', 'past_due', 'suspended') - AND i.status = 'confirmed'", + AND i.status = 'confirmed' AND i.received_zatoshis>=i.price_zatoshis", ) .fetch_all(pool) .await?; @@ -607,19 +608,52 @@ pub async fn check_settlement_payments(pool: &SqlitePool, config: &Config) -> an let enc_key = &config.encryption_key; for cycle in &settled { - sqlx::query( - "UPDATE billing_cycles - SET status = 'paid', outstanding_zec = 0.0, outstanding_zatoshis = 0 - WHERE id = ?", + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let claimed=sqlx::query("UPDATE billing_cycles SET status='paid',outstanding_zec=0.0,outstanding_zatoshis=0 WHERE id=? AND status IN ('invoiced','past_due','suspended')") + .bind(&cycle.id).execute(&mut *tx).await?.rows_affected(); + if claimed == 0 { + continue; + } + let settlement = cycle + .settlement_invoice_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing settlement"))?; + let mut budget: i64 = sqlx::query_scalar( + "SELECT MIN(received_zatoshis,price_zatoshis) FROM invoices WHERE id=?", ) - .bind(&cycle.id) - .execute(pool) + .bind(settlement) + .fetch_one(&mut *tx) .await?; - - sqlx::query("UPDATE merchants SET billing_status = 'active' WHERE id = ?") + let entries: Vec<(String,i64)>=sqlx::query_as("SELECT f.invoice_id,f.fee_amount_zatoshis-f.collected_zatoshis FROM fee_ledger f JOIN billing_cycles b ON b.id=f.billing_cycle_id + WHERE f.merchant_id=? AND (b.id=? OR b.status='carried_over') AND f.fee_amount_zatoshis>f.collected_zatoshis ORDER BY f.created_at,f.invoice_id") + .bind(&cycle.merchant_id).bind(&cycle.id).fetch_all(&mut *tx).await?; + for (invoice, remaining) in entries { + let units = remaining.min(budget); + if units <= 0 { + break; + } + budget -= units; + sqlx::query("INSERT INTO direct_fee_receipts(invoice_id,txid,units) VALUES (?,?,?)") + .bind(&invoice) + .bind(format!("settlement:{settlement}")) + .bind(units) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE fee_ledger SET collected_zatoshis=collected_zatoshis+? WHERE invoice_id=?", + ) + .bind(units) + .bind(&invoice) + .execute(&mut *tx) + .await?; + } + sqlx::query("UPDATE merchants SET billing_status='active' WHERE id=?") .bind(&cycle.merchant_id) - .execute(pool) + .execute(&mut *tx) .await?; + tx.commit().await?; + crate::referrals::reconcile(pool).await?; + tracing::info!(merchant_id = %cycle.merchant_id, "Settlement paid, merchant restored"); if let Some(email) = diff --git a/src/config.rs b/src/config.rs index f491e28..5096a17 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,11 +35,14 @@ pub struct Config { impl Config { pub fn from_env() -> anyhow::Result { + let network = env::var("NETWORK").unwrap_or_else(|_| "testnet".into()); + let api_url = env::var("ZECBLOCK_API_URL") + .or_else(|_| env::var("CIPHERSCAN_API_URL")) + .unwrap_or_else(|_| default_explorer_url(&network).into()); Ok(Self { database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:cipherpay.db".into()), - cipherscan_api_url: env::var("CIPHERSCAN_API_URL") - .unwrap_or_else(|_| "https://api.testnet.cipherscan.app".into()), - network: env::var("NETWORK").unwrap_or_else(|_| "testnet".into()), + cipherscan_api_url: api_url.trim_end_matches('/').to_string(), + network, api_host: env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".into()), api_port: env::var("API_PORT") .unwrap_or_else(|_| "3080".into()) @@ -84,14 +87,14 @@ impl Config { .unwrap_or_else(|_| "30".into()) .parse()?, admin_key: env::var("ADMIN_KEY").ok().filter(|s| !s.is_empty()), - cipherscan_service_key: env::var("CIPHERSCAN_SERVICE_KEY") + cipherscan_service_key: env::var("ZECBLOCK_SERVICE_KEY") + .or_else(|_| env::var("CIPHERSCAN_SERVICE_KEY")) .ok() .filter(|s| !s.is_empty()), checkout_service_key: env::var("CHECKOUT_SERVICE_KEY") .ok() .filter(|s| !s.is_empty()), - webauthn_rp_id: env::var("WEBAUTHN_RP_ID") - .unwrap_or_else(|_| "localhost".into()), + webauthn_rp_id: env::var("WEBAUTHN_RP_ID").unwrap_or_else(|_| "localhost".into()), webauthn_rp_origin: env::var("WEBAUTHN_RP_ORIGIN").unwrap_or_else(|_| { // Derive from FRONTEND_URL if set, otherwise default to localhost env::var("FRONTEND_URL") @@ -128,3 +131,25 @@ impl Config { Ok(()) } } + +fn default_explorer_url(network: &str) -> &'static str { + if network == "testnet" { + "https://api.testnet.zecblock.com" + } else { + "https://api.zecblock.com" + } +} + +#[cfg(test)] +mod tests { + use super::default_explorer_url; + + #[test] + fn explorer_defaults_follow_network() { + assert_eq!( + default_explorer_url("testnet"), + "https://api.testnet.zecblock.com" + ); + assert_eq!(default_explorer_url("mainnet"), "https://api.zecblock.com"); + } +} diff --git a/src/crosschain.rs b/src/crosschain.rs new file mode 100644 index 0000000..9f28103 --- /dev/null +++ b/src/crosschain.rs @@ -0,0 +1,1058 @@ +//! Invoice-bound 1Click payments. Provider status is informational: only the +//! Zcash scanner may settle an invoice. Never retry an ambiguous quote creation. +use actix_web::{web, HttpResponse}; +use anyhow::{bail, ensure, Context}; +use chrono::{Duration, Utc}; +use futures::{stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::SqlitePool; +use std::time::Instant; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{Mutex, Semaphore}; + +pub mod fees; +pub mod security; +pub mod operations; +mod signatures; + +const ZEC: &str = "nep141:zec.omft.near"; +const BASE: &str = "https://1click.chaindefuser.com/v0"; +const CONFIDENTIALITY: &str = "basic"; + +#[derive(Debug, thiserror::Error)] +#[error("Payment is below the route's minimum output")] +struct BelowMinimumOutput(u128); + +fn minimum_output_error(status: reqwest::StatusCode, body: &Value) -> Option { + if status != reqwest::StatusCode::BAD_REQUEST { + return None; + } + let units = body + .get("message")? + .as_str()? + .strip_prefix("Amount is too low for bridge, try at least ")?; + if units.is_empty() || !units.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let minimum = units.parse::().ok()?; + (minimum > 0).then_some(BelowMinimumOutput(minimum)) +} + +#[derive(Debug, thiserror::Error)] +#[error("Payment is below the provider's minimum swap amount")] +struct BelowMinimumSwapUsd(String); + +fn minimum_swap_usd_error( + status: reqwest::StatusCode, + body: &Value, +) -> Option { + if status != reqwest::StatusCode::BAD_REQUEST { + return None; + } + let amount = body + .get("message")? + .as_str()? + .strip_prefix("Temporary swap limits: minimum swap amount is $")?; + let mut parts = amount.split('.'); + let whole = parts.next()?; + if whole.is_empty() || whole.len() > 9 || !whole.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + if let Some(fraction) = parts.next() { + if fraction.is_empty() + || fraction.len() > 2 + || !fraction.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + } + if parts.next().is_some() || !amount.bytes().any(|b| matches!(b, b'1'..=b'9')) { + return None; + } + Some(BelowMinimumSwapUsd(amount.into())) +} + +pub struct Gateway { + enabled: bool, + fee_recipient: Option, + key: String, + base: String, + client: reqwest::Client, + confirmation_client: reqwest::Client, + cipherscan_api_url: String, + tokens: Mutex)>>, + eligibility: Mutex>>>>, + eligibility_slots: Semaphore, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Asset { + pub asset_id: String, + pub symbol: String, + pub blockchain: String, + pub decimals: u32, + #[serde(default)] + pub contract_address: Option, +} + +// Explicit token identities prevent lookalike symbols and wrapped ZEC from +// becoming checkout assets. The live registry remains the availability source. +fn curated(id: &str) -> bool { + matches!( + id, + "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near" + | "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near" + | "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near" + | "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near" + | "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near" + | "nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near" + | "nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near" + | "nep141:eth.omft.near" + | "nep141:base.omft.near" + | "nep141:arb.omft.near" + | "nep141:btc.omft.near" + | "nep141:sol.omft.near" + | "nep141:tron.omft.near" + ) +} + +impl Gateway { + pub fn collects_fees(&self) -> bool { + self.enabled && self.fee_recipient.is_some() + } + + pub fn from_env(config: &crate::config::Config) -> anyhow::Result { + let key = std::env::var("NEAR_ONECLICK_API_KEY").unwrap_or_default(); + let enabled = std::env::var("NEAR_CROSSCHAIN_ENABLED").as_deref() == Ok("true"); + if enabled { + ensure!(!config.is_testnet(), "1Click checkout is mainnet only"); + ensure!(!key.is_empty(), "NEAR_ONECLICK_API_KEY is required"); + // Refund addresses and deposit instructions are encrypted at rest. + crate::crypto::encrypt("crosschain", &config.encryption_key)?; + ensure!( + !config.encryption_key.is_empty(), + "Cross-chain checkout requires encryption" + ); + } + let fee_recipient = std::env::var("NEAR_APP_FEE_RECIPIENT") + .ok() + .filter(|s| !s.is_empty()); + if let Some(recipient) = &fee_recipient { + ensure!( + enabled && !key.is_empty(), + "App fees require authenticated cross-chain checkout" + ); + ensure!( + recipient == "cipherscan.near", + "Unapproved app fee recipient" + ); + } + // Internal explorer credentials must never accompany provider requests. + let mut confirmation_headers = reqwest::header::HeaderMap::new(); + if let Some(key) = &config.cipherscan_service_key { + let mut value = reqwest::header::HeaderValue::from_str(key)?; + value.set_sensitive(true); + confirmation_headers.insert("X-Service-Key", value); + } + Ok(Self { + enabled, + fee_recipient, + key, + base: BASE.into(), + client: reqwest::Client::builder() + .user_agent("CipherPay/1.0") + .timeout(std::time::Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + confirmation_client: reqwest::Client::builder() + .user_agent("CipherPay/1.0") + .default_headers(confirmation_headers) + .timeout(std::time::Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::none()) + .build()?, + cipherscan_api_url: config.cipherscan_api_url.clone(), + tokens: Mutex::new(None), + eligibility: Mutex::new(HashMap::new()), + eligibility_slots: Semaphore::new(4), + }) + } + + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let req = self.client.request(method, format!("{}{path}", self.base)); + if self.key.is_empty() { + req + } else { + req.bearer_auth(&self.key) + } + } + + async fn assets(&self) -> anyhow::Result> { + let mut cache = self.tokens.lock().await; + if let Some((at, tokens)) = cache.as_ref() { + if at.elapsed().as_secs() < 600 { + return Ok(tokens.clone()); + } + } + let all: Vec = self + .request(reqwest::Method::GET, "/tokens") + .send() + .await? + .error_for_status()? + .json() + .await?; + ensure!( + all.iter() + .any(|a| a.asset_id == ZEC && a.decimals == 8 && a.blockchain == "zec"), + "ZEC unavailable" + ); + let mut tokens: Vec<_> = all + .into_iter() + .filter(|a| curated(&a.asset_id) && a.decimals <= 18) + .collect(); + tokens.sort_by_key(|a| { + let rank = match a.symbol.as_str() { + "USDC" => 0, + "USDT" => 1, + "ETH" => 2, + "BTC" => 3, + "SOL" => 4, + _ => 5, + }; + let chain = match a.blockchain.as_str() { + "base" => 0, + "sol" => 1, + "tron" => 2, + "eth" => 3, + _ => 4, + }; + (rank, chain) + }); + *cache = Some((Instant::now(), tokens.clone())); + Ok(tokens) + } + + async fn quote(&self, body: &Value) -> anyhow::Result { + let _permit = tokio::time::timeout( + std::time::Duration::from_secs(5), + self.eligibility_slots.acquire(), + ) + .await??; + let res = self + .request(reqwest::Method::POST, "/quote") + .json(body) + .send() + .await?; + // Extract only a known numeric minimum, never echo provider messages + // that may contain destination/refund data. All requests are EXACT_OUTPUT. + let status = res.status(); + if !status.is_success() { + let body: Value = res.json().await.unwrap_or(Value::Null); + if let Some(error) = minimum_output_error(status, &body) { + return Err(error.into()); + } + if let Some(error) = minimum_swap_usd_error(status, &body) { + return Err(error.into()); + } + bail!("No quote available for this route and amount"); + } + let response: Value = res.json().await?; + signatures::verify_fresh(&response)?; + validate_confidentiality(&response, body)?; + Ok(response) + } +} + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query("ALTER TABLE invoices ADD COLUMN exact_payment INTEGER NOT NULL DEFAULT 0") + .execute(&mut *tx) + .await?; + sqlx::query("CREATE TABLE crosschain_attempts ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + state TEXT NOT NULL, payload TEXT, last_checked INTEGER NOT NULL DEFAULT 0, last_success INTEGER NOT NULL DEFAULT 0, + send_token TEXT, sent_txid TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )").execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QuoteInput { + asset_id: String, + refund_address: String, +} + +fn valid_refund(chain: &str, address: &str) -> bool { + if address.len() > 120 || address.trim() != address { + return false; + } + use sha2::Digest; + let checked_base58 = |version: u8| { + let Ok(bytes) = bs58::decode(address).into_vec() else { + return false; + }; + bytes.len() == 25 + && bytes[0] == version + && sha2::Sha256::digest(sha2::Sha256::digest(&bytes[..21]))[..4] == bytes[21..] + }; + match chain { + "eth" | "base" | "arb" => { + if address.len() != 42 + || !address.starts_with("0x") + || !address[2..].bytes().all(|b| b.is_ascii_hexdigit()) + || !address[2..].bytes().any(|b| b != b'0') + { + return false; + } + let raw = &address[2..]; + if raw == raw.to_lowercase() || raw == raw.to_uppercase() { + return true; + } + let hash = hex::encode(sha3::Keccak256::digest(raw.to_lowercase().as_bytes())); + raw.bytes().zip(hash.bytes()).all(|(c, h)| { + !c.is_ascii_alphabetic() + || c.is_ascii_uppercase() == ((h as char).to_digit(16).unwrap() >= 8) + }) + } + "sol" => bs58::decode(address) + .into_vec() + .is_ok_and(|bytes| bytes.len() == 32 && bytes.iter().any(|b| *b != 0)), + "tron" => checked_base58(0x41), + "btc" => { + checked_base58(0) + || checked_base58(5) + || bech32::segwit::decode(address) + .is_ok_and(|(hrp, _, _)| hrp == bech32::Hrp::parse("bc").unwrap()) + } + _ => false, + } +} + +fn validate_invoice(inv: &crate::invoices::Invoice) -> anyhow::Result<()> { + ensure!( + inv.status == "pending" && inv.received_zatoshis == 0 && inv.price_zatoshis > 0, + "Invoice is not available for a new payment" + ); + ensure!( + chrono::DateTime::parse_from_rfc3339(&inv.expires_at)? > Utc::now(), + "Invoice has expired" + ); + let receiver = crate::invoices::matching::orchard_receiver(&inv.payment_address); + ensure!( + receiver.is_some() && receiver == inv.orchard_receiver_hex, + "Invoice needs a unique shielded address" + ); + ensure!( + inv.payment_address.starts_with("u1"), + "Mainnet address required" + ); + Ok(()) +} + +fn quote_body( + inv: &crate::invoices::Invoice, + input: &QuoteInput, + asset: &Asset, + dry: bool, +) -> anyhow::Result { + validate_invoice(inv)?; + ensure!( + valid_refund(&asset.blockchain, &input.refund_address), + "Enter a valid refund address on the selected network" + ); + let minutes = if asset.blockchain == "btc" { 120 } else { 30 }; + Ok(json!({ + "dry": dry, "swapType": "EXACT_OUTPUT", "slippageTolerance": 100, + "originAsset": asset.asset_id, "destinationAsset": ZEC, + "amount": inv.price_zatoshis.to_string(), + "recipient": inv.payment_address, "recipientType": "DESTINATION_CHAIN", + "refundTo": input.refund_address, "refundType": "ORIGIN_CHAIN", + "depositType": "ORIGIN_CHAIN", "depositMode": "SIMPLE", + "confidentiality": CONFIDENTIALITY, + "deadline": std::cmp::min(Utc::now() + Duration::minutes(minutes), chrono::DateTime::parse_from_rfc3339(&inv.expires_at)?.with_timezone(&Utc)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "quoteWaitingTimeMs": 3000 + })) +} + +fn positive_units(value: &Value) -> anyhow::Result { + let raw = value.as_str().context("Missing amount")?; + ensure!( + !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit()), + "Invalid integer amount" + ); + let n: u128 = raw.parse()?; + ensure!(n > 0, "Invalid amount"); + Ok(n) +} + +// 1Click's signed-field selection does not cover confidentiality. Check the +// authenticated HTTPS response separately; a signature alone cannot attest it. +// Saved legacy quotes omit the field and retain their original routing terms. +fn validate_confidentiality(response: &Value, expected: &Value) -> anyhow::Result<()> { + if let Some(mode) = expected.get("confidentiality") { + ensure!(mode == CONFIDENTIALITY, "Unsupported confidentiality mode"); + ensure!( + response["quoteRequest"]["confidentiality"] == *mode, + "Provider did not confirm confidential routing" + ); + } + Ok(()) +} + +fn checked_quote( + response: &Value, + body: &Value, + asset: &Asset, + dry: bool, +) -> anyhow::Result { + validate_confidentiality(response, body)?; + fees::validate_quote(response, body)?; + let q = &response["quote"]; + let target = positive_units(&body["amount"])?; + ensure!( + positive_units(&q["amountOut"])? == target, + "Quote does not cover the invoice" + ); + if !q["minAmountOut"].is_null() { + ensure!( + positive_units(&q["minAmountOut"])? >= target, + "Quote permits underpayment" + ); + } + positive_units(&q["amountIn"])?; + // If present, reject an echoed request for another invoice/route. + if let Some(echo) = response.get("quoteRequest") { + for key in [ + "recipient", + "amount", + "originAsset", + "destinationAsset", + "swapType", + "refundTo", + ] { + ensure!(echo[key] == body[key], "Quote request mismatch"); + } + } + let mut payment_deadline = q["deadline"].clone(); + if !dry { + let deposit = q["depositAddress"] + .as_str() + .context("Missing deposit address")?; + ensure!( + valid_refund(&asset.blockchain, deposit), + "Invalid deposit address" + ); + let deadline = q["deadline"].as_str().context("Missing quote deadline")?; + let until = chrono::DateTime::parse_from_rfc3339(deadline)?; + let requested = chrono::DateTime::parse_from_rfc3339( + body["deadline"] + .as_str() + .context("Missing requested deadline")?, + )?; + // 1Click may extend committed ZEC quote lifetimes (currently by three + // days). Keep our short funding window separate from provider recovery. + ensure!( + until > Utc::now() && until <= requested + Duration::days(7), + "Invalid quote deadline" + ); + let mut funding_until = until.min(requested); + if !q["timeWhenInactive"].is_null() { + let inactive = chrono::DateTime::parse_from_rfc3339( + q["timeWhenInactive"] + .as_str() + .context("Invalid inactive time")?, + )?; + funding_until = funding_until.min(inactive); + } + ensure!(funding_until > Utc::now(), "Quote is already inactive"); + payment_deadline = + json!(funding_until.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + } + Ok(json!({ + "asset": asset, "amountIn": q["amountIn"], "amountOut": q["amountOut"], + "timeEstimate": q["timeEstimate"], "depositAddress": q["depositAddress"], + "depositMemo": q["depositMemo"], "deadline": payment_deadline, "providerDeadline": q["deadline"], + "refundAddress": body["refundTo"], "confidentiality": body["confidentiality"] + })) +} + +fn error(status: actix_web::http::StatusCode, message: &str) -> HttpResponse { + HttpResponse::build(status) + .insert_header(("Cache-Control", "no-store")) + .json(json!({"error": message})) +} + +pub async fn assets(g: web::Data) -> HttpResponse { + if !g.enabled { + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(json!({"enabled": false, "assets": []})); + } + match g.assets().await { + Ok(assets) => HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(json!({"enabled": true, "assets": assets})), + Err(_) => error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Crypto payment options are temporarily unavailable", + ), + } +} + +// Provider-required refund placeholders for DRY availability checks only. These +// never reach the browser or any stored/fundable quote. Actual checkout still +// requires the buyer's address. Do not reuse this function for quote creation. +fn eligibility_body(inv: &crate::invoices::Invoice, asset: &Asset) -> anyhow::Result { + let refund = match asset.blockchain.as_str() { + "eth" | "base" | "arb" => "0x1111111111111111111111111111111111111111", + "sol" => "5dTEX4m5e4B64xuDr83gaCSVa3W7GeqMmZPj3RoT5cQs", + "btc" => "1BitcoinEaterAddressDontSendf59kuE", + "tron" => "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb", + _ => bail!("Unsupported availability route"), + }; + quote_body( + inv, + &QuoteInput { + asset_id: asset.asset_id.clone(), + refund_address: refund.into(), + }, + asset, + true, + ) +} + +fn route_eligibility(result: anyhow::Result, body: &Value, asset: &Asset) -> Value { + match result { + Ok(q) + if q["quote"]["depositAddress"].is_null() + && checked_quote(&q, body, asset, true).is_ok() => + { + json!({"assetId":asset.asset_id,"state":"AVAILABLE"}) + } + Err(e) if e.downcast_ref::().is_some() => { + let minimum = e.downcast_ref::().unwrap().0; + // Only call it a minimum failure if that minimum actually exceeds + // this invoice. Other provider/validation failures remain unknown. + if body["amount"] + .as_str() + .and_then(|s| s.parse::().ok()) + .is_some_and(|amount| amount < minimum) + { + json!({"assetId":asset.asset_id,"state":"BELOW_MINIMUM","minimumOutputZatoshis":minimum.to_string()}) + } else { + json!({"assetId":asset.asset_id,"state":"UNKNOWN"}) + } + } + Err(e) if e.downcast_ref::().is_some() => { + json!({"assetId":asset.asset_id,"state":"BELOW_MINIMUM","minimumSwapUsd":e.downcast_ref::().unwrap().0}) + } + _ => json!({"assetId":asset.asset_id,"state":"UNKNOWN"}), + } +} + +pub async fn eligibility( + pool: web::Data, + g: web::Data, + path: web::Path, +) -> HttpResponse { + if !g.enabled { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Cross-chain payments are not enabled", + ); + } + let inv = match crate::invoices::get_invoice(&pool, &path).await { + Ok(Some(inv)) => inv, + Ok(None) => return error(actix_web::http::StatusCode::NOT_FOUND, "Invoice not found"), + Err(_) => { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Could not check availability", + ) + } + }; + if !operations::enabled(&pool, &path).await.unwrap_or(false) { + return HttpResponse::Ok().json(json!({"enabled":false,"state":"DISABLED","routes":[],"hasUnknown":false})); + } + if let Err(e) = validate_invoice(&inv) { + return error(actix_web::http::StatusCode::BAD_REQUEST, &e.to_string()); + } + // Preserve active attempts: a new availability result must never offer a + // second route or turn an existing payment into a direct ZEC payment. + match sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM crosschain_attempts WHERE invoice_id=?)", + ) + .bind(path.as_str()) + .fetch_one(pool.get_ref()) + .await + { + Ok(true) => { + return error( + actix_web::http::StatusCode::CONFLICT, + "Payment already started", + ) + } + Err(_) => { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Could not check payment state", + ) + } + _ => {} + } + let key = format!("{}:{}:{}", inv.id, inv.price_zatoshis, inv.payment_address); + let slot = { + let mut entries = g.eligibility.lock().await; + if entries.len() >= 256 && !entries.contains_key(&key) { + entries.retain(|_, entry| Arc::strong_count(entry) > 1); + if entries.len() >= 256 { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Availability is busy; retry shortly", + ); + } + } + entries + .entry(key) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone() + }; + // Single-flight per invoice and bounded provider concurrency across invoices. + let mut cached = + match tokio::time::timeout(std::time::Duration::from_secs(18), slot.lock()).await { + Ok(lock) => lock, + Err(_) => { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Availability is busy; retry shortly", + ) + } + }; + if let Some((at, value)) = cached.as_ref() { + let ttl = if value["hasUnknown"] == true { 5 } else { 30 }; + if at.elapsed().as_secs() < ttl { + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(value); + } + } + let assets = match tokio::time::timeout(std::time::Duration::from_secs(5), g.assets()).await { + Ok(Ok(assets)) => assets, + _ => { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Could not check availability", + ) + } + }; + let cipherpay_bps = match crate::referrals::service_bps(&pool, &inv.id).await { + Ok(bps) => bps + 25, + Err(_) => { + return error( + actix_web::http::StatusCode::SERVICE_UNAVAILABLE, + "Fee policy unavailable", + ) + } + }; + let mut routes: Vec = assets + .iter() + .map(|a| json!({"assetId":a.asset_id,"state":"UNKNOWN"})) + .collect(); + let mut checks = stream::iter(assets.iter().enumerate().map(|(i, asset)| { + let g = &g; + let inv = &inv; + async move { + let result = async { + let mut body = eligibility_body(inv, asset)?; + fees::apply(&mut body, g.fee_recipient.as_deref()); + if g.fee_recipient.is_some() { + body["appFees"][0]["fee"] = json!(cipherpay_bps); + } + Ok::<_, anyhow::Error>(route_eligibility(g.quote(&body).await, &body, asset)) + } + .await; + ( + i, + result.unwrap_or_else(|_| json!({"assetId":asset.asset_id,"state":"UNKNOWN"})), + ) + } + })) + .buffer_unordered(4); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(16); + while let Ok(Some((i, route))) = tokio::time::timeout_at(deadline, checks.next()).await { + routes[i] = route; + } + let has_unknown = routes.iter().any(|r| r["state"] == "UNKNOWN") || routes.is_empty(); + let state = if routes.iter().any(|r| r["state"] == "AVAILABLE") { + "AVAILABLE" + } else if !has_unknown { + "UNAVAILABLE" + } else { + "UNKNOWN" + }; + let value = json!({"state":state,"routes":routes,"hasUnknown":has_unknown,"checkedAt":Utc::now().to_rfc3339(),"validForSeconds":if has_unknown {5} else {30}}); + *cached = Some((Instant::now(), value.clone())); + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(value) +} + +async fn prepare( + pool: &SqlitePool, + g: &Gateway, + id: &str, + input: &QuoteInput, + dry: bool, +) -> anyhow::Result<(Value, Asset)> { + ensure!(g.enabled, "Cross-chain payments are not enabled"); + let inv = crate::invoices::get_invoice(pool, id) + .await? + .context("Invoice not found")?; + ensure!(operations::enabled(pool, id).await?, "The merchant accepts ZEC only"); + let asset = g + .assets() + .await? + .into_iter() + .find(|a| a.asset_id == input.asset_id) + .context("Unsupported asset")?; + let mut body = quote_body(&inv, input, &asset, dry)?; + fees::apply(&mut body, g.fee_recipient.as_deref()); + if g.fee_recipient.is_some() { + body["appFees"][0]["fee"] = + json!(crate::referrals::service_bps(pool, id).await? + 25); + } + Ok((body, asset)) +} + +pub async fn preview( + pool: web::Data, + g: web::Data, + path: web::Path, + input: web::Json, +) -> HttpResponse { + let (body, asset) = match prepare(&pool, &g, &path, &input, true).await { + Ok(v) => v, + Err(e) => return error(actix_web::http::StatusCode::BAD_REQUEST, &e.to_string()), + }; + match g + .quote(&body) + .await + .and_then(|q| checked_quote(&q, &body, &asset, true)) + { + Ok(q) => HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(q), + Err(e) if e.downcast_ref::().is_some() => { + let minimum = e.downcast_ref::().unwrap().0; + HttpResponse::BadRequest() + .insert_header(("Cache-Control", "no-store")) + .json(json!({"error": "This invoice is below the cross-chain route minimum. Pay with ZEC or ask the merchant for a larger invoice.", "code": "BELOW_MINIMUM_OUTPUT", "minimumOutputZatoshis": minimum.to_string()})) + } + Err(_) => error( + actix_web::http::StatusCode::BAD_GATEWAY, + "No exact-output quote available. Try another network or amount.", + ), + } +} + +async fn reserve( + pool: &SqlitePool, + id: &str, + deadline: &str, + fee_body: Option<&Value>, +) -> anyhow::Result { + // One durable attempt per invoice, including after a timeout/crash. A second + // browser can never create competing deposit addresses. No provider call is + // made while a SQLite transaction is held. + let mut tx = pool.begin().await?; + let changed = sqlx::query( + "INSERT OR IGNORE INTO crosschain_attempts (invoice_id,state,price_expires_at) + SELECT id,'CREATING',expires_at FROM invoices WHERE id=? AND status='pending' + AND received_zatoshis=0 AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ','now') + AND EXISTS(SELECT 1 FROM merchants m WHERE m.id=invoices.merchant_id AND m.crosschain_enabled=1)", + ) + .bind(id) + .execute(&mut *tx) + .await? + .rows_affected(); + if changed == 0 { + return Ok(false); + } + chrono::DateTime::parse_from_rfc3339(deadline)?; + sqlx::query("UPDATE invoices SET exact_payment=1 WHERE id=?") + .bind(id) + .execute(&mut *tx) + .await?; + if let Some(body) = fee_body { + fees::reserve(&mut tx, id, body).await?; + } + tx.commit().await?; + Ok(true) +} + +pub async fn create( + req: actix_web::HttpRequest, + pool: web::Data, + g: web::Data, + config: web::Data, + path: web::Path, + input: web::Json, +) -> HttpResponse { + let id = path.into_inner(); + if !security::authorized(&req, &pool, &id).await { + return error( + actix_web::http::StatusCode::FORBIDDEN, + "Open the full payment link provided by the merchant to start or resume this payment.", + ); + } + // Return the original attempt on duplicate requests, even if invoice is paid. + match read_attempt(&pool, &id, &config.encryption_key).await { + Ok(Some(mut a)) => { + a["canManage"] = json!(true); + if !a["quote"].is_null() + && (a["quote"]["asset"]["assetId"] != input.asset_id + || a["quote"]["refundAddress"] != input.refund_address) + { + return error(actix_web::http::StatusCode::CONFLICT, "An existing payment uses different token or refund details. Resume it from the original checkout."); + } + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(a); + } + Err(_) => { + return error( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Could not restore payment", + ) + } + _ => {} + } + let (body, asset) = match prepare(&pool, &g, &id, &input, false).await { + Ok(v) => v, + Err(e) => return error(actix_web::http::StatusCode::BAD_REQUEST, &e.to_string()), + }; + match reserve(&pool, &id, body["deadline"].as_str().unwrap(), Some(&body)).await { + Ok(true) => {} + Ok(false) => { + return error( + actix_web::http::StatusCode::CONFLICT, + "Payment already started. Refresh to resume.", + ) + } + Err(_) => { + return error( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Could not save payment", + ) + } + } + let mut setup_stage = "provider_quote"; + let result = async { + let response = g.quote(&body).await?; + setup_stage = "quote_validation"; + let quote = checked_quote(&response, &body, &asset, false)?; + setup_stage = "persist_quote"; + let payload = crate::crypto::encrypt("e.to_string(), &config.encryption_key)?; + let mut tx = pool.begin().await?; + sqlx::query("UPDATE crosschain_attempts SET state='PENDING_DEPOSIT',payload=?,last_success=? WHERE invoice_id=?") + .bind(payload).bind(Utc::now().timestamp()).bind(&id).execute(&mut *tx).await?; + fees::bind_quote(&mut tx, &id, &response, &body).await?; + tx.commit().await?; + anyhow::Ok(json!({"state":"PENDING_DEPOSIT", "quote":quote, "stale":false, "canManage":true, "settlementVerified":false})) + }.await; + match result { + Ok(a) => HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(a), + Err(_) => { + tracing::warn!(invoice_id = %id, stage = setup_stage, "Cross-chain payment setup failed; reservation preserved"); + // Outcome may be ambiguous: keep the reservation rather than risk a + // second swap. No deposit instructions have been shown to the buyer. + let _ = sqlx::query("UPDATE crosschain_attempts SET state='NEEDS_REVIEW' WHERE invoice_id=? AND payload IS NULL") + .bind(&id).execute(pool.get_ref()).await; + error( + actix_web::http::StatusCode::BAD_GATEWAY, + "Payment setup could not be verified. Refresh for recovery instructions.", + ) + } + } +} + +async fn read_attempt(pool: &SqlitePool, id: &str, key: &str) -> anyhow::Result> { + let row: Option<(String, Option, i64, Option, Option, bool)> = sqlx::query_as( + "SELECT state,payload,last_success,send_token,sent_txid, + EXISTS(SELECT 1 FROM crosschain_settlements s JOIN invoices i ON i.id=s.invoice_id WHERE s.invoice_id=a.invoice_id AND i.status='confirmed') + FROM crosschain_attempts a WHERE invoice_id=?") + .bind(id).fetch_optional(pool).await?; + row.map(|(state, payload, checked, send_token, sent_txid, settlement_verified)| { + let quote = match payload { + Some(p) => serde_json::from_str::(&crate::crypto::decrypt(&p, key)?)?, + None => Value::Null, + }; + Ok( + json!({"state":state, "quote":quote, "stale":Utc::now().timestamp()-checked > 60, + "sendStarted": send_token.is_some(), "sentTxid": sent_txid, "settlementVerified":settlement_verified}), + ) + }) + .transpose() +} + +pub async fn status( + req: actix_web::HttpRequest, + pool: web::Data, + g: web::Data, + config: web::Data, + path: web::Path, +) -> HttpResponse { + let id = path.into_inner(); + let mut attempt = match read_attempt(&pool, &id, &config.encryption_key).await { + Ok(Some(a)) => a, + Ok(None) => { + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(json!({"state":"NONE","enabled":g.enabled && operations::enabled(&pool,&id).await.unwrap_or(false),"settlementVerified":false})) + } + Err(_) => { + return error( + actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + "Could not restore payment", + ) + } + }; + attempt["canManage"] = json!(security::authorized(&req, &pool, &id).await); + if attempt["canManage"] == false { + // Public receipt viewers may see the asset, never the buyer's addresses, + // source transaction, funding instructions or wallet-send state. + return HttpResponse::Ok().insert_header(("Cache-Control", "no-store")) + .json(json!({"state":attempt["state"],"stale":attempt["stale"],"canManage":false,"asset":attempt["quote"]["asset"],"settlementVerified":attempt["settlementVerified"]})); + } + let state = attempt["state"].as_str().unwrap_or(""); + if matches!(state, "SUCCESS" | "REFUNDED") || attempt["quote"].is_null() { + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(attempt); + } + let now = Utc::now().timestamp(); + // Database lease bounds polling across browsers and API workers. + let lease = sqlx::query( + "UPDATE crosschain_attempts SET last_checked=? WHERE invoice_id=? AND last_checked < ?", + ) + .bind(now) + .bind(&id) + .bind(now - 30) + .execute(pool.get_ref()) + .await; + if !matches!(lease, Ok(r) if r.rows_affected() == 1) { + return HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(attempt); + } + let q = &attempt["quote"]; + let mut req = g + .request(reqwest::Method::GET, "/status") + .query(&[("depositAddress", q["depositAddress"].as_str().unwrap_or(""))]); + if let Some(memo) = q["depositMemo"].as_str().filter(|s| !s.is_empty()) { + req = req.query(&[("depositMemo", memo)]); + } + let result = async { + let data: Value = req.send().await?.error_for_status()?.json().await?; + signatures::verify_status(&data["quoteResponse"])?; + let inv = crate::invoices::get_invoice(&pool, &id).await?.context("Missing invoice")?; + let correlation: Option = sqlx::query_scalar("SELECT correlation_id FROM crosschain_fees WHERE invoice_id=?").bind(&id).fetch_optional(pool.get_ref()).await?.flatten(); + fees::validate_route(&data, q, &inv.payment_address, correlation.as_deref())?; + let state = data["status"].as_str().context("Missing status")?; + if !matches!(state, "PENDING_DEPOSIT" | "KNOWN_DEPOSIT_TX" | "INCOMPLETE_DEPOSIT" | "PROCESSING" | "SUCCESS" | "REFUNDED" | "FAILED") { bail!("Unknown status"); } + sqlx::query("UPDATE crosschain_attempts SET state=?,last_success=? WHERE invoice_id=? AND state NOT IN ('SUCCESS','REFUNDED')") + .bind(state).bind(now).bind(&id).execute(pool.get_ref()).await?; + anyhow::Ok(state.to_string()) + }.await; + match result { + Ok(state) => { + attempt["state"] = json!(state); + attempt["stale"] = json!(false); + } + Err(_) => { + let _ = sqlx::query("UPDATE crosschain_attempts SET last_checked=? WHERE invoice_id=?") + .bind(now - 20) + .bind(&id) + .execute(pool.get_ref()) + .await; + attempt["stale"] = json!(true); + } + } + HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(attempt) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SendInput { + action: String, + token: Option, + txid: Option, +} + +// A durable send lock protects against double clicks, reloads and another tab. +// Only a definite wallet rejection may release it. Ambiguous RPC errors keep it. +pub async fn wallet_send( + req: actix_web::HttpRequest, + pool: web::Data, + config: web::Data, + path: web::Path, + body: web::Json, +) -> HttpResponse { + let id = path.into_inner(); + if !security::authorized(&req, &pool, &id).await { + return error( + actix_web::http::StatusCode::FORBIDDEN, + "Open the full payment link provided by the merchant to start or resume this payment.", + ); + } + let result = async { + match body.action.as_str() { + "start" => { + let a = read_attempt(&pool, &id, &config.encryption_key).await?.context("No payment attempt")?; + ensure!(a["state"] == "PENDING_DEPOSIT", "Payment already in progress"); + ensure!(a["stale"] == false, "Payment status is unavailable. Wait for it to refresh before sending."); + let deadline = a["quote"]["deadline"].as_str().context("No payment instructions")?; + ensure!(chrono::DateTime::parse_from_rfc3339(deadline)? > Utc::now(), "Quote expired"); + let token = uuid::Uuid::new_v4().to_string(); + let rows = sqlx::query("UPDATE crosschain_attempts SET send_token=? WHERE invoice_id=? AND send_token IS NULL AND state='PENDING_DEPOSIT' + AND EXISTS (SELECT 1 FROM invoices WHERE id=? AND status='pending' AND received_zatoshis=0)") + .bind(&token).bind(&id).bind(&id).execute(pool.get_ref()).await?.rows_affected(); + ensure!(rows == 1, "Payment already started. Refresh to resume."); + Ok(json!({"token":token})) + }, + "rejected" | "submitted" => { + let token = body.token.as_deref().context("Missing send token")?; + ensure!(uuid::Uuid::parse_str(token).is_ok(), "Invalid send token"); + let rows = if body.action == "rejected" { + sqlx::query("UPDATE crosschain_attempts SET send_token=NULL WHERE invoice_id=? AND send_token=? AND sent_txid IS NULL AND state='PENDING_DEPOSIT'") + .bind(&id).bind(token).execute(pool.get_ref()).await?.rows_affected() + } else { + let txid = body.txid.as_deref().context("Missing transaction id")?; + ensure!((32..=128).contains(&txid.len()) && txid.bytes().all(|b| b.is_ascii_alphanumeric()), "Invalid transaction id"); + sqlx::query("UPDATE crosschain_attempts SET sent_txid=? WHERE invoice_id=? AND send_token=? AND sent_txid IS NULL") + .bind(txid).bind(&id).bind(token).execute(pool.get_ref()).await?.rows_affected() + }; + ensure!(rows == 1, "Payment state changed. Refresh to resume."); + Ok(json!({"ok":true})) + }, + _ => bail!("Invalid action"), + } + }.await; + match result { + Ok(v) => HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(v), + Err(e) => error(actix_web::http::StatusCode::CONFLICT, &e.to_string()), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/crosschain/fees.rs b/src/crosschain/fees.rs new file mode 100644 index 0000000..2eb1ace --- /dev/null +++ b/src/crosschain/fees.rs @@ -0,0 +1,1297 @@ +//! Source-token fee accounting, separate from the merchant's ZEC billing ledger. +//! Public receipts establish actual collection. Confidential fees can instead +//! be accepted as provider-handled after our exact swap and ZEC receipt settle; +//! that policy never invents received token units or referral earnings. +use super::*; + +const TOTAL_BPS: u64 = 250; +const CIPHERPAY_BPS: u64 = 125; +const CONFIDENTIAL_PROVIDER_BPS: u64 = 20; +const RPC: &str = "https://archival-rpc.mainnet.near.org"; + +pub(super) fn apply(body: &mut Value, recipient: Option<&str>) { + if let Some(recipient) = recipient { + let fee = if body["confidentiality"] == CONFIDENTIALITY { CIPHERPAY_BPS } else { TOTAL_BPS }; + body["appFees"] = json!([{ "recipient": recipient, "fee": fee }]); + } +} + +// Confidential quotes retain our requested share and add 20 bps for 1Click. +// Legacy public quotes split the requested combined fee evenly. +fn terms(body: &Value) -> anyhow::Result<(u64, u64)> { + let requested = body["appFees"][0]["fee"].as_u64().context("Missing requested fee")?; + if body["confidentiality"] == CONFIDENTIALITY { + Ok((requested.checked_add(CONFIDENTIAL_PROVIDER_BPS).context("Fee overflow")?, requested)) + } else { + Ok((requested, requested / 2)) + } +} + +pub(super) fn validate_quote(response: &Value, body: &Value) -> anyhow::Result<()> { + let Some(requested) = body.get("appFees") else { + return Ok(()); + }; + let recipient = requested[0]["recipient"] + .as_str() + .context("Missing fee recipient")?; + let fees = response["quoteRequest"]["appFees"] + .as_array() + .context("Missing fee allocation")?; + let mut total = 0_u64; + let mut ours = 0_u64; + for fee in fees { + let bps = fee["fee"].as_u64().context("Invalid fee allocation")?; + total = total.checked_add(bps).context("Fee overflow")?; + ensure!( + fee["recipient"].as_str().is_some_and(|s| !s.is_empty()), + "Missing fee recipient" + ); + if fee["recipient"] == recipient { + ours = ours.checked_add(bps).context("Fee overflow")?; + } + } + let (expected_total, expected_ours) = terms(body)?; + // Reject changes to buyer costs or the allocation of our revenue. + ensure!( + total == expected_total && ours == expected_ours, + "Unexpected provider fee split" + ); + Ok(()) +} + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TABLE crosschain_fees ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, recipient TEXT NOT NULL, + total_bps INTEGER NOT NULL, cipherpay_bps INTEGER NOT NULL, + state TEXT NOT NULL DEFAULT 'reserved', correlation_id TEXT UNIQUE, + quoted_input TEXT, received_units TEXT, evidence TEXT, + next_check INTEGER NOT NULL DEFAULT 0, failures INTEGER NOT NULL DEFAULT 0, + reconciled_at TEXT + )", + ) + .execute(&mut *tx) + .await?; + sqlx::query("CREATE INDEX crosschain_fees_pending ON crosschain_fees(next_check) WHERE state != 'collected'") + .execute(&mut *tx).await?; + sqlx::query("CREATE TABLE crosschain_fee_evidence ( + proof_id TEXT PRIMARY KEY, invoice_id TEXT NOT NULL REFERENCES crosschain_fees(invoice_id) ON DELETE CASCADE + )").execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +pub(super) async fn reserve( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + body: &Value, +) -> anyhow::Result<()> { + let Some(fees) = body.get("appFees") else { + return Ok(()); + }; + let billed: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM fee_ledger WHERE invoice_id=?)") + .bind(id) + .fetch_one(&mut **tx) + .await?; + ensure!(!billed, "Invoice already has merchant fee accounting"); + let (total, ours) = terms(body)?; + sqlx::query("INSERT INTO crosschain_fees (invoice_id,asset_id,recipient,total_bps,cipherpay_bps) VALUES (?,?,?,?,?)") + .bind(id).bind(body["originAsset"].as_str().context("Missing asset")?) + .bind(fees[0]["recipient"].as_str().context("Missing fee recipient")?) + .bind(i64::try_from(total)?).bind(i64::try_from(ours)?).execute(&mut **tx).await?; + Ok(()) +} + +pub(super) async fn bind_quote( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + response: &Value, + body: &Value, +) -> anyhow::Result<()> { + if body.get("appFees").is_none() { + return Ok(()); + } + let correlation = response["correlationId"] + .as_str() + .filter(|v| !v.is_empty()) + .context("Missing quote correlation")?; + sqlx::query("UPDATE crosschain_fees SET correlation_id=?,quoted_input=?,state='pending' WHERE invoice_id=? AND state='reserved'") + .bind(correlation).bind(response["quote"]["amountIn"].as_str().context("Missing input")?) + .bind(id).execute(&mut **tx).await?; + Ok(()) +} + +#[derive(Debug)] +struct Credit { + proof_id: String, + units: u128, +} + +fn credits( + result: &Value, + tx_hash: &str, + intents: &[String], + recipient: &str, + asset: &str, +) -> anyhow::Result> { + ensure!( + result["final_execution_status"] == "FINAL", + "NEAR transaction is not final" + ); + ensure!( + result["transaction"]["hash"] == tx_hash, + "NEAR transaction mismatch" + ); + ensure!( + result["status"].get("SuccessValue").is_some(), + "NEAR transaction did not succeed" + ); + let outcomes = result["receipts_outcome"] + .as_array() + .context("Missing NEAR receipts")?; + let mut found = Vec::new(); + for receipt in outcomes { + let outcome = &receipt["outcome"]; + if outcome["executor_id"] != "intents.near" + || outcome["status"].get("SuccessValue").is_none() + { + continue; + } + let receipt_id = receipt["id"].as_str().context("Missing receipt id")?; + for (log_index, log) in outcome["logs"] + .as_array() + .context("Missing logs")? + .iter() + .enumerate() + { + let Some(raw) = log.as_str().and_then(|s| s.strip_prefix("EVENT_JSON:")) else { + continue; + }; + let event: Value = serde_json::from_str(raw)?; + if event["standard"] != "dip4" || event["event"] != "transfer" { + continue; + } + for (index, transfer) in event["data"] + .as_array() + .context("Invalid transfer events")? + .iter() + .enumerate() + { + if transfer["receiver_id"] != recipient { + continue; + } + let Some(intent) = transfer["intent_hash"].as_str() else { + continue; + }; + if !intents.iter().any(|h| h == intent) { + continue; + } + let Some(amount) = transfer["tokens"].get(asset) else { + continue; + }; + found.push(Credit { + proof_id: format!("{receipt_id}:{log_index}:{index}:{asset}"), + units: positive_units(amount)?, + }); + } + } + } + Ok(found) +} + +async fn record( + pool: &SqlitePool, + id: &str, + credits: &[Credit], + evidence: &str, +) -> anyhow::Result<()> { + ensure!(!credits.is_empty(), "No attributable fee receipt yet"); + let amount = credits.iter().try_fold(0_u128, |sum, c| { + sum.checked_add(c.units).context("Fee amount overflow") + })?; + let mut tx = pool.begin().await?; + for c in credits { + sqlx::query("INSERT INTO crosschain_fee_evidence (proof_id,invoice_id) VALUES (?,?) ON CONFLICT(proof_id) DO NOTHING") + .bind(&c.proof_id).bind(id).execute(&mut *tx).await?; + let owner: String = + sqlx::query_scalar("SELECT invoice_id FROM crosschain_fee_evidence WHERE proof_id=?") + .bind(&c.proof_id) + .fetch_one(&mut *tx) + .await?; + ensure!( + owner == id, + "Fee receipt already belongs to another invoice" + ); + } + sqlx::query("UPDATE crosschain_fees SET state='collected',received_units=?,evidence=?,reconciled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE invoice_id=? AND state != 'collected'") + .bind(amount.to_string()).bind(evidence).bind(id).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +// Status responses omit creation correlationId. Bind the immutable route and +// deposit instead; an optional correlation, if returned, must still match. +fn validate_settlement( + data: &Value, + q: &Value, + recipient: &str, + correlation: Option<&str>, +) -> anyhow::Result<()> { + ensure!(data["status"] == "SUCCESS", "Swap is not settled"); + validate_route(data, q, recipient, correlation) +} + +pub(super) fn validate_route( + data: &Value, + q: &Value, + recipient: &str, + correlation: Option<&str>, +) -> anyhow::Result<()> { + let response = &data["quoteResponse"]; + validate_confidentiality(response, q)?; + if let Some(actual) = response["correlationId"].as_str() { + ensure!(Some(actual) == correlation, "Swap correlation mismatch"); + } + for field in ["depositAddress", "depositMemo"] { + ensure!(response["quote"][field] == q[field], "Deposit mismatch"); + } + ensure!( + q["depositAddress"].as_str().is_some_and(|s| !s.is_empty()), + "Missing deposit" + ); + let expected = json!({"originAsset":q["asset"]["assetId"],"destinationAsset":ZEC, + "recipient":recipient,"refundTo":q["refundAddress"],"amount":q["amountOut"],"swapType":"EXACT_OUTPUT"}); + for field in [ + "originAsset", + "destinationAsset", + "recipient", + "refundTo", + "amount", + "swapType", + ] { + ensure!( + response["quoteRequest"][field] == expected[field], + "Settlement route mismatch" + ); + } + ensure!( + response["quote"]["amountOut"] == q["amountOut"], + "Settlement output mismatch" + ); + Ok(()) +} + +pub async fn migrate_settlements(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + "CREATE TABLE crosschain_settlements ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + verified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )", + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn migrate_provider_handled(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query("CREATE TABLE crosschain_provider_confirmations ( + invoice_id TEXT PRIMARY KEY REFERENCES crosschain_fees(invoice_id) ON DELETE CASCADE, + confirmation TEXT NOT NULL, + confirmed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )").execute(pool).await?; + Ok(()) +} + +fn zcash_deliveries(details: &Value) -> anyhow::Result> { + let entries = details["destinationChainTxHashes"] + .as_array() + .context("Missing ZEC delivery")?; + ensure!(!entries.is_empty() && entries.len() <= 16, "Invalid delivery count"); + let mut hashes = Vec::new(); + for entry in entries { + let hash = entry["hash"].as_str().context("Missing delivery hash")?; + ensure!(!hash.is_empty() && hash.len() <= 128, "Invalid delivery hash"); + // 1Click can include an origin-chain refund here alongside the ZEC + // withdrawal. Only Zcash-shaped hashes are scanner candidates. A hash + // alone is never proof: settlement still requires this invoice's actual + // shielded outputs to cover its price. + if hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { + let hash = hash.to_ascii_lowercase(); + if !hashes.contains(&hash) { + hashes.push(hash); + } + } + } + ensure!(!hashes.is_empty(), "Missing ZEC delivery"); + Ok(hashes) +} + +async fn matched_delivery(pool: &SqlitePool, g: &Gateway, id: &str, hashes: &[String]) -> anyhow::Result { + let mut delivered = 0_i64; + for hash in hashes { + let units: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(zatoshis),0) FROM invoice_payments WHERE invoice_id=? AND lower(txid)=?") + .bind(id).bind(hash).fetch_one(pool).await?; + // invoice_payments also contains mempool observations. Another transfer + // may confirm the invoice while this route's output remains unconfirmed + // or reorged, so check each original delivery's canonical inclusion. + if units > 0 && crate::scanner::blocks::check_tx_confirmed( + &g.confirmation_client, &g.cipherscan_api_url, hash, + ).await? { + delivered = delivered.checked_add(units).context("Delivery overflow")?; + } + } + Ok(delivered) +} + +async fn reconcile(pool: &SqlitePool, g: &Gateway, id: &str, key: &str) -> anyhow::Result<()> { + let terminal: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM crosschain_fees WHERE invoice_id=? AND state IN ('collected','direct','provider_handled'))") + .bind(id).fetch_one(pool).await?; + if terminal { + return Ok(()); + } + let attempt = read_attempt(pool, id, key) + .await? + .context("Missing payment attempt")?; + if attempt["quote"].is_null() { + let confirmed: bool = + sqlx::query_scalar("SELECT status='confirmed' FROM invoices WHERE id=?") + .bind(id) + .fetch_one(pool) + .await?; + if confirmed { + resolve_direct(pool, id).await?; + return Ok(()); + } + } + let q = &attempt["quote"]; + let deposit = q["depositAddress"].as_str().context("Quote needs review")?; + let mut req = g + .request(reqwest::Method::GET, "/status") + .query(&[("depositAddress", deposit)]); + if let Some(memo) = q["depositMemo"].as_str().filter(|s| !s.is_empty()) { + req = req.query(&[("depositMemo", memo)]); + } + let data: Value = req.send().await?.error_for_status()?.json().await?; + reconcile_status(pool, g, id, key, &attempt, &data).await +} + +async fn reconcile_status( + pool: &SqlitePool, + g: &Gateway, + id: &str, + key: &str, + attempt: &Value, + data: &Value, +) -> anyhow::Result<()> { + let q = &attempt["quote"]; + let fee: Option<(String, String, Option)> = sqlx::query_as( + "SELECT asset_id,recipient,correlation_id FROM crosschain_fees WHERE invoice_id=?", + ) + .bind(id) + .fetch_optional(pool) + .await?; + let inv = crate::invoices::get_invoice(pool, id) + .await? + .context("Missing invoice")?; + let correlation = fee.as_ref().and_then(|f| f.2.as_deref()); + signatures::verify_status(&data["quoteResponse"])?; + validate_route(&data, q, &inv.payment_address, correlation)?; + // An expired, still-unfunded route (or a completed refund) cannot account + // for an already confirmed ZEC transfer. Resolve the retained direct fee. + let deadline_passed = q["providerDeadline"] + .as_str() + .or(q["deadline"].as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .is_some_and(|d| d < Utc::now()); + if inv.status == "confirmed" + && (data["status"] == "REFUNDED" + || (data["status"] == "PENDING_DEPOSIT" && deadline_passed)) + { + resolve_direct(pool, id).await?; + return Ok(()); + } + validate_settlement(&data, q, &inv.payment_address, correlation)?; + ensure!( + positive_units(&q["amountOut"])? == inv.price_zatoshis as u128, + "Invoice amount mismatch" + ); + if let Some((asset, recipient, _)) = &fee { + ensure!(correlation.is_some(), "Unbound quote"); + ensure!(q["asset"]["assetId"] == *asset, "Fee asset mismatch"); + let mut body = json!({}); + if let Some(mode) = q.get("confidentiality") { + body["confidentiality"] = mode.clone(); + } + apply(&mut body, Some(recipient)); + let (total, ours): (i64, i64) = + sqlx::query_as("SELECT total_bps,cipherpay_bps FROM crosschain_fees WHERE invoice_id=?") + .bind(id) + .fetch_one(pool) + .await?; + body["appFees"][0]["fee"] = json!(if q["confidentiality"] == CONFIDENTIALITY { ours } else { total }); + validate_quote(&data["quoteResponse"], &body)?; + } + let details = &data["swapDetails"]; + let destination = zcash_deliveries(details)?; + // Queue exact destination txids for the scanner. Provider SUCCESS alone + // never credits the invoice; raw ZEC outputs and finality remain authoritative. + let mut recovery = pool.begin().await?; + for hash in &destination { + sqlx::query("INSERT OR IGNORE INTO crosschain_deliveries(invoice_id,txid) VALUES (?,?)") + .bind(id) + .bind(hash) + .execute(&mut *recovery) + .await?; + } + sqlx::query( + "UPDATE invoices SET status=CASE WHEN status='expired' THEN 'pending' ELSE status END, + expires_at=MAX(expires_at,strftime('%Y-%m-%dT%H:%M:%SZ','now','+24 hours')) + WHERE id=? AND status IN ('pending','underpaid','expired')", + ) + .bind(id) + .execute(&mut *recovery) + .await?; + recovery.commit().await?; + let delivered = matched_delivery(pool, g, id, &destination).await?; + ensure!( + delivered >= inv.price_zatoshis, + "Full ZEC delivery has not been matched by our scanner" + ); + let provider_handled = fee.is_some() && q["confidentiality"] == CONFIDENTIALITY; + if provider_handled { + ensure!(inv.status == "confirmed", "ZEC payment is not confirmed"); + } + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + if provider_handled { + let status: String = sqlx::query_scalar("SELECT status FROM invoices WHERE id=?") + .bind(id).fetch_one(&mut *tx).await?; + ensure!(status == "confirmed", "ZEC payment is not confirmed"); + let state: String = sqlx::query_scalar("SELECT state FROM crosschain_fees WHERE invoice_id=?") + .bind(id).fetch_one(&mut *tx).await?; + if matches!(state.as_str(), "collected" | "direct" | "provider_handled") { + return Ok(()); + } + } + sqlx::query("INSERT OR IGNORE INTO crosschain_settlements(invoice_id) VALUES (?)") + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE crosschain_attempts SET state='SUCCESS',last_success=? WHERE invoice_id=?") + .bind(Utc::now().timestamp()) + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE deferred_payment_fees SET state='crosschain' WHERE invoice_id=?") + .bind(id) + .execute(&mut *tx) + .await?; + if provider_handled { + // This is the explicitly accepted provider-trust policy, separate from + // independent credit evidence. Keep source-token cash amounts absent. + let confirmation = crate::crypto::encrypt(&json!({ + "policy":"provider_handled", "provider":"near_1click", "status":data, + "correlation_id":correlation, + }).to_string(), key)?; + let updated = sqlx::query("UPDATE crosschain_fees SET state='provider_handled',failures=0,next_check=0,reconciled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE invoice_id=? AND state NOT IN ('collected','direct','provider_handled')") + .bind(id).execute(&mut *tx).await?; + if updated.rows_affected() == 1 { + sqlx::query("INSERT INTO crosschain_provider_confirmations(invoice_id,confirmation) VALUES (?,?)") + .bind(id).bind(confirmation).execute(&mut *tx).await?; + } + tx.commit().await?; + return Ok(()); + } + tx.commit().await?; + // Legacy fee-free payments still need a verified payment-method record. + let Some((asset, recipient, _)) = fee else { + return Ok(()); + }; + let intents: Vec = serde_json::from_value(details["intentHashes"].clone())?; + let hashes: Vec = serde_json::from_value(details["nearTxHashes"].clone())?; + ensure!( + !intents.is_empty() && intents.len() <= 64 && !hashes.is_empty() && hashes.len() <= 16, + "Invalid settlement references" + ); + let mut all = Vec::new(); + for hash in &hashes { + let response: Value = g + .client + .post(RPC) + .json(&json!({"jsonrpc":"2.0","id":"fee","method":"tx", + "params":{"tx_hash":hash,"sender_account_id":"intents.near","wait_until":"FINAL"}})) + .send() + .await? + .error_for_status()? + .json() + .await?; + all.extend(credits( + &response["result"], + hash, + &intents, + &recipient, + &asset, + )?); + } + all.sort_by(|a, b| a.proof_id.cmp(&b.proof_id)); + all.dedup_by(|a, b| a.proof_id == b.proof_id); + // Store actual credit events, not a percentage-derived estimate. Evidence + // connects the public-chain payment to a shielded invoice, so encrypt it. + let evidence = crate::crypto::encrypt(&json!({"nearTxHashes":hashes,"intentHashes":intents, + "credits":all.iter().map(|c|json!({"proof":c.proof_id,"units":c.units.to_string()})).collect::>()}).to_string(),key)?; + for credit in &mut all { + credit.proof_id = crate::crypto::blind_index(&hex::encode(credit.proof_id.as_bytes()), key); + } + record(pool, id, &all, &evidence).await +} + +/// Merchant-scoped actual receipts. Never mix units from different tokens or +/// count an unpaid quote as a pending bill. No deposit/refund addresses exposed. +pub async fn merchant_summary( + pool: &SqlitePool, + merchant: &str, + key: &str, + enabled: bool, +) -> anyhow::Result { + let rows: Vec<( + String, + String, + String, + Option, + Option, + String, + )> = sqlx::query_as( + "SELECT i.id,i.memo_code,f.state,f.received_units,a.payload,i.created_at + FROM crosschain_fees f JOIN invoices i ON i.id=f.invoice_id + JOIN crosschain_attempts a ON a.invoice_id=i.id + WHERE i.merchant_id=? AND (i.status IN ('detected','confirmed') OR f.state='collected') + ORDER BY i.created_at DESC", + ) + .bind(merchant) + .fetch_all(pool) + .await?; + let mut assets: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut recent = Vec::new(); + let mut collected = 0; + let mut pending = 0; + let mut provider_handled = 0; + for (id, reference, state, received, payload, created) in rows { + if state == "direct" { + continue; + } + let Some(payload) = payload else { + pending += 1; + continue; + }; + let q: Value = serde_json::from_str(&crate::crypto::decrypt(&payload, key)?)?; + let asset: Asset = serde_json::from_value(q["asset"].clone())?; + if state == "collected" { + let amount = received + .as_ref() + .context("Missing received fee")? + .parse::()?; + let entry = assets + .entry(asset.asset_id.clone()) + .or_insert((asset.clone(), 0, 0)); + entry.1 = entry.1.checked_add(amount).context("Fee total overflow")?; + entry.2 += 1; + collected += 1; + } else if state == "provider_handled" { + provider_handled += 1; + } else { + pending += 1; + } + if recent.len() < 10 { + recent.push(json!({"invoice_id":id,"reference":reference,"state":if state == "collected" {"collected"} else if state == "provider_handled" {"provider_handled"} else {"pending"}, + "received_units":received,"asset":asset,"created_at":created})); + } + } + let service: i64=sqlx::query_scalar("SELECT MIN(1000,MAX(0,CAST(ROUND(COALESCE(fee_rate,0.01)*10000) AS INTEGER)), CASE WHEN EXISTS(SELECT 1 FROM merchant_referrals r WHERE r.merchant_id=merchants.id AND r.discount_until>strftime('%Y-%m-%dT%H:%M:%SZ','now')) THEN 50 ELSE 1000 END) FROM merchants WHERE id=?") + .bind(merchant).fetch_one(pool).await?; + Ok( + json!({"enabled":enabled,"total_bps":service+25+CONFIDENTIAL_PROVIDER_BPS as i64,"cipherpay_bps":service+25, + "collected_count":collected,"provider_handled_count":provider_handled,"pending_count":pending,"recent":recent, + "assets":assets.into_values().map(|(asset, amount, count)|json!({"asset":asset,"received_units":amount.to_string(),"count":count})).collect::>() }), + ) +} + +pub async fn payment_methods(pool: &SqlitePool, merchant: &str) -> anyhow::Result { + let (direct, crosschain, verifying): (i64, i64, i64) = sqlx::query_as( + "SELECT + COUNT(CASE WHEN s.invoice_id IS NULL AND (a.invoice_id IS NULL OR f.state='direct') THEN 1 END), + COUNT(CASE WHEN s.invoice_id IS NOT NULL THEN 1 END), + COUNT(CASE WHEN a.invoice_id IS NOT NULL AND s.invoice_id IS NULL AND COALESCE(f.state,'')!='direct' THEN 1 END) + FROM invoices i LEFT JOIN crosschain_attempts a ON a.invoice_id=i.id + LEFT JOIN crosschain_settlements s ON s.invoice_id=i.id + LEFT JOIN crosschain_fees f ON f.invoice_id=i.id + WHERE i.merchant_id=? AND i.status='confirmed'", + ) + .bind(merchant) + .fetch_one(pool) + .await?; + Ok(json!({"direct_zec":direct,"crosschain":crosschain,"verifying":verifying})) +} + +pub(super) async fn resolve_direct(pool: &SqlitePool, id: &str) -> anyhow::Result<()> { + let obligation: Option<(String,f64,f64)> = sqlx::query_as("UPDATE deferred_payment_fees SET state='direct' WHERE invoice_id=? AND state!='crosschain' RETURNING merchant_id,amount_zec,rate") + .bind(id).fetch_optional(pool).await?; + if let Some((merchant, amount, rate)) = obligation { + crate::billing::create_fee_entry(pool, id, &merchant, amount, rate).await?; + let receipts: Vec<(String, i64)> = + sqlx::query_as("SELECT txid,units FROM direct_fee_receipts WHERE invoice_id=?") + .bind(id) + .fetch_all(pool) + .await?; + for (txid, units) in receipts { + crate::billing::record_direct_receipt(pool, id, &txid, units).await?; + } + } + sqlx::query( + "UPDATE crosschain_fees SET state='direct' WHERE invoice_id=? AND state NOT IN ('collected','provider_handled')", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub fn start_worker(pool: SqlitePool, gateway: web::Data, key: String) { + if !gateway.enabled { + return; + } + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last_alert = 0; + loop { + interval.tick().await; + if let Err(e) = crate::referrals::reconcile(&pool).await { + tracing::error!(error=%e,"Referral reconciliation failed"); + } + let now = Utc::now().timestamp(); + if now / 900 != last_alert { + last_alert = now / 900; + if let Ok(issues) = operations::issues(&pool).await { + if !issues.is_empty() { tracing::warn!(count=issues.len(), "Cross-chain payments require review; see admin payment operations"); } + } + } + // Recover completed legacy payments even when the buyer closed the + // page before the last provider poll. Unfunded quotes are not counted. + let legacy = sqlx::query_scalar::<_, String>("SELECT a.invoice_id FROM crosschain_attempts a JOIN invoices i ON i.id=a.invoice_id + WHERE i.status IN ('pending','underpaid','expired','detected','confirmed') AND a.payload IS NOT NULL AND a.last_checked("SELECT invoice_id FROM crosschain_fees WHERE state NOT IN ('collected','direct','provider_handled') AND next_check<=? ORDER BY next_check LIMIT 8") + .bind(now).fetch_all(&pool).await; + let Ok(ids) = ids else { + tracing::error!("Could not load pending cross-chain fees"); + continue; + }; + for id in ids { + let lease = sqlx::query("UPDATE crosschain_fees SET next_check=? WHERE invoice_id=? AND state NOT IN ('collected','direct','provider_handled') AND next_check<=?") + .bind(now+600).bind(&id).bind(now).execute(&pool).await; + if !matches!(lease,Ok(r) if r.rows_affected()==1) { + continue; + } + match tokio::time::timeout( + std::time::Duration::from_secs(90), + reconcile(&pool, &gateway, &id, &key), + ) + .await + { + Ok(Ok(())) => { + tracing::info!(invoice_id=%id,"Cross-chain fee reconciliation completed") + } + _ => { + // Unverified/ambiguous attempts stay explicitly pending; + // never turn a provider outage into a merchant bill. + let _ = sqlx::query("UPDATE crosschain_fees SET failures=failures+1,next_check=?+MIN(3600,60*(1+failures)) WHERE invoice_id=? AND state NOT IN ('collected','direct','provider_handled')") + .bind(Utc::now().timestamp()).bind(&id).execute(&pool).await; + } + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn confirmation_fixture(confirmations: i64) -> (String, Arc) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use std::sync::atomic::{AtomicI64, Ordering}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let count = Arc::new(AtomicI64::new(confirmations)); + let state = count.clone(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let read = socket.read(&mut buffer).await.unwrap(); + if read == 0 { break; } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { break; } + } + assert!(String::from_utf8_lossy(&request).starts_with("GET /v1/transactions/")); + // A positive block height alongside zero confirmations models + // stale explorer metadata after a transaction is reorged out. + let body = json!({"data":{"block_height":100,"confirmations":state.load(Ordering::SeqCst)},"meta":{}}).to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body); + socket.write_all(response.as_bytes()).await.unwrap(); + } + }); + (base, count) + } + + async fn confidential_fee_fixture() -> (SqlitePool, String, Value, Value, Gateway) { + use crate::repair_tests::{invoice, merchant, pool}; + let p = pool().await; + merchant(&p, "m").await; + merchant(&p, "referrer").await; + sqlx::query("INSERT INTO merchant_referrals(merchant_id,referrer_id) VALUES ('m','referrer')") + .execute(&p).await.unwrap(); + invoice(&p, "i", "m").await; + invoice(&p, "other", "m").await; + let key = "11".repeat(32); + let q = json!({"confidentiality":"basic", "asset":{"assetId":"usdc", "symbol":"USDC", "blockchain":"base", "decimals":6}, + "amountOut":"1000", "depositAddress":"our-deposit", "refundAddress":"refund", "providerDeadline":"2099-01-01T00:00:00Z"}); + let response = signatures::signed(json!({"correlationId":"our-quote", "quoteRequest":{ + "dry":false, "confidentiality":"basic", "originAsset":"usdc", + "destinationAsset":ZEC, "recipient":"test-address", "refundTo":"refund", + "amount":"1000", "swapType":"EXACT_OUTPUT", "appFees":[ + {"recipient":"cipherscan.near","fee":125}, {"recipient":"provider.near","fee":20}]}, + "quote":{"depositAddress":"our-deposit", "amountOut":"1000"}})); + let data = json!({"status":"SUCCESS", "quoteResponse":response, + "swapDetails":{"destinationChainTxHashes":[{"hash":"ab".repeat(32)}], "intentHashes":[], "nearTxHashes":[]}}); + sqlx::query("INSERT INTO crosschain_attempts(invoice_id,state,payload) VALUES ('i','PROCESSING',?)") + .bind(crate::crypto::encrypt(&q.to_string(), &key).unwrap()).execute(&p).await.unwrap(); + sqlx::query("INSERT INTO crosschain_fees(invoice_id,asset_id,recipient,total_bps,cipherpay_bps,state,correlation_id,quoted_input,failures) VALUES ('i','usdc','cipherscan.near',145,125,'pending','our-quote','5502400',5)") + .execute(&p).await.unwrap(); + let (cipherscan_api_url, _) = confirmation_fixture(2).await; + let g = Gateway { enabled:true, fee_recipient:Some("cipherscan.near".into()), key:String::new(), base:"http://127.0.0.1:1".into(), + client:reqwest::Client::new(), confirmation_client:reqwest::Client::new(), cipherscan_api_url, tokens:Mutex::new(None), + eligibility:Mutex::new(HashMap::new()), eligibility_slots:Semaphore::new(4) }; + (p, key, json!({"quote":q}), data, g) + } + + #[tokio::test] + async fn confidential_fee_is_handled_only_for_our_confirmed_full_delivery() { + let (p, key, attempt, data, g) = confidential_fee_fixture().await; + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap_err().to_string().contains("Full ZEC delivery")); + // A separate swap or direct ZEC transfer can settle the invoice, but its + // txid is not our quote's delivery and cannot mark our NEAR fee handled. + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('i',?,1000),('other',?,1000)") + .bind("cd".repeat(32)).bind("ab".repeat(32)).execute(&p).await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed',received_zatoshis=1000 WHERE id='i'") + .execute(&p).await.unwrap(); + sqlx::query("UPDATE crosschain_attempts SET state='SUCCESS' WHERE invoice_id='i'").execute(&p).await.unwrap(); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap_err().to_string().contains("Full ZEC delivery")); + assert_eq!(read_attempt(&p, "i", &key).await.unwrap().unwrap()["settlementVerified"], false); + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('i',?,600)") + .bind("ab".repeat(32)).execute(&p).await.unwrap(); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap_err().to_string().contains("Full ZEC delivery")); + sqlx::query("UPDATE invoice_payments SET zatoshis=1000 WHERE invoice_id='i' AND txid=?") + .bind("ab".repeat(32)).execute(&p).await.unwrap(); + sqlx::query("UPDATE invoices SET status='detected' WHERE id='i'").execute(&p).await.unwrap(); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap_err().to_string().contains("not confirmed")); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "pending"); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements").fetch_one(&p).await.unwrap(), 0); + crate::billing::create_fee_entry(&p, "i", "m", 0.001, 0.01).await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='i'").execute(&p).await.unwrap(); + reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap(); + assert_eq!(read_attempt(&p, "i", &key).await.unwrap().unwrap()["settlementVerified"], true); + let stored: (String, Option, Option, i64, i64) = sqlx::query_as("SELECT state,received_units,evidence,failures,next_check FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(); + assert_eq!(stored, ("provider_handled".into(), None, None, 0, 0)); + let confirmation: String = sqlx::query_scalar("SELECT confirmation FROM crosschain_provider_confirmations WHERE invoice_id='i'").fetch_one(&p).await.unwrap(); + assert!(!confirmation.contains("our-deposit")); + let audit: Value = serde_json::from_str(&crate::crypto::decrypt(&confirmation, &key).unwrap()).unwrap(); + assert_eq!(audit["policy"], "provider_handled"); + assert_eq!(audit["status"], data); + // Both repeated provider data and worker retries are idempotent; no + // further network call or independent credit evidence is required. + reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap(); + reconcile(&p, &g, "i", &key).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_provider_confirmations").fetch_one(&p).await.unwrap(), 1); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT confirmation FROM crosschain_provider_confirmations WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), confirmation); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM deferred_payment_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "crosschain"); + crate::billing::create_fee_entry(&p, "i", "m", 0.001, 0.01).await.unwrap(); + crate::referrals::reconcile(&p).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM fee_ledger").fetch_one(&p).await.unwrap(), 0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM referral_earnings").fetch_one(&p).await.unwrap(), 0); + assert!(operations::issues(&p).await.unwrap().is_empty()); + let summary = merchant_summary(&p, "m", &key, true).await.unwrap(); + assert_eq!(summary["provider_handled_count"], 1); + assert_eq!(summary["pending_count"], 0); + assert_eq!(summary["collected_count"], 0); + assert_eq!(summary["assets"], json!([])); + assert_eq!(summary["recent"][0]["state"], "provider_handled"); + assert!(summary["recent"][0]["received_units"].is_null()); + // The accepted terminal policy also releases account-deletion holds; + // existing invoice-owned encrypted metadata follows invoice deletion. + crate::merchants::delete_merchant(&p, "m").await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_provider_confirmations").fetch_one(&p).await.unwrap(), 0); + } + + #[tokio::test] + async fn confidential_success_rejects_altered_route_fee_or_quote() { + let (p, key, attempt, data, g) = confidential_fee_fixture().await; + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('i',?,1000)") + .bind("ab".repeat(32)).execute(&p).await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed',received_zatoshis=1000 WHERE id='i'").execute(&p).await.unwrap(); + for changes in [ + json!({"correlationId":"another-quote"}), + json!({"quote":{"depositAddress":"another-deposit","amountOut":"1000"}}), + json!({"quoteRequest":{"confidentiality":"public"}}), + json!({"quoteRequest":{"recipient":"someone-else"}}), + json!({"quoteRequest":{"appFees":[{"recipient":"attacker.near","fee":125},{"recipient":"provider.near","fee":20}]}}), + json!({"quoteRequest":{"appFees":[{"recipient":"cipherscan.near","fee":124},{"recipient":"provider.near","fee":21}]}}), + json!({"quoteRequest":{"appFees":[{"recipient":"cipherscan.near","fee":125},{"recipient":"provider.near","fee":21}]}}), + ] { + let mut response = data["quoteResponse"].clone(); + for (field, value) in changes.as_object().unwrap() { + if field == "quoteRequest" { + for (subfield, value) in value.as_object().unwrap() { + response[field][subfield] = value.clone(); + } + } else { response[field] = value.clone(); } + } + let mut changed = data.clone(); + changed["quoteResponse"] = signatures::signed(response); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &changed).await.is_err(), "{changes}"); + } + let mut unsigned = data.clone(); + unsigned["quoteResponse"]["quote"]["amountOut"] = json!("1001"); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &unsigned).await.is_err()); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "pending"); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_provider_confirmations").fetch_one(&p).await.unwrap(), 0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements").fetch_one(&p).await.unwrap(), 0); + } + + #[tokio::test] + async fn independent_confirmation_never_confirms_an_unconfirmed_provider_delivery() { + use std::sync::atomic::Ordering; + let (p, key, attempt, data, mut g) = confidential_fee_fixture().await; + let (base, confirmations) = confirmation_fixture(0).await; + g.cipherscan_api_url = base; + // Both outputs were observed, but only the independent direct payment + // has confirmed. Overall invoice status cannot confirm our swap txid. + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('i',?,1000),('i',?,1000)") + .bind("cd".repeat(32)).bind("ab".repeat(32)).execute(&p).await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed',received_zatoshis=1000 WHERE id='i'") + .execute(&p).await.unwrap(); + for count in [0, -1] { + confirmations.store(count, Ordering::SeqCst); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap_err().to_string().contains("Full ZEC delivery")); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "pending"); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements").fetch_one(&p).await.unwrap(), 0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_provider_confirmations").fetch_one(&p).await.unwrap(), 0); + assert_eq!(read_attempt(&p, "i", &key).await.unwrap().unwrap()["settlementVerified"], false); + } + confirmations.store(2, Ordering::SeqCst); + reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "provider_handled"); + assert_eq!(read_attempt(&p, "i", &key).await.unwrap().unwrap()["settlementVerified"], true); + } + + #[tokio::test] + async fn direct_payment_to_invoice_never_marks_our_unfunded_swap_fee_handled() { + let (p, key, mut attempt, mut data, g) = confidential_fee_fixture().await; + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('i',?,1000)") + .bind("cd".repeat(32)).execute(&p).await.unwrap(); + sqlx::query("UPDATE invoices SET status='confirmed',received_zatoshis=1000 WHERE id='i'").execute(&p).await.unwrap(); + crate::billing::create_fee_entry(&p, "i", "m", 0.001, 0.01).await.unwrap(); + data["status"] = json!("PENDING_DEPOSIT"); + assert!(reconcile_status(&p, &g, "i", &key, &attempt, &data).await.is_err()); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "pending"); + // Only when the original deposit remains unfunded beyond its deadline + // do we resolve the ordinary direct-ZEC obligation, without NEAR credit. + attempt["quote"]["providerDeadline"] = json!("2020-01-01T00:00:00Z"); + reconcile_status(&p, &g, "i", &key, &attempt, &data).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "direct"); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM fee_ledger WHERE invoice_id='i' AND auto_collected=0").fetch_one(&p).await.unwrap(), 1); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_provider_confirmations").fetch_one(&p).await.unwrap(), 0); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements").fetch_one(&p).await.unwrap(), 0); + assert!(sqlx::query_scalar::<_,Option>("SELECT received_units FROM crosschain_fees WHERE invoice_id='i'").fetch_one(&p).await.unwrap().is_none()); + assert_eq!(payment_methods(&p, "m").await.unwrap(), json!({"direct_zec":1,"crosschain":0,"verifying":0})); + assert_eq!(merchant_summary(&p, "m", &key, true).await.unwrap()["provider_handled_count"], 0); + } + + #[test] + fn mixed_delivery_hashes_select_unique_zcash_candidates() { + let zec = "ab".repeat(32); + let evm = format!("0x{}", "cd".repeat(32)); + let details = json!({"destinationChainTxHashes":[ + {"hash":evm}, {"hash":zec.to_uppercase()}, {"hash":zec} + ]}); + assert_eq!(zcash_deliveries(&details).unwrap(), vec![zec.clone()]); + for entries in [json!([]), json!([{"hash":evm}]), json!([{"hash":zec},{"hash":null}]), + json!([{"hash":""}]), json!([{"hash":"g".repeat(64)}]), + json!([{"hash":"a".repeat(129)}]), json!(vec![json!({"hash":zec});17])] { + assert!(zcash_deliveries(&json!({"destinationChainTxHashes":entries})).is_err()); + } + assert!(zcash_deliveries(&json!({})).is_err()); + } + + #[actix_web::test] + async fn mixed_refund_reconciles_only_after_this_invoices_full_zcash_receipt() { + use actix_web::{App, HttpServer}; + use crate::repair_tests::{invoice, merchant, pool}; + let p = pool().await; + merchant(&p, "m").await; + invoice(&p, "i", "m").await; + invoice(&p, "other", "m").await; + let key = "11".repeat(32); + let zec = "ab".repeat(32); + let evm = format!("0x{}", "cd".repeat(32)); + let q = json!({"confidentiality":"basic", "asset":{"assetId":"usdc"}, + "amountOut":"1000", "depositAddress":"deposit", "refundAddress":"refund"}); + let signed = signatures::signed(json!({"quoteRequest":{ + "dry":false, "confidentiality":"basic", "originAsset":"usdc", + "destinationAsset":ZEC, "recipient":"test-address", "refundTo":"refund", + "amount":"1000", "swapType":"EXACT_OUTPUT"}, + "quote":{"depositAddress":"deposit", "amountOut":"1000"}})); + let data = web::Data::new(json!({"status":"SUCCESS", "quoteResponse":signed, + "swapDetails":{"destinationChainTxHashes":[ + {"hash":zec.to_uppercase()}, {"hash":evm}, {"hash":zec}]}})); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(move || App::new().app_data(data.clone()) + .route("/status", web::get().to(|data:web::Data| async move { + HttpResponse::Ok().json(data.get_ref()) + })) + .route("/v1/transactions/{txid}", web::get().to(|| async { + HttpResponse::Ok().json(json!({"data":{"confirmations":2},"meta":{}})) + }))).listen(listener).unwrap().run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + let g = Gateway { enabled:true, fee_recipient:None, key:String::new(), cipherscan_api_url:base.clone(), base, + client:reqwest::Client::new(), confirmation_client:reqwest::Client::new(), tokens:Mutex::new(None), + eligibility:Mutex::new(HashMap::new()), eligibility_slots:Semaphore::new(4) }; + sqlx::query("INSERT INTO crosschain_attempts(invoice_id,state,payload) VALUES ('i','PROCESSING',?)") + .bind(crate::crypto::encrypt(&q.to_string(), &key).unwrap()).execute(&p).await.unwrap(); + // Provider SUCCESS and unrelated scanner receipts cannot settle this route. + let error = reconcile(&p, &g, "i", &key).await.unwrap_err(); + assert!(error.to_string().contains("Full ZEC delivery"), "{error:#}"); + sqlx::query("INSERT INTO invoice_payments(invoice_id,txid,zatoshis) VALUES ('other',?,1000),('i',?,1000),('i',?,600)") + .bind(&zec).bind("ef".repeat(32)).bind(&zec).execute(&p).await.unwrap(); + assert!(reconcile(&p, &g, "i", &key).await.unwrap_err().to_string().contains("Full ZEC delivery")); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), 0); + // Duplicate hashes never turn 600 zatoshis into a full 1,000-zatoshi receipt. + sqlx::query("UPDATE invoice_payments SET zatoshis=1000 WHERE invoice_id='i' AND txid=?") + .bind(&zec).execute(&p).await.unwrap(); + reconcile(&p, &g, "i", &key).await.unwrap(); + reconcile(&p, &g, "i", &key).await.unwrap(); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM crosschain_settlements WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), 1); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_attempts WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), "SUCCESS"); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT txid FROM crosschain_deliveries WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), zec); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT COUNT(*) FROM fee_ledger WHERE invoice_id='i'").fetch_one(&p).await.unwrap(), 0); + handle.stop(true).await; + } + + #[test] + fn confidential_fees_preserve_our_share_and_reject_changed_costs() { + for ours in [125_u64, 75] { + let mut body = json!({"confidentiality":"basic"}); + apply(&mut body, Some("cipherscan.near")); + assert_eq!(body["appFees"][0]["fee"], 125); + body["appFees"][0]["fee"] = json!(ours); + assert_eq!(terms(&body).unwrap(), (ours + 20, ours)); + let mut response = json!({"quoteRequest":{"appFees":[ + {"recipient":"cipherscan.near","fee":ours},{"recipient":"provider.near","fee":20} + ]}}); + validate_quote(&response, &body).unwrap(); + response["quoteRequest"]["appFees"][1]["fee"] = json!(21); + assert!(validate_quote(&response, &body).is_err()); + response["quoteRequest"]["appFees"][1]["fee"] = json!(20); + response["quoteRequest"]["appFees"][0]["fee"] = json!(ours * 2); + assert!(validate_quote(&response, &body).is_err()); + response["quoteRequest"]["appFees"][0]["fee"] = json!(ours - 1); + response["quoteRequest"]["appFees"][1]["fee"] = json!(21); + assert!(validate_quote(&response, &body).is_err()); + } + } + + #[tokio::test] + async fn reservations_store_confidential_and_legacy_fee_terms() { + use crate::repair_tests::{pool, merchant, invoice}; + let p = pool().await; + merchant(&p, "m").await; + for (id, mode, requested, expected) in [ + ("new", Some("basic"), 125, (145_i64, 125_i64)), + ("discounted", Some("basic"), 75, (95, 75)), + ("legacy", None, 250, (250, 125)), + ] { + invoice(&p, id, "m").await; + let mut body = json!({"originAsset":"usdc","appFees":[{"recipient":"cipherscan.near","fee":requested}]}); + if let Some(mode) = mode { body["confidentiality"] = json!(mode); } + let mut tx = p.begin().await.unwrap(); + reserve(&mut tx, id, &body).await.unwrap(); + tx.commit().await.unwrap(); + let stored: (i64, i64) = sqlx::query_as("SELECT total_bps,cipherpay_bps FROM crosschain_fees WHERE invoice_id=?") + .bind(id).fetch_one(&p).await.unwrap(); + assert_eq!(stored, expected); + } + } + + #[test] + fn resumed_confidential_routes_reject_downgrades_and_keep_legacy_payments() { + let mut q = json!({"asset":{"assetId":"usdc"},"depositAddress":"deposit","amountOut":"42","refundAddress":"refund"}); + let mut data = json!({"status":"SUCCESS","quoteResponse":{"quote":{"depositAddress":"deposit","amountOut":"42"}, + "quoteRequest":{"originAsset":"usdc","destinationAsset":ZEC,"recipient":"merchant","refundTo":"refund","amount":"42","swapType":"EXACT_OUTPUT"}}}); + // Legacy attempts predate the setting and can still complete. + validate_route(&data, &q, "merchant", None).unwrap(); + q["confidentiality"] = json!("basic"); + assert!(validate_route(&data, &q, "merchant", None).is_err()); + data["quoteResponse"]["quoteRequest"]["confidentiality"] = json!("basic"); + validate_route(&data, &q, "merchant", None).unwrap(); + data["quoteResponse"]["quoteRequest"]["confidentiality"] = json!("public"); + assert!(validate_route(&data, &q, "merchant", None).is_err()); + } + + #[test] + fn status_without_correlation_remains_bound_to_the_original_route() { + let q = json!({"asset":{"assetId":"usdc"},"depositAddress":"deposit","amountOut":"42","refundAddress":"refund"}); + let data = json!({"status":"SUCCESS","quoteResponse":{"quote":{"depositAddress":"deposit","amountOut":"42"}, + "quoteRequest":{"originAsset":"usdc","destinationAsset":ZEC,"recipient":"merchant","refundTo":"refund","amount":"42","swapType":"EXACT_OUTPUT"}}}); + validate_settlement(&data, &q, "merchant", Some("creation-id")).unwrap(); + for field in [ + "originAsset", + "destinationAsset", + "recipient", + "refundTo", + "amount", + "swapType", + ] { + let mut bad = data.clone(); + bad["quoteResponse"]["quoteRequest"][field] = json!("replaced"); + assert!( + validate_settlement(&bad, &q, "merchant", Some("creation-id")).is_err(), + "{field}" + ); + } + for field in ["depositAddress", "depositMemo", "amountOut"] { + let mut bad = data.clone(); + bad["quoteResponse"]["quote"][field] = json!("replaced"); + assert!( + validate_settlement(&bad, &q, "merchant", Some("creation-id")).is_err(), + "{field}" + ); + } + let mut correlated = data.clone(); + correlated["quoteResponse"]["correlationId"] = json!("creation-id"); + validate_settlement(&correlated, &q, "merchant", Some("creation-id")).unwrap(); + correlated["quoteResponse"]["correlationId"] = json!("other"); + assert!(validate_settlement(&correlated, &q, "merchant", Some("creation-id")).is_err()); + } + + #[tokio::test] + async fn merchant_reporting_excludes_other_merchants_and_unfunded_quotes() { + use crate::repair_tests::{invoice, merchant, pool}; + let p = pool().await; + merchant(&p, "m").await; + merchant(&p, "other").await; + let key = "11".repeat(32); + let payload = crate::crypto::encrypt( + &json!({"asset":{"assetId":"usdc","symbol":"USDC","blockchain":"base","decimals":6}}) + .to_string(), + &key, + ) + .unwrap(); + for (id, owner, paid, attempt, settled, units) in [ + ("direct", "m", true, false, false, None), + ("cross", "m", true, true, true, Some("69581")), + ("unpaid", "m", false, true, false, None), + ("unverified", "m", true, true, false, None), + ("hidden", "other", true, true, true, Some("999999999")), + ] { + invoice(&p, id, owner).await; + if paid { + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id=?") + .bind(id) + .execute(&p) + .await + .unwrap(); + } + if attempt { + sqlx::query("INSERT INTO crosschain_attempts(invoice_id,state,payload) VALUES (?,'PENDING_DEPOSIT',?)").bind(id).bind(&payload).execute(&p).await.unwrap(); + sqlx::query("INSERT INTO crosschain_fees(invoice_id,asset_id,recipient,total_bps,cipherpay_bps,state,received_units) VALUES (?,'usdc','cipherscan.near',250,125,?,?)") + .bind(id).bind(if units.is_some() {"collected"} else {"pending"}).bind(units).execute(&p).await.unwrap(); + } + if settled { + sqlx::query("INSERT INTO crosschain_settlements(invoice_id) VALUES (?)") + .bind(id) + .execute(&p) + .await + .unwrap(); + } + } + let methods = payment_methods(&p, "m").await.unwrap(); + assert_eq!( + methods, + json!({"direct_zec":1,"crosschain":1,"verifying":1}) + ); + // Independent of direct ZEC fee enablement; actual received units only. + let summary = merchant_summary(&p, "m", &key, true).await.unwrap(); + assert_eq!(summary["collected_count"], 1); + assert_eq!(summary["pending_count"], 1); + assert_eq!(summary["assets"][0]["received_units"], "69581"); + assert_eq!(summary["recent"].as_array().unwrap().len(), 2); + assert!(!summary.to_string().contains("hidden")); + let disabled = merchant_summary(&p, "m", &key, false).await.unwrap(); + assert_eq!(disabled["collected_count"], 1); // History survives a feature toggle. + } + + fn request() -> Value { + let mut body = json!({"originAsset":"nep141:usdc.near"}); + apply(&mut body, Some("cipherscan.near")); + body + } + + #[test] + fn validates_provider_rewritten_split_and_rejects_silent_price_changes() { + let body = request(); + assert_eq!(body["appFees"][0]["fee"], 250); + let mut response = json!({"quoteRequest":{"appFees":[ + {"recipient":"cipherscan.near","fee":125},{"recipient":"provider.near","fee":125} + ]}}); + validate_quote(&response, &body).unwrap(); + response["quoteRequest"]["appFees"][1]["fee"] = json!(145); + assert!(validate_quote(&response, &body).is_err()); + response["quoteRequest"]["appFees"][1]["fee"] = json!(125); + response["quoteRequest"]["appFees"][0]["recipient"] = json!("other.near"); + assert!(validate_quote(&response, &body).is_err()); + assert!(validate_quote(&json!({}), &body).is_err()); + validate_quote(&json!({}), &json!({})).unwrap(); // old fee-free quotes + } + + fn receipt() -> Value { + // DIP4 transfer shape verified against a finalized 1Click settlement. + let event = json!({"standard":"dip4","version":"0.4.3","event":"transfer","data":[ + {"intent_hash":"this-intent","account_id":"source.near","receiver_id":"cipherscan.near","tokens":{"nep141:usdc.near":"71234"}}, + {"intent_hash":"another-intent","account_id":"source.near","receiver_id":"cipherscan.near","tokens":{"nep141:usdc.near":"999999"}} + ]}); + json!({"final_execution_status":"FINAL","transaction":{"hash":"tx"},"status":{"SuccessValue":""}, + "receipts_outcome":[{"id":"receipt","outcome":{"executor_id":"intents.near","status":{"SuccessValue":""},"logs":[format!("EVENT_JSON:{event}")]}}]}) + } + + #[test] + fn collection_requires_final_contract_receipt_bound_to_this_swap_and_asset() { + let mut r = receipt(); + let intents = vec!["this-intent".into()]; + let c = credits(&r, "tx", &intents, "cipherscan.near", "nep141:usdc.near").unwrap(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].units, 71234); + assert!(credits( + &r, + "other-tx", + &intents, + "cipherscan.near", + "nep141:usdc.near" + ) + .is_err()); + assert!( + credits(&r, "tx", &intents, "cipherscan.near", "nep141:other.near") + .unwrap() + .is_empty() + ); + r["receipts_outcome"][0]["outcome"]["executor_id"] = json!("fake.near"); + assert!( + credits(&r, "tx", &intents, "cipherscan.near", "nep141:usdc.near") + .unwrap() + .is_empty() + ); + r["receipts_outcome"][0]["outcome"]["executor_id"] = json!("intents.near"); + r["receipts_outcome"][0]["outcome"]["status"] = json!({"Failure":{}}); + assert!( + credits(&r, "tx", &intents, "cipherscan.near", "nep141:usdc.near") + .unwrap() + .is_empty() + ); + r["final_execution_status"] = json!("EXECUTED_OPTIMISTIC"); + assert!(credits(&r, "tx", &intents, "cipherscan.near", "nep141:usdc.near").is_err()); + } + + #[tokio::test] + async fn pending_or_collected_swap_never_enters_merchant_bill_and_proof_cannot_be_reused() { + let p = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&p, "m").await; + for id in ["swap", "second", "direct"] { + crate::repair_tests::invoice(&p, id, "m").await; + } + for id in ["swap", "second"] { + let mut tx = p.begin().await.unwrap(); + reserve(&mut tx, id, &request()).await.unwrap(); + tx.commit().await.unwrap(); + } + for _ in 0..2 { + crate::billing::create_fee_entry(&p, "swap", "m", 0.001, 0.01) + .await + .unwrap(); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fee_ledger") + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(count, 0); + let c = credits( + &receipt(), + "tx", + &["this-intent".into()], + "cipherscan.near", + "nep141:usdc.near", + ) + .unwrap(); + record(&p, "swap", &c, "encrypted-test-evidence") + .await + .unwrap(); + record(&p, "swap", &c, "encrypted-test-evidence") + .await + .unwrap(); + assert!(record(&p, "second", &c, "encrypted-test-evidence") + .await + .is_err()); + let actual: Option = sqlx::query_scalar( + "SELECT received_units FROM crosschain_fees WHERE invoice_id='swap'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(actual.as_deref(), Some("71234")); + let pending: Option = sqlx::query_scalar( + "SELECT received_units FROM crosschain_fees WHERE invoice_id='second'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(pending, None); + crate::billing::create_fee_entry(&p, "swap", "m", 0.001, 0.01) + .await + .unwrap(); + crate::billing::create_fee_entry(&p, "direct", "m", 0.001, 0.01) + .await + .unwrap(); + let billed: Vec = sqlx::query_scalar("SELECT invoice_id FROM fee_ledger") + .fetch_all(&p) + .await + .unwrap(); + assert_eq!(billed, vec!["direct"]); + } +} diff --git a/src/crosschain/operations.rs b/src/crosschain/operations.rs new file mode 100644 index 0000000..f1c2cc1 --- /dev/null +++ b/src/crosschain/operations.rs @@ -0,0 +1,55 @@ +//! Merchant controls and read-only triage. Never recreate an ambiguous swap. +use super::*; + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query("ALTER TABLE merchants ADD COLUMN crosschain_enabled INTEGER NOT NULL DEFAULT 1 CHECK(crosschain_enabled IN (0,1))").execute(pool).await?; + Ok(()) +} +pub async fn enabled(pool: &SqlitePool, invoice: &str) -> anyhow::Result { + Ok(sqlx::query_scalar("SELECT m.crosschain_enabled FROM merchants m JOIN invoices i ON i.merchant_id=m.id WHERE i.id=?") + .bind(invoice).fetch_optional(pool).await?.unwrap_or(false)) +} +pub async fn issues(pool: &SqlitePool) -> anyhow::Result> { + let rows: Vec<(String,String,String,String,i64)> = sqlx::query_as( + "SELECT i.id,i.memo_code,a.state, + CASE WHEN a.state IN ('CREATING','NEEDS_REVIEW') THEN 'setup_review' + WHEN f.state NOT IN ('collected','direct','provider_handled') AND i.status='confirmed' AND f.failures>=3 THEN 'fee_verification' + WHEN a.state IN ('FAILED','INCOMPLETE_DEPOSIT') THEN 'payment_review' + ELSE 'settlement_delayed' END, + COALESCE(f.failures,0) + FROM crosschain_attempts a JOIN invoices i ON i.id=a.invoice_id + LEFT JOIN crosschain_fees f ON f.invoice_id=i.id + WHERE (a.state='NEEDS_REVIEW' + OR (a.state='CREATING' AND i.created_at=3) + OR (a.state IN ('FAILED','INCOMPLETE_DEPOSIT') AND i.status NOT IN ('cancelled','refunded')) + OR (a.state IN ('KNOWN_DEPOSIT_TX','PROCESSING','SUCCESS') AND i.status NOT IN ('confirmed','refunded','cancelled') + AND i.expires_at) -> HttpResponse { + if !crate::api::admin::authenticate_admin(&req) { return HttpResponse::Unauthorized().finish(); } + match issues(&pool).await { + Ok(issues)=>HttpResponse::Ok().insert_header(("Cache-Control","no-store")).json(json!({"issues":issues})), + Err(_)=>HttpResponse::ServiceUnavailable().json(json!({"error":"Payment monitoring unavailable"})), + } +} +/// Retry only reconciliation of the existing deposit. No new quote or payment. +pub async fn retry(req: actix_web::HttpRequest,pool:web::Data,path:web::Path) -> HttpResponse { + if !crate::api::admin::authenticate_admin(&req) { return HttpResponse::Unauthorized().finish(); } + let result=async { + let mut tx=pool.begin().await?; + let found:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM crosschain_attempts WHERE invoice_id=? AND payload IS NOT NULL)").bind(path.as_str()).fetch_one(&mut *tx).await?; + ensure!(found,"No saved deposit to reconcile. Review setup with the provider; do not create another swap."); + sqlx::query("UPDATE crosschain_fees SET next_check=0 WHERE invoice_id=? AND state NOT IN ('collected','direct','provider_handled')").bind(path.as_str()).execute(&mut *tx).await?; + sqlx::query("UPDATE crosschain_attempts SET last_checked=0 WHERE invoice_id=?").bind(path.as_str()).execute(&mut *tx).await?; + tx.commit().await?; + anyhow::Ok(()) + }.await; + match result { + Ok(())=>HttpResponse::Ok().json(json!({"ok":true})), + Err(e)=>HttpResponse::Conflict().json(json!({"error":e.to_string()})), + } +} diff --git a/src/crosschain/security.rs b/src/crosschain/security.rs new file mode 100644 index 0000000..cbc4487 --- /dev/null +++ b/src/crosschain/security.rs @@ -0,0 +1,93 @@ +//! Checkout mutation capabilities are separate from public invoice identifiers. +use super::*; +use actix_web::HttpRequest; + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + sqlx::raw_sql("CREATE TABLE checkout_access ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE); + INSERT INTO checkout_access SELECT id,lower(hex(randomblob(32))) FROM invoices; + CREATE TRIGGER invoice_checkout_access AFTER INSERT ON invoices BEGIN + INSERT INTO checkout_access VALUES (NEW.id,lower(hex(randomblob(32)))); END; + CREATE TABLE deferred_payment_fees ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants(id), amount_zec REAL NOT NULL, + rate REAL NOT NULL, state TEXT NOT NULL DEFAULT 'verifying'); + ALTER TABLE crosschain_attempts ADD COLUMN price_expires_at TEXT; + UPDATE crosschain_attempts SET price_expires_at=(SELECT MIN(expires_at,strftime('%Y-%m-%dT%H:%M:%SZ','now')) FROM invoices WHERE id=invoice_id); + CREATE TABLE crosschain_deliveries ( + invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + txid TEXT NOT NULL, PRIMARY KEY(invoice_id,txid));") + .execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) +} + +pub async fn token(pool: &SqlitePool, id: &str) -> anyhow::Result { + Ok( + sqlx::query_scalar("SELECT token FROM checkout_access WHERE invoice_id=?") + .bind(id) + .fetch_one(pool) + .await?, + ) +} + +pub async fn authorized(req: &HttpRequest, pool: &SqlitePool, id: &str) -> bool { + let Some(supplied) = req + .headers() + .get("X-Checkout-Token") + .and_then(|v| v.to_str().ok()) + else { + return false; + }; + if supplied.len() != 64 { + return false; + } + let Ok(expected) = token(pool, id).await else { + return false; + }; + // Fixed-length comparison; do not log this capability or put it in query strings. + expected + .bytes() + .zip(supplied.bytes()) + .fold(0_u8, |diff, (a, b)| diff | (a ^ b)) + == 0 +} + +pub async fn access( + req: HttpRequest, + pool: web::Data, + path: web::Path, +) -> HttpResponse { + let merchant = match crate::api::auth::require_full_or_session(&req, &pool).await { + Ok(m) => m, + Err(r) => return r, + }; + let owned = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM invoices WHERE id=? AND merchant_id=?)", + ) + .bind(path.as_str()) + .bind(&merchant.id) + .fetch_one(pool.get_ref()) + .await + .unwrap_or(false); + if !owned { + return HttpResponse::NotFound().finish(); + } + match token(&pool, &path).await { + Ok(token) => HttpResponse::Ok() + .insert_header(("Cache-Control", "no-store")) + .json(json!({"checkout_token":token})), + Err(_) => HttpResponse::InternalServerError().finish(), + } +} + +/// During recovery the scanner may accept only the provider's verified delivery, +/// never an unrelated direct transfer at an obsolete fiat exchange rate. +pub async fn accepts_payment(pool: &SqlitePool, id: &str, txid: &str) -> anyhow::Result { + Ok(sqlx::query_scalar("SELECT NOT EXISTS(SELECT 1 FROM crosschain_attempts WHERE invoice_id=? AND price_expires_at<=strftime('%Y-%m-%dT%H:%M:%SZ','now')) + OR EXISTS(SELECT 1 FROM crosschain_deliveries WHERE invoice_id=? AND lower(txid)=lower(?)) + OR EXISTS(SELECT 1 FROM invoice_payments WHERE invoice_id=? AND txid=?)") + .bind(id).bind(id).bind(txid).bind(id).bind(txid).fetch_one(pool).await?) +} diff --git a/src/crosschain/signatures.rs b/src/crosschain/signatures.rs new file mode 100644 index 0000000..5eff927 --- /dev/null +++ b/src/crosschain/signatures.rs @@ -0,0 +1,124 @@ +//! Matches @defuse-protocol/one-click-sdk-typescript 0.1.26 signed field selection. +//! Fees and confidentiality are not signed by 1Click; validate them separately +//! against the authenticated response rather than treating them as signed proof. +use anyhow::{ensure, Context}; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +const MANAGER: &str = "reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc"; + +fn truthy(v: &Value) -> bool { + !v.is_null() && v != false && v != 0 && v != "" +} +fn message(response: &Value) -> anyhow::Result { + let req = response["quoteRequest"].as_object().context("Missing signed request")?; + let quote = response["quote"].as_object().context("Missing signed quote")?; + let mut signed = Map::new(); + for k in ["dry", "swapType", "slippageTolerance", "originAsset", "depositType", "destinationAsset", "amount", "refundTo", "refundType", "recipient", "recipientType", "deadline"] { + if let Some(v) = req.get(k) { signed.insert(k.into(), v.clone()); } + } + for k in ["quoteWaitingTimeMs", "referral", "virtualChainRecipient", "virtualChainRefundRecipient", "customRecipientMsg"] { + if let Some(v) = req.get(k).filter(|v| truthy(v)) { signed.insert(k.into(), v.clone()); } + } + for k in ["amountIn", "amountInFormatted", "amountInUsd", "minAmountIn", "amountOut", "amountOutFormatted", "amountOutUsd", "minAmountOut"] { + if let Some(v) = quote.get(k) { signed.insert(k.into(), v.clone()); } + } + if req.get("dry") != Some(&Value::Bool(true)) { + // Object spread in the SDK overrides the request's deadline, even if absent. + signed.remove("deadline"); + for k in ["depositAddress", "depositMemo", "deadline", "timeWhenInactive", "timeEstimate", "refundFee", "withdrawFee"] { + if let Some(v) = quote.get(k).filter(|v| truthy(v)) { signed.insert(k.into(), v.clone()); } + } + } + signed.insert("timestamp".into(), response.get("timestamp").context("Missing signature timestamp")?.clone()); + // RFC 8785 uses ECMAScript number formatting, including integral floats. + let bytes = serde_json_canonicalizer::to_vec(&Value::Object(signed))?; + Ok(bs58::encode(Sha256::digest(bytes)).into_string()) +} +fn verify_with(response: &Value, key: &[u8;32]) -> anyhow::Result<()> { + let raw = response["signature"].as_str().context("Missing quote signature")?; + let bytes = bs58::decode(raw.strip_prefix("ed25519:").unwrap_or(raw)).into_vec()?; + let sig = Signature::from_slice(&bytes)?; + VerifyingKey::from_bytes(key)?.verify_strict(message(response)?.as_bytes(), &sig)?; + Ok(()) +} +pub(super) fn verify(response: &Value) -> anyhow::Result<()> { + let key: [u8;32] = bs58::decode(MANAGER).into_vec()?.try_into().map_err(|_| anyhow::anyhow!("Invalid pinned signer"))?; + #[cfg(test)] + if verify_with(response, &ed25519_dalek::SigningKey::from_bytes(&[42;32]).verifying_key().to_bytes()).is_ok() { return Ok(()); } + verify_with(response, &key) +} +pub(super) fn verify_fresh(response: &Value) -> anyhow::Result<()> { + verify(response)?; + let timestamp = response["timestamp"].as_str().context("Missing quote timestamp")?; + let age = chrono::Utc::now().signed_duration_since(chrono::DateTime::parse_from_rfc3339(timestamp)?).num_seconds(); + ensure!((-60..=300).contains(&age), "Stale quote signature"); + Ok(()) +} +/// 1Click status omits the default quoteWaitingTimeMs even though the original +/// signature covers it. Confirmed against both fresh and historical real swaps. +/// Restore only this known default, and still require the pinned signature. +pub(super) fn verify_status(response: &Value) -> anyhow::Result<()> { + if verify(response).is_ok() { return Ok(()); } + ensure!(response["quoteRequest"].is_object(), "Missing signed request"); + ensure!(response["quoteRequest"].get("quoteWaitingTimeMs").is_none(), "Invalid status signature"); + let mut normalized = response.clone(); + normalized["quoteRequest"]["quoteWaitingTimeMs"] = Value::from(3000); + verify(&normalized) +} +#[cfg(test)] +pub(super) fn signed(mut response: Value) -> Value { + use ed25519_dalek::Signer; + response["timestamp"] = Value::String(chrono::Utc::now().to_rfc3339()); + let sig = ed25519_dalek::SigningKey::from_bytes(&[42;32]).sign(message(&response).unwrap().as_bytes()); + response["signature"] = Value::String(format!("ed25519:{}",bs58::encode(sig.to_bytes()).into_string())); + response +} +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn status_normalization_preserves_signature_and_tamper_checks() { + let original=signed(json!({"quoteRequest":{"dry":false,"quoteWaitingTimeMs":3000,"recipient":"merchant"},"quote":{"depositAddress":"deposit","amountOut":"123"}})); + let mut status=original.clone(); + status["quoteRequest"].as_object_mut().unwrap().remove("quoteWaitingTimeMs"); + assert!(verify(&status).is_err()); + verify_status(&status).unwrap(); + status["quote"]["depositAddress"]=json!("attacker"); + assert!(verify_status(&status).is_err()); + let mut altered=original; + altered["quoteRequest"]["quoteWaitingTimeMs"]=json!(4000); + assert!(verify_status(&altered).is_err()); + let mut other=signed(json!({"quoteRequest":{"dry":false,"quoteWaitingTimeMs":4000},"quote":{"amountOut":"123"}})); + other["quoteRequest"].as_object_mut().unwrap().remove("quoteWaitingTimeMs"); + assert!(verify_status(&other).is_err()); + } + #[test] + fn signatures_reject_mutation_missing_signer_and_stale_quotes() { + let q=signed(json!({"quoteRequest":{"dry":false,"recipient":"merchant","deadline":"old"},"quote":{"amountIn":"123","amountOut":"456","depositAddress":"deposit","deadline":"new"}})); + verify_fresh(&q).unwrap(); + for key in ["depositAddress","deadline","amountIn","amountOut"] { + let mut bad=q.clone();bad["quote"][key]=json!("attacker");assert!(verify(&bad).is_err()); + } + let mut bad=q.clone();bad["quoteRequest"]["recipient"]=json!("attacker");assert!(verify(&bad).is_err()); + let mut bad=q.clone();bad["signature"]=Value::Null;assert!(verify(&bad).is_err()); + assert!(verify_with(&q,&[1;32]).is_err()); + let mut old=q;old["timestamp"]=json!("2020-01-01T00:00:00Z"); + use ed25519_dalek::Signer; + let sig=ed25519_dalek::SigningKey::from_bytes(&[42;32]).sign(message(&old).unwrap().as_bytes()); + old["signature"]=json!(bs58::encode(sig.to_bytes()).into_string()); + verify(&old).unwrap();assert!(verify_fresh(&old).is_err()); + } +} + +#[test] +#[ignore = "requires a real provider fixture supplied via SIGNED_QUOTE_FIXTURE"] +fn real_provider_signature() { + let file=std::env::var("SIGNED_QUOTE_FIXTURE").unwrap(); + let q:Value=serde_json::from_slice(&std::fs::read(file).unwrap()).unwrap(); + let key:[u8;32]=bs58::decode(MANAGER).into_vec().unwrap().try_into().unwrap(); + if std::env::var("SIGNED_QUOTE_STATUS").is_ok() { verify_status(&q).unwrap(); } + else { verify_with(&q,&key).unwrap(); } + let mut tampered=q;tampered["quote"]["amountIn"]=Value::String("1".into());assert!(verify_with(&tampered,&key).is_err()); +} diff --git a/src/crosschain/tests.rs b/src/crosschain/tests.rs new file mode 100644 index 0000000..86e62b0 --- /dev/null +++ b/src/crosschain/tests.rs @@ -0,0 +1,1142 @@ +use super::*; +use crate::repair_tests::{invoice, merchant, pool}; +use zcash_address::unified::{Encoding, Fvk, Ufvk}; + +fn asset() -> Asset { + Asset { + asset_id: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near".into(), + symbol: "USDC".into(), + blockchain: "base".into(), + decimals: 6, + contract_address: Some("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913".into()), + } +} + +async fn fixture() -> (SqlitePool, crate::invoices::Invoice) { + let pool = pool().await; + merchant(&pool, "m").await; + invoice(&pool, "i", "m").await; + let sk = orchard::keys::SpendingKey::from_bytes([7; 32]).unwrap(); + let key = Ufvk::try_from_items(vec![Fvk::Orchard( + orchard::keys::FullViewingKey::from(&sk).to_bytes(), + )]) + .unwrap() + .encode(&zcash_protocol::consensus::NetworkType::Main); + let address = crate::addresses::derive_invoice_address(&key, 1).unwrap(); + sqlx::query("UPDATE invoices SET payment_address=?,orchard_receiver_hex=?,price_zatoshis=10000000 WHERE id='i'") + .bind(address.ua_string).bind(address.orchard_receiver_hex).execute(&pool).await.unwrap(); + let i = crate::invoices::get_invoice(&pool, "i") + .await + .unwrap() + .unwrap(); + (pool, i) +} + +fn input() -> QuoteInput { + QuoteInput { + asset_id: asset().asset_id, + refund_address: "0x1234567890123456789012345678901234567890".into(), + } +} + +#[tokio::test] +async fn quote_is_exact_and_server_bound() { + let (_, i) = fixture().await; + let body = quote_body(&i, &input(), &asset(), true).unwrap(); + assert_eq!(body["amount"], "10000000"); + assert_eq!(body["recipient"], i.payment_address); + assert_eq!(body["swapType"], "EXACT_OUTPUT"); + assert_eq!(body["destinationAsset"], ZEC); + assert_eq!(body["confidentiality"], "basic"); + assert!(body.get("appFees").is_none()); + assert!(serde_json::from_value::( + json!({"asset_id":"x","refund_address":"x","amount":"1"}) + ) + .is_err()); + for status in ["draft", "confirmed", "expired", "underpaid"] { + let mut invalid = i.clone(); + invalid.status = status.into(); + assert!(quote_body(&invalid, &input(), &asset(), true).is_err()); + } + let mut legacy = i.clone(); + legacy.orchard_receiver_hex = None; + assert!(quote_body(&legacy, &input(), &asset(), true).is_err()); +} + +#[tokio::test] +async fn rejects_underpayment_and_substituted_recipient() { + let (_, i) = fixture().await; + let body = quote_body(&i, &input(), &asset(), true).unwrap(); + let mut response = json!({"quoteRequest":body,"quote":{"amountIn":"12345678","amountOut":"10000000","minAmountOut":"10000000"}}); + assert!(checked_quote(&response, &body, &asset(), true).is_ok()); + response["quote"]["minAmountOut"] = json!("9950000"); + assert!(checked_quote(&response, &body, &asset(), true).is_err()); + response["quote"]["minAmountOut"] = json!("10000000"); + response["quoteRequest"]["recipient"] = json!("attacker"); + assert!(checked_quote(&response, &body, &asset(), true).is_err()); +} + +#[tokio::test] +async fn confidential_quotes_reject_public_or_unconfirmed_routing() { + let (_, i) = fixture().await; + for dry in [true, false] { + let body = quote_body(&i, &input(), &asset(), dry).unwrap(); + let response = json!({"quoteRequest":body,"quote":{ + "amountIn":"12345678","amountOut":"10000000","minAmountOut":"10000000", + "depositAddress":input().refund_address,"deadline":body["deadline"] + }}); + let quote = checked_quote(&response, &body, &asset(), dry).unwrap(); + assert_eq!(quote["confidentiality"], "basic"); + for mode in [json!("public"), json!("advanced"), Value::Null] { + let mut changed = response.clone(); + changed["quoteRequest"]["confidentiality"] = mode; + assert!(checked_quote(&changed, &body, &asset(), dry).is_err()); + } + let mut missing = response; + missing["quoteRequest"].as_object_mut().unwrap().remove("confidentiality"); + assert!(checked_quote(&missing, &body, &asset(), dry).is_err()); + missing.as_object_mut().unwrap().remove("quoteRequest"); + assert!(checked_quote(&missing, &body, &asset(), dry).is_err()); + } +} + +#[actix_web::test] +async fn confidential_gateway_never_retries_with_public_routing() { + use actix_web::{App, HttpServer}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = web::Data::new(AtomicUsize::new(0)); + let cc = calls.clone(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(move || App::new().app_data(cc.clone()).route("/quote", web::post().to( + |body: web::Json, calls: web::Data| async move { + assert_eq!(body["confidentiality"], "basic"); + let attempt = calls.fetch_add(1, Ordering::SeqCst); + if attempt == 2 { return HttpResponse::ServiceUnavailable().finish(); } + let mut echo = body.into_inner(); + if attempt == 0 { echo["confidentiality"] = json!("public"); } + else if attempt == 1 { echo.as_object_mut().unwrap().remove("confidentiality"); } + HttpResponse::Ok().json(signatures::signed(json!({"quoteRequest":echo,"quote":{ + "amountIn":"12345678","amountOut":"10000000","minAmountOut":"10000000" + }}))) + } + ))).listen(listener).unwrap().run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + let gateway = Gateway { + enabled: true, fee_recipient: None, key: String::new(), base, + client: reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(), tokens: Mutex::new(None), + eligibility: Mutex::new(HashMap::new()), eligibility_slots: Semaphore::new(4), + }; + let (_, invoice) = fixture().await; + let body = quote_body(&invoice, &input(), &asset(), true).unwrap(); + for expected_calls in 1..=3 { + assert!(gateway.quote(&body).await.is_err()); + assert_eq!(calls.load(Ordering::SeqCst), expected_calls); + } + assert!(gateway.quote(&body).await.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 4); + handle.stop(true).await; +} + +#[tokio::test] +async fn reservation_is_durable_unique_and_requires_full_payment() { + let (p, i) = fixture().await; + let deadline = (Utc::now() + Duration::minutes(30)).to_rfc3339(); + let (a, b) = tokio::join!( + reserve(&p, "i", &deadline, None), + reserve(&p, "i", &deadline, None) + ); + assert_eq!(a.unwrap() as u8 + b.unwrap() as u8, 1); + let strict = crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap(); + assert_eq!(strict.minimum_payment_zatoshis(), 10000000); + assert_eq!(i.minimum_payment_zatoshis(), 9950000); + let pending = crate::invoices::get_pending_invoices(&p).await.unwrap(); + assert!(pending[0].exact_payment); + assert_eq!( + read_attempt(&p, "i", "").await.unwrap().unwrap()["state"], + "CREATING" + ); + assert!(!reserve(&p, "i", &deadline, None).await.unwrap()); + sqlx::query("DELETE FROM invoices WHERE id='i'") + .execute(&p) + .await + .unwrap(); + assert!(read_attempt(&p, "i", "").await.unwrap().is_none()); +} + +#[tokio::test] +async fn encrypted_attempt_survives_reload_without_changing_invoice_status() { + let (p, _) = fixture().await; + let key = "11".repeat(32); + reserve( + &p, + "i", + &(Utc::now() + Duration::minutes(30)).to_rfc3339(), + None, + ) + .await + .unwrap(); + let payload = crypto_payload(&key); + sqlx::query("UPDATE crosschain_attempts SET state='SUCCESS',payload=? WHERE invoice_id='i'") + .bind(&payload) + .execute(&p) + .await + .unwrap(); + assert!(!payload.contains("refundAddress")); + let restored = read_attempt(&p, "i", &key).await.unwrap().unwrap(); + assert_eq!(restored["quote"]["refundAddress"], "private"); + assert_eq!( + crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap() + .status, + "pending" + ); + assert!(read_attempt(&p, "i", &"22".repeat(32)).await.is_err()); +} + +fn crypto_payload(key: &str) -> String { + crate::crypto::encrypt(&json!({"refundAddress":"private"}).to_string(), key).unwrap() +} + +#[test] +fn asset_and_refund_networks_are_restricted() { + assert!(curated(&asset().asset_id)); + assert!(!curated("nep141:fake-usdc.near")); + assert!(!curated("1cs_v1:sol:wrapped-zec")); + assert!(valid_refund("base", &input().refund_address)); + assert!(!valid_refund("tron", &input().refund_address)); + assert!(!valid_refund( + "eth", + "0x0000000000000000000000000000000000000000" + )); + assert!(!valid_refund("btc", "tb1qtestnet")); + assert!(positive_units(&json!("1.5")).is_err()); + assert!(positive_units(&json!("-1")).is_err()); +} + +#[tokio::test] +#[ignore = "prints a public test-fixture address for dry quote probes; never fund"] +async fn dry_probe_address() { + let (_, i) = fixture().await; + println!("DRY_PROBE_ADDRESS={}", i.payment_address); +} + +#[actix_web::test] +async fn wallet_lock_requires_ownership_and_survives_reload() { + use actix_web::{test, App}; + let (p, _) = fixture().await; + let mut config = crate::config::Config::from_env().unwrap(); + config.encryption_key = "11".repeat(32); + let deadline = (Utc::now() + Duration::minutes(30)).to_rfc3339(); + reserve(&p, "i", &deadline, None).await.unwrap(); + let payload = crate::crypto::encrypt( + &json!({"deadline":deadline}).to_string(), + &config.encryption_key, + ) + .unwrap(); + sqlx::query("UPDATE crosschain_attempts SET state='PENDING_DEPOSIT',payload=?,last_success=strftime('%s','now') WHERE invoice_id='i'").bind(payload).execute(&p).await.unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .app_data(web::Data::new(config.clone())) + .route("/{id}", web::post().to(wallet_send)), + ) + .await; + let access = security::token(&p, "i").await.unwrap(); + assert_eq!( + test::call_service( + &app, + test::TestRequest::post() + .uri("/i") + .set_json(json!({"action":"start"})) + .to_request() + ) + .await + .status(), + 403 + ); + let req = || { + test::TestRequest::post() + .uri("/i") + .insert_header(("X-Checkout-Token", access.clone())) + }; + let first: Value = + test::call_and_read_body_json(&app, req().set_json(json!({"action":"start"})).to_request()) + .await; + let token = first["token"].as_str().unwrap(); + assert_eq!( + test::call_service(&app, req().set_json(json!({"action":"start"})).to_request()) + .await + .status(), + 409 + ); + assert_eq!( + read_attempt(&p, "i", &config.encryption_key) + .await + .unwrap() + .unwrap()["sendStarted"], + true + ); + assert_eq!( + test::call_service( + &app, + req() + .set_json(json!({"action":"rejected","token":uuid::Uuid::new_v4().to_string()})) + .to_request() + ) + .await + .status(), + 409 + ); + assert_eq!( + test::call_service( + &app, + req() + .set_json(json!({"action":"rejected","token":token})) + .to_request() + ) + .await + .status(), + 200 + ); + let second: Value = + test::call_and_read_body_json(&app, req().set_json(json!({"action":"start"})).to_request()) + .await; + let token = second["token"].as_str().unwrap(); + assert_eq!( + test::call_service( + &app, + req() + .set_json(json!({"action":"submitted","token":token,"txid":"a".repeat(64)})) + .to_request() + ) + .await + .status(), + 200 + ); + assert_eq!( + test::call_service( + &app, + req() + .set_json(json!({"action":"rejected","token":token})) + .to_request() + ) + .await + .status(), + 409 + ); + assert_eq!( + crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap() + .status, + "pending" + ); +} + +#[tokio::test] +async fn strict_settlement_cannot_be_bypassed_by_a_stale_scanner_snapshot() { + let (p, _) = fixture().await; + reserve( + &p, + "i", + &(Utc::now() + Duration::minutes(30)).to_rfc3339(), + None, + ) + .await + .unwrap(); + assert!(!crate::invoices::mark_detected(&p, "i", "tx", 9999999) + .await + .unwrap()); + assert!(crate::invoices::mark_detected(&p, "i", "tx", 10000000) + .await + .unwrap()); + sqlx::query("UPDATE invoices SET received_zatoshis=9999999 WHERE id='i'") + .execute(&p) + .await + .unwrap(); + assert!(!crate::invoices::mark_confirmed(&p, "i", None, None) + .await + .unwrap()); + sqlx::query("UPDATE invoices SET received_zatoshis=10000000 WHERE id='i'") + .execute(&p) + .await + .unwrap(); + assert!(crate::invoices::mark_confirmed(&p, "i", None, None) + .await + .unwrap()); +} + +#[actix_web::test] +async fn http_flow_creates_once_and_provider_success_does_not_settle() { + use actix_web::{test, App, HttpServer}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let quote_calls = web::Data::new(AtomicUsize::new(0)); + let calls = quote_calls.clone(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(move || App::new().app_data(calls.clone()) + .route("/quote", web::post().to(|body: web::Json, calls: web::Data| async move { + if body["dry"] == false { calls.fetch_add(1,Ordering::SeqCst); } + assert_eq!(body["appFees"],json!([{"recipient":"cipherscan.near","fee":125}])); + assert_eq!(body["confidentiality"], "basic"); + let mut echo=body.0.clone(); + echo["appFees"]=json!([{"recipient":"cipherscan.near","fee":125},{"recipient":"provider.near","fee":20}]); + HttpResponse::Ok().json(signatures::signed(json!({"correlationId":"test-quote","quoteRequest":echo,"quote":{ + "amountIn":"130000000","amountOut":"10000000","minAmountOut":"10000000", + "depositAddress":"0x2345678901234567890123456789012345678901","deadline":(chrono::DateTime::parse_from_rfc3339(body["deadline"].as_str().unwrap()).unwrap()+Duration::days(3)).to_rfc3339(),"timeEstimate":150 + }}))) + })) + .route("/status", web::get().to(|| async { HttpResponse::Ok().json(json!({"status":"SUCCESS"})) }))) + .listen(listener).unwrap().run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + let (p, _) = fixture().await; + let mut config = crate::config::Config::from_env().unwrap(); + config.encryption_key = "11".repeat(32); + let gateway = Gateway { + enabled: true, + fee_recipient: Some("cipherscan.near".into()), + key: String::new(), + base, + client: reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(), + tokens: Mutex::new(Some((Instant::now(), vec![asset()]))), + eligibility: Mutex::new(HashMap::new()), + eligibility_slots: Semaphore::new(4), + }; + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .app_data(web::Data::new(config)) + .app_data(web::Data::new(gateway)) + .route("/{id}", web::post().to(create)) + .route("/{id}", web::get().to(status)) + .route("/{id}/preview", web::post().to(preview)), + ) + .await; + let input = json!({"asset_id":asset().asset_id,"refund_address":input().refund_address}); + let preview_response = test::call_service( + &app, + test::TestRequest::post() + .uri("/i/preview") + .set_json(&input) + .to_request(), + ) + .await; + assert_eq!(preview_response.status(), 200); + assert_eq!(quote_calls.load(Ordering::SeqCst), 0); + assert!(read_attempt(&p, "i", "").await.unwrap().is_none()); + let first: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/i") + .insert_header(("X-Checkout-Token", security::token(&p, "i").await.unwrap())) + .set_json(&input) + .to_request(), + ) + .await; + assert_eq!(first["state"], "PENDING_DEPOSIT"); + assert_eq!(first["quote"]["confidentiality"], "basic"); + let fee_terms: (i64, i64) = sqlx::query_as("SELECT total_bps,cipherpay_bps FROM crosschain_fees WHERE invoice_id='i'") + .fetch_one(&p).await.unwrap(); + assert_eq!(fee_terms, (145, 125)); + let funding_deadline = + chrono::DateTime::parse_from_rfc3339(first["quote"]["deadline"].as_str().unwrap()).unwrap(); + let provider_deadline = + chrono::DateTime::parse_from_rfc3339(first["quote"]["providerDeadline"].as_str().unwrap()) + .unwrap(); + assert!(funding_deadline < Utc::now() + Duration::minutes(31)); + assert!(provider_deadline > Utc::now() + Duration::days(2)); + let invoice = crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap(); + assert!( + chrono::DateTime::parse_from_rfc3339(&invoice.expires_at).unwrap() + == chrono::DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z").unwrap() + ); + + let second: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/i") + .insert_header(("X-Checkout-Token", security::token(&p, "i").await.unwrap())) + .set_json(&input) + .to_request(), + ) + .await; + assert_eq!(first["quote"], second["quote"]); + assert_eq!(quote_calls.load(Ordering::SeqCst), 1); + let status: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/i").to_request()).await; + assert_eq!(status["state"], "PENDING_DEPOSIT"); + assert!(status.get("quote").is_none()); + assert!(status.get("sentTxid").is_none()); + assert_eq!(status["asset"]["symbol"],"USDC"); + assert_eq!( + crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap() + .status, + "pending" + ); + let fee: (String, Option, Option) = sqlx::query_as( + "SELECT state,correlation_id,received_units FROM crosschain_fees WHERE invoice_id='i'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(fee, ("pending".into(), Some("test-quote".into()), None)); + crate::billing::create_fee_entry(&p, "i", "m", 0.001, 0.01) + .await + .unwrap(); + let billed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM fee_ledger WHERE invoice_id='i'") + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(billed, 0); + handle.stop(true).await; +} + +#[actix_web::test] +async fn minimum_quote_error_is_actionable_without_exposing_provider_payload_or_creating_attempt() { + use actix_web::{test, App, HttpServer}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(|| { + App::new().route( + "/quote", + web::post().to(|| async { + HttpResponse::BadRequest().json(json!({ + "message":"Amount is too low for bridge, try at least 100000", + "private_debug":"recipient-and-refund-data-must-not-leak" + })) + }), + ) + }) + .listen(listener) + .unwrap() + .run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + let (p, _) = fixture().await; + sqlx::query("UPDATE invoices SET price_zatoshis=75326 WHERE id='i'") + .execute(&p) + .await + .unwrap(); + let gateway = Gateway { + enabled: true, + fee_recipient: None, + key: String::new(), + base, + client: reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(), + tokens: Mutex::new(Some((Instant::now(), vec![asset()]))), + eligibility: Mutex::new(HashMap::new()), + eligibility_slots: Semaphore::new(4), + }; + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .app_data(web::Data::new(gateway)) + .route("/{id}/preview", web::post().to(preview)), + ) + .await; + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/i/preview") + .set_json(json!({"asset_id":asset().asset_id,"refund_address":input().refund_address})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 400); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["code"], "BELOW_MINIMUM_OUTPUT"); + assert_eq!(body["minimumOutputZatoshis"], "100000"); + assert!(!body.to_string().contains("recipient-and-refund")); + assert!(read_attempt(&p, "i", "").await.unwrap().is_none()); + for message in [ + "Amount is too low for bridge, try at least 100000 recipient=secret", + "Amount is too low for bridge, try at least 0", + "Other failure", + ] { + assert!(minimum_output_error( + reqwest::StatusCode::BAD_REQUEST, + &json!({"message":message}) + ) + .is_none()); + } + handle.stop(true).await; +} + +#[actix_web::test] +async fn eligibility_checks_routes_without_wallet_or_attempt_and_distinguishes_outages() { + use actix_web::{test, App, HttpServer}; + use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; + let calls = web::Data::new(AtomicUsize::new(0)); + let state = web::Data::new(AtomicU8::new(0)); + let cc = calls.clone(); + let ss = state.clone(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(move || App::new().app_data(cc.clone()).app_data(ss.clone()).route("/quote",web::post().to(|body:web::Json, calls:web::Data, state:web::Data| async move { + calls.fetch_add(1,Ordering::SeqCst); + assert_eq!(body["dry"],true); + assert_eq!(body["swapType"],"EXACT_OUTPUT"); + assert_eq!(body["confidentiality"], "basic"); + assert!(body["refundTo"].as_str().is_some()); + let mode=state.load(Ordering::SeqCst); + if mode==3 { return HttpResponse::BadRequest().json(json!({"message":"Temporary swap limits: minimum swap amount is $100"})); } + if mode==2 { return HttpResponse::ServiceUnavailable().json(json!({"message":"upstream unavailable"})); } + if mode==0 || body["originAsset"].as_str().unwrap().contains("base-") { + return HttpResponse::BadRequest().json(json!({"message":"Amount is too low for bridge, try at least 100000"})); + } + HttpResponse::Ok().json(signatures::signed(json!({"quoteRequest":body.0,"quote":{"amountIn":"5000000","amountOut":body["amount"],"minAmountOut":body["amount"]}}))) + }))).listen(listener).unwrap().run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + let (p, _) = fixture().await; + sqlx::query("UPDATE invoices SET price_zatoshis=75326 WHERE id='i'") + .execute(&p) + .await + .unwrap(); + let mut sol = asset(); + sol.asset_id = "nep141:sol.omft.near".into(); + sol.blockchain = "sol".into(); + sol.symbol = "SOL".into(); + let gateway = web::Data::new(Gateway { + enabled: true, + fee_recipient: None, + key: String::new(), + base, + client: reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(), + tokens: Mutex::new(Some((Instant::now(), vec![asset(), sol]))), + eligibility: Mutex::new(HashMap::new()), + eligibility_slots: Semaphore::new(4), + }); + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .app_data(gateway.clone()) + .route("/{id}/eligibility", web::get().to(eligibility)), + ) + .await; + for mode in [0, 1, 2, 3] { + state.store(mode as u8, Ordering::SeqCst); + gateway.eligibility.lock().await.clear(); + let response = test::call_service( + &app, + test::TestRequest::get().uri("/i/eligibility").to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let body: Value = test::read_body_json(response).await; + assert_eq!( + body["state"], + ["UNAVAILABLE", "AVAILABLE", "UNKNOWN", "UNAVAILABLE"][mode] + ); + assert_eq!( + body["routes"][0]["state"], + if mode == 2 { + "UNKNOWN" + } else { + "BELOW_MINIMUM" + } + ); + if mode == 1 { + assert_eq!(body["routes"][1]["state"], "AVAILABLE"); + } + if mode == 3 { + assert_eq!(body["routes"][0]["minimumSwapUsd"], "100"); + } + for invalid in ["0", "100 secret", "-100", "100.000", "1.2.3"] { + assert!(minimum_swap_usd_error(reqwest::StatusCode::BAD_REQUEST, &json!({"message":format!("Temporary swap limits: minimum swap amount is ${invalid}")})).is_none()); + } + assert!(!body.to_string().contains("refundTo")); + assert!(!body.to_string().contains("depositAddress")); + let before = calls.load(Ordering::SeqCst); + let _: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/i/eligibility").to_request(), + ) + .await; + assert_eq!( + before, + calls.load(Ordering::SeqCst), + "cached check should not repeat provider calls" + ); + assert!(read_attempt(&p, "i", "").await.unwrap().is_none()); + } + // Invoice amount changes invalidate cached eligibility. + state.store(1, Ordering::SeqCst); + sqlx::query("UPDATE invoices SET price_zatoshis=200000 WHERE id='i'") + .execute(&p) + .await + .unwrap(); + let before = calls.load(Ordering::SeqCst); + let _: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/i/eligibility").to_request(), + ) + .await; + assert_eq!(calls.load(Ordering::SeqCst), before + 2); + // Existing attempts win even over a cached available result. + sqlx::query("INSERT INTO crosschain_attempts(invoice_id,state) VALUES ('i','CREATING')") + .execute(&p) + .await + .unwrap(); + let before = calls.load(Ordering::SeqCst); + let response = test::call_service( + &app, + test::TestRequest::get().uri("/i/eligibility").to_request(), + ) + .await; + assert_eq!(response.status(), 409); + assert_eq!(before, calls.load(Ordering::SeqCst)); + handle.stop(true).await; +} + +#[tokio::test] +async fn committed_quote_extension_keeps_funding_window_bounded() { + let (_, i) = fixture().await; + let body = quote_body(&i, &input(), &asset(), false).unwrap(); + let requested = + chrono::DateTime::parse_from_rfc3339(body["deadline"].as_str().unwrap()).unwrap(); + let provider = (requested + Duration::days(3)).to_rfc3339(); + let mut response = json!({"quoteRequest":body,"quote":{"amountIn":"5490787","amountOut":"10000000","minAmountOut":"10000000","depositAddress":input().refund_address,"deadline":provider,"timeWhenInactive":provider}}); + let checked = checked_quote(&response, &body, &asset(), false).unwrap(); + assert_eq!( + chrono::DateTime::parse_from_rfc3339(checked["deadline"].as_str().unwrap()).unwrap(), + requested + ); + assert_eq!(checked["providerDeadline"], provider); + let earlier = + (Utc::now() + Duration::minutes(5)).to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + response["quote"]["timeWhenInactive"] = json!(earlier); + assert_eq!( + checked_quote(&response, &body, &asset(), false).unwrap()["deadline"], + earlier + ); + response["quote"]["timeWhenInactive"] = json!((Utc::now() - Duration::seconds(1)).to_rfc3339()); + assert!(checked_quote(&response, &body, &asset(), false).is_err()); + response["quote"]["timeWhenInactive"] = Value::Null; + for deadline in [ + (Utc::now() - Duration::seconds(1)).to_rfc3339(), + (requested + Duration::days(8)).to_rfc3339(), + "invalid".into(), + ] { + response["quote"]["deadline"] = json!(deadline); + assert!(checked_quote(&response, &body, &asset(), false).is_err()); + } +} + +#[actix_web::test] +async fn public_invoice_marks_crosschain_without_exposing_swap_details() { + use actix_web::{test, App}; + let (p, _) = fixture().await; + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .route("/{id}", web::get().to(crate::api::invoices::get)), + ) + .await; + let before: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/i").to_request()).await; + assert_eq!(before["is_crosschain"], false); + reserve( + &p, + "i", + &(Utc::now() + Duration::minutes(30)).to_rfc3339(), + None, + ) + .await + .unwrap(); + let after: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/i").to_request()).await; + assert_eq!(after["is_crosschain"], true); + assert!(after.get("payload").is_none()); + assert!(after.get("depositAddress").is_none()); + assert!(after.get("refundAddress").is_none()); +} + +#[actix_web::test] +async fn merchant_invoice_tag_is_scoped_and_preserves_received_amount() { + use actix_web::{cookie::Cookie, test, App}; + let (p, _) = fixture().await; + merchant(&p, "other").await; + invoice(&p, "other-invoice", "other").await; + invoice(&p, "direct", "m").await; + reserve( + &p, + "i", + &(Utc::now() + Duration::minutes(30)).to_rfc3339(), + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE invoices SET received_zatoshis=10007000,status='confirmed' WHERE id='i'") + .execute(&p) + .await + .unwrap(); + sqlx::query("INSERT INTO sessions(id,merchant_id,expires_at) VALUES ('test-session','m','2099-01-01T00:00:00Z')").execute(&p).await.unwrap(); + let config = crate::config::Config::from_env().unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(p)) + .app_data(web::Data::new(config)) + .route("/", web::get().to(crate::api::auth::my_invoices)), + ) + .await; + let rows: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri("/") + .cookie(Cookie::new("cpay_session", "test-session")) + .to_request(), + ) + .await; + let rows = rows.as_array().expect("authenticated invoice list"); + assert_eq!(rows.len(), 2); + let cross = rows.iter().find(|r| r["id"] == "i").unwrap(); + assert_eq!(cross["is_crosschain"], true); + assert_eq!(cross["received_zatoshis"], 10007000); + assert_eq!( + rows.iter().find(|r| r["id"] == "direct").unwrap()["is_crosschain"], + false + ); + assert!(cross.get("payload").is_none()); +} + +#[tokio::test] +async fn unused_reservation_preserves_and_resolves_direct_fee_obligation() { + let (p, inv) = fixture().await; + let mut body = quote_body(&inv, &input(), &asset(), false).unwrap(); + fees::apply(&mut body, Some("cipherscan.near")); + reserve(&p, "i", body["deadline"].as_str().unwrap(), Some(&body)) + .await + .unwrap(); + let paid = crate::invoices::record_payment(&p, "i", "direct", inv.price_zatoshis) + .await + .unwrap(); + crate::invoices::mark_detected(&p, "i", "direct", paid) + .await + .unwrap(); + crate::invoices::mark_confirmed(&p, "i", None, None) + .await + .unwrap(); + crate::billing::create_fee_entry(&p, "i", "m", 0.001, 0.01) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM deferred_payment_fees WHERE invoice_id='i'" + ) + .fetch_one(&p) + .await + .unwrap(), + "verifying" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM fee_ledger") + .fetch_one(&p) + .await + .unwrap(), + 0 + ); + fees::resolve_direct(&p, "i").await.unwrap(); + fees::resolve_direct(&p, "i").await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT fee_amount_zatoshis FROM fee_ledger WHERE invoice_id='i'" + ) + .fetch_one(&p) + .await + .unwrap(), + 100000 + ); +} + +#[tokio::test] +async fn quote_never_extends_direct_price_lock_and_recovery_accepts_only_delivery() { + let (p, _) = fixture().await; + let original = + (Utc::now() + Duration::minutes(2)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + sqlx::query("UPDATE invoices SET expires_at=? WHERE id='i'") + .bind(&original) + .execute(&p) + .await + .unwrap(); + reserve( + &p, + "i", + &(Utc::now() + Duration::days(3)).to_rfc3339(), + None, + ) + .await + .unwrap(); + assert_eq!( + crate::invoices::get_invoice(&p, "i") + .await + .unwrap() + .unwrap() + .expires_at, + original + ); + sqlx::query("UPDATE crosschain_attempts SET price_expires_at='2000-01-01T00:00:00Z'") + .execute(&p) + .await + .unwrap(); + assert_eq!( + crate::invoices::record_payment(&p, "i", "late-direct", 10000000) + .await + .unwrap(), + 0 + ); + sqlx::query("INSERT INTO crosschain_deliveries VALUES ('i','delivery')") + .execute(&p) + .await + .unwrap(); + assert_eq!( + crate::invoices::record_payment(&p, "i", "delivery", 10000000) + .await + .unwrap(), + 10000000 + ); + assert_eq!( + crate::invoices::record_payment(&p, "i", "delivery", 10000000) + .await + .unwrap(), + 10000000 + ); +} + +#[actix_web::test] +async fn public_invoice_cannot_create_adopt_or_lock_a_swap() { + use actix_web::{test, App}; + let (p, _) = fixture().await; + let mut config = crate::config::Config::from_env().unwrap(); + config.encryption_key = "11".repeat(32); + let gateway = Gateway { + enabled: true, + fee_recipient: None, + key: String::new(), + base: "http://127.0.0.1:1".into(), + client: reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(), + tokens: Mutex::new(None), + eligibility: Mutex::new(HashMap::new()), + eligibility_slots: Semaphore::new(4), + }; + let app = test::init_service( + App::new() + .app_data(web::Data::new(p.clone())) + .app_data(web::Data::new(config.clone())) + .app_data(web::Data::new(gateway)) + .route("/{id}", web::post().to(create)) + .route("/{id}/send", web::post().to(wallet_send)), + ) + .await; + let body = json!({"asset_id":asset().asset_id,"refund_address":input().refund_address}); + for path in ["/i", "/i/send"] { + let data = if path.ends_with("send") { + json!({"action":"start"}) + } else { + body.clone() + }; + assert_eq!( + test::call_service( + &app, + test::TestRequest::post() + .uri(path) + .set_json(data) + .to_request() + ) + .await + .status(), + 403 + ); + } + assert!(read_attempt(&p, "i", &config.encryption_key) + .await + .unwrap() + .is_none()); + reserve( + &p, + "i", + &(Utc::now() + Duration::minutes(30)).to_rfc3339(), + None, + ) + .await + .unwrap(); + let payload = crate::crypto::encrypt( + &json!({"asset":asset(),"refundAddress":"other"}).to_string(), + &config.encryption_key, + ) + .unwrap(); + sqlx::query("UPDATE crosschain_attempts SET payload=? WHERE invoice_id='i'") + .bind(payload) + .execute(&p) + .await + .unwrap(); + assert_eq!( + test::call_service( + &app, + test::TestRequest::post() + .uri("/i") + .insert_header(("X-Checkout-Token", security::token(&p, "i").await.unwrap())) + .set_json(body) + .to_request() + ) + .await + .status(), + 409 + ); +} + +#[test] +fn refund_addresses_require_network_checksums() { + assert!(valid_refund("btc", "1BoatSLRHtKNngkdXEeobR76b53LETtpyT")); + assert!(!valid_refund("btc", "1BoatSLRHtKNngkdXEeobR76b53LETtpyU")); + assert!(valid_refund("tron", "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7")); + assert!(!valid_refund("tron", "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU8")); + assert!(!valid_refund("sol", "11111111111111111111111111111111")); + assert!(!valid_refund("sol", "z".repeat(44).as_str())); + assert!(valid_refund( + "eth", + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + )); + assert!(!valid_refund( + "eth", + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeD" + )); +} + +#[actix_web::test] +async fn private_swap_details_require_the_invoice_capability_for_every_state() { + use actix_web::{test,App}; + let (p,_)=fixture().await; + let mut config=crate::config::Config::from_env().unwrap();config.encryption_key="11".repeat(32); + reserve(&p,"i",&(Utc::now()+Duration::minutes(10)).to_rfc3339(),None).await.unwrap(); + let payload=crate::crypto::encrypt(&json!({"asset":asset(),"refundAddress":"private-refund","depositAddress":"private-deposit","depositMemo":"private-memo","amountIn":"123","deadline":(Utc::now()+Duration::minutes(10)).to_rfc3339()}).to_string(),&config.encryption_key).unwrap(); + let gateway=Gateway{enabled:true,fee_recipient:None,key:String::new(),base:"http://127.0.0.1:1".into(),client:reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(),tokens:Mutex::new(None),eligibility:Mutex::new(HashMap::new()),eligibility_slots:Semaphore::new(4)}; + let app=test::init_service(App::new().app_data(web::Data::new(p.clone())).app_data(web::Data::new(config)).app_data(web::Data::new(gateway)).route("/{id}",web::get().to(status))).await; + let token=security::token(&p,"i").await.unwrap(); + for state in ["PENDING_DEPOSIT","PROCESSING","SUCCESS","REFUNDED","NEEDS_REVIEW"] { + sqlx::query("UPDATE crosschain_attempts SET state=?,payload=?,sent_txid='private-tx',send_token='private-lock',last_checked=strftime('%s','now'),last_success=strftime('%s','now') WHERE invoice_id='i'").bind(state).bind(&payload).execute(&p).await.unwrap(); + for header in [None,Some("a".repeat(64))] { + let mut req=test::TestRequest::get().uri("/i");if let Some(h)=header{req=req.insert_header(("X-Checkout-Token",h));} + let public:Value=test::call_and_read_body_json(&app,req.to_request()).await; + assert_eq!(public["canManage"],false);assert_eq!(public["asset"]["symbol"],"USDC"); + assert_eq!(public["settlementVerified"],false); + assert!(!public.to_string().contains("private-"));assert!(public.get("quote").is_none());assert!(public.get("sentTxid").is_none()); + } + let private:Value=test::call_and_read_body_json(&app,test::TestRequest::get().uri("/i").insert_header(("X-Checkout-Token",token.clone())).to_request()).await; + assert_eq!(private["quote"]["refundAddress"],"private-refund");assert_eq!(private["canManage"],true); + assert_eq!(private["settlementVerified"],false); + } + sqlx::query("INSERT INTO crosschain_settlements(invoice_id) VALUES ('i')").execute(&p).await.unwrap(); + for (invoice_status, verified) in [("detected", false), ("confirmed", true)] { + sqlx::query("UPDATE invoices SET status=? WHERE id='i'").bind(invoice_status).execute(&p).await.unwrap(); + sqlx::query("UPDATE crosschain_attempts SET state='SUCCESS' WHERE invoice_id='i'").execute(&p).await.unwrap(); + for header in [None, Some(token.clone())] { + let mut req=test::TestRequest::get().uri("/i");if let Some(h)=header{req=req.insert_header(("X-Checkout-Token",h));} + let response:Value=test::call_and_read_body_json(&app,req.to_request()).await; + assert_eq!(response["settlementVerified"],verified); + } + } +} + +#[actix_web::test] +async fn disabling_crosschain_blocks_new_reservations_but_preserves_existing_deposits() { + use actix_web::{test,App}; + let (p,_)=fixture().await; + assert!(operations::enabled(&p,"i").await.unwrap()); + let deadline=(Utc::now()+Duration::minutes(10)).to_rfc3339(); + sqlx::query("UPDATE merchants SET crosschain_enabled=0 WHERE id='m'").execute(&p).await.unwrap(); + assert!(!reserve(&p,"i",&deadline,None).await.unwrap()); + let config=crate::config::Config::from_env().unwrap(); + let gateway=Gateway{enabled:true,fee_recipient:None,key:String::new(),base:"http://127.0.0.1:1".into(),client:reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(),tokens:Mutex::new(Some((Instant::now(),vec![asset()]))),eligibility:Mutex::new(HashMap::new()),eligibility_slots:Semaphore::new(4)}; + assert!(prepare(&p,&gateway,"i",&input(),true).await.unwrap_err().to_string().contains("ZEC only")); + let app=test::init_service(App::new().app_data(web::Data::new(p.clone())).app_data(web::Data::new(config)).app_data(web::Data::new(gateway)).route("/{id}",web::post().to(create)).route("/{id}/eligibility",web::get().to(eligibility))).await; + let e:Value=test::call_and_read_body_json(&app,test::TestRequest::get().uri("/i/eligibility").to_request()).await; + assert_eq!(e["enabled"],false); + sqlx::query("UPDATE merchants SET crosschain_enabled=1 WHERE id='m'").execute(&p).await.unwrap(); + assert!(reserve(&p,"i",&deadline,None).await.unwrap()); + sqlx::query("UPDATE merchants SET crosschain_enabled=0 WHERE id='m'").execute(&p).await.unwrap(); + let a:Value=test::call_and_read_body_json(&app,test::TestRequest::post().uri("/i").insert_header(("X-Checkout-Token",security::token(&p,"i").await.unwrap())).set_json(json!({"asset_id":asset().asset_id,"refund_address":input().refund_address})).to_request()).await; + assert_eq!(a["state"],"CREATING"); // no provider call or competing deposit +} + +#[actix_web::test] +async fn interrupted_setup_is_reported_and_cannot_be_retried_as_a_new_quote() { + use actix_web::{test,App}; + let (p,_)=fixture().await; + let mut config=crate::config::Config::from_env().unwrap();config.admin_key=Some("test-admin".into()); + reserve(&p,"i",&(Utc::now()+Duration::minutes(10)).to_rfc3339(),None).await.unwrap(); + sqlx::query("UPDATE crosschain_attempts SET state='NEEDS_REVIEW' WHERE invoice_id='i'").execute(&p).await.unwrap(); + let issues=operations::issues(&p).await.unwrap();assert_eq!(issues[0]["reason"],"setup_review"); + let app=test::init_service(App::new().app_data(web::Data::new(p.clone())).app_data(web::Data::new(config)).route("/issues",web::get().to(operations::admin_issues)).route("/{id}",web::post().to(operations::retry))).await; + assert_eq!(test::call_service(&app,test::TestRequest::get().uri("/issues").to_request()).await.status(),401); + assert_eq!(test::call_service(&app,test::TestRequest::post().uri("/i").insert_header(("X-Admin-Key","test-admin")).to_request()).await.status(),409); + assert_eq!(sqlx::query_scalar::<_,String>("SELECT state FROM crosschain_attempts WHERE invoice_id='i'").fetch_one(&p).await.unwrap(),"NEEDS_REVIEW"); + sqlx::query("UPDATE crosschain_attempts SET payload='encrypted-existing-payload',last_checked=12345 WHERE invoice_id='i'").execute(&p).await.unwrap(); + assert_eq!(test::call_service(&app,test::TestRequest::post().uri("/i").insert_header(("X-Admin-Key","test-admin")).to_request()).await.status(),200); + assert_eq!(sqlx::query_scalar::<_,i64>("SELECT last_checked FROM crosschain_attempts WHERE invoice_id='i'").fetch_one(&p).await.unwrap(),0); +} + +#[actix_web::test] +async fn status_outage_and_tampered_quotes_never_accept_payment_or_unlock_wallet() { + use actix_web::{test,App,HttpServer}; + use std::sync::atomic::{AtomicU8,Ordering}; + let (p,inv)=fixture().await; + let mut config=crate::config::Config::from_env().unwrap();config.encryption_key="11".repeat(32); + let body=quote_body(&inv,&input(),&asset(),false).unwrap(); + let q=json!({"asset":asset(),"amountIn":"12345678","amountOut":"10000000","refundAddress":input().refund_address,"depositAddress":"0x2345678901234567890123456789012345678901","deadline":body["deadline"],"confidentiality":"basic"}); + let signed=signatures::signed(json!({"quoteRequest":body,"quote":{"amountIn":q["amountIn"],"amountOut":q["amountOut"],"depositAddress":q["depositAddress"],"deadline":q["deadline"]}})); + let mode=web::Data::new(AtomicU8::new(0));let mm=mode.clone(); + let listener=std::net::TcpListener::bind("127.0.0.1:0").unwrap();let base=format!("http://{}",listener.local_addr().unwrap()); + let response=web::Data::new(signed); + let server=HttpServer::new(move||App::new().app_data(mm.clone()).app_data(response.clone()).route("/status",web::get().to(|mode:web::Data,q:web::Data|async move{ + let m=mode.load(Ordering::SeqCst);if m==0{return HttpResponse::ServiceUnavailable().finish();} + let mut quote=q.get_ref().clone();if m==1{quote["quote"]["depositAddress"]=json!("attacker");} + if m==4{quote["quoteRequest"]["confidentiality"]=json!("public");} + if m==5{quote["quoteRequest"].as_object_mut().unwrap().remove("confidentiality");} + HttpResponse::Ok().json(json!({"status":if m==3{"REFUNDED"}else{"SUCCESS"},"quoteResponse":quote})) + }))).listen(listener).unwrap().run();let handle=server.handle();actix_web::rt::spawn(server); + reserve(&p,"i",body["deadline"].as_str().unwrap(),None).await.unwrap(); + let payload=crate::crypto::encrypt(&q.to_string(),&config.encryption_key).unwrap(); + sqlx::query("UPDATE crosschain_attempts SET state='PENDING_DEPOSIT',payload=?,send_token='keep-locked' WHERE invoice_id='i'").bind(payload).execute(&p).await.unwrap(); + let gateway=Gateway{enabled:true,fee_recipient:None,key:String::new(),base,client:reqwest::Client::new(), confirmation_client: reqwest::Client::new(), cipherscan_api_url: "http://127.0.0.1:1".into(),tokens:Mutex::new(None),eligibility:Mutex::new(HashMap::new()),eligibility_slots:Semaphore::new(4)}; + let app=test::init_service(App::new().app_data(web::Data::new(p.clone())).app_data(web::Data::new(config)).app_data(web::Data::new(gateway)).route("/{id}",web::get().to(status))).await; + for m in 0..6 { + mode.store(m,Ordering::SeqCst); + sqlx::query("UPDATE crosschain_attempts SET state='PENDING_DEPOSIT',last_checked=0 WHERE invoice_id='i'").execute(&p).await.unwrap(); + let result:Value=test::call_and_read_body_json(&app,test::TestRequest::get().uri("/i").insert_header(("X-Checkout-Token",security::token(&p,"i").await.unwrap())).to_request()).await; + assert_eq!(result["state"],if m==2{"SUCCESS"}else if m==3{"REFUNDED"}else{"PENDING_DEPOSIT"}); + assert_eq!(result["stale"],m<2 || m>=4); + assert_eq!(result["sendStarted"],true); + assert_eq!(crate::invoices::get_invoice(&p,"i").await.unwrap().unwrap().status,"pending"); + } + handle.stop(true).await; +} + +#[test] +#[ignore = "requires a real confidential dry quote supplied via SIGNED_QUOTE_FIXTURE"] +fn real_provider_confidential_quote() { + let file = std::env::var("SIGNED_QUOTE_FIXTURE").unwrap(); + let response: Value = serde_json::from_slice(&std::fs::read(file).unwrap()).unwrap(); + signatures::verify(&response).unwrap(); + let mut body = response["quoteRequest"].clone(); + body["confidentiality"] = json!("basic"); + body["appFees"] = json!([{"recipient":"cipherscan.near","fee":125}]); + assert_eq!(body["dry"], true); + let quote = checked_quote(&response, &body, &asset(), true).unwrap(); + assert_eq!(quote["confidentiality"], "basic"); +} diff --git a/src/db.rs b/src/db.rs index 5365a30..1876110 100644 --- a/src/db.rs +++ b/src/db.rs @@ -64,6 +64,12 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { }) .await?; + run_tracked_migration(&pool, "payment_email_preferences_v2026_10_14", || { + migrate_payment_email_preferences(&pool) + }) + .await?; + crate::email::migrate_payment_email_queue(&pool).await?; + run_tracked_migration(&pool, "passkey_auth_v2026_04_20", || async { sqlx::query( "CREATE TABLE IF NOT EXISTS passkey_credentials ( @@ -344,5 +350,98 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result { Ok(()) }).await?; + run_tracked_migration(&pool, "crosschain_v2026_10_03", || { + crate::crosschain::migrate(&pool) + }) + .await?; + + run_tracked_migration(&pool, "crosschain_fees_v2026_10_05", || { + crate::crosschain::fees::migrate(&pool) + }) + .await?; + + run_tracked_migration(&pool, "crosschain_settlements_v2026_10_06", || { + crate::crosschain::fees::migrate_settlements(&pool) + }) + .await?; + + run_tracked_migration(&pool, "checkout_security_v2026_10_06", || { + crate::crosschain::security::migrate(&pool) + }).await?; + + run_tracked_migration(&pool, "referrals_v2026_10_06", || crate::referrals::migrate(&pool)).await?; + + run_tracked_migration(&pool, "referral_guardrails_v2026_10_06", || crate::referrals::guardrails::migrate(&pool)).await?; + + run_tracked_migration(&pool, "crosschain_controls_v2026_10_06", || crate::crosschain::operations::migrate(&pool)).await?; + + run_tracked_migration(&pool, "crosschain_provider_handled_v2026_10_14", || crate::crosschain::fees::migrate_provider_handled(&pool)).await?; + Ok(pool) } + +async fn migrate_payment_email_preferences(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::query( + "ALTER TABLE merchants ADD COLUMN payment_email_notifications INTEGER NOT NULL DEFAULT 0 + CHECK (payment_email_notifications IN (0, 1))", + ) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +mod payment_email_preference_tests { + use super::*; + + #[tokio::test] + async fn existing_merchants_default_to_no_payment_email_and_migration_is_tracked() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE merchants (id TEXT PRIMARY KEY)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO merchants (id) VALUES ('existing')") + .execute(&pool) + .await + .unwrap(); + let name = "payment_email_preferences_v2026_10_14"; + run_tracked_migration(&pool, name, || migrate_payment_email_preferences(&pool)) + .await + .unwrap(); + let enabled: bool = sqlx::query_scalar( + "SELECT payment_email_notifications FROM merchants WHERE id = 'existing'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!enabled); + run_tracked_migration(&pool, name, || migrate_payment_email_preferences(&pool)) + .await + .unwrap(); + assert!( + sqlx::query("UPDATE merchants SET payment_email_notifications = 2") + .execute(&pool) + .await + .is_err() + ); + } + + #[tokio::test] + async fn preference_migration_failures_are_reported_and_not_marked_applied() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + let name = "payment_email_preferences_v2026_10_14"; + assert!( + run_tracked_migration(&pool, name, || migrate_payment_email_preferences(&pool)) + .await + .is_err() + ); + let status: String = + sqlx::query_scalar("SELECT status FROM schema_migrations WHERE name = ?") + .bind(name) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, "failed"); + } +} diff --git a/src/email.rs b/src/email.rs index df8b36e..dacf34b 100644 --- a/src/email.rs +++ b/src/email.rs @@ -3,6 +3,9 @@ use uuid::Uuid; use crate::config::Config; +mod payments; +pub use payments::{migrate_payment_email_queue, run_payment_email_worker}; + pub async fn send_recovery_email(config: &Config, to: &str, token: &str) -> anyhow::Result<()> { let from = config .smtp_from diff --git a/src/email/payments.rs b/src/email/payments.rs new file mode 100644 index 0000000..7bb399b --- /dev/null +++ b/src/email/payments.rs @@ -0,0 +1,660 @@ +use anyhow::Context; +use serde_json::{json, Value}; +use sqlx::{FromRow, SqlitePool}; +use uuid::Uuid; + +use crate::{config::Config, crypto, invoices}; + +const TEMPLATE: &str = "merchant_payment_confirmed"; +const MAX_ATTEMPTS: i64 = 8; + +/// Queue in the same database statement as confirmation, including confirmations +/// from cross-chain payments. Enabling the setting never emails old invoices. +pub async fn migrate_payment_email_queue(pool: &SqlitePool) -> anyhow::Result<()> { + crate::db::run_tracked_migration(pool, "payment_email_queue_v2026_10_14", || async { + let mut tx = pool.begin().await?; + sqlx::query( + "CREATE TABLE IF NOT EXISTS payment_email_queue ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE, + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending','sending','sent','cancelled','failed')), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt INTEGER NOT NULL DEFAULT (strftime('%s','now')), + first_attempt_at INTEGER, + claim_id TEXT, + payload TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + )", + ).execute(&mut *tx).await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_payment_email_due ON payment_email_queue(state,next_attempt)") + .execute(&mut *tx).await?; + sqlx::query( + "CREATE TRIGGER IF NOT EXISTS queue_confirmed_payment_email + AFTER UPDATE OF status ON invoices + WHEN NEW.status='confirmed' AND OLD.status!='confirmed' + AND NEW.memo_code NOT LIKE 'SETTLE-%' + BEGIN + INSERT OR IGNORE INTO payment_email_queue(invoice_id,merchant_id) + SELECT NEW.id,NEW.merchant_id FROM merchants m + WHERE m.id=NEW.merchant_id AND m.payment_email_notifications=1 + AND m.recovery_email IS NOT NULL AND m.recovery_email!='' + AND NOT EXISTS(SELECT 1 FROM email_events e + WHERE e.merchant_id=NEW.merchant_id + AND e.template='merchant_payment_confirmed' AND e.entity_id=NEW.id); + END", + ).execute(&mut *tx).await?; + // Stop queued mail immediately on opt-out or a recipient change. Clear + // the snapshot so account settings never leave unnecessary PII behind. + sqlx::query( + "CREATE TRIGGER IF NOT EXISTS cancel_payment_emails_on_settings_change + AFTER UPDATE OF payment_email_notifications,recovery_email,recovery_email_hash ON merchants + WHEN NEW.payment_email_notifications=0 + OR NEW.recovery_email_hash IS NOT OLD.recovery_email_hash + OR ((NEW.recovery_email_hash IS NULL OR OLD.recovery_email_hash IS NULL) + AND NEW.recovery_email IS NOT OLD.recovery_email) + BEGIN + UPDATE payment_email_queue SET state='cancelled',payload=NULL,claim_id=NULL + WHERE merchant_id=NEW.id AND state IN ('pending','sending'); + END", + ).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) + }).await +} + +#[derive(FromRow)] +struct PendingEmail { + invoice_id: String, + merchant_id: String, + attempts: i64, + payload: Option, +} + +pub async fn run_payment_email_worker(pool: SqlitePool, config: Config) { + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::error!(%error, "Could not start payment email worker"); + return; + } + }; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + interval.tick().await; + if !config.smtp_configured() { + continue; + } + // A small batch keeps provider requests bounded. A lease and a stable + // provider key protect against concurrent workers and process restarts. + for _ in 0..10 { + match process_next(&pool, &config, &client, "https://api.resend.com/emails").await { + Ok(true) => {} + Ok(false) => break, + Err(error) => { + tracing::error!(%error, "Payment email queue processing failed"); + break; + } + } + } + } +} + +async fn process_next( + pool: &SqlitePool, + config: &Config, + client: &reqwest::Client, + endpoint: &str, +) -> anyhow::Result { + if !config.smtp_configured() { + return Ok(false); + } + // Resend retains idempotency keys for 24 hours. Never retry an ambiguous + // request past that window, including after a long process outage. + sqlx::query( + "UPDATE payment_email_queue SET state='failed',payload=NULL,claim_id=NULL + WHERE state IN ('pending','sending') + AND ((first_attempt_at IS NOT NULL AND first_attempt_at < strftime('%s','now')-82800) + OR (attempts>=? AND next_attempt<=strftime('%s','now')))", + ) + .bind(MAX_ATTEMPTS) + .execute(pool) + .await?; + let claim_id = Uuid::new_v4().to_string(); + let queued: Option = sqlx::query_as( + "UPDATE payment_email_queue SET state='sending',claim_id=?, + next_attempt=strftime('%s','now')+120,attempts=attempts+1, + first_attempt_at=COALESCE(first_attempt_at,strftime('%s','now')) + WHERE invoice_id=(SELECT invoice_id FROM payment_email_queue + WHERE state IN ('pending','sending') AND next_attempt<=strftime('%s','now') + ORDER BY next_attempt,invoice_id LIMIT 1) + RETURNING invoice_id,merchant_id,attempts,payload", + ) + .bind(&claim_id) + .fetch_optional(pool) + .await?; + let Some(queued) = queued else { + return Ok(false); + }; + match deliver(pool, config, client, endpoint, &queued, &claim_id).await { + Ok(()) => {} + Err(error) => { + // Avoid logging provider response bodies or recipient addresses. + tracing::warn!(invoice_id=%queued.invoice_id, attempt=queued.attempts, %error, + "Payment email delivery failed; scheduling retry"); + let delay = (60_i64 * 2_i64.pow((queued.attempts - 1).min(6) as u32)).min(3600); + sqlx::query( + "UPDATE payment_email_queue SET state=?,claim_id=NULL, + next_attempt=strftime('%s','now')+?, + payload=CASE WHEN ? THEN NULL ELSE payload END + WHERE invoice_id=? AND claim_id=?", + ) + .bind(if queued.attempts >= MAX_ATTEMPTS { + "failed" + } else { + "pending" + }) + .bind(delay) + .bind(queued.attempts >= MAX_ATTEMPTS) + .bind(&queued.invoice_id) + .bind(&claim_id) + .execute(pool) + .await?; + } + } + Ok(true) +} + +async fn deliver( + pool: &SqlitePool, + config: &Config, + client: &reqwest::Client, + endpoint: &str, + queued: &PendingEmail, + claim_id: &str, +) -> anyhow::Result<()> { + let enabled: bool = + sqlx::query_scalar("SELECT payment_email_notifications=1 FROM merchants WHERE id=?") + .bind(&queued.merchant_id) + .fetch_optional(pool) + .await? + .unwrap_or(false); + let invoice = invoices::get_invoice(pool, &queued.invoice_id).await?; + if !enabled || invoice.as_ref().is_none_or(|i| i.status != "confirmed") { + return finish(pool, queued, claim_id, "cancelled").await; + } + let already_sent: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM email_events WHERE merchant_id=? AND template=? AND entity_id=?)", + ).bind(&queued.merchant_id).bind(TEMPLATE).bind(&queued.invoice_id).fetch_one(pool).await?; + if already_sent { + return finish(pool, queued, claim_id, "sent").await; + } + let email = super::get_merchant_email(pool, &queued.merchant_id, &config.encryption_key) + .await + .context("Payment notification recipient unavailable")?; + let payload: Value = match &queued.payload { + Some(stored) => { + let plaintext = if config.encryption_key.is_empty() { + stored.clone() + } else { + crypto::decrypt(stored, &config.encryption_key)? + }; + serde_json::from_str(&plaintext)? + } + None => { + let invoice = invoice.unwrap(); + let payload = payment_payload(config, &email, &invoice); + let plaintext = serde_json::to_string(&payload)?; + let stored = if config.encryption_key.is_empty() { + plaintext + } else { + crypto::encrypt(&plaintext, &config.encryption_key)? + }; + let saved = sqlx::query( + "UPDATE payment_email_queue SET payload=? WHERE invoice_id=? AND claim_id=?", + ) + .bind(stored) + .bind(&queued.invoice_id) + .bind(claim_id) + .execute(pool) + .await?; + if saved.rows_affected() == 0 { + return Ok(()); + } + payload + } + }; + if payload["to"][0].as_str() != Some(email.as_str()) { + return finish(pool, queued, claim_id, "cancelled").await; + } + let still_claimed: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM payment_email_queue WHERE invoice_id=? AND claim_id=? AND state='sending')", + ).bind(&queued.invoice_id).bind(claim_id).fetch_one(pool).await?; + if !still_claimed { + return Ok(()); + } + let response = client + .post(endpoint) + .bearer_auth(config.smtp_pass.as_deref().unwrap()) + .header( + "Idempotency-Key", + format!("{TEMPLATE}/{}", queued.invoice_id), + ) + .json(&payload) + .send() + .await?; + anyhow::ensure!( + response.status().is_success(), + "Email provider returned {}", + response.status() + ); + + let mut tx = pool.begin().await?; + sqlx::query( + "INSERT OR IGNORE INTO email_events(id,merchant_id,template,entity_id,sent_at) + SELECT ?,q.merchant_id,?,q.invoice_id,strftime('%Y-%m-%dT%H:%M:%SZ','now') + FROM payment_email_queue q JOIN merchants m ON m.id=q.merchant_id + WHERE q.invoice_id=? AND q.claim_id=? AND q.state='sending'", + ) + .bind(Uuid::new_v4().to_string()) + .bind(TEMPLATE) + .bind(&queued.invoice_id) + .bind(claim_id) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE payment_email_queue SET state='sent',payload=NULL,claim_id=NULL WHERE invoice_id=? AND claim_id=?") + .bind(&queued.invoice_id).bind(claim_id).execute(&mut *tx).await?; + tx.commit().await?; + tracing::info!(invoice_id=%queued.invoice_id, "Merchant payment notification sent"); + Ok(()) +} + +async fn finish( + pool: &SqlitePool, + queued: &PendingEmail, + claim_id: &str, + state: &str, +) -> anyhow::Result<()> { + sqlx::query("UPDATE payment_email_queue SET state=?,payload=NULL,claim_id=NULL WHERE invoice_id=? AND claim_id=?") + .bind(state).bind(&queued.invoice_id).bind(claim_id).execute(pool).await?; + Ok(()) +} + +fn payment_payload(config: &Config, email: &str, invoice: &invoices::Invoice) -> Value { + let url = config + .frontend_url + .as_deref() + .unwrap_or("https://cipherpay.app") + .trim_end_matches('/'); + let body = format!( + "CipherPay: Customer Payment Confirmed\n\n\ + A customer payment has been confirmed.\n\n\ + Reference: {}\n\ + Invoice ID: {}\n\ + Invoice amount: {}.{:08} ZEC\n\n\ + View the payment in your dashboard:\n{}/dashboard\n\n\ + Manage payment email notifications in dashboard Settings.\n\n— CipherPay", + invoice.memo_code, + invoice.id, + invoice.price_zatoshis / 100_000_000, + invoice.price_zatoshis % 100_000_000, + url, + ); + json!({"from": config.smtp_from.as_deref().unwrap(), "to": [email], + "subject": "CipherPay: Customer Payment Confirmed", "text": body}) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn setup() -> (SqlitePool, Config) { + let pool = crate::repair_tests::pool().await; + crate::repair_tests::merchant(&pool, "merchant").await; + let mut config = Config::from_env().unwrap(); + config.encryption_key = "11".repeat(32); + config.smtp_pass = Some("test-api-key".into()); + config.smtp_from = Some("CipherPay ".into()); + config.frontend_url = Some("https://example.com".into()); + (pool, config) + } + + async fn opt_in(pool: &SqlitePool, config: &Config) { + let email = crypto::encrypt("merchant@example.com", &config.encryption_key).unwrap(); + sqlx::query("UPDATE merchants SET recovery_email=?,payment_email_notifications=1 WHERE id='merchant'") + .bind(email).execute(pool).await.unwrap(); + } + + async fn confirm(pool: &SqlitePool, id: &str) { + invoices::mark_detected(pool, id, "private-txid", 1000) + .await + .unwrap(); + assert!(invoices::mark_confirmed(pool, id, None, None) + .await + .unwrap()); + } + + async fn count(pool: &SqlitePool) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM payment_email_queue") + .fetch_one(pool) + .await + .unwrap() + } + + async fn server(statuses: Vec) -> (String, tokio::task::JoinHandle>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/emails", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + let mut requests = Vec::new(); + for status in statuses { + let (mut stream, _) = + tokio::time::timeout(std::time::Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut request = Vec::new(); + let (header, body) = loop { + let mut chunk = [0_u8; 4096]; + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&chunk[..n]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let header = String::from_utf8(request[..end].to_vec()).unwrap(); + let length: usize = header + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + .unwrap(); + if request.len() >= end + 4 + length { + break ( + header, + serde_json::from_slice(&request[end + 4..end + 4 + length]) + .unwrap(), + ); + } + } + }; + requests.push((header, body)); + stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").as_bytes()).await.unwrap(); + } + requests + }); + (endpoint, handle) + } + + #[tokio::test] + async fn queues_only_new_opted_in_customer_confirmations_once() { + let (pool, config) = setup().await; + crate::repair_tests::invoice(&pool, "old", "merchant").await; + confirm(&pool, "old").await; + assert_eq!(count(&pool).await, 0); + opt_in(&pool, &config).await; + assert_eq!(count(&pool).await, 0); // No historical backfill. + crate::repair_tests::invoice(&pool, "partial", "merchant").await; + invoices::mark_underpaid(&pool, "partial", 400, "first") + .await + .unwrap(); + assert_eq!(count(&pool).await, 0); + invoices::mark_detected(&pool, "partial", "second", 1000) + .await + .unwrap(); + assert_eq!(count(&pool).await, 0); + assert!(invoices::mark_confirmed(&pool, "partial", None, None) + .await + .unwrap()); + assert!(!invoices::mark_confirmed(&pool, "partial", None, None) + .await + .unwrap()); + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='partial'") + .execute(&pool) + .await + .unwrap(); + assert_eq!(count(&pool).await, 1); + crate::repair_tests::invoice(&pool, "billing", "merchant").await; + sqlx::query("UPDATE invoices SET memo_code='SETTLE-cycle' WHERE id='billing'") + .execute(&pool) + .await + .unwrap(); + confirm(&pool, "billing").await; + assert_eq!(count(&pool).await, 1); + sqlx::query("UPDATE merchants SET recovery_email=NULL WHERE id='merchant'") + .execute(&pool) + .await + .unwrap(); + crate::repair_tests::invoice(&pool, "no-email", "merchant").await; + confirm(&pool, "no-email").await; + assert_eq!(count(&pool).await, 1); + } + + #[tokio::test] + async fn retries_with_stable_encrypted_payload_and_records_delivery_once() { + let (pool, mut config) = setup().await; + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "payment", "merchant").await; + confirm(&pool, "payment").await; + let (endpoint, handle) = server(vec![503, 200]).await; + let client = reqwest::Client::new(); + assert!(process_next(&pool, &config, &client, &endpoint) + .await + .unwrap()); + let (state, stored, attempts, due): (String, String, i64, bool) = sqlx::query_as( + "SELECT state,payload,attempts,next_attempt>strftime('%s','now') FROM payment_email_queue", + ).fetch_one(&pool).await.unwrap(); + assert_eq!(state, "pending"); + assert_eq!(attempts, 1); + assert!(due); + assert!(!stored.contains("merchant@example.com")); + assert!(crypto::decrypt(&stored, &config.encryption_key) + .unwrap() + .contains("merchant@example.com")); + assert!(!process_next(&pool, &config, &client, &endpoint) + .await + .unwrap()); + sqlx::query("UPDATE payment_email_queue SET next_attempt=0") + .execute(&pool) + .await + .unwrap(); + config.smtp_from = Some("changed@example.com".into()); + config.frontend_url = Some("https://changed.example.com".into()); + assert!(process_next(&pool, &config, &client, &endpoint) + .await + .unwrap()); + assert!(!process_next(&pool, &config, &client, &endpoint) + .await + .unwrap()); + let requests = handle.await.unwrap(); + assert_eq!(requests[0].1, requests[1].1); + for (header, body) in &requests { + assert!(header + .to_lowercase() + .contains("idempotency-key: merchant_payment_confirmed/payment")); + let body = body["text"].as_str().unwrap(); + assert!(body.contains("Invoice amount: 0.00001000 ZEC")); + assert!(!body.contains("private-txid")); + assert!(!body.contains("test-address")); + } + let (state, payload): (String, Option) = + sqlx::query_as("SELECT state,payload FROM payment_email_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, "sent"); + assert!(payload.is_none()); + let events: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM email_events WHERE template='merchant_payment_confirmed'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(events, 1); + } + + #[tokio::test] + async fn opt_out_recipient_change_refund_and_deletion_suppress_queued_mail() { + let (pool, config) = setup().await; + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "opt-out", "merchant").await; + confirm(&pool, "opt-out").await; + sqlx::query("UPDATE merchants SET payment_email_notifications=0 WHERE id='merchant'") + .execute(&pool) + .await + .unwrap(); + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "new-recipient", "merchant").await; + confirm(&pool, "new-recipient").await; + sqlx::query("UPDATE merchants SET recovery_email='new@example.com' WHERE id='merchant'") + .execute(&pool) + .await + .unwrap(); + let cancelled: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM payment_email_queue WHERE state='cancelled'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(cancelled, 2); + crate::repair_tests::invoice(&pool, "refunded", "merchant").await; + confirm(&pool, "refunded").await; + invoices::mark_refunded(&pool, "refunded", None) + .await + .unwrap(); + let client = reqwest::Client::new(); + assert!(process_next(&pool, &config, &client, "http://127.0.0.1:1") + .await + .unwrap()); + assert!(!process_next(&pool, &config, &client, "http://127.0.0.1:1") + .await + .unwrap()); + crate::repair_tests::invoice(&pool, "deleted", "merchant").await; + confirm(&pool, "deleted").await; + assert_eq!(count(&pool).await, 4); + crate::merchants::delete_merchant(&pool, "merchant") + .await + .unwrap(); + assert_eq!(count(&pool).await, 0); + } + + #[tokio::test] + async fn concurrent_workers_claim_once_and_old_ambiguous_attempts_expire() { + let (pool, config) = setup().await; + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "concurrent", "merchant").await; + confirm(&pool, "concurrent").await; + let (endpoint, handle) = server(vec![200]).await; + let client = reqwest::Client::new(); + let (a, b) = tokio::join!( + process_next(&pool, &config, &client, &endpoint), + process_next(&pool, &config, &client, &endpoint) + ); + assert_eq!(a.unwrap() as u8 + b.unwrap() as u8, 1); + assert_eq!(handle.await.unwrap().len(), 1); + crate::repair_tests::invoice(&pool, "too-old", "merchant").await; + confirm(&pool, "too-old").await; + sqlx::query("UPDATE payment_email_queue SET state='sending',first_attempt_at=strftime('%s','now')-86400,next_attempt=0 WHERE invoice_id='too-old'") + .execute(&pool).await.unwrap(); + assert!(!process_next(&pool, &config, &client, "http://127.0.0.1:1") + .await + .unwrap()); + let state: String = + sqlx::query_scalar("SELECT state FROM payment_email_queue WHERE invoice_id='too-old'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, "failed"); + } + + #[tokio::test] + async fn unconfigured_email_keeps_queue_and_crashed_workers_have_bounded_retries() { + let (pool, mut config) = setup().await; + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "waiting", "merchant").await; + confirm(&pool, "waiting").await; + let client = reqwest::Client::new(); + config.smtp_pass = None; + assert!(!process_next(&pool, &config, &client, "http://127.0.0.1:1") + .await + .unwrap()); + let attempts: i64 = sqlx::query_scalar("SELECT attempts FROM payment_email_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(attempts, 0); + config.smtp_pass = Some("test-api-key".into()); + sqlx::query("UPDATE payment_email_queue SET state='sending',attempts=8,next_attempt=0") + .execute(&pool) + .await + .unwrap(); + assert!(!process_next(&pool, &config, &client, "http://127.0.0.1:1") + .await + .unwrap()); + let state: String = sqlx::query_scalar("SELECT state FROM payment_email_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, "failed"); + } + + #[tokio::test] + async fn queued_notification_survives_database_reopen_and_expired_worker_lease() { + let path = std::env::temp_dir().join(format!("cipherpay-email-{}.db", Uuid::new_v4())); + let url = format!("sqlite:{}", path.display()); + let pool = crate::db::create_pool(&url).await.unwrap(); + crate::repair_tests::merchant(&pool, "merchant").await; + let (_, config) = setup().await; + opt_in(&pool, &config).await; + crate::repair_tests::invoice(&pool, "restart", "merchant").await; + confirm(&pool, "restart").await; + sqlx::query("UPDATE payment_email_queue SET state='sending',attempts=1,claim_id='crashed',next_attempt=0,first_attempt_at=strftime('%s','now')") + .execute(&pool).await.unwrap(); + pool.close().await; + let pool = crate::db::create_pool(&url).await.unwrap(); + assert_eq!(count(&pool).await, 1); + let (endpoint, handle) = server(vec![200]).await; + assert!( + process_next(&pool, &config, &reqwest::Client::new(), &endpoint) + .await + .unwrap() + ); + assert_eq!(handle.await.unwrap().len(), 1); + let state: String = sqlx::query_scalar("SELECT state FROM payment_email_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, "sent"); + pool.close().await; + std::fs::remove_file(path).unwrap(); + } + + #[tokio::test] + async fn saving_the_same_address_keeps_queued_notifications() { + let (pool, config) = setup().await; + opt_in(&pool, &config).await; + let hash = crypto::blind_index("merchant@example.com", &config.encryption_key); + sqlx::query("UPDATE merchants SET recovery_email_hash=? WHERE id='merchant'") + .bind(&hash) + .execute(&pool) + .await + .unwrap(); + crate::repair_tests::invoice(&pool, "same-address", "merchant").await; + confirm(&pool, "same-address").await; + sqlx::query( + "UPDATE merchants SET recovery_email=?,recovery_email_hash=? WHERE id='merchant'", + ) + .bind(crypto::encrypt("merchant@example.com", &config.encryption_key).unwrap()) + .bind(&hash) + .execute(&pool) + .await + .unwrap(); + let state: String = sqlx::query_scalar("SELECT state FROM payment_email_queue") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(state, "pending"); + } +} diff --git a/src/invoices/mod.rs b/src/invoices/mod.rs index eab81e0..c7141ea 100644 --- a/src/invoices/mod.rs +++ b/src/invoices/mod.rs @@ -196,6 +196,7 @@ pub async fn create_invoice( ); Ok(CreateInvoiceResponse { + checkout_token: crate::crosschain::security::token(pool, &id).await?, invoice_id: id, memo_code, amount, @@ -224,13 +225,15 @@ pub async fn mark_detected( .to_string(); let result = sqlx::query( "UPDATE invoices SET status = 'detected', detected_txid = ?, detected_at = ?, received_zatoshis = ?, expires_at = ? - WHERE id = ? AND status IN ('pending', 'underpaid')" + WHERE id = ? AND status IN ('pending', 'underpaid') + AND (exact_payment=0 OR ? >= price_zatoshis)" ) .bind(txid) .bind(&now) .bind(received_zatoshis) .bind(&new_expires) .bind(invoice_id) + .bind(received_zatoshis) .execute(pool) .await?; @@ -253,7 +256,7 @@ pub async fn mark_confirmed( let result = sqlx::query( "UPDATE invoices SET status = 'confirmed', confirmed_at = ?, confirmed_rate = ?, confirmed_fiat_amount = ? - WHERE id = ? AND status = 'detected'", + WHERE id = ? AND status = 'detected' AND (exact_payment=0 OR received_zatoshis >= price_zatoshis)", ) .bind(&now) .bind(confirmed_rate) @@ -355,6 +358,9 @@ pub async fn record_payment( txid: &str, zatoshis: i64, ) -> anyhow::Result { + if !crate::crosschain::security::accepts_payment(pool, invoice_id, txid).await? { + return Ok(sqlx::query_scalar("SELECT received_zatoshis FROM invoices WHERE id=?").bind(invoice_id).fetch_one(pool).await?); + } let result = sqlx::query( "INSERT OR IGNORE INTO invoice_payments (invoice_id, txid, zatoshis) VALUES (?, ?, ?)", ) @@ -491,6 +497,10 @@ pub async fn finalize_invoice( ); } + if invoice.exact_payment { + anyhow::bail!("Cross-chain payment already started. Create a new invoice instead of changing its amount."); + } + // In-flight payment guard: prevent re-finalization if payment already detected if invoice.received_zatoshis > 0 || invoice.detected_txid.is_some() { anyhow::bail!("Payment already detected for this invoice, awaiting confirmation"); @@ -577,7 +587,7 @@ pub async fn finalize_invoice( price_zec = ?, price_eur = ?, price_usd = ?, zec_rate_at_creation = ?, price_zatoshis = ?, zcash_uri = ?, expires_at = ? - WHERE id = ? AND status IN ('draft', 'expired') AND received_zatoshis=0 AND detected_txid IS NULL", + WHERE id = ? AND status IN ('draft', 'expired') AND exact_payment=0 AND received_zatoshis=0 AND detected_txid IS NULL", ) .bind(price_zec) .bind(price_eur) diff --git a/src/invoices/queries.rs b/src/invoices/queries.rs index f6a898d..94ed76e 100644 --- a/src/invoices/queries.rs +++ b/src/invoices/queries.rs @@ -13,7 +13,7 @@ pub async fn get_invoice(pool: &SqlitePool, id: &str) -> anyhow::Result anyhow::Result, + #[serde(default)] + #[sqlx(default)] + pub exact_payment: bool, pub price_zatoshis: i64, pub received_zatoshis: i64, pub subscription_id: Option, @@ -68,6 +71,7 @@ pub struct CreateInvoiceRequest { #[derive(Debug, Serialize)] pub struct CreateInvoiceResponse { + pub checkout_token: String, pub invoice_id: String, pub memo_code: String, pub amount: f64, @@ -86,3 +90,13 @@ pub struct FeeConfig { pub fee_address: String, pub fee_rate: f64, } + +impl Invoice { + pub fn minimum_payment_zatoshis(&self) -> i64 { + if self.exact_payment { + self.price_zatoshis + } else { + (self.price_zatoshis as f64 * crate::scanner::decrypt::SLIPPAGE_TOLERANCE) as i64 + } + } +} diff --git a/src/main.rs b/src/main.rs index 4b5dd01..b934b24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod api; mod api_keys; mod billing; mod config; +mod crosschain; mod crypto; mod db; mod email; @@ -13,6 +14,7 @@ mod merchants; mod payment_links; mod prices; mod products; +mod referrals; #[cfg(test)] mod repair_tests; mod scanner; @@ -98,7 +100,7 @@ async fn main() -> anyhow::Result<()> { ) .await?; } - // Internal client for CipherScan (carries X-Service-Key) + // Internal client for ZecBlock (carries X-Service-Key) let mut scanner_headers = reqwest::header::HeaderMap::new(); scanner_headers.insert( "User-Agent", @@ -277,6 +279,13 @@ async fn main() -> anyhow::Result<()> { .finish() .expect("Failed to build rate limiter"); + let crosschain = web::Data::new(crosschain::Gateway::from_env(&config)?); + crosschain::fees::start_worker( + pool.clone(), + crosschain.clone(), + config.encryption_key.clone(), + ); + HttpServer::new(move || { let cors = if config.is_testnet() { Cors::default() @@ -320,6 +329,7 @@ async fn main() -> anyhow::Result<()> { .app_data(web::Data::new(config.clone())) .app_data(web::Data::new(price_service.clone())) .app_data(web::Data::new(http_client.clone())) + .app_data(crosschain.clone()) .configure(api::configure) .route("/", web::get().to(serve_ui)) .service(web::resource("/widget/{filename}").route(web::get().to(serve_widget))) diff --git a/src/merchants/mod.rs b/src/merchants/mod.rs index 6d05636..6b24c30 100644 --- a/src/merchants/mod.rs +++ b/src/merchants/mod.rs @@ -525,12 +525,14 @@ pub async fn has_outstanding_balance(pool: &SqlitePool, merchant_id: &str) -> an } pub async fn delete_merchant(pool: &SqlitePool, merchant_id: &str) -> anyhow::Result<()> { - let mut tx = pool.begin().await?; + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + crate::referrals::before_delete(&mut tx, merchant_id).await?; // Resolve cycles at commit without ever disabling FK enforcement on a pooled connection. sqlx::query("PRAGMA defer_foreign_keys = ON") .execute(&mut *tx) .await?; for sql in [ + "DELETE FROM crosschain_fee_evidence WHERE invoice_id IN (SELECT id FROM invoices WHERE merchant_id = ?)", "DELETE FROM session_charges WHERE session_id IN (SELECT id FROM agent_sessions WHERE merchant_id = ?)", "DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE merchant_id = ?)", "DELETE FROM campaign_donations WHERE link_id IN (SELECT id FROM payment_links WHERE merchant_id = ?)", diff --git a/src/scanner/api.rs b/src/scanner/api.rs new file mode 100644 index 0000000..49f00e9 --- /dev/null +++ b/src/scanner/api.rs @@ -0,0 +1,180 @@ +use serde::de::DeserializeOwned; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Envelope { + data: T, + meta: serde_json::Value, +} + +/// Explorer configuration is a host URL; tolerate an explicit /v1 base too. +pub(super) fn base_url(api_url: &str) -> &str { + api_url.trim_end_matches('/').trim_end_matches("/v1") +} + +pub(super) async fn get( + http: &reqwest::Client, + api_url: &str, + path: &str, +) -> anyhow::Result { + let url = format!("{}/v1/{}", base_url(api_url), path); + let envelope: Envelope = http + .get(url) + .send() + .await? + .error_for_status()? + .json() + .await?; + anyhow::ensure!(envelope.meta.is_object(), "Invalid ZecBlock v1 metadata"); + Ok(envelope.data) +} + +#[cfg(test)] +mod tests { + use crate::scanner::{blocks, mempool}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn fixture(responses: Vec<(&'static str, u16, serde_json::Value)>) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + for (path, status, body) in responses { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let correct_path = String::from_utf8_lossy(&request) + .starts_with(&format!("GET {path} HTTP/1.1\r\n")); + let status = if correct_path { status } else { 400 }; + let body = body.to_string(); + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + } + }); + base + } + + fn envelope(data: serde_json::Value) -> serde_json::Value { + serde_json::json!({"data": data, "meta": {"network": "testnet"}}) + } + + #[tokio::test] + async fn scanner_reads_v1_paths_and_envelopes() { + let base = fixture(vec![ + ( + "/v1/mempool", + 200, + envelope(serde_json::json!({"transactions": [{"txid": "abc"}]})), + ), + ( + "/v1/transactions/abc/raw", + 200, + envelope(serde_json::json!({"hex": "deadbeef"})), + ), + ( + "/v1/network/blockchain-info", + 200, + envelope(serde_json::json!({"blocks": 100})), + ), + ( + "/v1/blocks/100", + 200, + envelope(serde_json::json!({"transactions": [{"txid": "abc"}]})), + ), + ( + "/v1/transactions/abc", + 200, + envelope(serde_json::json!({"blockHeight": "100", "confirmations": 1})), + ), + ( + "/v1/transactions/abc", + 200, + envelope(serde_json::json!({"blockHeight": "100", "confirmations": 0})), + ), + ( + "/v1/transactions/abc/raw", + 200, + envelope(serde_json::json!({"hex": "deadbeef"})), + ), + ]) + .await; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + assert_eq!( + mempool::fetch_mempool_txids(&http, &base).await.unwrap(), + vec!["abc"] + ); + assert_eq!( + mempool::fetch_raw_tx(&http, &format!("{base}/v1/"), "abc") + .await + .unwrap(), + "deadbeef" + ); + assert_eq!(blocks::get_chain_height(&http, &base).await.unwrap(), 100); + assert_eq!( + blocks::fetch_block_txids(&http, &base, 100, 100) + .await + .unwrap(), + vec!["abc"] + ); + assert!(blocks::check_tx_confirmed(&http, &base, "abc") + .await + .unwrap()); + assert!(!blocks::check_tx_confirmed(&http, &base, "abc") + .await + .unwrap()); + assert_eq!( + mempool::fetch_raw_txs_batch(&http, &base, &["abc".into()]).await, + vec![("abc".into(), "deadbeef".into())] + ); + } + + #[tokio::test] + async fn errors_and_malformed_payloads_cannot_look_like_empty_mempools() { + let base = fixture(vec![ + ( + "/v1/mempool", + 401, + envelope(serde_json::json!({"transactions": []})), + ), + ( + "/v1/mempool", + 429, + serde_json::json!({"title": "Too many requests"}), + ), + ("/v1/mempool", 200, serde_json::json!({"transactions": []})), + ("/v1/mempool", 200, envelope(serde_json::json!({}))), + ( + "/v1/mempool", + 200, + serde_json::json!({"data": {"transactions": []}, "meta": null}), + ), + ( + "/v1/mempool", + 200, + envelope(serde_json::json!({"transactions": []})), + ), + ]) + .await; + let http = reqwest::Client::new(); + for _ in 0..5 { + assert!(mempool::fetch_mempool_txids(&http, &base).await.is_err()); + } + assert!(mempool::fetch_mempool_txids(&http, &base) + .await + .unwrap() + .is_empty()); + } +} diff --git a/src/scanner/blocks.rs b/src/scanner/blocks.rs index c46741b..9196c96 100644 --- a/src/scanner/blocks.rs +++ b/src/scanner/blocks.rs @@ -7,7 +7,7 @@ struct BlockchainInfoResponse { headers: Option, } -/// Simple circuit breaker for CipherScan API resilience. +/// Simple circuit breaker for ZecBlock API resilience. /// Opens after `FAILURE_THRESHOLD` consecutive failures, stays open for /// `COOLDOWN_SECS`, then transitions to half-open (allows one probe request). pub struct CircuitBreaker { @@ -54,27 +54,16 @@ impl CircuitBreaker { tracing::warn!( failures = prev + 1, cooldown_secs = COOLDOWN_SECS, - "CipherScan circuit breaker OPEN" + "ZecBlock circuit breaker OPEN" ); } } } -/// Gets the current chain tip height from CipherScan API. +/// Gets the current chain tip height from ZecBlock API. pub async fn get_chain_height(http: &reqwest::Client, api_url: &str) -> anyhow::Result { - let url = format!("{}/api/blockchain-info", api_url); - let response = http.get(&url).send().await?; - let status = response.status(); - if !status.is_success() { - return Err(anyhow::anyhow!( - "CipherScan API returned HTTP {} for blockchain-info", - status.as_u16() - )); - } - let resp: BlockchainInfoResponse = response - .json() - .await - .map_err(|e| anyhow::anyhow!("Failed to parse blockchain-info JSON: {e}"))?; + let resp: BlockchainInfoResponse = + super::api::get(http, api_url, "network/blockchain-info").await?; resp.blocks .or(resp.headers) @@ -90,7 +79,7 @@ async fn fetch_single_block_txids( api_url: &str, height: u64, ) -> anyhow::Result> { - let url = format!("{}/api/block/{}", api_url, height); + let path = format!("blocks/{height}"); let mut last_err = None; for attempt in 0..BLOCK_FETCH_RETRIES { @@ -99,31 +88,10 @@ async fn fetch_single_block_txids( tokio::time::sleep(std::time::Duration::from_millis(delay)).await; } - let resp = match http.get(&url).send().await { - Ok(r) => { - let status = r.status(); - if !status.is_success() { - last_err = Some(anyhow::anyhow!( - "CipherScan API returned HTTP {} for block {}", - status.as_u16(), - height - )); - continue; - } - match r.json::().await { - Ok(v) => v, - Err(e) => { - last_err = Some(anyhow::anyhow!( - "Failed to parse block {} response: {}", - height, - e - )); - continue; - } - } - } + let resp = match super::api::get::(http, api_url, &path).await { + Ok(resp) => resp, Err(e) => { - last_err = Some(anyhow::anyhow!("Failed to fetch block {}: {}", height, e)); + last_err = Some(e); continue; } }; @@ -220,17 +188,8 @@ pub async fn check_tx_confirmed( api_url: &str, txid: &str, ) -> anyhow::Result { - let url = format!("{}/api/tx/{}", api_url, txid); - let response = http.get(&url).send().await?; - let status = response.status(); - if !status.is_success() { - return Err(anyhow::anyhow!( - "CipherScan API returned HTTP {} for tx {}", - status.as_u16(), - txid - )); - } - let resp: serde_json::Value = response.json().await?; + let resp: serde_json::Value = + super::api::get(http, api_url, &format!("transactions/{txid}")).await?; let confirmed = transaction_confirmed(&resp); diff --git a/src/scanner/invoice_detection.rs b/src/scanner/invoice_detection.rs index cc888f2..e1e3ec0 100644 --- a/src/scanner/invoice_detection.rs +++ b/src/scanner/invoice_detection.rs @@ -74,6 +74,7 @@ pub(super) async fn apply_mempool_invoice_totals( let mut newly_detected = Vec::new(); for (invoice_id, (invoice, tx_total)) in invoice_totals { + if !crate::crosschain::security::accepts_payment(pool, invoice_id, txid).await? { continue; } let dust_min = std::cmp::max( (invoice.price_zatoshis as f64 * super::decrypt::DUST_THRESHOLD_FRACTION) as i64, super::decrypt::DUST_THRESHOLD_MIN_ZATOSHIS, @@ -85,7 +86,7 @@ pub(super) async fn apply_mempool_invoice_totals( let new_received = invoices::record_payment(pool, invoice_id, txid, *tx_total).await?; - let min = (invoice.price_zatoshis as f64 * super::decrypt::SLIPPAGE_TOLERANCE) as i64; + let min = invoice.minimum_payment_zatoshis(); if new_received >= min { let changed = invoices::mark_detected(pool, invoice_id, txid, new_received).await?; diff --git a/src/scanner/mempool.rs b/src/scanner/mempool.rs index f3dbd06..55ac965 100644 --- a/src/scanner/mempool.rs +++ b/src/scanner/mempool.rs @@ -5,7 +5,7 @@ const BATCH_SIZE: usize = 20; #[derive(Debug, Deserialize)] struct MempoolResponse { - transactions: Option>, + transactions: Vec, } #[derive(Debug, Deserialize)] @@ -13,30 +13,24 @@ struct MempoolTx { txid: String, } -/// Fetches current mempool transaction IDs from CipherScan API. +/// Fetches current mempool transaction IDs from ZecBlock API. pub async fn fetch_mempool_txids( http: &reqwest::Client, api_url: &str, ) -> anyhow::Result> { - let url = format!("{}/api/mempool", api_url); - let resp: MempoolResponse = http.get(&url).send().await?.json().await?; + let resp: MempoolResponse = super::api::get(http, api_url, "mempool").await?; - Ok(resp - .transactions - .unwrap_or_default() - .into_iter() - .map(|tx| tx.txid) - .collect()) + Ok(resp.transactions.into_iter().map(|tx| tx.txid).collect()) } -/// Fetches raw transaction hex from CipherScan API. +/// Fetches raw transaction hex from ZecBlock API. pub async fn fetch_raw_tx( http: &reqwest::Client, api_url: &str, txid: &str, ) -> anyhow::Result { - let url = format!("{}/api/tx/{}/raw", api_url, txid); - let resp: serde_json::Value = http.get(&url).send().await?.json().await?; + let resp: serde_json::Value = + super::api::get(http, api_url, &format!("transactions/{txid}/raw")).await?; resp["hex"] .as_str() @@ -58,16 +52,11 @@ pub async fn fetch_raw_txs_batch( .iter() .map(|txid| { let http = http.clone(); - let url = format!("{}/api/tx/{}/raw", api_url, txid); - let txid = txid.clone(); async move { - let resp: Result = - async { Ok(http.get(&url).send().await?.json().await?) }.await; - - match resp { - Ok(val) => val["hex"].as_str().map(|hex| (txid, hex.to_string())), - Err::<_, anyhow::Error>(_) => None, - } + fetch_raw_tx(&http, api_url, txid) + .await + .ok() + .map(|hex| (txid.clone(), hex)) } }) .collect(); diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 485c286..84d0729 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -1,3 +1,4 @@ +mod api; pub mod blocks; pub mod decrypt; mod invoice_detection; @@ -36,7 +37,7 @@ pub async fn run( webhook_http: reqwest::Client, price_service: PriceService, ) { - // scanner_http: internal client for CipherScan API calls (carries X-Service-Key) + // scanner_http: internal client for ZecBlock API calls (carries X-Service-Key) // webhook_http: outbound client for merchant webhooks (no service keys, no redirects) let http = scanner_http; let circuit_breaker = Arc::new(blocks::CircuitBreaker::new()); @@ -129,7 +130,7 @@ pub async fn run( } _ = interval.tick() => { if mempool_cb.is_open() { - tracing::debug!("CipherScan circuit breaker open, skipping mempool scan"); + tracing::debug!("ZecBlock circuit breaker open, skipping mempool scan"); continue; } let mempool_start = Instant::now(); @@ -173,7 +174,7 @@ pub async fn run( let _ = invoices::expire_old_invoices(&block_pool).await; if block_cb.is_open() { - tracing::debug!("CipherScan circuit breaker open, skipping block scan"); + tracing::debug!("ZecBlock circuit breaker open, skipping block scan"); continue; } let block_start = Instant::now(); @@ -231,11 +232,17 @@ pub async fn run( } }); + let payment_email_handle = tokio::spawn(crate::email::run_payment_email_worker( + pool.clone(), + config.clone(), + )); + let _ = tokio::join!( mempool_handle, block_handle, evict_handle, - luma_retry_handle + luma_retry_handle, + payment_email_handle ); } @@ -367,7 +374,7 @@ async fn scan_mempool( ) .await?; for invoice_id in &detected { - try_detect_fee(pool, config, raw_hex, invoice_id).await; + try_detect_fee(pool, config, raw_hex, invoice_id, None).await; } invoice_detection::apply_campaign_totals(pool, txid, &campaign_totals, price_service).await; seen.write().await.insert(txid.clone(), Instant::now()); @@ -422,7 +429,7 @@ async fn process_ws_mempool_tx( ) .await?; for invoice_id in &detected { - try_detect_fee(pool, config, &push.raw_hex, invoice_id).await; + try_detect_fee(pool, config, &push.raw_hex, invoice_id, None).await; } invoice_detection::apply_campaign_totals(pool, &push.txid, &campaign_totals, price_service) .await; @@ -511,6 +518,44 @@ async fn scan_blocks( key_cache: &mut Option, price_service: &PriceService, ) -> anyhow::Result<()> { + if config.fee_enabled() { + let fees: Vec<(String,String)>=sqlx::query_as("SELECT p.invoice_id,p.txid FROM invoice_payments p JOIN invoices i ON i.id=p.invoice_id + LEFT JOIN direct_fee_checks c ON c.invoice_id=p.invoice_id AND c.txid=p.txid + WHERE i.status='confirmed' AND (c.next_check IS NULL OR c.next_check = sqlx::query_as("SELECT d.invoice_id,d.txid FROM crosschain_deliveries d JOIN invoices i ON i.id=d.invoice_id + WHERE i.status IN ('pending','underpaid') AND NOT EXISTS(SELECT 1 FROM invoice_payments p WHERE p.invoice_id=d.invoice_id AND lower(p.txid)=d.txid) LIMIT 8") + .fetch_all(pool).await?; + for (id, txid) in deliveries { + let Some(invoice) = invoices::get_invoice(pool,&id).await? else { continue; }; + let merchant = crate::merchants::get_merchant_by_id(pool,&invoice.merchant_id,&config.encryption_key).await?; + if let Some(merchant) = merchant { + let ufvk = crate::crypto::decrypt_or_plaintext(&merchant.ufvk,&config.encryption_key)?; + if let Ok(raw) = mempool::fetch_raw_tx(http,&config.cipherscan_api_url,&txid).await { + if let Ok(outputs) = decrypt::try_decrypt_all_outputs_ivk(&raw,&ufvk) { + let amount: i64 = outputs.iter().filter(|o|Some(hex::encode(o.recipient_raw))==invoice.orchard_receiver_hex).map(|o|o.amount_zatoshis as i64).sum(); + if amount>0 { + let mut totals=HashMap::new(); totals.insert(id,(invoice,amount)); + invoice_detection::apply_mempool_invoice_totals(pool,webhook_http,config,&txid,&totals).await?; + } + } + } + } + } let pending = invoices::get_pending_invoices(pool).await?; // Confirm detected invoices (uses direct txid lookup, no block scanning) @@ -525,11 +570,7 @@ async fn scan_blocks( match confirmed_payment_total(pool, http, &config.cipherscan_api_url, &invoice.id) .await { - Ok(total) - if total - >= (invoice.price_zatoshis as f64 * decrypt::SLIPPAGE_TOLERANCE) - as i64 => - { + Ok(total) if total >= invoice.minimum_payment_zatoshis() => { let mut invoice = invoice.clone(); invoice.received_zatoshis = total; let invoice = &invoice; @@ -553,19 +594,7 @@ async fn scan_blocks( ); on_invoice_confirmed(pool, webhook_http, config, invoice).await; - // Scan for ZIP 321 fee output that mempool path may have missed - if config.fee_enabled() { - match mempool::fetch_raw_tx(http, &config.cipherscan_api_url, txid) - .await - { - Ok(raw_hex) => { - try_detect_fee(pool, config, &raw_hex, &invoice.id).await; - } - Err(e) => { - tracing::debug!(txid, error = %e, "Could not fetch raw tx for fee scan"); - } - } - } + } } Ok(_) => {} @@ -711,6 +740,7 @@ async fn scan_blocks( } for (invoice_id, (invoice, tx_total)) in &invoice_totals { + if !crate::crosschain::security::accepts_payment(pool, invoice_id, txid).await? { continue; } let dust_min = std::cmp::max( (invoice.price_zatoshis as f64 * decrypt::DUST_THRESHOLD_FRACTION) as i64, decrypt::DUST_THRESHOLD_MIN_ZATOSHIS, @@ -728,7 +758,7 @@ async fn scan_blocks( let new_received = invoices::record_payment(pool, invoice_id, txid, *tx_total).await?; - let min = (invoice.price_zatoshis as f64 * decrypt::SLIPPAGE_TOLERANCE) as i64; + let min = invoice.minimum_payment_zatoshis(); if new_received >= min && (invoice.status == "pending" || invoice.status == "underpaid") @@ -739,7 +769,7 @@ async fn scan_blocks( metrics::global().record_payment_detected(); // Confirmation is performed on the next scan after checking // every contributing transaction, including mempool top-ups. - try_detect_fee(pool, config, &raw_hex, invoice_id).await; + try_detect_fee(pool, config, &raw_hex, invoice_id, None).await; } } else if new_received < min && invoice.status == "pending" { invoices::mark_underpaid(pool, invoice_id, new_received, txid).await?; @@ -951,16 +981,14 @@ async fn on_invoice_confirmed( } } + if invoice.memo_code.starts_with("SETTLE-") { return; } if !config.fee_enabled() { return; } - let fee_rate = match billing::get_effective_fee_rate(pool, &invoice.merchant_id, config).await { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "Failed to get merchant fee rate, using default"); - config.fee_rate - } + let fee_rate = match crate::referrals::service_bps(pool, &invoice.id).await { + Ok(bps) => bps as f64 / 10000.0, + Err(e) => { tracing::error!(error=%e,"Missing invoice fee policy"); return; } }; let fee_amount = invoice.price_zec * fee_rate; @@ -1313,7 +1341,8 @@ async fn mark_luma_failed(pool: &SqlitePool, invoice_id: &str) { /// After a merchant payment is detected, try to decrypt the same tx against /// the CipherPay fee UFVK to check if the fee output was included (ZIP 321). -async fn try_detect_fee(pool: &SqlitePool, config: &Config, raw_hex: &str, invoice_id: &str) { +async fn try_detect_fee(pool: &SqlitePool, config: &Config, raw_hex: &str, invoice_id: &str, confirmed_txid: Option<&str>) { + let Some(txid)=confirmed_txid else { return; }; let fee_ufvk = match &config.fee_ufvk { Some(u) => u, None => return, @@ -1323,15 +1352,10 @@ async fn try_detect_fee(pool: &SqlitePool, config: &Config, raw_hex: &str, invoi match decrypt::try_decrypt_all_outputs(raw_hex, fee_ufvk) { Ok(outputs) => { - for output in &outputs { - if output.memo.starts_with(&fee_memo_prefix) { - tracing::info!( - invoice_id, - fee_zec = output.amount_zec, - "Fee auto-collected via ZIP 321" - ); - let _ = billing::mark_fee_collected(pool, invoice_id).await; - return; + let units: i64=outputs.iter().filter(|o|o.memo==fee_memo_prefix).map(|o|o.amount_zatoshis as i64).sum(); + if units>0 { + if let Err(e)=billing::record_direct_receipt(pool,invoice_id,txid,units).await { + tracing::error!(error=%e,invoice_id,"Could not record confirmed fee receipt"); } } } @@ -1366,10 +1390,11 @@ mod payment_confirmation_tests { let mut buf = [0u8; 2048]; let n = stream.read(&mut buf).unwrap(); let request = String::from_utf8_lossy(&buf[..n]); + assert!(request.starts_with("GET /v1/transactions/")); let body = if request.contains("/first ") { - "{\"confirmations\":0,\"block_height\":100}" + "{\"data\":{\"confirmations\":0,\"block_height\":100},\"meta\":{}}" } else { - "{\"confirmations\":2}" + "{\"data\":{\"confirmations\":2},\"meta\":{}}" }; write!(stream,"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",body.len(),body).unwrap(); } diff --git a/src/scanner/ws.rs b/src/scanner/ws.rs index e48cd7d..d0801cd 100644 --- a/src/scanner/ws.rs +++ b/src/scanner/ws.rs @@ -13,6 +13,7 @@ pub struct MempoolPush { /// Converts an HTTP(S) API URL to its WebSocket equivalent. pub fn api_url_to_ws(api_url: &str) -> String { + let api_url = super::api::base_url(api_url); if api_url.starts_with("https://") { api_url.replacen("https://", "wss://", 1) } else if api_url.starts_with("http://") { @@ -22,14 +23,14 @@ pub fn api_url_to_ws(api_url: &str) -> String { } } -/// Long-running task: connect to CipherScan WebSocket, subscribe to raw_mempool, +/// Long-running task: connect to ZecBlock WebSocket, subscribe to raw_mempool, /// and forward mempool transactions with raw_hex through the channel. /// Reconnects automatically on disconnect with exponential backoff. pub async fn run(ws_url: String, service_key: String, tx: mpsc::Sender) { let mut delay = std::time::Duration::from_secs(RECONNECT_MIN_SECS); loop { - tracing::info!(url = %ws_url, "[WS] Connecting to CipherScan..."); + tracing::info!(url = %ws_url, "[WS] Connecting to ZecBlock..."); match connect(&ws_url, &service_key).await { Ok(mut stream) => { @@ -127,3 +128,18 @@ fn parse_mempool_tx(text: &str) -> Option { raw_hex: raw_hex.to_string(), }) } + +#[cfg(test)] +mod tests { + #[test] + fn websocket_uses_host_root_without_http_version_prefix() { + assert_eq!( + super::api_url_to_ws("https://api.zecblock.com/v1/"), + "wss://api.zecblock.com" + ); + assert_eq!( + super::api_url_to_ws("http://localhost:3002/"), + "ws://localhost:3002" + ); + } +} From 51c8d386b9e9113d7b1d91fd1501a78e80b8e8a9 Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Mon, 2 Nov 2026 07:57:52 -0500 Subject: [PATCH 8/9] Add CI release builds and main-only stage/activate/status scripts Production drifted from main because releases were built from working trees (crosschain-20261021 was 39e4ae3 plus uncommitted changes, while main lacked the September payment fixes). Make that impossible: - release-build.yml: on every push to main, test, build on ubuntu-24.04 (matches the hosts) and publish a build- prerelease with the binary, source archive, build-info.json and SHA256SUMS. The 1 vCPU / 1 GB host no longer compiles. - scripts/release/stage.sh: refuse commits not on main, verify checksums, validate migrations on a private DB copy (validate-database.py, generalized from the crosschain release), write release-info.json. - scripts/release/activate.sh: generalized activate-production.sh with backup, live --check-database, zz-release.conf drop-in, health check and automatic rollback. - scripts/release/status.sh: report the serving commit and fail when it is dirty or not on main. - docs/RELEASING.md documents the flow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-build.yml | 51 ++++++++++++ README.md | 2 + docs/RELEASING.md | 51 ++++++++++++ .../validate-database.cpython-314.pyc | Bin 0 -> 6009 bytes scripts/release/activate.sh | 75 ++++++++++++++++++ scripts/release/stage.sh | 48 +++++++++++ scripts/release/status.sh | 29 +++++++ scripts/release/validate-database.py | 61 ++++++++++++++ 8 files changed, 317 insertions(+) create mode 100644 .github/workflows/release-build.yml create mode 100644 docs/RELEASING.md create mode 100644 scripts/release/__pycache__/validate-database.cpython-314.pyc create mode 100755 scripts/release/activate.sh create mode 100755 scripts/release/stage.sh create mode 100755 scripts/release/status.sh create mode 100755 scripts/release/validate-database.py diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..3c848fe --- /dev/null +++ b/.github/workflows/release-build.yml @@ -0,0 +1,51 @@ +name: Release Build + +# Builds the production binary for every commit on main, so the server never +# compiles (1 vCPU / 1 GB) and every deployed binary maps to a clean main commit. +# scripts/release/stage.sh downloads these assets by commit SHA. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-build-${{ github.sha }} + +jobs: + build: + # Matches the CipherPay hosts (Ubuntu 24.04, glibc 2.39, OpenSSL 3). + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --locked + - run: cargo build --release --locked + - name: Package + run: | + mkdir -p dist + cp target/release/cipherpay dist/cipherpay-linux-x86_64 + git archive --format=tar.gz -o dist/source.tar.gz "$GITHUB_SHA" + cat > dist/build-info.json < SHA256SUMS) + - name: Publish build- release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="build-$GITHUB_SHA" + gh release create "$tag" dist/* --target "$GITHUB_SHA" --prerelease \ + --title "Build ${GITHUB_SHA::7}" \ + --notes "Production build of $GITHUB_SHA from main. Deploy with scripts/release/stage.sh $GITHUB_SHA." diff --git a/README.md b/README.md index 0e10d43..966b78e 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,8 @@ cargo build --release # Binary at target/release/cipherpay ``` +The hosted service deploys CI-built releases from `main`; see [docs/RELEASING.md](docs/RELEASING.md). + See the companion frontend at [cipherpay-web](https://github.com/atmospherelabs-dev/cipherpay-web) for the hosted checkout and merchant dashboard. ## Related diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..9b66c71 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,51 @@ +# Releasing the CipherPay API + +Production runs a CI-built binary from a commit on `main`, never a binary built +from a working tree. Every deployed release records its commit in +`release-info.json`, so `status.sh` can prove what is serving. + +## Flow + +1. **Merge to `main`.** `.github/workflows/release-build.yml` runs the tests, + builds on Ubuntu 24.04 (matching the hosts) and publishes a `build-` + prerelease with the binary, source archive, `build-info.json` and + `SHA256SUMS`. +2. **Stage on the host** (service keeps running): + + ```bash + sudo /opt/cipherpay-api/releases//source/scripts/release/stage.sh + ``` + + Refuses commits not on `main`, verifies checksums, migrates a private copy + of the live database and asserts every existing table/column is unchanged. + Output: `/opt/cipherpay-api/releases/-/`. +3. **Activate:** + + ```bash + sudo /source/scripts/release/activate.sh + ``` + + Takes a consistent database backup, stops the service, runs + `--check-database` on the live database, points + `cipherpay-api.service.d/zz-release.conf` at the new binary and waits for a + healthy `/api/health`. Any failure restores the previous drop-in/binary and + keeps current payment data. +4. **Check drift any time:** + + ```bash + sudo /source/scripts/release/status.sh + ``` + + Exit 0 means serving `main` (or an older `main` commit); exit 2 means dirty, + unknown or not on `main`. + +## Rules + +- Do not deploy uncommitted changes or branch builds. Land them on `main` first. +- Migrations must be additive; `validate-database.py` rejects changes to + existing rows. +- Environment files are untouched by these scripts. The historical + `zz-crosschain-20261021.conf` drop-in still supplies the NEAR cross-chain + `EnvironmentFile`; move it to a stable path before removing that release. +- Testnet (`/root/cipherpay-api-testnet`, `cipherpay-testnet.service`) still + uses a source checkout and is not covered by these scripts yet. diff --git a/scripts/release/__pycache__/validate-database.cpython-314.pyc b/scripts/release/__pycache__/validate-database.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2fa3a4158dd8304e6760a04e2a9aa203d096eb7d GIT binary patch literal 6009 zcmb_gTWk~A89w9j_%_5&949y?mk9|;42c6I5K6dg0?7g)i8CaDwlUUV5AlFwo0)ME zTJ(MQx=WW4m^TR=w;)+o$eAE3~V9=s#nR z<3QNmt~yE1Idjf`Ki_};GudISupoF|Tl&X^{V;k@dC{JV;^1+m8KESik%-PB8as}O znA+<^o!a9f4!!QU{um(=O$8c*Xi(RTqEYQlqRB@zBcI7@*Tk>&XR%J#+FLJH7<+5h z=Co07aUhb4G#b}v#Z5kv)_0N`9YSIS%n<8ltUlb^xJIFkn$g+Anw%!&tME2!D{H-` zx4k$OG$B-vCJ=4VRzjH1;N7~$p^d=jO`7z6%#X!&aomqIF46v6u8L3Ks`lBvZcW2l z@2l2$nqZ%lpI*GM`t1AX())aCt(xWyeEdIHOB**MvF3BxT2m@-1{`>|!f^;Vs-C04?5k-;)0n2GQ&SB+ zZ7F}#D4wxoU0$rWlYxSW&sKHXaqc* z#(XY7Wi42yE0_ebHkW+F#4S%_$5#*ZxOab<1H~b|$=3*LrQI+0imvD@$`_j;7p>*F z=pEQVmQ9Pp1{iAg8NElJ1*;S+{4du({{SL&rq2EA2Oum z?FM$X`dT+cn?tk7JMyd@+G(&Jd34RbGupmQAUIz(mRwtsJo>+J0BkJ4PVy}(Zd#{x8g8cXeG8g}BY?GO9bmMsgi1W76FYnzCEalLs%PeA z+UY&@tZrY2=1Dn5k0XF+P`SsmS^{IC)KNFqqv!W6s62_hGn1>$ggE+SADSvIC{aJ)+Hu~?=A zW|rMg%`;&R1h8G26c0#0z_Vc%Bz6Tk{*c8|uz`z$oVmClQs+QbFvy9Zd_Tp`v;1WW zEC?42C};#-j94&30Q&P$o>kOa0$`)6uU(+Jx&k3KaIvJB;^UEmf>1OZq_`j(5xD>p zrugWBKr!b#6P3X361rLop8w)c5R&h4M%&~$Zw@q#p z0z4NJh3=wF-5YJ8Ok9dbAz*NggbYMTP?{vllh$R&&H5YlD}mMCbX7-|?6}>NB|Vbf z^B4$FV(iq=$)O{Yl%kIsrpHGq;Zm3rS^pd(fH>;(u_1bh5--QtgYD{`w^IXSM+@X* z*G{eS$kV#=w;AJ_gpH`RId9qbfX1Pnp)uWz_I(52 z5u2fQRhDk$8h;F*v1xoz^@_Nesnq5+`j&yO68%Pzm@a**eA0J~ro#(0_vuHoyhjH( zBof9V6lAP3VJKi>9rE?S-vfV-_d&B2&iEvnM8jZv44Wxsz}oF}5hdZsuW+SpO#0a46Zg+#T~nDeKIv>AeI}T71=BV*XN#n4k+dzg zr2nJcnNmWKcJ`$0Jxj(v+U(1{-?;qk_SZb$^4xa(pze*jJAG+)f6hIaau256N7JsM zoNGMg8c(|>B-?~!n&9D!93_l}MBWc4(59mUlLLbTr-uAf^vT5L(y3Z@l!l|TGPWR_ zOQ&(7^64Xf(oM*CU;(1ZM&==iVlWyP)t=>fnNaINqPBQua1oK6G+-$^uku9z!HV={H!99$lX6uF za}h|sAO~U~H|9AW{J;f~m&xMW9K=i3M`Mt4xPmJj?gp~q)a23esY#ho?*hdfjYe4B zsi-2LOwB3wsXH(5%71rwkJ2dr`;iUcbUZA|mIZ(m>lfKYQ6_@%xtIXC8jS}+G66TN zhzR+M@iHdzl){C>O+?Mwo$wUSBh!JoXfPgT5AkP#LOJP#J5ZV4$1x21KySeGkF5x^ zJw)U~Wd0L!K19}ssOllI{MBMz60R>^TU^1u`IWR~OU}}gvb3Zv+mgmV7)fbUSK8=F z>hq*ss&QvYchZ!va4f%)t!PVH@k5^dpn&+Cm>Pki;nl9RP(CtYSoixTWT;RzYRql<`mL{x zy*idRo%uJ8HGBzsj4?8e@ew$i(+5su zt6tuHbN`L~w~zl6zkA|mwruB%8SAkmnKx8OmF}#;og=zaME4y$NAzZh-n)2SZ@oHp zW$eb0731oTtg|ca@JKrcrRpR1H%rq@-qo;rA=lQIYU{fz{s+g1Aq?Vd*s~U?FXad- zLr|+jd9w2Q!D|Plx?O2>iUQrn0-IGH5!l5H)47rW|TZp|%X__yZ!6 z!K)XpTv)D68=SvKbx%ey1(_`eq?Q980YKqbB3vK+y?qc6DYXy6TqFESMB~Sg>*XRs$m@C5ciBB+l988Y`*JGPAP zA>K6D$D7S>I!!QsOSi?_K)lsp_nOUbb(>)P>w3q8f%x^#D(^1S+n5Q)Z<{;4{g$`) zRC)K>-!T|r{7wZ4^Y1uK6PW3pZC0i2!C~h9;oTE1;y2X;_K9BNw{0~_+i#!nSbk?W zD6Nx(ncuYz;LyF>Opfoac(>CGR=m51^cu{Pne@6UB!?NSkQzv@$1Lq4K&rHt0G(35 bqBCV6CpMc?E^=arIpr~coK!FTU)ua%onjUx literal 0 HcmV?d00001 diff --git a/scripts/release/activate.sh b/scripts/release/activate.sh new file mode 100755 index 0000000..1f8f747 --- /dev/null +++ b/scripts/release/activate.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Switch cipherpay-api to a release staged by stage.sh; restore the previous +# binary (keeping current payment data) if any step fails. +# +# sudo scripts/release/activate.sh /opt/cipherpay-api/releases/ +set -euo pipefail + +release=$(realpath "${1:?usage: activate.sh }") +root=/opt/cipherpay-api +unitdir=/etc/systemd/system/cipherpay-api.service.d +# Sorts after the historical audit/subscriptions/crosschain drop-ins, so its +# ExecStart wins. EnvironmentFile entries from earlier drop-ins still apply. +dropin=$unitdir/zz-release.conf +test "$(id -u)" = 0 +test -f "$release/migration-verified" +test -x "$release/cipherpay" +test "$(jq -r .dirty "$release/release-info.json")" = false + +previous_exec=$(systemctl show -p ExecStart --value cipherpay-api.service | sed -n 's/.*path=\([^ ;]*\).*/\1/p') +echo "$previous_exec" > "$release/previous-binary" +if test -f "$dropin"; then cp -p "$dropin" "$release/previous-dropin.conf"; fi + +python3 - "$release" <<'PY' +from pathlib import Path +import sqlite3, sys, os +os.umask(0o077) +r = Path(sys.argv[1]) +with sqlite3.connect('/opt/cipherpay-api/cipherpay.db') as source, \ + sqlite3.connect(r / 'database-immediately-before.db') as backup: + source.backup(backup) + assert backup.execute('pragma integrity_check').fetchone()[0] == 'ok' +PY + +systemctl stop cipherpay-api.service +recover() { + trap - ERR + if test -f "$release/previous-dropin.conf"; then + cp -p "$release/previous-dropin.conf" "$dropin" + else + rm -f "$dropin" + fi + systemctl daemon-reload + systemctl restart cipherpay-api.service + echo "Activation failed; restored $previous_exec with current payment data retained." >&2 + exit 1 +} +trap recover ERR + +DATABASE_URL=sqlite:$root/cipherpay.db "$release/cipherpay" --check-database > "$release/database-live.log" 2>&1 +for file in $root/cipherpay.db $root/cipherpay.db-wal $root/cipherpay.db-shm; do + if test -f "$file"; then chown cipherpay:cipherpay "$file"; fi +done + +install -d -m 755 "$unitdir" +cat > "$dropin" < "$release/health-after.json" && + jq -e '.checks.database == "ok" and (.status == "ok" or .status == "degraded")' \ + "$release/health-after.json" > /dev/null; then + trap - ERR + date -u +%FT%TZ > "$release/activated-at" + echo "Activated $release (was $previous_exec). Verify scanner progress and checkout." + exit 0 + fi + sleep 2 +done +false diff --git a/scripts/release/stage.sh b/scripts/release/stage.sh new file mode 100755 index 0000000..77bbd7a --- /dev/null +++ b/scripts/release/stage.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Stage a CI-built release on a CipherPay host without touching the running service. +# +# sudo scripts/release/stage.sh +# +# Refuses commits that are not on main, verifies the CI checksums, then migrates +# a private copy of the live database and proves existing data is unchanged. +# Activate afterwards with scripts/release/activate.sh . +set -euo pipefail + +repo=atmospherelabs-dev/cipherpay-api +root=/opt/cipherpay-api +sha=${1:?usage: stage.sh } +[[ $sha =~ ^[0-9a-f]{40}$ ]] || { echo "Pass the full 40-character commit SHA" >&2; exit 1; } +test "$(id -u)" = 0 + +# Only commits reachable from main may ship: main must be identical to or ahead of $sha. +status=$(curl -fsS "https://api.github.com/repos/$repo/compare/$sha...main" | jq -r .status) +case $status in + identical|ahead) ;; + *) echo "Commit $sha is not on main (compare status: $status)" >&2; exit 1 ;; +esac + +release=$root/releases/$(date -u +%Y%m%d)-${sha:0:7} +test ! -e "$release" || { echo "$release already exists" >&2; exit 1; } +install -d -m 750 -o root -g cipherpay "$release" +cd "$release" + +base=https://github.com/$repo/releases/download/build-$sha +for asset in cipherpay-linux-x86_64 source.tar.gz build-info.json SHA256SUMS; do + curl -fsSL -o "$asset" "$base/$asset" +done +sha256sum --check --strict SHA256SUMS +test "$(jq -r .source_head build-info.json)" = "$sha" +test "$(jq -r .dirty build-info.json)" = false +mv cipherpay-linux-x86_64 cipherpay +chmod 755 cipherpay +mkdir source && tar -xzf source.tar.gz -C source + +python3 "$release/source/scripts/release/validate-database.py" "$release" + +jq -n --arg sha "$sha" --arg release "$release" \ + --arg binary_sha256 "$(sha256sum cipherpay | cut -d' ' -f1)" \ + --arg run_url "$(jq -r .run_url build-info.json)" \ + --arg staged_at "$(date -u +%FT%TZ)" \ + '{source_head: $sha, dirty: false, release_path: $release, binary_sha256: $binary_sha256, + build_run: $run_url, staged_at: $staged_at}' > release-info.json +echo "Staged $release. Activate with: scripts/release/activate.sh $release" diff --git a/scripts/release/status.sh b/scripts/release/status.sh new file mode 100755 index 0000000..5e190d0 --- /dev/null +++ b/scripts/release/status.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Report which commit cipherpay-api is serving and whether it has drifted from main. +# Exits non-zero when the running build is dirty, unknown, or not on main. +set -euo pipefail + +repo=atmospherelabs-dev/cipherpay-api +binary=$(systemctl show -p ExecStart --value cipherpay-api.service | sed -n 's/.*path=\([^ ;]*\).*/\1/p') +info=$(dirname "$binary")/release-info.json +echo "binary: $binary" +if ! test -f "$info"; then + echo "drift: UNKNOWN (no release-info.json; not deployed by stage.sh)" + exit 2 +fi + +sha=$(jq -r '.source_head // empty' "$info") +dirty=$(jq -r '.dirty // .source_dirty // "unknown"' "$info") +echo "commit: $sha (dirty: $dirty)" +if test "$dirty" != false; then + echo "drift: DIRTY (running code is not a committed revision)" + exit 2 +fi + +compare=$(curl -fsS "https://api.github.com/repos/$repo/compare/$sha...main") +status=$(jq -r .status <<< "$compare") +case $status in + identical) echo "drift: none (serving main)" ;; + ahead) echo "drift: main is $(jq -r .ahead_by <<< "$compare") commit(s) ahead; stage the latest build" ;; + *) echo "drift: NOT ON MAIN (compare status: $status)"; exit 2 ;; +esac diff --git a/scripts/release/validate-database.py b/scripts/release/validate-database.py new file mode 100755 index 0000000..8e0f346 --- /dev/null +++ b/scripts/release/validate-database.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Migrate a private copy of the live database and prove existing columns keep their data. + +Usage: validate-database.py + +Migrations must be additive: every table and column that exists before the +candidate's --check-database run must hold identical rows afterwards. +""" +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys + +release = Path(sys.argv[1]).resolve() +live = Path('/opt/cipherpay-api/cipherpay.db') +snapshot = release / 'database-before.db' +validation = release / 'validation.db' +os.umask(0o077) +assert not snapshot.exists(), 'Snapshot already exists; use a new release path' +with sqlite3.connect(live) as src, sqlite3.connect(snapshot) as dest: + src.backup(dest) +with sqlite3.connect(snapshot) as src, sqlite3.connect(validation) as dest: + src.backup(dest) + + +def quote(name): + return '"' + name.replace('"', '""') + '"' + + +with sqlite3.connect(snapshot) as before: + tables = [r[0] for r in before.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")] + columns = {t: [r[1] for r in before.execute('PRAGMA table_info(' + quote(t) + ')')] for t in tables} + + def digest(db, table): + rows = db.execute('SELECT ' + ','.join(map(quote, columns[table])) + ' FROM ' + quote(table)).fetchall() + encoded = sorted(repr(row) for row in rows) + return len(rows), hashlib.sha256('\n'.join(encoded).encode()).hexdigest() + + baseline = {t: digest(before, t) for t in tables if t not in ('schema_migrations', '_sqlx_migrations')} + +environment = dict(os.environ, DATABASE_URL='sqlite:' + str(validation)) +with (release / 'database-validation.log').open('w') as log: + subprocess.run([str(release / 'cipherpay'), '--check-database'], cwd=release, env=environment, + stdout=log, stderr=subprocess.STDOUT, check=True) + +with sqlite3.connect(validation) as after: + assert after.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' + assert not after.execute('PRAGMA foreign_key_check').fetchall() + changes = [table for table, old in baseline.items() if digest(after, table) != old] + assert not changes, 'Existing data changed: ' + ', '.join(changes) + invoice_count = after.execute('SELECT count(*) FROM invoices').fetchone()[0] + +result = {'existing_tables_preserved': len(baseline), 'invoice_count': invoice_count, + 'integrity': 'ok', 'foreign_keys': 'ok'} +(release / 'database-validation.json').write_text(json.dumps(result, indent=2) + '\n') +(release / 'migration-verified').touch() +print(json.dumps(result)) From 9d95a23b73248ddf1d30a285bf07aa276ebfcd6f Mon Sep 17 00:00:00 2001 From: kenbak wan Date: Mon, 2 Nov 2026 08:12:15 -0500 Subject: [PATCH 9/9] Track referral source and keep the program off unless REFERRALS_ENABLED src/referrals.rs and src/referrals/guardrails.rs ran in production (crosschain-20261021) but were never committed: a global *REFERRAL* exclude hid them, so main could not build the deployed code. Commit them from the release source (guardrails.rs matches the release manifest hash; referrals.rs differs only by the gate in apply() below) and re-include them in the repo .gitignore. The referral program is not meant to be live yet, but production exposed POST /merchants/me/referral and minted codes in the billing summary. Add REFERRALS_ENABLED (default off): applying a code returns 404 and the billing summary returns "referrals": null without creating a code. Fee-policy tables, migrations, service_bps and reconcile are unchanged, since every invoice's fee snapshot depends on them. Production has 0 applied referrals and 0 earnings, so nothing in flight is affected. 122 tests pass on rustc 1.99.1 (CI stable). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 + src/api/billing_routes.rs | 8 +- src/config.rs | 22 ++ src/referrals.rs | 554 ++++++++++++++++++++++++++++++++++++ src/referrals/guardrails.rs | 215 ++++++++++++++ 5 files changed, 803 insertions(+), 2 deletions(-) create mode 100644 src/referrals.rs create mode 100644 src/referrals/guardrails.rs diff --git a/.gitignore b/.gitignore index ed4f83c..ce957b6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ # Internal docs (referral program, strategy) docs/REFERRAL-* + +# Referral source is public; it is switched off in production via REFERRALS_ENABLED. +# Override global *REFERRAL* excludes so it is always committed. +!src/referrals.rs +!src/referrals/ +!src/referrals/** diff --git a/src/api/billing_routes.rs b/src/api/billing_routes.rs index 99a5290..a05c5c7 100644 --- a/src/api/billing_routes.rs +++ b/src/api/billing_routes.rs @@ -14,8 +14,12 @@ pub async fn billing_summary( Err(response) => return response, }; - let referrals = match crate::referrals::summary(&pool,&merchant.id,&config.encryption_key).await { - Ok(v)=>v, Err(_)=>return actix_web::HttpResponse::InternalServerError().finish(), + let referrals = if config.referrals_enabled { + match crate::referrals::summary(&pool,&merchant.id,&config.encryption_key).await { + Ok(v)=>v, Err(_)=>return actix_web::HttpResponse::InternalServerError().finish(), + } + } else { + serde_json::Value::Null }; let pending_fees: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM deferred_payment_fees WHERE merchant_id=? AND state='verifying'") .bind(&merchant.id).fetch_one(pool.get_ref()).await.unwrap_or(0); diff --git a/src/config.rs b/src/config.rs index 5096a17..fa8b7e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -31,6 +31,9 @@ pub struct Config { pub checkout_service_key: Option, pub webauthn_rp_id: String, pub webauthn_rp_origin: String, + /// Referral program (codes, applying a code, billing summary). Off unless + /// REFERRALS_ENABLED=true; fee-policy tables and migrations run either way. + pub referrals_enabled: bool, } impl Config { @@ -40,6 +43,7 @@ impl Config { .or_else(|_| env::var("CIPHERSCAN_API_URL")) .unwrap_or_else(|_| default_explorer_url(&network).into()); Ok(Self { + referrals_enabled: flag_enabled(env::var("REFERRALS_ENABLED").ok().as_deref()), database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:cipherpay.db".into()), cipherscan_api_url: api_url.trim_end_matches('/').to_string(), network, @@ -153,3 +157,21 @@ mod tests { assert_eq!(default_explorer_url("mainnet"), "https://api.zecblock.com"); } } + +fn flag_enabled(value: Option<&str>) -> bool { + matches!(value.map(str::trim), Some("1" | "true" | "TRUE" | "yes")) +} + +#[cfg(test)] +mod flag_tests { + use super::flag_enabled; + + #[test] + fn referrals_flag_defaults_off() { + assert!(!flag_enabled(None)); + assert!(!flag_enabled(Some(""))); + assert!(!flag_enabled(Some("false"))); + assert!(flag_enabled(Some("true"))); + assert!(flag_enabled(Some(" 1 "))); + } +} diff --git a/src/referrals.rs b/src/referrals.rs new file mode 100644 index 0000000..eea5120 --- /dev/null +++ b/src/referrals.rs @@ -0,0 +1,554 @@ +//! Referral earnings are a share of collected revenue, in the asset received. +//! No automatic payout, FX conversion, or credit against unrelated assets. +use actix_web::{web, HttpRequest, HttpResponse}; +use anyhow::{ensure, Context}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::SqlitePool; +pub mod guardrails; + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + sqlx::raw_sql("CREATE TABLE referral_codes ( + merchant_id TEXT PRIMARY KEY REFERENCES merchants(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE); + CREATE TABLE merchant_referrals ( + merchant_id TEXT PRIMARY KEY REFERENCES merchants(id), + referrer_id TEXT NOT NULL REFERENCES merchants(id), + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + discount_until TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now','+3 months')), + earning_until TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now','+12 months')), + CHECK (merchant_id != referrer_id)); + CREATE TABLE invoice_fee_policy ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + service_bps INTEGER NOT NULL CHECK(service_bps BETWEEN 0 AND 1000), + referrer_id TEXT REFERENCES merchants(id)); + CREATE TRIGGER invoice_fee_policy_snapshot AFTER INSERT ON invoices BEGIN + INSERT INTO invoice_fee_policy(invoice_id,service_bps,referrer_id) + SELECT NEW.id, + MIN(1000,MAX(0,CAST(ROUND(COALESCE(m.fee_rate,0.01)*10000) AS INTEGER))), + CASE WHEN r.earning_until>NEW.created_at THEN r.referrer_id END + FROM merchants m LEFT JOIN merchant_referrals r ON r.merchant_id=m.id WHERE m.id=NEW.merchant_id; + UPDATE invoice_fee_policy SET service_bps=MIN(service_bps,50) + WHERE invoice_id=NEW.id AND EXISTS(SELECT 1 FROM merchant_referrals WHERE merchant_id=NEW.merchant_id AND discount_until>NEW.created_at); + END; + CREATE TABLE referral_earnings ( + receipt_id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL REFERENCES invoices(id), + referrer_id TEXT NOT NULL REFERENCES merchants(id), + asset_id TEXT NOT NULL, collected_units TEXT NOT NULL, reward_units TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))); + ALTER TABLE fee_ledger ADD COLUMN collected_zatoshis INTEGER NOT NULL DEFAULT 0; + UPDATE fee_ledger SET collected_zatoshis=fee_amount_zatoshis WHERE auto_collected=1; + CREATE TABLE direct_fee_checks ( + invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + txid TEXT NOT NULL, next_check INTEGER NOT NULL, PRIMARY KEY(invoice_id,txid)); + CREATE TABLE direct_fee_receipts ( + invoice_id TEXT NOT NULL REFERENCES invoices(id), txid TEXT NOT NULL, + units INTEGER NOT NULL CHECK(units>0), PRIMARY KEY(invoice_id,txid));") + .execute(&mut *tx).await?; + // Existing quotes retain their stored fee policy; never retroactively award. + sqlx::query( + "INSERT INTO invoice_fee_policy(invoice_id,service_bps) SELECT id,100 FROM invoices", + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +pub async fn service_bps(pool: &SqlitePool, invoice: &str) -> anyhow::Result { + let bps: i64 = + sqlx::query_scalar("SELECT service_bps FROM invoice_fee_policy WHERE invoice_id=?") + .bind(invoice) + .fetch_one(pool) + .await?; + Ok(bps as u64) +} + +pub async fn accrue( + pool: &SqlitePool, + receipt: &str, + invoice: &str, + asset: &str, + units: u128, +) -> anyhow::Result<()> { + let mut conn = pool.acquire().await?; + accrue_on(&mut conn, receipt, invoice, asset, units).await +} + +async fn accrue_on( + conn: &mut sqlx::SqliteConnection, + receipt: &str, + invoice: &str, + asset: &str, + units: u128, +) -> anyhow::Result<()> { + ensure!(units > 0, "Empty receipt"); + let referrer: Option = sqlx::query_scalar( + "SELECT p.referrer_id FROM invoice_fee_policy p JOIN invoices i ON i.id=p.invoice_id + WHERE p.invoice_id=? AND i.status='confirmed'", + ) + .bind(invoice) + .fetch_optional(&mut *conn) + .await? + .flatten(); + let Some(referrer) = referrer else { + return Ok(()); + }; + // Round down in smallest asset units. Replays never mint another reward. + sqlx::query("INSERT INTO referral_earnings(receipt_id,invoice_id,referrer_id,asset_id,collected_units,reward_units) VALUES (?,?,?,?,?,?) ON CONFLICT(receipt_id) DO NOTHING") + .bind(receipt).bind(invoice).bind(referrer).bind(asset).bind(units.to_string()).bind((units/2).to_string()).execute(&mut *conn).await?; + let stored: (String, String, String) = sqlx::query_as( + "SELECT invoice_id,asset_id,collected_units FROM referral_earnings WHERE receipt_id=?", + ) + .bind(receipt) + .fetch_one(&mut *conn) + .await?; + ensure!( + stored == (invoice.to_string(), asset.to_string(), units.to_string()), + "Receipt attribution changed" + ); + Ok(()) +} + +/// Keep earned commissions even when their referred merchant closes an account. +/// Called under the same write transaction as deletion so receipt reconciliation +/// cannot race deletion and lose a referrer's earnings. +pub(crate) async fn before_delete( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + merchant: &str, +) -> anyhow::Result<()> { + guardrails::check_deletion(tx, merchant).await?; + guardrails::remember(tx, merchant).await?; + let swaps: Vec<(String,String,String)> = sqlx::query_as("SELECT f.invoice_id,f.asset_id,f.received_units FROM crosschain_fees f JOIN invoices i ON i.id=f.invoice_id WHERE i.merchant_id=? AND i.status='confirmed' AND f.state='collected'") + .bind(merchant).fetch_all(&mut **tx).await?; + for (id, asset, units) in swaps { + accrue_on( + &mut **tx, + &format!("swap:{id}"), + &id, + &asset, + units.parse()?, + ) + .await?; + } + let direct: Vec<(String,String,i64)> = sqlx::query_as("SELECT r.invoice_id,r.txid,r.units FROM direct_fee_receipts r JOIN invoices i ON i.id=r.invoice_id WHERE i.merchant_id=? AND i.status='confirmed'") + .bind(merchant).fetch_all(&mut **tx).await?; + for (id, txid, units) in direct { + accrue_on( + &mut **tx, + &format!("zec:{id}:{txid}"), + &id, + "zec", + units as u128, + ) + .await?; + } + Ok(()) +} + +/// Replay actual receipts after confirmation, including after process restarts. +pub async fn reconcile(pool: &SqlitePool) -> anyhow::Result<()> { + let cross: Vec<(String,String,String)> = sqlx::query_as("SELECT f.invoice_id,f.asset_id,f.received_units FROM crosschain_fees f JOIN invoices i ON i.id=f.invoice_id JOIN invoice_fee_policy p ON p.invoice_id=i.id + WHERE f.state='collected' AND i.status='confirmed' AND p.referrer_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM referral_earnings e WHERE e.receipt_id='swap:'||i.id) LIMIT 100") + .fetch_all(pool).await?; + for (id, asset, units) in cross { + accrue(pool, &format!("swap:{id}"), &id, &asset, units.parse()?).await?; + } + let direct: Vec<(String,String,i64)> = sqlx::query_as("SELECT r.invoice_id,r.txid,r.units FROM direct_fee_receipts r JOIN invoices i ON i.id=r.invoice_id JOIN invoice_fee_policy p ON p.invoice_id=i.id + WHERE i.status='confirmed' AND p.referrer_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM referral_earnings e WHERE e.receipt_id='zec:'||r.invoice_id||':'||r.txid) LIMIT 100") + .fetch_all(pool).await?; + for (id, txid, units) in direct { + accrue(pool, &format!("zec:{id}:{txid}"), &id, "zec", units as u128).await?; + } + Ok(()) +} + +pub async fn summary(pool: &SqlitePool, merchant: &str, key: &str) -> anyhow::Result { + sqlx::query("INSERT INTO referral_codes(merchant_id,code) VALUES (?,?) ON CONFLICT(merchant_id) DO NOTHING") + .bind(merchant).bind(format!("CPREF-{}",hex::encode(rand::random::<[u8;8]>()).to_uppercase())).execute(pool).await?; + let code: String = sqlx::query_scalar("SELECT code FROM referral_codes WHERE merchant_id=?") + .bind(merchant) + .fetch_one(pool) + .await?; + let applied: Option<(String, String)> = sqlx::query_as( + "SELECT discount_until,earning_until FROM merchant_referrals WHERE merchant_id=?", + ) + .bind(merchant) + .fetch_optional(pool) + .await?; + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM merchant_referrals WHERE referrer_id=?") + .bind(merchant) + .fetch_one(pool) + .await?; + let rows: Vec<(String, String)> = + sqlx::query_as("SELECT asset_id,reward_units FROM referral_earnings WHERE referrer_id=?") + .bind(merchant) + .fetch_all(pool) + .await?; + let mut totals = std::collections::BTreeMap::::new(); + for (asset, units) in rows { + let v = totals.entry(asset).or_default(); + *v = v + .checked_add(units.parse()?) + .context("Referral balance overflow")?; + } + let mut balances = Vec::new(); + for (asset, units) in totals { + let payload: Option=sqlx::query_scalar("SELECT a.payload FROM crosschain_attempts a JOIN crosschain_fees f ON f.invoice_id=a.invoice_id JOIN referral_earnings e ON e.invoice_id=a.invoice_id WHERE f.asset_id=? AND e.referrer_id=? LIMIT 1") + .bind(&asset).bind(merchant).fetch_optional(pool).await?.flatten(); + let metadata = payload + .and_then(|v| crate::crypto::decrypt(&v, key).ok()) + .and_then(|v| serde_json::from_str::(&v).ok()) + .map(|v| v["asset"].clone()); + balances.push(json!({"asset_id":asset,"units":units.to_string(),"asset":metadata})); + } + Ok( + json!({"code":code,"referred_merchants":count,"applied":applied.is_some(),"discount_until":applied.map(|v|v.0), + "reward_bps":5000,"payouts_enabled":false, + "balances":balances}), + ) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Apply { + code: String, +} +pub async fn apply( + req: HttpRequest, + pool: web::Data, + config: web::Data, + input: web::Json, +) -> HttpResponse { + if !config.referrals_enabled { + return HttpResponse::NotFound().json(json!({"error":"Referral program is not available"})); + } + let merchant = match crate::api::auth::require_full_session(&req, &pool).await { + Ok(m) => m, + Err(r) => return r, + }; + match apply_code(&pool, &merchant.id, input.code.trim()).await { + Ok(()) => HttpResponse::Ok().json(json!({"ok":true})), + Err(e) => HttpResponse::BadRequest().json(json!({"error":e.to_string()})), + } +} +async fn apply_code(pool: &SqlitePool, merchant: &str, code: &str) -> anyhow::Result<()> { + ensure!(code.len() <= 40, "Invalid referral code"); + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let referrer: String = + sqlx::query_scalar("SELECT merchant_id FROM referral_codes WHERE code=?") + .bind(code.to_ascii_uppercase()) + .fetch_optional(&mut *tx) + .await? + .context("Referral code not found")?; + ensure!(merchant != referrer, "You cannot refer yourself"); + // Also reject shared merchant receivers; wallet/account uniqueness alone is + // not Sybil resistance. Economics remain bounded by revenue actually earned. + let shared: bool=sqlx::query_scalar("SELECT a.payment_address=b.payment_address FROM merchants a,merchants b WHERE a.id=? AND b.id=?") + .bind(merchant).bind(&referrer).fetch_one(&mut *tx).await?; + ensure!(!shared, "A referral must be from a different merchant"); + guardrails::check(&mut tx, merchant, &referrer).await?; + let inserted=sqlx::query("INSERT INTO merchant_referrals(merchant_id,referrer_id) VALUES (?,?) ON CONFLICT(merchant_id) DO NOTHING") + .bind(merchant).bind(referrer).execute(&mut *tx).await?.rows_affected(); + ensure!(inserted == 1, "A referral has already been applied"); + guardrails::remember(&mut tx, merchant).await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repair_tests::{invoice, merchant, pool}; + async fn fixture() -> SqlitePool { + let p = pool().await; + for m in ["referrer", "buyer", "other"] { + merchant(&p, m).await; + sqlx::query("UPDATE merchants SET payment_address=? WHERE id=?") + .bind(format!("address-{m}")) + .bind(m) + .execute(&p) + .await + .unwrap(); + } + sqlx::query("INSERT INTO referral_codes VALUES ('referrer','CPREF-TEST')") + .execute(&p) + .await + .unwrap(); + p + } + #[tokio::test] + async fn discount_and_earnings_are_forward_only_and_snapshotted() { + let p = fixture().await; + invoice(&p, "before", "buyer").await; + apply_code(&p, "buyer", "CPREF-TEST").await.unwrap(); + invoice(&p, "during", "buyer").await; + assert_eq!(service_bps(&p, "before").await.unwrap(), 100); + assert_eq!(service_bps(&p, "during").await.unwrap(), 50); + sqlx::query("UPDATE merchant_referrals SET discount_until='2000-01-01T00:00:00Z'") + .execute(&p) + .await + .unwrap(); + invoice(&p, "after-discount", "buyer").await; + assert_eq!(service_bps(&p, "after-discount").await.unwrap(), 100); + assert_eq!(service_bps(&p, "during").await.unwrap(), 50); + sqlx::query("UPDATE merchant_referrals SET earning_until='2000-01-01T00:00:00Z'") + .execute(&p) + .await + .unwrap(); + invoice(&p, "after-program", "buyer").await; + let referrer: Option = sqlx::query_scalar( + "SELECT referrer_id FROM invoice_fee_policy WHERE invoice_id='after-program'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert!(referrer.is_none()); + } + #[tokio::test] + async fn receipt_replays_cannot_mint_rewards_or_mix_assets() { + let p = fixture().await; + apply_code(&p, "buyer", "CPREF-TEST").await.unwrap(); + invoice(&p, "one", "buyer").await; + accrue(&p, "receipt", "one", "usdc-base", 69581) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM referral_earnings") + .fetch_one(&p) + .await + .unwrap(), + 0 + ); + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='one'") + .execute(&p) + .await + .unwrap(); + for _ in 0..3 { + accrue(&p, "receipt", "one", "usdc-base", 69581) + .await + .unwrap(); + } + assert!(accrue(&p, "receipt", "one", "usdc-sol", 69581) + .await + .is_err()); + accrue(&p, "zec-receipt", "one", "zec", 101).await.unwrap(); + let balances = summary(&p, "referrer", "").await.unwrap(); + assert_eq!(balances["balances"][0]["units"], "34790"); + assert_eq!(balances["balances"][1]["units"], "50"); + assert!(summary(&p, "other", "").await.unwrap()["balances"] + .as_array() + .unwrap() + .is_empty()); + } + #[tokio::test] + async fn apply_is_once_only_and_rejects_self_and_shared_receiver() { + let p = fixture().await; + assert!(apply_code(&p, "referrer", "CPREF-TEST").await.is_err()); + sqlx::query("UPDATE merchants SET payment_address='address-referrer' WHERE id='other'") + .execute(&p) + .await + .unwrap(); + assert!(apply_code(&p, "other", "CPREF-TEST").await.is_err()); + let (a, b) = tokio::join!( + apply_code(&p, "buyer", "CPREF-TEST"), + apply_code(&p, "buyer", "CPREF-TEST") + ); + assert_eq!(u8::from(a.is_ok()) + u8::from(b.is_ok()), 1); + } + + #[tokio::test] + async fn deleting_and_recreating_a_wallet_does_not_restart_referrals_or_erase_rewards() { + let p = fixture().await; + apply_code(&p, "buyer", "CPREF-TEST").await.unwrap(); + invoice(&p, "sale", "buyer").await; + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='sale'") + .execute(&p) + .await + .unwrap(); + // The receipt arrives just before deletion, before the normal reward worker. + sqlx::query("INSERT INTO direct_fee_receipts VALUES ('sale','fee-tx',1000)") + .execute(&p) + .await + .unwrap(); + sqlx::query("UPDATE merchant_referrals SET discount_until='2000-01-01T00:00:00Z',earning_until='2001-01-01T00:00:00Z' WHERE merchant_id='buyer'").execute(&p).await.unwrap(); + crate::merchants::delete_merchant(&p, "buyer") + .await + .unwrap(); + assert_eq!( + summary(&p, "referrer", "").await.unwrap()["balances"][0]["units"], + "500" + ); + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&p) + .await + .unwrap(); + assert!(violations.is_empty()); + merchant(&p, "replacement").await; + sqlx::query("UPDATE merchants SET payment_address='address-buyer' WHERE id='replacement'") + .execute(&p) + .await + .unwrap(); + assert!(apply_code(&p, "replacement", "CPREF-TEST") + .await + .unwrap_err() + .to_string() + .contains("already used")); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM merchant_referrals WHERE merchant_id='replacement'" + ) + .fetch_one(&p) + .await + .unwrap(), + 0 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM referral_wallet_history") + .fetch_one(&p) + .await + .unwrap(), + 1 + ); + crate::merchants::delete_merchant(&p, "referrer") + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM referral_earnings") + .fetch_one(&p) + .await + .unwrap(), + 1 + ); + } + + #[tokio::test] + async fn referral_loops_and_concurrent_wallet_aliases_are_rejected() { + let p = fixture().await; + apply_code(&p, "buyer", "CPREF-TEST").await.unwrap(); + let buyer_code = summary(&p, "buyer", "").await.unwrap()["code"] + .as_str() + .unwrap() + .to_owned(); + apply_code(&p, "other", &buyer_code).await.unwrap(); + let other_code = summary(&p, "other", "").await.unwrap()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(apply_code(&p, "referrer", &buyer_code) + .await + .unwrap_err() + .to_string() + .contains("loop")); + assert!(apply_code(&p, "referrer", &other_code) + .await + .unwrap_err() + .to_string() + .contains("loop")); + for id in ["alias-a", "alias-b"] { + merchant(&p, id).await; + } + let (a, b) = tokio::join!( + apply_code(&p, "alias-a", "CPREF-TEST"), + apply_code(&p, "alias-b", "CPREF-TEST") + ); + assert_eq!(u8::from(a.is_ok()) + u8::from(b.is_ok()), 1); + } + + #[tokio::test] + async fn account_closure_waits_for_fee_verification_and_actual_collection() { + let p = fixture().await; + apply_code(&p, "buyer", "CPREF-TEST").await.unwrap(); + invoice(&p, "sale", "buyer").await; + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='sale'") + .execute(&p) + .await + .unwrap(); + sqlx::query("INSERT INTO deferred_payment_fees(invoice_id,merchant_id,amount_zec,rate) VALUES ('sale','buyer',0.001,0.01)").execute(&p).await.unwrap(); + assert!(crate::merchants::delete_merchant(&p, "buyer") + .await + .unwrap_err() + .downcast_ref::() + .is_some()); + sqlx::query("UPDATE deferred_payment_fees SET state='direct' WHERE invoice_id='sale'") + .execute(&p) + .await + .unwrap(); + crate::billing::create_fee_entry(&p, "sale", "buyer", 0.001, 0.01) + .await + .unwrap(); + assert!(crate::merchants::delete_merchant(&p, "buyer") + .await + .unwrap_err() + .downcast_ref::() + .is_some()); + crate::billing::record_direct_receipt(&p, "sale", "fee-tx", 100000) + .await + .unwrap(); + crate::merchants::delete_merchant(&p, "buyer") + .await + .unwrap(); + assert_eq!( + summary(&p, "referrer", "").await.unwrap()["balances"][0]["units"], + "50000" + ); + } +} + +#[cfg(test)] +mod settlement_tests { + use super::*; + #[tokio::test] + async fn postpaid_rewards_wait_for_real_settlement_and_cannot_replay() { + let p = crate::repair_tests::pool().await; + for id in ["merchant", "referrer"] { + crate::repair_tests::merchant(&p, id).await; + } + sqlx::query("INSERT INTO merchant_referrals(merchant_id,referrer_id) VALUES ('merchant','referrer')").execute(&p).await.unwrap(); + crate::repair_tests::invoice(&p, "sale", "merchant").await; + sqlx::query("UPDATE invoices SET status='confirmed' WHERE id='sale'") + .execute(&p) + .await + .unwrap(); + crate::repair_tests::invoice(&p, "bill", "merchant").await; + sqlx::query("INSERT INTO billing_cycles(id,merchant_id,period_start,period_end,status) VALUES ('cycle','merchant','2026-01-01','2026-02-01','open')").execute(&p).await.unwrap(); + crate::billing::create_fee_entry(&p, "sale", "merchant", 0.001, 0.01) + .await + .unwrap(); + sqlx::query("UPDATE billing_cycles SET status='invoiced',settlement_invoice_id='bill' WHERE id='cycle'").execute(&p).await.unwrap(); + reconcile(&p).await.unwrap(); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM referral_earnings") + .fetch_one(&p) + .await + .unwrap(), + 0 + ); + sqlx::query("UPDATE invoices SET status='confirmed',price_zatoshis=100000,received_zatoshis=100000 WHERE id='bill'").execute(&p).await.unwrap(); + let config = crate::config::Config::from_env().unwrap(); + for _ in 0..2 { + crate::billing::check_settlement_payments(&p, &config) + .await + .unwrap(); + } + let reward: String = sqlx::query_scalar( + "SELECT reward_units FROM referral_earnings WHERE invoice_id='sale'", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!(reward, "50000"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM referral_earnings") + .fetch_one(&p) + .await + .unwrap(), + 1 + ); + } +} diff --git a/src/referrals/guardrails.rs b/src/referrals/guardrails.rs new file mode 100644 index 0000000..cd6279c --- /dev/null +++ b/src/referrals/guardrails.rs @@ -0,0 +1,215 @@ +//! Minimal repeat-referral protection, independent of account deletion. +//! Wallet fingerprints identify a known receiver, not a person or business. +use anyhow::{ensure, Context}; +use sqlx::{Sqlite, SqlitePool, Transaction}; + +#[derive(Debug)] +pub struct PendingAccountFees; +impl std::fmt::Display for PendingAccountFees { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Settle outstanding fees and wait for pending payment fees to be verified before deleting your account.") + } +} +impl std::error::Error for PendingAccountFees {} + +pub(crate) async fn check_deletion( + tx: &mut Transaction<'_, Sqlite>, + merchant: &str, +) -> anyhow::Result<()> { + let pending: bool = sqlx::query_scalar("SELECT + EXISTS(SELECT 1 FROM fee_ledger f LEFT JOIN billing_cycles b ON b.id=f.billing_cycle_id + WHERE f.merchant_id=? AND f.fee_amount_zatoshis>f.collected_zatoshis AND COALESCE(b.status,'')!='paid') + OR EXISTS(SELECT 1 FROM deferred_payment_fees WHERE merchant_id=? AND state='verifying') + OR EXISTS(SELECT 1 FROM crosschain_fees f JOIN invoices i ON i.id=f.invoice_id + WHERE i.merchant_id=? AND f.state NOT IN ('collected','direct','provider_handled') AND i.status NOT IN ('expired','cancelled','refunded'))") + .bind(merchant).bind(merchant).bind(merchant).fetch_one(&mut **tx).await?; + if pending { + return Err(PendingAccountFees.into()); + } + Ok(()) +} + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let trigger: String = sqlx::query_scalar( + "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='invoice_fee_policy_snapshot'", + ) + .fetch_one(&mut *tx) + .await?; + // Historical actor/invoice IDs in the reward ledger must survive account + // deletion. They confer no authentication rights and contain no wallet data. + sqlx::raw_sql( + "DROP TRIGGER invoice_fee_policy_snapshot; + CREATE TABLE merchant_referrals_new ( + merchant_id TEXT PRIMARY KEY REFERENCES merchants(id) ON DELETE CASCADE, + referrer_id TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + discount_until TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now','+3 months')), + earning_until TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now','+12 months')), + CHECK (merchant_id != referrer_id)); + INSERT INTO merchant_referrals_new SELECT * FROM merchant_referrals; + DROP TABLE merchant_referrals; + ALTER TABLE merchant_referrals_new RENAME TO merchant_referrals; + CREATE TABLE invoice_fee_policy_new ( + invoice_id TEXT PRIMARY KEY REFERENCES invoices(id) ON DELETE CASCADE, + service_bps INTEGER NOT NULL CHECK(service_bps BETWEEN 0 AND 1000), + referrer_id TEXT); + INSERT INTO invoice_fee_policy_new SELECT * FROM invoice_fee_policy; + DROP TABLE invoice_fee_policy; + ALTER TABLE invoice_fee_policy_new RENAME TO invoice_fee_policy; + CREATE TABLE referral_earnings_new ( + receipt_id TEXT PRIMARY KEY, invoice_id TEXT NOT NULL, + referrer_id TEXT NOT NULL, asset_id TEXT NOT NULL, + collected_units TEXT NOT NULL, reward_units TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))); + INSERT INTO referral_earnings_new SELECT * FROM referral_earnings; + DROP TABLE referral_earnings; + ALTER TABLE referral_earnings_new RENAME TO referral_earnings; + CREATE INDEX referral_earnings_owner ON referral_earnings(referrer_id); + CREATE TABLE direct_fee_receipts_new ( + invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + txid TEXT NOT NULL, units INTEGER NOT NULL CHECK(units>0), + PRIMARY KEY(invoice_id,txid)); + INSERT INTO direct_fee_receipts_new SELECT * FROM direct_fee_receipts; + DROP TABLE direct_fee_receipts; + ALTER TABLE direct_fee_receipts_new RENAME TO direct_fee_receipts; + CREATE TABLE referral_wallet_history ( + fingerprint TEXT PRIMARY KEY, merchant_id TEXT NOT NULL, + referrer_id TEXT NOT NULL, applied_at TEXT NOT NULL, + discount_until TEXT NOT NULL, earning_until TEXT NOT NULL);", + ) + .execute(&mut *tx) + .await?; + sqlx::query(&trigger).execute(&mut *tx).await?; + let existing: Vec = sqlx::query_scalar("SELECT merchant_id FROM merchant_referrals") + .fetch_all(&mut *tx) + .await?; + for merchant in existing { + remember(&mut tx, &merchant).await?; + } + tx.commit().await?; + Ok(()) +} + +fn fingerprint(address: &str) -> String { + crate::merchants::hash_key(&format!("cipherpay:referral-wallet:v1:{address}")) +} + +pub(super) async fn check( + tx: &mut Transaction<'_, Sqlite>, + merchant: &str, + referrer: &str, +) -> anyhow::Result<()> { + let address: String = sqlx::query_scalar("SELECT payment_address FROM merchants WHERE id=?") + .bind(merchant) + .fetch_one(&mut **tx) + .await?; + let used: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM referral_wallet_history WHERE fingerprint=?)", + ) + .bind(fingerprint(&address)) + .fetch_one(&mut **tx) + .await?; + ensure!(!used, "This wallet has already used a referral. Creating another account does not restart the offer."); + let cycle: bool = sqlx::query_scalar("WITH RECURSIVE parents(id) AS ( + SELECT ? UNION SELECT r.referrer_id FROM merchant_referrals r JOIN parents p ON r.merchant_id=p.id + ) SELECT EXISTS(SELECT 1 FROM parents WHERE id=?)") + .bind(referrer).bind(merchant).fetch_one(&mut **tx).await?; + ensure!( + !cycle, + "Merchants cannot refer each other in a referral loop." + ); + Ok(()) +} + +/// Remember both the receiver at application and, defensively, at deletion. +/// Only a one-way fingerprint and original eligibility dates are retained. +pub(crate) async fn remember( + tx: &mut Transaction<'_, Sqlite>, + merchant: &str, +) -> anyhow::Result<()> { + let row: Option<(String, String, String, String, String)> = sqlx::query_as( + "SELECT m.payment_address,r.referrer_id,r.applied_at,r.discount_until,r.earning_until + FROM merchants m JOIN merchant_referrals r ON r.merchant_id=m.id WHERE m.id=?", + ) + .bind(merchant) + .fetch_optional(&mut **tx) + .await?; + if let Some((address, referrer, applied, discount, earning)) = row { + ensure!(!address.is_empty(), "Missing referral wallet"); + sqlx::query("INSERT INTO referral_wallet_history VALUES (?,?,?,?,?,?) ON CONFLICT(fingerprint) DO NOTHING") + .bind(fingerprint(&address)).bind(merchant).bind(referrer) + .bind(applied).bind(discount).bind(earning) + .execute(&mut **tx).await.context("Could not retain referral eligibility")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn migration_preserves_existing_dates_rewards_and_fee_receipts() { + let p = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::raw_sql( + "PRAGMA foreign_keys=ON; + CREATE TABLE merchants(id TEXT PRIMARY KEY,payment_address TEXT,fee_rate REAL); + CREATE TABLE invoices(id TEXT PRIMARY KEY,merchant_id TEXT,created_at TEXT,status TEXT); + CREATE TABLE fee_ledger(auto_collected INTEGER,fee_amount_zatoshis INTEGER); + INSERT INTO merchants VALUES ('a','wallet-a',NULL),('b','wallet-b',NULL);", + ) + .execute(&p) + .await + .unwrap(); + crate::referrals::migrate(&p).await.unwrap(); + sqlx::raw_sql("INSERT INTO merchant_referrals VALUES ('b','a','2026-01-01T00:00:00Z','2026-04-01T00:00:00Z','2027-01-01T00:00:00Z'); + INSERT INTO invoices VALUES ('i','b','2026-02-01T00:00:00Z','confirmed'); + INSERT INTO referral_earnings VALUES ('r','i','a','zec','100','50','2026-02-02T00:00:00Z'); + INSERT INTO direct_fee_receipts VALUES ('i','tx',100);") + .execute(&p).await.unwrap(); + migrate(&p).await.unwrap(); + assert_eq!(crate::referrals::service_bps(&p, "i").await.unwrap(), 50); + let row: (String, String, String) = sqlx::query_as( + "SELECT applied_at,discount_until,earning_until FROM referral_wallet_history", + ) + .fetch_one(&p) + .await + .unwrap(); + assert_eq!( + row, + ( + "2026-01-01T00:00:00Z".into(), + "2026-04-01T00:00:00Z".into(), + "2027-01-01T00:00:00Z".into() + ) + ); + sqlx::query("DELETE FROM invoices WHERE id='i'") + .execute(&p) + .await + .unwrap(); + sqlx::query("DELETE FROM merchants") + .execute(&p) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar::<_, String>("SELECT reward_units FROM referral_earnings") + .fetch_one(&p) + .await + .unwrap(), + "50" + ); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM direct_fee_receipts") + .fetch_one(&p) + .await + .unwrap(), + 0 + ); + assert!(sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&p) + .await + .unwrap() + .is_empty()); + } +}