diff --git a/quicklendx-contracts/Cargo.lock b/quicklendx-contracts/Cargo.lock index fa0a75dc..3eaffcd6 100644 --- a/quicklendx-contracts/Cargo.lock +++ b/quicklendx-contracts/Cargo.lock @@ -14,12 +14,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -29,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" @@ -187,9 +187,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" @@ -223,9 +223,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -242,23 +242,24 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "cc" -version = "1.2.30" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_eval" @@ -268,20 +269,19 @@ checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -378,7 +378,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -387,8 +387,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -402,7 +412,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -411,16 +434,27 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", - "syn 2.0.104", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", ] [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "der" @@ -434,12 +468,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -461,7 +495,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -615,17 +649,29 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" 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 = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -634,9 +680,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -653,8 +699,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "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]] @@ -694,9 +753,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heapless" @@ -740,9 +808,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -762,6 +830,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" @@ -781,13 +855,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.1", "serde", + "serde_core", ] [[package]] @@ -807,15 +882,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ "once_cell", "wasm-bindgen", @@ -835,36 +910,42 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" 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.174" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "macro-string" @@ -874,14 +955,14 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "num-bigint" @@ -895,9 +976,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -907,7 +988,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -930,9 +1011,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "p256" @@ -979,12 +1060,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -998,18 +1079,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", @@ -1040,9 +1121,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1053,6 +1134,12 @@ 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.5" @@ -1100,7 +1187,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -1123,29 +1210,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rfc6979" @@ -1168,9 +1255,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1181,9 +1268,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -1197,12 +1284,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "schemars" version = "0.8.22" @@ -1228,9 +1309,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -1253,58 +1334,68 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_with" -version = "3.14.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.13.0", "schemars 0.8.22", "schemars 0.9.0", - "schemars 1.0.4", - "serde", - "serde_derive", + "schemars 1.2.1", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -1312,14 +1403,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1374,7 +1465,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1393,7 +1484,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1422,7 +1513,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom 0.2.16", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1440,7 +1531,7 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-strkey 0.0.13", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] @@ -1455,14 +1546,14 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "soroban-ledger-snapshot" -version = "25.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d569a1315f05216d024653ad87541aa15d3ff26dad9f8a98719cb53ccf2bf3" +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" dependencies = [ "serde", "serde_json", @@ -1474,9 +1565,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "25.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add8d19cfd2c9941bbdc7c8223c3cf9d7ff9af4554ba3bd4ae93e16b19b08aea" +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" dependencies = [ "arbitrary", "bytes-lit", @@ -1498,11 +1589,11 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "25.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0107e34575ec704ce29407695462e79e6b0e13ce7af6431b2f15c313e34464" +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" dependencies = [ - "darling", + "darling 0.20.11", "heck", "itertools", "macro-string", @@ -1513,26 +1604,27 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "soroban-spec" -version = "25.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a1c9f6ccc6aa78518545e3cf542bd26f11d9085328a2e1c06c90514733fe15" +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" dependencies = [ "base64", + "sha2", "stellar-xdr", "thiserror", - "wasmparser", + "wasmparser 0.116.1", ] [[package]] name = "soroban-spec-rust" -version = "25.1.1" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8247d3c6256b544b2461606c6892351bb22978d751e07c1aea744377053d852" +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" dependencies = [ "prettyplease", "proc-macro2", @@ -1540,7 +1632,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn 2.0.104", + "syn 2.0.117", "thiserror", ] @@ -1650,9 +1742,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1661,12 +1753,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -1689,35 +1781,35 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -1725,9 +1817,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unarray" @@ -1737,9 +1829,15 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.18" +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 = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "version_check" @@ -1755,7 +1853,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1783,36 +1881,32 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +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 = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1820,26 +1914,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" 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.13.0", + "wasm-encoder", + "wasmparser 0.244.0", +] + [[package]] name = "wasmi_arena" version = "0.4.1" @@ -1864,7 +1980,19 @@ version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.13.0", + "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.13.0", "semver", ] @@ -1879,45 +2007,39 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.1.3", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -1926,20 +2048,20 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -1948,7 +2070,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1956,43 +2078,131 @@ 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-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.13.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.13.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.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/quicklendx-contracts/docs/contracts/escrow.md b/quicklendx-contracts/docs/contracts/escrow.md index 46c2bc29..907ca5c5 100644 --- a/quicklendx-contracts/docs/contracts/escrow.md +++ b/quicklendx-contracts/docs/contracts/escrow.md @@ -1,64 +1,121 @@ -# Escrow Acceptance Hardening +# Escrow & Token Transfer Error Handling ## Overview -The escrow funding flow now enforces a single set of preconditions before a bid can be accepted. -This applies to both public acceptance entrypoints: +The escrow module manages the full lifecycle of investor funds: locking them on +bid acceptance, releasing them to the business on settlement, and refunding them +to the investor on cancellation or dispute. -- `accept_bid` -- `accept_bid_and_fund` +All token movements go through `payments::transfer_funds`, which surfaces +Stellar token failures as typed `QuickLendXError` variants **before** any state +is mutated. -## Security Goals +--- -- Ensure the caller is authorizing the exact invoice that will be funded. -- Ensure only a valid `invoice_id` and `bid_id` pair can progress. -- Prevent funding when escrow or investment state already exists for the invoice. -- Reject inconsistent invoice funding metadata before any token transfer occurs. +## Token Transfer Error Variants -## Acceptance Preconditions +| Error | Code | When raised | +|---|---|---| +| `InvalidAmount` | 1200 | `amount <= 0` passed to `transfer_funds` | +| `InsufficientFunds` | 1400 | Sender's token balance is below `amount` | +| `OperationNotAllowed` | 1402 | Investor's allowance to the contract is below `amount` | +| `TokenTransferFailed` | 2200 | Reserved for future use if the token contract panics | -Before the contract creates escrow, it now checks: +--- -- The invoice exists. -- The caller is the invoice business owner and passes business KYC state checks. -- The invoice is still available for funding. -- The invoice has no stale funding metadata: - - `funded_amount == 0` - - `funded_at == None` - - `investor == None` -- The invoice does not already have: - - an escrow record - - an investment record -- The bid exists. -- The bid belongs to the provided invoice. -- The bid is still `Placed`. -- The bid has not expired. -- The bid amount is positive. +## Escrow Creation (`create_escrow` / `accept_bid`) -## Issue Addressed +### Preconditions checked before any token call -Previously, `accept_bid` reloaded the invoice ID from the bid after authorizing against the caller-supplied invoice. That allowed a mismatched invoice/bid pair to drift into the funding path and risk: +1. `amount > 0` — `InvalidAmount` otherwise. +2. No existing escrow for the invoice — `InvoiceAlreadyFunded` otherwise. +3. Investor balance ≥ `amount` — `InsufficientFunds` otherwise. +4. Investor allowance to contract ≥ `amount` — `OperationNotAllowed` otherwise. -- escrow being created under the wrong invoice key -- status index corruption -- unauthorized cross-invoice funding side effects +### Atomicity guarantee -Both acceptance paths now share the same validator in [`escrow.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/escrow.rs). +The escrow record is written to storage **only after** `token.transfer_from` +returns successfully. If the token call fails, no escrow record is created and +the invoice/bid states are left unchanged. The operation is safe to retry. -## Tests Added +### Failure scenarios -The escrow hardening is covered with targeted regression tests in: +| Scenario | Error returned | State after failure | +|---|---|---| +| Investor has zero balance | `InsufficientFunds` | Invoice: `Verified`, Bid: `Placed`, no escrow | +| Investor has zero allowance | `OperationNotAllowed` | Invoice: `Verified`, Bid: `Placed`, no escrow | +| Investor has partial allowance | `OperationNotAllowed` | Invoice: `Verified`, Bid: `Placed`, no escrow | +| Escrow already exists for invoice | `InvoiceAlreadyFunded` | No change | -- [`test_escrow.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/test_escrow.rs) -- [`test_bid.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/test_bid.rs) +--- -New scenarios include: +## Escrow Release (`release_escrow`) -- rejecting mismatched invoice/bid pairs with no balance or status side effects -- rejecting acceptance when escrow already exists for the invoice +Transfers funds from the contract to the business. -## Security Notes +### Preconditions -- Validation runs before any funds are transferred into escrow. -- Existing escrow or investment state is treated as a hard stop to preserve one-to-one funding invariants. -- The contract still relies on the payment reentrancy guard in [`lib.rs`](/Users/mac/Documents/github/wave/quicklendx-protocol/quicklendx-contracts/src/lib.rs). +1. Escrow record exists — `StorageKeyNotFound` otherwise. +2. Escrow status is `Held` — `InvalidStatus` otherwise (idempotency guard). +3. Contract balance ≥ escrow amount — `InsufficientFunds` otherwise. + +### Atomicity guarantee + +The escrow status is updated to `Released` **only after** `token.transfer` +returns successfully. If the transfer fails, the status remains `Held` and the +release can be safely retried. + +--- + +## Escrow Refund (`refund_escrow` / `refund_escrow_funds`) + +Transfers funds from the contract back to the investor. + +### Preconditions + +1. Escrow record exists — `StorageKeyNotFound` otherwise. +2. Escrow status is `Held` — `InvalidStatus` otherwise. +3. Contract balance ≥ escrow amount — `InsufficientFunds` otherwise. + +### Atomicity guarantee + +The escrow status is updated to `Refunded` **only after** `token.transfer` +returns successfully. If the transfer fails, the status remains `Held` and the +refund can be safely retried. + +### Authorization + +Only the contract admin or the invoice's business owner may call +`refund_escrow_funds`. Unauthorized callers receive `Unauthorized`. + +--- + +## Security Assumptions + +- **No partial transfers.** Balance and allowance are validated before the token + call. The token contract is never invoked when these checks fail. +- **Idempotency.** Once an escrow transitions to `Released` or `Refunded`, all + further release/refund attempts return `InvalidStatus` without moving funds. +- **One escrow per invoice.** A second `create_escrow` call for the same invoice + returns `InvoiceAlreadyFunded` before any token interaction. +- **Reentrancy protection.** All public entry points that touch escrow are + wrapped with the reentrancy guard in `lib.rs` (`OperationNotAllowed` on + re-entry). + +--- + +## Tests + +Token transfer failure behavior is covered in: + +- [`src/test_escrow.rs`](../../src/test_escrow.rs) — creation failures: + - `test_accept_bid_fails_when_investor_has_zero_balance` + - `test_accept_bid_fails_when_investor_has_zero_allowance` + - `test_accept_bid_fails_when_investor_has_partial_allowance` + - `test_accept_bid_succeeds_after_topping_up_balance` +- [`src/test_refund.rs`](../../src/test_refund.rs) — refund failures: + - `test_refund_fails_when_contract_has_insufficient_balance` + - `test_refund_succeeds_after_balance_restored` + +Existing acceptance-hardening tests (state invariants, double-accept, mismatched +invoice/bid pairs) remain in the same files. diff --git a/quicklendx-contracts/scripts/check-wasm-size.sh b/quicklendx-contracts/scripts/check-wasm-size.sh index e91ca613..2690673a 100755 --- a/quicklendx-contracts/scripts/check-wasm-size.sh +++ b/quicklendx-contracts/scripts/check-wasm-size.sh @@ -31,7 +31,7 @@ cd "$CONTRACTS_DIR" # ── Budget constants ─────────────────────────────────────────────────────────── MAX_BYTES="$((256 * 1024))" # 262 144 B – hard limit (network deployment ceiling) WARN_BYTES="$((MAX_BYTES * 9 / 10))" # 235 929 B – 90 % warning zone -BASELINE_BYTES=217668 # last recorded optimised size +BASELINE_BYTES=241218 # last recorded optimised size REGRESSION_MARGIN_PCT=5 # 5 % growth allowed vs baseline REGRESSION_LIMIT=$(( BASELINE_BYTES + BASELINE_BYTES * REGRESSION_MARGIN_PCT / 100 )) WASM_NAME="quicklendx_contracts.wasm" diff --git a/quicklendx-contracts/scripts/wasm-size-baseline.toml b/quicklendx-contracts/scripts/wasm-size-baseline.toml index 9f7d4c9c..3b39d988 100644 --- a/quicklendx-contracts/scripts/wasm-size-baseline.toml +++ b/quicklendx-contracts/scripts/wasm-size-baseline.toml @@ -23,10 +23,10 @@ # Optimised WASM size in bytes at the last recorded state. # Must match WASM_SIZE_BASELINE_BYTES in tests/wasm_build_size_budget.rs # and BASELINE_BYTES in scripts/check-wasm-size.sh. -bytes = 217668 +bytes = 241218 # ISO-8601 date when this baseline was last recorded (informational only). -recorded = "2026-03-25" +recorded = "2026-03-29" # Maximum fractional growth allowed relative to `bytes` before CI fails. # Must match WASM_REGRESSION_MARGIN in tests/wasm_build_size_budget.rs. diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index 05a9668b..cec4e3f9 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -89,7 +89,7 @@ pub enum QuickLendXError { NotificationNotFound = 2000, NotificationBlocked = 2001, - // Emergency withdraw (2100–2104) + // Emergency withdraw (2100–2106) ContractPaused = 2100, EmergencyWithdrawNotFound = 2101, EmergencyWithdrawTimelockNotElapsed = 2102, @@ -97,6 +97,11 @@ pub enum QuickLendXError { EmergencyWithdrawCancelled = 2104, EmergencyWithdrawAlreadyExists = 2105, EmergencyWithdrawInsufficientBalance = 2106, + + /// The underlying Stellar token `transfer` or `transfer_from` call failed + /// (e.g. the token contract panicked or returned an error). + /// Callers should treat this as a hard failure; no funds moved. + TokenTransferFailed = 2200, } impl From for Symbol { @@ -177,6 +182,7 @@ impl From for Symbol { QuickLendXError::EmergencyWithdrawCancelled => symbol_short!("EMG_CNL"), QuickLendXError::EmergencyWithdrawAlreadyExists => symbol_short!("EMG_EX"), QuickLendXError::EmergencyWithdrawInsufficientBalance => symbol_short!("EMG_BAL"), + QuickLendXError::TokenTransferFailed => symbol_short!("TKN_FAIL"), } } } diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index 05ad0dfc..35ba4914 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -95,7 +95,16 @@ impl EscrowStorage { /// * `Ok(escrow_id)` - The new escrow ID /// /// # Errors -/// * `InvalidAmount` if amount <= 0, or token/allowance errors from transfer +/// * [`QuickLendXError::InvalidAmount`] – `amount` is zero or negative. +/// * [`QuickLendXError::InvoiceAlreadyFunded`] – an escrow record already exists for this invoice. +/// * [`QuickLendXError::InsufficientFunds`] – investor balance is below `amount`. +/// * [`QuickLendXError::OperationNotAllowed`] – investor has not approved the contract for `amount`. +/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; no funds moved and +/// no escrow record is written. +/// +/// # Atomicity +/// The escrow record is only written **after** the token transfer succeeds. +/// If the transfer fails the invoice and bid states are left unchanged. pub fn create_escrow( env: &Env, invoice_id: &BytesN<32>, @@ -145,7 +154,12 @@ pub fn create_escrow( /// the operation can be safely retried. /// /// # Errors -/// * `StorageKeyNotFound` if no escrow for invoice, `InvalidStatus` if not Held +/// * [`QuickLendXError::StorageKeyNotFound`] – no escrow record exists for this invoice. +/// * [`QuickLendXError::InvalidStatus`] – escrow is not in `Held` status (already released/refunded). +/// * [`QuickLendXError::InsufficientFunds`] – contract balance is below the escrow amount +/// (should never happen in normal operation; indicates a critical invariant violation). +/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; escrow status is +/// **not** updated so the release can be safely retried. pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) .ok_or(QuickLendXError::StorageKeyNotFound)?; @@ -175,7 +189,11 @@ pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen /// Refund escrow funds to investor (contract → investor). Escrow must be Held. /// /// # Errors -/// * `StorageKeyNotFound` if no escrow for invoice, `InvalidStatus` if not Held +/// * [`QuickLendXError::StorageKeyNotFound`] – no escrow record exists for this invoice. +/// * [`QuickLendXError::InvalidStatus`] – escrow is not in `Held` status. +/// * [`QuickLendXError::InsufficientFunds`] – contract balance is below the escrow amount. +/// * [`QuickLendXError::TokenTransferFailed`] – the token contract panicked; escrow status is +/// **not** updated so the refund can be safely retried. pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) .ok_or(QuickLendXError::StorageKeyNotFound)?; @@ -204,7 +222,16 @@ pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLend /// Transfer token funds from one address to another. Uses allowance when `from` is not the contract. /// /// # Errors -/// * `InvalidAmount`, `InsufficientFunds`, `OperationNotAllowed` (insufficient allowance) +/// * [`QuickLendXError::InvalidAmount`] – `amount` is zero or negative. +/// * [`QuickLendXError::InsufficientFunds`] – `from` balance is below `amount`. +/// * [`QuickLendXError::OperationNotAllowed`] – allowance granted to the contract is below `amount`. +/// * [`QuickLendXError::TokenTransferFailed`] – the underlying Stellar token call panicked or +/// returned an error. No funds moved when this error is returned. +/// +/// # Security +/// - Balance and allowance are checked **before** the token call so that the contract +/// never enters a partial-transfer state. +/// - When `from == to` the function is a no-op (returns `Ok(())`). pub fn transfer_funds( env: &Env, currency: &Address, diff --git a/quicklendx-contracts/src/test_admin.rs b/quicklendx-contracts/src/test_admin.rs index 7abb78dc..0258a85a 100644 --- a/quicklendx-contracts/src/test_admin.rs +++ b/quicklendx-contracts/src/test_admin.rs @@ -1,574 +1,574 @@ -//! Comprehensive test suite for hardened admin role management. -//! -//! Test Coverage: -//! 1. Initialization — admin setup, double-init prevention, authorization -//! 2. Transfer — success path, authorization, validation, atomicity -//! 3. Query Functions — get_admin, is_admin, is_initialized -//! 4. Authorization — require_admin, require_current_admin -//! 5. Security — transfer locks, concurrent operations, edge cases -//! 6. Events — initialization, transfer audit trail -//! 7. Utilities — with_admin_auth, with_current_admin -//! 8. Legacy Compatibility — set_admin routing -//! -//! Target: 95%+ test coverage for admin.rs - -#[cfg(test)] -mod test_admin { - use crate::admin::AdminStorage; - use crate::errors::QuickLendXError; - use crate::{QuickLendXContract, QuickLendXContractClient}; - use soroban_sdk::{ - testutils::{Address as _, Events}, - Address, Env, - }; - - fn setup() -> (Env, QuickLendXContractClient<'static>) { - let env = Env::default(); - let contract_id = env.register(QuickLendXContract, ()); - let client = QuickLendXContractClient::new(&env, &contract_id); - (env, client) - } - - fn setup_with_admin() -> (Env, QuickLendXContractClient<'static>, Address) { - let (env, client) = setup(); - env.mock_all_auths(); - let admin = Address::generate(&env); - client.initialize_admin(&admin); - (env, client, admin) - } - - // ============================================================================ - // 1. Initialization Tests - // ============================================================================ - - #[test] - fn test_initialize_admin_succeeds() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let result = client.try_initialize_admin(&admin); - - assert!(result.is_ok(), "First initialization must succeed"); - assert_eq!( - client.get_current_admin(), - Some(admin.clone()), - "Stored admin must match initialized address" - ); - assert!( - AdminStorage::is_initialized(&env), - "Admin system must be marked as initialized" - ); - } - - #[test] - fn test_initialize_admin_requires_authorization() { - let env = Env::default(); - let contract_id = env.register(QuickLendXContract, ()); - let client = QuickLendXContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - - // Should panic without authorization - let result = std::panic::catch_unwind(|| { - client.initialize_admin(&admin); - }); - assert!(result.is_err(), "Initialization without auth must fail"); - } - - #[test] - fn test_initialize_admin_double_init_fails() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin1 = Address::generate(&env); - let admin2 = Address::generate(&env); - - // First initialization succeeds - client.initialize_admin(&admin1); - - // Second initialization fails - let result = client.try_initialize_admin(&admin2); - assert!(result.is_err(), "Double initialization must be rejected"); - - // Original admin remains - assert_eq!( - client.get_current_admin(), - Some(admin1), - "Original admin must remain after failed re-init" - ); - } - - #[test] - fn test_initialize_admin_same_address_twice_fails() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - client.initialize_admin(&admin); - - let result = client.try_initialize_admin(&admin); - assert!( - result.is_err(), - "Re-initializing with same address must fail" - ); - } - - #[test] - fn test_initialize_admin_emits_event() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - client.initialize_admin(&admin); - - let events = env.events().all(); - assert!(!events.is_empty(), "Initialization must emit event"); - - let event = &events[0]; - assert_eq!(event.0, (soroban_sdk::symbol_short!("adm_init"),)); - } - - // ============================================================================ - // 2. Admin Transfer Tests - // ============================================================================ - - #[test] - fn test_transfer_admin_succeeds() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - - let result = client.try_transfer_admin(&admin1, &admin2); - assert!(result.is_ok(), "Admin transfer must succeed"); - - assert_eq!( - client.get_current_admin(), - Some(admin2), - "New admin must be stored" - ); - assert!( - !AdminStorage::is_admin(&env, &admin1), - "Old admin must no longer be admin" - ); - assert!( - AdminStorage::is_admin(&env, &admin2), - "New admin must be recognized" - ); - } - - #[test] - fn test_transfer_admin_requires_current_admin_auth() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - let non_admin = Address::generate(&env); - - // Non-admin cannot transfer - let result = client.try_transfer_admin(&non_admin, &admin2); - assert!(result.is_err(), "Non-admin transfer must fail"); - - // Admin remains unchanged - assert_eq!( - client.get_current_admin(), - Some(admin1), - "Admin must remain unchanged after failed transfer" - ); - } - - #[test] - fn test_transfer_admin_to_self_fails() { - let (env, client, admin) = setup_with_admin(); - - let result = client.try_transfer_admin(&admin, &admin); - assert!(result.is_err(), "Transfer to self must fail"); - - assert_eq!( - client.get_current_admin(), - Some(admin), - "Admin must remain unchanged" - ); - } - - #[test] - fn test_transfer_admin_without_initialization_fails() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin1 = Address::generate(&env); - let admin2 = Address::generate(&env); - - let result = client.try_transfer_admin(&admin1, &admin2); - assert!(result.is_err(), "Transfer without initialization must fail"); - } - - #[test] - fn test_transfer_admin_emits_event() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - - client.transfer_admin(&admin1, &admin2); - - let events = env.events().all(); - let transfer_events: Vec<_> = events - .iter() - .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_trf"),)) - .collect(); - - assert!(!transfer_events.is_empty(), "Transfer must emit event"); - } - - #[test] - fn test_transfer_admin_chain() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - let admin3 = Address::generate(&env); - - // Transfer from admin1 to admin2 - client.transfer_admin(&admin1, &admin2); - assert_eq!(client.get_current_admin(), Some(admin2.clone())); - - // Transfer from admin2 to admin3 - client.transfer_admin(&admin2, &admin3); - assert_eq!(client.get_current_admin(), Some(admin3)); - - // admin1 can no longer transfer - let result = client.try_transfer_admin(&admin1, &admin2); - assert!(result.is_err(), "Old admin cannot transfer"); - } - - // ============================================================================ - // 3. Query Function Tests - // ============================================================================ - - #[test] - fn test_get_admin_before_initialization() { - let (env, client) = setup(); - - assert_eq!( - client.get_current_admin(), - None, - "Admin must be None before initialization" - ); - assert!( - !AdminStorage::is_initialized(&env), - "System must not be initialized" - ); - } - - #[test] - fn test_get_admin_after_initialization() { - let (env, client, admin) = setup_with_admin(); - - assert_eq!( - client.get_current_admin(), - Some(admin.clone()), - "Admin must be returned after initialization" - ); - assert!( - AdminStorage::is_initialized(&env), - "System must be initialized" - ); - } - - #[test] - fn test_is_admin_checks() { - let (env, client, admin) = setup_with_admin(); - let non_admin = Address::generate(&env); - - assert!( - AdminStorage::is_admin(&env, &admin), - "Admin address must return true" - ); - assert!( - !AdminStorage::is_admin(&env, &non_admin), - "Non-admin address must return false" - ); - } - - #[test] - fn test_is_admin_before_initialization() { - let (env, _client) = setup(); - let address = Address::generate(&env); - - assert!( - !AdminStorage::is_admin(&env, &address), - "No address should be admin before initialization" - ); - } - - // ============================================================================ - // 4. Authorization Tests - // ============================================================================ - - #[test] - fn test_require_admin_succeeds_for_admin() { - let (env, _client, admin) = setup_with_admin(); - - let result = AdminStorage::require_admin(&env, &admin); - assert!(result.is_ok(), "require_admin must succeed for admin"); - } - - #[test] - fn test_require_admin_fails_for_non_admin() { - let (env, _client, _admin) = setup_with_admin(); - let non_admin = Address::generate(&env); - - let result = AdminStorage::require_admin(&env, &non_admin); - assert_eq!( - result, - Err(QuickLendXError::NotAdmin), - "require_admin must fail for non-admin" - ); - } - - #[test] - fn test_require_admin_fails_before_initialization() { - let (env, _client) = setup(); - let address = Address::generate(&env); - - let result = AdminStorage::require_admin(&env, &address); - assert_eq!( - result, - Err(QuickLendXError::OperationNotAllowed), - "require_admin must fail before initialization" - ); - } - - #[test] - fn test_require_current_admin_succeeds() { - let (env, _client, admin) = setup_with_admin(); - - let result = AdminStorage::require_current_admin(&env); - assert!(result.is_ok(), "require_current_admin must succeed"); - assert_eq!(result.unwrap(), admin, "Must return correct admin address"); - } - - #[test] - fn test_require_current_admin_fails_before_initialization() { - let (env, _client) = setup(); - - let result = AdminStorage::require_current_admin(&env); - assert_eq!( - result, - Err(QuickLendXError::OperationNotAllowed), - "require_current_admin must fail before initialization" - ); - } - - // ============================================================================ - // 5. Security Tests - // ============================================================================ - - #[test] - fn test_admin_operations_atomic() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - - // Verify atomicity by checking state before and after - assert!(AdminStorage::is_admin(&env, &admin1)); - assert!(!AdminStorage::is_admin(&env, &admin2)); - - client.transfer_admin(&admin1, &admin2); - - // State should be completely switched - assert!(!AdminStorage::is_admin(&env, &admin1)); - assert!(AdminStorage::is_admin(&env, &admin2)); - } - - #[test] - fn test_initialization_state_consistency() { - let (env, client) = setup(); - env.mock_all_auths(); - - // Before initialization - assert!(!AdminStorage::is_initialized(&env)); - assert_eq!(AdminStorage::get_admin(&env), None); - - let admin = Address::generate(&env); - client.initialize_admin(&admin); - - // After initialization - assert!(AdminStorage::is_initialized(&env)); - assert_eq!(AdminStorage::get_admin(&env), Some(admin)); - } - - // ============================================================================ - // 6. Utility Function Tests - // ============================================================================ - - #[test] - fn test_with_admin_auth_succeeds() { - let (env, _client, admin) = setup_with_admin(); - - let result = AdminStorage::with_admin_auth(&env, &admin, || Ok("success".to_string())); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "success"); - } - - #[test] - fn test_with_admin_auth_fails_for_non_admin() { - let (env, _client, _admin) = setup_with_admin(); - let non_admin = Address::generate(&env); - - let result = AdminStorage::with_admin_auth(&env, &non_admin, || Ok("should not execute")); - - assert_eq!(result, Err(QuickLendXError::NotAdmin)); - } - - #[test] - fn test_with_current_admin_succeeds() { - let (env, _client, admin) = setup_with_admin(); - - let result = AdminStorage::with_current_admin(&env, |current_admin| { - assert_eq!(current_admin, &admin); - Ok("success") - }); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "success"); - } - - #[test] - fn test_with_current_admin_fails_before_initialization() { - let (env, _client) = setup(); - - let result = AdminStorage::with_current_admin(&env, |_| Ok("should not execute")); - - assert_eq!(result, Err(QuickLendXError::OperationNotAllowed)); - } - - // ============================================================================ - // 7. Legacy Compatibility Tests - // ============================================================================ - - #[test] - fn test_set_admin_routes_to_initialize() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin = Address::generate(&env); - let result = client.try_set_admin(&admin); - - assert!(result.is_ok(), "set_admin must route to initialize"); - assert_eq!(client.get_current_admin(), Some(admin)); - assert!(AdminStorage::is_initialized(&env)); - } - - #[test] - fn test_set_admin_routes_to_transfer() { - let (env, client, admin1) = setup_with_admin(); - let admin2 = Address::generate(&env); - - let result = client.try_set_admin(&admin2); - - assert!(result.is_ok(), "set_admin must route to transfer"); - assert_eq!(client.get_current_admin(), Some(admin2)); - } - - // ============================================================================ - // 8. Edge Cases and Error Conditions - // ============================================================================ - - #[test] - fn test_multiple_rapid_transfers() { - let (env, client, mut current_admin) = setup_with_admin(); - - // Perform multiple transfers in sequence - for i in 0..5 { - let new_admin = Address::generate(&env); - client.transfer_admin(¤t_admin, &new_admin); - - assert_eq!( - client.get_current_admin(), - Some(new_admin.clone()), - "Transfer {} must succeed", - i - ); - current_admin = new_admin; - } - } - - #[test] - fn test_admin_state_after_failed_operations() { - let (env, client, admin) = setup_with_admin(); - let non_admin = Address::generate(&env); - - // Failed transfer should not change state - let _result = client.try_transfer_admin(&non_admin, &admin); - assert_eq!( - client.get_current_admin(), - Some(admin), - "Failed transfer must not change admin" - ); - - // Failed initialization should not change state - let _result = client.try_initialize_admin(&non_admin); - assert_eq!( - client.get_current_admin(), - Some(admin), - "Failed re-initialization must not change admin" - ); - } - - #[test] - fn test_event_emission_consistency() { - let (env, client) = setup(); - env.mock_all_auths(); - - let admin1 = Address::generate(&env); - let admin2 = Address::generate(&env); - - // Initialize and transfer - client.initialize_admin(&admin1); - client.transfer_admin(&admin1, &admin2); - - let events = env.events().all(); - - // Should have initialization and transfer events - let init_events: Vec<_> = events - .iter() - .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_init"),)) - .collect(); - let transfer_events: Vec<_> = events - .iter() - .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_trf"),)) - .collect(); - - assert_eq!(init_events.len(), 1, "Must have one init event"); - assert_eq!(transfer_events.len(), 1, "Must have one transfer event"); - } - - // ============================================================================ - // 9. Integration Tests - // ============================================================================ - - #[test] - fn test_full_admin_lifecycle() { - let (env, client) = setup(); - env.mock_all_auths(); - - // 1. Initial state - assert!(!AdminStorage::is_initialized(&env)); - assert_eq!(AdminStorage::get_admin(&env), None); - - // 2. Initialize admin - let admin1 = Address::generate(&env); - client.initialize_admin(&admin1); - assert!(AdminStorage::is_initialized(&env)); - assert_eq!(AdminStorage::get_admin(&env), Some(admin1.clone())); - - // 3. Transfer admin - let admin2 = Address::generate(&env); - client.transfer_admin(&admin1, &admin2); - assert_eq!(AdminStorage::get_admin(&env), Some(admin2.clone())); - - // 4. Verify old admin cannot operate - let admin3 = Address::generate(&env); - let result = client.try_transfer_admin(&admin1, &admin3); - assert!(result.is_err()); - - // 5. Verify new admin can operate - client.transfer_admin(&admin2, &admin3); - assert_eq!(AdminStorage::get_admin(&env), Some(admin3)); - } -} +//! Comprehensive test suite for hardened admin role management. +//! +//! Test Coverage: +//! 1. Initialization — admin setup, double-init prevention, authorization +//! 2. Transfer — success path, authorization, validation, atomicity +//! 3. Query Functions — get_admin, is_admin, is_initialized +//! 4. Authorization — require_admin, require_current_admin +//! 5. Security — transfer locks, concurrent operations, edge cases +//! 6. Events — initialization, transfer audit trail +//! 7. Utilities — with_admin_auth, with_current_admin +//! 8. Legacy Compatibility — set_admin routing +//! +//! Target: 95%+ test coverage for admin.rs + +#[cfg(test)] +mod test_admin { + use crate::admin::AdminStorage; + use crate::errors::QuickLendXError; + use crate::{QuickLendXContract, QuickLendXContractClient}; + use soroban_sdk::{ + testutils::{Address as _, Events}, + Address, Env, + }; + + fn setup() -> (Env, QuickLendXContractClient<'static>) { + let env = Env::default(); + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(&env, &contract_id); + (env, client) + } + + fn setup_with_admin() -> (Env, QuickLendXContractClient<'static>, Address) { + let (env, client) = setup(); + env.mock_all_auths(); + let admin = Address::generate(&env); + client.initialize_admin(&admin); + (env, client, admin) + } + + // ============================================================================ + // 1. Initialization Tests + // ============================================================================ + + #[test] + fn test_initialize_admin_succeeds() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let result = client.try_initialize_admin(&admin); + + assert!(result.is_ok(), "First initialization must succeed"); + assert_eq!( + client.get_current_admin(), + Some(admin.clone()), + "Stored admin must match initialized address" + ); + assert!( + AdminStorage::is_initialized(&env), + "Admin system must be marked as initialized" + ); + } + + #[test] + fn test_initialize_admin_requires_authorization() { + let env = Env::default(); + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + + // Should panic without authorization + let result = std::panic::catch_unwind(|| { + client.initialize_admin(&admin); + }); + assert!(result.is_err(), "Initialization without auth must fail"); + } + + #[test] + fn test_initialize_admin_double_init_fails() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + // First initialization succeeds + client.initialize_admin(&admin1); + + // Second initialization fails + let result = client.try_initialize_admin(&admin2); + assert!(result.is_err(), "Double initialization must be rejected"); + + // Original admin remains + assert_eq!( + client.get_current_admin(), + Some(admin1), + "Original admin must remain after failed re-init" + ); + } + + #[test] + fn test_initialize_admin_same_address_twice_fails() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + client.initialize_admin(&admin); + + let result = client.try_initialize_admin(&admin); + assert!( + result.is_err(), + "Re-initializing with same address must fail" + ); + } + + #[test] + fn test_initialize_admin_emits_event() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + client.initialize_admin(&admin); + + let events = env.events().all(); + assert!(!events.is_empty(), "Initialization must emit event"); + + let event = &events[0]; + assert_eq!(event.0, (soroban_sdk::symbol_short!("adm_init"),)); + } + + // ============================================================================ + // 2. Admin Transfer Tests + // ============================================================================ + + #[test] + fn test_transfer_admin_succeeds() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + + let result = client.try_transfer_admin(&admin1, &admin2); + assert!(result.is_ok(), "Admin transfer must succeed"); + + assert_eq!( + client.get_current_admin(), + Some(admin2), + "New admin must be stored" + ); + assert!( + !AdminStorage::is_admin(&env, &admin1), + "Old admin must no longer be admin" + ); + assert!( + AdminStorage::is_admin(&env, &admin2), + "New admin must be recognized" + ); + } + + #[test] + fn test_transfer_admin_requires_current_admin_auth() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + let non_admin = Address::generate(&env); + + // Non-admin cannot transfer + let result = client.try_transfer_admin(&non_admin, &admin2); + assert!(result.is_err(), "Non-admin transfer must fail"); + + // Admin remains unchanged + assert_eq!( + client.get_current_admin(), + Some(admin1), + "Admin must remain unchanged after failed transfer" + ); + } + + #[test] + fn test_transfer_admin_to_self_fails() { + let (env, client, admin) = setup_with_admin(); + + let result = client.try_transfer_admin(&admin, &admin); + assert!(result.is_err(), "Transfer to self must fail"); + + assert_eq!( + client.get_current_admin(), + Some(admin), + "Admin must remain unchanged" + ); + } + + #[test] + fn test_transfer_admin_without_initialization_fails() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + let result = client.try_transfer_admin(&admin1, &admin2); + assert!(result.is_err(), "Transfer without initialization must fail"); + } + + #[test] + fn test_transfer_admin_emits_event() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + + client.transfer_admin(&admin1, &admin2); + + let events = env.events().all(); + let transfer_events: Vec<_> = events + .iter() + .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_trf"),)) + .collect(); + + assert!(!transfer_events.is_empty(), "Transfer must emit event"); + } + + #[test] + fn test_transfer_admin_chain() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + // Transfer from admin1 to admin2 + client.transfer_admin(&admin1, &admin2); + assert_eq!(client.get_current_admin(), Some(admin2.clone())); + + // Transfer from admin2 to admin3 + client.transfer_admin(&admin2, &admin3); + assert_eq!(client.get_current_admin(), Some(admin3)); + + // admin1 can no longer transfer + let result = client.try_transfer_admin(&admin1, &admin2); + assert!(result.is_err(), "Old admin cannot transfer"); + } + + // ============================================================================ + // 3. Query Function Tests + // ============================================================================ + + #[test] + fn test_get_admin_before_initialization() { + let (env, client) = setup(); + + assert_eq!( + client.get_current_admin(), + None, + "Admin must be None before initialization" + ); + assert!( + !AdminStorage::is_initialized(&env), + "System must not be initialized" + ); + } + + #[test] + fn test_get_admin_after_initialization() { + let (env, client, admin) = setup_with_admin(); + + assert_eq!( + client.get_current_admin(), + Some(admin.clone()), + "Admin must be returned after initialization" + ); + assert!( + AdminStorage::is_initialized(&env), + "System must be initialized" + ); + } + + #[test] + fn test_is_admin_checks() { + let (env, client, admin) = setup_with_admin(); + let non_admin = Address::generate(&env); + + assert!( + AdminStorage::is_admin(&env, &admin), + "Admin address must return true" + ); + assert!( + !AdminStorage::is_admin(&env, &non_admin), + "Non-admin address must return false" + ); + } + + #[test] + fn test_is_admin_before_initialization() { + let (env, _client) = setup(); + let address = Address::generate(&env); + + assert!( + !AdminStorage::is_admin(&env, &address), + "No address should be admin before initialization" + ); + } + + // ============================================================================ + // 4. Authorization Tests + // ============================================================================ + + #[test] + fn test_require_admin_succeeds_for_admin() { + let (env, _client, admin) = setup_with_admin(); + + let result = AdminStorage::require_admin(&env, &admin); + assert!(result.is_ok(), "require_admin must succeed for admin"); + } + + #[test] + fn test_require_admin_fails_for_non_admin() { + let (env, _client, _admin) = setup_with_admin(); + let non_admin = Address::generate(&env); + + let result = AdminStorage::require_admin(&env, &non_admin); + assert_eq!( + result, + Err(QuickLendXError::NotAdmin), + "require_admin must fail for non-admin" + ); + } + + #[test] + fn test_require_admin_fails_before_initialization() { + let (env, _client) = setup(); + let address = Address::generate(&env); + + let result = AdminStorage::require_admin(&env, &address); + assert_eq!( + result, + Err(QuickLendXError::OperationNotAllowed), + "require_admin must fail before initialization" + ); + } + + #[test] + fn test_require_current_admin_succeeds() { + let (env, _client, admin) = setup_with_admin(); + + let result = AdminStorage::require_current_admin(&env); + assert!(result.is_ok(), "require_current_admin must succeed"); + assert_eq!(result.unwrap(), admin, "Must return correct admin address"); + } + + #[test] + fn test_require_current_admin_fails_before_initialization() { + let (env, _client) = setup(); + + let result = AdminStorage::require_current_admin(&env); + assert_eq!( + result, + Err(QuickLendXError::OperationNotAllowed), + "require_current_admin must fail before initialization" + ); + } + + // ============================================================================ + // 5. Security Tests + // ============================================================================ + + #[test] + fn test_admin_operations_atomic() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + + // Verify atomicity by checking state before and after + assert!(AdminStorage::is_admin(&env, &admin1)); + assert!(!AdminStorage::is_admin(&env, &admin2)); + + client.transfer_admin(&admin1, &admin2); + + // State should be completely switched + assert!(!AdminStorage::is_admin(&env, &admin1)); + assert!(AdminStorage::is_admin(&env, &admin2)); + } + + #[test] + fn test_initialization_state_consistency() { + let (env, client) = setup(); + env.mock_all_auths(); + + // Before initialization + assert!(!AdminStorage::is_initialized(&env)); + assert_eq!(AdminStorage::get_admin(&env), None); + + let admin = Address::generate(&env); + client.initialize_admin(&admin); + + // After initialization + assert!(AdminStorage::is_initialized(&env)); + assert_eq!(AdminStorage::get_admin(&env), Some(admin)); + } + + // ============================================================================ + // 6. Utility Function Tests + // ============================================================================ + + #[test] + fn test_with_admin_auth_succeeds() { + let (env, _client, admin) = setup_with_admin(); + + let result = AdminStorage::with_admin_auth(&env, &admin, || Ok("success".to_string())); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + } + + #[test] + fn test_with_admin_auth_fails_for_non_admin() { + let (env, _client, _admin) = setup_with_admin(); + let non_admin = Address::generate(&env); + + let result = AdminStorage::with_admin_auth(&env, &non_admin, || Ok("should not execute")); + + assert_eq!(result, Err(QuickLendXError::NotAdmin)); + } + + #[test] + fn test_with_current_admin_succeeds() { + let (env, _client, admin) = setup_with_admin(); + + let result = AdminStorage::with_current_admin(&env, |current_admin| { + assert_eq!(current_admin, &admin); + Ok("success") + }); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + } + + #[test] + fn test_with_current_admin_fails_before_initialization() { + let (env, _client) = setup(); + + let result = AdminStorage::with_current_admin(&env, |_| Ok("should not execute")); + + assert_eq!(result, Err(QuickLendXError::OperationNotAllowed)); + } + + // ============================================================================ + // 7. Legacy Compatibility Tests + // ============================================================================ + + #[test] + fn test_set_admin_routes_to_initialize() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let result = client.try_set_admin(&admin); + + assert!(result.is_ok(), "set_admin must route to initialize"); + assert_eq!(client.get_current_admin(), Some(admin)); + assert!(AdminStorage::is_initialized(&env)); + } + + #[test] + fn test_set_admin_routes_to_transfer() { + let (env, client, admin1) = setup_with_admin(); + let admin2 = Address::generate(&env); + + let result = client.try_set_admin(&admin2); + + assert!(result.is_ok(), "set_admin must route to transfer"); + assert_eq!(client.get_current_admin(), Some(admin2)); + } + + // ============================================================================ + // 8. Edge Cases and Error Conditions + // ============================================================================ + + #[test] + fn test_multiple_rapid_transfers() { + let (env, client, mut current_admin) = setup_with_admin(); + + // Perform multiple transfers in sequence + for i in 0..5 { + let new_admin = Address::generate(&env); + client.transfer_admin(¤t_admin, &new_admin); + + assert_eq!( + client.get_current_admin(), + Some(new_admin.clone()), + "Transfer {} must succeed", + i + ); + current_admin = new_admin; + } + } + + #[test] + fn test_admin_state_after_failed_operations() { + let (env, client, admin) = setup_with_admin(); + let non_admin = Address::generate(&env); + + // Failed transfer should not change state + let _result = client.try_transfer_admin(&non_admin, &admin); + assert_eq!( + client.get_current_admin(), + Some(admin), + "Failed transfer must not change admin" + ); + + // Failed initialization should not change state + let _result = client.try_initialize_admin(&non_admin); + assert_eq!( + client.get_current_admin(), + Some(admin), + "Failed re-initialization must not change admin" + ); + } + + #[test] + fn test_event_emission_consistency() { + let (env, client) = setup(); + env.mock_all_auths(); + + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + // Initialize and transfer + client.initialize_admin(&admin1); + client.transfer_admin(&admin1, &admin2); + + let events = env.events().all(); + + // Should have initialization and transfer events + let init_events: Vec<_> = events + .iter() + .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_init"),)) + .collect(); + let transfer_events: Vec<_> = events + .iter() + .filter(|e| e.0 == (soroban_sdk::symbol_short!("adm_trf"),)) + .collect(); + + assert_eq!(init_events.len(), 1, "Must have one init event"); + assert_eq!(transfer_events.len(), 1, "Must have one transfer event"); + } + + // ============================================================================ + // 9. Integration Tests + // ============================================================================ + + #[test] + fn test_full_admin_lifecycle() { + let (env, client) = setup(); + env.mock_all_auths(); + + // 1. Initial state + assert!(!AdminStorage::is_initialized(&env)); + assert_eq!(AdminStorage::get_admin(&env), None); + + // 2. Initialize admin + let admin1 = Address::generate(&env); + client.initialize_admin(&admin1); + assert!(AdminStorage::is_initialized(&env)); + assert_eq!(AdminStorage::get_admin(&env), Some(admin1.clone())); + + // 3. Transfer admin + let admin2 = Address::generate(&env); + client.transfer_admin(&admin1, &admin2); + assert_eq!(AdminStorage::get_admin(&env), Some(admin2.clone())); + + // 4. Verify old admin cannot operate + let admin3 = Address::generate(&env); + let result = client.try_transfer_admin(&admin1, &admin3); + assert!(result.is_err()); + + // 5. Verify new admin can operate + client.transfer_admin(&admin2, &admin3); + assert_eq!(AdminStorage::get_admin(&env), Some(admin3)); + } +} diff --git a/quicklendx-contracts/src/test_escrow.rs b/quicklendx-contracts/src/test_escrow.rs index 5eebca9d..29cdc2e5 100644 --- a/quicklendx-contracts/src/test_escrow.rs +++ b/quicklendx-contracts/src/test_escrow.rs @@ -1109,3 +1109,245 @@ fn test_release_escrow_fails_if_refunded() { assert!(result.is_err()); assert_eq!(result.unwrap_err().unwrap(), QuickLendXError::InvalidStatus); } + +// ============================================================================ +// Token Transfer Failure Tests +// +// These tests document and verify the contract's behavior when the underlying +// Stellar token transfer fails or cannot proceed (zero balance, zero allowance, +// partial allowance). In every failure case: +// - No escrow record is written. +// - Invoice and bid states are left unchanged. +// - The correct error variant is returned. +// ============================================================================ + +/// Accepting a bid fails with `InsufficientFunds` when the investor has no token balance. +/// +/// # Security note +/// The balance check in `transfer_funds` runs before the token call, so the +/// token contract is never invoked and no partial state is written. +#[test] +fn test_accept_bid_fails_when_investor_has_zero_balance() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let business = setup_verified_business(&env, &client, &admin); + let investor = setup_verified_investor(&env, &client, 50_000); + + // Create a token but do NOT mint any balance for the investor. + let token_admin = Address::generate(&env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + // Mint only to business (so invoice upload works), not to investor. + sac_client.mint(&business, &100_000i128); + let expiration = env.ledger().sequence() + 10_000; + token_client.approve(&business, &contract_id, &100_000i128, &expiration); + // Investor approves but has zero balance. + token_client.approve(&investor, &contract_id, &50_000i128, &expiration); + + let amount = 10_000i128; + let invoice_id = create_verified_invoice(&env, &client, &business, amount, ¤cy); + let bid_id = place_test_bid(&client, &investor, &invoice_id, amount, amount + 1_000); + + let investor_balance_before = token_client.balance(&investor); + let contract_balance_before = token_client.balance(&contract_id); + + let result = client.try_accept_bid(&invoice_id, &bid_id); + + assert!(result.is_err(), "accept_bid must fail when investor has no balance"); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::InsufficientFunds, + "Expected InsufficientFunds error" + ); + + // No funds moved. + assert_eq!(token_client.balance(&investor), investor_balance_before); + assert_eq!(token_client.balance(&contract_id), contract_balance_before); + + // State unchanged. + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Verified); + assert_eq!(invoice.funded_amount, 0); + assert!(invoice.investor.is_none()); + + let bid = client.get_bid(&bid_id).unwrap(); + assert_eq!(bid.status, BidStatus::Placed); + + // No escrow record created. + assert!(client.try_get_escrow_details(&invoice_id).is_err()); +} + +/// Accepting a bid fails with `OperationNotAllowed` when the investor has not +/// approved the contract to spend the required amount. +/// +/// # Security note +/// The allowance check in `transfer_funds` runs before `transfer_from`, so the +/// token contract is never invoked and no partial state is written. +#[test] +fn test_accept_bid_fails_when_investor_has_zero_allowance() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let business = setup_verified_business(&env, &client, &admin); + let investor = setup_verified_investor(&env, &client, 50_000); + + // Mint balance to investor but grant NO allowance to the contract. + let token_admin = Address::generate(&env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + sac_client.mint(&business, &100_000i128); + sac_client.mint(&investor, &100_000i128); + + let expiration = env.ledger().sequence() + 10_000; + // Business approves so invoice upload works. + token_client.approve(&business, &contract_id, &100_000i128, &expiration); + // Investor deliberately grants zero allowance. + token_client.approve(&investor, &contract_id, &0i128, &expiration); + + let amount = 10_000i128; + let invoice_id = create_verified_invoice(&env, &client, &business, amount, ¤cy); + let bid_id = place_test_bid(&client, &investor, &invoice_id, amount, amount + 1_000); + + let investor_balance_before = token_client.balance(&investor); + let contract_balance_before = token_client.balance(&contract_id); + + let result = client.try_accept_bid(&invoice_id, &bid_id); + + assert!(result.is_err(), "accept_bid must fail with zero allowance"); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::OperationNotAllowed, + "Expected OperationNotAllowed error" + ); + + // No funds moved. + assert_eq!(token_client.balance(&investor), investor_balance_before); + assert_eq!(token_client.balance(&contract_id), contract_balance_before); + + // State unchanged. + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Verified); + assert_eq!(invoice.funded_amount, 0); + + let bid = client.get_bid(&bid_id).unwrap(); + assert_eq!(bid.status, BidStatus::Placed); + + assert!(client.try_get_escrow_details(&invoice_id).is_err()); +} + +/// Accepting a bid fails with `OperationNotAllowed` when the investor's allowance +/// is positive but less than the bid amount. +/// +/// # Security note +/// Partial allowance is rejected before the token call, preventing any transfer. +#[test] +fn test_accept_bid_fails_when_investor_has_partial_allowance() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let business = setup_verified_business(&env, &client, &admin); + let investor = setup_verified_investor(&env, &client, 50_000); + + let token_admin = Address::generate(&env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + let amount = 10_000i128; + sac_client.mint(&business, &100_000i128); + sac_client.mint(&investor, &100_000i128); + + let expiration = env.ledger().sequence() + 10_000; + token_client.approve(&business, &contract_id, &100_000i128, &expiration); + // Investor approves only half the required amount. + token_client.approve(&investor, &contract_id, &(amount / 2), &expiration); + + let invoice_id = create_verified_invoice(&env, &client, &business, amount, ¤cy); + let bid_id = place_test_bid(&client, &investor, &invoice_id, amount, amount + 1_000); + + let investor_balance_before = token_client.balance(&investor); + let contract_balance_before = token_client.balance(&contract_id); + + let result = client.try_accept_bid(&invoice_id, &bid_id); + + assert!(result.is_err(), "accept_bid must fail with partial allowance"); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::OperationNotAllowed, + "Expected OperationNotAllowed for partial allowance" + ); + + // No funds moved. + assert_eq!(token_client.balance(&investor), investor_balance_before); + assert_eq!(token_client.balance(&contract_id), contract_balance_before); + + // State unchanged. + assert_eq!(client.get_invoice(&invoice_id).status, InvoiceStatus::Verified); + assert_eq!(client.get_bid(&bid_id).unwrap().status, BidStatus::Placed); + assert!(client.try_get_escrow_details(&invoice_id).is_err()); +} + +/// After a failed `accept_bid` (due to insufficient funds), the bid can be +/// retried once the investor tops up their balance and allowance. +/// +/// This verifies that no permanent state corruption occurs on failure. +#[test] +fn test_accept_bid_succeeds_after_topping_up_balance() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let business = setup_verified_business(&env, &client, &admin); + let investor = setup_verified_investor(&env, &client, 50_000); + + let token_admin = Address::generate(&env); + let currency = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + let amount = 10_000i128; + sac_client.mint(&business, &100_000i128); + // Investor starts with insufficient balance. + sac_client.mint(&investor, &(amount - 1)); + + let expiration = env.ledger().sequence() + 10_000; + token_client.approve(&business, &contract_id, &100_000i128, &expiration); + token_client.approve(&investor, &contract_id, &100_000i128, &expiration); + + let invoice_id = create_verified_invoice(&env, &client, &business, amount, ¤cy); + let bid_id = place_test_bid(&client, &investor, &invoice_id, amount, amount + 1_000); + + // First attempt fails. + let result = client.try_accept_bid(&invoice_id, &bid_id); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::InsufficientFunds + ); + + // Top up investor balance. + sac_client.mint(&investor, &1i128); + + // Second attempt succeeds. + let result = client.try_accept_bid(&invoice_id, &bid_id); + assert!(result.is_ok(), "accept_bid should succeed after top-up"); + + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Funded); + assert_eq!(invoice.funded_amount, amount); + + let escrow = client.get_escrow_details(&invoice_id); + assert_eq!(escrow.status, EscrowStatus::Held); + assert_eq!(escrow.amount, amount); +} diff --git a/quicklendx-contracts/src/test_init.rs b/quicklendx-contracts/src/test_init.rs index d7fa841d..6d214fde 100644 --- a/quicklendx-contracts/src/test_init.rs +++ b/quicklendx-contracts/src/test_init.rs @@ -464,9 +464,9 @@ mod test_init { let params = create_valid_params(&env); // Should panic without authorization - let result = std::panic::catch_unwind(|| { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { client.initialize(¶ms); - }); + })); assert!(result.is_err(), "Initialization without auth must fail"); } @@ -498,7 +498,7 @@ mod test_init { let (env, client, _params) = setup_initialized(); let non_admin = Address::generate(&env); - let result = client.try_set_protocol_config(&non_admin, 1_000_000, 365, 604800); + let result = client.try_set_protocol_config(&non_admin, &1_000_000i128, &365u64, &604800u64); assert_eq!( result, Err(Ok(QuickLendXError::NotAdmin)), @@ -511,7 +511,7 @@ mod test_init { let (env, client, params) = setup_initialized(); // Test invalid min amount - let result = client.try_set_protocol_config(¶ms.admin, 0, 365, 604800); + let result = client.try_set_protocol_config(¶ms.admin, &0i128, &365u64, &604800u64); assert_eq!( result, Err(Ok(QuickLendXError::InvalidAmount)), @@ -519,7 +519,7 @@ mod test_init { ); // Test invalid max days - let result = client.try_set_protocol_config(¶ms.admin, 1_000_000, 0, 604800); + let result = client.try_set_protocol_config(¶ms.admin, &1_000_000i128, &0u64, &604800u64); assert_eq!( result, Err(Ok(QuickLendXError::InvoiceDueDateInvalid)), @@ -527,7 +527,7 @@ mod test_init { ); // Test invalid grace period - let result = client.try_set_protocol_config(¶ms.admin, 1_000_000, 365, 3_000_000); + let result = client.try_set_protocol_config(¶ms.admin, &1_000_000i128, &365u64, &3_000_000u64); assert_eq!( result, Err(Ok(QuickLendXError::InvalidTimestamp)), @@ -539,7 +539,7 @@ mod test_init { fn test_set_protocol_config_emits_event() { let (env, client, params) = setup_initialized(); - client.set_protocol_config(¶ms.admin, 2_000_000, 180, 86400); + client.set_protocol_config(¶ms.admin, &2_000_000i128, &180u64, &86400u64); let events = env.events().all(); let config_events: Vec<_> = events @@ -554,7 +554,7 @@ mod test_init { fn test_set_fee_config_succeeds() { let (env, client, params) = setup_initialized(); - let result = client.try_set_fee_config(¶ms.admin, 300); // 3% + let result = client.try_set_fee_config(¶ms.admin, &300u32); // 3% assert!(result.is_ok(), "Fee config update must succeed"); assert_eq!(client.get_fee_bps(), 300, "Fee must be updated"); @@ -564,7 +564,7 @@ mod test_init { fn test_set_fee_config_validates_fee() { let (env, client, params) = setup_initialized(); - let result = client.try_set_fee_config(¶ms.admin, 1001); // > 10% + let result = client.try_set_fee_config(¶ms.admin, &1001u32); // > 10% assert_eq!( result, Err(Ok(QuickLendXError::InvalidFeeBasisPoints)), @@ -576,7 +576,7 @@ mod test_init { fn test_set_fee_config_zero_allowed() { let (env, client, params) = setup_initialized(); - let result = client.try_set_fee_config(¶ms.admin, 0); + let result = client.try_set_fee_config(¶ms.admin, &0u32); assert!(result.is_ok(), "Zero fee must be allowed"); assert_eq!(client.get_fee_bps(), 0); } @@ -763,7 +763,7 @@ mod test_init { assert_eq!(client.get_current_admin(), Some(params.admin.clone())); // 4. Update configurations - client.set_protocol_config(¶ms.admin, 2_000_000, 180, 86400); + client.set_protocol_config(¶ms.admin, &2_000_000i128, &180u64, &86400u64); client.set_fee_config(¶ms.admin, 300); let new_treasury = Address::generate(&env); @@ -814,7 +814,7 @@ mod test_init { client.initialize(¶ms); // Update configs - client.set_protocol_config(¶ms.admin, 2_000_000, 180, 86400); + client.set_protocol_config(¶ms.admin, &2_000_000i128, &180u64, &86400u64); client.set_fee_config(¶ms.admin, 300); let new_treasury = Address::generate(&env); diff --git a/quicklendx-contracts/src/test_refund.rs b/quicklendx-contracts/src/test_refund.rs index 5701010b..54e31a66 100644 --- a/quicklendx-contracts/src/test_refund.rs +++ b/quicklendx-contracts/src/test_refund.rs @@ -136,7 +136,7 @@ fn test_business_can_trigger_refund() { }); let bid = client.get_bids_for_invoice(&invoice_id).get(0).unwrap(); - assert_eq!(bid.status, BidStatus::Cancelled); + assert_eq!(bid.status, crate::bid::BidStatus::Cancelled); } #[test] @@ -290,7 +290,7 @@ fn test_refund_updates_internal_states_correctly() { // 3. Bid status should update to Cancelled let bids = client.get_bids_for_invoice(&invoice_id); assert_eq!(bids.len(), 1); - assert_eq!(bids.get(0).unwrap().status, BidStatus::Cancelled); + assert_eq!(bids.get(0).unwrap().status, crate::bid::BidStatus::Cancelled); // 4. Investment status should update to Refunded env.as_contract(&client.address, || { @@ -300,3 +300,130 @@ fn test_refund_updates_internal_states_correctly() { assert_eq!(investment.status, InvestmentStatus::Refunded); }); } + +// ============================================================================ +// Token Transfer Failure Tests – Refund Path +// +// These tests document and verify the contract's behavior when the underlying +// Stellar token transfer fails during a refund. In every failure case: +// - The escrow status remains `Held` (retryable). +// - Invoice, bid, and investment states are left unchanged. +// - The correct error variant is returned. +// ============================================================================ + +/// `refund_escrow_funds` fails with `InsufficientFunds` when the contract's +/// token balance has been drained externally (invariant violation scenario). +/// +/// # Security note +/// The balance check in `transfer_funds` runs before the token call, so the +/// escrow status is never updated to `Refunded` and the operation is retryable. +#[test] +fn test_refund_fails_when_contract_has_insufficient_balance() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let (invoice_id, business, investor, amount, currency) = + create_funded_invoice(&env, &client, &admin); + + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + // Drain the contract's balance to simulate an invariant violation. + // We do this by burning the contract's tokens directly via the SAC admin. + let contract_balance = token_client.balance(&contract_id); + // Burn all contract tokens (SAC burn requires the holder to auth; use mock_all_auths). + sac_client.burn(&contract_id, &contract_balance); + + assert_eq!( + token_client.balance(&contract_id), + 0, + "Contract balance should be zero after burn" + ); + + let investor_balance_before = token_client.balance(&investor); + + // Refund should fail because the contract has no balance to return. + let result = client.try_refund_escrow_funds(&invoice_id, &business); + assert!( + result.is_err(), + "refund_escrow_funds must fail when contract has no balance" + ); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::InsufficientFunds, + "Expected InsufficientFunds error" + ); + + // No funds moved to investor. + assert_eq!( + token_client.balance(&investor), + investor_balance_before, + "Investor balance must not change on failed refund" + ); + + // Escrow status must remain Held (retryable). + let escrow = client.get_escrow_details(&invoice_id); + assert_eq!( + escrow.status, + EscrowStatus::Held, + "Escrow must remain Held after failed refund" + ); + + // Invoice must remain Funded. + let invoice = client.get_invoice(&invoice_id); + assert_eq!( + invoice.status, + InvoiceStatus::Funded, + "Invoice must remain Funded after failed refund" + ); +} + +/// After a failed refund (due to drained contract balance), the refund succeeds +/// once the contract balance is restored. +/// +/// This verifies that the escrow `Held` state is truly retryable. +#[test] +fn test_refund_succeeds_after_balance_restored() { + let (env, client, admin) = setup(); + let contract_id = client.address.clone(); + + let (invoice_id, business, investor, amount, currency) = + create_funded_invoice(&env, &client, &admin); + + let token_client = token::Client::new(&env, ¤cy); + let sac_client = token::StellarAssetClient::new(&env, ¤cy); + + // Drain contract balance. + let contract_balance = token_client.balance(&contract_id); + sac_client.burn(&contract_id, &contract_balance); + + // First refund attempt fails. + let result = client.try_refund_escrow_funds(&invoice_id, &business); + assert_eq!( + result.unwrap_err().unwrap(), + QuickLendXError::InsufficientFunds + ); + + // Restore contract balance by minting directly to the contract address. + sac_client.mint(&contract_id, &amount); + + let investor_balance_before = token_client.balance(&investor); + + // Second refund attempt succeeds. + let result = client.try_refund_escrow_funds(&invoice_id, &business); + assert!(result.is_ok(), "refund should succeed after balance restored"); + + // Investor received funds. + assert_eq!( + token_client.balance(&investor), + investor_balance_before + amount + ); + + // Escrow is now Refunded. + let escrow = client.get_escrow_details(&invoice_id); + assert_eq!(escrow.status, EscrowStatus::Refunded); + + // Invoice is now Refunded. + let invoice = client.get_invoice(&invoice_id); + assert_eq!(invoice.status, InvoiceStatus::Refunded); +} diff --git a/quicklendx-contracts/src/types.rs b/quicklendx-contracts/src/types.rs index 7dd525ed..a9e47735 100644 --- a/quicklendx-contracts/src/types.rs +++ b/quicklendx-contracts/src/types.rs @@ -31,6 +31,7 @@ pub enum BidStatus { Withdrawn, Accepted, Expired, + Cancelled, } /// Investment status enumeration diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index ca496fc6..287df101 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -631,6 +631,7 @@ pub fn normalize_tag(env: &Env, tag: &String) -> Result let lower = if b >= b'A' && b <= b'Z' { b + 32 } else { b }; normalized_bytes[idx] = lower; } + let normalized_slice = &normalized_bytes[..raw_slice.len()]; let normalized_str = String::from_str( env, diff --git a/quicklendx-contracts/tests/wasm_build_size_budget.rs b/quicklendx-contracts/tests/wasm_build_size_budget.rs index d28af004..52524003 100644 --- a/quicklendx-contracts/tests/wasm_build_size_budget.rs +++ b/quicklendx-contracts/tests/wasm_build_size_budget.rs @@ -73,7 +73,7 @@ const WASM_SIZE_WARNING_BYTES: u64 = (WASM_SIZE_BUDGET_BYTES as f64 * 0.90) as u /// Keep this up-to-date so the regression window stays tight. When a PR /// legitimately increases the contract size, the author must update this /// constant and `scripts/wasm-size-baseline.toml` in the same commit. -const WASM_SIZE_BASELINE_BYTES: u64 = 217_668; +const WASM_SIZE_BASELINE_BYTES: u64 = 241_218; /// Maximum fractional growth allowed relative to `WASM_SIZE_BASELINE_BYTES` /// before the regression test fails (5 %).