diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs deleted file mode 100644 index 8fec793..0000000 --- a/.git-blame-ignore-revs +++ /dev/null @@ -1,3 +0,0 @@ -# .git-blame-ignore-revs -# format code. -6501efe88bdae828ff7c6149b6dc368b9656f72c diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d05bfa..0a26f0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,6 @@ jobs: uses: stellar-expert/soroban-build-workflow/.github/workflows/release.yml@main with: release_name: ${{ github.ref_name }} - release_description: 'Release of the contract' + release_description: 'Contract release' secrets: release_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ae993aa..6ad9459 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ +/.idea /.soroban -/target -/price-oracle/test_snapshots -/test_snapshots \ No newline at end of file +target +test_snapshots diff --git a/Cargo.lock b/Cargo.lock index 3f21a5d..fef722f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,22 +1,19 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] -name = "addr2line" -version = "0.21.0" +name = "ahash" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ - "gimli", + "cfg-if", + "once_cell", + "version_check", + "zerocopy", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -42,49 +39,151 @@ dependencies = [ ] [[package]] -name = "autocfg" -version = "1.1.0" +name = "ark-bls12-381" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] [[package]] -name = "backtrace" -version = "0.3.69" +name = "ark-bn254" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", + "ark-ec", + "ark-ff", + "ark-std", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "ark-ec" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] [[package]] -name = "base32" +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] [[package]] -name = "base64" -version = "0.13.1" +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base16ct" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -107,6 +206,12 @@ version = "3.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea184aa71bb362a1157c896979544cc23974e08fd265f29ea96b59f0b4a555b" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes-lit" version = "0.0.5" @@ -116,7 +221,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -131,6 +236,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "chrono" version = "0.4.34" @@ -200,26 +316,31 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.7" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad291aa74992b9b7a7e88c38acbbf6ad7e107f1d90ee8775b7bc1fc3394f485c" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" dependencies = [ - "quote", - "syn", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "curve25519-dalek" -version = "4.1.2" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a677b8922c94e01bdbb12126b0bc852f00447528dee1782229af9c720c3f348" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", "fiat-crypto", - "platforms", "rustc_version", "subtle", "zeroize", @@ -233,44 +354,85 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "darling" -version = "0.20.8" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ - "darling_core", - "darling_macro", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.101", ] [[package]] name = "darling_core" -version = "0.20.8" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.101", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.101", ] [[package]] name = "darling_macro" -version = "0.20.8" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", - "syn", + "syn 2.0.101", ] +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + [[package]] name = "der" version = "0.7.8" @@ -283,12 +445,23 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -299,7 +472,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -320,6 +493,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +[[package]] +name = "dtor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecdsa" version = "0.16.9" @@ -331,7 +525,6 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", - "spki", ] [[package]] @@ -346,15 +539,16 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" dependencies = [ "curve25519-dalek", "ed25519", "rand_core", "serde", "sha2", + "subtle", "zeroize", ] @@ -376,7 +570,6 @@ dependencies = [ "ff", "generic-array", "group", - "pkcs8", "rand_core", "sec1", "subtle", @@ -447,12 +640,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" - [[package]] name = "group" version = "0.13.0" @@ -464,18 +651,52 @@ dependencies = [ "subtle", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -559,9 +780,9 @@ checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" [[package]] name = "itertools" -version = "0.11.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] @@ -583,16 +804,14 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", - "once_cell", "sha2", - "signature", ] [[package]] @@ -623,19 +842,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] -name = "memchr" -version = "2.7.1" +name = "macro-string" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] [[package]] -name = "miniz_oxide" -version = "0.7.2" +name = "memchr" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" -dependencies = [ - "adler", -] +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "num-bigint" @@ -662,7 +883,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -685,19 +906,30 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.32.2" +name = "once_cell" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "oracle" +version = "6.0.0" dependencies = [ - "memchr", + "soroban-sdk", + "test-case", ] [[package]] -name = "once_cell" -version = "1.19.0" +name = "p256" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] [[package]] name = "paste" @@ -715,12 +947,6 @@ dependencies = [ "spki", ] -[[package]] -name = "platforms" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" - [[package]] name = "powerfmt" version = "0.2.0" @@ -740,23 +966,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.101", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -792,10 +1027,41 @@ dependencies = [ ] [[package]] -name = "reflector-oracle" -version = "5.0.0" +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "reflector-beam-contract" +version = "6.0.0" +dependencies = [ + "oracle", + "soroban-sdk", + "test-case", +] + +[[package]] +name = "reflector-pulse-contract" +version = "6.0.0" dependencies = [ + "oracle", "soroban-sdk", + "test-case", ] [[package]] @@ -808,17 +1074,11 @@ dependencies = [ "subtle", ] -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] @@ -829,6 +1089,41 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "sec1" version = "0.7.3" @@ -838,7 +1133,6 @@ dependencies = [ "base16ct", "der", "generic-array", - "pkcs8", "subtle", "zeroize", ] @@ -851,48 +1145,62 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.192" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" +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 = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.192" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", + "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_with" -version = "3.6.1" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d167997bd841ec232f5b2b8e0e26606df2e7caa4c31b95ea9ca52b200bd270" +checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" dependencies = [ - "base64 0.21.7", + "base64", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.2.3", - "serde", - "serde_derive", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.0.4", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -900,21 +1208,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.6.1" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865f9743393e638991566a8b7a479043c2c8da94a33e0a31f18214c9cae0a64d" +checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -949,21 +1257,21 @@ checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "soroban-builtin-sdk-macros" -version = "20.2.2" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ef302d2118a14267e441e50e33705adc4f0da56616e7d2d9f198448d5714b2" +checksum = "7192e3a5551a7aeee90d2110b11b615798e81951fd8c8293c87ea7f88b0168f5" dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-env-common" -version = "20.2.2" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc40ac91f70bb93aed7dff6057caac8810d49a8c451f44286e1e49243c799beb" +checksum = "bfc49a80a68fc1005847308e63b9fce39874de731940b1807b721d472de3ff01" dependencies = [ "arbitrary", "crate-git-revision", @@ -975,13 +1283,14 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", + "wasmparser", ] [[package]] name = "soroban-env-guest" -version = "20.2.2" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949587b3608cb05fe1d5eecce24aed1c33063c38fa79402f2e5b1c2a29466350" +checksum = "ea2334ba1cfe0a170ab744d96db0b4ca86934de9ff68187ceebc09dc342def55" dependencies = [ "soroban-env-common", "static_assertions", @@ -989,13 +1298,20 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "20.2.2" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faaa4e738232cacae7deb7947adfd4718e47cd2b50676e9518a8a38ee00930c9" +checksum = "43af5d53c57bc2f546e122adc0b1cca6f93942c718977379aa19ddd04f06fcec" dependencies = [ - "backtrace", + "ark-bls12-381", + "ark-bn254", + "ark-ec", + "ark-ff", + "ark-serialize", "curve25519-dalek", + "ecdsa", "ed25519-dalek", + "elliptic-curve", + "generic-array", "getrandom", "hex-literal", "hmac", @@ -1003,22 +1319,25 @@ dependencies = [ "num-derive", "num-integer", "num-traits", + "p256", "rand", "rand_chacha", + "sec1", "sha2", "sha3", "soroban-builtin-sdk-macros", "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey", + "stellar-strkey 0.0.13", + "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "20.2.2" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff09cd5f1e4968e6dbac40eb4fbb2bdbb478fa989a96088fe0466d09e8ff40c6" +checksum = "a989167512e3592d455b1e204d703cfe578a36672a77ed2f9e6f7e1bbfd9cc5c" dependencies = [ "itertools", "proc-macro2", @@ -1026,14 +1345,14 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-ledger-snapshot" -version = "20.3.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a7b822725a73a90ef650bc1f325d13c8bae7a808156c101953092327e2edee" +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" dependencies = [ "serde", "serde_json", @@ -1045,51 +1364,56 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "20.3.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdff4b5fc50f554499b81aa6ecbb4045beb84742ecda9777ebbdc90c0d93ec62" +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" dependencies = [ "arbitrary", "bytes-lit", + "crate-git-revision", "ctor", + "derive_arbitrary", "ed25519-dalek", "rand", + "rustc_version", "serde", "serde_json", "soroban-env-guest", "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey", + "stellar-strkey 0.0.16", + "visibility", ] [[package]] name = "soroban-sdk-macros" -version = "20.3.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d147c3ce37842919893946a4467632aa012f567a7ab2286abe19e5ecc25e05" +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" dependencies = [ - "crate-git-revision", - "darling", + "darling 0.20.11", + "heck", "itertools", + "macro-string", "proc-macro2", "quote", - "rustc_version", "sha2", "soroban-env-common", "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-spec" -version = "20.3.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7a132b7c234edf6ef3add4ffb17807f3b25a4ce5ab944ebbaf4d2326470eb1" +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" dependencies = [ - "base64 0.13.1", + "base64", + "sha2", "stellar-xdr", "thiserror", "wasmparser", @@ -1097,9 +1421,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "20.3.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8d396f3b29800138e8abf2562aba0b579d09d8c2d2b956379fc9e68914a6e62" +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" dependencies = [ "prettyplease", "proc-macro2", @@ -1107,7 +1431,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn", + "syn 2.0.101", "thiserror", ] @@ -1140,6 +1464,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1148,36 +1478,49 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stellar-strkey" -version = "0.0.8" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" dependencies = [ - "base32", "crate-git-revision", - "thiserror", + "data-encoding", +] + +[[package]] +name = "stellar-strkey" +version = "0.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" +dependencies = [ + "crate-git-revision", + "data-encoding", + "heapless", ] [[package]] name = "stellar-xdr" -version = "20.1.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59cdf3eb4467fb5a4b00b52e7de6dca72f67fac6f9b700f55c95a5d86f09c9d" +checksum = "10d20dafed80076b227d4b17c0c508a4bbc4d5e4c3d4c1de7cd42242df4b1eaf" dependencies = [ "arbitrary", - "base64 0.13.1", + "base64", + "cfg_eval", "crate-git-revision", "escape-bytes", + "ethnum", "hex", "serde", "serde_with", - "stellar-strkey", + "sha2", + "stellar-strkey 0.0.13", ] [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" @@ -1187,15 +1530,59 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" -version = "2.0.39" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "test-case-core", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1213,14 +1600,14 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "time" -version = "0.3.34" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -1233,15 +1620,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.17" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -1265,6 +1652,17 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1292,7 +1690,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-shared", ] @@ -1314,7 +1712,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1345,11 +1743,12 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.88.0" +version = "0.116.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8cf7dd82407fe68161bedcd57fde15596f32ebf6e9b3bdbf3ae1da20e38e5e" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" dependencies = [ - "indexmap 1.9.3", + "indexmap 2.2.3", + "semver", ] [[package]] @@ -1427,8 +1826,42 @@ version = "0.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0770833d60a970638e989b3fa9fd2bb1aaadcf88963d1659fd7d9990196ed2d6" +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] diff --git a/Cargo.toml b/Cargo.toml index c066558..b75a864 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ -[package] -name = "reflector-oracle" -version = "4.1.0" -edition = "2021" +[workspace] +resolver = "2" -[lib] -crate-type = ["cdylib"] +members = ["oracle", "pulse-contract", "beam-contract"] + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true [profile.release] opt-level = "z" @@ -16,15 +17,5 @@ panic = "abort" codegen-units = 1 lto = true -[dependencies] -soroban-sdk = "20.3.2" - -[dev_dependencies] -soroban-sdk = { version = "20.3.2", features = ["testutils"] } - -[features] -testutils = ["soroban-sdk/testutils"] - -[profile.release-with-logs] -inherits = "release" -debug-assertions = true +[workspace.dependencies.soroban-sdk] +version = "25.3.0" diff --git a/README.md b/README.md index 218e23f..4de24f2 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,390 @@ -# Reflector oracle smart contract +# Reflector Oracle -This contract implementation is fully compatible with +Reflector oracle protocol is a combination of specialized smart contracts and peer-to-peer consensus of data provider +nodes maintained by trusted Stellar ecosystem organizations that serve as intermediaries between Stellar smart contracts +and external price feed data sources. + +## How It Works + +Oracle contracts are controlled by the multisig-protected consensus of reputable organizations. The smart contract admin +account always has all node public keys as co-signers with >50% multisig threshold, so more than half of the oracle +backing nodes have to agree on a transaction in order to store price feed data or modify the contract state. + +Each node independently calculates values of quoted prices using deterministic idempotent algorithms to ensure +consistency, generates an update transaction, signs it with a node's private key, and then shares it with other peers via +WebSocket protocol. If for some reason (ledger access delay, failing connection, version incompatibility, adversary +attack) any given node quotes a token/asset price different from other nodes, the transaction hash will not match the +hash generated by the majority and such transaction will be discarded by the ledger. This way Reflector utilizes Stellar +protocol underlying security to implement an uncomplicated yet robust consensus, which guarantees reliability, fault +tolerance, and regular price feed updates. + +For on-chain Stellar assets price feed data retrieval Reflector relies on a quorum of nodes connected to Stellar RPC +nodes. Each node independently fetches trades and state information from the Stellar ledger. Price feeds for generic +tokens get updated in a similar fashion, but nodes have to agree on the data pulled from external sources (CEX/DEX API, +banks, forex venues, price aggregators, stock exchanges, derivative platforms, etc.) All historical price feed data is +stored in the oracle contract and becomes immutable once it is written to the contract storage. + +Reflector offers two data access models for Stellar on-chain oracles: + +- **ReflectorPulse** oracles with a uniform 5-minute update interval provide free access to published price feeds +- **ReflectorBeam** oracles allow flexible oracle provisioning and feature faster price updates in return for a small + XRF invocation fee + +Both contract implementations are compatible with the [SEP-40](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0040.md) ecosystem standard. Check the standard for general info and public consumer interface documentation. -## Usage example +Cluster nodes report price feeds for all assets denominated in the `base` asset of the contract using the uniform +precision specified in `decimals()`. Prices get encoded as `i128` numbers where last N digits designate the fractional +part of the given oracle feed. So the actual price can be calculated as `price/10^decimals`. + +An oracle feed receives regular updates with a pre-defined resolution. Timestamps from trades and other price sources +are normalized as `floor(unix_now()/resolution)*resolution` during the aggregation phase. It is important for consumer +contracts to check the `timestamp` field of the returned values against the current ledger timestamp to make sure that +reported quotes are not stale. + +Other contracts interact with oracle contracts, retrieving data stored earlier by Reflector consensus. +Consumers can fetch historical ranges or simply pull the most recent token price depending on the use-case. + +The following diagram outlines a general oracle data flow: + +![Reflector flow diagram](https://reflector.network/images/reflector-diagram.svg "Reflector flow diagram") + +## DAO + +Reflector oracles network is governed by the decentralized autonomous organization ("DAO") consisting of organizations +and individuals that maintain Reflector server nodes, participating in the cluster consensus. XRF token is a utility +cryptocurrency token issued by the DAO. + +Cluster operators receive tokens for participating in the consensus mechanism and providing their computational +resources to aggregate, validate, certify, and publish token price information. Correspondingly, accrued tokens +represent the equivalent of computational resources contributed by each party. These tokens can be used for Reflector +cluster governance voting, subscription services. + +Each of DAO members has equal voting power, and Reflector governance decisions are enacted by a simple majority of +votes (a ballot requirement of more than half of all members). A member of the DAO can be expelled from the organization +and the Reflector cluster only by the DAO decision. Inclusion of new members (only those who run a Reflector node and +participate in the consensus are eligible) follows the same rule. + +Reflector DAO employs XRF tokens in key governance areas and as a fee token for paid services. XRF tokens spent in the +process are permanently taken out of circulation without the possibility to recover them in the future ("burned"), +representing spent computational resources equivalent. + +## Usage + +### ReflectorPulse Contract -### Forced position liquidation +Follow this example to invoke oracles functions from your contract code. ```rust -pub fn check_liquidation(env: Env, reflector_contract_id: Address, loan: Loan, liquidation_threshold: i128) { - // loan position example - // { - // collateral_asset: Asset::Other(Symbol::new(&env, "BTC")), - // collateral_amount: 10753533963_i128, - // borrowed_asset: Asset::Other(Symbol::new(&env, "ETH")), - // borrowed_amount: 154850889072_i128 - // } - - // create the price oracle client instance - let reflector_contract = PriceOracleClient::new(&env, &reflector_contract_id); - - // get oracle prcie precision - let decimals = reflector_contract.decimals(); - - // get the price and calculate the value of the collateral - let collateral_asset_price = reflector_contract.lastprice(&loan.collateral_asset).unwrap(); - let collateral_value = collateral_asset_.price * loan.collateral_amount; - - // get the price and calculate the value of the borrowed asset - let asset_price = reflector_contract.lastprice(&loan.borrowed_asset).unwrap(); - let borrowed_value = asset_price.price * loan.borrowed_amount; - - // calculate the current loan to value ratio, SAC contracts - let collateralization_ratio = collateral_value * 10000000_i128 / borrowed_value; - - if collateralization_ratio <= liquidation_threshold { - // collateralization ratio is too small – liquidate the loan +/* contract.rs */ +use crate::reflector::{ReflectorPulseClient, Asset as ReflectorAsset}; // Import Reflector interface +use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol}; + +#[contract] +pub struct MyAwesomeContract; // Of course, it's awesome, we know it! + +#[contractimpl] +impl MyAwesomeContract { + pub fn lets_rock(e: Env) { + // Oracle contract address to use + let oracle_address = Address::from_str(&e, "CAFJZQWSED6YAWZU3GWRTOCNPPCGBN32L7QV43XX5LZLFTK6JLN34DLN"); + // Create client for working with oracle + let reflector_client = ReflectorPulseClient::new(&e, &oracle_address); + // Ticker to lookup the price + let ticker = ReflectorAsset::Other(Symbol::new(&e, &("BTC"))); + // Fetch the most recent price record for it + let recent = reflector_client.lastprice(&ticker); + // Check the result + if recent.is_none() { + //panic_with_error!(&e, "price not available"); + } + // Retrieve the price itself + let price = recent.unwrap().price; + // Do not forget for price precision, get decimals from the oracle + // (this value can be also hardcoded once the price feed has been + // selected because decimals never change in live oracles) + let price_decimals = reflector_client.decimals(); + + // Let's check how much of quoted asset we can potentially purchase for $10 + let usd_balance = 10_0000000i128; // $10 with standard Stellar token precision + let can_purchase = (usd_balance * 10i128.pow(price_decimals)) / price; + + // How many USD we'll need to buy 5 quoted asset tokens? + let want_purchase = 5_0000000i128; // 5 tokens with standard Stellar token precision + let need_usd = (want_purchase * price) / 10i128.pow(price_decimals); + + // Please note: check for potential overflows or use safe math when dealing with prices } } ``` -### Portfolio rebalancing +#### Pulse contract interface + +Copy and save it in your smart contract project as `reflector_pulse.rs` file. +This is the oracle client interface definition. ```rust -pub fn rebalance_portfolio(env: Env, reflector_contract_id: Address, portfolio: Vec) { - // portfolio example - // [{ - // asset: Asset::Stellar(Address::from_str(&env, "CD8H6KNN9...")), - // amount: 45675353821010_i128, - // }, - // { - // asset: Asset::Stellar(Symbol::new(&env, "BTC")), - // amount: 10753533963_i128, - // }] - - // create the price oracle client instance - let reflector_contract = PriceOracleClient::new(&env, &reflector_contract_id); - - // storage for portfolio position values - let mut values: [i128; 3] = [0; 3]; - // calculate total value of the portfolio - let mut total_value = 0_i128; - let total_positions = portfolio.len() - for i in 0..total_positions { - let position = &portfolio[i]; - // get price of an asset - let asset_price = reflector_contract.lastprice(&position.asset).unwrap(); - // calculate position USD value - let asset_value = asset_price.price * position.amount; - total_value += asset_value; - values[i] = asset_value; - } +/* reflector_pulse.rs */ +use soroban_sdk::{contracttype, Address, Symbol, Vec}; - // calculate average value per position - let average_position_value = total_value / (total_positions as i128); +// Oracle contract interface exported as ReflectorPulseClient +#[soroban_sdk::contractclient(name = "ReflectorPulseClient")] +pub trait Contract { + // Base oracle symbol the price is reported in + fn base() -> Asset; + // All assets quoted by the contract + fn assets() -> Vec; + // Number of decimal places used to represent price for all assets quoted by the oracle + fn decimals() -> u32; + // Quotes asset price in base asset at specific timestamp + fn price(asset: Asset, timestamp: u64) -> Option; + // Quotes the most recent price for an asset + fn lastprice(asset: Asset) -> Option; + // Quotes last N price records for the given asset + fn prices(asset: Asset, records: u32) -> Option>; + // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) + fn resolution() -> u32; + // Historical records retention period, in seconds (24 hours by default) + fn history_retention_period() -> Option; + // The most recent price update timestamp + fn last_timestamp() -> u64; + // Contract version + fn version() -> u32; + // Contract admin address + fn admin() -> Option
; + // Extend asset TTL (time-to-live) in the contract storage + fn extend_asset_ttl(sponsor: Address, asset: Asset); + // Get asset expiration timestamp + fn expires(asset: Asset) -> Option; +} - // calculate the difference between the target value and the actual value for each position - for i in 0..total_positions { - let value: i128 = values[i]; - if value > average_position_value { - // sell some tokens to decrease share in the portfolio - } else if value < average_position_value { - // buy tokens to increase position size - } - } +// Quoted asset definition +#[contracttype(export = false)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum Asset { + Stellar(Address), // for Stellar Classic and Soroban assets + Other(Symbol) // for any external currencies/tokens/assets/symbols +} + +// Price record definition +#[contracttype(export = false)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct PriceData { + pub price: i128, // asset price at given point in time + pub timestamp: u64 // record timestamp +} + +// Possible runtime errors +#[soroban_sdk::contracterror(export = false)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum Error { + AlreadyInitialized = 0, + Unauthorized = 1, + AssetMissing = 2, + AssetAlreadyExists = 3, + InvalidConfigVersion = 4, + InvalidTimestamp = 5, + InvalidUpdateLength = 6, + AssetLimitExceeded = 7, + InvalidPricesUpdate = 8 } ``` +### ReflectorBeam contract -### Algorithmic stablecoin price correction +Follow this example to invoke oracle functions from your contract code. ```rust -pub fn maintain_stable_coin_peg(env: Env, reflector_contract_id: Address, current_price: i128) { - // create oracle client instance - let reflector_contract = PriceOracleClient::new(&env, &reflector_contract_id); - - // fetch TWAP-approximated external price for the associated reference ticker - let coin = Asset::Other(Symbol::new(&env, "CHF")); - let reference_price = reflector_contract.twap(&coin, &5).unwrap(); - - // take action if the price diverts more than 0.1% from the reference price - let threshold = reference_price / 1000_i128; - if current_price > reference_price + threshold { - // mint and sell coin - } - if current_price < reference_price - threshold { - // buy and burn coin +/* contract.rs */ +use crate::reflector::{ReflectorBeamClient, Asset as ReflectorAsset}; // Import Reflector Beam interface +use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol, symbol_short, auth::{ContractContext, InvokerContractAuthEntry, SubContractInvocation}}; + +#[contract] +pub struct MyAwesomeContract; // Of course, it's awesome, we know it! + +#[contractimpl] +impl MyAwesomeContract { + pub fn lets_rock(e: Env) { + // Oracle contract address to use + let oracle_address = Address::from_str(&e, "CAFJZQWSED6YAWZU3GWRTOCNPPCGBN32L7QV43XX5LZLFTK6JLN34DLN"); + // Create client for working with oracle + let reflector_client = ReflectorBeamClient::new(&e, &oracle_address); + // Authorize XRF fee charge for lastprice() invocation + authorize_spend(&e, &reflector_client, 1); + // Ticker to lookup the price + let ticker = ReflectorAsset::Other(Symbol::new(&e, &("BTC"))); + // Fetch the most recent price record for it + let recent = reflector_client.lastprice(&e.current_contract_address(), &ticker); + // Check the result + if recent.is_none() { + //panic_with_error!(&e, "price not available"); + } + // Retrieve the price itself + let price = recent.unwrap().price; + // Do not forget for price precision, get decimals from the oracle + // (this value can be also hardcoded once the price feed has been + // selected because decimals never change in live oracles) + let price_decimals = reflector_client.decimals(); + + // Let's check how much of quoted asset we can potentially purchase for $10 + let usd_balance = 10_0000000i128; // $10 with standard Stellar token precision + let can_purchase = (usd_balance * 10i128.pow(price_decimals)) / price; + + // How many USD we'll need to buy 5 quoted asset tokens? + let want_purchase = 5_0000000i128; // 5 tokens with standard Stellar token precision + let need_usd = (want_purchase * price) / 10i128.pow(price_decimals); + + // Please note: check for potential overflows or use safe math when dealing with prices } } + +// Authorization is required to spend XRF tokens that cover invocation cost +fn authorize_spend(e: &Env, reflector_client: &ReflectorBeamClient, periods: u32) { + // How much will it cost + let cost = reflector_client.estimate_cost(&periods); + // XRF token address on Mainnet + let xrf = Address::from_str(&e, "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"); + // Build authorization request + let invocation = InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: xrf, // Contract address + fn_name: symbol_short!("burn"), // XRF tokens get burned after usage + args: Vec::from_array( + e, + [ + e.current_contract_address().to_val(), // Current contract authorizes spend + cost.into_val(&e), // Reflector invocation cost + ], + ), + }, + sub_invocations: Vec::new(&e), // No subinvocations required + }); + // Request authorization + e.authorize_as_current_contract(Vec::from_array(e, [invocation])); +} ``` +#### Beam contract interface + +Copy and save it in your smart contract project as `reflector_beam.rs` file. +This is the oracle client interface definition. + +```rust +/* reflector_beam.rs */ +use soroban_sdk::{contracttype, Address, Symbol, Vec}; + +// Oracle contract interface exported as ReflectorBeamClient +#[soroban_sdk::contractclient(name = "ReflectorBeamClient")] +pub trait Contract { + // Base oracle symbol the price is reported in + fn base() -> Asset; + // All assets quoted by the contract + fn assets() -> Vec; + // Number of decimal places used to represent price for all assets quoted by the oracle + fn decimals() -> u32; + // Quotes asset price in base asset at specific timestamp + fn price(caller: Address, asset: Asset, timestamp: u64) -> Option; + // Quotes the most recent price for an asset + fn lastprice(caller: Address, asset: Asset) -> Option; + // Quotes last N price records for the given asset + fn prices(caller: Address, asset: Asset, records: u32) -> Option>; + // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) + fn resolution() -> u32; + // Historical records retention period, in seconds (24 hours by default) + fn history_retention_period() -> Option; + // The most recent price update timestamp + fn last_timestamp() -> u64; + // Contract version + fn version() -> u32; + // Contract admin address + fn admin() -> Option
; + // Extend asset TTL (time-to-live) in the contract storage + fn extend_asset_ttl(sponsor: Address, asset: Asset); + // Get asset expiration timestamp + fn expires(asset: Asset) -> Option; + // Estimate invocation cost based on periods + fn estimate_cost(periods: u32) -> i128; +} + +// Quoted asset definition +#[contracttype(export = false)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum Asset { + Stellar(Address), // for Stellar Classic and Soroban assets + Other(Symbol) // for any external currencies/tokens/assets/symbols +} + +// Price record definition +#[contracttype(export = false)] +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct PriceData { + pub price: i128, // asset price at given point in time + pub timestamp: u64 // record timestamp +} + +// Possible runtime errors +#[soroban_sdk::contracterror(export = false)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum Error { + AlreadyInitialized = 0, + Unauthorized = 1, + AssetMissing = 2, + AssetAlreadyExists = 3, + InvalidConfigVersion = 4, + InvalidTimestamp = 5, + InvalidUpdateLength = 6, + AssetLimitExceeded = 7, + InvalidPricesUpdate = 8 +} +``` -## Building the Contracts +## Development ### Prerequisites -- Ensure you have Rust installed and set up on your local machine. [Follow the official guide here.](https://www.rust-lang.org/tools/install) +- Ensure you have Rust installed and set up ([official installation guide](https://www.rust-lang.org/tools/install)) +- Install Stellar CLI ([CLI installation guide](https://developers.stellar.org/docs/tools/cli/install-cli)) -### Building the Price Oracle +### Compilation -1. Navigate to the directory of the contract: +Run `stellar contract build` commands from the project root directory. It will compile all contracts included into the +workspace: - ```bash - cd ./price-oracle - ``` +```shell +stellar contract build +``` -2. Run the build command: - ```bash - cargo build --release --target wasm32-unknown-unknown - ``` \ No newline at end of file +To compile a specific contract, run `stellar contract build` command with the `--package` argument from the project root +directory: + +```shell +stellar contract build --package reflector-pulse-contract +stellar contract build --package reflector-beam-contract +``` + +#### Optimizing WASM + +Use `stellar contract optimize` CLI command to reduce the contract WASM binary size before deployment. +For example: + +```shell +stellar contract optimize --wasm ./target/wasm32v1-none/release/reflector_pulse_contract.wasm +``` + +This command will generate an optimized WASM file at +`./target/wasm32v1-none/release/reflector_pulse_contract.optimized.wasm`. + +### Testing + +To run all workspace tests, execute `cargo test` from the project root directory: + +```shell +cargo test +``` + +Or for a specific contract: + +```shell +cargo test --package reflector-pulse-contract +cargo test --package reflector-beam-contract +``` diff --git a/audits/reflector_code4rena_audit_beam_pulse_2025.pdf b/audits/reflector_code4rena_audit_beam_pulse_2025.pdf new file mode 100644 index 0000000..9f8333b Binary files /dev/null and b/audits/reflector_code4rena_audit_beam_pulse_2025.pdf differ diff --git a/beam-contract/Cargo.toml b/beam-contract/Cargo.toml new file mode 100644 index 0000000..5d03d4b --- /dev/null +++ b/beam-contract/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "reflector-beam-contract" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +oracle = { path = "../oracle" } +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" +oracle = { path = "../oracle", features = ["testutils"] } diff --git a/beam-contract/src/cost.rs b/beam-contract/src/cost.rs new file mode 100644 index 0000000..7e0360d --- /dev/null +++ b/beam-contract/src/cost.rs @@ -0,0 +1,67 @@ +use oracle::settings; +use oracle::types::FeeConfig; +use soroban_sdk::{token, Address, Env, Vec}; + +const COST_CONFIG_KEY: &str = "cost"; +const SCALE: i128 = 10_000_000; + +// Update invocation costs config +#[inline] +pub fn set_costs_config(e: &Env, costs: &Vec) { + e.storage().instance().set(&COST_CONFIG_KEY, &costs); +} + +// Load config containing invocation costs +pub fn load_costs_config(e: &Env) -> Vec { + e.storage() + .instance() + .get(&COST_CONFIG_KEY) + .unwrap_or_else(|| { + Vec::from_array( + e, // RecordsModifier, Price + [2_000_000, 10_000_000], + ) + }) +} + +// Charge per-invocation fee +pub fn charge_invocation_fee(e: &Env, caller: &Address, periods: u32) { + //load fee config + let fee_config = settings::get_fee_config(e); + if let FeeConfig::Some((fee_token, _)) = fee_config.clone() { + //calculate amount to charge + let cost = estimate_invocation_cost(e, periods, fee_config); + if cost <= 0 { + return; + } + //init fee token client + let fee_client = token::Client::new(e, &fee_token); + //burn tokens + fee_client.burn(caller, &cost); + } +} + +// Estimate invocation cost based on its complexity and fee config +pub fn estimate_invocation_cost(e: &Env, periods: u32, fee_config: FeeConfig) -> i128 { + match fee_config { + FeeConfig::None => 0, + FeeConfig::Some(_) => { + //load rates + let costs = load_costs_config(e); + //calculate amount to charge + //resolve base cost based on the invocation type + let mut cost = costs.get(1).unwrap_or_default() as i128; + if cost < 1 { + return 0; + } + //charge additional per each loaded period + if periods > 1 { + let period_modifier = costs.get(0).unwrap_or_default() as i128; + if period_modifier > 0 { + cost = cost * (SCALE + (periods - 1) as i128 * period_modifier) / SCALE; + } + } + cost + } + } +} diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs new file mode 100644 index 0000000..ea62436 --- /dev/null +++ b/beam-contract/src/lib.rs @@ -0,0 +1,357 @@ +#![no_std] +mod cost; + +use cost::{charge_invocation_fee, load_costs_config, set_costs_config}; +use oracle::price_oracle::PriceOracleContractBase; +use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; +use oracle::{auth, settings}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; + +#[contract] +pub struct BeamOracleContract; + +#[contractimpl] +impl BeamOracleContract { + // Return base asset price is reported in + // + // # Returns + // + // Oracle base asset + pub fn base(e: &Env) -> Asset { + PriceOracleContractBase::base(e) + } + + // Return number of decimal places used to represent price for all quoted assets + // + // # Returns + // + // Number of decimals places in quoted prices + pub fn decimals(e: &Env) -> u32 { + PriceOracleContractBase::decimals(e) + } + + // Return default tick period timeframe (in seconds) + // + // # Returns + // + // Price feed resolution (in seconds) + pub fn resolution(e: &Env) -> u32 { + PriceOracleContractBase::resolution(e) + } + + // Return historical records retention period (in seconds) + // + // # Returns + // + // History retention period (in seconds) + pub fn history_retention_period(e: &Env) -> Option { + PriceOracleContractBase::history_retention_period(e) + } + + // Return price records cache size + // + // # Returns + // + // Price records cache size + pub fn cache_size(e: &Env) -> u32 { + PriceOracleContractBase::cache_size(e) + } + + // Return all quoted assets + // + // # Returns + // + // Quoted assets + pub fn assets(e: &Env) -> Vec { + PriceOracleContractBase::assets(e) + } + + // Return most recent price update timestamp in seconds + // + // # Returns + // + // Timestamp of last recorded price update + pub fn last_timestamp(e: &Env) -> u64 { + PriceOracleContractBase::last_timestamp(e) + } + + // Return current contract version (from package) + // + // # Returns + // + // Contract version + pub fn version(e: &Env) -> u32 { + PriceOracleContractBase::version(e) + } + + // Return expiration date for a given asset + // + // # Arguments + // + // * `asset` - Quoted asset + // + // # Returns + // + // Asset expiration timestamp (in seconds) or None if asset is not supported + // + // # Panics + // + // Panics if asset is not supported + pub fn expires(e: &Env, asset: Asset) -> Option { + PriceOracleContractBase::expires(e, asset) + } + + // Estimates amount of fee tokens required to extend the asset retention config for a given time + // + // # Arguments + // + // * `period` - Desired retention period extension (in seconds) + // + // # Returns + // + // Fee asset and estimated amount required for the fee bump + // + // # Panics + // + // Panics if the asset is not supported or if retention config is malformed/missing + pub fn estimate_retention_cost(e: &Env, period: u64) -> (Address, i128) { + PriceOracleContractBase::estimate_retention_cost(e, period) + } + + // Extends asset expiration date by a given amount of tokens. + // + // # Arguments + // + // * `sponsor` - Address that sponsors price feed + // * `asset` - Quoted asset + // * `amount` - Amount of tokens to burn for extending the expiration date + // + // # Returns + // + // Current asset expiration timestamp (in seconds) + // + // # Panics + // + // Panics if asset is not supported or if retention config is malformed/missing + pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) -> u64 { + PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, 0) + } + + // Return fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn fee_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::fee_config(e) + } + + // Retrieve current invocation costs config + // + // # Returns + // + // Invocation costs. 0 index - records modifier, 1 index - price invocation cost + pub fn invocation_costs(e: &Env) -> Vec { + load_costs_config(e) + } + + // Estimate invocation cost based on its complexity + // + // # Arguments + // + // * `periods` - Number of requested history periods + // + // # Returns + // + // Amount of fee tokens required to pay for invocation + pub fn estimate_cost(e: &Env, periods: u32) -> i128 { + let fee_config = settings::get_fee_config(e); + cost::estimate_invocation_cost(e, periods, fee_config) + } + + // Return contract admin address + // + // # Returns + // + // Contract admin account address + pub fn admin(e: &Env) -> Option
{ + PriceOracleContractBase::admin(e) + } + + // Returns price for an asset at specific timestamp + // + // # Arguments + // + // * `caller` - Caller that covers invocation cost + // * `asset` - Asset to quote + // * `timestamp` - Timestamp in seconds + // + // # Returns + // + // Price record for given asset at given timestamp or None if not found + pub fn price(e: &Env, caller: Address, asset: Asset, timestamp: u64) -> Option { + caller.require_auth(); + let res = PriceOracleContractBase::price(e, asset, timestamp); + if res.is_some() { + charge_invocation_fee(e, &caller, 1); + } + res + } + + // Returns most recent price for an asset + // + // # Arguments + // + // * `caller` - Caller that covers invocation cost + // * `asset` - Asset to quote + // + // # Returns + // + // Most recent price for given asset or None if asset is not supported + pub fn lastprice(e: &Env, caller: Address, asset: Asset) -> Option { + caller.require_auth(); + let res = PriceOracleContractBase::lastprice(e, asset); + if res.is_some() { + charge_invocation_fee(e, &caller, 1); + } + res + } + + // Return last N price records for given asset + // + // # Arguments + // + // * `caller` - Caller that covers invocation cost + // * `asset` - Asset to quote + // * `records` - Number of records to return + // + // # Returns + // + // Prices for given asset or None if asset is not supported + pub fn prices(e: &Env, caller: Address, asset: Asset, records: u32) -> Option> { + caller.require_auth(); + let res = PriceOracleContractBase::prices(e, asset, records); + if res.is_some() { + charge_invocation_fee(e, &caller, records); + } + res + } + + /* Admin section */ + + // Initializes contract configuration + // Requires admin authorization + // # Arguments + // + // * `config` - Configuration parameters + // + // # Panics + // + // Panics if not authorized or if contract is already initialized + pub fn config(e: &Env, config: ConfigData) { + PriceOracleContractBase::config(e, config, 0); + } + + // Update contract cache size + // Requires admin authorization + // + // # Arguments + // + // * `cache_size` - New cache size (number of rounds stored in cache) + // + // # Panics + // + // Panics if not authorized + pub fn set_cache_size(e: &Env, cache_size: u32) { + PriceOracleContractBase::set_cache_size(e, cache_size); + } + + // Adds given assets to the contract quoted assets list + // Requires admin authorization + // + // # Arguments + // + // * `assets` - Assets to add + // + // # Panics + // + // Panics if not authorized, any of the assets were added earlier, or assets limit exceeded + pub fn add_assets(e: &Env, assets: Vec) { + PriceOracleContractBase::add_assets(e, assets, 0); + } + + // Sets history retention period for the prices + // Requires admin authorization + // + // # Arguments + // + // * `period` - History retention period (in milliseconds) + // + // # Panics + // + // Panics if not authorized + pub fn set_history_retention_period(e: &Env, period: u64) { + PriceOracleContractBase::set_history_retention_period(e, period); + } + + // Set fee token address and daily price feed retainer fee amount + // Requires admin authorization + // + // # Arguments + // + // * `fee_config` - Fee token address and fee amount + // + // # Panics + // + // Panics if not authorized or not initialized yet + pub fn set_fee_config(e: &Env, config: FeeConfig) { + PriceOracleContractBase::set_fee_config(e, config, 0); + } + + // Update costs configuration per each invocation category + // Requires admin authorization + // + // # Arguments + // + // * `config` - Invocation costs for different invocation categories + // + // # Panics + // + // Panics if not authorized or not initialized yet + pub fn set_invocation_costs_config(e: &Env, config: Vec) { + auth::panic_if_not_admin(e); + set_costs_config(e, &config); + } + + // Record new price feed history snapshot + // Requires admin authorization + // + // # Arguments + // + // * `updates` - Price feed snapshot + // * `timestamp` - History snapshot timestamp + // + // # Panics + // + // Panics if not authorized or price snapshot record is invalid + pub fn set_price(e: &Env, updates: PriceUpdate, timestamp: u64) { + PriceOracleContractBase::set_price(e, updates, timestamp); + } + + // Update contract source code + // Requires admin authorization + // + // # Arguments + // + // * `wasm_hash` - WASM hash of the contract source code + // + // # Panics + // + // Panics if not authorized + pub fn update_contract(e: &Env, wasm_hash: BytesN<32>) { + PriceOracleContractBase::update_contract(e, wasm_hash); + } +} + +#[cfg(test)] +mod tests; diff --git a/beam-contract/src/tests/contract_tests.rs b/beam-contract/src/tests/contract_tests.rs new file mode 100644 index 0000000..ba24bf2 --- /dev/null +++ b/beam-contract/src/tests/contract_tests.rs @@ -0,0 +1,108 @@ +#![cfg(test)] +extern crate std; + +use crate::{BeamOracleContract, BeamOracleContractClient}; +use oracle::testutils::register_token; +use oracle::types::{Asset, FeeConfig}; +use oracle::{assets, init_contract_with_admin, timestamps}; +use soroban_sdk::{testutils::Address as _, Address, Vec}; +use test_case::test_case; + +#[test] +fn set_invocation_config_test() { + let (env, client, _) = + init_contract_with_admin!(BeamOracleContract, BeamOracleContractClient, true); + + let initial_costs = client.invocation_costs(); + assert_eq!(initial_costs.len(), 2); + assert_eq!( + initial_costs, + Vec::from_array(&env, [2_000_000, 10_000_000]) + ); + + let costs = Vec::from_array(&env, [10, 20]); + client.set_invocation_costs_config(&costs); + + let result = client.invocation_costs(); + assert_eq!(result, costs); +} + +#[test] +fn invocation_charge_for_none_result_test() { + let (env, client, init_data) = + init_contract_with_admin!(BeamOracleContract, BeamOracleContractClient, true); + + let fee_token_client = register_token(&env, &init_data.admin); + let fee_config = FeeConfig::Some((fee_token_client.address.clone(), 1_000_000)); + client.set_fee_config(&fee_config); + + let caller = Address::generate(&env); + //mint fee token to caller + fee_token_client.mint(&caller, &100_000_000); + //get price for the first asset + client.lastprice(&caller, &init_data.assets.first_unchecked()); + //check that fee token was deducted + let fee_token_balance = fee_token_client.balance(&caller); + assert_eq!(fee_token_balance, 100_000_000); +} + +#[test_case(1, 5_000_000 ; "price")] +#[test_case(2, 5_750_000 ; "multi round price")] +fn invocation_charge_estimate_test(periods: u32, expected_fee: i128) { + let (env, client, init_data) = + init_contract_with_admin!(BeamOracleContract, BeamOracleContractClient, true); + + let fee_token_client = register_token(&env, &init_data.admin); + let fee_config = FeeConfig::Some((fee_token_client.address.clone(), 1_000_000)); + client.set_fee_config(&fee_config); + let costs = Vec::from_array(&env, [1_500_000, 5_000_000]); + client.set_invocation_costs_config(&costs); + + let fee = client.estimate_cost(&periods); + assert_eq!(fee, expected_fee); +} + +#[test] +fn check_extending_asset_ttl() { + //initialize contract + let (env, client, init_data) = + init_contract_with_admin!(BeamOracleContract, BeamOracleContractClient, true); + + //set fee config + let fee_token_client = register_token(&env, &init_data.admin); + let fee_config = FeeConfig::Some((fee_token_client.address.clone(), 1_000_000)); + client.set_fee_config(&fee_config); + + //add new asset to the oracle + let new_asset = Asset::Stellar(Address::generate(&env)); + let mut new_assets = Vec::new(&env); + new_assets.push_back(new_asset.clone()); + client.add_assets(&new_assets); + + //check that expiration is set for the new asset + let exp = client.expires(&new_asset); + assert_ne!(exp, None, "Expected expiration to be set for the new asset"); + + //extend TTL for the new asset + let sponsor = Address::generate(&env); + fee_token_client.mint(&sponsor, &10_000_000); + + //estimate bump cost for 1 day + let day = 24 * 60 * 60u64; + let amount = client.estimate_retention_cost(&day); + assert_eq!(amount.0, fee_token_client.address); + assert_eq!(amount.1, 1_000_000); + + //check bump + let current_expiration = client.expires(&new_asset).unwrap(); + assert_eq!(current_expiration, 0); + let ttl = client.extend_asset_ttl(&sponsor, &new_asset, &amount.1); + assert_eq!(ttl, client.expires(&new_asset).unwrap()); + assert_eq!(ttl, timestamps::ledger_timestamp(&env) / 1000 + day); + + //check that expiration records length matches assets length + env.as_contract(&client.address, || { + let expiration: Vec = env.storage().instance().get(&"expiration").unwrap(); + assert_eq!(assets::load_all_assets(&env).len(), expiration.len()); + }); +} diff --git a/beam-contract/src/tests/mod.rs b/beam-contract/src/tests/mod.rs new file mode 100644 index 0000000..fbe7a86 --- /dev/null +++ b/beam-contract/src/tests/mod.rs @@ -0,0 +1 @@ +mod contract_tests; diff --git a/oracle/Cargo.toml b/oracle/Cargo.toml new file mode 100644 index 0000000..ecfd32b --- /dev/null +++ b/oracle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "oracle" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["rlib"] + +[features] +testutils = [] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" \ No newline at end of file diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs new file mode 100644 index 0000000..71c4df3 --- /dev/null +++ b/oracle/src/assets.rs @@ -0,0 +1,166 @@ +use crate::types::{Asset, Error, FeeConfig}; +use crate::{settings, timestamps}; +use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env, Vec}; + +const ASSET_LIMIT: u32 = 256; + +//storage keys +const ASSETS_KEY: &str = "assets"; +const EXPIRATION_KEY: &str = "expiration"; +const DAY: i128 = 86400000; + +fn get_expiration_timestamp(e: &Env, initial_expiration_period: u32) -> u64 { + if initial_expiration_period > 0 { + return timestamps::ledger_timestamp(&e) + .checked_add(timestamps::days_to_milliseconds(initial_expiration_period)) + .unwrap(); + } + 0u64 +} + +// Get all contract assets +pub fn load_all_assets(e: &Env) -> Vec { + e.storage() + .instance() + .get(&ASSETS_KEY) + .unwrap_or_else(|| Vec::new(e)) +} + +// Load asset index +pub fn resolve_asset_index(e: &Env, asset: &Asset) -> Option { + load_all_assets(e).first_index_of(asset) +} + +// Add assets to the oracle +pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { + //use default expiration period for new assets + let expiration_timestamp = get_expiration_timestamp(e, initial_expiration_period); + //load current state + let mut asset_list = load_all_assets(e); + let mut expiration = load_expiration_records(e); + //for each new asset + for asset in assets.iter() { + //check if the asset has been already added + if asset_list.first_index_of(&asset).is_some() { + panic_with_error!(&e, Error::AssetAlreadyExists); + } + asset_list.push_back(asset); + //update expiration records + expiration.push_back(expiration_timestamp); + } + if asset_list.len() > ASSET_LIMIT { + panic_with_error!(&e, Error::AssetLimitExceeded); + } + //update assets list and expirations vector + e.storage().instance().set(&ASSETS_KEY, &asset_list); + set_expirations_records(e, &expiration); +} + +// Retrieve expiration timestamp for given asset +pub fn expires(e: &Env, asset: Asset) -> Option { + let asset_index = resolve_asset_index(e, &asset); + if asset_index.is_none() { + e.panic_with_error(Error::AssetMissing); + } + let expirations = load_expiration_records(e); + expirations.get(asset_index.unwrap()) +} + +// Initialize expiration records for all existing assets +pub fn init_expiration_config(e: &Env, initial_expiration_period: u32) { + let mut expiration_records = load_expiration_records(e); + if expiration_records.len() > 0 { + return; // expiration values for existing price feeds already initialized + } + //init expiration, set INITIAL_EXPIRATION_PERIOD for all symbols by default + let exp = get_expiration_timestamp(e, initial_expiration_period); + //add records to the expirations vector + let assets = load_all_assets(e); + for _ in 0..assets.len() { + expiration_records.push_back(exp); + } + set_expirations_records(e, &expiration_records); +} + +// Extend time-to-live for given asset price feed +pub fn extend_ttl( + e: &Env, + sponsor: Address, + asset: Asset, + amount: i128, + initial_expiration_period: u32, +) -> u64 { + //check if the amount is valid + if amount <= 0 { + e.panic_with_error(Error::InvalidAmount); + } + //ensure that the asset is supported + let asset_index = resolve_asset_index(e, &asset); + if asset_index.is_none() { + e.panic_with_error(Error::AssetMissing); + } + let asset_index = asset_index.unwrap(); + //load required fee amount from retention config + let (xrf, fee) = load_fee_settings(e); + //calculate extension period + let bump = amount * DAY / fee; // in milliseconds + if bump <= 0 { + e.panic_with_error(Error::InvalidAmount); + } + //burn corresponding amount of fee tokens + TokenClient::new(&e, &xrf).burn(&sponsor, &amount); + //load expiration info + let mut expiration = load_expiration_records(e); + let now = timestamps::ledger_timestamp(&e); + let mut asset_expiration = expiration + .get(asset_index) + .unwrap_or_else(|| now + timestamps::days_to_milliseconds(initial_expiration_period)); + //if the asset expiration is not set, or it's already expired - set it to now + if asset_expiration == 0 || asset_expiration < now { + asset_expiration = now; + } + //bump expiration + asset_expiration = asset_expiration.checked_add(bump as u64).unwrap(); + //write into the vector that holds expiration dates for all symbols + expiration.set(asset_index, asset_expiration); + //update expiration records in instance storage + set_expirations_records(e, &expiration); + //return current asset TTL + asset_expiration +} + +// Estimate amount of fee tokens required to bump the retention for a given time (in milliseconds) +pub fn estimate_retention_cost(e: &Env, bump: u64) -> (Address, i128) { + //load daily retention cost from config + let (xrf, fee) = load_fee_settings(e); + let amount = bump as i128 * fee / DAY; + (xrf, amount) +} + +// Load current asset retention fee settings +fn load_fee_settings(e: &Env) -> (Address, i128) { + match settings::get_fee_config(e) { + FeeConfig::Some(fee_data) => { + if fee_data.1 <= 0 { + e.panic_with_error(Error::InvalidConfig); + } + fee_data + } + FeeConfig::None => { + e.panic_with_error(Error::InvalidConfig); + } + } +} + +// Load expiration data for all assets +fn load_expiration_records(e: &Env) -> Vec { + e.storage() + .instance() + .get(&EXPIRATION_KEY) + .unwrap_or_else(|| Vec::new(e)) +} + +// Set expiration data for all assets +fn set_expirations_records(e: &Env, expiration: &Vec) { + e.storage().instance().set(&EXPIRATION_KEY, expiration) +} diff --git a/oracle/src/auth.rs b/oracle/src/auth.rs new file mode 100644 index 0000000..681eec7 --- /dev/null +++ b/oracle/src/auth.rs @@ -0,0 +1,27 @@ +use crate::types::Error; +use soroban_sdk::{panic_with_error, Address, Env}; + +//storage keys +const ADMIN_KEY: &str = "admin"; + +// Get current admin account address +#[inline] +pub fn get_admin(e: &Env) -> Option
{ + e.storage().instance().get(&ADMIN_KEY) +} + +// Set current admin account address +#[inline] +pub fn set_admin(e: &Env, admin: &Address) { + e.storage().instance().set(&ADMIN_KEY, admin); +} + +// Throw exception if call hasn't been authorized by admin +#[inline] +pub fn panic_if_not_admin(e: &Env) { + let admin = get_admin(e); + if admin.is_none() { + panic_with_error!(e, Error::Unauthorized); + } + admin.unwrap().require_auth() +} diff --git a/oracle/src/events.rs b/oracle/src/events.rs new file mode 100644 index 0000000..8026a7c --- /dev/null +++ b/oracle/src/events.rs @@ -0,0 +1,42 @@ +use crate::types::{Asset, Error}; +use soroban_sdk::{contractevent, panic_with_error, Env, Val, Vec}; + +#[contractevent(topics = ["REFLECTOR", "update"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdateEvent { + #[topic] + pub timestamp: u64, + pub update_data: Vec<(Val, i128)>, +} + +// Compose and publish price update event +#[inline] +pub fn publish_update_event(e: &Env, updates: &Vec, all_assets: &Vec, timestamp: u64) { + //validate length + if all_assets.len() < updates.len() { + panic_with_error!(&e, Error::AssetLimitExceeded); + } + //prepare update event + let mut event_updates = Vec::new(&e); + for (index, asset) in all_assets.iter().enumerate() { + //retrieve individual price + let price = updates.get(index as u32).unwrap_or_default(); + if price == 0 { + continue; //skip zero prices + } + //resolve asset symbol + let symbol = match asset { + Asset::Stellar(address) => address.to_val(), + Asset::Other(symbol) => symbol.to_val(), + }; + //add to updates vector + event_updates.push_back((symbol, price)); + } + + //compose and publish price update event + let event = UpdateEvent { + timestamp, + update_data: event_updates, + }; + e.events().publish_event(&event); +} diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs new file mode 100644 index 0000000..b316eee --- /dev/null +++ b/oracle/src/lib.rs @@ -0,0 +1,17 @@ +#![no_std] +pub mod assets; +pub mod auth; +pub mod events; +pub mod mapping; +pub mod price_oracle; +pub mod prices; +pub mod protocol; +pub mod settings; +pub mod timestamps; +pub mod types; + +#[cfg(any(test, feature = "testutils"))] +pub mod testutils; + +#[cfg(test)] +mod tests; diff --git a/oracle/src/mapping.rs b/oracle/src/mapping.rs new file mode 100644 index 0000000..e058e80 --- /dev/null +++ b/oracle/src/mapping.rs @@ -0,0 +1,97 @@ +use soroban_sdk::{Bytes, Vec}; + +// Each history record occupies 32 bytes in history mask, allowing to store information for up to 256 recent periods +const RECORD_SIZE: u32 = 32; +const URECORD_SIZE: usize = 32; +const MAX_HISTORY_SIZE: usize = 256 * URECORD_SIZE; // 256 assets * 32 bytes + +// Update history records containing a bitmask of all prices recorded within the last update period +pub fn update_history_mask( + history_mask: Bytes, + updates: &Vec, + mut updates_delta: u32, +) -> Bytes { + //create a buffer that can hold the entire history mask + let mut buffer = [0u8; MAX_HISTORY_SIZE]; + let mask_length = history_mask.len() as usize; + + if updates_delta < 1 { + updates_delta = 1; //this should never happen, but just in case + } + if updates_delta > 255 { + //entire history is obsolete - ignore + updates_delta = 1; //reset delta to 1 + } else { + //copy existing history mask into buffer + history_mask.copy_into_slice(&mut buffer[..mask_length]); + } + //iterate through all updates and update corresponding history records in the buffer + for (asset_index, price) in updates.iter().enumerate() { + //position in the mask + let offset = asset_index * URECORD_SIZE; + + //256 bits as two 128 parts + let mut hi = u128::from_be_bytes(buffer[offset..offset + 16].try_into().unwrap()); + let mut lo = u128::from_be_bytes(buffer[offset + 16..offset + 32].try_into().unwrap()); + + if lo > 0 || hi > 0 { + //shift left by the number of skipped periods + (hi, lo) = if updates_delta < 128 { + ( + (hi << updates_delta) | (lo >> (128 - updates_delta)), + lo << updates_delta, + ) + } else { + (lo << (updates_delta & 0x7f), 0) + }; + } + + //set lowest bit if price found + if price > 0 { + let (new_lo, carry) = lo.overflowing_add(1); + lo = new_lo; + if carry { + (hi, _) = hi.overflowing_add(1); + } + } + //write back to buffer + buffer[offset..offset + 16].copy_from_slice(&hi.to_be_bytes()); + buffer[offset + 16..offset + 32].copy_from_slice(&lo.to_be_bytes()); + } + + //get total size of updated history mask based on the number of assets and return as Bytes + let updates_length = mask_length.max(updates.len() as usize * URECORD_SIZE); + Bytes::from_slice(history_mask.env(), &buffer[..updates_length]) +} + +// Check whether asset price has been quoted for a certain period based on history records bitmask +pub fn check_history_updated(history_mask: &Bytes, asset_index: u32, period: u32) -> bool { + //locate particular asset mask slice position within entire history record + let from = asset_index * RECORD_SIZE + (RECORD_SIZE - 1 - period / 8); + //and calculate specific bit that we need to check + let bit = 1 << (period % 8); + //retrieve byte from array + let encoded_byte = history_mask.get(from).unwrap_or_default(); + //compare with bit mask + encoded_byte & bit == bit +} + +// Check whether price update record contains update for given asset by its index +pub fn check_period_updated(period_mask: &Bytes, asset_index: u32) -> bool { + //calculate byte position and bit index to check + let (byte, bitmask) = resolve_period_update_mask_position(asset_index); + //retrieve byte from array + let bytemask = period_mask.get(byte).unwrap_or_default(); + //compare with bit mask + bytemask & bitmask == bitmask +} + +// Calculate byte position and bit index to check in 256-bit update record mask +#[inline] +pub fn resolve_period_update_mask_position(asset_index: u32) -> (u32, u8) { + //locate particular asset mask position within update record + let byte = asset_index / 8; + //and calculate specific bit that we need to check + let bitmask = 1 << (asset_index % 8); + (byte, bitmask) +} diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs new file mode 100644 index 0000000..f1e1d30 --- /dev/null +++ b/oracle/src/price_oracle.rs @@ -0,0 +1,375 @@ +use crate::types::ConfigData; +use crate::types::{Asset, Error, FeeConfig, PriceData, PriceUpdate}; +use crate::{assets, auth, events, prices, protocol, settings, timestamps}; +use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; + +pub struct PriceOracleContractBase; + +impl PriceOracleContractBase { + // Return base asset price is reported in + // + // # Returns + // + // Oracle base asset + pub fn base(e: &Env) -> Asset { + settings::get_base_asset(e) + } + + // Return number of decimal places used to represent price for all quoted assets + // + // # Returns + // + // Number of decimals places in quoted prices + pub fn decimals(e: &Env) -> u32 { + settings::get_decimals(e) + } + + // Return default tick period timeframe (in seconds) + // + // # Returns + // + // Price feed resolution (in seconds) + pub fn resolution(e: &Env) -> u32 { + settings::get_resolution(e) / 1000 + } + + // Return historical records retention period (in seconds) + // + // # Returns + // + // History retention period (in seconds) + pub fn history_retention_period(e: &Env) -> Option { + let period: u64 = settings::get_history_retention_period(e); + if period == 0 { + None + } else { + Some(period / 1000) //convert to seconds + } + } + + // Return price records cache size + // + // # Returns + // + // Price records cache size + pub fn cache_size(e: &Env) -> u32 { + settings::get_cache_size(e) + } + + // Return all quoted assets + // + // # Returns + // + // Quoted assets + pub fn assets(e: &Env) -> Vec { + assets::load_all_assets(e) + } + + // Return most recent price update timestamp in seconds + // + // # Returns + // + // Timestamp of last recorded price update + pub fn last_timestamp(e: &Env) -> u64 { + prices::get_last_timestamp(e) / 1000 //convert to seconds + } + + // Return current contract version (from package) + // + // # Returns + // + // Contract version + pub fn version(_e: &Env) -> u32 { + env!("CARGO_PKG_VERSION") + .split(".") + .next() + .unwrap() + .parse::() + .unwrap() + } + + // Return expiration date for a given asset + // + // # Arguments + // + // * `asset` - Quoted asset + // + // # Returns + // + // Asset expiration timestamp (in seconds) or None if asset is expired + // + // # Panics + // + // Panics if asset is not supported + pub fn expires(e: &Env, asset: Asset) -> Option { + match assets::expires(e, asset) { + Some(ts) => Some(ts / 1000), //convert to seconds + None => None, + } + } + + // Estimates amount of fee tokens required to extend the asset retention config for a given time + // + // # Arguments + // + // * `period` - Desired retention period extension (in seconds) + // + // # Returns + // + // Fee asset and estimated amount required for the fee bump + // + // # Panics + // + // Panics if the asset is not supported or if retention config is malformed/missing + pub fn estimate_retention_cost(e: &Env, period: u64) -> (Address, i128) { + assets::estimate_retention_cost(e, period * 1000) + } + + // Extends the asset expiration date by a given amount of tokens. + // + // # Arguments + // + // * `sponsor` - Address that sponsors price feed + // * `asset` - Quoted asset + // * `amount` - Amount of tokens to burn for extending the expiration date + // * `initial_expiration_period` - Initial expiration period for new assets (in days) + // + // # Returns + // + // Current asset expiration timestamp (in seconds) + // + // # Panics + // + // Panics if the asset is not supported or if retention config is malformed/missing + pub fn extend_asset_ttl( + e: &Env, + sponsor: Address, + asset: Asset, + amount: i128, + initial_expiration_period: u32, + ) -> u64 { + //check sponsor authorization + sponsor.require_auth(); + //extend and return current TTL + assets::extend_ttl(e, sponsor, asset, amount, initial_expiration_period) / 1000 + } + + // Return the fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn fee_config(e: &Env) -> FeeConfig { + settings::get_fee_config(e) + } + + // Return contract admin address + // + // # Returns + // + // Contract admin account address + pub fn admin(e: &Env) -> Option
{ + auth::get_admin(e) + } + + // Returns price for an asset at specific timestamp + // + // # Arguments + // + // * `asset` - Asset to quote + // * `timestamp` - Timestamp in seconds + // + // # Returns + // + // Price record for given asset at given timestamp or None if not found + pub fn price(e: &Env, asset: Asset, timestamp: u64) -> Option { + //normalize timestamp + let ts = timestamps::normalize(e, timestamp * 1000); + //resolve index for the asset + let asset = assets::resolve_asset_index(e, &asset)?; + prices::retrieve_asset_price_data(e, asset, ts) + } + + // Returns most recent price for an asset + // + // # Arguments + // + // * `asset` - Asset to quote + // + // # Returns + // + // Most recent price for given asset or None if asset is not supported + pub fn lastprice(e: &Env, asset: Asset) -> Option { + //get the last timestamp + let ts = prices::obtain_last_record_timestamp(&e); + if ts == 0 { + return None; + } + //get the price + let asset = assets::resolve_asset_index(e, &asset)?; + //resolve index for the asset + prices::retrieve_asset_price_data(e, asset, ts) + } + + // Return last N price records for given asset + // + // # Arguments + // + // * `asset` - Asset to quote + // * `records` - Number of records to return + // + // # Returns + // + // Prices for given asset or None if asset is not supported + pub fn prices(e: &Env, asset: Asset, records: u32) -> Option> { + let asset_index = assets::resolve_asset_index(e, &asset)?; //get the asset index to avoid multiple calls + prices::load_prices(&e, asset_index, records) + } + + /* Admin section */ + + // Initializes contract configuration + // Requires admin authorization + // # Arguments + // + // * `admin` - Admin address + // * `base` - Base asset + // * `decimals` - Number of decimals for price records + // * `resolution` - History timeframe resolution (in milliseconds) + // * `history_retention_period` - Price history retention period (in milliseconds) + // * `cache_size` - Number of rounds held in instance cache + // * `fee_config` - Contract retention config + // * `assets` - Initial list of supported assets + // * `initial_expiration_period` - Initial expiration period for new assets (in days) + // + // # Panics + // + // Panics if not authorized or if contract is already initialized + pub fn config(e: &Env, config: ConfigData, initial_expiration_period: u32) { + //should be invoked by admin + config.admin.require_auth(); + //apply settings + settings::init( + e, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.fee_config, + ); + auth::set_admin(e, &config.admin); + protocol::set_protocol_version(e, protocol::CURRENT_PROTOCOL); + //add initial assets + assets::add_assets(&e, config.assets, initial_expiration_period); + } + + // Update contract cache size + // Requires admin authorization + // + // # Arguments + // + // * `cache_size` - New cache size (number of rounds stored in cache) + // + // # Panics + // + // Panics if not authorized + pub fn set_cache_size(e: &Env, cache_size: u32) { + auth::panic_if_not_admin(e); + settings::set_cache_size(e, cache_size); + } + + // Adds given assets to the contract quoted assets list + // Requires admin authorization + // + // # Arguments + // + // * `assets` - Assets to add + // * `initial_expiration_period` - Initial expiration period for new assets (in days) + // + // # Panics + // + // Panics if not authorized, any of the assets were added earlier, or assets limit exceeded + pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { + auth::panic_if_not_admin(e); + assets::add_assets(&e, assets, initial_expiration_period); + } + + // Sets history retention period for the prices + // Requires admin authorization + // + // # Arguments + // + // * `period` - History retention period (in milliseconds) + // + // # Panics + // + // Panics if not authorized + pub fn set_history_retention_period(e: &Env, period: u64) { + auth::panic_if_not_admin(e); + settings::set_history_retention_period(e, period); + } + + // Set fee token address and daily price feed retainer fee amount + // Requires admin authorization + // + // # Arguments + // + // * `fee_config` - Fee token address and fee amount + // * `initial_expiration_period` - Initial expiration period for new assets (in days) + // + // # Panics + // + // Panics if not authorized or not initialized yet + pub fn set_fee_config(e: &Env, fee_config: FeeConfig, initial_expiration_period: u32) { + auth::panic_if_not_admin(e); + if fee_config == FeeConfig::None { + e.panic_with_error(Error::InvalidConfig); //prevent using empty fee config + } + settings::set_fee_config(e, &fee_config); + assets::init_expiration_config(e, initial_expiration_period); + } + + // Record new price feed history snapshot + // Requires admin authorization + // + // # Arguments + // + // * `updates` - Price feed snapshot + // * `timestamp` - History snapshot timestamp + // + // # Panics + // + // Panics if not authorized or price snapshot record is invalid + pub fn set_price(e: &Env, update: PriceUpdate, timestamp: u64) { + auth::panic_if_not_admin(e); + //extract prices for all assets from update record + let all = assets::load_all_assets(e); + //validate prices length + if update.prices.len() == 0 || update.prices.len() > all.len() { + panic_with_error!(&e, Error::InvalidPricesUpdate); + } + let asset_prices = prices::extract_update_record_prices(e, &update, all.len()); + //store history timestamps for all assets + prices::update_history_mask(e, &asset_prices, timestamp); + //prepare and publish update event + events::publish_update_event(e, &asset_prices, &all, timestamp); + //store new prices + prices::store_prices(e, update, timestamp, asset_prices); + } + + // Update contract source code + // Requires admin authorization + // + // # Arguments + // + // * `wasm_hash` - WASM hash of the contract source code + // + // # Panics + // + // Panics if not authorized + pub fn update_contract(e: &Env, wasm_hash: BytesN<32>) { + auth::panic_if_not_admin(e); + e.deployer().update_current_contract_wasm(wasm_hash); + } +} diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs new file mode 100644 index 0000000..fcc4cd7 --- /dev/null +++ b/oracle/src/prices.rs @@ -0,0 +1,302 @@ +use crate::types::{Error, PriceData, PriceUpdate}; +use crate::{mapping, protocol, settings, timestamps}; +use soroban_sdk::{panic_with_error, Bytes, Env, Vec}; + +const CACHE_KEY: &str = "cache"; +const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; +const HISTORY_KEY: &str = "history"; + +pub const PRICE_RECORDS_LIMIT: u32 = 20; //max number of records to return + +fn normalize_price_data(price: i128, timestamp: u64) -> PriceData { + PriceData { + price, + timestamp: timestamp / 1000, //convert to seconds + } +} + +// Get last known record timestamp +pub fn obtain_last_record_timestamp(e: &Env) -> u64 { + let last_timestamp = get_last_timestamp(e); + let ledger_timestamp = timestamps::ledger_timestamp(&e); + let resolution = settings::get_resolution(e) as u64; + if last_timestamp == 0 //no prices yet + || last_timestamp > ledger_timestamp //last timestamp is in the future + || ledger_timestamp - last_timestamp >= resolution * 2 + //last timestamp is too far in the past, so we cannot return the last price + { + return 0; + } + last_timestamp +} + +// Retrieve price from record for specific asset +pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option { + //if protocol version < 2, use legacy method + if !protocol::at_latest_protocol_version(e) { + let price = get_price_v1(e, asset as u8, timestamp)?; + return Some(normalize_price_data(price, timestamp)); + } + let last = get_last_timestamp(e); + //get the timestamp index in the bitmask + if last < timestamp { + return None; + } + let mut period = 0; + if last > timestamp { + period = (last - timestamp) / settings::get_resolution(e) as u64; + } + if period > 255 { + return None; //we cannot track more than 256 updates in the bitmask + } + if !has_price(e, asset, period as u32) { + return None; //no price record + } + //load the prices for the timestamp + let record = load_history_record(e, timestamp)?; + //get price for the asset index + let price = extract_single_update_record_price(&record, asset); + Some(normalize_price_data(price, timestamp)) +} + +// Extract prices for all assets from update record +pub fn extract_update_record_prices(e: &Env, update: &PriceUpdate, total: u32) -> Vec { + let mut res = Vec::new(&e); + let mut update_index = 0; + for asset_index in 0..total { + let mut price = 0; + if mapping::check_period_updated(&update.mask, asset_index) { + //set price from the update record + price = update.prices.get_unchecked(update_index); + update_index += 1; + } + res.push_back(price); + } + res +} + +fn extract_single_update_record_price(update: &PriceUpdate, asset_index: u32) -> i128 { + let mut update_index = 0; + for asset in 0..asset_index + 1 { + if mapping::check_period_updated(&update.mask, asset) { + if asset == asset_index { + return update.prices.get_unchecked(update_index); + } + update_index += 1; + } + } + 0 +} + +// Load last update timestamp +pub fn get_last_timestamp(e: &Env) -> u64 { + //get the marker + e.storage() + .instance() + .get(&LAST_TIMESTAMP_KEY) + .unwrap_or_default() +} + +// Store last update timestamp +pub fn set_last_timestamp(e: &Env, timestamp: u64) { + e.storage().instance().set(&LAST_TIMESTAMP_KEY, ×tamp); +} + +// Load history mask containing the map of all periods that had price updates +fn get_history_map(e: &Env) -> Bytes { + e.storage() + .instance() + .get(&HISTORY_KEY) + .unwrap_or_else(|| Bytes::new(e)) +} + +pub fn update_history_mask(e: &Env, prices: &Vec, timestamp: u64) { + //load state + let last_timestamp = get_last_timestamp(e); + let mut history_map = get_history_map(e); + let resolution = settings::get_resolution(e) as u64; + //find the delta in updates + let mut update_delta = 0; + if last_timestamp > 0 && timestamp > last_timestamp { + update_delta = (timestamp - last_timestamp) / resolution; + update_delta = core::cmp::min(update_delta, 256); //max 256 periods tracked + } + + //update the position mask + history_map = mapping::update_history_mask(history_map, prices, update_delta as u32); + //store updated timestamps + e.storage().instance().set(&HISTORY_KEY, &history_map); +} + +pub fn has_price(e: &Env, asset_index: u32, periods_ago: u32) -> bool { + let timestamps = get_history_map(e); + mapping::check_history_updated(×tamps, asset_index, periods_ago) +} + +// Load prices for a given timestamp +pub fn load_history_record(e: &Env, timestamp: u64) -> Option { + //check if the timestamp is in the cache + let cache = load_price_records_cache(e); + if cache.is_some() { + //check the cache first + for (ts, prices) in cache.unwrap() { + if ts == timestamp { + return Some(prices); + } + } + } + //get the price from the temporary storage + e.storage().temporary().get(×tamp) +} + +// Update prices stored in the oracle +pub fn store_prices(e: &Env, update: PriceUpdate, timestamp: u64, update_v1: Vec) { + //validate timestamp + let ledger_timestamp = timestamps::ledger_timestamp(&e); + let last_timestamp = get_last_timestamp(e); + if !timestamps::is_valid(e, timestamp) + || timestamp > ledger_timestamp + || timestamp <= last_timestamp + { + panic_with_error!(&e, Error::InvalidTimestamp); + } + + //update last timestamp + set_last_timestamp(e, timestamp); + + //set the price + let temps_storage = e.storage().temporary(); + temps_storage.set(×tamp, &update); + //update cache + let cache_size = settings::get_cache_size(e); + if cache_size > 0 { + //if cache size is non-empty, store it in the instance + let mut cache = load_price_records_cache(e).unwrap_or(Vec::new(&e)); + cache.push_front((timestamp, update)); + while cache.len() > cache_size { + cache.pop_back(); //remove the oldest record if cache size exceeded + } + //write cache entry + e.storage().instance().set(&CACHE_KEY, &cache); + } + //calculate TTL + let retention_period = settings::get_history_retention_period(e); + let ledgers_to_live = ((retention_period / 1000 / 5 + 1) * 2) as u32; + //bump if needed + if ledgers_to_live > 16 { + //16 ledgers is the minimum extension period + temps_storage.extend_ttl(×tamp, ledgers_to_live, ledgers_to_live) + } + + //if the protocol hasn't updated to the latest version yet + if !protocol::at_latest_protocol_version(e) { + store_price_v1(e, update_v1, timestamp, ledgers_to_live); + } +} + +// Load requested number of price records with a price function callback +pub fn load_prices(e: &Env, asset_index: u32, records: u32) -> Option> { + let mut timestamp = obtain_last_record_timestamp(e); + if timestamp == 0 { + return None; + } + + let mut prices = Vec::new(e); + let resolution = settings::get_resolution(e) as u64; + + //limit the number of returned records to 20 + if records > PRICE_RECORDS_LIMIT { + return None; + } + + //expected timestamp for the oldest record to fetch based on the requested records count + let lower_boundary = timestamp - resolution * records as u64; + //last timestamp included in the response + let mut last_included = timestamp; + //continue to iterate until the last record with ts<=lower_boundary found + //(required for further interpolation if the value at lower_boundary is not available) + while last_included > lower_boundary { + //TODO: Load `history_map` and `cache` once outside the loop + //invoke price fetch callback for each record + if let Some(price) = retrieve_asset_price_data(e, asset_index, timestamp) { + prices.push_back(price); + last_included = timestamp; + } + timestamp -= resolution; //walk back in time + if timestamp < resolution { + break; //reached 0 timestamp - never happens in real life + } + } + + if prices.is_empty() { + None + } else { + Some(prices) + } +} + +// Get cached records from the instance storage +fn load_price_records_cache(e: &Env) -> Option> { + e.storage().instance().get(&CACHE_KEY) +} + +// Update price in legacy format (deprecated) +pub fn store_price_v1(e: &Env, updates: Vec, timestamp: u64, ledgers_to_live: u32) { + //iterate over the updates + for (i, price) in updates.iter().enumerate() { + //ignore zero prices + if price == 0 { + continue; + } + let asset = i as u8; + + //build key for price record + let data_key = format_price_key_v1(asset, timestamp); + //store new price + let temp_storage = e.storage().temporary(); + temp_storage.set(&data_key, &price); + if ledgers_to_live > 16 { + //16 ledgers is the minimum extension period + temp_storage.extend_ttl(&data_key, ledgers_to_live, ledgers_to_live) + } + } +} + +// Load price in legacy format (deprecated) +pub fn get_price_v1(e: &Env, asset: u8, timestamp: u64) -> Option { + //load the price from temporary storage + e.storage() + .temporary() + .get(&format_price_key_v1(asset, timestamp)) +} + +// (deprecated) +fn format_price_key_v1(asset: u8, timestamp: u64) -> u128 { + (timestamp as u128) << 64 | asset as u128 +} + +// Div+floor with a specified precision +pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> Option { + if dividend <= 0 || divisor <= 0 { + return None; + } + let ashift = core::cmp::min(38 - dividend.ilog10(), decimals); + let bshift = core::cmp::max(decimals - ashift, 0); + + let mut vdividend = dividend; + let mut vdivisor = divisor; + if ashift > 0 { + let svdividend = vdividend.checked_mul(10_i128.pow(ashift)); + if svdividend.is_none() { + return None; + } + vdividend = svdividend?; + } + if bshift > 0 { + vdivisor /= 10_i128.pow(bshift); + } + if vdivisor <= 0 { + return None; + } + Some(vdividend / vdivisor) +} diff --git a/oracle/src/protocol.rs b/oracle/src/protocol.rs new file mode 100644 index 0000000..f94dbde --- /dev/null +++ b/oracle/src/protocol.rs @@ -0,0 +1,54 @@ +use crate::timestamps; +use soroban_sdk::Env; + +//current protocol version +pub const CURRENT_PROTOCOL: u32 = 2; + +//storage keys +const UPDATE_TS_KEY: &str = "protocol_update"; +const PROTOCOL_KEY: &str = "protocol"; + +// Load current protocol version +#[inline(always)] +pub fn get_protocol_version(e: &Env) -> u32 { + e.storage().instance().get(&PROTOCOL_KEY).unwrap_or(1) +} + +// Set current protocol version +#[inline(always)] +pub fn set_protocol_version(e: &Env, protocol: u32) { + e.storage().instance().set(&PROTOCOL_KEY, &protocol); +} + +// Check whether the oracle already uses the latest protocol version and if not - schedule the upgrade +pub fn at_latest_protocol_version(e: &Env) -> bool { + //load current protocol version + let protocol = get_protocol_version(e); + //already at the latest version + if protocol == CURRENT_PROTOCOL { + return true; + } + schedule_update(e) +} + +// Schedule protocol update +fn schedule_update(e: &Env) -> bool { + //get current ledger ts + let ledger_timestamp = timestamps::ledger_timestamp(&e); + let scheduled_update_ts = e.storage().instance().get(&UPDATE_TS_KEY).unwrap_or(0); + if scheduled_update_ts == 0 { + set_protocol_upgrade_timestamp(e, ledger_timestamp); //set update timestamp to now if not set + return false; + } + //upgrade protocol to current version if the upgrade timestamp is older than 1 day + if scheduled_update_ts + timestamps::days_to_milliseconds(1) < ledger_timestamp { + set_protocol_version(e, CURRENT_PROTOCOL); + set_protocol_upgrade_timestamp(e, 0); // reset update timestamp + return true; //now we are at the latest protocol version + } + false +} + +fn set_protocol_upgrade_timestamp(e: &Env, timestamp: u64) { + e.storage().instance().set(&UPDATE_TS_KEY, ×tamp); +} diff --git a/oracle/src/settings.rs b/oracle/src/settings.rs new file mode 100644 index 0000000..596c9b0 --- /dev/null +++ b/oracle/src/settings.rs @@ -0,0 +1,91 @@ +use crate::types::{Asset, Error, FeeConfig}; +use soroban_sdk::Env; + +const RETENTION_PERIOD_KEY: &str = "period"; +const BASE_KEY: &str = "base_asset"; +const DECIMALS_KEY: &str = "decimals"; +const RESOLUTION_KEY: &str = "resolution"; +const RETENTION_KEY: &str = "retention"; +const CACHE_SIZE_KEY: &str = "cache_size"; + +#[inline] +pub fn init( + e: &Env, + base: &Asset, + decimals: u32, + resolution: u32, + history_retention_period: u64, + cache_size: u32, + fee_config: &FeeConfig, +) { + //do not allow to initialize more than once + if e.storage().instance().has(&RETENTION_PERIOD_KEY) { + e.panic_with_error(Error::AlreadyInitialized); + } + let instance = e.storage().instance(); + //initialized only once and cannot be changed in the future + instance.set(&BASE_KEY, base); + instance.set(&DECIMALS_KEY, &decimals); + set_resolution(e, resolution); + set_history_retention_period(e, history_retention_period); + set_cache_size(e, cache_size); + set_fee_config(e, fee_config); +} + +#[inline] +pub fn get_base_asset(e: &Env) -> Asset { + e.storage().instance().get(&BASE_KEY).unwrap() +} + +#[inline] +pub fn get_decimals(e: &Env) -> u32 { + e.storage().instance().get(&DECIMALS_KEY).unwrap() +} + +#[inline] +pub fn get_resolution(e: &Env) -> u32 { + e.storage().instance().get(&RESOLUTION_KEY).unwrap() +} + +#[inline] +pub fn set_resolution(e: &Env, resolution: u32) { + e.storage().instance().set(&RESOLUTION_KEY, &resolution) +} + +#[inline] +pub fn get_history_retention_period(e: &Env) -> u64 { + e.storage() + .instance() + .get(&RETENTION_PERIOD_KEY) + .unwrap_or_default() +} + +#[inline] +pub fn set_history_retention_period(e: &Env, retention_period: u64) { + e.storage() + .instance() + .set(&RETENTION_PERIOD_KEY, &retention_period); +} + +#[inline] +pub fn get_cache_size(e: &Env) -> u32 { + e.storage().instance().get(&CACHE_SIZE_KEY).unwrap_or(2) +} + +#[inline] +pub fn set_cache_size(e: &Env, cache_size: u32) { + e.storage().instance().set(&CACHE_SIZE_KEY, &cache_size); +} + +#[inline] +pub fn set_fee_config(e: &Env, fee_config: &FeeConfig) { + e.storage().instance().set(&RETENTION_KEY, &fee_config); +} + +#[inline] +pub fn get_fee_config(e: &Env) -> FeeConfig { + e.storage() + .instance() + .get(&RETENTION_KEY) + .unwrap_or_else(|| FeeConfig::None) +} diff --git a/oracle/src/tests/fetch_prices_tests.rs b/oracle/src/tests/fetch_prices_tests.rs new file mode 100644 index 0000000..b25343d --- /dev/null +++ b/oracle/src/tests/fetch_prices_tests.rs @@ -0,0 +1,99 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; +use alloc::string::ToString; + +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env, Symbol, Vec}; + +use test_case::test_case; + +use crate::testutils::set_ledger_timestamp; +use crate::*; + +fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { + let mut mask = [0u8; 32]; + for (asset_index, price) in updates.iter().enumerate() { + if price > 0 { + let (byte, bitmask) = mapping::resolve_period_update_mask_position(asset_index as u32); + let i = byte as usize; + let bytemask = mask[i] | bitmask; + mask[i] = bytemask + } + } + Bytes::from_array(e, &mask) +} + +fn generate_updates(env: &Env, assets: &Vec, price: i128) -> types::PriceUpdate { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + types::PriceUpdate { + prices: updates, + mask, + } +} + +#[test_case(600_000, 8, 600_000, 2; "skipped 5 rounds")] +#[test_case(600_000, 30, 600_000, 2; "skipped 30 rounds")] +fn store_prices_test( + first_timestamp: u64, + rounds_gap: u64, + expected_first_price_ts: u64, + expected_prices_count: u32, +) { + let e = Env::default(); + + set_ledger_timestamp(&e, 600_000); + + let mut assets = Vec::new(&e); + for i in 0..255 { + assets.push_back(types::Asset::Other(Symbol::new( + &e, + &("ASSET_".to_string() + &i.to_string()), + ))); + } + //register asset contract just to have storage + let contract_id = e.register_stellar_asset_contract_v2(Address::generate(&e)); + e.as_contract(&contract_id.address(), || { + let timeframe: u64 = 300_000; + settings::set_resolution(&e, timeframe as u32); + protocol::set_protocol_version(&e, 2); + + assets::add_assets(&e, assets.clone(), 180); + fn set_price(e: &Env, timestamp: u64, assets: &Vec) { + let updates = generate_updates(e, &assets, 100); + let asset_prices = prices::extract_update_record_prices(e, &updates, assets.len()); + let legacy_update = updates.prices.clone(); + //store history timestamps for all assets + prices::update_history_mask(e, &asset_prices, timestamp); + prices::store_prices(e, updates, timestamp, legacy_update); + } + + let mut timestamp = first_timestamp; + set_price(&e, timestamp, &assets); + timestamp += timeframe * rounds_gap; + set_price(&e, timestamp, &assets); + + set_ledger_timestamp(&e, timestamp / 1000); + + let prices = prices::load_prices(&e, 0, 3); + assert_ne!(prices, None); + let prices = prices.unwrap(); + assert_eq!(prices.len(), expected_prices_count); + assert_eq!(prices.get_unchecked(0).timestamp, timestamp / 1000); //latest price + assert_eq!( + prices + .get(1) + .unwrap_or_else(|| types::PriceData { + price: 0, + timestamp: 0 + }) + .timestamp, + expected_first_price_ts / 1000 + ); + }); + + e.cost_estimate().budget().print(); +} diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs new file mode 100644 index 0000000..cbcd4ef --- /dev/null +++ b/oracle/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod fetch_prices_tests; +mod prices_tests; +mod util_tests; diff --git a/oracle/src/tests/prices_tests.rs b/oracle/src/tests/prices_tests.rs new file mode 100644 index 0000000..3c33130 --- /dev/null +++ b/oracle/src/tests/prices_tests.rs @@ -0,0 +1,93 @@ +#![cfg(test)] +extern crate std; + +use crate::testutils::generate_update_record_mask; +use crate::testutils::set_ledger_timestamp; +use crate::{price_oracle, prices, types}; +use soroban_sdk::{testutils::Address as _, vec, Address, Env, Symbol}; +use test_case::test_case; + +#[should_panic] +#[test_case(0; "zero timestamp")] +#[test_case(1_000_000; "timestamp greater than current ledger")] +#[test_case(900_001 ; "unaligned timestamp")] +#[test_case(600_000 ; "valid timestamp same as last")] +#[test_case(300_000 ; "valid timestamp less than last")] +fn invalid_timestamp_update_test(ts: u64) { + let e = Env::default(); + //register contract to have storage available + let contract = e.register_stellar_asset_contract_v2(Address::generate(&e)); + e.mock_all_auths(); + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::config( + &e, + types::ConfigData { + admin: Address::generate(&e), + history_retention_period: 86_400_000, + assets: vec![&e, types::Asset::Other(Symbol::new(&e, "ASSET_A"))], + base_asset: types::Asset::Other(Symbol::new(&e, "BASE_ASSET")), + decimals: 8, + resolution: 300_000, + cache_size: 10, + fee_config: types::FeeConfig::None, + }, + 100, + ); + prices::set_last_timestamp(&e, 600_000); + set_ledger_timestamp(&e, 9001); + }); + + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::set_price( + &e, + types::PriceUpdate { + prices: vec![&e, 12345678i128], + mask: generate_update_record_mask( + &e, + &std::collections::VecDeque::from([12345678i128]), + ), + }, + ts, + ); + }); +} + +#[test] +fn single_price_update_test() { + let e = Env::default(); + //register contract to have storage available + let contract = e.register_stellar_asset_contract_v2(Address::generate(&e)); + e.mock_all_auths(); + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::config( + &e, + types::ConfigData { + admin: Address::generate(&e), + history_retention_period: 86_400_000, + assets: vec![&e, types::Asset::Other(Symbol::new(&e, "ASSET_A"))], + base_asset: types::Asset::Other(Symbol::new(&e, "BASE_ASSET")), + decimals: 8, + resolution: 300_000, + cache_size: 10, + fee_config: types::FeeConfig::None, + }, + 100, + ); + prices::set_last_timestamp(&e, 600_000); + set_ledger_timestamp(&e, 9001); + }); + + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::set_price( + &e, + types::PriceUpdate { + prices: vec![&e, 12345678i128], + mask: generate_update_record_mask( + &e, + &std::collections::VecDeque::from([12345678i128]), + ), + }, + 900_000, + ); + }); +} diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs new file mode 100644 index 0000000..443aead --- /dev/null +++ b/oracle/src/tests/util_tests.rs @@ -0,0 +1,105 @@ +#![cfg(test)] +extern crate std; + +use soroban_sdk::{log, testutils::Address as _, Address, Bytes, Env, Vec}; +use test_case::test_case; + +use crate::{mapping, prices, settings, testutils::generate_update_record_mask}; + +#[test_case(1, 0, 14)] +#[test_case(0, 1, 14)] +#[test_case(0, 0, 14)] +#[test_case(-1i128, 0, 14)] +#[test_case(0, -1i128, 14)] +#[test_case(-1, -1, 14)] +#[test_case(1000000000000000000000, 5, 18)] +#[test_case(5000000000000000000000000000000, 10000000000, 14)] +#[test_case(i128::MAX, 1, 14)] +fn fixed_div_floor_failed_tests(a: i128, b: i128, decimals: u32) { + let result = prices::fixed_div_floor(a.clone(), b, decimals); + assert!(result.is_none()); +} + +#[test_case(154467226919499, 133928752749774, 14, 115335373284703)] +#[test_case(i128::MAX / 100, 231731687303715884105728, 14, 734216306110962248249052545)] +#[test_case(231731687303715884105728, i128::MAX / 100, 14, 13)] +#[test_case(i128::MAX, i128::MAX, 14, 100000000000000)] +fn fixed_div_floor_success_tests(a: i128, b: i128, decimals: u32, expected: i128) { + let result = prices::fixed_div_floor(a.clone(), b, decimals); + assert_eq!(result.unwrap(), expected); +} + +#[test] +fn position_encoding_bitmask_test() { + let e = Env::default(); + let mut mask = Bytes::new(&e); + let total_assets = 5; + let mut total_periods = 130; + for period in 0..total_periods { + let mut updates = Vec::new(&e); + for asset_index in 0..total_assets { + let price = match asset_index > 0 && (period % asset_index == 0) { + true => 1, + _ => 0, + }; + updates.push_back(price); + } + mask = mapping::update_history_mask(mask, &updates, 1); + } + log!(&e, "entire mask", mask); + + //check previous prices + let period_diff = if total_periods > 255 { + total_periods - 255 + } else { + 0 + }; + total_periods = std::cmp::min(total_periods, 255); + for period in 0..total_periods { + let check_period = total_periods - period - 1; + for asset_index in 0..total_assets { + let expected = asset_index > 0 && ((period + period_diff) % asset_index == 0); + let found = mapping::check_history_updated(&mask, asset_index, check_period); + assert_eq!(found, expected); + } + } +} + +#[test] +fn update_record_bitmask_test() { + let e = Env::default(); + let iterations = 70; + + let mut updates = std::collections::VecDeque::from([0i128; 254]); + for i in 0..iterations { + for asset_index in 0..updates.len() { + let price = match i & asset_index == 0 { + true => 1, + _ => 0, + }; + updates[asset_index] = price; + } + let mask = generate_update_record_mask(&e, &updates); + //log!(&e, "entire mask", mask); + for (asset_index, price) in updates.iter().enumerate() { + assert_eq!( + mapping::check_period_updated(&mask, asset_index as u32), + price > &0 + ); + } + } +} + +#[test_case(0, 0; "zero timestamp")] +#[test_case(600_000, 600_000; "aligned timestamp")] +#[test_case(623_456, 600_000; "non-aligned timestamp")] +fn normalize_timestamp_test(input: u64, expected: u64) { + let e = Env::default(); + //register contract to have storage available + let contract = e.register_stellar_asset_contract_v2(Address::generate(&e)); + e.as_contract(&contract.address(), || { + settings::set_resolution(&e, 300_000); + let normalized = crate::timestamps::normalize(&e, input); + assert_eq!(normalized, expected); + }); +} diff --git a/oracle/src/testutils/constants.rs b/oracle/src/testutils/constants.rs new file mode 100644 index 0000000..5dfe82d --- /dev/null +++ b/oracle/src/testutils/constants.rs @@ -0,0 +1,2 @@ +pub const RESOLUTION: u32 = 300_000; +pub const DECIMALS: u32 = 14; diff --git a/oracle/src/testutils/env.rs b/oracle/src/testutils/env.rs new file mode 100644 index 0000000..57fe0d3 --- /dev/null +++ b/oracle/src/testutils/env.rs @@ -0,0 +1,19 @@ +use soroban_sdk::{ + testutils::{Ledger, LedgerInfo}, + token::StellarAssetClient, + Address, +}; + +pub fn register_token<'a>(env: &soroban_sdk::Env, admin: &Address) -> StellarAssetClient<'a> { + let asset_contract = env.register_stellar_asset_contract_v2(admin.clone()); + let fee_asset = asset_contract.address(); + StellarAssetClient::new(&env, &fee_asset) +} + +pub fn set_ledger_timestamp(env: &soroban_sdk::Env, timestamp: u64) { + let ledger_info = env.ledger().get(); + env.ledger().set(LedgerInfo { + timestamp, + ..ledger_info + }); +} diff --git a/oracle/src/testutils/generators.rs b/oracle/src/testutils/generators.rs new file mode 100644 index 0000000..44f89d0 --- /dev/null +++ b/oracle/src/testutils/generators.rs @@ -0,0 +1,120 @@ +extern crate alloc; +extern crate std; + +use super::constants::RESOLUTION; +use crate::{ + mapping, + types::{Asset, ConfigData, FeeConfig, PriceUpdate}, +}; +use alloc::string::ToString; +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env, Symbol, Vec}; +use std::collections::VecDeque; + +pub fn generate_update_record_mask(e: &Env, updates: &VecDeque) -> Bytes { + let mut mask = [0u8; 32]; + for (asset, price) in updates.iter().enumerate() { + if price > &0 { + let (byte, bitmask) = mapping::resolve_period_update_mask_position(asset as u32); + let i = byte as usize; + let bytemask = mask[i] | bitmask; + mask[i] = bytemask + } + } + Bytes::from_array(e, &mask) +} + +pub fn generate_test_env() -> (ConfigData, Env) { + let env = Env::default(); + let admin = Address::generate(&env); + let config = ConfigData { + admin: admin.clone(), + history_retention_period: (100 * RESOLUTION).into(), + assets: generate_assets(&env, 10, 0), + base_asset: Asset::Stellar(Address::generate(&env)), + decimals: 14, + resolution: RESOLUTION, + cache_size: 0, + fee_config: FeeConfig::None, + }; + (config, env) +} + +pub fn generate_updates( + env: &Env, + assets: &Vec, + price: i128, +) -> (PriceUpdate, VecDeque) { + let mut updates = VecDeque::new(); + let mut filtered_price = Vec::new(&env); + for _ in assets.iter() { + updates.push_back(price); + filtered_price.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + ( + PriceUpdate { + prices: filtered_price, + mask, + }, + updates, + ) +} + +fn get_random_bool() -> bool { + //TODO: rewrite to use deterministic algo + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let random_bool = (nanos % 200) == 0; + random_bool +} + +pub fn generate_random_updates( + env: &Env, + assets: &Vec, + price: i128, +) -> (PriceUpdate, VecDeque) { + let mut updates = VecDeque::new(); + let mut filtered_price = Vec::new(&env); + let mut has_price = false; + for _ in assets.iter() { + //ensure that at least one price is set + let price = if (has_price || updates.len() < assets.len() as usize - 1) && get_random_bool() + { + 0 + } else { + price + }; + updates.push_back(price); + if price > 0 { + filtered_price.push_back(price); + } + if price > 0 { + has_price = true; + } + } + let mask = generate_update_record_mask(env, &updates); + ( + PriceUpdate { + prices: filtered_price, + mask, + }, + updates, + ) +} + +pub fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { + let mut assets = Vec::new(&e); + for i in 0..count { + if i % 2 == 0 { + assets.push_back(Asset::Stellar(Address::generate(&e))); + } else { + assets.push_back(Asset::Other(Symbol::new( + e, + &("ASSET_".to_string() + &(start_index + i as u32).to_string()), + ))); + } + } + assets +} diff --git a/oracle/src/testutils/helpers.rs b/oracle/src/testutils/helpers.rs new file mode 100644 index 0000000..5b5fccd --- /dev/null +++ b/oracle/src/testutils/helpers.rs @@ -0,0 +1,9 @@ +use super::constants::DECIMALS; + +pub fn convert_to_seconds(timestamp: u64) -> u64 { + timestamp / 1000 +} + +pub fn normalize_price(price: i128) -> i128 { + price * 10i128.pow(DECIMALS) +} diff --git a/oracle/src/testutils/macros.rs b/oracle/src/testutils/macros.rs new file mode 100644 index 0000000..a80585f --- /dev/null +++ b/oracle/src/testutils/macros.rs @@ -0,0 +1,19 @@ +#[macro_export] +macro_rules! init_contract_with_admin { + ($contract_type:path, $client_type:path, $mock_auth:expr) => {{ + let (init_data, env) = oracle::testutils::generate_test_env(); + + oracle::testutils::set_ledger_timestamp(&env, 900); + + let contract_id = env.register($contract_type, ()); + let client = <$client_type>::new(&env, &contract_id); + + client.mock_all_auths().config(&init_data); + + if $mock_auth { + env.mock_all_auths(); + } + + (env, client, init_data) + }}; +} diff --git a/oracle/src/testutils/mod.rs b/oracle/src/testutils/mod.rs new file mode 100644 index 0000000..92b83ff --- /dev/null +++ b/oracle/src/testutils/mod.rs @@ -0,0 +1,10 @@ +pub mod constants; +pub mod env; +pub mod generators; +pub mod helpers; +pub mod macros; + +pub use constants::*; +pub use env::*; +pub use generators::*; +pub use helpers::*; diff --git a/oracle/src/timestamps.rs b/oracle/src/timestamps.rs new file mode 100644 index 0000000..4319838 --- /dev/null +++ b/oracle/src/timestamps.rs @@ -0,0 +1,26 @@ +use crate::settings; +use soroban_sdk::Env; + +// Normalize timestamp trimming it to the timeframe resolution defined in settings +pub fn normalize(e: &Env, value: u64) -> u64 { + let timeframe = settings::get_resolution(e) as u64; + if value == 0 || timeframe == 0 { + return 0; + } + (value / timeframe) * timeframe +} + +// Whether the timestamp is valid +pub fn is_valid(e: &Env, value: u64) -> bool { + value > 0 && value == normalize(e, value) +} + +// Convert days to milliseconds +pub fn days_to_milliseconds(days: u32) -> u64 { + (days as u64) * 24 * 60 * 60 * 1000 +} + +// Get timestamp for current ledger +pub fn ledger_timestamp(e: &Env) -> u64 { + e.ledger().timestamp() * 1000 //convert to milliseconds +} diff --git a/oracle/src/types.rs b/oracle/src/types.rs new file mode 100644 index 0000000..3a001df --- /dev/null +++ b/oracle/src/types.rs @@ -0,0 +1,83 @@ +use soroban_sdk::{contracterror, contracttype, Address, Bytes, Symbol, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Quoted symbol descriptor +pub enum Asset { + Stellar(Address), // Stellar asset contract address + Other(Symbol), // Symbol for all other external price sources +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Contract configuration parameters +pub struct ConfigData { + // Admin address + pub admin: Address, + // Price history retention period (in milliseconds) + pub history_retention_period: u64, + // List of supported assets + pub assets: Vec, + // Base asset + pub base_asset: Asset, + // Number of decimals for price records + pub decimals: u32, + // History timeframe resolution (in milliseconds) + pub resolution: u32, + // Number of rounds held in instance cache + pub cache_size: u32, + // Contract retention config + pub fee_config: FeeConfig, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Oracle retention config containing fee asset and daily retention fee amount +pub enum FeeConfig { + Some((Address, i128)), + None, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Asset price data at specific timestamp +pub struct PriceData { + // Price stored with configured decimals places + pub price: i128, + // Record timestamp + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Oracle price data at specific timestamp +pub struct PriceUpdate { + // Prices for updated assets that have been updated + pub prices: Vec, + // Bitmap of updated asset positions + pub mask: Bytes, +} + +#[contracterror] +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +// Standard contract errors +pub enum Error { + // Contract already initialized + AlreadyInitialized = 0, + // Caller is not authorized to perform operation + Unauthorized = 1, + // Config asset list doesn't contain persistent asset + AssetMissing = 2, + // Asset already exists in supported assets list + AssetAlreadyExists = 3, + // Config is invalid + InvalidConfig = 4, + // Price timestamp is invalid + InvalidTimestamp = 5, + // Maximum assets limit reached + AssetLimitExceeded = 6, + // Amount is invalid (negative or zero). + InvalidAmount = 7, + // Prices update is invalid + InvalidPricesUpdate = 8, +} diff --git a/pulse-contract/Cargo.toml b/pulse-contract/Cargo.toml new file mode 100644 index 0000000..b85e49d --- /dev/null +++ b/pulse-contract/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "reflector-pulse-contract" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +oracle = { path = "../oracle" } +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" +oracle = { path = "../oracle", features = ["testutils"] } \ No newline at end of file diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs new file mode 100644 index 0000000..2964384 --- /dev/null +++ b/pulse-contract/src/lib.rs @@ -0,0 +1,305 @@ +#![no_std] + +use oracle::price_oracle::PriceOracleContractBase; +use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; + +const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months +#[contract] +pub struct PulseOracleContract; + +#[contractimpl] +impl PulseOracleContract { + // Return base asset price is reported in + // + // # Returns + // + // Oracle base asset + pub fn base(e: &Env) -> Asset { + PriceOracleContractBase::base(e) + } + + // Return number of decimal places used to represent price for all quoted assets + // + // # Returns + // + // Number of decimals places in quoted prices + pub fn decimals(e: &Env) -> u32 { + PriceOracleContractBase::decimals(e) + } + + // Return default tick period timeframe (in seconds) + // + // # Returns + // + // Price feed resolution (in seconds) + pub fn resolution(e: &Env) -> u32 { + PriceOracleContractBase::resolution(e) + } + + // Return historical records retention period (in seconds) + // + // # Returns + // + // History retention period (in seconds) + pub fn history_retention_period(e: &Env) -> Option { + PriceOracleContractBase::history_retention_period(e) + } + + // Return price records cache size + // + // # Returns + // + // Price records cache size + pub fn cache_size(e: &Env) -> u32 { + PriceOracleContractBase::cache_size(e) + } + + // Return all quoted assets + // + // # Returns + // + // Quoted assets + pub fn assets(e: &Env) -> Vec { + PriceOracleContractBase::assets(e) + } + + // Return most recent price update timestamp in seconds + // + // # Returns + // + // Timestamp of last recorded price update + pub fn last_timestamp(e: &Env) -> u64 { + PriceOracleContractBase::last_timestamp(e) + } + + // Return current contract protocol version + // + // # Returns + // + // Contract protocol version + pub fn version(e: &Env) -> u32 { + PriceOracleContractBase::version(e) + } + + // Return expiration date for a given asset + // + // # Arguments + // + // * `asset` - Quoted asset + // + // # Returns + // + // Asset expiration timestamp or None if asset is not supported + // + // # Panics + // + // Panics if asset is not supported + pub fn expires(e: &Env, asset: Asset) -> Option { + PriceOracleContractBase::expires(e, asset) + } + + // Estimates amount of fee tokens required to extend the asset retention config for a given time + // + // # Arguments + // + // * `period` - Desired retention period extension (in seconds) + // + // # Returns + // + // Fee asset and estimated amount required for the fee bump + // + // # Panics + // + // Panics if the asset is not supported or if retention config is malformed/missing + pub fn estimate_retention_cost(e: &Env, period: u64) -> (Address, i128) { + PriceOracleContractBase::estimate_retention_cost(e, period) + } + + // Extends the asset expiration date by a given amount of tokens. + // + // # Arguments + // + // * `sponsor` - Address that sponsors price feed + // * `asset` - Quoted asset + // * `amount` - Amount of tokens to burn for extending the expiration date + // + // # Returns + // + // Current asset expiration timestamp (in seconds) + // + // # Panics + // + // Panics if the asset is not supported or if retention config is malformed/missing + pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) -> u64 { + PriceOracleContractBase::extend_asset_ttl( + e, + sponsor, + asset, + amount, + INITIAL_EXPIRATION_PERIOD, + ) + } + + // Return the fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn fee_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::fee_config(e) + } + + // Return contract admin address + // + // # Returns + // + // Contract admin account address + pub fn admin(e: &Env) -> Option
{ + PriceOracleContractBase::admin(e) + } + + // Returns price for an asset at specific timestamp + // + // # Arguments + // + // * `asset` - Asset to quote + // * `timestamp` - Timestamp in seconds + // + // # Returns + // + // Price record for given asset at given timestamp or None if not found + pub fn price(e: &Env, asset: Asset, timestamp: u64) -> Option { + PriceOracleContractBase::price(e, asset, timestamp) + } + + // Returns most recent price for an asset + // + // # Arguments + // + // * `asset` - Asset to quote + // + // # Returns + // + // Most recent price for given asset or None if asset is not supported + pub fn lastprice(e: &Env, asset: Asset) -> Option { + PriceOracleContractBase::lastprice(e, asset) + } + + // Return last N price records for given asset + // + // # Arguments + // + // * `asset` - Asset to quote + // * `records` - Number of records to return + // + // # Returns + // + // Prices for given asset or None if asset is not supported + pub fn prices(e: &Env, asset: Asset, records: u32) -> Option> { + PriceOracleContractBase::prices(e, asset, records) + } + + /* Admin section */ + + // Initializes contract configuration + // Requires admin authorization + // # Arguments + // + // * `config` - Configuration parameters + // + // # Panics + // + // Panics if not authorized or if contract is already initialized + pub fn config(e: &Env, config: ConfigData) { + PriceOracleContractBase::config(e, config, INITIAL_EXPIRATION_PERIOD); + } + + // Update contract cache size + // Requires admin authorization + // + // # Arguments + // + // * `cache_size` - New cache size (number of rounds stored in cache) + // + // # Panics + // + // Panics if not authorized + pub fn set_cache_size(e: &Env, cache_size: u32) { + PriceOracleContractBase::set_cache_size(e, cache_size); + } + + // Adds given assets to the contract quoted assets list + // Requires admin authorization + // + // # Arguments + // + // * `assets` - Assets to add + // + // # Panics + // + // Panics if not authorized, any of the assets were added earlier, or assets limit exceeded + pub fn add_assets(e: &Env, assets: Vec) { + PriceOracleContractBase::add_assets(e, assets, INITIAL_EXPIRATION_PERIOD); + } + + // Sets history retention period for the prices + // Requires admin authorization + // + // # Arguments + // + // * `period` - History retention period (in milliseconds) + // + // # Panics + // + // Panics if not authorized + pub fn set_history_retention_period(e: &Env, period: u64) { + PriceOracleContractBase::set_history_retention_period(e, period); + } + + // Set fee token address and daily price feed retainer fee amount + // Requires admin authorization + // + // # Arguments + // + // * `fee_config` - Fee token address and fee amount + // + // # Panics + // + // Panics if not authorized or not initialized yet + pub fn set_fee_config(e: &Env, fee_config: FeeConfig) { + PriceOracleContractBase::set_fee_config(e, fee_config, INITIAL_EXPIRATION_PERIOD); + } + + // Record new price feed history snapshot + // Requires admin authorization + // + // # Arguments + // + // * `updates` - Price feed snapshot + // * `timestamp` - History snapshot timestamp (in milliseconds) + // + // # Panics + // + // Panics if not authorized or price snapshot record is invalid + pub fn set_price(e: &Env, updates: PriceUpdate, timestamp: u64) { + PriceOracleContractBase::set_price(e, updates, timestamp); + } + + // Update contract source code + // Requires admin authorization + // + // # Arguments + // + // * `wasm_hash` - WASM hash of the contract source code + // + // # Panics + // + // Panics if not authorized + pub fn update_contract(e: &Env, wasm_hash: BytesN<32>) { + PriceOracleContractBase::update_contract(e, wasm_hash); + } +} + +#[cfg(test)] +mod tests; diff --git a/pulse-contract/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs new file mode 100644 index 0000000..1f0ca8d --- /dev/null +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -0,0 +1,308 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use alloc::string::ToString; +use oracle::init_contract_with_admin; +use oracle::testutils::{ + convert_to_seconds, generate_assets, generate_update_record_mask, generate_updates, + normalize_price, DECIMALS, RESOLUTION, +}; +use oracle::types::{Asset, FeeConfig, PriceUpdate}; +use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{Address, Event, Symbol, TryIntoVal, Vec}; + +use crate::{PulseOracleContract, PulseOracleContractClient}; + +#[test] +fn init_test() { + let (_env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let address = client.admin(); + assert_eq!(address.unwrap(), init_data.admin.clone()); + + let base = client.base(); + assert_eq!(base, init_data.base_asset); + + let resolution = client.resolution(); + assert_eq!(resolution, convert_to_seconds(RESOLUTION.into()) as u32); + + let period = client.history_retention_period().unwrap(); + assert_eq!( + period, + convert_to_seconds(init_data.history_retention_period) + ); + + let decimals = client.decimals(); + assert_eq!(decimals, DECIMALS); + + let assets = client.assets(); + assert_eq!(assets, init_data.assets); +} + +#[test] +fn set_price_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + let timestamp = 600_000; + let updates = generate_updates(&env, &assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); + + //build expected event + let expected_event = oracle::events::UpdateEvent { + timestamp: 600_000, + update_data: { + let mut upd = Vec::new(&env); + for asset in assets.iter() { + let asset_val = match asset { + Asset::Stellar(address) => address.to_val(), + Asset::Other(symbol) => symbol.to_val(), + }; + upd.push_back((asset_val, normalize_price(100))); + } + upd + }, + }; + assert_eq!( + env.events().all().events().last().unwrap(), + &expected_event.to_xdr(&env, &client.address) + ); +} + +#[test] +#[should_panic] +fn set_price_zero_timestamp_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + let timestamp = 0; + let updates = generate_updates(&env, &assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); +} + +#[test] +#[should_panic] +fn set_price_invalid_timestamp_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + let timestamp = 600_001; + let updates = generate_updates(&env, &assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); +} + +#[test] +#[should_panic] +fn set_price_future_timestamp_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + let timestamp = 1_200_000; + let updates = generate_updates(&env, &assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); +} + +#[test] +fn add_assets_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = generate_assets(&env, 10, init_data.assets.len() - 1); + + env.mock_all_auths(); + + client.add_assets(&assets); + + let result = client.assets(); + + let mut expected_assets = init_data.assets.clone(); + for asset in assets.iter() { + expected_assets.push_back(asset.clone()); + } + + assert_eq!(result, expected_assets); +} + +#[test] +#[should_panic] +fn add_assets_duplicate_test() { + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let mut assets = Vec::new(&env); + let duplicate_asset = Asset::Other(Symbol::new(&env, &("ASSET_DUPLICATE"))); + assets.push_back(duplicate_asset.clone()); + assets.push_back(duplicate_asset); + + env.mock_all_auths(); + + client.add_assets(&assets); +} + +#[test] +#[should_panic] +fn asset_update_overflow_test() { + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + env.mock_all_auths(); + + let mut assets = Vec::new(&env); + for i in 1..=1000 { + assets.push_back(Asset::Other(Symbol::new( + &env, + &("Asset".to_string() + &i.to_string()), + ))); + } + + client.add_assets(&assets); +} + +#[test] +#[should_panic] +fn price_update_overflow_test() { + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + env.mock_all_auths(); + + let mut raw_prices = std::collections::VecDeque::new(); + for i in 1..=256 { + raw_prices.push_back(normalize_price(i as i128 + 1)); + } + let mask = generate_update_record_mask(&env, &raw_prices); + let update = PriceUpdate { + prices: Vec::from_iter(&env, raw_prices.into_iter()), + mask, + }; + client.set_price(&update, &600_000); +} + +#[test] +fn set_history_retention_period_test() { + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let period = 100_000; + + env.mock_all_auths(); + + client.set_history_retention_period(&period); + + let result = client.history_retention_period().unwrap(); + + assert_eq!(result, convert_to_seconds(period)); +} + +#[test] +fn set_fee_config_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + //emulate old contract state + env.as_contract(&client.address, || { + env.storage().instance().remove(&"retention"); + env.storage().instance().remove(&"expiration"); + }); + + //create fee asset token + let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); + + let fee_config = FeeConfig::Some((fee_asset.address(), 7)); + + client.set_fee_config(&fee_config); //3 days + + let result = client.fee_config(); + assert_ne!(result, FeeConfig::None); + assert_eq!(result, fee_config); + + let asset: Asset = init_data.assets.get_unchecked(0); + + let expires = client.expires(&asset); + assert!(expires.is_some()); + + let sponsor = Address::generate(&env); + let fee_token = StellarAssetClient::new(&env, &fee_asset.address()); + fee_token.mint(&sponsor, &10); + + let symbol_expires = client.expires(&asset).unwrap(); + assert_eq!(symbol_expires, 15552900); // 900s current ledger timestamp + 180 days of initial expiration period + client.extend_asset_ttl(&sponsor, &asset, &10); + //123428571 ms you get for 10 XRF tokens + assert_eq!( + client.expires(&asset).unwrap(), + symbol_expires + 123428571 / 1000 + ); + + let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); + assert_eq!(fee_token_balance, 0); +} + +#[test] +fn authorization_successful_test() { + let (env, client, config_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let period: u64 = 100; + //set prices for assets + client + .mock_auths(&[MockAuth { + address: &config_data.admin, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_history_retention_period", + args: Vec::from_array(&env, [period.clone().try_into_val(&env).unwrap()]), + sub_invokes: &[], + }, + }]) + .set_history_retention_period(&period); +} + +#[test] +#[should_panic] +fn authorization_failed_test() { + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + let account = Address::generate(&env); + + let period: u64 = 100; + //set prices for assets + client + .mock_auths(&[MockAuth { + address: &account, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "set_period", + args: Vec::from_array(&env, [period.clone().try_into_val(&env).unwrap()]), + sub_invokes: &[], + }, + }]) + .set_history_retention_period(&period); +} diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs new file mode 100644 index 0000000..ac7cc1d --- /dev/null +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -0,0 +1,212 @@ +#![cfg(test)] + +use oracle::init_contract_with_admin; +use oracle::testutils::{ + convert_to_seconds, generate_random_updates, generate_updates, normalize_price, register_token, + set_ledger_timestamp, +}; +use oracle::types::{FeeConfig, PriceData}; +use soroban_sdk::{testutils::Address as _, Address}; +use test_case::test_case; + +extern crate std; +use std::{collections::VecDeque, println}; + +use crate::{PulseOracleContract, PulseOracleContractClient}; + +#[test] +fn version_test() { + let (_, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + let result = client.version(); + let version = env!("CARGO_PKG_VERSION") + .split(".") + .next() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(result, version); +} + +#[test] +fn cache_size_test() { + let (_, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let mut result = client.cache_size(); + + assert_eq!(result, 0); + + client.set_cache_size(&5); + + result = client.cache_size(); + + assert_eq!(result, 5); +} + +#[test] +fn last_timestamp_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + let mut result = client.last_timestamp(); + + assert_eq!(result, 0); + + let timestamp = 600_000; + let updates = generate_updates(&env, &assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); + + result = client.last_timestamp(); + + assert_eq!(result, convert_to_seconds(600_000)); +} + +#[test] +fn lastprice_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = &init_data.assets; + + let timestamp = 600_000; + let updates = generate_updates(&env, assets, normalize_price(100)); + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&updates.0, ×tamp); + + let fee_asset = env + .register_stellar_asset_contract_v2(init_data.admin.clone()) + .address(); + let fee_config = FeeConfig::Some((fee_asset.clone(), 1_000_000)); + client.set_fee_config(&fee_config); + + //get price for the first asset + let price = client + .lastprice(&init_data.assets.first_unchecked()) + .unwrap(); + assert_eq!(price.price, normalize_price(100)); + assert_eq!(price.timestamp, convert_to_seconds(timestamp)); +} + +#[test_case(255, "gap 255")] +#[test_case(256, "gap 256")] +#[test_case(257, "gap 257")] +#[test_case(1000, "gap 1000")] +fn prices_update_test(gap: u64, _description: &str) { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let assets = init_data.assets; + + client.set_cache_size(&3); + + let mut history_prices = VecDeque::new(); + + println!("setting prices..."); + //set more than 256 prices to check that history mask is overwritten correctly + for i in 0..(gap + 256) { + let timestamp = 600_000 + i * 300_000; + + if i < 1 || i > gap { + let updates = generate_random_updates(&env, &assets, normalize_price(100)); + //set prices for assets + client.set_price(&updates.0, ×tamp); + history_prices.push_front((timestamp, Some(updates.1))); + } else { + //simulate time passage without setting prices to create gaps in updates + history_prices.push_front((timestamp, None)); + } + set_ledger_timestamp(&env, timestamp / 1000 + 300); + } + + println!("verifying prices..."); + + //verify + let mut had_gaps = false; + let mut had_prices = false; + let mut iterations = 0; + + for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { + //match price with mask for each asset in update + for (asset_index, asset) in assets.iter().enumerate() { + //get oracle-quoted price + let oracle_price = client.price(&asset, &(timestamp / 1000)); + //get expected price (from generated data) + let expected_price = match updates { + Some(updates) => { + if history_index > 255 { + &0 + } else { + updates.get(asset_index).unwrap() + } + } + None => &0, + }; + if expected_price > &0 { + let price = oracle_price.unwrap_or_else(|| PriceData { + price: 0, + timestamp: 0, + }); + assert_eq!( + price.price, *expected_price, + "asset {} at timestamp {} history index {}", + asset_index, timestamp, history_index + ); + assert_eq!(price.timestamp, convert_to_seconds(*timestamp)); + had_prices = true; + } else { + assert!( + oracle_price.is_none(), + "asset {} at timestamp {}", + asset_index, + timestamp + ); + had_gaps = true; + } + } + iterations += 1; + } + assert!(had_prices); + assert!(had_gaps); + println!("{} iterations", iterations); +} + +#[test] +fn extend_asset_ttl_test() { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + let fee_token = register_token(&env, &init_data.admin); + let fee_config = FeeConfig::Some((fee_token.address.clone(), 1_000_000)); + client.set_fee_config(&fee_config); + + //generate sponsor and mint fee tokens + let sponsor = Address::generate(&env); + fee_token.mint(&sponsor, &10_000_000); + + //get initial expiration + let asset = &init_data.assets.first_unchecked(); + let initial_expiration = client.expires(&asset).unwrap(); + + //estimate bump cost for 10 day (864000 seconds) + let ten_days = 10 * 24 * 60 * 60u64; + let amount = client.estimate_retention_cost(&ten_days); + assert_eq!(amount.0, fee_token.address); + assert_eq!(amount.1, 10_000_000); + + //extend TTL + let ttl = client.extend_asset_ttl(&sponsor, &asset, &amount.1); + assert_eq!(ttl, client.expires(&asset).unwrap()); + + //verify new expiration + assert_eq!(ttl, initial_expiration + 864000); +} diff --git a/pulse-contract/src/tests/integration_tests.rs b/pulse-contract/src/tests/integration_tests.rs new file mode 100644 index 0000000..dd31150 --- /dev/null +++ b/pulse-contract/src/tests/integration_tests.rs @@ -0,0 +1,158 @@ +#![cfg(test)] + +use oracle::init_contract_with_admin; +use oracle::testutils::{generate_updates, set_ledger_timestamp}; +use oracle::types::PriceData; +use test_case::test_case; + +extern crate std; +use std::collections::VecDeque; + +use crate::{PulseOracleContract, PulseOracleContractClient}; + +#[test_case(5, 600, VecDeque::from([]), None; "no updates")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 104)]), Some(()); "price increase below threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 105)]), None; "price increase equal to threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 106)]), None; "price increase above threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 96)]), Some(()); "price drop below threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 5000)]), None; "massive price increase")] +#[test_case(5, 600, VecDeque::from([(2100, 100)]), None; "no history for dev check")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 100)]), Some(()); "no price change")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (12000, 102)]), Some(()); "big gap with recent update")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (12000, 0)]), None; "big gap without recent update")] +#[test_case(0, 600, VecDeque::from([(2100, 100)]), Some(()); "price within max age")] +#[test_case(0, 900, VecDeque::from([(2100, 100), (3000, 0)]), Some(()); "price ts equal to max age")] +#[test_case(0, 100, VecDeque::from([(2100, 100), (2400, 0), (2700, 0), (3000, 0)]), None; "price ts older than max age")] +#[test_case(5, 600, VecDeque::from([(2100, 0), (2400, 0)]), None; "only skipped prices")] +#[test_case(5, 900, VecDeque::from([(2100, 100), (2400, 0), (2700, 0), (3000, 102)]), Some(()); "multiple skipped rounds between prices")] +fn yieldbox_get_price_test( + max_dev: u32, + max_age: u64, + updates: VecDeque<(u32, i128)>, + expected: Option<()>, +) { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + for (_, (timestamp, price)) in updates.iter().enumerate() { + set_ledger_timestamp(&env, (*timestamp).into()); + if price > &0 { + let updates = generate_updates(&env, &init_data.assets, *price); + client.set_price(&updates.0, &(timestamp * 1000).into()); + } + } + + let oldest_timestamp = env.ledger().timestamp() - max_age; + let mut price: Option = None; + if max_dev > 0 { + let prices = client.prices(&init_data.assets.get_unchecked(0), &4); + if let Some(prices) = prices { + if prices.len() >= 2 { + let first_price = prices.get_unchecked(0); + let second_price = prices.get_unchecked(1); + let diff = (first_price.price - second_price.price).abs(); + let max_dev = (second_price.price * max_dev as i128) / 100; + if diff < max_dev { + price = Some(first_price); + } + } + } + } else { + let round_timestamp = client.last_timestamp(); + if round_timestamp >= oldest_timestamp { + price = client.price(&init_data.assets.get_unchecked(0), &round_timestamp); + } + } + + if let Some(ref mut price_data) = price { + if price_data.timestamp >= oldest_timestamp { + assert_eq!(Some(()), expected); + return; + } + } + + assert_eq!(price.is_none(), expected.is_none()); +} + +#[test_case(5, 600, VecDeque::from([]), None; "no updates")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 104)]), Some(()); "price increase below threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 105)]), Some(()); "price increase equal to threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 106)]), None; "price increase above threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 96)]), Some(()); "price drop below threshold")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 5000)]), None; "massive price increase")] +#[test_case(5, 600, VecDeque::from([(2100, 100)]), None; "no history for dev check")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (2400, 100)]), Some(()); "no price change")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (12000, 102)]), None; "big gap with recent update")] +#[test_case(5, 600, VecDeque::from([(2100, 100), (12000, 0)]), None; "big gap without recent update")] +#[test_case(0, 600, VecDeque::from([(2100, 100)]), Some(()); "price within max age")] +#[test_case(0, 900, VecDeque::from([(2100, 100), (3000, 0)]), Some(()); "price ts equal to max age")] +#[test_case(0, 100, VecDeque::from([(2100, 100), (2400, 0), (2700, 0), (3000, 0)]), None; "price ts older than max age")] +#[test_case(5, 600, VecDeque::from([(2100, 0), (2400, 0)]), None; "only skipped prices")] +#[test_case(5, 900, VecDeque::from([(2100, 100), (2400, 0), (2700, 0), (3000, 102)]), Some(()); "multiple skipped rounds between prices")] +fn fixed_pool_get_price_test( + max_dev: u32, + max_age: u64, + updates: VecDeque<(u32, i128)>, + expected: Option<()>, +) { + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); + + for (_, (timestamp, price)) in updates.iter().enumerate() { + set_ledger_timestamp(&env, (*timestamp).into()); + if price > &0 { + let updates = generate_updates(&env, &init_data.assets, *price); + client.set_price(&updates.0, &(timestamp * 1000).into()); + } + } + + let oldest_timestamp = env.ledger().timestamp() - max_age; + let round_timestamp = client.last_timestamp(); + let oracle_resolution = client.resolution() as u64; + let mut price: Option = None; + let mut next_timestamp = round_timestamp.clone(); + while price.is_none() && next_timestamp >= oldest_timestamp { + price = client.price(&init_data.assets.get_unchecked(0), &next_timestamp); + next_timestamp = next_timestamp - oracle_resolution; + } + + // a price was found + if let Some(ref price) = price { + // if we need to verify the max dev, look for an older price + if max_dev > 0 { + // have a valid price for the asset from the oracle. Attempt to fetch an older price + // to validate max_dev. Looks at most `max_age / resolution` prices back from the most recent + // price. + let max_steps = max_age / oracle_resolution; + let mut old_price: Option = None; + for _ in 0..max_steps { + old_price = client.price(&init_data.assets.get_unchecked(0), &next_timestamp); + if old_price.is_some() { + break; + } + next_timestamp = next_timestamp - oracle_resolution; + } + if let Some(old_price) = old_price { + // check the price is within the max_dev, return None if it is not + let diff = (price.price - old_price.price).abs(); + let max_dev = (old_price.price * max_dev as i128) / 100; + if diff > max_dev { + assert_eq!(None, expected); + return; + } + } else { + // no old price found, so we cannot verify the max_dev, return None + assert_eq!(None, expected); + return; + } + } + + // normalize the decimals and verify the timestamp returned + if price.timestamp >= oldest_timestamp { + assert_eq!(Some(()), expected); + return; + } + } + + assert_eq!(price.is_none(), expected.is_none()); +} diff --git a/pulse-contract/src/tests/mod.rs b/pulse-contract/src/tests/mod.rs new file mode 100644 index 0000000..919a367 --- /dev/null +++ b/pulse-contract/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod contract_admin_tests; +mod contract_interface_tests; +mod integration_tests; diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs deleted file mode 100644 index 7b515b7..0000000 --- a/src/extensions/env_extensions.rs +++ /dev/null @@ -1,190 +0,0 @@ -#![allow(non_upper_case_globals)] -use soroban_sdk::storage::{Instance, Temporary}; -use soroban_sdk::{panic_with_error, Address, Env, Vec}; - -use crate::extensions; -use crate::types; - -use extensions::u128_helper::U128Helper; -use types::{asset::Asset, error::Error}; -const ADMIN_KEY: &str = "admin"; -const LAST_TIMESTAMP: &str = "last_timestamp"; -const RETENTION_PERIOD: &str = "period"; -const ASSETS: &str = "assets"; -const BASE_ASSET: &str = "base_asset"; -const DECIMALS: &str = "decimals"; -const RESOLUTION: &str = "resolution"; - -pub trait EnvExtensions { - fn get_admin(&self) -> Option
; - - fn set_admin(&self, admin: &Address); - - fn get_base_asset(&self) -> Asset; - - fn set_base_asset(&self, base_asset: &Asset); - - fn get_decimals(&self) -> u32; - - fn set_decimals(&self, decimals: u32); - - fn get_resolution(&self) -> u32; - - fn set_resolution(&self, resolution: u32); - - fn get_retention_period(&self) -> u64; - - fn set_retention_period(&self, period: u64); - - fn get_price(&self, asset: u8, timestamp: u64) -> Option; - - fn set_price(&self, asset: u8, price: i128, timestamp: u64, ledgers: u32); - - fn get_last_timestamp(&self) -> u64; - - fn set_last_timestamp(&self, timestamp: u64); - - fn get_assets(&self) -> Vec; - - fn set_assets(&self, assets: Vec); - - fn set_asset_index(&self, asset: &Asset, index: u32); - - fn get_asset_index(&self, asset: &Asset) -> Option; - - fn panic_if_not_admin(&self); - - fn is_initialized(&self) -> bool; -} - -impl EnvExtensions for Env { - fn is_initialized(&self) -> bool { - get_instance_storage(&self).has(&ADMIN_KEY) - } - - fn get_admin(&self) -> Option
{ - get_instance_storage(&self).get(&ADMIN_KEY) - } - - fn set_admin(&self, admin: &Address) { - get_instance_storage(&self).set(&ADMIN_KEY, admin); - } - - fn set_base_asset(&self, base_asset: &Asset) { - get_instance_storage(&self).set(&BASE_ASSET, base_asset) - } - - fn get_base_asset(&self) -> Asset { - get_instance_storage(self).get(&BASE_ASSET).unwrap() - } - - fn get_decimals(&self) -> u32 { - get_instance_storage(self).get(&DECIMALS).unwrap() - } - - fn set_decimals(&self, decimals: u32) { - get_instance_storage(&self).set(&DECIMALS, &decimals) - } - - fn get_resolution(&self) -> u32 { - get_instance_storage(self).get(&RESOLUTION).unwrap() - } - - fn set_resolution(&self, resolution: u32) { - get_instance_storage(&self).set(&RESOLUTION, &resolution) - } - - fn get_retention_period(&self) -> u64 { - get_instance_storage(&self) - .get(&RETENTION_PERIOD) - .unwrap_or_default() - } - - fn set_retention_period(&self, rdm_period: u64) { - get_instance_storage(&self).set(&RETENTION_PERIOD, &rdm_period); - } - - fn get_price(&self, asset: u8, timestamp: u64) -> Option { - //build the key for the price - let data_key = U128Helper::encode_price_record_key(timestamp, asset); - //get the price - get_temporary_storage(self).get(&data_key) - } - - fn set_price(&self, asset: u8, price: i128, timestamp: u64, ledgers_to_live: u32) { - //build the key for the price - let data_key = U128Helper::encode_price_record_key(timestamp, asset); - - //set the price - let temps_storage = get_temporary_storage(&self); - temps_storage.set(&data_key, &price); - if ledgers_to_live > 16 { - //16 is the minimum number - temps_storage.extend_ttl(&data_key, ledgers_to_live, ledgers_to_live) - } - } - - fn get_last_timestamp(&self) -> u64 { - //get the marker - get_instance_storage(&self) - .get(&LAST_TIMESTAMP) - .unwrap_or_default() - } - - fn set_last_timestamp(&self, timestamp: u64) { - get_instance_storage(&self).set(&LAST_TIMESTAMP, ×tamp); - } - - fn get_assets(&self) -> Vec { - get_instance_storage(&self) - .get(&ASSETS) - .unwrap_or_else(|| Vec::new(&self)) - } - - fn set_assets(&self, assets: Vec) { - get_instance_storage(&self).set(&ASSETS, &assets); - } - - fn set_asset_index(&self, asset: &Asset, index: u32) { - match asset { - Asset::Stellar(address) => { - get_instance_storage(&self).set(&address, &index); - } - Asset::Other(symbol) => { - get_instance_storage(&self).set(&symbol, &index); - } - } - } - - fn get_asset_index(&self, asset: &Asset) -> Option { - let index: Option; - match asset { - Asset::Stellar(address) => { - index = get_instance_storage(self).get(&address); - } - Asset::Other(symbol) => { - index = get_instance_storage(self).get(&symbol); - } - } - if index.is_none() { - return None; - } - return Some(index.unwrap() as u8); - } - - fn panic_if_not_admin(&self) { - let admin = self.get_admin(); - if admin.is_none() { - panic_with_error!(self, Error::Unauthorized); - } - admin.unwrap().require_auth() - } -} - -fn get_instance_storage(e: &Env) -> Instance { - e.storage().instance() -} - -fn get_temporary_storage(e: &Env) -> Temporary { - e.storage().temporary() -} diff --git a/src/extensions/i128_extensions.rs b/src/extensions/i128_extensions.rs deleted file mode 100644 index b90a931..0000000 --- a/src/extensions/i128_extensions.rs +++ /dev/null @@ -1,42 +0,0 @@ -pub trait I128Extensions { - // Divides two i128 numbers, considering decimal places. - // - // Arguments: - // - self: The dividend. - // - y: The divisor. Should not be zero; will cause panic if zero. - // - decimals: Number of decimal places for division. - // - // Behavior: - // - Rounds up towards zero for negative results. - // - // Panic: - // - If dividend (self) or divisor (y) is zero. - // - // Returns: - // - Division result with specified rounding behavior. - fn fixed_div_floor(self, y: i128, decimals: u32) -> i128; -} - -impl I128Extensions for i128 { - fn fixed_div_floor(self, y: i128, decimals: u32) -> i128 { - div_floor(self, y, decimals) - } -} - -fn div_floor(dividend: i128, divisor: i128, decimals: u32) -> i128 { - if dividend <= 0 || divisor <= 0 { - panic!("invalid division arguments") - } - let ashift = core::cmp::min(38 - dividend.ilog10(), decimals); - let bshift = core::cmp::max(decimals - ashift, 0); - - let mut vdividend = dividend; - let mut vdivisor = divisor; - if ashift > 0 { - vdividend *= 10_i128.pow(ashift); - } - if bshift > 0 { - vdivisor /= 10_i128.pow(bshift); - } - vdividend / vdivisor -} diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs deleted file mode 100644 index 93c1bc2..0000000 --- a/src/extensions/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod env_extensions; -pub mod i128_extensions; -pub mod u128_helper; -pub mod u64_extensions; diff --git a/src/extensions/u128_helper.rs b/src/extensions/u128_helper.rs deleted file mode 100644 index 42da2aa..0000000 --- a/src/extensions/u128_helper.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub struct U128Helper; - -impl U128Helper { - pub fn encode_price_record_key(val_u64: u64, val_u8: u8) -> u128 { - (val_u64 as u128) << 64 | val_u8 as u128 - } -} diff --git a/src/extensions/u64_extensions.rs b/src/extensions/u64_extensions.rs deleted file mode 100644 index f89e5a7..0000000 --- a/src/extensions/u64_extensions.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub trait U64Extensions { - fn get_normalized_timestamp(self, timeframe: u64) -> u64; - fn is_valid_timestamp(self, timeframe: u64) -> bool; -} - -impl U64Extensions for u64 { - fn get_normalized_timestamp(self, timeframe: u64) -> u64 { - if (self == 0) || (timeframe == 0) { - return 0; - } - (self / timeframe) * timeframe - } - - fn is_valid_timestamp(self, timeframe: u64) -> bool { - self == Self::get_normalized_timestamp(self, timeframe) - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index e74951b..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,580 +0,0 @@ -#![no_std] - -mod extensions; -mod test; -mod types; - -use extensions::i128_extensions::I128Extensions; -use extensions::{env_extensions::EnvExtensions, u64_extensions::U64Extensions}; -use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, Vec}; -use types::asset::Asset; -use types::error::Error; -use types::{config_data::ConfigData, price_data::PriceData}; - -#[contract] -pub struct PriceOracleContract; - -#[contractimpl] -impl PriceOracleContract { - // Returns the base asset the price is reported in. - // - // # Returns - // - // Base asset for the contract - pub fn base(e: Env) -> Asset { - e.get_base_asset() - } - - // Returns the number of decimal places used to represent price for all assets quoted by the oracle. - // - // # Returns - // - // Number of decimals places in quoted prices - pub fn decimals(e: Env) -> u32 { - e.get_decimals() - } - - // Returns the default tick period timeframe (in seconds). - // - // # Returns - // - // Price feed resolution (in seconds) - pub fn resolution(e: Env) -> u32 { - e.get_resolution() / 1000 - } - - // Returns the historical records retention period (in seconds). - // - // # Returns - // - // History retention period (in seconds) - pub fn period(e: Env) -> Option { - let period = e.get_retention_period(); - if period == 0 { - return None; - } else { - return Some(period / 1000); //convert to seconds - } - } - - // Returns all assets quoted by the contract. - // - // # Returns - // - // Assets quoted by the contract - pub fn assets(e: Env) -> Vec { - e.get_assets() - } - - // Returns the most recent price update timestamp in seconds. - // - // # Returns - // - // Timestamp of the last recorded price update - pub fn last_timestamp(e: Env) -> u64 { - e.get_last_timestamp() / 1000 //convert to seconds - } - - // Returns price in base asset at specific timestamp. - // - // # Arguments - // - // * `asset` - Asset to quote - // * `timestamp` - Timestamp in seconds - // - // # Returns - // - // Price record for the given asset at the given timestamp or None if the record was not found - pub fn price(e: Env, asset: Asset, timestamp: u64) -> Option { - let resolution = e.get_resolution(); - let normalized_timestamp = //convert to milliseconds and normalize - (timestamp * 1000).get_normalized_timestamp(resolution.into()); - //get the price - get_price_data(&e, asset, normalized_timestamp) - } - - // Returns the most recent price for an asset. - // - // # Arguments - // - // * `asset` - Asset to quote - // - // # Returns - // - // The most recent price for the given asset or None if the asset is not supported - pub fn lastprice(e: Env, asset: Asset) -> Option { - //get the last timestamp - let timestamp = obtain_record_timestamp(&e); - if timestamp == 0 { - return None; - } - //get the price - get_price_data(&e, asset, timestamp) - } - - // Returns last N price records for the given asset. - // - // # Arguments - // - // * `asset` - Asset to quote - // * `records` - Number of records to return - // - // # Returns - // - // Prices for the given asset or None if the asset is not supported - pub fn prices(e: Env, asset: Asset, records: u32) -> Option> { - let asset_index = e.get_asset_index(&asset); //get the asset index to avoid multiple calls - if asset_index.is_none() { - return None; - } - prices( - &e, - |timestamp| get_price_data_by_index(&e, asset_index.unwrap(), timestamp), - records, - ) - } - - // Returns the most recent cross price record for the pair of assets. - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // The most recent cross price (base_asset_price/quote_asset_price) for the given assets or None if if there were no records found for quoted asset - pub fn x_last_price(e: Env, base_asset: Asset, quote_asset: Asset) -> Option { - let timestamp = obtain_record_timestamp(&e); - if timestamp == 0 { - return None; - } - let decimals = e.get_decimals(); - get_x_price(&e, base_asset, quote_asset, timestamp, decimals) - } - - // Returns the cross price for the pair of assets at specific timestamp. - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `timestamp` - Timestamp - // - // # Returns - // - // Cross price (base_asset_price/quote_asset_price) at the given timestamp or None if there were no records found for quoted assets at specific timestamp - pub fn x_price( - e: Env, - base_asset: Asset, - quote_asset: Asset, - timestamp: u64, - ) -> Option { - let normalized_timestamp = //convert to milliseconds and normalize - (timestamp * 1000).get_normalized_timestamp(e.get_resolution().into()); - let decimals = e.get_decimals(); - get_x_price(&e, base_asset, quote_asset, normalized_timestamp, decimals) - } - - // Returns last N cross price records of for the pair of assets. - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // Last N cross prices (base_asset_price/quote_asset_price) or None if there were no records found for quoted assets - pub fn x_prices( - e: Env, - base_asset: Asset, - quote_asset: Asset, - records: u32, - ) -> Option> { - let asset_pair_indexes = get_asset_pair_indexes(&e, base_asset, quote_asset); - if asset_pair_indexes.is_none() { - return None; - } - let decimals = e.get_decimals(); - prices( - &e, - |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes.unwrap(), timestamp, decimals) - }, - records, - ) - } - - // Returns the time-weighted average price for the given asset over N recent records. - // - // # Arguments - // - // * `asset` - Asset to quote - // * `records` - Number of records to process - // - // # Returns - // - // TWAP for the given asset over N recent records or None if the asset is not supported - pub fn twap(e: Env, asset: Asset, records: u32) -> Option { - let asset_index = e.get_asset_index(&asset); //get the asset index to avoid multiple calls - if asset_index.is_none() { - return None; - } - get_twap( - &e, - |timestamp| get_price_data_by_index(&e, asset_index.unwrap(), timestamp), - records, - ) - } - - // Returns the time-weighted average cross price for the given asset pair over N recent records. - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // TWAP (base_asset_price/quote_asset_price) or None if the assets are not supported. - pub fn x_twap(e: Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option { - //get asset index to avoid multiple calls - let asset_pair_indexes = get_asset_pair_indexes(&e, base_asset, quote_asset); - if asset_pair_indexes.is_none() { - return None; - } - let decimals = e.get_decimals(); - get_twap( - &e, - |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes.unwrap(), timestamp, decimals) - }, - records, - ) - } - - // Returns current protocol version of the contract. - // - // # Returns - // - // Contract protocol version - pub fn version(_e: Env) -> u32 { - env!("CARGO_PKG_VERSION") - .split(".") - .next() - .unwrap() - .parse::() - .unwrap() - } - - //Admin section - - // Returns admin address of the contract. - // - // # Returns - // - // Contract admin account address - pub fn admin(e: Env) -> Option
{ - e.get_admin() - } - - // Updates the contract configuration parameters. Can be invoked only by the admin account. - // - // # Arguments - // - // * `admin` - Admin account address - // * `config` - Configuration parameters - // - // # Panics - // - // Panics if the contract is already initialized, or if the version is invalid - pub fn config(e: Env, config: ConfigData) { - config.admin.require_auth(); - if e.is_initialized() { - e.panic_with_error(Error::AlreadyInitialized); - } - e.set_admin(&config.admin); - e.set_base_asset(&config.base_asset); - e.set_decimals(config.decimals); - e.set_resolution(config.resolution); - e.set_retention_period(config.period); - - Self::__add_assets(&e, config.assets); - } - - // Adds given assets to the contract quoted assets list. Can be invoked only by the admin account. - // - // # Arguments - // - // * `admin` - Admin account address - // * `assets` - Assets to add - // * `version` - Configuration protocol version - // - // # Panics - // - // Panics if the caller doesn't match admin address, or if the assets are already added - pub fn add_assets(e: Env, assets: Vec) { - e.panic_if_not_admin(); - Self::__add_assets(&e, assets); - } - - // Sets history retention period for the prices. Can be invoked only by the admin account. - // - // # Arguments - // - // * `admin` - Admin account address - // * `period` - History retention period (in seconds) - // * `version` - Configuration protocol version - // - // # Panics - // - // Panics if the caller doesn't match admin address, or if the period/version is invalid - pub fn set_period(e: Env, period: u64) { - e.panic_if_not_admin(); - e.set_retention_period(period); - } - - // Record new price feed history snapshot. Can be invoked only by the admin account. - // - // # Arguments - // - // * `admin` - Admin account address - // * `updates` - Price feed snapshot - // * `timestamp` - History snapshot timestamp - // - // # Panics - // - // Panics if the caller doesn't match admin address, or if the price snapshot record is invalid - pub fn set_price(e: Env, updates: Vec, timestamp: u64) { - e.panic_if_not_admin(); - let updates_len = updates.len(); - if updates_len == 0 || updates_len >= 256 { - panic_with_error!(&e, Error::InvalidUpdateLength); - } - let timeframe: u64 = e.get_resolution().into(); - let ledger_timestamp = now(&e); - if timestamp == 0 - || !timestamp.is_valid_timestamp(timeframe) - || timestamp > ledger_timestamp - { - panic_with_error!(&e, Error::InvalidTimestamp); - } - - let retention_period = e.get_retention_period(); - - let ledgers_to_live: u32 = ((retention_period / 1000 / 5) + 1) as u32; - - //get the last timestamp - let last_timestamp = e.get_last_timestamp(); - - //iterate over the updates - for (i, price) in updates.iter().enumerate() { - //don't store zero prices - if price == 0 { - continue; - } - let asset = i as u8; - //store the new price - e.set_price(asset, price, timestamp, ledgers_to_live); - } - if timestamp > last_timestamp { - e.set_last_timestamp(timestamp); - } - } - - // Updates the contract source code. Can be invoked only by the admin account. - // - // # Arguments - // - // * `admin` - Admin account address - // * `wasm_hash` - WASM hash of the contract source code - // - // # Panics - // - // Panics if the caller doesn't match admin address - pub fn update_contract(env: Env, wasm_hash: BytesN<32>) { - env.panic_if_not_admin(); - env.deployer().update_current_contract_wasm(wasm_hash) - } - - fn __add_assets(e: &Env, assets: Vec) { - let mut current_assets = e.get_assets(); - for asset in assets.iter() { - //check if the asset has been already added - if e.get_asset_index(&asset).is_some() { - panic_with_error!(&e, Error::AssetAlreadyExists); - } - e.set_asset_index(&asset, current_assets.len()); - current_assets.push_back(asset); - } - if current_assets.len() >= 256 { - panic_with_error!(&e, Error::AssetLimitExceeded); - } - e.set_assets(current_assets); - } -} - -fn prices Option>( - e: &Env, - get_price_fn: F, - mut records: u32, -) -> Option> { - // Check if the asset is valid - let mut timestamp = obtain_record_timestamp(e); - if timestamp == 0 { - return None; - } - - let mut prices = Vec::new(e); - let resolution = e.get_resolution() as u64; - - // Limit the number of records to 20 - records = records.min(20); - - while records > 0 { - if let Some(price) = get_price_fn(timestamp) { - prices.push_back(price); - } - - // Decrement records counter in every iteration - records -= 1; - - if timestamp < resolution { - break; - } - timestamp -= resolution; - } - - if prices.is_empty() { - None - } else { - Some(prices) - } -} - -fn now(e: &Env) -> u64 { - e.ledger().timestamp() * 1000 //convert to milliseconds -} - -fn obtain_record_timestamp(e: &Env) -> u64 { - let last_timestamp = e.get_last_timestamp(); - let ledger_timestamp = now(&e); - let resolution = e.get_resolution() as u64; - if last_timestamp == 0 //no prices yet - || last_timestamp > ledger_timestamp //last timestamp is in the future - || ledger_timestamp - last_timestamp >= resolution * 2 - //last timestamp is too far in the past, so we cannot return the last price - { - return 0; - } - last_timestamp -} - -fn get_twap Option>( - e: &Env, - get_price_fn: F, - records: u32, -) -> Option { - let prices = prices(&e, get_price_fn, records)?; - - if prices.len() != records { - return None; - } - - let last_price_timestamp = prices.first()?.timestamp * 1000; //convert to milliseconds to match the timestamp format - let timeframe = e.get_resolution() as u64; - let current_time = now(&e); - - //check if the last price is too old - if last_price_timestamp + timeframe + 60 * 1000 < current_time { - return None; - } - - let sum: i128 = prices.iter().map(|price_data| price_data.price).sum(); - Some(sum / prices.len() as i128) -} - -fn get_x_price( - e: &Env, - base_asset: Asset, - quote_asset: Asset, - timestamp: u64, - decimals: u32, -) -> Option { - let asset_pair_indexes = get_asset_pair_indexes(e, base_asset, quote_asset); - if asset_pair_indexes.is_none() { - return None; - } - get_x_price_by_indexes(e, asset_pair_indexes.unwrap(), timestamp, decimals) -} - -fn get_x_price_by_indexes( - e: &Env, - asset_pair_indexes: (u8, u8), - timestamp: u64, - decimals: u32, -) -> Option { - let (base_asset, quote_asset) = asset_pair_indexes; - //check if the asset are the same - if base_asset == quote_asset { - return Some(get_normalized_price_data(10i128.pow(decimals), timestamp)); - } - - //get the price for base_asset - let base_asset_price = e.get_price(base_asset, timestamp); - if base_asset_price.is_none() { - return None; - } - - //get the price for quote_asset - let quote_asset_price = e.get_price(quote_asset, timestamp); - if quote_asset_price.is_none() { - return None; - } - - //calculate the cross price - Some(get_normalized_price_data( - base_asset_price - .unwrap() - .fixed_div_floor(quote_asset_price.unwrap(), decimals), - timestamp, - )) -} - -fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option<(u8, u8)> { - let base_asset = e.get_asset_index(&base_asset); - if base_asset.is_none() { - return None; - } - - let quote_asset = e.get_asset_index("e_asset); - if quote_asset.is_none() { - return None; - } - - Some((base_asset.unwrap(), quote_asset.unwrap())) -} - -fn get_price_data(e: &Env, asset: Asset, timestamp: u64) -> Option { - let asset: Option = e.get_asset_index(&asset); - if asset.is_none() { - return None; - } - get_price_data_by_index(e, asset.unwrap(), timestamp) -} - -fn get_price_data_by_index(e: &Env, asset: u8, timestamp: u64) -> Option { - let price = e.get_price(asset, timestamp); - if price.is_none() { - return None; - } - Some(get_normalized_price_data(price.unwrap(), timestamp)) -} - -fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { - PriceData { - price, - timestamp: timestamp / 1000, //convert to seconds - } -} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index b9de8a0..0000000 --- a/src/test.rs +++ /dev/null @@ -1,681 +0,0 @@ -#![cfg(test)] -extern crate alloc; -extern crate std; - -use super::*; -use alloc::string::ToString; -use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, - Address, Env, String, Symbol, TryIntoVal, -}; -use std::panic::{self, AssertUnwindSafe}; - -use {extensions::i128_extensions::I128Extensions, types::asset::Asset}; - -const RESOLUTION: u32 = 300_000; -const DECIMALS: u32 = 14; - -fn convert_to_seconds(timestamp: u64) -> u64 { - timestamp / 1000 -} - -fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, ConfigData) { - let env = Env::default(); - - //set timestamp to 900 seconds - let ledger_info = env.ledger().get(); - env.ledger().set(LedgerInfo { - timestamp: 900, - ..ledger_info - }); - - let admin = Address::generate(&env); - - let contract_id = &Address::from_string(&String::from_str( - &env, - "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", - )); - - env.register_contract(contract_id, PriceOracleContract); - let client: PriceOracleContractClient<'a> = PriceOracleContractClient::new(&env, contract_id); - - env.budget().reset_unlimited(); - - let init_data = ConfigData { - admin: admin.clone(), - period: (100 * RESOLUTION).into(), - assets: generate_assets(&env, 10, 0), - base_asset: Asset::Stellar(Address::generate(&env)), - decimals: 14, - resolution: RESOLUTION, - }; - - env.mock_all_auths(); - - //set admin - client.config(&init_data); - - (env, client, init_data) -} - -fn normalize_price(price: i128) -> i128 { - price * 10i128.pow(DECIMALS) -} - -fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { - let mut assets = Vec::new(&e); - for i in 0..count { - if i % 2 == 0 { - assets.push_back(Asset::Stellar(Address::generate(&e))); - } else { - assets.push_back(Asset::Other(Symbol::new( - e, - &("ASSET_".to_string() + &(start_index + i as u32).to_string()), - ))); - } - } - assets -} - -fn get_updates(env: &Env, assets: &Vec, price: i128) -> Vec { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - updates.push_back(price); - } - updates -} - -#[test] -fn version_test() { - let (_env, client, _init_data) = init_contract_with_admin(); - let result = client.version(); - let version = env!("CARGO_PKG_VERSION") - .split(".") - .next() - .unwrap() - .parse::() - .unwrap(); - assert_eq!(result, version); -} - -#[test] -fn init_test() { - let (_env, client, init_data) = init_contract_with_admin(); - - let address = client.admin(); - assert_eq!(address.unwrap(), init_data.admin.clone()); - - let base = client.base(); - assert_eq!(base, init_data.base_asset); - - let resolution = client.resolution(); - assert_eq!(resolution, RESOLUTION / 1000); - - let period = client.period().unwrap(); - assert_eq!(period, init_data.period / 1000); - - let decimals = client.decimals(); - assert_eq!(decimals, DECIMALS); - - let assets = client.assets(); - assert_eq!(assets, init_data.assets); -} - -#[test] -fn set_price_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); -} - -#[test] -#[should_panic] -fn set_price_zero_timestamp_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 0; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); -} - -#[test] -#[should_panic] -fn set_price_invalid_timestamp_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_001; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); -} - -#[test] -#[should_panic] -fn set_price_future_timestamp_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 1_200_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); -} - -#[test] -fn last_price_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &&assets, normalize_price(200)); - - //set prices for assets - client.set_price(&updates, ×tamp); - - //check last prices - let result = client.lastprice(&assets.get_unchecked(1)); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(200), - timestamp: convert_to_seconds(900_000) - }) - ); -} - -#[test] -fn last_timestamp_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let mut result = client.last_timestamp(); - - assert_eq!(result, 0); - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - result = client.last_timestamp(); - - assert_eq!(result, convert_to_seconds(600_000)); -} - -#[test] -fn add_assets_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = generate_assets(&env, 10, init_data.assets.len() - 1); - - env.mock_all_auths(); - - client.add_assets(&assets); - - let result = client.assets(); - - let mut expected_assets = init_data.assets.clone(); - for asset in assets.iter() { - expected_assets.push_back(asset.clone()); - } - - assert_eq!(result, expected_assets); -} - -#[test] -#[should_panic] -fn add_assets_duplicate_test() { - let (env, client, _) = init_contract_with_admin(); - - let mut assets = Vec::new(&env); - let duplicate_asset = Asset::Other(Symbol::new(&env, &("ASSET_DUPLICATE"))); - assets.push_back(duplicate_asset.clone()); - assets.push_back(duplicate_asset); - - env.mock_all_auths(); - - client.add_assets(&assets); -} - -#[test] -#[should_panic] -fn assets_update_overflow_test() { - let (env, client, _) = init_contract_with_admin(); - - env.mock_all_auths(); - - env.budget().reset_unlimited(); - - let mut assets = Vec::new(&env); - for i in 1..=256 { - assets.push_back(Asset::Other(Symbol::new( - &env, - &("Asset".to_string() + &i.to_string()), - ))); - } - - client.add_assets(&assets); -} - -#[test] -#[should_panic] -fn prices_update_overflow_test() { - let (env, client, _) = init_contract_with_admin(); - - env.mock_all_auths(); - - env.budget().reset_unlimited(); - - let mut updates = Vec::new(&env); - for i in 1..=256 { - updates.push_back(normalize_price(i as i128 + 1)); - } - client.set_price(&updates, &600_000); -} - -#[test] -fn set_period_test() { - let (env, client, _) = init_contract_with_admin(); - - let period = 100_000; - - env.mock_all_auths(); - - client.set_period(&period); - - let result = client.period().unwrap(); - - assert_eq!(result, convert_to_seconds(period)); -} - -#[test] -fn get_price_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &assets, normalize_price(200)); - - client.set_price(&updates, ×tamp); - - //check last prices - let mut result = client.lastprice(&assets.get_unchecked(1)); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(200), - timestamp: convert_to_seconds(900_000) - }) - ); - - //check price at 899_000 - result = client.price(&assets.get_unchecked(1), &convert_to_seconds(899_000)); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(100), - timestamp: convert_to_seconds(600_000) - }) - ); -} - -#[test] -fn get_lastprice_delayed_update_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 300_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - client.set_price(&updates, ×tamp); - - //check last prices - let result = client.lastprice(&assets.get_unchecked(1)); - assert_eq!(result, None); -} - -#[test] -fn get_x_last_price_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - client.set_price(&updates, ×tamp); - - //check last x price - let result = client.x_last_price(&assets.get_unchecked(1), &assets.get_unchecked(2)); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(600_000) - }) - ); -} - -#[test] -fn get_x_price_with_zero_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let mut updates = get_updates(&env, &assets, normalize_price(100)); - updates.set(1, 0); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let result = client.x_price( - &assets.get(0).unwrap(), - &assets.get(1).unwrap(), - &convert_to_seconds(timestamp), - ); - - assert_eq!(result, None); -} - -#[test] -fn get_x_price_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &assets, normalize_price(200)); - - //set prices for assets - client.set_price(&updates, ×tamp); - - //check last prices - let mut result = client.x_last_price(&assets.get_unchecked(1), &assets.get_unchecked(2)); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(900_000) - }) - ); - - //check price at 899_000 - result = client.x_price( - &assets.get_unchecked(1), - &assets.get_unchecked(2), - &convert_to_seconds(899_000), - ); - assert_ne!(result, None); - assert_eq!( - result, - Some(PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(600_000) - }) - ); -} - -#[test] -fn twap_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &assets, normalize_price(200)); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let result = client.twap(&assets.get_unchecked(1), &2); - - assert_ne!(result, None); - assert_eq!(result.unwrap(), normalize_price(150)); -} - -#[test] -fn x_twap_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - //set prices for assets - let timestamp = 600_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &assets, normalize_price(200)); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let result = client.x_twap(&assets.get_unchecked(1), &assets.get_unchecked(2), &2); - - assert_ne!(result, None); - assert_eq!(result.unwrap(), normalize_price(1)); -} - -#[test] -#[should_panic] -fn x_twap_with_gap_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - //set prices for assets with gap - let timestamp = 300_000; - let updates = get_updates(&env, &assets, normalize_price(100)); - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let timestamp = 900_000; - let updates = get_updates(&env, &assets, normalize_price(200)); - - //set prices for assets - client.set_price(&updates, ×tamp); - - let result = client.x_twap(&assets.get_unchecked(1), &assets.get_unchecked(2), &3); - - assert_ne!(result, None); - assert_eq!(result.unwrap(), normalize_price(1)); -} - -#[test] -fn get_non_registered_asset_price_test() { - let (env, client, config_data) = init_contract_with_admin(); - - //try to get price for unknown Stellar asset - let mut result = client.lastprice(&Asset::Stellar(Address::generate(&env))); - assert_eq!(result, None); - - //try to get price for unknown Other asset - result = client.lastprice(&Asset::Other(Symbol::new(&env, "NonRegisteredAsset"))); - assert_eq!(result, None); - - //try to get price for unknown base asset - result = client.x_last_price( - &Asset::Stellar(Address::generate(&env)), - &config_data.assets.get_unchecked(1), - ); - assert_eq!(result, None); - - //try to get price for unknown quote asset - result = client.x_last_price( - &config_data.assets.get_unchecked(1), - &Asset::Stellar(Address::generate(&env)), - ); - assert_eq!(result, None); - - //try to get price for both unknown assets - result = client.x_last_price( - &Asset::Stellar(Address::generate(&env)), - &Asset::Other(Symbol::new(&env, "NonRegisteredAsset")), - ); - assert_eq!(result, None); -} - -#[test] -fn get_asset_price_for_invalid_timestamp_test() { - let (env, client, config_data) = init_contract_with_admin(); - - let mut result = client.price( - &config_data.assets.get_unchecked(1), - &convert_to_seconds(u64::MAX), - ); - assert_eq!(result, None); - - //try to get price for unknown asset - result = client.lastprice(&Asset::Stellar(Address::generate(&env))); - assert_eq!(result, None); -} - -#[test] -fn authorized_test() { - let (env, client, config_data) = init_contract_with_admin(); - - let period: u64 = 100; - //set prices for assets - client - .mock_auths(&[MockAuth { - address: &config_data.admin, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "set_period", - args: Vec::from_array(&env, [period.clone().try_into_val(&env).unwrap()]), - sub_invokes: &[], - }, - }]) - .set_period(&period); -} - -#[test] -#[should_panic] -fn unauthorized_test() { - let (env, client, _) = init_contract_with_admin(); - - let account = Address::generate(&env); - - let period: u64 = 100; - //set prices for assets - client - .mock_auths(&[MockAuth { - address: &account, - invoke: &MockAuthInvoke { - contract: &client.address, - fn_name: "set_period", - args: Vec::from_array(&env, [period.clone().try_into_val(&env).unwrap()]), - sub_invokes: &[], - }, - }]) - .set_period(&period); -} - -#[test] -fn div_tests() { - let test_cases = [ - (154467226919499, 133928752749774, 115335373284703), - ( - i128::MAX / 100, - 231731687303715884105728, - 734216306110962248249052545, - ), - (231731687303715884105728, i128::MAX / 100, 13), - // -1 expected result for errors - (1, 0, -1), - (0, 1, -1), - (0, 0, -1), - (-1, 0, -1), - (0, -1, -1), - (-1, -1, -1), - ]; - - for (a, b, expected) in test_cases.iter() { - let result = panic::catch_unwind(AssertUnwindSafe(|| a.fixed_div_floor(*b, 14))); - if expected == &-1 { - assert!(result.is_err()); - } else { - assert_eq!(result.unwrap(), *expected); - } - } -} diff --git a/src/types/asset.rs b/src/types/asset.rs deleted file mode 100644 index b11bd67..0000000 --- a/src/types/asset.rs +++ /dev/null @@ -1,8 +0,0 @@ -use soroban_sdk::{contracttype, Address, Symbol}; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Asset { - Stellar(Address), - Other(Symbol), -} diff --git a/src/types/asset_type.rs b/src/types/asset_type.rs deleted file mode 100644 index 00c6d34..0000000 --- a/src/types/asset_type.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(PartialEq)] -#[repr(u8)] -#[allow(dead_code)] -pub enum AssetType { - Stellar = 1, - Other = 2, -} diff --git a/src/types/config_data.rs b/src/types/config_data.rs deleted file mode 100644 index 6aa2381..0000000 --- a/src/types/config_data.rs +++ /dev/null @@ -1,22 +0,0 @@ -use soroban_sdk::{contracttype, Address, Vec}; - -use super::asset::Asset; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] - -// The configuration parameters for the contract. -pub struct ConfigData { - // The admin address. - pub admin: Address, - // The retention period for the prices. - pub period: u64, - // The assets supported by the contract. - pub assets: Vec, - // The base asset for the prices. - pub base_asset: Asset, - // The number of decimals for the prices. - pub decimals: u32, - // The resolution of the prices. - pub resolution: u32, -} diff --git a/src/types/error.rs b/src/types/error.rs deleted file mode 100644 index 70350f6..0000000 --- a/src/types/error.rs +++ /dev/null @@ -1,23 +0,0 @@ -use soroban_sdk::contracterror; - -#[contracterror] -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -// The error codes for the contract. -pub enum Error { - // The contract is already initialized. - AlreadyInitialized = 0, - // The caller is not authorized to perform the operation. - Unauthorized = 1, - // The config assets doen't contain persistent asset. Delete assets is not supported. - AssetMissing = 2, - // The asset is already added to the contract's list of supported assets. - AssetAlreadyExists = 3, - // The config version is invalid - InvalidConfigVersion = 4, - // The prices timestamp is invalid - InvalidTimestamp = 5, - // The assets update length or prices update length is invalid - InvalidUpdateLength = 6, - // The assets storage is full - AssetLimitExceeded = 7, -} diff --git a/src/types/mod.rs b/src/types/mod.rs deleted file mode 100644 index 29a8bf7..0000000 --- a/src/types/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod asset; -pub mod asset_type; -pub mod config_data; -pub mod error; -pub mod price_data; diff --git a/src/types/price_data.rs b/src/types/price_data.rs deleted file mode 100644 index 2edbabf..0000000 --- a/src/types/price_data.rs +++ /dev/null @@ -1,11 +0,0 @@ -use soroban_sdk::contracttype; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -// The price data for an asset at a given timestamp. -pub struct PriceData { - // The price in contracts' base asset and decimals. - pub price: i128, - // The timestamp of the price. - pub timestamp: u64, -}