diff --git a/.github/workflows/ci-abi-metadata.yml b/.github/workflows/ci-abi-metadata.yml index a505b80..ea6e786 100644 --- a/.github/workflows/ci-abi-metadata.yml +++ b/.github/workflows/ci-abi-metadata.yml @@ -39,14 +39,5 @@ jobs: with: python-version: '3.12' - - name: Regenerate ABI metadata - run: bash scripts/generate_abi_metadata.sh abis - - - name: Fail if ABI snapshots are stale - run: | - if ! git diff --exit-code abis/; then - echo "" - echo "ERROR: ABI snapshots in abis/ are out of date." - echo "Run 'make update-abi-snapshots' locally and commit the updated files." - exit 1 - fi + - name: Verify ABI snapshots are up to date + run: bash scripts/generate_abi_metadata.sh --check diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8a7413..65d7f18 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,9 +64,38 @@ A working clone for end-to-end development looks like this (see `docs/dev-enviro When working inside this repository alone, the in-tree `COMEBACKHERE-contracts/` checkout acts as the canonical contracts tree; the sibling-clone step is optional for backend/frontend contributors. +## Invoice state machine + +The invoice contract (`COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`, mirrored at `contracts/invoice/src/lib.rs`) is the source of truth every other layer reads from: backend status displays, the indexer, and both frontend apps all ultimately derive their view of an invoice from this state machine. The diagram below shows every `InvoiceStatus` value and the function whose call transitions an invoice into it. Any transition not shown here is illegal and the calling function returns an `InvalidStateTransition` / `NotPending` style error (see [docs/error-codes.md](docs/error-codes.md) for the exact variant and code per contract). + +```mermaid +stateDiagram-v2 + [*] --> Pending: create_invoice + + Pending --> Paid: mark_paids / pay_invoice + Pending --> Expired: batch_expire + Pending --> Cancelled: cancel_invoiced / cancel_invoice + + Paid --> RefundRequested: request_refund + Paid --> RefundRequested: cancel_invoiced / cancel_invoice + + RefundRequested --> Released: release_escrow (after grace window) + + Expired --> [*] + Cancelled --> [*] + Released --> [*] +``` + +Notes on edges that are deliberately absent from this diagram: + +- **`Paid` is never re-entered.** Once an invoice leaves `Pending`, no function transitions it back to `Paid`. In particular, `mark_paids` guards against being called on an invoice that is `RefundRequested`, `Released`, `Cancelled`, or `Expired`, so a stale or replayed payment confirmation can never silently override a refund already in progress. +- **`RefundRequested`, `Released`, `Cancelled`, and `Expired` are terminal with respect to payment and cancellation** — `cancel_invoiced`, `request_refund`, and `mark_paids` all reject calls made once an invoice has reached one of these states. +- **`release_escrow` is time-gated**, not just state-gated: it additionally requires `ledger.timestamp() >= invoice.created_at + grace_window`. + ## Further reading - [docs/dev-environment.md](docs/dev-environment.md) — full local setup. - [docs/abi-snapshot-workflow.md](docs/abi-snapshot-workflow.md) — when and how to regenerate `abis/`. +- [docs/adr-0001-dual-source-trees.md](docs/adr-0001-dual-source-trees.md) — why the dual source trees exist and the plan to remove them. - [docs/error-codes.md](docs/error-codes.md) — contract error enums and their meanings. - [SECURITY.md](SECURITY.md) — which paths handle fund-safety-critical code. diff --git a/COMEBACKHERE-contracts/Cargo.lock b/COMEBACKHERE-contracts/Cargo.lock index 456241e..f9cc2b5 100644 --- a/COMEBACKHERE-contracts/Cargo.lock +++ b/COMEBACKHERE-contracts/Cargo.lock @@ -3,20 +3,17 @@ version = 4 [[package]] -name = "addr2line" -version = "0.21.0" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "gimli", + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.8.56", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -36,37 +33,134 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.5.1" +name = "ark-bls12-381" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] [[package]] -name = "backtrace" -version = "0.3.69" +name = "ark-ec" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "ark-ff" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest 0.10.7", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] -name = "base32" +name = "ark-std" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -116,6 +210,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -137,7 +240,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -177,14 +280,14 @@ dependencies = [ [[package]] name = "comebackhere-invoice" -version = "1.0.0" +version = "1.1.0" dependencies = [ "soroban-sdk", ] [[package]] name = "comebackhere-treasury" -version = "1.0.0" +version = "1.1.0" dependencies = [ "proptest", "soroban-sdk", @@ -211,6 +314,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crate-git-revision" version = "0.0.6" @@ -244,6 +356,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "0.2.9" @@ -251,7 +372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -261,16 +382,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a677b8922c94e01bdbb12126b0bc852f00447528dee1782229af9c720c3f348" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "platforms", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -279,7 +415,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -303,7 +439,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -314,9 +450,15 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.7.10" @@ -337,6 +479,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -345,7 +498,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -354,12 +507,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -373,11 +536,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", - "spki", ] [[package]] @@ -392,15 +554,16 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.2", "ed25519", "rand_core 0.6.4", "serde", "sha2", + "subtle", "zeroize", ] @@ -418,11 +581,10 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", - "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -479,6 +641,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -562,12 +730,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "gimli" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" - [[package]] name = "group" version = "0.13.0" @@ -585,6 +747,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -612,7 +783,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", ] [[package]] @@ -675,9 +855,9 @@ checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" [[package]] name = "itertools" -version = "0.11.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -701,16 +881,14 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", - "once_cell", "sha2", - "signature", ] [[package]] @@ -719,7 +897,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -746,21 +924,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "miniz_oxide" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] - [[package]] name = "num-bigint" version = "0.4.4" @@ -786,7 +949,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -808,21 +971,24 @@ dependencies = [ "autocfg", ] -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "paste" version = "1.0.15" @@ -863,7 +1029,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -873,14 +1039,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -912,9 +1087,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.33" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1015,12 +1190,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - [[package]] name = "rustc_version" version = "0.4.1" @@ -1076,7 +1245,6 @@ dependencies = [ "base16ct", "der", "generic-array", - "pkcs8", "subtle", "zeroize", ] @@ -1104,7 +1272,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1145,7 +1313,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1155,8 +1323,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -1165,7 +1333,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -1181,7 +1349,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -1199,21 +1367,21 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "soroban-builtin-sdk-macros" -version = "20.3.0" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc32c6e817f3ca269764ec0d7d14da6210b74a5bf14d4e745aa3ee860558900" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "soroban-env-common" -version = "20.3.0" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c14e18d879c520ff82612eaae0590acaf6a7f3b977407e1abb1c9e31f94c7814" +checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" dependencies = [ "arbitrary", "crate-git-revision", @@ -1225,13 +1393,14 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", + "wasmparser", ] [[package]] name = "soroban-env-guest" -version = "20.3.0" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5122ca2abd5ebcc1e876a96b9b44f87ce0a0e06df8f7c09772ddb58b159b7454" +checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" dependencies = [ "soroban-env-common", "static_assertions", @@ -1239,13 +1408,19 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "20.3.0" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "114a0fa0d0cc39d0be16b1ee35b6e5f4ee0592ddcf459bde69391c02b03cf520" +checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" dependencies = [ - "backtrace", - "curve25519-dalek", + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek 5.0.0", + "ecdsa", "ed25519-dalek", + "elliptic-curve", + "generic-array", "getrandom 0.2.11", "hex-literal", "hmac", @@ -1253,8 +1428,10 @@ dependencies = [ "num-derive", "num-integer", "num-traits", + "p256", "rand 0.8.5", "rand_chacha 0.3.1", + "sec1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1262,13 +1439,14 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-strkey", + "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "20.3.0" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b13e3f8c86f812e0669e78fcb3eae40c385c6a9dd1a4886a1de733230b4fcf27" +checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" dependencies = [ "itertools", "proc-macro2", @@ -1276,14 +1454,14 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.119", ] [[package]] name = "soroban-ledger-snapshot" -version = "20.5.0" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a54708f44890e0546180db6b4f530e2a88d83b05a9b38a131caa21d005e25a" +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf" dependencies = [ "serde", "serde_json", @@ -1295,15 +1473,17 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "20.5.0" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fc8be9068dd4e0212d8b13ad61089ea87e69ac212c262914503a961c8dc3a3" +checksum = "ff18e8d7ca6d5340a211605ca2c86383bd4dfacc4f8253d72a1573974ffffe69" dependencies = [ "arbitrary", "bytes-lit", "ctor", + "derive_arbitrary", "ed25519-dalek", "rand 0.8.5", + "rustc_version", "serde", "serde_json", "soroban-env-guest", @@ -1315,9 +1495,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "20.5.0" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db20def4ead836663633f58d817d0ed8e1af052c9650a04adf730525af85b964" +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8" dependencies = [ "crate-git-revision", "darling", @@ -1330,14 +1510,14 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.119", ] [[package]] name = "soroban-spec" -version = "20.5.0" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eefeb5d373b43f6828145d00f0c5cc35e96db56a6671ae9614f84beb2711cab" +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea" dependencies = [ "base64 0.13.1", "stellar-xdr", @@ -1347,9 +1527,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "20.5.0" +version = "22.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3152bca4737ef734ac37fe47b225ee58765c9095970c481a18516a2b287c7a33" +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc" dependencies = [ "prettyplease", "proc-macro2", @@ -1357,7 +1537,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn", + "syn 2.0.119", "thiserror", ] @@ -1398,20 +1578,20 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stellar-strkey" -version = "0.0.8" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" dependencies = [ - "base32", "crate-git-revision", + "data-encoding", "thiserror", ] [[package]] name = "stellar-xdr" -version = "20.1.0" +version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59cdf3eb4467fb5a4b00b52e7de6dca72f67fac6f9b700f55c95a5d86f09c9d" +checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" dependencies = [ "arbitrary", "base64 0.13.1", @@ -1437,9 +1617,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.39" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1476,7 +1667,7 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1590,7 +1781,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1623,11 +1814,12 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.88.0" +version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8cf7dd82407fe68161bedcd57fde15596f32ebf6e9b3bdbf3ae1da20e38e5e" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 1.9.3", + "indexmap 2.11.1", + "semver", ] [[package]] @@ -1660,7 +1852,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1671,7 +1863,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1720,7 +1912,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive 0.8.56", ] [[package]] @@ -1731,7 +1932,18 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1739,3 +1951,17 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/COMEBACKHERE-contracts/Cargo.toml b/COMEBACKHERE-contracts/Cargo.toml index 88113f1..65d232e 100644 --- a/COMEBACKHERE-contracts/Cargo.toml +++ b/COMEBACKHERE-contracts/Cargo.toml @@ -3,4 +3,4 @@ members = ["contracts/*"] resolver = "2" [workspace.dependencies] -soroban-sdk = "20.0.0" +soroban-sdk = "22.0.0" diff --git a/COMEBACKHERE-contracts/contracts/compliance/Cargo.toml b/COMEBACKHERE-contracts/contracts/compliance/Cargo.toml index 3f59a7b..edad897 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/compliance/Cargo.toml @@ -10,10 +10,10 @@ crate-type = ["cdylib"] testutils = ["soroban-sdk/testutils"] [dependencies] -soroban-sdk = "20.0.0" +soroban-sdk = "22.0.0" [dev-dependencies] -soroban-sdk = { version = "20.0.0", features = ["testutils"] } +soroban-sdk = { version = "22.0.0", features = ["testutils"] } [profile.release] opt-level = "z" diff --git a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs index 53bc477..d213625 100644 --- a/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/compliance/src/lib.rs @@ -10,6 +10,8 @@ pub enum ContractError { ContractPaused = 2, AlreadyInitialized = 3, AddressNotFound = 4, + PastExpiry = 5, + BatchTooLarge = 6, } #[contracttype] @@ -46,6 +48,17 @@ fn check_not_paused(e: &Env) -> Result<(), ContractError> { } } +fn check_not_past_expiry(e: &Env, until: u64) -> Result<(), ContractError> { + if until <= e.ledger().timestamp() { + Err(ContractError::PastExpiry) + } else { + Ok(()) + } +} + +/// Maximum number of addresses accepted by a single `batch_allow_addresses` call. +const MAX_BATCH_SIZE: u32 = 50; + #[contractimpl] impl ComplianceContract { pub fn initialize(e: Env, admin: Address) { @@ -103,6 +116,7 @@ impl ComplianceContract { ) -> Result<(), ContractError> { check_not_paused(&e)?; admin.require_auth(); + check_not_past_expiry(&e, until)?; e.storage().instance().set( &DataKey::Status(addr.clone()), &AddressStatus::AllowedUntil(until), @@ -112,6 +126,39 @@ impl ComplianceContract { Ok(()) } + /// Allows a batch of addresses until the given timestamp in a single invocation. + /// Enforces the same admin-only authorization and expiry validation as + /// `allow_address_until`, and rejects the whole batch (no partial writes) + /// if `addresses.len()` exceeds `MAX_BATCH_SIZE`. + pub fn batch_allow_addresses( + e: Env, + admin: Address, + addresses: Vec
, + until: u64, + ) -> Result<(), ContractError> { + check_not_paused(&e)?; + admin.require_auth(); + check_not_past_expiry(&e, until)?; + if addresses.len() > MAX_BATCH_SIZE { + return Err(ContractError::BatchTooLarge); + } + + for addr in addresses.iter() { + e.storage().instance().set( + &DataKey::Status(addr.clone()), + &AddressStatus::AllowedUntil(until), + ); + e.events() + .publish((Symbol::new(&e, "address_allowed"),), (addr.clone(), until)); + } + + e.events().publish( + (Symbol::new(&e, "compliance_batch_processed"),), + (admin, addresses.len()), + ); + Ok(()) + } + pub fn transfer_admin(e: Env, admin: Address, new_admin: Address) -> Result<(), ContractError> { check_not_paused(&e)?; admin.require_auth(); @@ -212,18 +259,191 @@ mod tests { #[test] fn test_is_allowed_exactly_at_expiry_returns_false() { + // `until` must be in the future at creation time (issue: past-expiry + // rejection), so we advance the ledger to the boundary afterwards + // instead of creating the entry already-expired. let (e, cid, admin, addr) = setup(1000); let c = ComplianceContractClient::new(&e, &cid); - c.allow_address_until(&admin, &addr, &1000u64); + c.allow_address_until(&admin, &addr, &2000u64); + e.ledger().with_mut(|li| li.timestamp = 2000); assert!(!c.is_allowed(&addr)); } #[test] fn test_is_allowed_past_expiry_returns_false() { - let (e, cid, admin, addr) = setup(1001); + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + e.ledger().with_mut(|li| li.timestamp = 2001); + assert!(!c.is_allowed(&addr)); + } + + // ── allow_address_until past-expiry validation ───────────────────────────── + + #[test] + fn test_allow_address_until_rejects_past_timestamp() { + let (e, cid, admin, addr) = setup(2000); + let c = ComplianceContractClient::new(&e, &cid); + let res = c.try_allow_address_until(&admin, &addr, &1000u64); + assert_eq!(res, Err(Ok(ContractError::PastExpiry))); + } + + #[test] + fn test_allow_address_until_rejects_timestamp_equal_to_now() { + let (e, cid, admin, addr) = setup(2000); + let c = ComplianceContractClient::new(&e, &cid); + let res = c.try_allow_address_until(&admin, &addr, &2000u64); + assert_eq!(res, Err(Ok(ContractError::PastExpiry))); + } + + #[test] + fn test_allow_address_until_accepts_future_timestamp() { + let (e, cid, admin, addr) = setup(2000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2001u64); + assert!(c.is_allowed(&addr)); + } + + // ── clear_address / expiry interaction ───────────────────────────────────── + + #[test] + fn test_clear_address_before_expiry() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + assert!(c.is_allowed(&addr)); + + c.clear_address(&admin, &addr); + assert!(!c.is_allowed(&addr)); + } + + #[test] + fn test_clear_address_after_expiry() { + let (e, cid, admin, addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + c.allow_address_until(&admin, &addr, &2000u64); + e.ledger().with_mut(|li| li.timestamp = 2001); + assert!(!c.is_allowed(&addr)); + + c.clear_address(&admin, &addr); + assert!(!c.is_allowed(&addr)); + } + + #[test] + fn test_clear_address_never_allowed() { + let (e, cid, admin, addr) = setup(1000); let c = ComplianceContractClient::new(&e, &cid); - c.allow_address_until(&admin, &addr, &1000u64); assert!(!c.is_allowed(&addr)); + + let res = c.try_clear_address(&admin, &addr); + assert_eq!(res, Err(Ok(ContractError::AddressNotFound))); + assert!(!c.is_allowed(&addr)); + } + + // ── batch_allow_addresses ─────────────────────────────────────────────────── + + #[test] + fn test_batch_allow_addresses_allows_all_and_emits_events() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let a1 = Address::generate(&e); + let a2 = Address::generate(&e); + let a3 = Address::generate(&e); + let addresses = soroban_sdk::vec![&e, a1.clone(), a2.clone(), a3.clone()]; + + c.batch_allow_addresses(&admin, &addresses, &2000u64); + + assert!(c.is_allowed(&a1)); + assert!(c.is_allowed(&a2)); + assert!(c.is_allowed(&a3)); + + let all_events = e.events().all(); + let allowed_count = all_events + .iter() + .filter(|ev| ev.0 == (cid.clone(), "address_allowed".into())) + .count(); + assert_eq!(allowed_count, 3); + + assert!(all_events + .iter() + .any(|ev| ev.0 == (cid.clone(), "compliance_batch_processed".into()))); + } + + #[test] + fn test_batch_allow_addresses_rejects_past_expiry() { + let (e, cid, admin, _addr) = setup(2000); + let c = ComplianceContractClient::new(&e, &cid); + let a1 = Address::generate(&e); + let addresses = soroban_sdk::vec![&e, a1]; + + let res = c.try_batch_allow_addresses(&admin, &addresses, &1000u64); + assert_eq!(res, Err(Ok(ContractError::PastExpiry))); + } + + #[test] + fn test_batch_allow_addresses_rejects_over_cap() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let mut addresses = Vec::new(&e); + for _ in 0..51 { + addresses.push_back(Address::generate(&e)); + } + + let res = c.try_batch_allow_addresses(&admin, &addresses, &2000u64); + assert_eq!(res, Err(Ok(ContractError::BatchTooLarge))); + } + + #[test] + fn test_batch_allow_addresses_accepts_exactly_cap() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let mut addresses = Vec::new(&e); + for _ in 0..50 { + addresses.push_back(Address::generate(&e)); + } + + c.batch_allow_addresses(&admin, &addresses, &2000u64); + + let all_events = e.events().all(); + assert!(all_events + .iter() + .any(|ev| ev.0 == (cid.clone(), "compliance_batch_processed".into()))); + } + + #[test] + fn test_batch_allow_addresses_summary_event_emitted_once_per_batch() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let addresses = soroban_sdk::vec![ + &e, + Address::generate(&e), + Address::generate(&e), + Address::generate(&e), + Address::generate(&e) + ]; + + c.batch_allow_addresses(&admin, &addresses, &2000u64); + + let all_events = e.events().all(); + let summary_count = all_events + .iter() + .filter(|ev| ev.0 == (cid.clone(), "compliance_batch_processed".into())) + .count(); + assert_eq!( + summary_count, 1, + "exactly one compliance_batch_processed event should be emitted per batch call" + ); + } + + #[test] + fn test_batch_allow_addresses_blocked_when_paused() { + let (e, cid, admin, _addr) = setup(1000); + let c = ComplianceContractClient::new(&e, &cid); + let addresses = soroban_sdk::vec![&e, Address::generate(&e)]; + + c.pause(&admin); + let res = c.try_batch_allow_addresses(&admin, &addresses, &2000u64); + assert_eq!(res, Err(Ok(ContractError::ContractPaused))); } #[test] diff --git a/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml b/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml index 616f59e..fe31ea4 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/invoice/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "comebackhere-invoice" -version = "1.0.0" +version = "1.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] -soroban-sdk = "20.0.0" +soroban-sdk = "22.0.0" [dev-dependencies] -soroban-sdk = { version = "20.0.0", features = ["testutils"] } +soroban-sdk = { version = "22.0.0", features = ["testutils"] } diff --git a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs index 1ce090e..614d0c3 100644 --- a/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/invoice/src/lib.rs @@ -3,9 +3,16 @@ mod events; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, String, Symbol, + Vec, }; +/// Maximum length, in bytes, allowed for the optional `reference` field on an invoice. +const MAX_REFERENCE_LEN: u32 = 64; + +/// Minimum invoice amount, in stroops (10,000,000 stroops == 1 USDC given 7 decimals). +const MIN_AMOUNT_USDC: i128 = 10_000_000; + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum ContractError { @@ -24,6 +31,13 @@ pub enum ContractError { DuplicateNonce = 13, TreasuryNotConfigured = 14, NotAParty = 15, + Overflow = 16, + AddressBlocked = 17, + /// A state-changing call was rejected because the invoice is in a terminal + /// or refund-related state that does not permit the requested transition + /// (e.g. `mark_paids` called on an invoice that is `RefundRequested`, + /// `Released`, `Cancelled`, or `Expired`). + InvalidStateTransition = 18, } #[contracttype] @@ -48,6 +62,9 @@ pub struct Invoice { pub status: InvoiceStatus, pub created_at: u64, pub expires_at: u64, + /// Optional merchant-supplied reference (e.g. an order or invoice number + /// from the merchant's own system), capped at `MAX_REFERENCE_LEN` bytes. + pub reference: Option, } #[contracttype] @@ -139,13 +156,18 @@ impl InvoiceContract { /// - `expires_at`: Absolute ledger timestamp (seconds since Unix epoch) after which /// the invoice can no longer be paid. /// - `nonce`: A per-merchant unique value used to prevent duplicate submissions. + /// - `reference`: An optional merchant-supplied reference (e.g. an order ID from the + /// merchant's own system), capped at `MAX_REFERENCE_LEN` (64) bytes. /// /// # Returns /// The newly assigned invoice ID (a `u64` counter starting at 1). /// /// # Errors /// - [`ContractError::ContractPaused`] if the contract is currently paused. + /// - [`ContractError::AmountPrecision`] if `amount` is below `MIN_AMOUNT_USDC` + /// (10,000,000 stroops, i.e. 1 USDC). /// - [`ContractError::DuplicateNonce`] if `(merchant, nonce)` has already been used. + /// - [`ContractError::ReferenceTooLong`] if `reference` exceeds `MAX_REFERENCE_LEN` bytes. /// /// # Events /// Emits `invoice_created(merchant, invoice_id)` on success. @@ -157,10 +179,21 @@ impl InvoiceContract { token: Address, expires_at: u64, nonce: u64, + reference: Option, ) -> Result { check_not_paused(&env)?; merchant.require_auth(); + if amount < MIN_AMOUNT_USDC { + return Err(ContractError::AmountPrecision); + } + + if let Some(ref r) = reference { + if r.len() > MAX_REFERENCE_LEN { + return Err(ContractError::ReferenceTooLong); + } + } + let nonce_key = DataKey::Nonce(merchant.clone(), nonce); if env.storage().persistent().has(&nonce_key) { return Err(ContractError::DuplicateNonce); @@ -187,6 +220,7 @@ impl InvoiceContract { status: InvoiceStatus::Pending, created_at: now, expires_at, + reference, }; env.storage() .persistent() @@ -227,6 +261,68 @@ impl InvoiceContract { Ok(invoice.status) } + /// Returns a paginated list of invoice IDs belonging to a given merchant, most useful + /// for callers (e.g. the backend indexer) that need to enumerate a merchant's invoices + /// without tracking IDs off-chain. + /// + /// Follows the same pagination shape as [`Self::get_pending_settlements`]-style calls + /// on the treasury contract: `start_after` is the number of matching invoices to skip, + /// and `limit` bounds the page size. + /// + /// # Parameters + /// - `merchant`: The merchant address to filter invoices by. + /// - `start_after`: Number of matching invoices to skip before collecting the page + /// (defaults to 0 when `None`). + /// - `limit`: Maximum number of invoice IDs to return. Capped at 100 regardless of the + /// value passed in. + /// + /// # Returns + /// A `Vec` of invoice IDs belonging to `merchant`, oldest first. + pub fn get_invoices_by_merchant( + env: Env, + merchant: Address, + start_after: Option, + limit: u32, + ) -> Vec { + const MAX_PAGE_SIZE: u32 = 100; + let cap: u32 = if limit > MAX_PAGE_SIZE { + MAX_PAGE_SIZE + } else { + limit + }; + let skip: u32 = start_after.unwrap_or(0); + + let count: u64 = env + .storage() + .persistent() + .get(&DataKey::InvoiceCount) + .unwrap_or(0); + + let mut result: Vec = Vec::new(&env); + let mut matched: u32 = 0; + let mut collected: u32 = 0; + + for id in 1..=count { + if let Some(invoice) = env + .storage() + .persistent() + .get::(&DataKey::Invoice(id)) + { + if invoice.merchant == merchant { + if matched >= skip { + if collected >= cap { + break; + } + result.push_back(id); + collected += 1; + } + matched += 1; + } + } + } + result + } + /// Marks a batch of invoices as [`InvoiceStatus::Paid`] in a single transaction. /// /// Each invoice in the batch must be in `Pending` status and must not have expired. @@ -239,7 +335,10 @@ impl InvoiceContract { /// # Errors /// - [`ContractError::ContractPaused`] if the contract is currently paused. /// - [`ContractError::InvoiceNotFound`] if any ID in the batch does not exist. - /// - [`ContractError::InvoiceAlreadyPaid`] if any invoice is not in `Pending` status. + /// - [`ContractError::InvalidStateTransition`] if any invoice is `RefundRequested`, + /// `Released`, `Cancelled`, or `Expired` — a payment confirmation must never + /// silently override a refund already in progress or a closed invoice. + /// - [`ContractError::InvoiceAlreadyPaid`] if any invoice is already `Paid`. /// - [`ContractError::InvoiceExpired`] if any invoice's `expires_at` has passed. /// /// # Events @@ -260,6 +359,20 @@ impl InvoiceContract { .persistent() .get::(&DataKey::Invoice(id)) .ok_or(ContractError::InvoiceNotFound)?; + // Terminal and refund-related states must never be silently + // overridden by a stale payment confirmation: a payer's refund + // request (or an already-settled/cancelled/expired invoice) is + // rejected with a distinct error rather than falling through to + // the generic "already paid" case below. + if matches!( + invoice.status, + InvoiceStatus::RefundRequested + | InvoiceStatus::Released + | InvoiceStatus::Cancelled + | InvoiceStatus::Expired + ) { + return Err(ContractError::InvalidStateTransition); + } if invoice.status != InvoiceStatus::Pending { return Err(ContractError::InvoiceAlreadyPaid); } @@ -641,7 +754,7 @@ impl InvoiceContract { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::testutils::{Address as _, Events, Ledger}; use soroban_sdk::Env; fn setup_contract(ts: u64) -> (Env, Address, Address) { @@ -661,7 +774,7 @@ mod tests { let merchant = Address::generate(&env); let customer = Address::generate(&env); let token = Address::generate(&env); - let invoice_id = client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let invoice_id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); assert_eq!(invoice_id, 1); } @@ -673,9 +786,9 @@ mod tests { let customer = Address::generate(&env); let token = Address::generate(&env); - client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); - let result = client.try_create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let result = client.try_create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); assert_eq!(result, Err(Ok(ContractError::DuplicateNonce))); } @@ -697,8 +810,8 @@ mod tests { let customer = Address::generate(&env); let token = Address::generate(&env); - client.create_invoice(&merchant_a, &customer, &1000i128, &token, &5000, &1); - client.create_invoice(&merchant_b, &customer, &1000i128, &token, &5000, &1); + client.create_invoice(&merchant_a, &customer, &10_000_000i128, &token, &5000, &1, &None); + client.create_invoice(&merchant_b, &customer, &10_000_000i128, &token, &5000, &1, &None); let invoice_a = client.get_invoice(&1); let invoice_b = client.get_invoice(&2); @@ -722,7 +835,7 @@ mod tests { client.pause(&admin); - let result = client.try_create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let result = client.try_create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); assert_eq!(result, Err(Ok(ContractError::ContractPaused))); } @@ -741,7 +854,7 @@ mod tests { let merchant = Address::generate(&env); let customer = Address::generate(&env); let token = Address::generate(&env); - let result = client.try_create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let result = client.try_create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); assert_eq!(result, Err(Ok(ContractError::Overflow))); } @@ -758,7 +871,7 @@ mod tests { let merchant = Address::generate(&env); let customer = Address::generate(&env); let token = Address::generate(&env); - let invoice_id = client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let invoice_id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); client.mark_paids(&soroban_sdk::vec![&env, invoice_id]); client.request_refund(&invoice_id, &customer); let result = client.try_release_escrow(&invoice_id, &merchant); @@ -780,11 +893,11 @@ mod tests { client.initialize(&admin); client.pause(&admin); - let result = client.try_create_invoice(&merchant, &customer, &1000i128, &token, &5000, &1); + let result = client.try_create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &1, &None); assert_eq!(result, Err(Ok(ContractError::ContractPaused))); client.unpause(&admin); - let invoice_id = client.create_invoice(&merchant, &customer, &1000i128, &token, &5000, &2); + let invoice_id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &5000, &2, &None); assert_eq!(invoice_id, 1); } @@ -874,13 +987,8 @@ mod tests { invoice_client.initialize(&admin); invoice_client.set_treasury(&admin, &treasury_cid); env.ledger().with_mut(|li| li.timestamp = ts); - ( - env, - invoice_cid, - treasury_cid, - admin, - Address::generate(&env), - ) + let customer = Address::generate(&env); + (env, invoice_cid, treasury_cid, admin, customer) } #[test] @@ -893,7 +1001,7 @@ mod tests { let customer = Address::generate(&env); let token = Address::generate(&env); let invoice_id = - invoice_client.create_invoice(&merchant, &customer, &1000i128, &token, &9999, &1); + invoice_client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); invoice_client.raise_dispute(&invoice_id, &1u64, &merchant, &1u32); @@ -912,7 +1020,7 @@ mod tests { let customer = Address::generate(&env); let token = Address::generate(&env); let invoice_id = - invoice_client.create_invoice(&merchant, &customer, &500i128, &token, &9999, &1); + invoice_client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); invoice_client.raise_dispute(&invoice_id, &2u64, &merchant, &1u32); @@ -948,7 +1056,7 @@ mod tests { let token = Address::generate(&env); let claimant = Address::generate(&env); let invoice_id = - invoice_client.create_invoice(&merchant, &customer, &100i128, &token, &9999, &1); + invoice_client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); let result = invoice_client.try_raise_dispute(&invoice_id, &1u64, &claimant, &1u32); assert_eq!(result, Err(Ok(ContractError::TreasuryNotConfigured))); @@ -963,7 +1071,7 @@ mod tests { let customer = Address::generate(&env); let token = Address::generate(&env); let invoice_id = - invoice_client.create_invoice(&merchant, &customer, &100i128, &token, &9999, &1); + invoice_client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); invoice_client.pause(&admin); @@ -980,7 +1088,7 @@ mod tests { let merchant = Address::generate(env); let customer = Address::generate(env); let token = Address::generate(env); - let id = client.create_invoice(&merchant, &customer, &1_000_000i128, &token, &9999, &1); + let id = client.create_invoice(&merchant, &customer, &10_000_000i128, &token, &9999, &1, &None); (merchant, customer, id) } @@ -1097,4 +1205,68 @@ mod tests { let res = client.try_cancel_invoiced(&id, &merchant); assert_eq!(res, Err(Ok(ContractError::ContractPaused))); } + + // ── mark_paids terminal/refund-state guard tests ───────────────────────── + + /// A stale mark_paids call must not silently override a refund already + /// requested by the customer — it should be rejected, not re-marked Paid. + #[test] + fn test_mark_paids_on_refund_requested_returns_invalid_state_transition() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let (_merchant, customer, id) = create_test_invoice(&client, &env); + + client.mark_paids(&soroban_sdk::vec![&env, id]); + client.request_refund(&id, &customer); + + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); + + // The refund request must survive the stale confirmation untouched. + let invoice = client.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::RefundRequested); + } + + /// mark_paids on a Released (escrow already released) invoice is rejected. + #[test] + fn test_mark_paids_on_released_returns_invalid_state_transition() { + let (env, cid, admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let (merchant, customer, id) = create_test_invoice(&client, &env); + + client.mark_paids(&soroban_sdk::vec![&env, id]); + client.request_refund(&id, &customer); + client.set_grace_window(&admin, &0u64); + client.release_escrow(&id, &merchant); + + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); + } + + /// mark_paids on a Cancelled invoice is rejected with the same distinct error. + #[test] + fn test_mark_paids_on_cancelled_returns_invalid_state_transition() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let (merchant, _customer, id) = create_test_invoice(&client, &env); + + client.cancel_invoiced(&id, &merchant); + + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvalidStateTransition))); + } + + /// mark_paids on an already-Paid invoice still returns the more specific + /// InvoiceAlreadyPaid error, distinct from the terminal/refund-state guard. + #[test] + fn test_mark_paids_on_already_paid_returns_invoice_already_paid() { + let (env, cid, _admin) = setup_contract(1000); + let client = InvoiceContractClient::new(&env, &cid); + let (_merchant, _customer, id) = create_test_invoice(&client, &env); + + client.mark_paids(&soroban_sdk::vec![&env, id]); + + let res = client.try_mark_paids(&soroban_sdk::vec![&env, id]); + assert_eq!(res, Err(Ok(ContractError::InvoiceAlreadyPaid))); + } } diff --git a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml index 04c75ed..2873437 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml +++ b/COMEBACKHERE-contracts/contracts/treasury/Cargo.toml @@ -1,16 +1,16 @@ [package] name = "comebackhere-treasury" -version = "1.0.0" +version = "1.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] -soroban-sdk = "20.0.0" +soroban-sdk = "22.0.0" [dev-dependencies] -soroban-sdk = { version = "20.0.0", features = ["testutils"] } +soroban-sdk = { version = "22.0.0", features = ["testutils"] } proptest = "1.4" [profile.release] diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs b/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs index ca8cf81..2b38c8d 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/benchmark.rs @@ -3,7 +3,7 @@ use super::*; use soroban_sdk::{testutils::Address as _, vec, Address, Env}; -fn setup_bench_env() -> (Env, Address, TreasuryContractClient) { +fn setup_bench_env() -> (Env, Address) { let e = Env::default(); e.mock_all_auths(); let contract_id = e.register(TreasuryContract, ()); @@ -13,24 +13,25 @@ fn setup_bench_env() -> (Env, Address, TreasuryContractClient) { let signer = Address::generate(&e); client.initialize(&vec![&e, (signer.clone(), 1u64)], &1, &admin); - (e, contract_id, client) + (e, contract_id) } #[test] fn bench_propose_settlement() { - let (e, _id, client) = setup_bench_env(); + let (e, id) = setup_bench_env(); + let client = TreasuryContractClient::new(&e, &id); let signer = Address::generate(&e); let token = Address::generate(&e); let merchant = Address::generate(&e); e.budget().reset_unlimited(); - let cpu_before = e.budget().get_cpu_instructions_used(); - let mem_before = e.budget().get_memory_bytes_used(); + let cpu_before = e.budget().cpu_instruction_cost(); + let mem_before = e.budget().memory_bytes_cost(); let sid = client.propose_settlement(&signer, &token, &5_000_000u64, &merchant); - let cpu_after = e.budget().get_cpu_instructions_used(); - let mem_after = e.budget().get_memory_bytes_used(); + let cpu_after = e.budget().cpu_instruction_cost(); + let mem_after = e.budget().memory_bytes_cost(); let cpu_delta = cpu_after - cpu_before; let mem_delta = mem_after - mem_before; @@ -50,22 +51,23 @@ fn bench_propose_settlement() { #[test] fn bench_propose_settlement_deterministic() { - let (e, _id, client) = setup_bench_env(); + let (e, id) = setup_bench_env(); + let client = TreasuryContractClient::new(&e, &id); let signer = Address::generate(&e); let token = Address::generate(&e); let merchant = Address::generate(&e); e.budget().reset_unlimited(); - let cpu1_before = e.budget().get_cpu_instructions_used(); + let cpu1_before = e.budget().cpu_instruction_cost(); client.propose_settlement(&signer, &token, &5_000_000u64, &merchant); - let cpu1_after = e.budget().get_cpu_instructions_used(); + let cpu1_after = e.budget().cpu_instruction_cost(); let cpu1 = cpu1_after - cpu1_before; let signer2 = Address::generate(&e); e.budget().reset_unlimited(); - let cpu2_before = e.budget().get_cpu_instructions_used(); + let cpu2_before = e.budget().cpu_instruction_cost(); client.propose_settlement(&signer2, &token, &5_000_000u64, &merchant); - let cpu2_after = e.budget().get_cpu_instructions_used(); + let cpu2_after = e.budget().cpu_instruction_cost(); let cpu2 = cpu2_after - cpu2_before; assert_eq!( @@ -73,3 +75,45 @@ fn bench_propose_settlement_deterministic() { "propose_settlement CPU cost should be deterministic" ); } + +/// Regression benchmark for the reordering in #31: the token allowlist check +/// now runs before the pause and signer-auth checks in `propose_settlement`, +/// so a disallowed token should be rejected for meaningfully less than the +/// cost of a full accepted validation pass, rather than paying for +/// pause/auth work first and only then failing on the allowlist. +#[test] +fn bench_propose_settlement_rejected_token_cheaper_than_accepted() { + let e = Env::default(); + e.mock_all_auths(); + let contract_id = e.register(TreasuryContract, ()); + let client = TreasuryContractClient::new(&e, &contract_id); + + let admin = Address::generate(&e); + let signer = Address::generate(&e); + let allowed_token = Address::generate(&e); + let disallowed_token = Address::generate(&e); + let merchant = Address::generate(&e); + + client.initialize(&vec![&e, (signer.clone(), 1u64)], &1, &admin); + client.add_token_to_allowlist(&admin, &allowed_token); + + e.budget().reset_unlimited(); + let cpu_accept_before = e.budget().get_cpu_instructions_used(); + client.propose_settlement(&signer, &allowed_token, &5_000_000u64, &merchant); + let cpu_accept_after = e.budget().get_cpu_instructions_used(); + let cpu_accept = cpu_accept_after - cpu_accept_before; + + e.budget().reset_unlimited(); + let cpu_reject_before = e.budget().get_cpu_instructions_used(); + let result = + client.try_propose_settlement(&signer, &disallowed_token, &5_000_000u64, &merchant); + let cpu_reject_after = e.budget().get_cpu_instructions_used(); + let cpu_reject = cpu_reject_after - cpu_reject_before; + + assert_eq!(result, Err(Ok(TreasuryError::TokenNotAllowed))); + assert!( + cpu_reject < cpu_accept, + "rejecting a disallowed token ({cpu_reject} cpu) should cost less than a full \ + accepted proposal ({cpu_accept} cpu) now that the allowlist check runs first" + ); +} diff --git a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs index fe7628d..7d276e1 100644 --- a/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs +++ b/COMEBACKHERE-contracts/contracts/treasury/src/lib.rs @@ -3,7 +3,9 @@ #[cfg(test)] extern crate std; -use soroban_sdk::{contract, contractimpl, contracttype, contracterror, Address, Env, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, Env, IntoVal, Symbol, Vec, +}; /// Status of a settlement proposal within the Treasury contract. #[contracttype] @@ -38,6 +40,17 @@ pub struct Settlement { pub proposer: Address, } +/// Tracks cumulative withdrawals of one token within the current rolling +/// 24h ledger-time window, used to enforce `daily_withdraw_limit`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithdrawWindow { + /// Ledger timestamp (seconds) at which the current window started. + pub window_start: u64, + /// Cumulative amount withdrawn since `window_start`. + pub spent: u64, +} + /// Error types returned by Treasury contract operations. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -58,6 +71,18 @@ pub enum TreasuryError { DuplicateSigner = 7, InvalidWeightSum = 8, NotSettlementParty = 9, + /// `update_threshold` was called with a threshold above the sum of all + /// registered signer weights. + ThresholdExceedsWeight = 10, + /// `get_pending_settlements` was called with `limit` above `MAX_PAGE_SIZE`. + InvalidPagination = 11, + /// `rotate_signer` was called with an `old_signer` that has no registered + /// weight (i.e. is not a current signer). + SignerNotFound = 12, + /// `withdraw` was called for a token with a configured + /// `daily_withdraw_limit` and the withdrawal would push cumulative + /// withdrawals for the current 24h window above that limit. + DailyLimitExceeded = 13, } /// Storage keys for Treasury contract instance state. @@ -69,6 +94,10 @@ pub enum DataKey { Paused, /// Mapping of signer address to voting weight key. Signer(Address), + /// List of all addresses ever registered as a signer, so total signer + /// weight can be recomputed on demand instead of relying on a cached + /// counter that could drift out of sync. + SignerList, /// Settlement proposal storage key by settlement ID. Settlement(u64), /// Auto-incrementing settlement ID counter key. @@ -77,6 +106,10 @@ pub enum DataKey { Threshold, /// Token allowlist key. TokenAllowlist, + /// Per-token admin-configured daily withdrawal cap. + DailyWithdrawLimit(Address), + /// Per-token rolling-window withdrawal ledger; value is a [`WithdrawWindow`]. + WithdrawWindow(Address), } fn is_paused(e: &Env) -> bool { @@ -169,6 +202,94 @@ impl TreasuryContract { Ok(()) } + /// Rotates a signer's key: deregisters `old_signer` entirely and + /// registers `new_signer` with `new_weight` in its place. Unlike calling + /// `set_signer` twice, this keeps `SignerList` (and therefore + /// `total_signer_weight`) exactly in sync with the active signer set + /// regardless of whether `new_weight` is higher or lower than the weight + /// `old_signer` held — `total_signer_weight` is always recomputed from + /// live storage rather than tracked as a separate running total, so it + /// cannot drift out of sync with the individual signer weights. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `admin` - Admin address (must authenticate). + /// * `old_signer` - The signer address being replaced; must currently hold non-zero weight. + /// * `new_signer` - The replacement signer address. + /// * `new_weight` - Voting weight assigned to `new_signer`. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if contract operations are paused. + /// * Returns [`TreasuryError::Unauthorized`] if `admin` is not the stored contract admin. + /// * Returns [`TreasuryError::SignerNotFound`] if `old_signer` is not a current signer. + /// * Returns [`TreasuryError::DuplicateSigner`] if `new_signer` is already a distinct + /// active signer. + /// + /// # Note + /// Settlements that already accumulated approval weight from `old_signer` keep that + /// weight as a snapshot on the settlement record — rotating (or reweighting) a signer + /// does not retroactively change `approval_weight` on in-flight settlements, so a + /// settlement that already reached quorum remains executable. + pub fn rotate_signer( + e: Env, + admin: Address, + old_signer: Address, + new_signer: Address, + new_weight: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + + let old_weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(old_signer.clone())) + .unwrap_or(0u64); + if old_weight == 0 { + return Err(TreasuryError::SignerNotFound); + } + + if new_signer != old_signer { + let existing_new_weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(new_signer.clone())) + .unwrap_or(0u64); + if existing_new_weight > 0 { + return Err(TreasuryError::DuplicateSigner); + } + } + + e.storage() + .instance() + .remove(&DataKey::Signer(old_signer.clone())); + e.storage() + .instance() + .set(&DataKey::Signer(new_signer.clone()), &new_weight); + + let signer_list: Vec
= e + .storage() + .instance() + .get(&DataKey::SignerList) + .unwrap_or_else(|| Vec::new(&e)); + let mut updated_list: Vec
= Vec::new(&e); + for s in signer_list.iter() { + if s != old_signer { + updated_list.push_back(s); + } + } + if !updated_list.contains(&new_signer) { + updated_list.push_back(new_signer.clone()); + } + e.storage().instance().set(&DataKey::SignerList, &updated_list); + + e.events().publish( + (Symbol::new(&e, "signer_rotated"),), + (old_signer, new_signer, old_weight, new_weight), + ); + Ok(()) + } + /// Proposes a new settlement for approval and execution. /// /// # Arguments @@ -182,8 +303,10 @@ impl TreasuryContract { /// * `Ok(u64)` - The auto-incremented settlement ID for the created proposal. /// /// # Errors - /// * Returns [`TreasuryError::ContractPaused`] if contract operations are paused. /// * Returns [`TreasuryError::TokenNotAllowed`] if token allowlist is non-empty and `token` is not allowed. + /// Checked first, before the pause and signer-auth checks, so a disallowed token is + /// rejected without paying for the rest of a full validation pass. + /// * Returns [`TreasuryError::ContractPaused`] if contract operations are paused. pub fn propose_settlement( e: Env, signer: Address, @@ -191,9 +314,6 @@ impl TreasuryContract { amount: u64, merchant: Address, ) -> Result { - check_not_paused(&e)?; - signer.require_auth(); - let allowlist: Vec
= e .storage() .instance() @@ -203,6 +323,9 @@ impl TreasuryContract { return Err(TreasuryError::TokenNotAllowed); } + check_not_paused(&e)?; + signer.require_auth(); + let settlement_id: u64 = e .storage() .instance() @@ -300,6 +423,61 @@ impl TreasuryContract { Ok(()) } + /// Previews the outcome of calling [`Self::execute_settlement`] on `settlement_id` + /// right now, performing the same checks (quorum reached, treasury balance sufficient) + /// without mutating any contract state. Intended for signers/frontends (e.g. + /// `SettlementDetail`) to preview the outcome and USDC balance impact before a signer + /// commits to the real transaction. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `settlement_id` - ID of the settlement proposal to preview. + /// + /// # Returns + /// * [`SettlementSimulation`] describing whether execution would succeed, along with + /// the approval and balance figures behind that verdict. + pub fn simulate_settlement( + e: Env, + settlement_id: u64, + ) -> Result { + let settlement: Settlement = e + .storage() + .instance() + .get(&DataKey::Settlement(settlement_id)) + .ok_or(TreasuryError::SettlementNotFound)?; + + let threshold: u64 = e + .storage() + .instance() + .get(&DataKey::Threshold) + .unwrap_or(0u64); + let quorum_reached = settlement.approval_weight >= threshold; + + let treasury_balance: i128 = e.invoke_contract( + &settlement.token, + &Symbol::new(&e, "balance"), + soroban_sdk::vec![&e, e.current_contract_address().into_val(&e)], + ); + let settlement_amount: i128 = settlement.amount as i128; + let sufficient_balance = treasury_balance >= settlement_amount; + + let would_succeed = !is_paused(&e) + && settlement.status == SettlementStatus::Pending + && quorum_reached + && sufficient_balance; + + Ok(SettlementSimulation { + settlement_id, + status: settlement.status, + would_succeed, + approval_weight: settlement.approval_weight, + threshold, + settlement_amount: settlement.amount, + treasury_balance, + projected_balance: treasury_balance - settlement_amount, + }) + } + /// Retrieves a paginated list of pending settlement IDs. /// /// # Arguments @@ -355,6 +533,25 @@ impl TreasuryContract { Ok(result) } + /// Sums the voting weight of every address in the signer list. + fn total_signer_weight(e: &Env) -> u64 { + let signer_list: Vec
= e + .storage() + .instance() + .get(&DataKey::SignerList) + .unwrap_or_else(|| Vec::new(e)); + let mut total: u64 = 0; + for signer in signer_list.iter() { + let weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(signer)) + .unwrap_or(0); + total += weight; + } + total + } + fn check_admin(e: &Env, admin: &Address) -> Result<(), TreasuryError> { admin.require_auth(); let stored_admin: Address = e.storage().instance().get(&DataKey::Admin).unwrap(); @@ -454,6 +651,31 @@ impl TreasuryContract { Ok(()) } + /// Returns the sum of all currently registered signer weights, recomputed + /// live from storage on every call (not a cached counter), so it can + /// never drift out of sync with the individual `Signer(address)` entries. + pub fn get_total_signer_weight(e: Env) -> u64 { + Self::total_signer_weight(&e) + } + + fn total_signer_weight(e: &Env) -> u64 { + let signer_list: Vec
= e + .storage() + .instance() + .get(&DataKey::SignerList) + .unwrap_or_else(|| Vec::new(e)); + let mut total: u64 = 0; + for signer in signer_list.iter() { + let weight: u64 = e + .storage() + .instance() + .get(&DataKey::Signer(signer)) + .unwrap_or(0u64); + total += weight; + } + total + } + /// Places a pending settlement on hold by raising a dispute. /// /// # Arguments @@ -528,8 +750,8 @@ impl TreasuryContract { return Err(TreasuryError::Unauthorized); } - let old_merchant = settlement.merchant; - settlement.merchant = new_merchant; + let old_merchant = settlement.merchant.clone(); + settlement.merchant = new_merchant.clone(); e.storage() .instance() @@ -565,25 +787,117 @@ impl TreasuryContract { Ok(()) } + /// Sets (or clears, with `limit = 0`) the admin-configured daily withdrawal + /// cap for a token. `withdraw` enforces this cap over a rolling 24h + /// ledger-time window per token; a token with no configured limit is + /// unrestricted. + /// + /// # Arguments + /// * `e` - Soroban environment handle. + /// * `admin` - Admin address (must authenticate). + /// * `token` - Token contract address the cap applies to. + /// * `limit` - Maximum cumulative withdrawal amount per 24h window. + /// + /// # Errors + /// * Returns [`TreasuryError::ContractPaused`] if contract is paused. + /// * Returns [`TreasuryError::Unauthorized`] if caller is not the contract admin. + pub fn set_daily_withdraw_limit( + e: Env, + admin: Address, + token: Address, + limit: u64, + ) -> Result<(), TreasuryError> { + check_not_paused(&e)?; + Self::check_admin(&e, &admin)?; + e.storage() + .instance() + .set(&DataKey::DailyWithdrawLimit(token.clone()), &limit); + e.events().publish( + (Symbol::new(&e, "daily_withdraw_limit_set"),), + (token, limit), + ); + Ok(()) + } + + /// Returns the configured daily withdrawal cap for a token, or `None` if + /// the admin has never set one (in which case withdrawals of that token + /// are unrestricted). + pub fn get_daily_withdraw_limit(e: Env, token: Address) -> Option { + e.storage() + .instance() + .get(&DataKey::DailyWithdrawLimit(token)) + } + /// Withdraws funds from the treasury to a recipient address. /// + /// If a `daily_withdraw_limit` has been configured for `token`, this + /// tracks cumulative withdrawals of that token in a 24h ledger-time + /// window (measured from the first withdrawal in the window; the window + /// resets once `ledger.timestamp()` has advanced 24h past its start) and + /// rejects any withdrawal that would push the window's cumulative total + /// above the limit. This bounds worst-case exposure from a compromised + /// signer set to one configured cap per settlement cycle, rather than + /// allowing the treasury to be drained in a single transaction. + /// /// # Arguments /// * `e` - Soroban environment handle. /// * `admin` - Admin address (must authenticate). + /// * `token` - Token being withdrawn; used to look up the daily cap. /// * `_to` - Target recipient address. - /// * `_amount` - Amount to withdraw. + /// * `amount` - Amount to withdraw. /// /// # Errors /// * Returns [`TreasuryError::ContractPaused`] if contract is paused. /// * Returns [`TreasuryError::Unauthorized`] if caller is not the contract admin. + /// * Returns [`TreasuryError::DailyLimitExceeded`] if `token` has a configured + /// daily limit and `amount` would push the current 24h window's cumulative + /// withdrawals above it. pub fn withdraw( e: Env, admin: Address, + token: Address, _to: Address, - _amount: u64, + amount: u64, ) -> Result<(), TreasuryError> { check_not_paused(&e)?; Self::check_admin(&e, &admin)?; + + const WINDOW_SECONDS: u64 = 86_400; + + let limit: Option = e + .storage() + .instance() + .get(&DataKey::DailyWithdrawLimit(token.clone())); + + if let Some(limit) = limit { + let now = e.ledger().timestamp(); + let window: Option = e + .storage() + .instance() + .get(&DataKey::WithdrawWindow(token.clone())); + let (window_start, spent) = match window { + Some(w) if now.saturating_sub(w.window_start) < WINDOW_SECONDS => { + (w.window_start, w.spent) + } + _ => (now, 0u64), + }; + + let new_spent = spent + .checked_add(amount) + .ok_or(TreasuryError::DailyLimitExceeded)?; + if new_spent > limit { + return Err(TreasuryError::DailyLimitExceeded); + } + + e.storage().instance().set( + &DataKey::WithdrawWindow(token.clone()), + &WithdrawWindow { + window_start, + spent: new_spent, + }, + ); + } + Ok(()) } @@ -658,41 +972,6 @@ impl TreasuryContract { .get(&DataKey::Settlement(settlement_id)) .unwrap() } - - fn get_dispute_internal(e: &Env, settlement_id: u64) -> Dispute { - e.storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)) - } - - fn finalize_dispute_internal(e: &Env, settlement_id: u64, resolve_in_favor: bool) { - let mut dispute: Dispute = e - .storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap_or_else(|| panic_with_error!(e, TreasuryError::DisputeNotFound)); - - dispute.status = if resolve_in_favor { - DisputeStatus::ResolvedClaimant - } else { - DisputeStatus::ResolvedCounterparty - }; - e.storage().instance().set(&DataKey::Dispute(settlement_id), &dispute); - - // In favour of the claimant (the dispute raiser): the settlement is voided. - // In favour of the counterparty (the merchant): the settlement resumes as - // Pending and can proceed through the normal approval/execution flow. - let mut settlement = Self::get_settlement_internal(e, settlement_id); - settlement.status = if resolve_in_favor { - SettlementStatus::Cancelled - } else { - SettlementStatus::Pending - }; - e.storage().instance().set(&DataKey::Settlement(settlement_id), &settlement); - - events::dispute_resolved(e, &settlement_id, &resolve_in_favor, &dispute.resolution_weight); - } } #[cfg(test)] @@ -730,7 +1009,7 @@ mod tests { let signer = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); let result = c.get_pending_settlements(&None, &None); - assert_eq!(result, Ok(Vec::new(&e))); + assert_eq!(result, Vec::new(&e)); } #[test] @@ -743,7 +1022,7 @@ mod tests { let signer = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); let sid = c.propose_settlement(&signer, &token, &1000u64, &merchant); - let result = c.get_pending_settlements(&None, &None).unwrap(); + let result = c.get_pending_settlements(&None, &None); assert_eq!(result.len(), 1); assert_eq!(result.get(0).unwrap(), sid); } @@ -761,7 +1040,7 @@ mod tests { let s2 = c.propose_settlement(&signer, &token, &2000u64, &merchant); c.approve_settlement(&signer, &s1); c.execute_settlement(&signer, &s1, &token); - let result = c.get_pending_settlements(&None, &None).unwrap(); + let result = c.get_pending_settlements(&None, &None); assert_eq!(result.len(), 1); assert_eq!(result.get(0).unwrap(), s2); } @@ -778,7 +1057,7 @@ mod tests { for _ in 0..5 { c.propose_settlement(&signer, &token, &100u64, &merchant); } - let page = c.get_pending_settlements(&Some(2u32), &Some(2u32)).unwrap(); + let page = c.get_pending_settlements(&Some(2u32), &Some(2u32)); assert_eq!(page.len(), 2); assert_eq!(page.get(0).unwrap(), 3u64); assert_eq!(page.get(1).unwrap(), 4u64); @@ -796,8 +1075,8 @@ mod tests { for _ in 0..5 { c.propose_settlement(&signer, &token, &100u64, &merchant); } - let result = c.get_pending_settlements(&None, &Some(200u32)); - assert_eq!(result, Err(TreasuryError::InvalidPagination)); + let result = c.try_get_pending_settlements(&None, &Some(200u32)); + assert_eq!(result, Err(Ok(TreasuryError::InvalidPagination))); } #[test] @@ -810,9 +1089,7 @@ mod tests { let signer = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); c.propose_settlement(&signer, &token, &100u64, &merchant); - let page = c - .get_pending_settlements(&Some(10u32), &Some(5u32)) - .unwrap(); + let page = c.get_pending_settlements(&Some(10u32), &Some(5u32)); assert!(page.is_empty()); } @@ -918,7 +1195,7 @@ mod tests { let res = c.try_execute_settlement(&s1, &sid, &token); assert_eq!(res, Err(Ok(TreasuryError::InsufficientApprovals))); // settlement must still be Pending - let pending = c.get_pending_settlements(&None, &None).unwrap(); + let pending = c.get_pending_settlements(&None, &None); assert!(pending.contains(&sid)); } @@ -936,7 +1213,7 @@ mod tests { c.approve_settlement(&signer, &sid); c.execute_settlement(&signer, &sid, &token); // settlement no longer pending - let pending = c.get_pending_settlements(&None, &None).unwrap(); + let pending = c.get_pending_settlements(&None, &None); assert!(!pending.contains(&sid)); } @@ -953,7 +1230,7 @@ mod tests { let sid = c.propose_settlement(&signer, &token, &500u64, &merchant); c.approve_settlement(&signer, &sid); c.execute_settlement(&signer, &sid, &token); - let pending = c.get_pending_settlements(&None, &None).unwrap(); + let pending = c.get_pending_settlements(&None, &None); assert!(!pending.contains(&sid)); } @@ -1061,7 +1338,7 @@ mod tests { // s2 approves: weight=3 == 3, can execute c.approve_settlement(&s2, &sid); c.execute_settlement(&s1, &sid, &token); - let pending = c.get_pending_settlements(&None, &None).unwrap(); + let pending = c.get_pending_settlements(&None, &None); assert!(!pending.contains(&sid)); } @@ -1117,16 +1394,40 @@ mod tests { assert_eq!(s2, 2u64); } + #[test] + fn test_token_allowlist_checked_before_paused() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let signer = soroban_sdk::Address::generate(&e); + let token1 = soroban_sdk::Address::generate(&e); + let token2 = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (signer.clone(), 1u64)], &1, &admin); + c.add_token_to_allowlist(&admin, &token1); + c.pause(&admin); + + // With the contract paused AND the token disallowed, TokenNotAllowed + // must win: the allowlist check runs before the pause check. + let err = c.try_propose_settlement(&signer, &token2, &100u64, &merchant); + assert_eq!(err, Err(Ok(TreasuryError::TokenNotAllowed))); + + // An allowed token still correctly surfaces ContractPaused. + let err = c.try_propose_settlement(&signer, &token1, &100u64, &merchant); + assert_eq!(err, Err(Ok(TreasuryError::ContractPaused))); + } + #[test] fn test_deposit_and_withdraw() { let (e, id) = setup(); let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e], &1, &admin); c.deposit(&user, &1000u64); - c.withdraw(&admin, &user, &500u64); + c.withdraw(&admin, &token, &user, &500u64); } #[test] @@ -1135,212 +1436,196 @@ mod tests { let c = client(&e, &id); let admin = soroban_sdk::Address::generate(&e); let non_admin = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); c.initialize(&soroban_sdk::vec![&e], &1, &admin); assert_eq!(c.try_pause(&non_admin), Err(Ok(TreasuryError::Unauthorized))); assert_eq!(c.try_unpause(&non_admin), Err(Ok(TreasuryError::Unauthorized))); assert_eq!(c.try_update_threshold(&non_admin, &2u32), Err(Ok(TreasuryError::Unauthorized))); - assert_eq!(c.try_withdraw(&non_admin, &non_admin, &100u64), Err(Ok(TreasuryError::Unauthorized))); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::{vec, Env}; - - struct TestContext { - env: Env, - contract_id: Address, - signer1: Address, - signer2: Address, - signer3: Address, - token: Address, - merchant: Address, + assert_eq!( + c.try_withdraw(&non_admin, &token, &non_admin, &100u64), + Err(Ok(TreasuryError::Unauthorized)) + ); } - fn setup() -> TestContext { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let signer1 = Address::generate(&env); - let signer2 = Address::generate(&env); - let signer3 = Address::generate(&env); - let token = Address::generate(&env); - let merchant = Address::generate(&env); - - let signers = vec![ - &env, - (signer1.clone(), 1u64), - (signer2.clone(), 1u64), - (signer3.clone(), 1u64), - ]; - - let contract_id = env.register_contract(None, TreasuryContract); - let client = TreasuryContractClient::new(&env, &contract_id); - client.initialize(&signers, &2u64, &admin); - - TestContext { - env, - contract_id, - signer1, - signer2, - signer3, - token, - merchant, - } - } + // ── rotate_signer tests ────────────────────────────────────────────────── - fn propose_and_raise(ctx: &TestContext) -> u64 { - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); - client.raise_dispute(&ctx.signer1, &settlement_id, &1u32); - settlement_id - } + /// Rotating a signer to a HIGHER weight must increase total_signer_weight + /// by exactly the delta, not leave the old weight double-counted or lost. + #[test] + fn test_rotate_signer_to_higher_weight_updates_total_signer_weight() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + let new_s1 = soroban_sdk::Address::generate(&e); + // total weight = 3 (s1=1, s2=2) + c.initialize( + &soroban_sdk::vec![&e, (s1.clone(), 1u64), (s2.clone(), 2u64)], + &1, + &admin, + ); + assert_eq!(c.get_total_signer_weight(), 3u64); - fn read_dispute(ctx: &TestContext, settlement_id: u64) -> Dispute { - ctx.env.as_contract(&ctx.contract_id, || { - ctx.env - .storage() - .instance() - .get(&DataKey::Dispute(settlement_id)) - .unwrap() - }) - } + // Rotate s1 (weight 1) -> new_s1 (weight 5): total should become 2 + 5 = 7. + c.rotate_signer(&admin, &s1, &new_s1, &5u64); + assert_eq!(c.get_total_signer_weight(), 7u64); - fn read_settlement(ctx: &TestContext, settlement_id: u64) -> Settlement { - ctx.env.as_contract(&ctx.contract_id, || { - ctx.env - .storage() - .instance() - .get(&DataKey::Settlement(settlement_id)) - .unwrap() - }) + // The old signer address must no longer carry any weight. + let res = c.try_rotate_signer(&admin, &s1, &new_s1, &1u64); + assert_eq!(res, Err(Ok(TreasuryError::SignerNotFound))); } + /// Rotating a signer to a LOWER weight updates total_signer_weight, but + /// must not retroactively shrink approval_weight already accumulated on + /// an in-flight settlement — a settlement that already reached quorum + /// stays executable even after the approving signer's weight drops. #[test] - fn test_raise_dispute_holds_settlement_and_records_dispute() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::Raised); - assert_eq!(dispute.resolution_weight, 0u64); - assert_eq!(dispute.raised_by, ctx.signer1); - assert_eq!(dispute.reason, 1u32); - assert!(dispute.voters.is_empty()); + fn test_rotate_signer_to_lower_weight_preserves_inflight_quorum() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + let merchant = soroban_sdk::Address::generate(&e); + let new_s1 = soroban_sdk::Address::generate(&e); + // threshold=3, s1 weight=3 (alone meets threshold), s2 weight=1 + c.initialize( + &soroban_sdk::vec![&e, (s1.clone(), 3u64), (s2.clone(), 1u64)], + &3, + &admin, + ); + assert_eq!(c.get_total_signer_weight(), 4u64); - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::OnHold)); - } + let sid = c.propose_settlement(&s1, &token, &500u64, &merchant); + c.approve_settlement(&s1, &sid); - #[test] - fn test_raise_dispute_twice_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + // Rotate s1 down to weight 1 *after* it already approved. + c.rotate_signer(&admin, &s1, &new_s1, &1u64); + assert_eq!(c.get_total_signer_weight(), 2u64, "total weight must reflect the new, lower weight"); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let result = client.try_raise_dispute(&ctx.signer2, &settlement_id, &2u32); - assert_eq!(result, Err(Ok(TreasuryError::DisputeAlreadyRaised))); + // The settlement's already-captured approval_weight (3) is a snapshot + // and is unaffected by the later rotation, so it still clears the + // threshold (3) and executes successfully. + c.execute_settlement(&s1, &sid, &token); + let settlement = c.get_settlement(&sid).unwrap(); + assert!(matches!(settlement.status, SettlementStatus::Executed)); } #[test] - fn test_votes_resolve_dispute_in_favour_of_claimant() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - - // First vote: weight 1 < threshold 2, dispute stays Raised. - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::Raised); - assert_eq!(dispute.resolution_weight, 1u64); - - // Second vote reaches the threshold and resolves in favour of the claimant. - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::ResolvedClaimant); - assert_eq!(dispute.resolution_weight, 2u64); + fn test_rotate_signer_requires_admin() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let non_admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let new_s1 = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e, (s1.clone(), 1u64)], &1, &admin); - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::Cancelled)); + let res = c.try_rotate_signer(&non_admin, &s1, &new_s1, &1u64); + assert_eq!(res, Err(Ok(TreasuryError::Unauthorized))); } #[test] - fn test_votes_resolve_dispute_in_favour_of_counterparty() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &false); - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &false); - - let dispute = read_dispute(&ctx, settlement_id); - assert_eq!(dispute.status, DisputeStatus::ResolvedCounterparty); + fn test_rotate_signer_rejects_duplicate_new_signer() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let s1 = soroban_sdk::Address::generate(&e); + let s2 = soroban_sdk::Address::generate(&e); + c.initialize( + &soroban_sdk::vec![&e, (s1.clone(), 1u64), (s2.clone(), 2u64)], + &1, + &admin, + ); - let settlement = read_settlement(&ctx, settlement_id); - assert!(matches!(settlement.status, SettlementStatus::Pending)); + // s2 is already an active signer; rotating s1 onto it must be rejected. + let res = c.try_rotate_signer(&admin, &s1, &s2, &5u64); + assert_eq!(res, Err(Ok(TreasuryError::DuplicateSigner))); } - #[test] - fn test_signer_cannot_vote_twice() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + // ── daily withdrawal limit tests ───────────────────────────────────────── - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + #[test] + fn test_withdraw_within_daily_limit_succeeds() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::AlreadyVoted))); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &600u64); + c.withdraw(&admin, &token, &user, &400u64); } #[test] - fn test_non_signer_cannot_vote() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + fn test_withdraw_exceeding_daily_limit_rejected() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let outsider = Address::generate(&ctx.env); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &600u64); - let result = client.try_vote_dispute_resolution(&outsider, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::UnauthorizedSigner))); + let res = c.try_withdraw(&admin, &token, &user, &500u64); + assert_eq!(res, Err(Ok(TreasuryError::DailyLimitExceeded))); } #[test] - fn test_vote_without_dispute_fails() { - let ctx = setup(); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - let settlement_id = client.propose_settlement(&ctx.signer1, &ctx.token, &1000u64, &ctx.merchant); + fn test_withdraw_limit_is_per_token() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token_a = soroban_sdk::Address::generate(&e); + let token_b = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); + + c.set_daily_withdraw_limit(&admin, &token_a, &1_000u64); + c.withdraw(&admin, &token_a, &user, &1_000u64); - let result = client.try_vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::DisputeNotFound))); + // token_b has no configured limit, so it is unrestricted even though + // token_a's window is exhausted. + c.withdraw(&admin, &token_b, &user, &1_000_000u64); } #[test] - fn test_resolve_before_threshold_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); + fn test_withdraw_window_resets_after_24h() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); + c.set_daily_withdraw_limit(&admin, &token, &1_000u64); + c.withdraw(&admin, &token, &user, &1_000u64); + assert_eq!( + c.try_withdraw(&admin, &token, &user, &1u64), + Err(Ok(TreasuryError::DailyLimitExceeded)) + ); - let result = client.try_resolve_dispute(&ctx.signer2, &settlement_id, &true); - assert_eq!(result, Err(Ok(TreasuryError::ThresholdNotMet))); + e.ledger().with_mut(|li| li.timestamp += 86_400); + // A full window has elapsed, so the cap applies fresh. + c.withdraw(&admin, &token, &user, &1_000u64); } #[test] - fn test_vote_after_resolution_fails() { - let ctx = setup(); - let settlement_id = propose_and_raise(&ctx); - - let client = TreasuryContractClient::new(&ctx.env, &ctx.contract_id); - client.vote_dispute_resolution(&ctx.signer1, &settlement_id, &true); - client.vote_dispute_resolution(&ctx.signer2, &settlement_id, &true); + fn test_withdraw_without_configured_limit_is_unrestricted() { + let (e, id) = setup(); + let c = client(&e, &id); + let admin = soroban_sdk::Address::generate(&e); + let user = soroban_sdk::Address::generate(&e); + let token = soroban_sdk::Address::generate(&e); + c.initialize(&soroban_sdk::vec![&e], &1, &admin); - let result = client.try_vote_dispute_resolution(&ctx.signer3, &settlement_id, &false); - assert_eq!(result, Err(Ok(TreasuryError::DisputeNotRaised))); + c.withdraw(&admin, &token, &user, &1_000_000_000u64); } } diff --git a/abis/invoice.json b/abis/invoice.json index bc326a9..f289192 100644 --- a/abis/invoice.json +++ b/abis/invoice.json @@ -4,26 +4,21 @@ "functions": [ "initialize", "create_invoice", - "mark_paid", "get_invoice", "get_invoice_status", - "cancel_invoice", + "get_invoices_by_merchant", + "mark_paids", + "cancel_invoiced", "request_refund", - "batch_expire(offset: u32, limit: u32, returns: u32)", + "release_escrow", + "batch_expire", + "set_treasury", + "get_treasury", + "raise_dispute", "pause", "unpause", "set_grace_window", - "get_grace_window", - "release_escrow" + "get_grace_window" ], - "events": [ - "invoice_created", - "invoice_paid", - "invoice_expired", - "invoice_cancelled", - "invoice_refund_req", - "escrow_released", - "contract_paused", - "contract_unpaused" - ] + "events": ["invoice_created", "invoice_paid", "invoice_expired", "invoice_cancelled", "invoice_refund_req", "escrow_released", "contract_paused", "contract_unpaused", "dispute_raised"] } diff --git a/abis/treasury.json b/abis/treasury.json index b93dacd..e24e1aa 100644 --- a/abis/treasury.json +++ b/abis/treasury.json @@ -1,64 +1,26 @@ { "contract": "treasury", - "version": "1.0.0", + "version": "1.1.0", "functions": [ "initialize", "set_signer", "propose_settlement", - "propose_partial_settlement", "approve_settlement", - "approve_partial_settlement", "execute_settlement", - "partially_execute_settlement", - "cancel_settlement", + "simulate_settlement", "get_pending_settlements", - "get_pending_settlements_page", - "get_settlement", - "update_threshold", "pause", "unpause", + "get_threshold", + "update_threshold", "raise_dispute", "resolve_dispute", - "vote_dispute_resolution", - "deposit", - "withdraw", - "add_allowed_token", - "remove_allowed_token", - "get_allowed_tokens", - "propose_signer_rotation", - "approve_signer_rotation", - "update_merchant_payout_address", - "get_merchant_payout_address", - "hold_settlement", - "release_hold" - ], - "execute_settlement_params": ["signer: Address", "settlement_id: u64", "token_contract: Address"], - "threshold": "2-of-3", - "events": [ - "treasury_initialized", - "signer_weight_set", - "settlement_proposed", - "settlement_approved", - "settlement_partial_approved", - "settlement_executed", - "settlement_partial_executed", - "settlement_cancelled", - "threshold_updated", - "contract_paused", - "contract_unpaused", - "dispute_raised", - "dispute_resolved", - "dispute_resolution_voted", + "update_settlement_merchant", + "get_settlement", "deposit", "withdraw", - "token_allowed", - "token_removed", - "rotation_proposed", - "rotation_approved", - "rotation_executed", - "merchant_payout_address_updated", - "settlement_held", - "settlement_released" + "add_token_to_allowlist", + "remove_token_from_allowlist" ], "errors": { "1": "ContractPaused", diff --git a/comebackhere-backend/src/db/mongo.ts b/comebackhere-backend/src/db/mongo.ts index 86371f0..6218c90 100644 --- a/comebackhere-backend/src/db/mongo.ts +++ b/comebackhere-backend/src/db/mongo.ts @@ -19,6 +19,7 @@ export interface InvoiceRecord { token: string amount: number due_date: number + reference?: string status: InvoiceStatus created_at: Date updated_at: Date @@ -62,33 +63,6 @@ export interface IndexerCursor { processed_event_ids?: string[] } -export type InvoiceStatus = - | "Pending" - | "Paid" - | "Expired" - | "Cancelled" - | "RefundRequested" - | "Released" - -export interface InvoiceRecord { - invoice_id: string - merchant_address: string - payer_address: string | null - token: string - amount: string - status: InvoiceStatus - created_at: number | null // Unix timestamp (seconds) - expires_at: number | null // Unix timestamp (seconds) - paid_at: number | null // Unix timestamp (seconds) - tx_hash: string | null - updated_at: Date -} - -export interface InvoiceSearchFilter { - status?: InvoiceStatus - merchant_address?: string -} - export const DEFAULT_PAGE_SIZE = 20 export const MAX_PAGE_SIZE = 100 diff --git a/comebackhere-backend/src/lib/soroban.ts b/comebackhere-backend/src/lib/soroban.ts index 2732555..1590545 100644 --- a/comebackhere-backend/src/lib/soroban.ts +++ b/comebackhere-backend/src/lib/soroban.ts @@ -145,6 +145,61 @@ export async function getOnChainSettlement( } } +export interface SettlementSimulation { + settlementId: bigint + status: string + wouldSucceed: boolean + approvalWeight: bigint + threshold: bigint + settlementAmount: bigint + treasuryBalance: bigint + projectedBalance: bigint +} + +/** + * Previews the outcome of `execute_settlement` for `settlementId` without submitting + * or mutating any on-chain state, by simulating a call to the treasury contract's + * read-only `simulate_settlement` function. + */ +export async function getSettlementSimulation( + client: SorobanClient, + treasuryContractId: string, + settlementId: bigint, + sourceAccount: string, + networkPassphrase: string, +): Promise { + const retval = await simulateContractRead( + client, + treasuryContractId, + "simulate_settlement", + [nativeToScVal(settlementId, { type: "u64" })], + sourceAccount, + networkPassphrase, + ) + + const map = retval.map() + if (!map) { + throw Object.assign(new Error("Invalid simulate_settlement response"), { status: 422 }) + } + + const entries: Record = {} + for (const entry of map) { + const key = entry.key().sym().toString() + entries[key] = entry.val() + } + + return { + settlementId: BigInt(entries.settlement_id?.u64()?.toString() ?? "0"), + status: scValToSettlementStatus(entries.status ?? xdr.ScVal.scvVoid()), + wouldSucceed: entries.would_succeed?.b() ?? false, + approvalWeight: BigInt(entries.approval_weight?.u64()?.toString() ?? "0"), + threshold: BigInt(entries.threshold?.u64()?.toString() ?? "0"), + settlementAmount: BigInt(entries.settlement_amount?.u64()?.toString() ?? "0"), + treasuryBalance: BigInt(entries.treasury_balance?.i128()?.toString() ?? "0"), + projectedBalance: BigInt(entries.projected_balance?.i128()?.toString() ?? "0"), + } +} + export async function submitContractCall( client: SorobanClient, contractId: string, diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index 99c1c2b..645ef36 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -13,6 +13,7 @@ export interface CreateInvoiceBody { token: string amount: number due_date: number // Unix timestamp (seconds) + reference?: string // Optional merchant-supplied reference, max 64 bytes } // Soroban interaction extracted so it can be replaced in tests @@ -53,7 +54,9 @@ export async function createInvoice( nativeToScVal(body.amount, { type: "u64" }), nativeToScVal(expiresInSeconds, { type: "u64" }), nativeToScVal(null, { type: "void" }), - nativeToScVal(null, { type: "void" }), + body.reference + ? nativeToScVal(body.reference, { type: "string" }) + : nativeToScVal(null, { type: "void" }), ] const account = await client.getAccount(keypair.publicKey()) @@ -316,6 +319,11 @@ router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, re * type: integer * description: Future Unix timestamp (seconds) * example: 1720000000 + * reference: + * type: string + * description: Optional merchant-supplied reference (e.g. an order ID), max 64 bytes + * maxLength: 64 + * example: "order-12345" * responses: * 201: * description: Invoice created @@ -381,6 +389,7 @@ router.post("/", validateBody(createInvoiceSchema), async (req: Request, res: Re token: body.token, amount: body.amount, due_date: body.due_date, + reference: body.reference, status: "Pending", created_at: now, updated_at: now, diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index 753add9..1c67a4f 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -3,6 +3,7 @@ import { Keypair, nativeToScVal, Address } from "stellar-sdk" import { buildSorobanClient, getOnChainSettlement, + getSettlementSimulation, getTokenBalance, submitContractCall, type SorobanClient, @@ -374,6 +375,151 @@ router.post("/execute-settlement", validateBody(executeSettlementSchema), async } }) +export interface SimulateSettlementBody { + settlement_id: number +} + +export interface SimulateSettlementDeps { + getSettlementSimulation: typeof getSettlementSimulation +} + +const defaultSimulateSettlementDeps: SimulateSettlementDeps = { + getSettlementSimulation, +} + +/** + * Previews whether `execute_settlement` would succeed for `settlement_id` right now + * (quorum reached, treasury balance sufficient) without submitting or mutating any + * on-chain state. + */ +export async function simulateSettlement( + body: SimulateSettlementBody, + env: { + rpcUrl: string + treasuryContractId: string + signerSecret: string + networkPassphrase: string + }, + clientOverride?: SorobanClient, + deps: SimulateSettlementDeps = defaultSimulateSettlementDeps, +): Promise<{ + settlement_id: number + status: string + would_succeed: boolean + approval_weight: string + threshold: string + settlement_amount: string + treasury_balance: string + projected_balance: string +}> { + const client = clientOverride ?? buildSorobanClient(env.rpcUrl) + const keypair = Keypair.fromSecret(env.signerSecret) + + const simulation = await deps.getSettlementSimulation( + client, + env.treasuryContractId, + BigInt(body.settlement_id), + keypair.publicKey(), + env.networkPassphrase, + ) + + return { + settlement_id: body.settlement_id, + status: simulation.status, + would_succeed: simulation.wouldSucceed, + approval_weight: simulation.approvalWeight.toString(), + threshold: simulation.threshold.toString(), + settlement_amount: simulation.settlementAmount.toString(), + treasury_balance: simulation.treasuryBalance.toString(), + projected_balance: simulation.projectedBalance.toString(), + } +} + +/** + * @openapi + * /api/treasury/simulate-settlement: + * post: + * tags: [Treasury] + * summary: Preview whether execute-settlement would succeed, without mutating state + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [settlement_id] + * properties: + * settlement_id: + * type: integer + * description: Positive integer settlement ID + * example: 1 + * responses: + * 200: + * description: Simulation result + * content: + * application/json: + * schema: + * type: object + * properties: + * settlement_id: + * type: integer + * example: 1 + * status: + * type: string + * example: "Pending" + * would_succeed: + * type: boolean + * example: true + * approval_weight: + * type: string + * example: "2" + * threshold: + * type: string + * example: "2" + * settlement_amount: + * type: string + * example: "5000000" + * treasury_balance: + * type: string + * example: "10000000" + * projected_balance: + * type: string + * example: "5000000" + * 400: + * description: settlement_id is not a positive integer + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 422: + * description: Soroban simulation failure (e.g. settlement not found) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 503: + * description: Service misconfiguration + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +router.post("/simulate-settlement", validateBody(settlementIdSchema), async (req: Request, res: Response) => { + const env = requireEnv(res) + if (!env) return + + const settlementId = req.body.settlement_id + + try { + const result = await simulateSettlement({ settlement_id: settlementId }, env) + res.json(result) + } catch (err: unknown) { + const status = (err as { status?: number })?.status ?? 500 + const message = err instanceof Error ? err.message : String(err) + res.status(status).json({ error: message }) + } +}) + /** * @openapi * /api/treasury/on-hold-settlements: diff --git a/comebackhere-backend/src/schemas/index.ts b/comebackhere-backend/src/schemas/index.ts index 132afa2..1dd77d8 100644 --- a/comebackhere-backend/src/schemas/index.ts +++ b/comebackhere-backend/src/schemas/index.ts @@ -37,6 +37,10 @@ export const createInvoiceSchema = z.object({ .number({ message: "amount must be a positive number" }) .positive("amount must be a positive number"), due_date: futureTimestamp, + reference: z + .string() + .refine((val) => Buffer.byteLength(val, "utf8") <= 64, "reference must not exceed 64 bytes") + .optional(), }) export const invoiceIdParamSchema = z.object({ diff --git a/comebackhere-backend/src/tests/treasury.test.ts b/comebackhere-backend/src/tests/treasury.test.ts index 3856e7e..a72e175 100644 --- a/comebackhere-backend/src/tests/treasury.test.ts +++ b/comebackhere-backend/src/tests/treasury.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import request from "supertest" import { createApp } from "../app.js" -import { executeSettlementWithBalanceCheck } from "../routes/treasury.js" +import { executeSettlementWithBalanceCheck, simulateSettlement } from "../routes/treasury.js" import { setGraceWindow } from "../routes/invoice-settings.js" import type { SorobanClient } from "../lib/soroban.js" import { SorobanRpc, SorobanDataBuilder, xdr } from "stellar-sdk" @@ -344,6 +344,116 @@ describe("executeSettlementWithBalanceCheck — #205 additional cases", () => { }) }) +describe("simulateSettlement", () => { + const env = { + rpcUrl: ENV.SOROBAN_RPC_URL, + treasuryContractId: TREASURY_CONTRACT, + signerSecret: SIGNER_SECRET, + networkPassphrase: NETWORK, + } + + const mockClient = makeMockClient() + + it("returns would_succeed: true without submitting or mutating state", async () => { + const getSettlementSimulation = vi.fn().mockResolvedValue({ + settlementId: 1n, + status: "Pending", + wouldSucceed: true, + approvalWeight: 2n, + threshold: 2n, + settlementAmount: 5_000_000n, + treasuryBalance: 10_000_000n, + projectedBalance: 5_000_000n, + }) + + const result = await simulateSettlement({ settlement_id: 1 }, env, mockClient, { + getSettlementSimulation, + }) + + expect(result).toEqual({ + settlement_id: 1, + status: "Pending", + would_succeed: true, + approval_weight: "2", + threshold: "2", + settlement_amount: "5000000", + treasury_balance: "10000000", + projected_balance: "5000000", + }) + expect(getSettlementSimulation).toHaveBeenCalledWith( + mockClient, + TREASURY_CONTRACT, + 1n, + expect.any(String), + NETWORK, + ) + }) + + it("returns would_succeed: false when quorum or balance checks fail", async () => { + const getSettlementSimulation = vi.fn().mockResolvedValue({ + settlementId: 2n, + status: "Pending", + wouldSucceed: false, + approvalWeight: 1n, + threshold: 2n, + settlementAmount: 5_000_000n, + treasuryBalance: 1_000_000n, + projectedBalance: -4_000_000n, + }) + + const result = await simulateSettlement({ settlement_id: 2 }, env, mockClient, { + getSettlementSimulation, + }) + + expect(result.would_succeed).toBe(false) + expect(result.projected_balance).toBe("-4000000") + }) + + it("propagates a 422 when the settlement does not exist", async () => { + const getSettlementSimulation = vi + .fn() + .mockRejectedValue(Object.assign(new Error("Soroban simulation failed: Error(Contract, #10)"), { status: 422 })) + + await expect( + simulateSettlement({ settlement_id: 999 }, env, mockClient, { getSettlementSimulation }), + ).rejects.toMatchObject({ status: 422 }) + }) +}) + +describe("POST /api/treasury/simulate-settlement", () => { + const app = createApp() + let envBackup: Record + + beforeEach(() => { + envBackup = {} + for (const key of Object.keys(ENV)) { + envBackup[key] = process.env[key] + process.env[key] = ENV[key as keyof typeof ENV] + } + }) + + afterEach(() => { + for (const [key, val] of Object.entries(envBackup)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + }) + + it("400 when settlement_id is missing", async () => { + const res = await request(app).post("/api/treasury/simulate-settlement").send({}) + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/settlement_id/) + }) + + it("503 when required env vars are missing", async () => { + delete process.env.TREASURY_CONTRACT_ID + const res = await request(app) + .post("/api/treasury/simulate-settlement") + .send({ settlement_id: 1 }) + expect(res.status).toBe(503) + }) +}) + // ── #205: HTTP layer — additional coverage ──────────────────────────────────── describe("POST /api/treasury/execute-settlement — HTTP layer additional cases", () => { diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..6d1c2e2 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,17 @@ +# `contracts/` + +This is the **legacy, mirrored** Rust contracts tree. It is a valid PR +target — it does not have its own CI workflow, and it is still referenced +from documentation such as [`docs/error-codes.md`](../docs/error-codes.md) +— but it is not the canonical source of truth. + +**[`COMEBACKHERE-contracts/`](../COMEBACKHERE-contracts/) is canonical for +CI.** New contract work should target that tree: it is the one built and +tested by `make test` and `.github/workflows/ci-contracts.yml` (and the +other `ci-*.yml` workflows), and it is the source ABI snapshots in +[`abis/`](../abis/) are generated from. + +See [`CONTRIBUTING.md`](../CONTRIBUTING.md#which-stack-do-i-work-on) for the +full rule on which tree to target, and +[`ARCHITECTURE.md`](../ARCHITECTURE.md) for why both trees exist and how +they're kept in sync. diff --git a/contracts/settlement/src/integration_settlement_multisig.rs b/contracts/settlement/src/integration_settlement_multisig.rs new file mode 100644 index 0000000..7c57bf3 --- /dev/null +++ b/contracts/settlement/src/integration_settlement_multisig.rs @@ -0,0 +1,186 @@ +#![cfg(test)] + +//! Multi-sig propose/approve integration coverage for the legacy settlement +//! contract, mirroring +//! `COMEBACKHERE-contracts/contracts/treasury/src/integration_settlement_multisig.rs` +//! in the canonical tree (see #31 / CONTRIBUTING.md on keeping the two trees +//! in sync). +//! +//! Unlike the canonical treasury contract, this legacy contract has no +//! separate `execute_settlement` entry point: `approve_settlement` returns +//! the accumulated `approval_weight` and `threshold` directly, and a caller +//! reaching quorum (`approval_weight >= threshold`) *is* the execute signal +//! this simpler contract exposes. These tests treat "quorum reached" as the +//! execute step the canonical tests exercise explicitly via +//! `execute_settlement`. + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn setup_env() -> (Env, Address) { + let e = Env::default(); + e.mock_all_auths(); + let contract_id = e.register_contract(None, SettlementContract); + (e, contract_id) +} + +fn make_client<'a>(e: &'a Env, id: &Address) -> SettlementContractClient<'a> { + SettlementContractClient::new(e, id) +} + +#[test] +fn test_multisig_propose_collect_2_of_3_reaches_quorum() { + let (e, id) = setup_env(); + let c = make_client(&e, &id); + + let signer_a = Address::generate(&e); + let signer_b = Address::generate(&e); + let signer_c = Address::generate(&e); + let merchant = Address::generate(&e); + + c.initialize( + &soroban_sdk::vec![ + &e, + (signer_a.clone(), 1u64), + (signer_b.clone(), 1u64), + (signer_c.clone(), 1u64), + ], + &2u64, + ); + + let sid = c.propose(&signer_a, &merchant, &5_000_000u64); + + let r1 = c.approve_settlement(&signer_a, &sid); + assert_eq!(r1.approval_weight, 1); + assert!( + r1.approval_weight < r1.threshold, + "quorum should not be reached after 1-of-2 approvals" + ); + + let r2 = c.approve_settlement(&signer_b, &sid); + assert_eq!(r2.approval_weight, 2); + assert!( + r2.approval_weight >= r2.threshold, + "quorum should be reached after 2-of-2 approvals" + ); +} + +#[test] +fn test_single_signer_insufficient_for_threshold_2() { + let (e, id) = setup_env(); + let c = make_client(&e, &id); + + let signer_a = Address::generate(&e); + let signer_b = Address::generate(&e); + let signer_c = Address::generate(&e); + let merchant = Address::generate(&e); + + c.initialize( + &soroban_sdk::vec![ + &e, + (signer_a.clone(), 1u64), + (signer_b.clone(), 1u64), + (signer_c.clone(), 1u64), + ], + &2u64, + ); + + let sid = c.propose(&signer_a, &merchant, &1_000_000u64); + let res = c.approve_settlement(&signer_a, &sid); + + assert!( + res.approval_weight < res.threshold, + "a single signer must not be able to reach a threshold of 2 alone" + ); +} + +#[test] +fn test_weighted_signers_reach_threshold() { + let (e, id) = setup_env(); + let c = make_client(&e, &id); + + let signer_a = Address::generate(&e); + let signer_b = Address::generate(&e); + let signer_c = Address::generate(&e); + let merchant = Address::generate(&e); + + c.initialize( + &soroban_sdk::vec![ + &e, + (signer_a.clone(), 2u64), + (signer_b.clone(), 1u64), + (signer_c.clone(), 1u64), + ], + &2u64, + ); + + let sid = c.propose(&signer_a, &merchant, &3_000_000u64); + let res = c.approve_settlement(&signer_a, &sid); + + assert_eq!(res.approval_weight, 2); + assert!( + res.approval_weight >= res.threshold, + "a single signer with weight=2 should meet threshold=2 alone" + ); +} + +#[test] +fn test_multiple_settlements_independent_approvals() { + let (e, id) = setup_env(); + let c = make_client(&e, &id); + + let signer_a = Address::generate(&e); + let signer_b = Address::generate(&e); + let merchant = Address::generate(&e); + + c.initialize( + &soroban_sdk::vec![&e, (signer_a.clone(), 1u64), (signer_b.clone(), 1u64)], + &2u64, + ); + + let s1 = c.propose(&signer_a, &merchant, &1_000_000u64); + let s2 = c.propose(&signer_a, &merchant, &2_000_000u64); + + c.approve_settlement(&signer_a, &s1); + let r1 = c.approve_settlement(&signer_b, &s1); + assert!( + r1.approval_weight >= r1.threshold, + "s1 should reach quorum" + ); + + let r2 = c.approve_settlement(&signer_a, &s2); + assert!( + r2.approval_weight < r2.threshold, + "s2 should still be short of quorum with only one approval" + ); +} + +#[test] +fn test_quorum_reached_settlement_can_still_be_cancelled() { + // The legacy contract has no `execute_settlement` call that flips + // status to `Executed`; a settlement stays `Pending` (and thus + // cancellable) even after quorum is reached on-chain. Execution is + // expected to be driven by an off-chain caller reacting to + // `ApproveResult`. + let (e, id) = setup_env(); + let c = make_client(&e, &id); + + let signer_a = Address::generate(&e); + let signer_b = Address::generate(&e); + let merchant = Address::generate(&e); + + c.initialize( + &soroban_sdk::vec![&e, (signer_a.clone(), 1u64), (signer_b.clone(), 1u64)], + &2u64, + ); + + let sid = c.propose(&signer_a, &merchant, &10_000_000u64); + c.approve_settlement(&signer_a, &sid); + let res = c.approve_settlement(&signer_b, &sid); + assert!(res.approval_weight >= res.threshold); + + c.cancel(&signer_a, &sid); + + let cancel_again = c.try_cancel(&signer_a, &sid); + assert_eq!(cancel_again, Err(Ok(SettlementError::NotPending))); +} diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index cf77ec4..2237bef 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -200,6 +200,9 @@ impl SettlementContract { } } +#[cfg(test)] +mod integration_settlement_multisig; + #[cfg(test)] mod tests { use super::*; @@ -403,6 +406,8 @@ mod tests { // Cancelling an already-cancelled (or executed) settlement fails with NotPending let res = c.try_cancel(&proposer, &sid); assert_eq!(res, Err(Ok(SettlementError::NotPending))); + } + use proptest::prelude::*; extern crate std; use std::collections::HashSet; diff --git a/docs/MAINNET_DEPLOYMENT.md b/docs/MAINNET_DEPLOYMENT.md index 0d011f8..d6a454e 100644 --- a/docs/MAINNET_DEPLOYMENT.md +++ b/docs/MAINNET_DEPLOYMENT.md @@ -123,6 +123,78 @@ that itself meets the current threshold. their key must be removed via a signed `set_signer` transaction within 24 hours. The remaining signers must meet quorum to execute this. +### Signer Loss or Compromise Recovery + +This is the procedure for the scenario the rest of this document does not +cover: a signer's key is lost or compromised, and the remaining signers can +no longer reach the current `Threshold` even with every one of them +approving. + +On-chain, `set_signer` and `update_threshold` (see +`COMEBACKHERE-contracts/contracts/treasury/src/lib.rs`) are gated by +`check_admin` — the single stored admin address — not by a live +signer-quorum vote. The contract itself does not require any treasury +signer's approval weight to execute either call. The multi-sig protection +around this recovery path is therefore an off-chain ceremony control (the +procedure below), the same way the rest of this document's governance +model is enforced by process rather than by the contract. + +#### Recovery procedure + +1. **Freeze the treasury.** Call `pause(admin)` immediately to block new + `propose_settlement` / `approve_settlement` / `execute_settlement` calls + while recovery is in progress. +2. **Convene an emergency ceremony.** Use the same [Signer + Roles](#signer-roles) as a deployment ceremony — Lead Deployer, remaining + Treasury Signers, and a Ceremony Witness at minimum — and record + identities and the reason for recovery in the ceremony log, per + [Ceremony](#ceremony). +3. **Lower the threshold first if needed.** Compute the total weight of the + signers that remain trusted (excluding the lost/compromised signer). If + that total is below the current threshold (`get_threshold`), call + `update_threshold(admin, new_threshold)` to bring the threshold to at or + below the remaining trusted weight **before** revoking the compromised + signer. Doing this out of order — revoking the signer first — can leave + the treasury unable to reach quorum for any settlement, including the one + that would restore a working signer set. +4. **Revoke the lost/compromised signer.** Call + `set_signer(admin, compromised_signer, 0)`. A weight of `0` removes that + signer's ability to approve settlements; there is no separate "remove" + call. +5. **Onboard a replacement signer, if any.** Call + `set_signer(admin, new_signer, weight)` for the replacement key, + generated and custodied per [Key Custody + Requirements](#key-custody-requirements) (hardware wallet, distinct + geography, no shared custody). +6. **Restore the threshold.** Once the replacement signer is onboarded and + the trusted total weight supports it, call `update_threshold` again to + return to (or set) the desired threshold. +7. **Unpause.** Call `unpause(admin)` once the signer set and threshold are + consistent. +8. **Record the recovery** in the deployment/ceremony log: old and new + signer addresses, old and new threshold values, and the transaction hash + for every `pause` / `update_threshold` / `set_signer` / `unpause` call. + +#### Minimum remaining signers needed + +Steps 1–7 need only the admin key's signature on-chain — `set_signer` and +`update_threshold` do not check treasury-signer approval weight. What +actually requires signer participation is this project's governance policy: +under the default configuration (**3-of-5 by weight**, minimum quorum 3), an +emergency ceremony still needs the same ceremony quorum as a normal +deployment — **3 signers** (Treasury Signer role, 2+, plus Lead +Deployer/Witness) — to independently verify and attest to the recovery +before the admin key is used. + +A **single** lost or compromised signer (5 → 4 remaining) can always be +recovered under the default model, since 4 remaining signers still clear the +3-signer ceremony quorum. If **two or more** signers are lost or compromised +at once, fewer than 3 trusted signers remain, the ceremony quorum itself +cannot be met, and recovery is gated solely by the admin key — a residual +single point of failure. Treat that case as a severity-1 incident (see +[Abort Conditions](#abort-conditions)) requiring out-of-band verification of +every remaining signer's identity before the admin proceeds. + --- ## Mainnet Signing Ceremony Checklist @@ -257,13 +329,63 @@ ceremony. The Ceremony Witness records each step. ### Emergency Rollback -If a critical issue is discovered after deployment: +If a critical issue is discovered after a deployment partially or fully +succeeds, follow this procedure. + +#### What rollback does and does not do + +**Soroban contracts cannot be un-deployed.** Any contract that was submitted +to the Stellar ledger during the failed deployment remains on-chain and is +accessible by anyone with its contract ID. Rollback only restores the local +`artifacts/addresses.json` address registry so that downstream services +(backend, frontend) can be pointed back at the previous known-good deployment. + +#### Scripted rollback + +`scripts/deploy_mainnet.sh` backs up `artifacts/addresses.json` to +`artifacts/addresses.json.bak` before overwriting it with new contract IDs. +To restore the previous address registry: + +```sh +scripts/deploy_mainnet.sh --rollback +``` -1. The Lead Deployer opens an emergency deployment issue -2. If the contracts support pause: execute `pause` via multi-sig to halt - operations immediately -3. Follow the standard ceremony process for any corrective deployment -4. Document the incident in a post-mortem within 48 hours +The script will: + +1. Confirm that `artifacts/addresses.json.bak` exists (created during the + previous successful deployment). +2. Save the current (potentially corrupt) `addresses.json` as a timestamped + forensic copy (e.g., `addresses.json.rollback-20260101T120000Z`). +3. Restore `artifacts/addresses.json` from the backup. +4. Print a detailed summary of contract-level implications and next steps. + +If no backup file exists (e.g., this was the first ever deployment), the +script exits with an error and provides manual recovery instructions. + +#### Manual recovery when no backup is available + +If `artifacts/addresses.json.bak` does not exist, reconstruct +`artifacts/addresses.json` from the on-chain deployment ceremony record: + +1. Open the deployment issue for the last known-good deployment. +2. Locate the recorded contract IDs (invoice, treasury, compliance) and + transaction hashes. +3. Manually write `artifacts/addresses.json` using the structure defined in + `artifacts/addresses.json.example`. +4. Commit the restored file and open a PR referencing the incident. + +#### Procedure after rollback + +1. The Lead Deployer opens an emergency deployment issue documenting: + - The partial or failed deployment's contract IDs + - Which contracts were deployed but not initialized + - The rollback timestamp and the restored address set +2. If any contract supports `pause`: execute `pause` via multi-sig to halt + operations on the abandoned contract immediately. +3. Reconfigure backend production secrets to use the restored (previous) + contract IDs from `artifacts/addresses.json`. +4. Follow the standard ceremony process for any corrective redeployment. +5. Document the incident in a post-mortem within 48 hours. --- diff --git a/docs/adr-0001-dual-source-trees.md b/docs/adr-0001-dual-source-trees.md new file mode 100644 index 0000000..dd996e3 --- /dev/null +++ b/docs/adr-0001-dual-source-trees.md @@ -0,0 +1,104 @@ +# ADR-0001: Dual Contract/Backend/Frontend Source Trees + +## Status + +Accepted + +## Context + +The repository contains two parallel source trees for each layer of the stack: + +| Layer | Canonical tree | Legacy tree | +| --- | --- | --- | +| Contracts (Rust) | `COMEBACKHERE-contracts/` | `contracts/` | +| Backend (Node/Express) | `comebackhere-backend/` | `backend/` | +| Frontend (React) | `comebackhere-frontend/` | `frontend/` | + +The legacy trees (`contracts/`, `backend/`, `frontend/`) were the original +in-tree copies of the source code. When the project reorganised to use +dedicated CI workflows per layer (`.github/workflows/ci-contracts.yml`, +`ci.yml`), new canonical directories were introduced with the `COMEBACKHERE-` +prefix. The legacy trees were kept in place rather than deleted. + +### Why the duplication was introduced + +1. **CI restructure.** The canonical trees are wired into GitHub Actions + workflows that run `cargo test`, `npm ci`, `tsc --noEmit`, and + `npm run build` on every PR. The legacy trees have no equivalent + dedicated CI — they rely on file-matching jobs that may or may not + cover a given change. + +2. **Backward-compatible doc references.** Documentation such as + `docs/error-codes.md` cites `contracts/invoice/src/lib.rs` as the + source of `InvoiceError`. Removing the legacy tree without updating + every reference would break cross-links. + +3. **Reviewer familiarity.** Contributors accustomed to the old paths + could continue reviewing changes at the same locations during the + transition period. + +4. **No-breaking-change migration.** Deleting the legacy trees in a + single PR would force every open PR and branch to rebase, creating + unnecessary churn across dozens of in-flight contributions. + +### What each tree contains + +The canonical trees carry the full current source plus event modules, +benchmark suites, integration tests, and ABI-generation hooks. The legacy +trees carry an older snapshot — some modules (e.g. `events.rs`, +`benchmark.rs`, `test.rs`) have never been back-ported. + +See [docs/contract-tree-feature-parity.md](./contract-tree-feature-parity.md) +for a detailed comparison. + +--- + +## Decision + +We maintain the dual-tree structure until the following conditions are met: + +1. **All doc references** to legacy paths are updated or removed. +2. **Full feature parity** is achieved (see the migration checklist in + `contract-tree-feature-parity.md`). +3. **No open PRs** target the legacy trees. +4. A **single PR** deletes the legacy trees and updates any remaining + references. + +Until then, the rule is simple: + +> New work targets the `COMEBACKHERE-*` tree. The legacy tree is a valid +> PR target only for narrow fixes to files that CI already covers. + +--- + +## Consequences + +### Positive + +- No breaking changes to existing PRs or branches. +- Contributors can onboard at their own pace without re-learning paths. +- CI coverage is unambiguous — the canonical tree is the only one with + dedicated status checks. + +### Negative + +- Contributors must learn which tree to target (mitigated by + `CONTRIBUTING.md` and `ARCHITECTURE.md`). +- Feature parity gaps can accumulate silently (mitigated by the + parity table in `docs/contract-tree-feature-parity.md`). +- The repository carries more files than strictly necessary until the + legacy trees are removed. + +### Risks + +- If the migration stalls, the legacy trees could drift far enough + apart that porting changes becomes error-prone. The parity table + and regular reviews should prevent this. + +--- + +## References + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — canonical vs mirrored trees. +- [CONTRIBUTING.md](../CONTRIBUTING.md) — which tree to target. +- [docs/contract-tree-feature-parity.md](./contract-tree-feature-parity.md) — feature parity comparison. diff --git a/docs/api-reference.md b/docs/api-reference.md index b2e09e7..5eb8805 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,6 +8,10 @@ All responses are JSON. > **Machine-readable spec:** A Swagger/OpenAPI 3.0 spec is served at > [`GET /api-docs/swagger.json`](http://localhost:3000/api-docs/swagger.json) (raw JSON) > and [`GET /api-docs`](http://localhost:3000/api-docs) (interactive Swagger UI). +> +> **Rate limits:** All endpoints are subject to per-IP rate limiting. See +> [docs/rate-limits.md](./rate-limits.md) for default limits, configuration, +> and the 429 response shape. --- diff --git a/docs/contract-interaction-guide.md b/docs/contract-interaction-guide.md index 77051a5..9971a7e 100644 --- a/docs/contract-interaction-guide.md +++ b/docs/contract-interaction-guide.md @@ -382,6 +382,99 @@ soroban contract invoke \ --- +### Check whether an address is allowed (`is_allowed`) + +`is_allowed` returns `true` only when an address has been explicitly allowed +**and** its allowance has not expired. Three distinct scenarios produce a +`false` result, and integrators must not treat them as equivalent — the +appropriate response differs in each case. + +#### Scenario 1 — Address was never allowed + +The address has never been passed to `allow_address`. `is_allowed` returns +`false` immediately. The correct action is to route the payer through your +KYC/onboarding flow before retrying. + +```sh +# The address GNEW... has never been allowed. +soroban contract invoke \ + --id $COMPLIANCE_CONTRACT \ + --source $SECRET_KEY \ + --rpc-url $RPC_URL \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- is_allowed \ + --address GNEWADDRESSNEVERALLOWED... +# → false +``` + +Expected response: `false` + +The backend maps this to error code `COMPLIANCE_NOT_ALLOWED`. Return HTTP 403 +to the caller with a message indicating that the address must complete +onboarding before transacting. + +#### Scenario 2 — Address was allowed but the allowance has expired + +`allow_address` was called with an `expires_at` timestamp that has since +passed. `is_allowed` returns `false` because the allowance window closed. +The address is not blocked — it simply needs to be re-allowed (e.g., after +a periodic compliance re-check). + +```sh +# GEXPIRED... was allowed until ledger time 1700000000, which has passed. +soroban contract invoke \ + --id $COMPLIANCE_CONTRACT \ + --source $SECRET_KEY \ + --rpc-url $RPC_URL \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- is_allowed \ + --address GEXPIREDADDRESS... +# → false (allowance window closed) +``` + +Expected response: `false` + +The backend maps this to error code `COMPLIANCE_ALLOWANCE_EXPIRED`. Return +HTTP 403 with a message telling the caller that their compliance approval has +lapsed and they must renew it. Do **not** present this to the user as a block +— it is a renewal prompt. + +#### Scenario 3 — Address is explicitly blocked + +`block_address` was called for this address. `is_allowed` returns `false` +regardless of any prior allowance. A blocked address must not transact until +the block is explicitly lifted by a compliance admin. + +```sh +# GBLOCKED... was explicitly blocked via block_address. +soroban contract invoke \ + --id $COMPLIANCE_CONTRACT \ + --source $SECRET_KEY \ + --rpc-url $RPC_URL \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- is_allowed \ + --address GBLOCKEDADDRESS... +# → false (explicit block in effect) +``` + +Expected response: `false` + +The backend maps this to error code `COMPLIANCE_BLOCKED`. Return HTTP 403 +with a message indicating that the address is blocked from transacting. Do +**not** expose details about why it was blocked to the end-user; log the event +for compliance audit purposes and direct the user to your support channel. + +#### Summary of `is_allowed` return values + +| State | `is_allowed` result | Error code | Suggested HTTP status | +| ----- | ------------------- | ---------- | --------------------- | +| Never allowed | `false` | `COMPLIANCE_NOT_ALLOWED` | 403 | +| Allowance expired | `false` | `COMPLIANCE_ALLOWANCE_EXPIRED` | 403 | +| Explicitly blocked | `false` | `COMPLIANCE_BLOCKED` | 403 | +| Allowed and within window | `true` | — | proceed | + +--- + ## Invoice grace window ### Read diff --git a/docs/contract-tree-feature-parity.md b/docs/contract-tree-feature-parity.md new file mode 100644 index 0000000..381dd32 --- /dev/null +++ b/docs/contract-tree-feature-parity.md @@ -0,0 +1,75 @@ +# Contract Source Tree Feature Parity + +This page tracks feature parity between the two in-tree contract workspaces: + +- **`COMEBACKHERE-contracts/`** — the canonical, CI-enforced workspace. +- **`contracts/`** — the legacy, mirrored workspace. + +> **Rule of thumb:** target `COMEBACKHERE-contracts/` for all new work. +> See [ARCHITECTURE.md](../ARCHITECTURE.md) for background. + +--- + +## Workspace layout + +| | `COMEBACKHERE-contracts/` | `contracts/` | +| --- | --- | --- | +| Workspace file | `Cargo.toml` (members: `contracts/*`) | `Cargo.toml` (members listed explicitly) | +| CI coverage | `ci-contracts.yml` (build + test) | No dedicated CI; only file-matching jobs | +| ABI generation | Source for `make update-abi-snapshots` | Not used for ABI snapshots | + +--- + +## Contract and module inventory + +| Module | `COMEBACKHERE-contracts/` | `contracts/` | Notes | +| --- | --- | --- | --- | +| **Invoice contract** | ✅ `contracts/invoice/` | ✅ `contracts/invoice/` | Legacy copy lacks `events.rs`, `test.rs`, `tests.rs` | +| Invoice — `events.rs` | ✅ | ❌ | | +| Invoice — `test.rs` | ✅ | ❌ | | +| Invoice — `tests.rs` | ✅ | ❌ | | +| **Treasury contract** | ✅ `contracts/treasury/` | ❌ | Legacy tree has `settlement/` instead (different contract) | +| Treasury — `benchmark.rs` | ✅ | ❌ | | +| Treasury — `events.rs` | ✅ | ❌ | | +| Treasury — `integration_dispute_lifecycle.rs` | ✅ | ❌ | | +| Treasury — `integration_settlement_multisig.rs` | ✅ | ❌ | | +| Treasury — `integration_update_merchant.rs` | ✅ | ❌ | | +| **Compliance contract** | ✅ `contracts/compliance/` | ❌ | Not present in legacy tree | +| **Settlement contract** | ❌ | ✅ `contracts/settlement/` | Legacy-only; superseded by treasury in canonical tree | +| **API integration tests** | ❌ | ✅ `contracts/api-integration-tests/` | Legacy-only; no equivalent in canonical workspace | + +--- + +## Cross-referenced issues + +| Issue | Description | Status | +| --- | --- | --- | +| [#182](https://github.com/WHEELBACK/COMEBACKHERE/issues/182) | Add benchmarks to treasury contract | ✅ Present in canonical tree (`benchmark.rs`) | +| [#186](https://github.com/WHEELBACK/COMEBACKHERE/issues/186) | Add audit trail (event indexing) | ✅ Present in canonical tree (`events.rs` in invoice and treasury) | + +--- + +## When to target the legacy tree + +The legacy `contracts/` tree is a valid PR target only when: + +1. The change is a **narrow fix** to a file that CI already covers + (e.g. a doc tweak or a small Rust fix in `contracts/invoice/src/lib.rs`). +2. You are intentionally maintaining backward compatibility with the + legacy workspace for a transitional period. + +For **all new features, bug fixes, and contract changes**, target +`COMEBACKHERE-contracts/` to inherit full CI coverage. + +--- + +## Migration checklist + +As features are ported or parity issues resolved, update this table by +flipping the status column. The goal is full parity — at which point the +legacy tree can be removed. + +- [ ] Port `api-integration-tests` to canonical workspace (or remove) +- [ ] Resolve `settlement` vs `treasury` naming alignment +- [ ] Add `events.rs` and `test.rs` / `tests.rs` to legacy invoice (or remove legacy tree) +- [ ] Remove legacy tree once no doc references remain diff --git a/docs/error-codes.md b/docs/error-codes.md index 5de9b22..6e1e4df 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -3,6 +3,8 @@ This document maps every `InvoiceError`, `ContractError`, `SettlementError`, and `TreasuryError` variant (and other contract error codes) to its numeric value, the condition that triggers it, and the recommended remediation steps for integrators. > Cross-reference: see [docs/api-reference.md](./api-reference.md) for HTTP-level error shapes returned by the backend. +> +> Cross-reference: see [ARCHITECTURE.md § Invoice state machine](../ARCHITECTURE.md#invoice-state-machine) for a diagram of every legal `InvoiceStatus` transition and the function that triggers it — useful context for knowing which `InvalidStateTransition` / `NotPending` cases below are expected versus a real bug. --- @@ -47,6 +49,10 @@ Defined in `COMEBACKHERE-contracts/contracts/invoice/src/lib.rs`. Shares some va | 12 | `GraceWindowNotExpired` | `release_escrow` was called before `created_at + grace_window`. | Wait until `ledger.timestamp() >= created_at + grace_window`. Admin may reduce `GraceWindow` via `set_grace_window` (default 86 400 seconds). | | 13 | `DuplicateNonce` | The (merchant, nonce) pair has already been used by a previous invoice. | Generate a fresh nonce for each invoice. Different merchants may reuse the same nonce value without collision. | | 14 | `TreasuryNotConfigured` | `raise_dispute` was called before the admin ran `set_treasury`. | Admin must call `set_treasury` once before disputes can be raised. | +| 15 | `NotAParty` | `raise_dispute` was called by an address that is neither the invoice's merchant nor its customer. | Sign with the merchant or customer key associated with the invoice. | +| 16 | `Overflow` | An internal counter (invoice ID, or `created_at + grace_window`) would overflow `u64`. | Practically unreachable outside of adversarial ledger state; not user-actionable. | +| 17 | `AddressBlocked` | `mark_paids` was called for a customer that the configured compliance contract reports as not allowed. | Confirm the customer's compliance status with `ComplianceContract.is_allowed` before retrying. | +| 18 | `InvalidStateTransition` | `mark_paids` was called on an invoice in `RefundRequested`, `Released`, `Cancelled`, or `Expired` status — see [ARCHITECTURE.md § Invoice state machine](../ARCHITECTURE.md#invoice-state-machine) for the full legal-transition diagram. | Fetch the current status with `get_invoice_status` first. A refund already in progress must not be overridden by a stale payment confirmation. | --- @@ -60,6 +66,29 @@ Defined in `COMEBACKHERE-contracts/contracts/compliance/src/lib.rs`. | 2 | `ContractPaused` | A state-changing call was made while the compliance contract is paused. | Compliance check calls return early on pause; defer the user action or have the admin unpause. | | 3 | `AlreadyInitialized` | `initialize` was called on a contract that is already set up. | Deployment-time error. The compliance contract can only be initialised once. | | 4 | `AddressNotFound` | A status query (or block/unblock flow) referenced an address that has not been recorded in `Status(Address)`. | Register the address via `set_status` first, or use the `Cleared` default if no entry exists. | +| 5 | `PastExpiry` | `allow_address_until` or `batch_allow_addresses` was called with `until <= env.ledger().timestamp()`. | Pass a `until` timestamp strictly greater than the current ledger time. An already-expired entry is rejected rather than silently created as a no-op. | +| 6 | `BatchTooLarge` | `batch_allow_addresses` was called with more than 50 addresses in a single invocation. | Split the address list into batches of 50 or fewer and submit multiple `batch_allow_addresses` calls. | + +> Note: the enum in `COMEBACKHERE-contracts/contracts/compliance/src/lib.rs` is named `ContractError`, matching the naming convention used by the invoice and treasury contracts in this repo. It is sometimes referred to informally as `ComplianceError` in design discussions — they are the same type. + +See [Compliance events](#compliance-events) below for the event shapes emitted by `batch_allow_addresses` and the other compliance entry points. + +--- + +## Compliance events + +Defined in `COMEBACKHERE-contracts/contracts/compliance/src/lib.rs`. Every state-changing compliance call emits one or more Soroban events so an off-chain indexer can reconstruct allowlist state without re-reading contract storage. + +| Event topic | Emitted by | Data payload | Notes | +| ------------- | ------------ | --------------- | ------- | +| `address_allowed` | `allow_address` | `Address` | Permanent allow, no expiry. | +| `address_allowed` | `batch_allow_addresses` | `(Address, u64)` — address and its `until` timestamp | One event per address processed. Same topic as `allow_address`, but the payload additionally carries the `until` value shared by the whole batch. | +| `address_allowed_until` | `allow_address_until` | `(Address, u64)` — address and its `until` timestamp | Single-address, time-bounded allow. | +| `address_blocked` | `block_address` | `Address` | | +| `address_cleared` | `clear_address` | `(Address, AddressStatus)` — address and the status it held immediately before clearing | Never emitted when the address was already `Cleared` (that call fails with `AddressNotFound` instead). | +| `compliance_batch_processed` | `batch_allow_addresses` | `(Address, u32)` — the calling admin and the number of addresses processed | Emitted once per `batch_allow_addresses` call, after all per-address `address_allowed` events for that call. Lets an indexer confirm a batch operation has fully landed (`processed_count` matches the number of `address_allowed` events it should have seen in that transaction) without treating event counting as the sole source of truth. | + +`batch_allow_addresses` caps `addresses` at 50 entries per call (`ContractError::BatchTooLarge` above that) and validates `until` the same way `allow_address_until` does (`ContractError::PastExpiry` if `until <= env.ledger().timestamp()`). Both checks run before any storage writes or events, so a rejected call has no partial effects. --- diff --git a/docs/rate-limits.md b/docs/rate-limits.md new file mode 100644 index 0000000..c3778a5 --- /dev/null +++ b/docs/rate-limits.md @@ -0,0 +1,109 @@ +# Rate Limits and Throttling + +Both the TypeScript backend (`comebackhere-backend`) and the Rust backend +(`backend`) enforce **per-IP rate limiting** on every API endpoint. This page +documents the default limits, how to configure them, and the response shape +returned when a client exceeds the budget. + +--- + +## Default limits + +| Setting | Default | Environment variable | +| --- | --- | --- | +| Max requests per window | **60** | `RATE_LIMIT_POINTS` | +| Window duration | **60 seconds** | `RATE_LIMIT_DURATION` | + +The same defaults apply to both backends. Operators can override them by setting +the environment variables before starting the service. + +--- + +## Scope + +The rate limiter is applied as **global middleware** — every endpoint listed in +[docs/api-reference.md](./api-reference.md) is subject to the same per-IP +budget. There is currently no per-route or per-user differentiation. + +| Backend | Middleware layer | +| --- | --- | +| `comebackhere-backend` (Express) | `rateLimitMiddleware` in `src/middleware/rateLimiter.ts` | +| `backend` (Axum / Tower) | `RateLimiterLayer` in `src/rate_limiter.rs` | + +--- + +## How the IP is determined + +The client IP is resolved in the following order: + +1. **`X-Forwarded-For` header** — the first comma-separated entry is used. + Leading and trailing whitespace is trimmed. +2. **Peer socket address** — the TCP connection's remote address. +3. **`"unknown"`** — fallback when neither source is available. + +> **Note:** Because the limiter is per-IP, all clients sharing the same public +> IP (e.g. behind a corporate NAT) share the same rate-limit bucket. + +--- + +## 429 response + +When the limit is exceeded the backend returns **HTTP 429** with the following +shape: + +```json +{ + "error": "Too many requests. Please retry after the indicated number of seconds.", + "retryAfter": 12 +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `error` | string | Human-readable message. | +| `retryAfter` | number | Seconds to wait before retrying. | + +The response also includes a `Retry-After` header with the same integer value. + +--- + +## Configuration examples + +### Increase the limit for a high-traffic deployment + +```bash +RATE_LIMIT_POINTS=200 RATE_LIMIT_DURATION=60 node dist/app.js +``` + +### Tighten the limit for a staging environment + +```bash +RATE_LIMIT_POINTS=10 RATE_LIMIT_DURATION=60 node dist/app.js +``` + +--- + +## Implementation details + +### TypeScript backend (`comebackhere-backend`) + +- Uses [`rate-limiter-flexible`](https://github.com/animir/node-rate-limiter-flexible). +- When `REDIS_URL` is set, rate-limit state is stored in Redis (key prefix + `rl:invoice`) with an in-memory fallback if Redis is unreachable. +- When `REDIS_URL` is not set (local development and tests), the limiter runs + entirely in memory. + +### Rust backend (`backend`) + +- Implements a sliding-window algorithm as a `tower::Layer`. +- Stores per-IP buckets in an in-memory `HashMap` protected by a `Mutex`. +- Retains only timestamps that fall inside the current window, so the window + rolls forward naturally without a background cleanup. + +--- + +## Further reading + +- [docs/api-reference.md](./api-reference.md) — full endpoint catalogue. +- [docs/error-codes.md](./error-codes.md) — contract-level error codes (distinct from HTTP 429). +- [Issue #215](https://github.com/WHEELBACK/COMEBACKHERE/issues/215) — rate-limiter test suite. diff --git a/scripts/deploy_mainnet.sh b/scripts/deploy_mainnet.sh index 2bd47c6..6616340 100755 --- a/scripts/deploy_mainnet.sh +++ b/scripts/deploy_mainnet.sh @@ -9,31 +9,111 @@ # without submitting any transaction. The output is formatted to be easy to paste # into a deployment-checklist PR or issue. # +# Use --rollback to revert artifacts/addresses.json to the last known-good +# deployment recorded in artifacts/addresses.json.bak. This does NOT un-deploy +# any contracts on-chain; it only restores the local address registry so that +# downstream services can be pointed back at the previous deployment. +# # Usage: -# scripts/deploy_mainnet.sh --dry-run # preview only — zero network-mutating calls -# scripts/deploy_mainnet.sh # refuses; live deploy requires multi-sig ceremony +# scripts/deploy_mainnet.sh --dry-run # preview only — zero network-mutating calls +# scripts/deploy_mainnet.sh --rollback # restore addresses.json from backup +# scripts/deploy_mainnet.sh # refuses; live deploy requires multi-sig ceremony set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" DRY_RUN=0 +ROLLBACK=0 for arg in "$@"; do case "$arg" in - --dry-run) DRY_RUN=1 ;; + --dry-run) DRY_RUN=1 ;; + --rollback) ROLLBACK=1 ;; *) echo "Unknown argument: $arg" >&2 - echo "Usage: $0 [--dry-run]" >&2 + echo "Usage: $0 [--dry-run | --rollback]" >&2 exit 1 ;; esac done +# ── rollback mode ───────────────────────────────────────────────────────────── + +if [ "$ROLLBACK" -eq 1 ]; then + ADDRESSES_FILE="$ROOT_DIR/artifacts/addresses.json" + BACKUP_FILE="$ROOT_DIR/artifacts/addresses.json.bak" + + if [ ! -f "$BACKUP_FILE" ]; then + echo "ERROR: No rollback backup found at $BACKUP_FILE." >&2 + echo "" >&2 + echo "A backup is created automatically at $BACKUP_FILE each time this script" >&2 + echo "writes a new artifacts/addresses.json. If no backup exists, either:" >&2 + echo " - This is the first ever deployment (no prior state to roll back to)" >&2 + echo " - The backup was manually deleted" >&2 + echo "" >&2 + echo "To recover manually, consult the on-chain deployment ceremony record" >&2 + echo "in the deployment issue and reconstruct artifacts/addresses.json from" >&2 + echo "the recorded contract IDs." >&2 + echo "" >&2 + echo "IMPORTANT: Rolling back artifacts/addresses.json does NOT un-deploy" >&2 + echo "contracts on Soroban. Contracts cannot be removed from the ledger once" >&2 + echo "deployed. This rollback only restores the local address registry so" >&2 + echo "that backend services can be pointed back at the previous deployment." >&2 + exit 1 + fi + + TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + echo "========================================================" + echo " COMEBACKHERE MAINNET DEPLOYMENT — ROLLBACK" + echo " Generated: $TIMESTAMP" + echo "========================================================" + echo "" + echo "Restoring artifacts/addresses.json from backup..." + echo "" + + if [ -f "$ADDRESSES_FILE" ]; then + # Save the current (failed) state for forensic reference + cp "$ADDRESSES_FILE" "${ADDRESSES_FILE}.rollback-$(date -u +"%Y%m%dT%H%M%SZ")" + echo " Saved current addresses.json as addresses.json.rollback-${TIMESTAMP} for forensic reference." + fi + + cp "$BACKUP_FILE" "$ADDRESSES_FILE" + echo " Restored: artifacts/addresses.json from artifacts/addresses.json.bak" + echo "" + echo "IMPORTANT — Contract-level implications:" + echo "" + echo " 1. Contracts already deployed on-chain CANNOT be un-deployed." + echo " Any contract that was deployed during the failed deployment remains" + echo " on the Stellar ledger and is accessible by anyone with its contract ID." + echo "" + echo " 2. This rollback only restores the local artifacts/addresses.json registry." + echo " Downstream services (backend, frontend) pointed at the NEW contract IDs" + echo " must be manually reconfigured to use the PREVIOUS contract IDs from the" + echo " restored addresses.json." + echo "" + echo " 3. If any newly deployed contract was partially initialized (e.g., the" + echo " compliance contract was deployed but the invoice contract was not)," + echo " the partially-initialized contract should be documented in the" + echo " post-incident record. It may need to be treated as an abandoned" + echo " deployment." + echo "" + echo " 4. Open an emergency deployment issue to document the partial deployment," + echo " record all deployed contract IDs (including abandoned ones), and plan" + echo " any corrective redeployment through the full multi-sig ceremony process." + echo "" + echo " See docs/MAINNET_DEPLOYMENT.md — Emergency Rollback section for full guidance." + echo "" + echo "Rollback complete." + echo "========================================================" + exit 0 +fi + # ── resolve env ─────────────────────────────────────────────────────────────── # shellcheck disable=SC1091 -source scripts/validate_env.sh .env.mainnet mainnet deployment +source scripts/validate_env.sh .env.mainnet mainnet deployment --env mainnet # ── dry-run mode ────────────────────────────────────────────────────────────── diff --git a/scripts/generate_abi_metadata.sh b/scripts/generate_abi_metadata.sh index f143ba9..625ea1b 100755 --- a/scripts/generate_abi_metadata.sh +++ b/scripts/generate_abi_metadata.sh @@ -3,11 +3,31 @@ # Contract sources are searched in two locations (CI checkout and local sibling): # 1. $ROOT_DIR/COMEBACKHERE-contracts (GitHub Actions checkout path) # 2. $ROOT_DIR/../COMEBACKHERE-contracts (local sibling directory) +# +# Usage: +# scripts/generate_abi_metadata.sh [OUT_DIR] +# Regenerate ABI metadata and write to OUT_DIR (default: abis/). +# +# scripts/generate_abi_metadata.sh --check +# Generate ABI metadata to a temp directory and diff against committed +# abis/*.json without overwriting them. Exits non-zero if there are +# differences (like `prettier --check`). OUT_DIR is ignored when +# --check is specified. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +CHECK_MODE=0 OUT_DIR="${1:-"$ROOT_DIR/abis"}" +for arg in "$@"; do + case "$arg" in + --check) + CHECK_MODE=1 + ;; + esac +done + if [ -d "$ROOT_DIR/COMEBACKHERE-contracts" ]; then CONTRACTS_DIR="$ROOT_DIR/COMEBACKHERE-contracts" elif [ -d "$ROOT_DIR/../COMEBACKHERE-contracts" ]; then @@ -23,6 +43,28 @@ fi export LC_ALL=C export LANG=C +if [ "$CHECK_MODE" -eq 1 ]; then + TEMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TEMP_DIR"' EXIT + + echo "Building COMEBACKHERE contracts (workspace test build)..." + (cd "$CONTRACTS_DIR" && cargo test --no-run --workspace) + + mkdir -p "$TEMP_DIR" + python3 "$ROOT_DIR/scripts/generate_abi_metadata.py" "$TEMP_DIR" + + echo "Comparing generated ABI metadata against committed abis/..." + if diff -ru "$ROOT_DIR/abis/" "$TEMP_DIR/"; then + echo "ABI snapshots are up to date." + exit 0 + else + echo "" >&2 + echo "ERROR: ABI snapshots in abis/ are out of sync with COMEBACKHERE-contracts/." >&2 + echo "Run 'make update-abi-snapshots' (or 'just snapshot') locally and commit the updated files." >&2 + exit 1 + fi +fi + echo "Building COMEBACKHERE contracts (workspace test build)..." (cd "$CONTRACTS_DIR" && cargo test --no-run --workspace) diff --git a/scripts/validate_env.sh b/scripts/validate_env.sh index 0718ad7..7df5cbc 100755 --- a/scripts/validate_env.sh +++ b/scripts/validate_env.sh @@ -1,8 +1,57 @@ #!/usr/bin/env bash +# Validate required environment variables before deployment. +# +# Usage: +# scripts/validate_env.sh [ENV_FILE] [ENV_LABEL] [--env testnet|mainnet|local] +# +# The --env flag selects which set of required variables to enforce: +# local — minimal set for local Docker Compose development +# testnet — standard set for testnet deployments +# mainnet — strict set for mainnet deployments (no test keys, additional checks) +# +# If --env is not specified, the script validates the common baseline variables +# (compatible with prior behaviour). +# +# Examples: +# scripts/validate_env.sh .env.testnet testnet --env testnet +# scripts/validate_env.sh .env.mainnet mainnet --env mainnet +# scripts/validate_env.sh .env.local local --env local set -euo pipefail ENV_FILE="${1:-}" ENV_LABEL="${2:-deployment}" +ENV_MODE="" + +# Parse --env flag from remaining arguments +for arg in "$@"; do + case "$arg" in + --env) + # next iteration will capture the value + ;; + esac +done + +# Robust flag parsing: scan all args for --env +i=1 +for arg in "$@"; do + if [[ "$arg" == "--env" ]]; then + next_i=$((i + 1)) + eval "ENV_MODE=\"\${${next_i}:-}\"" + break + fi + i=$((i + 1)) +done + +# Validate the mode value if provided +if [[ -n "$ENV_MODE" ]]; then + case "$ENV_MODE" in + local|testnet|mainnet) ;; + *) + echo "Error: --env must be one of: local, testnet, mainnet (got '$ENV_MODE')." >&2 + exit 1 + ;; + esac +fi if [[ -n "$ENV_FILE" && -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 @@ -115,6 +164,8 @@ require_any_var() { return 1 } +# ── common baseline variables (all modes) ──────────────────────────────────── + if ! require_any_var "Set SOROBAN_RPC_URL (or RPC_URL) to your Soroban RPC endpoint." "SOROBAN_RPC_URL" "RPC_URL"; then : fi @@ -136,6 +187,97 @@ if ! require_any_var "Set ADMIN_SECRET_KEY (or SECRET_KEY) to the corresponding : fi +# ── mode-specific variable checks ──────────────────────────────────────────── + +case "$ENV_MODE" in + + local) + # Local development: RPC is typically the local Docker Compose node. + # Verify that the RPC URL points to localhost or a local address. + _rpc_url="${SOROBAN_RPC_URL:-${RPC_URL:-}}" + if [[ -n "$_rpc_url" && "$_rpc_url" != *"localhost"* && "$_rpc_url" != *"127.0.0.1"* && "$_rpc_url" != *"0.0.0.0"* ]]; then + echo "Warning: --env local is set but SOROBAN_RPC_URL ('$_rpc_url') does not appear to point to a local node." >&2 + echo "Hint: For local mode, use http://localhost:8000/soroban/rpc or similar." >&2 + fi + ;; + + testnet) + # Testnet: requires STELLAR_NETWORK to be explicitly set to 'testnet'. + if ! require_var "STELLAR_NETWORK" "Set STELLAR_NETWORK=testnet for testnet deployments."; then + : + else + if [[ "${STELLAR_NETWORK}" != "testnet" ]]; then + echo "Error: --env testnet requires STELLAR_NETWORK=testnet (got '${STELLAR_NETWORK}')." >&2 + invalid+=("STELLAR_NETWORK") + fi + fi + # Testnet also requires deployed contract IDs to be present. + if ! require_var "INVOICE_CONTRACT_ID" "Set INVOICE_CONTRACT_ID to the deployed testnet invoice contract address."; then + : + fi + if ! require_var "TREASURY_CONTRACT_ID" "Set TREASURY_CONTRACT_ID to the deployed testnet treasury contract address."; then + : + fi + if ! require_var "COMPLIANCE_CONTRACT_ID" "Set COMPLIANCE_CONTRACT_ID to the deployed testnet compliance contract address."; then + : + fi + ;; + + mainnet) + # Mainnet: strictest checks. Secret keys must not use testnet-only values, + # and the network passphrase must match the Stellar mainnet passphrase. + MAINNET_PASSPHRASE="Public Global Stellar Network ; September 2015" + + _passphrase="${SOROBAN_NETWORK_PASSPHRASE:-${NETWORK_PASSPHRASE:-}}" + if [[ -n "$_passphrase" && "$_passphrase" != "$MAINNET_PASSPHRASE" ]]; then + echo "Error: --env mainnet requires SOROBAN_NETWORK_PASSPHRASE to equal the Stellar mainnet passphrase." >&2 + echo " Expected: '$MAINNET_PASSPHRASE'" >&2 + echo " Got: '$_passphrase'" >&2 + invalid+=("SOROBAN_NETWORK_PASSPHRASE") + fi + + if ! require_var "STELLAR_NETWORK" "Set STELLAR_NETWORK=mainnet for mainnet deployments."; then + : + else + if [[ "${STELLAR_NETWORK}" != "mainnet" ]]; then + echo "Error: --env mainnet requires STELLAR_NETWORK=mainnet (got '${STELLAR_NETWORK}')." >&2 + invalid+=("STELLAR_NETWORK") + fi + fi + + # Deployed contract IDs are required for mainnet. + if ! require_var "INVOICE_CONTRACT_ID" "Set INVOICE_CONTRACT_ID to the deployed mainnet invoice contract address."; then + : + fi + if ! require_var "TREASURY_CONTRACT_ID" "Set TREASURY_CONTRACT_ID to the deployed mainnet treasury contract address."; then + : + fi + if ! require_var "COMPLIANCE_CONTRACT_ID" "Set COMPLIANCE_CONTRACT_ID to the deployed mainnet compliance contract address."; then + : + fi + + # USDC contract ID is required on mainnet. + if ! require_var "USDC_CONTRACT_ID" "Set USDC_CONTRACT_ID to the official USDC asset contract ID on Stellar mainnet."; then + : + fi + + # Reject obviously-testnet RPC URLs on mainnet mode. + _rpc_url="${SOROBAN_RPC_URL:-${RPC_URL:-}}" + if [[ -n "$_rpc_url" && "$_rpc_url" == *"testnet"* ]]; then + echo "Error: --env mainnet is set but SOROBAN_RPC_URL ('$_rpc_url') contains 'testnet'. Use a mainnet RPC endpoint." >&2 + invalid+=("SOROBAN_RPC_URL") + fi + + # Warn if RPC points to localhost — unusual for mainnet. + if [[ -n "$_rpc_url" && ( "$_rpc_url" == *"localhost"* || "$_rpc_url" == *"127.0.0.1"* ) ]]; then + echo "Warning: --env mainnet is set but SOROBAN_RPC_URL ('$_rpc_url') points to localhost. Confirm this is intentional." >&2 + fi + ;; + +esac + +# ── final summary ───────────────────────────────────────────────────────────── + if (( ${#missing[@]} > 0 )); then echo "Missing required environment variables:" >&2 printf ' - %s\n' "${missing[@]}" >&2