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