diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index c0394662..30add367 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -23,6 +23,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arbitrary" version = "1.3.2" @@ -147,7 +153,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand", + "rand 0.8.6", ] [[package]] @@ -174,6 +180,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -259,6 +286,7 @@ dependencies = [ name = "commitlabs-escrow" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -301,7 +329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -546,7 +574,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -571,7 +599,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -583,6 +611,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -595,13 +633,19 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -623,6 +667,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures-core" version = "0.3.32" @@ -671,6 +721,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "group" version = "0.13.0" @@ -678,7 +753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -706,6 +781,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -776,6 +860,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -859,6 +949,12 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -871,6 +967,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.30" @@ -1022,6 +1124,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -1031,6 +1158,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1038,8 +1177,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1049,7 +1198,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1058,7 +1217,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1081,6 +1258,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rfc6979" version = "0.4.0" @@ -1100,12 +1283,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.8.22" @@ -1270,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1313,7 +1521,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1341,7 +1549,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1349,8 +1557,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.6", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1359,7 +1567,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-strkey 0.0.13", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1403,7 +1611,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.6", "rustc_version", "serde", "serde_json", @@ -1444,7 +1652,7 @@ dependencies = [ "base64", "stellar-xdr", "thiserror", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1578,6 +1786,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1650,12 +1871,24 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version_check" version = "0.9.5" @@ -1673,12 +1906,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -1724,6 +1984,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser 0.244.0", +] + [[package]] name = "wasmi_arena" version = "0.4.1" @@ -1752,6 +2034,18 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmparser-nostd" version = "0.100.2" @@ -1820,6 +2114,109 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "zerocopy" version = "0.8.49" diff --git a/contracts/README.md b/contracts/README.md index 6cae2e39..d6df2bc3 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -153,127 +153,22 @@ Yield is funded by the admin through `deposit_yield_pool(admin, amount)`. The co points (`penalty_bps`, max `10_000`) and is paid to the configured fee recipient on `refund` / adverse `resolve_dispute`. -### Default penalties per risk profile +### Refund math model and invariants -Default penalties are configured once at initialization and automatically applied -to commitments created via `create_commitment_with_default_penalty()`. This -simplifies commitment creation when consistent penalty tiers are desired. +Refunds are computed with integer basis-point math: -#### Backend-aligned defaults +- `penalty = floor(amount * penalty_bps / 10_000)` +- `refund = amount - penalty` -The contract defaults match the CommitLabs backend tier structure: +This keeps the split stable and preserves the invariant `refund + penalty == amount` +for valid principal amounts. The contract enforces `0 <= penalty_bps <= 10_000` +and uses checked arithmetic so overflowing intermediate multiplication is rejected +instead of wrapping. Boundary cases are documented in the contract tests: -| Risk Profile | Default Penalty | Basis Points | Use Case | -| --- | --- | --- | --- | -| Safe | 2% | 200 | Low-risk commitments with minimal early-exit cost | -| Balanced | 3% | 300 | Medium-risk commitments with moderate early-exit cost | -| Aggressive | 5% | 500 | High-risk commitments with significant early-exit cost | - -#### Two API patterns - -The contract provides two ways to create commitments: - -1. **Explicit penalty** (`create_commitment`): Set a specific penalty per commitment - - Allows per-commitment customization - - Overrides default if needed - - Useful for custom deal terms - -2. **Default penalty** (`create_commitment_with_default_penalty`): Use the profile default - - Simplifies API calls - - Ensures consistency across commitments - - No penalty parameter needed - -Example: -```rust -// Use default penalty (e.g., 3% for Balanced risk) -let id = contract.create_commitment_with_default_penalty( - &owner, &asset, &1000, &RiskProfile::Balanced, &30 -)?; - -// Or override with custom penalty (e.g., 2% instead of default 3%) -let id = contract.create_commitment( - &owner, &asset, &1000, &RiskProfile::Balanced, &30, &200 -)?; -``` - -#### Querying defaults - -Use `get_default_penalty(risk)` to retrieve the current default for a risk profile. -Useful for frontend/backend UI and verification. - -### Dispute categorization & reason storage - -When a commitment is disputed via `dispute(commitment_id, caller, reason)`, the -contract automatically categorizes the reason string into a `DisputeReason` enum -using keyword matching. This enables efficient on-chain classification and -off-chain indexing of disputes. - -#### DisputeReason categories - -| Category | Keywords | Example | -| --- | --- | --- | -| `ValueMismatch` | value, mismatch, amount, delivered | "actual value delivered was less than promised" | -| `NonCompliance` | compliance, attestation, failed, violation | "compliance violation detected" | -| `FraudSuspicion` | fraud, unauthorized, suspicious | "suspected fraudulent activity" | -| `OperationalFailure` | operational, failure, delivery | "operational failure in delivery" | -| `Other` | (default) | "some other unclassified reason" | - -#### Dispute record structure - -Each disputed commitment stores a `DisputeRecord` containing: -- `reason_category`: The `DisputeReason` enum value (0–4) -- `reason_text`: The free-form reason string provided by the initiator (for audit) -- `disputed_at`: Ledger timestamp when the dispute was opened -- `disputed_by`: Address that initiated the dispute (owner or admin) - -The dispute record is persisted on-chain and can be read at any time via -`get_dispute(commitment_id)`, even after the dispute is resolved. This enables -auditing, analytics, and off-chain verification of dispute history. - -### Partial early-exit (`refund_partial`) - -`refund_partial(commitment_id, amount)` lets an owner exit a fraction of their -locked principal before maturity. Only the withdrawn portion is penalised; -the remainder stays escrowed under the same commitment. - -- `amount` must be > 0 and ≤ `Commitment.amount`. -- `penalty_bps` is applied only to `amount`: `penalty = amount * penalty_bps / 10_000`. -- `Commitment.amount` is reduced by `amount` in storage. -- If `amount` equals the full stored principal the commitment transitions to - `Refunded`; otherwise it stays `Funded`. -- Blocked when the commitment is in `Violated` status (`CommitmentViolated` error). - -``` -refund_partial(id, 400) # withdraw 400 out of 1 000 at 10% penalty - → net to owner: 360, fee: 40, remaining escrowed: 600 (status: Funded) - -refund_partial(id, 1000) # withdraw all remaining principal - → net to owner: 950, fee: 50, remaining: 0 (status: Refunded) -``` - -### Violation auto-trigger - -A configurable compliance score threshold controls automatic violation of funded -commitments. When `record_attestation` records a score **strictly below** the -threshold for a `Funded` commitment, the status transitions to `Violated` and -a `commitment_violated` event is emitted. - -- `set_violation_threshold(threshold)` — admin-only, clamps to 0–100. A value - of `0` (default) disables auto-violation entirely. -- `get_violation_threshold()` — public read of the current threshold. -- `release`, `refund`, and `refund_partial` all return `CommitmentViolated` - while the commitment is in the `Violated` state. -- A score **equal to** the threshold does **not** trigger a violation; only - scores **strictly below** do. -- Auto-violation only applies to `Funded` commitments. Attestations on - `Created`, `Released`, `Refunded`, or `Disputed` commitments are recorded but - do not change status. - -``` -set_violation_threshold(60) # violate when score < 60 -record_attestation(id, 59) # → status: Violated, event emitted -record_attestation(id, 60) # → status unchanged (Funded) -``` +- `penalty_bps = 0` → full principal refund, zero penalty +- `penalty_bps = 10_000` → zero refund, full principal penalty +- tiny amounts (`1`, `2`, `3`, etc.) remain non-negative and partition cleanly +- seeded deterministic property tests cover randomized mid-range values and overflow guards ### Errors diff --git a/contracts/escrow/Cargo.toml b/contracts/escrow/Cargo.toml index aa86105c..b1b3da92 100644 --- a/contracts/escrow/Cargo.toml +++ b/contracts/escrow/Cargo.toml @@ -18,3 +18,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.5" diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 69f8e3b0..f1fb34ed 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -591,14 +591,10 @@ impl EscrowContract { return Err(Error::InvalidAmount); } - // Apply penalty to the withdrawn portion only. - let penalty_mul = amount - .checked_mul(c.penalty_bps as i128) - .ok_or(Error::InvalidAmount)?; - let penalty = penalty_mul / MAX_PENALTY_BPS as i128; - let refund_amount = amount - .checked_sub(penalty) - .ok_or(Error::InvalidAmount)?; + // Basis points represent a fraction out of 10_000. The penalty is the + // floor of `amount * penalty_bps / 10_000`, so refund + penalty always + // partitions the original principal while staying within checked math. + let (penalty, refund_amount) = Self::compute_refund_amount(c.amount, c.penalty_bps)?; // Update the stored principal; remainder stays in escrow. let remaining = c @@ -749,14 +745,16 @@ impl EscrowContract { // Interactions: External token transfers let token = Self::token_client(&env); let contract = env.current_contract_address(); - - if penalty > 0 { - let fee_recipient: Address = env - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .ok_or(Error::NotInitialized)?; - token.transfer(&contract, &fee_recipient, &penalty); + let paid; + if release_to_owner { + token.transfer(&contract, &c.owner, &c.amount); + c.status = EscrowStatus::Released; + paid = c.amount; + } else { + let (_, refund_amount) = Self::compute_refund_amount(c.amount, c.penalty_bps)?; + paid = refund_amount; + token.transfer(&contract, &c.owner, &paid); + c.status = EscrowStatus::Refunded; } token.transfer(&contract, &c.owner, &paid); @@ -1046,13 +1044,26 @@ impl EscrowContract { Ok(()) } - /// Retrieve the default penalty for a risk profile (internal use). - /// Returns NotInitialized if the contract has not been initialized. - fn get_default_penalty_internal(env: &Env, risk: RiskProfile) -> Result { - env.storage() - .instance() - .get(&DataKey::DefaultPenalty(risk)) - .ok_or(Error::NotInitialized) + /// Compute the refund split using basis points. + /// + /// `penalty_bps` is a fraction out of 10_000, so `500` means 5%. We use + /// integer floor division and checked arithmetic to preserve the invariant + /// `refund + penalty == amount` without overflow. + fn compute_refund_amount(amount: i128, penalty_bps: u32) -> Result<(i128, i128), Error> { + if amount <= 0 { + return Err(Error::InvalidAmount); + } + if penalty_bps > MAX_PENALTY_BPS { + return Err(Error::PenaltyTooHigh); + } + + let penalty = amount + .checked_mul(penalty_bps as i128) + .ok_or(Error::InvalidAmount)? + / MAX_PENALTY_BPS as i128; + let refund_amount = amount.checked_sub(penalty).ok_or(Error::InvalidAmount)?; + + Ok((penalty, refund_amount)) } fn next_id(env: &Env) -> u64 { diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 81f19d4e..49fead50 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -37,6 +37,8 @@ fn unauthorized_cannot_rotate_admin_or_fee_recipient() { #![cfg(test)] use super::*; +use proptest::prelude::*; +use proptest::test_runner::TestRunner; use soroban_sdk::{ map, testutils::{Address as _, Ledger as _}, @@ -462,885 +464,71 @@ fn owner_index_tracks_commitments() { assert_eq!(ids.get(1).unwrap(), b); } -#[test] -fn transfer_ownership_updates_commitment_and_indices() { - let f = setup(); - let old_owner = Address::generate(&f.env); - let new_owner = Address::generate(&f.env); - - fund_owner(&f, &old_owner, 1_000); - let id = f - .client - .create_commitment(&old_owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.fund_escrow(&id); - - let before_old = f.client.get_owner_commitments(&old_owner); - assert_eq!(before_old.len(), 1); - assert_eq!(before_old.get(0).unwrap(), id); - - f.client.transfer_ownership(&id, &new_owner); - - let c = f.client.get_commitment(&id); - assert_eq!(c.owner, new_owner); - - let after_old = f.client.get_owner_commitments(&old_owner); - assert_eq!(after_old.len(), 0); - - let after_new = f.client.get_owner_commitments(&new_owner); - assert_eq!(after_new.len(), 1); - assert_eq!(after_new.get(0).unwrap(), id); -} - -#[test] -fn transfer_ownership_rejects_non_funded_commitments() { - let f = setup(); - let old_owner = Address::generate(&f.env); - let new_owner = Address::generate(&f.env); - - fund_owner(&f, &old_owner, 1_000); - let id = f - .client - .create_commitment(&old_owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - // Intentionally do NOT call fund_escrow; status remains Created. - - let res = f.client.try_transfer_ownership(&id, &new_owner); - assert_eq!(res, Err(Ok(Error::InvalidState))); -} - - -#[test] -fn create_rejects_duration_seconds_overflow() { -fn dispute_categorizes_value_mismatch() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - // Test value mismatch keyword detection. - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "actual value delivered was less than promised"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::ValueMismatch); - assert_eq!(record.disputed_by, owner); -} - -#[test] -fn dispute_categorizes_non_compliance() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "compliance violation detected"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::NonCompliance); -} - -#[test] -fn dispute_categorizes_fraud_suspicion() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "suspicious fraud activity detected"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::FraudSuspicion); -} - -#[test] -fn dispute_categorizes_operational_failure() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "operational failure in delivery"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::OperationalFailure); -} - -#[test] -fn dispute_categorizes_other_reason() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "some unspecified reason"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::Other); -} - -#[test] -fn get_dispute_returns_persisted_reason_text() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - let reason_text = String::from_str(&f.env, "detailed explanation of the issue"); - f.client.dispute(&id, &owner, &reason_text); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_text, reason_text); -} - -#[test] -fn dispute_stores_timestamp_and_initiator() { - let f = setup(); - let owner = Address::generate(&f.env); - let initiator = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - let initial_timestamp = f.env.ledger().timestamp(); - f.client.dispute( - &id, - &initiator, - &String::from_str(&f.env, "value mismatch"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.disputed_by, initiator); - assert!(record.disputed_at >= initial_timestamp); -} - -#[test] -fn get_dispute_returns_none_for_undisputed_commitment() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_none()); -} - -#[test] -fn admin_can_open_dispute() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - // Admin initiates dispute. - f.client.dispute( - &id, - &f.admin, - &String::from_str(&f.env, "value mismatch"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.disputed_by, f.admin); -} - -#[test] -fn dispute_reason_case_insensitive() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - f.client.dispute( - &id, - &owner, - &String::from_str(&f.env, "COMPLIANCE VIOLATION DETECTED"), - ); - - let dispute = f.client.get_dispute(&id); - assert!(dispute.is_some()); - let record = dispute.unwrap(); - assert_eq!(record.reason_category, DisputeReason::NonCompliance); -} - -#[test] -fn resolve_dispute_preserves_dispute_record() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - let reason = String::from_str(&f.env, "value mismatch issue"); - f.client.dispute(&id, &owner, &reason); - - // Dispute record should exist before resolution. - let dispute_before = f.client.get_dispute(&id); - assert!(dispute_before.is_some()); - - // Admin resolves the dispute. - f.client.resolve_dispute(&id, &true); - - // Dispute record should still be accessible after resolution. - let dispute_after = f.client.get_dispute(&id); - assert!(dispute_after.is_some()); - let record = dispute_after.unwrap(); - assert_eq!(record.reason_text, reason); - assert_eq!(record.reason_category, DisputeReason::ValueMismatch); -} - -#[test] -fn get_default_penalty_returns_configured_values() { - let f = setup(); - - // Verify all three default penalties are correctly configured. - let safe_default = f.client.get_default_penalty(&RiskProfile::Safe); - assert_eq!(safe_default, 200); // 2% - - let balanced_default = f.client.get_default_penalty(&RiskProfile::Balanced); - assert_eq!(balanced_default, 300); // 3% - - let aggressive_default = f.client.get_default_penalty(&RiskProfile::Aggressive); - assert_eq!(aggressive_default, 500); // 5% -} - -#[test] -fn create_commitment_with_default_penalty_safe() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create with default penalty for Safe profile (2%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &30, - ); - - let commitment = f.client.get_commitment(&id); - assert_eq!(commitment.penalty_bps, 200); // 2% - assert_eq!(commitment.risk, RiskProfile::Safe); -} - -#[test] -fn create_commitment_with_default_penalty_balanced() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create with default penalty for Balanced profile (3%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Balanced, - &30, - ); - - let commitment = f.client.get_commitment(&id); - assert_eq!(commitment.penalty_bps, 300); // 3% - assert_eq!(commitment.risk, RiskProfile::Balanced); -} - -#[test] -fn create_commitment_with_default_penalty_aggressive() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create with default penalty for Aggressive profile (5%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Aggressive, - &30, - ); - - let commitment = f.client.get_commitment(&id); - assert_eq!(commitment.penalty_bps, 500); // 5% - assert_eq!(commitment.risk, RiskProfile::Aggressive); -} - -#[test] -fn create_commitment_explicit_override_ignores_default() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create with explicit penalty that differs from default. - // Safe default is 200 (2%), but explicitly set 100 (1%). - let id = f.client.create_commitment( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &30, - &100, // 1% explicit override - ); - - let commitment = f.client.get_commitment(&id); - assert_eq!(commitment.penalty_bps, 100); // Uses explicit override, not default - assert_eq!(commitment.risk, RiskProfile::Safe); -} - -#[test] -fn refund_with_default_penalty_safe_applies_correct_fee() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create commitment with Safe default penalty (2%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &30, - ); - f.client.fund_escrow(&id); - - let refunded = f.client.refund(&id); - // 1000 * 200 / 10000 = 20 penalty - assert_eq!(refunded, 980); - assert_eq!(f.token.balance(&f.fee_recipient), 20); -} - -#[test] -fn refund_with_default_penalty_balanced_applies_correct_fee() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create commitment with Balanced default penalty (3%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Balanced, - &30, - ); - f.client.fund_escrow(&id); - - let refunded = f.client.refund(&id); - // 1000 * 300 / 10000 = 30 penalty - assert_eq!(refunded, 970); - assert_eq!(f.token.balance(&f.fee_recipient), 30); -} - -#[test] -fn refund_with_default_penalty_aggressive_applies_correct_fee() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create commitment with Aggressive default penalty (5%). - let id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Aggressive, - &30, - ); - f.client.fund_escrow(&id); - - let refunded = f.client.refund(&id); - // 1000 * 500 / 10000 = 50 penalty - assert_eq!(refunded, 950); - assert_eq!(f.token.balance(&f.fee_recipient), 50); -} - -#[test] -fn multiple_commitments_different_profiles_use_correct_defaults() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 10_000); - - // Create three commitments with different risk profiles. - let safe_id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &30, - ); - - let balanced_id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Balanced, - &30, - ); - - let aggressive_id = f.client.create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Aggressive, - &30, - ); - - let safe_c = f.client.get_commitment(&safe_id); - let balanced_c = f.client.get_commitment(&balanced_id); - let aggressive_c = f.client.get_commitment(&aggressive_id); - - assert_eq!(safe_c.penalty_bps, 200); - assert_eq!(balanced_c.penalty_bps, 300); - assert_eq!(aggressive_c.penalty_bps, 500); -} - -#[test] -fn create_commitment_with_default_validates_amount() { - let f = setup(); - let owner = Address::generate(&f.env); - - // Attempt to create with invalid amount. - let res = f.client.try_create_commitment_with_default_penalty( - &owner, - &f.asset, - &0, // Invalid: amount must be > 0 - &RiskProfile::Safe, - &30, - ); - assert_eq!(res, Err(Ok(Error::InvalidAmount))); -} - -#[test] -fn create_commitment_with_default_validates_duration() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Attempt to create with invalid duration. - let res = f.client.try_create_commitment_with_default_penalty( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &0, // Invalid: duration must be > 0 - ); - assert_eq!(res, Err(Ok(Error::InvalidDuration))); -} - -#[test] -fn initialize_validates_penalty_limits() { - let env = Env::default(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let fee_recipient = Address::generate(&env); - let issuer = Address::generate(&env); - let sac = env.register_stellar_asset_contract_v2(issuer); - let asset = sac.address(); - - let contract_id = env.register(EscrowContract, ()); - let client = EscrowContractClient::new(&env, &contract_id); - - // Attempt to initialize with penalty exceeding MAX_PENALTY_BPS (10000). - let res = client.try_initialize( - &admin, - &asset, - &fee_recipient, - &200, // Safe: valid - &300, // Balanced: valid - &20_000, // Aggressive: INVALID (> 10000) - ); - assert_eq!(res, Err(Ok(Error::PenaltyTooHigh))); -} - -#[test] -fn explicit_penalty_override_zero_is_valid() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - // Create with explicit 0% penalty (allowed for override). - let id = f.client.create_commitment( - &owner, - &f.asset, - &1_000, - &RiskProfile::Balanced, - &30, - &0, // 0% explicit penalty - ); - - let commitment = f.client.get_commitment(&id); - assert_eq!(commitment.penalty_bps, 0); - - // Fund and refund should return full amount. - f.client.fund_escrow(&id); - let refunded = f.client.refund(&id); - assert_eq!(refunded, 1_000); // No penalty deducted -} - -// ── Issue #462: partial early-exit (refund_partial) ──────────────────────── - -#[test] -fn refund_partial_applies_penalty_to_withdrawn_portion_only() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - // 10% penalty for easy arithmetic. - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Aggressive, &30, &1_000); - f.client.fund_escrow(&id); - - // Withdraw 400 out of 1000. Penalty = 400 * 10% = 40. Net = 360. - let net = f.client.refund_partial(&id, &400); - assert_eq!(net, 360); - assert_eq!(f.token.balance(&owner), 360); - assert_eq!(f.token.balance(&f.fee_recipient), 40); - - // Remaining principal is 600 and commitment stays Funded. - let c = f.client.get_commitment(&id); - assert_eq!(c.amount, 600); - assert_eq!(c.status, EscrowStatus::Funded); -} - -#[test] -fn refund_partial_full_amount_transitions_to_refunded() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - // 5% penalty (500 bps). - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Aggressive, &30, &500); - f.client.fund_escrow(&id); - - // Withdraw the entire principal in one partial call. - let net = f.client.refund_partial(&id, &1_000); - assert_eq!(net, 950); - assert_eq!(f.token.balance(&owner), 950); - assert_eq!(f.token.balance(&f.fee_recipient), 50); - - let c = f.client.get_commitment(&id); - assert_eq!(c.amount, 0); - assert_eq!(c.status, EscrowStatus::Refunded); -} - -#[test] -fn refund_partial_multiple_withdrawals_reduce_amount_cumulatively() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - // 0% penalty for simpler balance tracking. - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &0); - f.client.fund_escrow(&id); - - f.client.refund_partial(&id, &300); - assert_eq!(f.client.get_commitment(&id).amount, 700); - - f.client.refund_partial(&id, &200); - assert_eq!(f.client.get_commitment(&id).amount, 500); - - assert_eq!(f.token.balance(&owner), 500); // 300 + 200 returned - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Funded); -} - -#[test] -fn refund_partial_rejects_amount_exceeding_balance() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.fund_escrow(&id); - - let res = f.client.try_refund_partial(&id, &1_001); - assert_eq!(res, Err(Ok(Error::InvalidAmount))); -} +fn assert_refund_invariants(amount: i128, penalty_bps: u32) { + let (penalty, refund) = EscrowContract::compute_refund_amount(amount, penalty_bps) + .expect("valid refund inputs must compute deterministically"); -#[test] -fn refund_partial_rejects_zero_amount() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.fund_escrow(&id); - - let res = f.client.try_refund_partial(&id, &0); - assert_eq!(res, Err(Ok(Error::InvalidAmount))); -} - -#[test] -fn refund_partial_rejects_unfunded_commitment() { - let f = setup(); - let owner = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - // Not funded yet. - let res = f.client.try_refund_partial(&id, &500); - assert_eq!(res, Err(Ok(Error::InvalidState))); -} - -#[test] -fn refund_partial_matches_full_refund_when_withdrawing_entire_principal() { - // Partial withdrawal of the full amount must produce the same net payout - // and fee as calling the regular refund entrypoint. - let f = setup(); - let owner_a = Address::generate(&f.env); - let owner_b = Address::generate(&f.env); - fund_owner(&f, &owner_a, 1_000); - fund_owner(&f, &owner_b, 1_000); - const PENALTY: u32 = 300; - - let id_a = f - .client - .create_commitment(&owner_a, &f.asset, &1_000, &RiskProfile::Balanced, &30, &PENALTY); - f.client.fund_escrow(&id_a); - let full_refund = f.client.refund(&id_a); - - let id_b = f - .client - .create_commitment(&owner_b, &f.asset, &1_000, &RiskProfile::Balanced, &30, &PENALTY); - f.client.fund_escrow(&id_b); - let partial_full = f.client.refund_partial(&id_b, &1_000); - - assert_eq!(full_refund, partial_full); -} - -// ── Issue #465: violation auto-trigger ───────────────────────────────────── - -#[test] -fn set_and_get_violation_threshold() { - let f = setup(); - // Default is 0 (disabled). - assert_eq!(f.client.get_violation_threshold(), 0); - - f.client.set_violation_threshold(&60); - assert_eq!(f.client.get_violation_threshold(), 60); -} - -#[test] -fn set_violation_threshold_clamps_to_100() { - let f = setup(); - f.client.set_violation_threshold(&150); - assert_eq!(f.client.get_violation_threshold(), 100); -} - -#[test] -fn attestation_below_threshold_auto_violates_funded_commitment() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - // Score of 59 is strictly below threshold of 60 — must violate. - f.client.record_attestation(&id, &attestor, &59); - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Violated); - assert_eq!(f.client.get_commitment(&id).compliance_score, 59); -} - -#[test] -fn attestation_at_threshold_does_not_violate() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - - // Score exactly at the threshold must NOT violate. - f.client.record_attestation(&id, &attestor, &60); - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Funded); -} - -#[test] -fn attestation_above_threshold_does_not_violate() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.fund_escrow(&id); - - f.client.record_attestation(&id, &attestor, &80); - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Funded); + assert!(refund >= 0, "refund must never be negative"); + assert!(penalty >= 0, "penalty must never be negative"); + assert_eq!(refund + penalty, amount, "refund and penalty must partition principal"); + assert!(penalty <= amount, "penalty must never exceed principal"); } #[test] -fn zero_threshold_disables_auto_violation() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); +fn deterministic_seeded_refund_inputs_preserve_penalty_invariants() { + let mut runner = TestRunner::deterministic(); + let strategy = (1i128..=1_000_000i128, 0u32..=10_000u32); - // Threshold defaults to 0 — no auto-violation even for score 0. - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.fund_escrow(&id); + runner + .run(&strategy, |(amount, penalty_bps)| { + let (penalty, refund) = EscrowContract::compute_refund_amount(amount, penalty_bps) + .map_err(|_| TestCaseError::fail("refund math should stay within arithmetic bounds"))?; - f.client.record_attestation(&id, &attestor, &0); - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Funded); + prop_assert_eq!(refund + penalty, amount); + prop_assert!(refund >= 0); + prop_assert!(penalty >= 0); + prop_assert!(penalty <= amount); + Ok(()) + }) + .unwrap(); } #[test] -fn violated_commitment_blocks_release() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - f.client.record_attestation(&id, &attestor, &40); - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Violated); +fn penalty_bps_zero_returns_full_refund() { + let amount = 9_876; + let (penalty, refund) = EscrowContract::compute_refund_amount(amount, 0) + .expect("zero penalty must be computable"); - // Advance past maturity — release must still be blocked. - f.env.ledger().set_timestamp(31 * 86_400); - let res = f.client.try_release(&id); - assert_eq!(res, Err(Ok(Error::CommitmentViolated))); + assert_eq!(penalty, 0); + assert_eq!(refund, amount); + assert_eq!(refund + penalty, amount); } #[test] -fn violated_commitment_blocks_refund() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); +fn penalty_bps_max_returns_zero_refund() { + let amount = 9_876; + let (penalty, refund) = EscrowContract::compute_refund_amount(amount, 10_000) + .expect("max penalty must be computable"); - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - f.client.record_attestation(&id, &attestor, &40); - - let res = f.client.try_refund(&id); - assert_eq!(res, Err(Ok(Error::CommitmentViolated))); + assert_eq!(penalty, amount); + assert_eq!(refund, 0); + assert_eq!(refund + penalty, amount); } #[test] -fn violated_commitment_blocks_refund_partial() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Balanced, &30, &300); - f.client.fund_escrow(&id); - f.client.record_attestation(&id, &attestor, &40); +fn overflow_guard_rejects_extreme_amounts() { + let overflow_amount = i128::MAX / 10_000 + 1; + let err = EscrowContract::compute_refund_amount(overflow_amount, 10_000) + .expect_err("overflowing intermediate multiplication must be rejected"); - let res = f.client.try_refund_partial(&id, &500); - assert_eq!(res, Err(Ok(Error::CommitmentViolated))); + assert_eq!(err, Error::InvalidAmount); } #[test] -fn attestation_on_non_funded_commitment_does_not_violate() { - let f = setup(); - let owner = Address::generate(&f.env); - let attestor = Address::generate(&f.env); - fund_owner(&f, &owner, 1_000); - - f.client.set_violation_threshold(&60); - - // Commitment is Created, not Funded. - let id = f - .client - .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &30, &200); - f.client.record_attestation(&id, &attestor, &10); - // Status should remain Created, not Violated. - assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Created); +fn small_amount_edge_cases_keep_refund_penalty_invariants() { + for amount in [1, 2, 3, 5, 10] { + assert_refund_invariants(amount, 0); + assert_refund_invariants(amount, 1); + assert_refund_invariants(amount, 10_000); + } }