From ca0aa150a963a1aff9e8637e7a4ede36f2c7b3a0 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Sat, 10 May 2025 00:13:09 +0300 Subject: [PATCH 01/55] add event on price update; add asset expiration mechanism --- Cargo.lock | 434 +++++++++++++++++++++---------- Cargo.toml | 8 +- src/extensions/env_extensions.rs | 28 ++ src/lib.rs | 176 +++++++++++-- src/test.rs | 51 +++- 5 files changed, 525 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f21a5d..0ec4757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,20 +3,17 @@ version = 3 [[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,37 +39,134 @@ 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-ec" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "ark-ff" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] [[package]] -name = "base32" +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 = "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 = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -116,7 +210,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -200,26 +294,25 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.7" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad291aa74992b9b7a7e88c38acbbf6ad7e107f1d90ee8775b7bc1fc3394f485c" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.101", ] [[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,7 +326,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -257,7 +350,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.101", ] [[package]] @@ -268,9 +361,15 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "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" @@ -291,6 +390,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -299,7 +409,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -331,7 +441,6 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature", - "spki", ] [[package]] @@ -346,15 +455,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 +486,6 @@ dependencies = [ "ff", "generic-array", "group", - "pkcs8", "rand_core", "sec1", "subtle", @@ -447,12 +556,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" @@ -470,6 +573,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.3" @@ -559,9 +671,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 +695,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]] @@ -622,21 +732,6 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" -[[package]] -name = "memchr" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" - -[[package]] -name = "miniz_oxide" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" -dependencies = [ - "adler", -] - [[package]] name = "num-bigint" version = "0.4.4" @@ -662,7 +757,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -684,21 +779,24 @@ dependencies = [ "autocfg", ] -[[package]] -name = "object" -version = "0.32.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "paste" version = "1.0.14" @@ -715,12 +813,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 +832,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", ] @@ -793,7 +894,7 @@ dependencies = [ [[package]] name = "reflector-oracle" -version = "5.0.0" +version = "4.2.0" dependencies = [ "soroban-sdk", ] @@ -808,17 +909,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", ] @@ -838,7 +933,6 @@ dependencies = [ "base16ct", "der", "generic-array", - "pkcs8", "subtle", "zeroize", ] @@ -866,7 +960,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -907,7 +1001,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -949,21 +1043,21 @@ checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "soroban-builtin-sdk-macros" -version = "20.2.2" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ef302d2118a14267e441e50e33705adc4f0da56616e7d2d9f198448d5714b2" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-env-common" -version = "20.2.2" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc40ac91f70bb93aed7dff6057caac8810d49a8c451f44286e1e49243c799beb" +checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" dependencies = [ "arbitrary", "crate-git-revision", @@ -975,13 +1069,14 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-xdr", + "wasmparser", ] [[package]] name = "soroban-env-guest" -version = "20.2.2" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "949587b3608cb05fe1d5eecce24aed1c33063c38fa79402f2e5b1c2a29466350" +checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" dependencies = [ "soroban-env-common", "static_assertions", @@ -989,13 +1084,19 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "20.2.2" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faaa4e738232cacae7deb7947adfd4718e47cd2b50676e9518a8a38ee00930c9" +checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" dependencies = [ - "backtrace", + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", "curve25519-dalek", + "ecdsa", "ed25519-dalek", + "elliptic-curve", + "generic-array", "getrandom", "hex-literal", "hmac", @@ -1003,8 +1104,10 @@ dependencies = [ "num-derive", "num-integer", "num-traits", + "p256", "rand", "rand_chacha", + "sec1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1012,13 +1115,14 @@ dependencies = [ "soroban-wasmi", "static_assertions", "stellar-strkey", + "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "20.2.2" +version = "22.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff09cd5f1e4968e6dbac40eb4fbb2bdbb478fa989a96088fe0466d09e8ff40c6" +checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" dependencies = [ "itertools", "proc-macro2", @@ -1026,14 +1130,14 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-ledger-snapshot" -version = "20.3.2" +version = "22.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a7b822725a73a90ef650bc1f325d13c8bae7a808156c101953092327e2edee" +checksum = "80bbe59497cb50e81861187e6bd2a2c805df253573d44ed56e7d373f79530758" dependencies = [ "serde", "serde_json", @@ -1045,15 +1149,17 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "20.3.2" +version = "22.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdff4b5fc50f554499b81aa6ecbb4045beb84742ecda9777ebbdc90c0d93ec62" +checksum = "c85edd55eb09aa5dd7ba5ab595d2be7ac3f453e90e2f26d704ff26c130f2926f" dependencies = [ "arbitrary", "bytes-lit", "ctor", + "derive_arbitrary", "ed25519-dalek", "rand", + "rustc_version", "serde", "serde_json", "soroban-env-guest", @@ -1065,9 +1171,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "20.3.2" +version = "22.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d147c3ce37842919893946a4467632aa012f567a7ab2286abe19e5ecc25e05" +checksum = "a141230aa65006d4b3eeee9d0589172d734a2abfbe15b84670e38e76e200b370" dependencies = [ "crate-git-revision", "darling", @@ -1080,14 +1186,14 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.101", ] [[package]] name = "soroban-spec" -version = "20.3.2" +version = "22.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7a132b7c234edf6ef3add4ffb17807f3b25a4ce5ab944ebbaf4d2326470eb1" +checksum = "b54326e9516b33be99c701b37242b27efb8e66cc1f1eff994b9d9a013a4be136" dependencies = [ "base64 0.13.1", "stellar-xdr", @@ -1097,9 +1203,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "20.3.2" +version = "22.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8d396f3b29800138e8abf2562aba0b579d09d8c2d2b956379fc9e68914a6e62" +checksum = "f009cab4dfd653bc94a06c5022f1ca9d30e198b0e451f84cf307231563d11de2" dependencies = [ "prettyplease", "proc-macro2", @@ -1107,7 +1213,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn", + "syn 2.0.101", "thiserror", ] @@ -1148,20 +1254,20 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stellar-strkey" -version = "0.0.8" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12d2bf45e114117ea91d820a846fd1afbe3ba7d717988fee094ce8227a3bf8bd" +checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" dependencies = [ - "base32", "crate-git-revision", + "data-encoding", "thiserror", ] [[package]] name = "stellar-xdr" -version = "20.1.0" +version = "22.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59cdf3eb4467fb5a4b00b52e7de6dca72f67fac6f9b700f55c95a5d86f09c9d" +checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" dependencies = [ "arbitrary", "base64 0.13.1", @@ -1187,9 +1293,20 @@ checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" [[package]] name = "syn" -version = "2.0.39" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +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 = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -1213,7 +1330,7 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1292,7 +1409,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-shared", ] @@ -1314,7 +1431,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1345,11 +1462,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 +1545,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..80ebfe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflector-oracle" -version = "4.1.0" +version = "4.2.0" edition = "2021" [lib] @@ -17,10 +17,10 @@ codegen-units = 1 lto = true [dependencies] -soroban-sdk = "20.3.2" +soroban-sdk = "22.0.7" -[dev_dependencies] -soroban-sdk = { version = "20.3.2", features = ["testutils"] } +[dev-dependencies] +soroban-sdk = { version = "22.0.7", features = ["testutils"] } [features] testutils = ["soroban-sdk/testutils"] diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index 7b515b7..a46da6d 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -14,6 +14,8 @@ const ASSETS: &str = "assets"; const BASE_ASSET: &str = "base_asset"; const DECIMALS: &str = "decimals"; const RESOLUTION: &str = "resolution"; +const ASSET_TTLS: &str = "asset_ttls"; +const FEE: &str = "fee"; pub trait EnvExtensions { fn get_admin(&self) -> Option
; @@ -52,6 +54,14 @@ pub trait EnvExtensions { fn get_asset_index(&self, asset: &Asset) -> Option; + fn set_asset_ttls(&self, assets: &Vec); + + fn get_asset_ttls(&self) -> Vec; + + fn set_fee_data(&self, fee_data: (Address, i128)); + + fn get_fee_data(&self) -> Option<(Address, i128)>; + fn panic_if_not_admin(&self); fn is_initialized(&self) -> bool; @@ -172,6 +182,24 @@ impl EnvExtensions for Env { return Some(index.unwrap() as u8); } + fn set_asset_ttls(&self, assets: &Vec) { + get_instance_storage(self).set(&ASSET_TTLS, assets) + } + + fn get_asset_ttls(&self) -> Vec { + get_instance_storage(self) + .get(&ASSET_TTLS) + .unwrap_or_else(|| Vec::new(self)) + } + + fn set_fee_data(&self, fee_data: (Address, i128)) { + get_instance_storage(self).set(&FEE, &fee_data); + } + + fn get_fee_data(&self) -> Option<(Address, i128)> { + get_instance_storage(self).get(&FEE) + } + fn panic_if_not_admin(&self) { let admin = self.get_admin(); if admin.is_none() { diff --git a/src/lib.rs b/src/lib.rs index e74951b..62853da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,17 @@ 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 soroban_sdk::token::TokenClient; +use soroban_sdk::{ + contract, contractimpl, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, +}; use types::asset::Asset; use types::error::Error; use types::{config_data::ConfigData, price_data::PriceData}; +const REFLECTOR: Symbol = symbol_short!("reflector"); +const DEFAULT_ASSET_TTL_DAYS: u32 = 365; //year in seconds + #[contract] pub struct PriceOracleContract; @@ -268,7 +274,84 @@ impl PriceOracleContract { .unwrap() } - //Admin section + // Returns the ttl for the given asset. + // + // # Arguments + // + // * `asset` - Asset to quote + // + // # Returns + // + // Asset expiration timestamp or None if the asset is not supported + pub fn asset_ttl(e: &Env, asset: Asset) -> Option { + let asset_index = e.get_asset_index(&asset); + if asset_index.is_none() { + e.panic_with_error(Error::AssetMissing); + } + let expirations = e.get_asset_ttls(); + let asset_index = asset_index.unwrap() as u32; + return expirations.get(asset_index); + } + + // Extends the asset expiration date by given number of days. + // + // # Arguments + // + // * `sponsor` - Sponsor account address + // * `asset` - Asset to quote + // * `days` - Number of days to extend the expiration date + // + // # Panics + // + // Panics if the asset is not supported, or if the fee token or fee itself are not set + pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, days: u32) { + sponsor.require_auth(); //check if the sponsor is the caller + //ensure that the asset is supported + let asset_index = e.get_asset_index(&asset); + if asset_index.is_none() { + e.panic_with_error(Error::AssetMissing); + } + let asset_index = asset_index.unwrap() as u32; + + //ensure that the fee token and fee are set + let fee_data = e.get_fee_data(); + if fee_data.is_none() { + e.panic_with_error(Error::InvalidConfigVersion); + } + let (fee_token, fee) = fee_data.unwrap(); + + //get total fee amount + let fee_amount = fee.checked_mul(days.into()).unwrap(); + if fee_amount > 0 { + //burn the fee token + TokenClient::new(&e, &fee_token).burn(&sponsor, &fee_amount); + } + + //load assets ttl + let mut asset_ttls = e.get_asset_ttls(); + let mut asset_ttl = asset_ttls + .get(asset_index) + .unwrap_or_default(); + let now = now(&e); + if asset_ttl == 0 || asset_ttl < now { + //if the asset is not set or expired, set it to now + asset_ttl = now; + } + asset_ttl = asset_ttl + .checked_add(days_to_milliseconds(days)) + .unwrap(); + asset_ttls.set(asset_index, asset_ttl); + e.set_asset_ttls(&asset_ttls) + } + + // Returns the fee token address and fee amount. + // + // # Returns + // + // Fee token address + pub fn fee(e: Env) -> Option<(Address, i128)> { + e.get_fee_data() + } // Returns admin address of the contract. // @@ -279,6 +362,34 @@ impl PriceOracleContract { e.get_admin() } + //Admin section + + // Sets the fee token address and fee. Can be invoked only by the admin account. + // + // # Arguments + // + // * `fee_data` - Fee token address and fee amount + // + // # Panics + // + // Panics if the caller doesn't match admin address, or not initialized yet + pub fn set_fee(e: Env, fee_data: (Address, i128)) { + e.panic_if_not_admin(); + + e.set_fee_data(fee_data); + let mut asset_ttls = e.get_asset_ttls(); + if asset_ttls.len() > 0 { + return; //the ttls are already set, so we don't need to set them again + } + //set the asset ttls to 365 days for all assets + let assets = e.get_assets(); + let ttl = now(&e).checked_add(days_to_milliseconds(DEFAULT_ASSET_TTL_DAYS)).unwrap(); + for _ in 0..assets.len() { + asset_ttls.push_back(ttl); + } + e.set_asset_ttls(&asset_ttls); + } + // Updates the contract configuration parameters. Can be invoked only by the admin account. // // # Arguments @@ -300,7 +411,7 @@ impl PriceOracleContract { e.set_resolution(config.resolution); e.set_retention_period(config.period); - Self::__add_assets(&e, config.assets); + add_assets(&e, config.assets); } // Adds given assets to the contract quoted assets list. Can be invoked only by the admin account. @@ -316,7 +427,7 @@ impl PriceOracleContract { // 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); + add_assets(&e, assets); } // Sets history retention period for the prices. Can be invoked only by the admin account. @@ -368,8 +479,15 @@ impl PriceOracleContract { //get the last timestamp let last_timestamp = e.get_last_timestamp(); + let assets = e.get_assets(); + let mut asset_prices: Vec<(Asset, i128)> = Vec::new(&e); //iterate over the updates for (i, price) in updates.iter().enumerate() { + let asset = assets.get(i as u32); + if asset.is_some() { + //asset can be None if the asset was added but the update is not applied yet + asset_prices.push_back((asset.unwrap(), price)); + } //don't store zero prices if price == 0 { continue; @@ -381,6 +499,10 @@ impl PriceOracleContract { if timestamp > last_timestamp { e.set_last_timestamp(timestamp); } + e.events().publish( + (REFLECTOR, symbol_short!("prices"), symbol_short!("update")), + asset_prices, + ); } // Updates the contract source code. Can be invoked only by the admin account. @@ -397,22 +519,6 @@ impl PriceOracleContract { 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>( @@ -578,3 +684,33 @@ fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { timestamp: timestamp / 1000, //convert to seconds } } + +fn add_assets(e: &Env, assets: Vec) { + let ttl = now(&e).checked_add(days_to_milliseconds(DEFAULT_ASSET_TTL_DAYS)).unwrap(); + let mut current_assets = e.get_assets(); + let mut asset_ttls = e.get_asset_ttls(); + let is_fee_initialized = e.get_fee_data().is_some(); + 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 the fee is not initialized, we don't need to set the ttl. Otherwise it can lead to inconsistent state + if is_fee_initialized { + //set expiration to 365 days for the new asset + asset_ttls.push_back(ttl); + } + } + if current_assets.len() >= 256 { + panic_with_error!(&e, Error::AssetLimitExceeded); + } + e.set_assets(current_assets); + e.set_asset_ttls(&asset_ttls); +} + +fn days_to_milliseconds(days: u32) -> u64 { + (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds +} \ No newline at end of file diff --git a/src/test.rs b/src/test.rs index b9de8a0..a3007da 100644 --- a/src/test.rs +++ b/src/test.rs @@ -5,8 +5,7 @@ extern crate std; use super::*; use alloc::string::ToString; use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, - Address, Env, String, Symbol, TryIntoVal, + testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, token::StellarAssetClient, Address, Env, String, Symbol, TryIntoVal }; use std::panic::{self, AssertUnwindSafe}; @@ -36,10 +35,10 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", )); - env.register_contract(contract_id, PriceOracleContract); + env.register_at(contract_id, PriceOracleContract, ()); let client: PriceOracleContractClient<'a> = PriceOracleContractClient::new(&env, contract_id); - env.budget().reset_unlimited(); + env.cost_estimate().budget().reset_unlimited(); let init_data = ConfigData { admin: admin.clone(), @@ -47,7 +46,7 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config assets: generate_assets(&env, 10, 0), base_asset: Asset::Stellar(Address::generate(&env)), decimals: 14, - resolution: RESOLUTION, + resolution: RESOLUTION }; env.mock_all_auths(); @@ -281,7 +280,7 @@ fn assets_update_overflow_test() { env.mock_all_auths(); - env.budget().reset_unlimited(); + env.cost_estimate().budget().reset_unlimited(); let mut assets = Vec::new(&env); for i in 1..=256 { @@ -301,7 +300,7 @@ fn prices_update_overflow_test() { env.mock_all_auths(); - env.budget().reset_unlimited(); + env.cost_estimate().budget().reset_unlimited(); let mut updates = Vec::new(&env); for i in 1..=256 { @@ -679,3 +678,41 @@ fn div_tests() { } } } + +#[test] +fn set_fee_test() { + let (env, client, init_data) = init_contract_with_admin(); + + //emulate old contract state + env.as_contract(&client.address, || { + env.storage().instance().remove(&"fee"); + env.storage().instance().remove(&"asset_ttls"); + }); + + let fee_data = client.fee(); + assert!(fee_data.is_none()); + + //create fee asset token + let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); + + let fee_data = (fee_asset.address(), 10); + + client.set_fee(&fee_data); + + let result = client.fee(); + assert!(result.is_some()); + assert_eq!(result.unwrap(), fee_data); + + let asset: Asset = init_data.assets.get_unchecked(0); + + let asset_ttls = client.asset_ttl(&asset); + assert!(asset_ttls.is_some()); + + let sponsor = Address::generate(&env); + let fee_token = StellarAssetClient::new(&env, &fee_data.0); + fee_token.mint(&sponsor, &100); + + let asset_ttl = client.asset_ttl(&asset).unwrap(); + client.extend_asset_ttl(&sponsor, &asset, &10); + assert_eq!(client.asset_ttl(&asset).unwrap(), asset_ttl + days_to_milliseconds(10)); +} \ No newline at end of file From 24d65bd693770cb40a080693ff30bb959e7f3935 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 9 Jun 2025 22:16:42 +0000 Subject: [PATCH 02/55] Add estimate_extend fn Rearrange and cleanup interfaces Rename storage keys --- .git-blame-ignore-revs | 3 - .gitignore | 3 +- Cargo.lock | 4 +- Cargo.toml | 2 +- src/extensions/env_extensions.rs | 30 ++--- src/lib.rs | 184 ++++++++++++++++++------------- src/test.rs | 31 +++--- 7 files changed, 143 insertions(+), 114 deletions(-) delete mode 100644 .git-blame-ignore-revs 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/.gitignore b/.gitignore index ae993aa..fa1b716 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ +/.idea /.soroban /target /price-oracle/test_snapshots -/test_snapshots \ No newline at end of file +/test_snapshots diff --git a/Cargo.lock b/Cargo.lock index 0ec4757..98f828f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -894,7 +894,7 @@ dependencies = [ [[package]] name = "reflector-oracle" -version = "4.2.0" +version = "5.0.0" dependencies = [ "soroban-sdk", ] diff --git a/Cargo.toml b/Cargo.toml index 80ebfe1..771e101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflector-oracle" -version = "4.2.0" +version = "5.0.0" edition = "2021" [lib] diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index a46da6d..ea9901f 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -14,8 +14,8 @@ const ASSETS: &str = "assets"; const BASE_ASSET: &str = "base_asset"; const DECIMALS: &str = "decimals"; const RESOLUTION: &str = "resolution"; -const ASSET_TTLS: &str = "asset_ttls"; -const FEE: &str = "fee"; +const EXPIRATION: &str = "expiration"; +const RETENTION: &str = "retention"; pub trait EnvExtensions { fn get_admin(&self) -> Option
; @@ -54,13 +54,13 @@ pub trait EnvExtensions { fn get_asset_index(&self, asset: &Asset) -> Option; - fn set_asset_ttls(&self, assets: &Vec); + fn set_expiration(&self, assets: &Vec); - fn get_asset_ttls(&self) -> Vec; + fn get_expiration(&self) -> Vec; - fn set_fee_data(&self, fee_data: (Address, i128)); + fn set_retention_config(&self, fee_data: (Address, i128)); - fn get_fee_data(&self) -> Option<(Address, i128)>; + fn get_retention_config(&self) -> Option<(Address, i128)>; fn panic_if_not_admin(&self); @@ -179,25 +179,25 @@ impl EnvExtensions for Env { if index.is_none() { return None; } - return Some(index.unwrap() as u8); + Some(index.unwrap() as u8) //case to u8 } - fn set_asset_ttls(&self, assets: &Vec) { - get_instance_storage(self).set(&ASSET_TTLS, assets) + fn set_expiration(&self, expiration: &Vec) { + get_instance_storage(self).set(&EXPIRATION, expiration) } - fn get_asset_ttls(&self) -> Vec { + fn get_expiration(&self) -> Vec { get_instance_storage(self) - .get(&ASSET_TTLS) + .get(&EXPIRATION) .unwrap_or_else(|| Vec::new(self)) } - fn set_fee_data(&self, fee_data: (Address, i128)) { - get_instance_storage(self).set(&FEE, &fee_data); + fn set_retention_config(&self, retention_config: (Address, i128)) { + get_instance_storage(self).set(&RETENTION, &retention_config); } - fn get_fee_data(&self) -> Option<(Address, i128)> { - get_instance_storage(self).get(&FEE) + fn get_retention_config(&self) -> Option<(Address, i128)> { + get_instance_storage(self).get(&RETENTION) } fn panic_if_not_admin(&self) { diff --git a/src/lib.rs b/src/lib.rs index 62853da..8852aea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,23 +4,21 @@ mod extensions; mod test; mod types; -use extensions::i128_extensions::I128Extensions; -use extensions::{env_extensions::EnvExtensions, u64_extensions::U64Extensions}; -use soroban_sdk::token::TokenClient; -use soroban_sdk::{ - contract, contractimpl, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, +use extensions::{ + env_extensions::EnvExtensions, i128_extensions::I128Extensions, u64_extensions::U64Extensions, }; -use types::asset::Asset; -use types::error::Error; +use soroban_sdk::token::TokenClient; +use soroban_sdk::{panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec}; +use types::{asset::Asset, error::Error}; use types::{config_data::ConfigData, price_data::PriceData}; const REFLECTOR: Symbol = symbol_short!("reflector"); -const DEFAULT_ASSET_TTL_DAYS: u32 = 365; //year in seconds +const DEFAULT_EXPIRATION_PERIOD: u32 = 365; //days in year -#[contract] +#[soroban_sdk::contract] pub struct PriceOracleContract; -#[contractimpl] +#[soroban_sdk::contractimpl] impl PriceOracleContract { // Returns the base asset the price is reported in. // @@ -274,83 +272,109 @@ impl PriceOracleContract { .unwrap() } - // Returns the ttl for the given asset. + // Returns the expiration date for a given asset. // // # Arguments // - // * `asset` - Asset to quote + // * `asset` - Quoted asset // // # Returns // // Asset expiration timestamp or None if the asset is not supported - pub fn asset_ttl(e: &Env, asset: Asset) -> Option { + pub fn expires(e: &Env, asset: Asset) -> Option { let asset_index = e.get_asset_index(&asset); if asset_index.is_none() { e.panic_with_error(Error::AssetMissing); } - let expirations = e.get_asset_ttls(); + let expirations = e.get_expiration(); let asset_index = asset_index.unwrap() as u32; - return expirations.get(asset_index); + expirations.get(asset_index) } - // Extends the asset expiration date by given number of days. + // Extends the asset expiration date by a given number of days. // // # Arguments // - // * `sponsor` - Sponsor account address - // * `asset` - Asset to quote - // * `days` - Number of days to extend the expiration date + // * `sponsor` - Sponsor account address that burns tokens + // * `asset` - Quoted asset + // * `days` - Number of days to add // // # Panics // // Panics if the asset is not supported, or if the fee token or fee itself are not set - pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, days: u32) { - sponsor.require_auth(); //check if the sponsor is the caller + pub fn extend(e: &Env, sponsor: Address, asset: Asset, days: u32) { + //check sponsor authorization + sponsor.require_auth(); //ensure that the asset is supported let asset_index = e.get_asset_index(&asset); if asset_index.is_none() { e.panic_with_error(Error::AssetMissing); } let asset_index = asset_index.unwrap() as u32; - //ensure that the fee token and fee are set - let fee_data = e.get_fee_data(); + let fee_data = e.get_retention_config(); if fee_data.is_none() { e.panic_with_error(Error::InvalidConfigVersion); } let (fee_token, fee) = fee_data.unwrap(); - //get total fee amount - let fee_amount = fee.checked_mul(days.into()).unwrap(); - if fee_amount > 0 { - //burn the fee token - TokenClient::new(&e, &fee_token).burn(&sponsor, &fee_amount); + //calculate amount of tokens to charge + let charge = fee.checked_mul(days.into()).unwrap(); + if charge == 0 { + return; } - //load assets ttl - let mut asset_ttls = e.get_asset_ttls(); - let mut asset_ttl = asset_ttls - .get(asset_index) - .unwrap_or_default(); + //burn the corresponding amount of fee tokens + TokenClient::new(&e, &fee_token).burn(&sponsor, &charge); + + //load expiration info + let mut expiration = e.get_expiration(); + let mut asset_expiration = expiration.get(asset_index).unwrap_or_default(); let now = now(&e); - if asset_ttl == 0 || asset_ttl < now { - //if the asset is not set or expired, set it to now - asset_ttl = now; + //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; } - asset_ttl = asset_ttl + //bump expiration + asset_expiration = asset_expiration .checked_add(days_to_milliseconds(days)) .unwrap(); - asset_ttls.set(asset_index, asset_ttl); - e.set_asset_ttls(&asset_ttls) + //write the vector that holds expiration dates for all symbols + expiration.set(asset_index, asset_expiration); + //update instance + e.set_expiration(&expiration) + } + + // Estimates the cost of asset retention bump + // + // # Arguments + // + // * `days` - Number of days + // + // # Returns + // + // Amount that will be charged for the expiration bump for a given number of days + // + // # Panics + // + // Panics if the retention config hasn't been initialized + pub fn estimate_extend(e: &Env, days: u32) -> i128 { + let fee_data = e.get_retention_config(); + if fee_data.is_none() { + e.panic_with_error(Error::InvalidConfigVersion); + } + let (_, fee) = fee_data.unwrap(); + + fee.checked_mul(days.into()).unwrap() } - // Returns the fee token address and fee amount. + // Returns the fee token address and daily retainer fee amount. // // # Returns // - // Fee token address - pub fn fee(e: Env) -> Option<(Address, i128)> { - e.get_fee_data() + // Fee token address and daily price feed retainer fee amount + pub fn retention_config(e: Env) -> Option<(Address, i128)> { + e.get_retention_config() } // Returns admin address of the contract. @@ -364,32 +388,6 @@ impl PriceOracleContract { //Admin section - // Sets the fee token address and fee. Can be invoked only by the admin account. - // - // # Arguments - // - // * `fee_data` - Fee token address and fee amount - // - // # Panics - // - // Panics if the caller doesn't match admin address, or not initialized yet - pub fn set_fee(e: Env, fee_data: (Address, i128)) { - e.panic_if_not_admin(); - - e.set_fee_data(fee_data); - let mut asset_ttls = e.get_asset_ttls(); - if asset_ttls.len() > 0 { - return; //the ttls are already set, so we don't need to set them again - } - //set the asset ttls to 365 days for all assets - let assets = e.get_assets(); - let ttl = now(&e).checked_add(days_to_milliseconds(DEFAULT_ASSET_TTL_DAYS)).unwrap(); - for _ in 0..assets.len() { - asset_ttls.push_back(ttl); - } - e.set_asset_ttls(&asset_ttls); - } - // Updates the contract configuration parameters. Can be invoked only by the admin account. // // # Arguments @@ -446,6 +444,34 @@ impl PriceOracleContract { e.set_retention_period(period); } + // Sets the fee token address and daily retainer fee amount. + // Can be invoked only by the admin account. + // + // # Arguments + // + // * `fee_config` - Fee token address and fee amount + // + // # Panics + // + // Panics if the caller doesn't match admin address, or not initialized yet + pub fn set_retention_config(e: Env, retention_config: (Address, i128)) { + e.panic_if_not_admin(); + e.set_retention_config(retention_config); + let mut expiration = e.get_expiration(); + if expiration.len() > 0 { + return; // expiration values for existing price feeds already initialized + } + //init expiration, set 365 days for all symbols by default + let exp = now(&e) + .checked_add(days_to_milliseconds(DEFAULT_EXPIRATION_PERIOD)) + .unwrap(); + let assets = e.get_assets(); + for _ in 0..assets.len() { + expiration.push_back(exp); + } + e.set_expiration(&expiration); + } + // Record new price feed history snapshot. Can be invoked only by the admin account. // // # Arguments @@ -686,10 +712,13 @@ fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { } fn add_assets(e: &Env, assets: Vec) { - let ttl = now(&e).checked_add(days_to_milliseconds(DEFAULT_ASSET_TTL_DAYS)).unwrap(); + //use default expiration period for new assets + let expiration_timestamp = now(&e) + .checked_add(days_to_milliseconds(DEFAULT_EXPIRATION_PERIOD)) + .unwrap(); let mut current_assets = e.get_assets(); - let mut asset_ttls = e.get_asset_ttls(); - let is_fee_initialized = e.get_fee_data().is_some(); + let mut expiration = e.get_expiration(); + let retention_config_initialized = e.get_retention_config().is_some(); for asset in assets.iter() { //check if the asset has been already added if e.get_asset_index(&asset).is_some() { @@ -698,19 +727,18 @@ fn add_assets(e: &Env, assets: Vec) { e.set_asset_index(&asset, current_assets.len()); current_assets.push_back(asset); - //if the fee is not initialized, we don't need to set the ttl. Otherwise it can lead to inconsistent state - if is_fee_initialized { - //set expiration to 365 days for the new asset - asset_ttls.push_back(ttl); + //if the fee is not initialized, we don't need to set the expiration + if retention_config_initialized { + expiration.push_back(expiration_timestamp); //set expiration } } if current_assets.len() >= 256 { panic_with_error!(&e, Error::AssetLimitExceeded); } e.set_assets(current_assets); - e.set_asset_ttls(&asset_ttls); + e.set_expiration(&expiration); } fn days_to_milliseconds(days: u32) -> u64 { (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds -} \ No newline at end of file +} diff --git a/src/test.rs b/src/test.rs index a3007da..a8cd714 100644 --- a/src/test.rs +++ b/src/test.rs @@ -680,39 +680,42 @@ fn div_tests() { } #[test] -fn set_fee_test() { +fn set_retention_config_test() { let (env, client, init_data) = init_contract_with_admin(); //emulate old contract state env.as_contract(&client.address, || { - env.storage().instance().remove(&"fee"); - env.storage().instance().remove(&"asset_ttls"); + env.storage().instance().remove(&"retention"); + env.storage().instance().remove(&"expiration"); }); - let fee_data = client.fee(); + let fee_data = client.retention_config(); assert!(fee_data.is_none()); //create fee asset token let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); - let fee_data = (fee_asset.address(), 10); + let retention_config = (fee_asset.address(), 7); - client.set_fee(&fee_data); + client.set_retention_config(&retention_config); - let result = client.fee(); + let result = client.retention_config(); assert!(result.is_some()); - assert_eq!(result.unwrap(), fee_data); + assert_eq!(result.unwrap(), retention_config); let asset: Asset = init_data.assets.get_unchecked(0); - let asset_ttls = client.asset_ttl(&asset); - assert!(asset_ttls.is_some()); + let expires = client.expires(&asset); + assert!(expires.is_some()); let sponsor = Address::generate(&env); - let fee_token = StellarAssetClient::new(&env, &fee_data.0); + let fee_token = StellarAssetClient::new(&env, &retention_config.0); fee_token.mint(&sponsor, &100); - let asset_ttl = client.asset_ttl(&asset).unwrap(); - client.extend_asset_ttl(&sponsor, &asset, &10); - assert_eq!(client.asset_ttl(&asset).unwrap(), asset_ttl + days_to_milliseconds(10)); + let bump_price = client.estimate_extend(&10); + assert_eq!(bump_price, 70); + + let symbol_expires = client.expires(&asset).unwrap(); + client.extend(&sponsor, &asset, &10); + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + days_to_milliseconds(10)); } \ No newline at end of file From 8453a9b664a734e9fc2c5bc2abf59e1e67b40be2 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Tue, 15 Jul 2025 16:51:18 +0300 Subject: [PATCH 03/55] implement new price storage mechanism --- src/extensions/env_extensions.rs | 78 ++++++++++- src/lib.rs | 234 ++++++++++++++++++++++++------- src/test.rs | 61 +++++++- src/types/config_data.rs | 2 + 4 files changed, 316 insertions(+), 59 deletions(-) diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index ea9901f..4a1a3e8 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -2,10 +2,9 @@ use soroban_sdk::storage::{Instance, Temporary}; use soroban_sdk::{panic_with_error, Address, Env, Vec}; -use crate::extensions; +use crate::extensions::u128_helper::U128Helper; 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"; @@ -16,6 +15,10 @@ const DECIMALS: &str = "decimals"; const RESOLUTION: &str = "resolution"; const EXPIRATION: &str = "expiration"; const RETENTION: &str = "retention"; +const CACHE: &str = "cache"; +const CACHE_SIZE: &str = "cache_size"; + +const V2_UPDATE_TS: &str = "v2_update_ts"; pub trait EnvExtensions { fn get_admin(&self) -> Option
; @@ -42,6 +45,18 @@ pub trait EnvExtensions { fn set_price(&self, asset: u8, price: i128, timestamp: u64, ledgers: u32); + fn get_prices(&self, timestamp: u64) -> Option>; + + fn set_prices(&self, prices: &Vec, timestamp: u64, ledgers: u32); + + fn get_cache(&self) -> Option)>>; + + fn set_cache(&self, prices: Vec<(u64,Vec)>); + + fn get_cache_size(&self) -> u32; + + fn set_cache_size(&self, cache_size: u32); + fn get_last_timestamp(&self) -> u64; fn set_last_timestamp(&self, timestamp: u64); @@ -52,7 +67,7 @@ pub trait EnvExtensions { fn set_asset_index(&self, asset: &Asset, index: u32); - fn get_asset_index(&self, asset: &Asset) -> Option; + fn get_asset_index(&self, asset: &Asset) -> Option; fn set_expiration(&self, assets: &Vec); @@ -65,6 +80,10 @@ pub trait EnvExtensions { fn panic_if_not_admin(&self); fn is_initialized(&self) -> bool; + + fn get_v2_update_ts(&self) -> u64; + + fn set_v2_update_ts(&self, timestamp: u64); } impl EnvExtensions for Env { @@ -134,6 +153,47 @@ impl EnvExtensions for Env { } } + fn get_prices(&self, timestamp: u64) -> Option> { + //check if the timestamp is in the cache + let cache = self.get_cache(); + 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 + get_temporary_storage(self).get(×tamp) + } + + fn set_prices(&self, prices: &Vec, timestamp: u64, ledgers: u32) { + //set the price + let temps_storage = get_temporary_storage(&self); + temps_storage.set(×tamp, prices); + if ledgers > 16 { + //16 is the minimum number + temps_storage.extend_ttl(×tamp, ledgers, ledgers) + } + } + + fn get_cache(&self) -> Option)>> { + get_instance_storage(self).get(&CACHE) + } + + fn set_cache(&self, prices: Vec<(u64,Vec)>) { + get_instance_storage(&self).set(&CACHE, &prices); + } + + fn get_cache_size(&self) -> u32 { + get_instance_storage(self).get(&CACHE_SIZE).unwrap_or(0) + } + + fn set_cache_size(&self, cache_size: u32) { + get_instance_storage(&self).set(&CACHE_SIZE, &cache_size); + } + fn get_last_timestamp(&self) -> u64 { //get the marker get_instance_storage(&self) @@ -166,7 +226,7 @@ impl EnvExtensions for Env { } } - fn get_asset_index(&self, asset: &Asset) -> Option { + fn get_asset_index(&self, asset: &Asset) -> Option { let index: Option; match asset { Asset::Stellar(address) => { @@ -179,7 +239,7 @@ impl EnvExtensions for Env { if index.is_none() { return None; } - Some(index.unwrap() as u8) //case to u8 + Some(index.unwrap()) } fn set_expiration(&self, expiration: &Vec) { @@ -207,6 +267,14 @@ impl EnvExtensions for Env { } admin.unwrap().require_auth() } + + fn get_v2_update_ts(&self) -> u64 { + get_instance_storage(self).get(&V2_UPDATE_TS).unwrap_or(0) + } + + fn set_v2_update_ts(&self, timestamp: u64) { + get_instance_storage(self).set(&V2_UPDATE_TS, ×tamp); + } } fn get_instance_storage(e: &Env) -> Instance { diff --git a/src/lib.rs b/src/lib.rs index 8852aea..25829a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ impl PriceOracleContract { // # Returns // // Base asset for the contract - pub fn base(e: Env) -> Asset { + pub fn base(e: &Env) -> Asset { e.get_base_asset() } @@ -34,7 +34,7 @@ impl PriceOracleContract { // # Returns // // Number of decimals places in quoted prices - pub fn decimals(e: Env) -> u32 { + pub fn decimals(e: &Env) -> u32 { e.get_decimals() } @@ -43,7 +43,7 @@ impl PriceOracleContract { // # Returns // // Price feed resolution (in seconds) - pub fn resolution(e: Env) -> u32 { + pub fn resolution(e: &Env) -> u32 { e.get_resolution() / 1000 } @@ -52,7 +52,7 @@ impl PriceOracleContract { // # Returns // // History retention period (in seconds) - pub fn period(e: Env) -> Option { + pub fn period(e: &Env) -> Option { let period = e.get_retention_period(); if period == 0 { return None; @@ -61,12 +61,21 @@ impl PriceOracleContract { } } + // Returns the cache size for the prices. + // + // # Returns + // + // Cache size for the prices + pub fn cache_size(e: &Env) -> u32 { + e.get_cache_size() + } + // Returns all assets quoted by the contract. // // # Returns // // Assets quoted by the contract - pub fn assets(e: Env) -> Vec { + pub fn assets(e: &Env) -> Vec { e.get_assets() } @@ -75,7 +84,7 @@ impl PriceOracleContract { // # Returns // // Timestamp of the last recorded price update - pub fn last_timestamp(e: Env) -> u64 { + pub fn last_timestamp(e: &Env) -> u64 { e.get_last_timestamp() / 1000 //convert to seconds } @@ -89,7 +98,7 @@ impl PriceOracleContract { // # 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 { + 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()); @@ -106,7 +115,7 @@ impl PriceOracleContract { // # 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 { + pub fn lastprice(e: &Env, asset: Asset) -> Option { //get the last timestamp let timestamp = obtain_record_timestamp(&e); if timestamp == 0 { @@ -126,14 +135,21 @@ impl PriceOracleContract { // # Returns // // Prices for the given asset or None if the asset is not supported - pub fn prices(e: Env, asset: Asset, records: u32) -> Option> { + 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; } + if !is_legacy_expired(&e, now(&e)) { + return prices( + &e, + |timestamp| get_price_data_by_index_legacy(e, asset_index.unwrap() as u8, timestamp), + records, + ); + } prices( &e, - |timestamp| get_price_data_by_index(&e, asset_index.unwrap(), timestamp), + |timestamp| get_price_data_by_index(asset_index.unwrap(), timestamp, &e.get_prices(timestamp)), records, ) } @@ -148,7 +164,7 @@ impl PriceOracleContract { // # 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 { + 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; @@ -169,7 +185,7 @@ impl PriceOracleContract { // // 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, + e: &Env, base_asset: Asset, quote_asset: Asset, timestamp: u64, @@ -191,7 +207,7 @@ impl PriceOracleContract { // // 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, + e: &Env, base_asset: Asset, quote_asset: Asset, records: u32, @@ -220,14 +236,23 @@ impl PriceOracleContract { // # 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 { + 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; } + + if !is_legacy_expired(&e, now(&e)) { + return get_twap( + &e, + |timestamp| get_price_data_by_index_legacy(e, asset_index.unwrap() as u8, timestamp), + records, + ); + } + get_twap( &e, - |timestamp| get_price_data_by_index(&e, asset_index.unwrap(), timestamp), + |timestamp| get_price_data_by_index(asset_index.unwrap(), timestamp, &e.get_prices(timestamp)), records, ) } @@ -242,7 +267,7 @@ impl PriceOracleContract { // # 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 { + 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() { @@ -263,7 +288,7 @@ impl PriceOracleContract { // # Returns // // Contract protocol version - pub fn version(_e: Env) -> u32 { + pub fn version(_e: &Env) -> u32 { env!("CARGO_PKG_VERSION") .split(".") .next() @@ -373,7 +398,7 @@ impl PriceOracleContract { // # Returns // // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: Env) -> Option<(Address, i128)> { + pub fn retention_config(e: &Env) -> Option<(Address, i128)> { e.get_retention_config() } @@ -382,7 +407,7 @@ impl PriceOracleContract { // # Returns // // Contract admin account address - pub fn admin(e: Env) -> Option
{ + pub fn admin(e: &Env) -> Option
{ e.get_admin() } @@ -398,7 +423,7 @@ impl PriceOracleContract { // # Panics // // Panics if the contract is already initialized, or if the version is invalid - pub fn config(e: Env, config: ConfigData) { + pub fn config(e: &Env, config: ConfigData) { config.admin.require_auth(); if e.is_initialized() { e.panic_with_error(Error::AlreadyInitialized); @@ -408,10 +433,18 @@ impl PriceOracleContract { e.set_decimals(config.decimals); e.set_resolution(config.resolution); e.set_retention_period(config.period); + e.set_cache_size(config.cache_size); + //set update timestamp to 1 to indicate that contract is already v2 + e.set_v2_update_ts(1); add_assets(&e, config.assets); } + pub fn set_cache_size(e: &Env, cache_size: u32) { + e.panic_if_not_admin(); + e.set_cache_size(cache_size); + } + // Adds given assets to the contract quoted assets list. Can be invoked only by the admin account. // // # Arguments @@ -423,7 +456,7 @@ impl PriceOracleContract { // # 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) { + pub fn add_assets(e: &Env, assets: Vec) { e.panic_if_not_admin(); add_assets(&e, assets); } @@ -439,7 +472,7 @@ impl PriceOracleContract { // # 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) { + pub fn set_period(e: &Env, period: u64) { e.panic_if_not_admin(); e.set_retention_period(period); } @@ -454,7 +487,7 @@ impl PriceOracleContract { // # Panics // // Panics if the caller doesn't match admin address, or not initialized yet - pub fn set_retention_config(e: Env, retention_config: (Address, i128)) { + pub fn set_retention_config(e: &Env, retention_config: (Address, i128)) { e.panic_if_not_admin(); e.set_retention_config(retention_config); let mut expiration = e.get_expiration(); @@ -483,7 +516,7 @@ impl PriceOracleContract { // # 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) { + 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 { @@ -500,34 +533,41 @@ impl PriceOracleContract { let retention_period = e.get_retention_period(); + //TODO: compute ledgers per second, for now we assume 5 seconds per ledger let ledgers_to_live: u32 = ((retention_period / 1000 / 5) + 1) as u32; + ensure_v2_update_ts(&e); + //update legacy for 24 hours after v2 update + if !is_legacy_expired(&e, timestamp) { + update_price_legacy(&e, &updates, timestamp, ledgers_to_live); + } + //get the last timestamp let last_timestamp = e.get_last_timestamp(); - let assets = e.get_assets(); - let mut asset_prices: Vec<(Asset, i128)> = Vec::new(&e); - //iterate over the updates - for (i, price) in updates.iter().enumerate() { - let asset = assets.get(i as u32); - if asset.is_some() { - //asset can be None if the asset was added but the update is not applied yet - asset_prices.push_back((asset.unwrap(), price)); - } - //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); + //store the new prices + e.set_prices(&updates, timestamp, ledgers_to_live); + + //update the cache + let mut cache = e.get_cache().unwrap_or(Vec::new(&e)); + let cache_size = e.get_cache_size(); + cache.push_front((timestamp, updates.clone())); + while cache.len() > cache_size { + cache.pop_back(); //remove the oldest record if cache size exceeded } + if cache_size > 0 { //if cache size is set, update the cache + e.set_cache(cache); + } + + //update the last timestamp if timestamp > last_timestamp { e.set_last_timestamp(timestamp); } + + //publish the price updates e.events().publish( (REFLECTOR, symbol_short!("prices"), symbol_short!("update")), - asset_prices, + updates, ); } @@ -541,7 +581,7 @@ impl PriceOracleContract { // # Panics // // Panics if the caller doesn't match admin address - pub fn update_contract(env: Env, wasm_hash: BytesN<32>) { + pub fn update_contract(env: &Env, wasm_hash: BytesN<32>) { env.panic_if_not_admin(); env.deployer().update_current_contract_wasm(wasm_hash) } @@ -643,24 +683,31 @@ fn get_x_price( fn get_x_price_by_indexes( e: &Env, - asset_pair_indexes: (u8, u8), + asset_pair_indexes: (u32, u32), timestamp: u64, decimals: u32, ) -> Option { + + if !is_legacy_expired(e, now(e)) { + return get_x_price_by_indexes_legacy(e, asset_pair_indexes, timestamp, decimals); + } + 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)); } + + let prices = e.get_prices(timestamp); //get the price for base_asset - let base_asset_price = e.get_price(base_asset, timestamp); + let base_asset_price = get_price_data_by_index(base_asset, timestamp, &prices); if base_asset_price.is_none() { return None; } //get the price for quote_asset - let quote_asset_price = e.get_price(quote_asset, timestamp); + let quote_asset_price = get_price_data_by_index(quote_asset, timestamp, &prices); if quote_asset_price.is_none() { return None; } @@ -669,12 +716,13 @@ fn get_x_price_by_indexes( Some(get_normalized_price_data( base_asset_price .unwrap() - .fixed_div_floor(quote_asset_price.unwrap(), decimals), + .price + .fixed_div_floor(quote_asset_price.unwrap().price, decimals), timestamp, )) } -fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option<(u8, u8)> { +fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option<(u32, u32)> { let base_asset = e.get_asset_index(&base_asset); if base_asset.is_none() { return None; @@ -689,19 +737,30 @@ fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Opt } fn get_price_data(e: &Env, asset: Asset, timestamp: u64) -> Option { - let asset: Option = e.get_asset_index(&asset); + let asset: Option = e.get_asset_index(&asset); if asset.is_none() { return None; } - get_price_data_by_index(e, asset.unwrap(), timestamp) + if !is_legacy_expired(e, now(e)) { + return get_price_data_by_index_legacy(e, asset.unwrap() as u8, timestamp); + } + get_price_data_by_index(asset.unwrap(), timestamp, &e.get_prices(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() { +fn get_price_data_by_index(asset: u32, timestamp: u64, prices: &Option>) -> Option { + if prices.is_none() { return None; } - Some(get_normalized_price_data(price.unwrap(), timestamp)) + let asset = asset as u32; + let prices = prices.as_ref().unwrap(); + if prices.len() <= asset { + return None; + } + let price = prices.get_unchecked(asset); + if price == 0 { + return None; + } + Some(get_normalized_price_data(price, timestamp)) } fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { @@ -742,3 +801,72 @@ fn add_assets(e: &Env, assets: Vec) { fn days_to_milliseconds(days: u32) -> u64 { (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds } + +fn ensure_v2_update_ts(e: &Env) { + //ensure that the v2 update timestamp is set + if e.get_v2_update_ts() == 0 { + e.set_v2_update_ts(now(e)); + } +} + +fn is_legacy_expired(e: &Env, timestamp: u64) -> bool { + e.get_v2_update_ts() + days_to_milliseconds(1) < timestamp +} + +fn update_price_legacy(e: &Env, updates: &Vec, timestamp: u64, ledgers_to_live: u32) { + let assets = e.get_assets(); + let mut asset_prices: Vec<(Asset, i128)> = Vec::new(&e); + //iterate over the updates + for (i, price) in updates.iter().enumerate() { + let asset = assets.get(i as u32); + if asset.is_some() { + //asset can be None if the asset was added but the update is not applied yet + asset_prices.push_back((asset.unwrap(), price)); + } + //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); + } +} + +fn get_price_data_by_index_legacy(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_x_price_by_indexes_legacy( + e: &Env, + asset_pair_indexes: (u32, u32), + timestamp: u64, + decimals: u32, +) -> Option { + let (base_asset, quote_asset) = asset_pair_indexes; + + //get the price for base_asset + let base_asset_price = get_price_data_by_index_legacy(e, base_asset as u8, timestamp); + if base_asset_price.is_none() { + return None; + } + + //get the price for quote_asset + let quote_asset_price = get_price_data_by_index_legacy(e, quote_asset as u8, timestamp); + if quote_asset_price.is_none() { + return None; + } + + //calculate the cross price + Some(get_normalized_price_data( + base_asset_price + .unwrap() + .price + .fixed_div_floor(quote_asset_price.unwrap().price, decimals), + timestamp, + )) +} \ No newline at end of file diff --git a/src/test.rs b/src/test.rs index a8cd714..2f722ce 100644 --- a/src/test.rs +++ b/src/test.rs @@ -46,7 +46,8 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config assets: generate_assets(&env, 10, 0), base_asset: Asset::Stellar(Address::generate(&env)), decimals: 14, - resolution: RESOLUTION + resolution: RESOLUTION, + cache_size: 0 }; env.mock_all_auths(); @@ -215,6 +216,64 @@ fn last_price_test() { ); } +#[test] +fn prices_test() { + let (env, client, init_data) = init_contract_with_admin(); + + let assets = init_data.assets; + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&get_updates(&env, &assets, normalize_price(100)), &600_000); + client.set_price(&get_updates(&env, &assets, normalize_price(200)), &900_000); + + let result = client.prices(&assets.get_unchecked(1), &2); + assert_ne!(result, None); + assert_eq!( + result, + Some(Vec::from_array(&env, [ + PriceData { + price: normalize_price(200), + timestamp: convert_to_seconds(900_000) + }, + PriceData { + price: normalize_price(100), + timestamp: convert_to_seconds(600_000) + } + ])) + ); +} + +#[test] +fn x_prices_test() { + let (env, client, init_data) = init_contract_with_admin(); + + let assets = init_data.assets; + + env.mock_all_auths(); + + //set prices for assets + client.set_price(&get_updates(&env, &assets, normalize_price(100)), &600_000); + client.set_price(&get_updates(&env, &assets, normalize_price(200)), &900_000); + + let result = client.x_prices(&assets.get_unchecked(0), &assets.get_unchecked(1), &2); + assert_ne!(result, None); + assert_eq!( + result, + Some(Vec::from_array(&env, [ + PriceData { + price: normalize_price(1), + timestamp: convert_to_seconds(900_000) + }, + PriceData { + price: normalize_price(1), + timestamp: convert_to_seconds(600_000) + } + ])) + ); +} + #[test] fn last_timestamp_test() { let (env, client, init_data) = init_contract_with_admin(); diff --git a/src/types/config_data.rs b/src/types/config_data.rs index 6aa2381..f3e06c1 100644 --- a/src/types/config_data.rs +++ b/src/types/config_data.rs @@ -19,4 +19,6 @@ pub struct ConfigData { pub decimals: u32, // The resolution of the prices. pub resolution: u32, + // The cache size for the prices. + pub cache_size: u32 } From f4780218aed89c7bf9803ce4202fac24929381d5 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Tue, 5 Aug 2025 20:57:42 +0300 Subject: [PATCH 04/55] apply review comments --- Cargo.lock | 22 +-- Cargo.toml | 6 +- src/extensions/env_extensions.rs | 75 ++++--- src/lib.rs | 330 +++++++++++++------------------ src/test.rs | 39 ++-- src/types/config_data.rs | 8 +- src/types/error.rs | 2 + src/types/mod.rs | 1 + src/types/retention_config.rs | 8 + 9 files changed, 228 insertions(+), 263 deletions(-) create mode 100644 src/types/retention_config.rs diff --git a/Cargo.lock b/Cargo.lock index 98f828f..fa3ac59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,7 +894,7 @@ dependencies = [ [[package]] name = "reflector-oracle" -version = "5.0.0" +version = "6.0.0" dependencies = [ "soroban-sdk", ] @@ -1135,9 +1135,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "22.0.7" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bbe59497cb50e81861187e6bd2a2c805df253573d44ed56e7d373f79530758" +checksum = "2826e2c9d364edbb2ea112dc861077c74557bdad0a7a00487969088c7c648169" dependencies = [ "serde", "serde_json", @@ -1149,9 +1149,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "22.0.7" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85edd55eb09aa5dd7ba5ab595d2be7ac3f453e90e2f26d704ff26c130f2926f" +checksum = "c7ac27d7573e62b745513fa1be8dab7a09b9676a7f39db97164f1d458a344749" dependencies = [ "arbitrary", "bytes-lit", @@ -1171,9 +1171,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "22.0.7" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a141230aa65006d4b3eeee9d0589172d734a2abfbe15b84670e38e76e200b370" +checksum = "9ef0d7d62b2584696d306b8766728971c7d0731a03a5e047f1fc68722ac8cf0c" dependencies = [ "crate-git-revision", "darling", @@ -1191,9 +1191,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "22.0.7" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54326e9516b33be99c701b37242b27efb8e66cc1f1eff994b9d9a013a4be136" +checksum = "a4ad0867aec99770ed614fedbec7ac4591791df162ff9e548ab7ebd07cd23a9c" dependencies = [ "base64 0.13.1", "stellar-xdr", @@ -1203,9 +1203,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "22.0.7" +version = "22.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f009cab4dfd653bc94a06c5022f1ca9d30e198b0e451f84cf307231563d11de2" +checksum = "aebe31c042adfa2885ec47b67b08fcead8707da80a3fe737eaf2a9ae1a8cfdc3" dependencies = [ "prettyplease", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 771e101..b8998e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "reflector-oracle" -version = "5.0.0" +version = "6.0.0" edition = "2021" [lib] @@ -17,10 +17,10 @@ codegen-units = 1 lto = true [dependencies] -soroban-sdk = "22.0.7" +soroban-sdk = "22.0.8" [dev-dependencies] -soroban-sdk = { version = "22.0.7", features = ["testutils"] } +soroban-sdk = { version = "22.0.8", features = ["testutils"] } [features] testutils = ["soroban-sdk/testutils"] diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index 4a1a3e8..c318e91 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -5,7 +5,7 @@ use soroban_sdk::{panic_with_error, Address, Env, Vec}; use crate::extensions::u128_helper::U128Helper; use crate::types; -use types::{asset::Asset, error::Error}; +use types::{asset::Asset, error::Error, retention_config::RetentionConfig}; const ADMIN_KEY: &str = "admin"; const LAST_TIMESTAMP: &str = "last_timestamp"; const RETENTION_PERIOD: &str = "period"; @@ -18,7 +18,11 @@ const RETENTION: &str = "retention"; const CACHE: &str = "cache"; const CACHE_SIZE: &str = "cache_size"; -const V2_UPDATE_TS: &str = "v2_update_ts"; +const UPDATE_TS: &str = "update_ts"; +const PROTOCOL: &str = "protocol"; + +const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; +const DEFAULT_RETENTION_FEE: i128 = 100_000_000; pub trait EnvExtensions { fn get_admin(&self) -> Option
; @@ -37,17 +41,17 @@ pub trait EnvExtensions { fn set_resolution(&self, resolution: u32); - fn get_retention_period(&self) -> u64; + fn get_history_retention_period(&self) -> u64; - fn set_retention_period(&self, period: u64); + fn set_history_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 set_price(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32); fn get_prices(&self, timestamp: u64) -> Option>; - fn set_prices(&self, prices: &Vec, timestamp: u64, ledgers: u32); + fn set_prices(&self, prices: &Vec, timestamp: u64, bump_ledgers_count: u32); fn get_cache(&self) -> Option)>>; @@ -73,17 +77,21 @@ pub trait EnvExtensions { fn get_expiration(&self) -> Vec; - fn set_retention_config(&self, fee_data: (Address, i128)); + fn set_retention_config(&self, retention_config: RetentionConfig); - fn get_retention_config(&self) -> Option<(Address, i128)>; + fn get_retention_config(&self) -> RetentionConfig; fn panic_if_not_admin(&self); fn is_initialized(&self) -> bool; - fn get_v2_update_ts(&self) -> u64; + fn get_update_ts(&self) -> u64; + + fn set_update_ts(&self, timestamp: u64); + + fn get_protocol(&self) -> u32; - fn set_v2_update_ts(&self, timestamp: u64); + fn set_protocol(&self, protocol: u32); } impl EnvExtensions for Env { @@ -123,13 +131,13 @@ impl EnvExtensions for Env { get_instance_storage(&self).set(&RESOLUTION, &resolution) } - fn get_retention_period(&self) -> u64 { + fn get_history_retention_period(&self) -> u64 { get_instance_storage(&self) .get(&RETENTION_PERIOD) .unwrap_or_default() } - fn set_retention_period(&self, rdm_period: u64) { + fn set_history_retention_period(&self, rdm_period: u64) { get_instance_storage(&self).set(&RETENTION_PERIOD, &rdm_period); } @@ -140,16 +148,16 @@ impl EnvExtensions for Env { get_temporary_storage(self).get(&data_key) } - fn set_price(&self, asset: u8, price: i128, timestamp: u64, ledgers_to_live: u32) { + fn set_price(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: 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 { + if bump_ledgers_count > 16 { //16 is the minimum number - temps_storage.extend_ttl(&data_key, ledgers_to_live, ledgers_to_live) + temps_storage.extend_ttl(&data_key, bump_ledgers_count, bump_ledgers_count) } } @@ -168,13 +176,13 @@ impl EnvExtensions for Env { get_temporary_storage(self).get(×tamp) } - fn set_prices(&self, prices: &Vec, timestamp: u64, ledgers: u32) { + fn set_prices(&self, prices: &Vec, timestamp: u64, bump_ledgers_count: u32) { //set the price let temps_storage = get_temporary_storage(&self); temps_storage.set(×tamp, prices); - if ledgers > 16 { + if bump_ledgers_count > 16 { //16 is the minimum number - temps_storage.extend_ttl(×tamp, ledgers, ledgers) + temps_storage.extend_ttl(×tamp, bump_ledgers_count, bump_ledgers_count) } } @@ -187,7 +195,7 @@ impl EnvExtensions for Env { } fn get_cache_size(&self) -> u32 { - get_instance_storage(self).get(&CACHE_SIZE).unwrap_or(0) + get_instance_storage(self).get(&CACHE_SIZE).unwrap_or(2) } fn set_cache_size(&self, cache_size: u32) { @@ -236,10 +244,7 @@ impl EnvExtensions for Env { index = get_instance_storage(self).get(&symbol); } } - if index.is_none() { - return None; - } - Some(index.unwrap()) + index } fn set_expiration(&self, expiration: &Vec) { @@ -252,12 +257,14 @@ impl EnvExtensions for Env { .unwrap_or_else(|| Vec::new(self)) } - fn set_retention_config(&self, retention_config: (Address, i128)) { + fn set_retention_config(&self, retention_config: RetentionConfig) { get_instance_storage(self).set(&RETENTION, &retention_config); } - fn get_retention_config(&self) -> Option<(Address, i128)> { - get_instance_storage(self).get(&RETENTION) + fn get_retention_config(&self) -> RetentionConfig { + get_instance_storage(self) + .get(&RETENTION) + .unwrap_or_else(|| RetentionConfig::Some((Address::from_str(&self, XRF_TOKEN_ADDRESS), DEFAULT_RETENTION_FEE))) } fn panic_if_not_admin(&self) { @@ -268,12 +275,20 @@ impl EnvExtensions for Env { admin.unwrap().require_auth() } - fn get_v2_update_ts(&self) -> u64 { - get_instance_storage(self).get(&V2_UPDATE_TS).unwrap_or(0) + fn get_update_ts(&self) -> u64 { + get_instance_storage(self).get(&UPDATE_TS).unwrap_or(0) + } + + fn set_update_ts(&self, timestamp: u64) { + get_instance_storage(self).set(&UPDATE_TS, ×tamp); + } + + fn get_protocol(&self) -> u32 { + get_instance_storage(self).get(&PROTOCOL).unwrap_or(1) } - fn set_v2_update_ts(&self, timestamp: u64) { - get_instance_storage(self).set(&V2_UPDATE_TS, ×tamp); + fn set_protocol(&self, protocol: u32) { + get_instance_storage(self).set(&PROTOCOL, &protocol); } } diff --git a/src/lib.rs b/src/lib.rs index 25829a9..d811165 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,8 +12,11 @@ use soroban_sdk::{panic_with_error, symbol_short, Address, BytesN, Env, Symbol, use types::{asset::Asset, error::Error}; use types::{config_data::ConfigData, price_data::PriceData}; +use crate::types::retention_config::RetentionConfig; + const REFLECTOR: Symbol = symbol_short!("reflector"); -const DEFAULT_EXPIRATION_PERIOD: u32 = 365; //days in year +const INITIAL_EXPIRATION_PERIOD: u32 = 365; //days in year +const CURRENT_PROTOCOL: u32 = 2; //current protocol version #[soroban_sdk::contract] pub struct PriceOracleContract; @@ -52,8 +55,8 @@ impl PriceOracleContract { // # Returns // // History retention period (in seconds) - pub fn period(e: &Env) -> Option { - let period = e.get_retention_period(); + pub fn history_retention_period(e: &Env) -> Option { + let period: u64 = e.get_history_retention_period(); if period == 0 { return None; } else { @@ -136,20 +139,10 @@ impl PriceOracleContract { // // 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; - } - if !is_legacy_expired(&e, now(&e)) { - return prices( - &e, - |timestamp| get_price_data_by_index_legacy(e, asset_index.unwrap() as u8, timestamp), - records, - ); - } + let asset_index = e.get_asset_index(&asset)?; //get the asset index to avoid multiple calls prices( &e, - |timestamp| get_price_data_by_index(asset_index.unwrap(), timestamp, &e.get_prices(timestamp)), + |timestamp| get_price_data_by_index(e, asset_index, timestamp, &e.get_prices(timestamp)), records, ) } @@ -212,15 +205,12 @@ impl PriceOracleContract { 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 asset_pair_indexes = get_asset_pair_indexes(&e, base_asset, quote_asset)?; let decimals = e.get_decimals(); prices( &e, |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes.unwrap(), timestamp, decimals) + get_x_price_by_indexes(&e, asset_pair_indexes, timestamp, decimals) }, records, ) @@ -237,22 +227,10 @@ impl PriceOracleContract { // // 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; - } - - if !is_legacy_expired(&e, now(&e)) { - return get_twap( - &e, - |timestamp| get_price_data_by_index_legacy(e, asset_index.unwrap() as u8, timestamp), - records, - ); - } - + let asset_index = e.get_asset_index(&asset)?; //get the asset index to avoid multiple calls get_twap( &e, - |timestamp| get_price_data_by_index(asset_index.unwrap(), timestamp, &e.get_prices(timestamp)), + |timestamp| get_price_data_by_index(e, asset_index, timestamp, &e.get_prices(timestamp)), records, ) } @@ -269,15 +247,12 @@ impl PriceOracleContract { // 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 asset_pair_indexes = get_asset_pair_indexes(&e, base_asset, quote_asset)?; let decimals = e.get_decimals(); get_twap( &e, |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes.unwrap(), timestamp, decimals) + get_x_price_by_indexes(&e, asset_pair_indexes, timestamp, decimals) }, records, ) @@ -312,93 +287,84 @@ impl PriceOracleContract { e.panic_with_error(Error::AssetMissing); } let expirations = e.get_expiration(); - let asset_index = asset_index.unwrap() as u32; - expirations.get(asset_index) + expirations.get(asset_index.unwrap() as u32) } - // Extends the asset expiration date by a given number of days. + // Extends the asset expiration date by a given amount of tokens. // // # Arguments // // * `sponsor` - Sponsor account address that burns tokens // * `asset` - Quoted asset - // * `days` - Number of days to add + // * `amount` - Amount of tokens to burn for extending the expiration date // // # Panics // // Panics if the asset is not supported, or if the fee token or fee itself are not set - pub fn extend(e: &Env, sponsor: Address, asset: Asset, days: u32) { + pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) { //check sponsor authorization sponsor.require_auth(); + //check if the amount is valid + if amount <= 0 { + e.panic_with_error(Error::InvalidAmount); + } //ensure that the asset is supported let asset_index = e.get_asset_index(&asset); if asset_index.is_none() { e.panic_with_error(Error::AssetMissing); } let asset_index = asset_index.unwrap() as u32; - //ensure that the fee token and fee are set - let fee_data = e.get_retention_config(); - if fee_data.is_none() { - e.panic_with_error(Error::InvalidConfigVersion); + + let (token, fee) = match e.get_retention_config() { + RetentionConfig::Some(fee_data) => { + if fee_data.1 <= 0 { + e.panic_with_error(Error::InvalidConfigVersion); + } + fee_data + } + RetentionConfig::None => { + e.panic_with_error(Error::InvalidConfigVersion); + } + }; + + //get minutes to extend + let minutes = amount * 1440 / fee; //minute is min period to extend. fee is value per day, so we divide by minutes in a day + if minutes <= 0 { + e.panic_with_error(Error::InvalidAmount); } - let (fee_token, fee) = fee_data.unwrap(); - //calculate amount of tokens to charge - let charge = fee.checked_mul(days.into()).unwrap(); - if charge == 0 { - return; - } + //get actual price for the extension + let charge = minutes * fee / 1440; //burn the corresponding amount of fee tokens - TokenClient::new(&e, &fee_token).burn(&sponsor, &charge); + TokenClient::new(&e, &token).burn(&sponsor, &charge); //load expiration info let mut expiration = e.get_expiration(); - let mut asset_expiration = expiration.get(asset_index).unwrap_or_default(); let now = now(&e); + let mut asset_expiration = expiration + .get(asset_index) + .unwrap_or_else(|| now + 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(days_to_milliseconds(days)) + .checked_add(minutes_to_milliseconds(minutes as u32)) .unwrap(); - //write the vector that holds expiration dates for all symbols + //write into the vector that holds expiration dates for all symbols expiration.set(asset_index, asset_expiration); //update instance e.set_expiration(&expiration) } - // Estimates the cost of asset retention bump - // - // # Arguments - // - // * `days` - Number of days - // - // # Returns - // - // Amount that will be charged for the expiration bump for a given number of days - // - // # Panics - // - // Panics if the retention config hasn't been initialized - pub fn estimate_extend(e: &Env, days: u32) -> i128 { - let fee_data = e.get_retention_config(); - if fee_data.is_none() { - e.panic_with_error(Error::InvalidConfigVersion); - } - let (_, fee) = fee_data.unwrap(); - - fee.checked_mul(days.into()).unwrap() - } - // Returns the fee token address and daily retainer fee amount. // // # Returns // // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: &Env) -> Option<(Address, i128)> { + pub fn retention_config(e: &Env) -> RetentionConfig { e.get_retention_config() } @@ -432,11 +398,12 @@ impl PriceOracleContract { e.set_base_asset(&config.base_asset); e.set_decimals(config.decimals); e.set_resolution(config.resolution); - e.set_retention_period(config.period); + e.set_history_retention_period(config.period); e.set_cache_size(config.cache_size); - //set update timestamp to 1 to indicate that contract is already v2 - e.set_v2_update_ts(1); - + e.set_retention_config(config.retention_config); + //set protocol version to current + e.set_protocol(CURRENT_PROTOCOL); + //add assets add_assets(&e, config.assets); } @@ -472,12 +439,12 @@ impl PriceOracleContract { // # 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) { + pub fn set_history_retention_period(e: &Env, period: u64) { e.panic_if_not_admin(); - e.set_retention_period(period); + e.set_history_retention_period(period); } - // Sets the fee token address and daily retainer fee amount. + // Sets the fee token address and daily asset feed retainer fee amount. // Can be invoked only by the admin account. // // # Arguments @@ -487,7 +454,7 @@ impl PriceOracleContract { // # Panics // // Panics if the caller doesn't match admin address, or not initialized yet - pub fn set_retention_config(e: &Env, retention_config: (Address, i128)) { + pub fn set_retention_config(e: &Env, retention_config: RetentionConfig) { e.panic_if_not_admin(); e.set_retention_config(retention_config); let mut expiration = e.get_expiration(); @@ -496,7 +463,7 @@ impl PriceOracleContract { } //init expiration, set 365 days for all symbols by default let exp = now(&e) - .checked_add(days_to_milliseconds(DEFAULT_EXPIRATION_PERIOD)) + .checked_add(days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) .unwrap(); let assets = e.get_assets(); for _ in 0..assets.len() { @@ -531,16 +498,11 @@ impl PriceOracleContract { panic_with_error!(&e, Error::InvalidTimestamp); } - let retention_period = e.get_retention_period(); + let retention_period = e.get_history_retention_period(); - //TODO: compute ledgers per second, for now we assume 5 seconds per ledger - let ledgers_to_live: u32 = ((retention_period / 1000 / 5) + 1) as u32; + let ledgers_to_live = ((retention_period / 1000 / 5 + 1) * 2) as u32; - ensure_v2_update_ts(&e); - //update legacy for 24 hours after v2 update - if !is_legacy_expired(&e, timestamp) { - update_price_legacy(&e, &updates, timestamp, ledgers_to_live); - } + update_price_v1(&e, &updates, timestamp, ledger_timestamp, ledgers_to_live); //get the last timestamp let last_timestamp = e.get_last_timestamp(); @@ -549,13 +511,13 @@ impl PriceOracleContract { e.set_prices(&updates, timestamp, ledgers_to_live); //update the cache - let mut cache = e.get_cache().unwrap_or(Vec::new(&e)); let cache_size = e.get_cache_size(); - cache.push_front((timestamp, updates.clone())); - while cache.len() > cache_size { - cache.pop_back(); //remove the oldest record if cache size exceeded - } - if cache_size > 0 { //if cache size is set, update the cache + if cache_size > 0 { //if cache size is non-empty, store it in the instance + let mut cache = e.get_cache().unwrap_or(Vec::new(&e)); + cache.push_front((timestamp, updates.clone())); + while cache.len() > cache_size { + cache.pop_back(); //remove the oldest record if cache size exceeded + } e.set_cache(cache); } @@ -564,10 +526,29 @@ impl PriceOracleContract { e.set_last_timestamp(timestamp); } + //get the assets + let assets = e.get_assets(); + //event updates + let mut event_updates = Vec::new(&e); + for (index, asset) in assets.iter().enumerate() { + let price = updates.get(index as u32).unwrap_or(0i128); + if price == 0 { + continue; //skip zero prices + } + let symbol = match asset { + Asset::Stellar(address) => { + address.to_val() + }, + Asset::Other(symbol) => { + symbol.to_val() + } + }; + event_updates.push_back((symbol, price)); + } //publish the price updates e.events().publish( - (REFLECTOR, symbol_short!("prices"), symbol_short!("update")), - updates, + (REFLECTOR, symbol_short!("update"), timestamp), + event_updates ); } @@ -583,7 +564,7 @@ impl PriceOracleContract { // 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) + env.deployer().update_current_contract_wasm(wasm_hash); } } @@ -674,11 +655,8 @@ fn get_x_price( 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) + let asset_pair_indexes = get_asset_pair_indexes(e, base_asset, quote_asset)?; + get_x_price_by_indexes(e, asset_pair_indexes, timestamp, decimals) } fn get_x_price_by_indexes( @@ -687,11 +665,7 @@ fn get_x_price_by_indexes( timestamp: u64, decimals: u32, ) -> Option { - - if !is_legacy_expired(e, now(e)) { - return get_x_price_by_indexes_legacy(e, asset_pair_indexes, timestamp, decimals); - } - + //get the asset indexes let (base_asset, quote_asset) = asset_pair_indexes; //check if the asset are the same if base_asset == quote_asset { @@ -701,53 +675,39 @@ fn get_x_price_by_indexes( let prices = e.get_prices(timestamp); //get the price for base_asset - let base_asset_price = get_price_data_by_index(base_asset, timestamp, &prices); - if base_asset_price.is_none() { - return None; - } + let base_asset_price = get_price_data_by_index(e, base_asset, timestamp, &prices)?; //get the price for quote_asset - let quote_asset_price = get_price_data_by_index(quote_asset, timestamp, &prices); - if quote_asset_price.is_none() { - return None; - } + let quote_asset_price = get_price_data_by_index(e, quote_asset, timestamp, &prices)?; //calculate the cross price Some(get_normalized_price_data( base_asset_price - .unwrap() .price - .fixed_div_floor(quote_asset_price.unwrap().price, decimals), + .fixed_div_floor(quote_asset_price.price, decimals), timestamp, )) } fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option<(u32, u32)> { - let base_asset = e.get_asset_index(&base_asset); - if base_asset.is_none() { - return None; - } + let base_asset = e.get_asset_index(&base_asset)?; - let quote_asset = e.get_asset_index("e_asset); - if quote_asset.is_none() { - return None; - } + let quote_asset = e.get_asset_index("e_asset)?; - Some((base_asset.unwrap(), quote_asset.unwrap())) + Some((base_asset, quote_asset)) } 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; - } - if !is_legacy_expired(e, now(e)) { - return get_price_data_by_index_legacy(e, asset.unwrap() as u8, timestamp); - } - get_price_data_by_index(asset.unwrap(), timestamp, &e.get_prices(timestamp)) + let asset = e.get_asset_index(&asset)?; + get_price_data_by_index(e, asset, timestamp, &e.get_prices(timestamp)) } -fn get_price_data_by_index(asset: u32, timestamp: u64, prices: &Option>) -> Option { +fn get_price_data_by_index(e: &Env, asset: u32, timestamp: u64, prices: &Option>) -> Option { + //if the protocol version is not current, use legacy method + if !is_current_protocol_version(e, now(e)) { + let price = e.get_price(asset as u8, timestamp)?; + return Some(get_normalized_price_data(price, timestamp)); + } if prices.is_none() { return None; } @@ -756,7 +716,7 @@ fn get_price_data_by_index(asset: u32, timestamp: u64, prices: &Option if prices.len() <= asset { return None; } - let price = prices.get_unchecked(asset); + let price = prices.get(asset)?; if price == 0 { return None; } @@ -773,11 +733,11 @@ fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { fn add_assets(e: &Env, assets: Vec) { //use default expiration period for new assets let expiration_timestamp = now(&e) - .checked_add(days_to_milliseconds(DEFAULT_EXPIRATION_PERIOD)) + .checked_add(days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) .unwrap(); let mut current_assets = e.get_assets(); let mut expiration = e.get_expiration(); - let retention_config_initialized = e.get_retention_config().is_some(); + let is_retention_config_set = e.get_retention_config() != RetentionConfig::None; for asset in assets.iter() { //check if the asset has been already added if e.get_asset_index(&asset).is_some() { @@ -786,8 +746,8 @@ fn add_assets(e: &Env, assets: Vec) { e.set_asset_index(&asset, current_assets.len()); current_assets.push_back(asset); - //if the fee is not initialized, we don't need to set the expiration - if retention_config_initialized { + //if the fee is not set, we don't need to set the expiration + if is_retention_config_set { expiration.push_back(expiration_timestamp); //set expiration } } @@ -802,18 +762,32 @@ fn days_to_milliseconds(days: u32) -> u64 { (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds } -fn ensure_v2_update_ts(e: &Env) { - //ensure that the v2 update timestamp is set - if e.get_v2_update_ts() == 0 { - e.set_v2_update_ts(now(e)); - } +fn minutes_to_milliseconds(minutes: u32) -> u64 { + (minutes as u64) * 60 * 1000 //convert to milliseconds } -fn is_legacy_expired(e: &Env, timestamp: u64) -> bool { - e.get_v2_update_ts() + days_to_milliseconds(1) < timestamp +fn is_current_protocol_version(e: &Env, now: u64) -> bool { + let protocol = e.get_protocol(); + if protocol == CURRENT_PROTOCOL { + return true; + } + let update_ts = e.get_update_ts(); + if update_ts == 0 { + e.set_update_ts(now); //set update timestamp to now if not set + return false; + } else if update_ts + days_to_milliseconds(1) < now { + e.set_protocol(CURRENT_PROTOCOL); //set protocol to current if the update timestamp is older than 1 day + e.set_update_ts(0); // reset update timestamp + return true; + } + false } -fn update_price_legacy(e: &Env, updates: &Vec, timestamp: u64, ledgers_to_live: u32) { +fn update_price_v1(e: &Env, updates: &Vec, timestamp: u64, ledger_timestamp: u64, ledgers_to_live: u32) { + //if the protocol version is current, we can skip the legacy update + if !is_current_protocol_version(e, ledger_timestamp) { + return; + } let assets = e.get_assets(); let mut asset_prices: Vec<(Asset, i128)> = Vec::new(&e); //iterate over the updates @@ -831,42 +805,4 @@ fn update_price_legacy(e: &Env, updates: &Vec, timestamp: u64, ledgers_to_ //store the new price e.set_price(asset, price, timestamp, ledgers_to_live); } -} - -fn get_price_data_by_index_legacy(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_x_price_by_indexes_legacy( - e: &Env, - asset_pair_indexes: (u32, u32), - timestamp: u64, - decimals: u32, -) -> Option { - let (base_asset, quote_asset) = asset_pair_indexes; - - //get the price for base_asset - let base_asset_price = get_price_data_by_index_legacy(e, base_asset as u8, timestamp); - if base_asset_price.is_none() { - return None; - } - - //get the price for quote_asset - let quote_asset_price = get_price_data_by_index_legacy(e, quote_asset as u8, timestamp); - if quote_asset_price.is_none() { - return None; - } - - //calculate the cross price - Some(get_normalized_price_data( - base_asset_price - .unwrap() - .price - .fixed_div_floor(quote_asset_price.unwrap().price, decimals), - timestamp, - )) } \ No newline at end of file diff --git a/src/test.rs b/src/test.rs index 2f722ce..0991df7 100644 --- a/src/test.rs +++ b/src/test.rs @@ -47,7 +47,8 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config base_asset: Asset::Stellar(Address::generate(&env)), decimals: 14, resolution: RESOLUTION, - cache_size: 0 + cache_size: 0, + retention_config: RetentionConfig::None, }; env.mock_all_auths(); @@ -111,7 +112,7 @@ fn init_test() { let resolution = client.resolution(); assert_eq!(resolution, RESOLUTION / 1000); - let period = client.period().unwrap(); + let period = client.history_retention_period().unwrap(); assert_eq!(period, init_data.period / 1000); let decimals = client.decimals(); @@ -376,9 +377,9 @@ fn set_period_test() { env.mock_all_auths(); - client.set_period(&period); + client.set_history_retention_period(&period); - let result = client.period().unwrap(); + let result = client.history_retention_period().unwrap(); assert_eq!(result, convert_to_seconds(period)); } @@ -679,12 +680,12 @@ fn authorized_test() { address: &config_data.admin, invoke: &MockAuthInvoke { contract: &client.address, - fn_name: "set_period", + fn_name: "set_history_retention_period", args: Vec::from_array(&env, [period.clone().try_into_val(&env).unwrap()]), sub_invokes: &[], }, }]) - .set_period(&period); + .set_history_retention_period(&period); } #[test] @@ -706,7 +707,7 @@ fn unauthorized_test() { sub_invokes: &[], }, }]) - .set_period(&period); + .set_history_retention_period(&period); } #[test] @@ -748,19 +749,16 @@ fn set_retention_config_test() { env.storage().instance().remove(&"expiration"); }); - let fee_data = client.retention_config(); - assert!(fee_data.is_none()); - //create fee asset token let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); - let retention_config = (fee_asset.address(), 7); + let retention_config = RetentionConfig::Some((fee_asset.address(), 7)); client.set_retention_config(&retention_config); let result = client.retention_config(); - assert!(result.is_some()); - assert_eq!(result.unwrap(), retention_config); + assert_ne!(result, RetentionConfig::None); + assert_eq!(result, retention_config); let asset: Asset = init_data.assets.get_unchecked(0); @@ -768,13 +766,14 @@ fn set_retention_config_test() { assert!(expires.is_some()); let sponsor = Address::generate(&env); - let fee_token = StellarAssetClient::new(&env, &retention_config.0); - fee_token.mint(&sponsor, &100); - - let bump_price = client.estimate_extend(&10); - assert_eq!(bump_price, 70); + let fee_token = StellarAssetClient::new(&env, &fee_asset.address()); + fee_token.mint(&sponsor, &10); let symbol_expires = client.expires(&asset).unwrap(); - client.extend(&sponsor, &asset, &10); - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + days_to_milliseconds(10)); + client.extend_asset_ttl(&sponsor, &asset, &10); + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 2057 * 60 * 1000); //2057 minutes you get for 9 XRF tokens + + let fee_token_balance = TokenClient::new(&env, &fee_asset.address()) + .balance(&sponsor); + assert_eq!(fee_token_balance, 1); //1 XRF token is left after paying the fee } \ No newline at end of file diff --git a/src/types/config_data.rs b/src/types/config_data.rs index f3e06c1..0083eb9 100644 --- a/src/types/config_data.rs +++ b/src/types/config_data.rs @@ -1,5 +1,7 @@ use soroban_sdk::{contracttype, Address, Vec}; +use crate::types::retention_config::RetentionConfig; + use super::asset::Asset; #[contracttype] @@ -20,5 +22,7 @@ pub struct ConfigData { // The resolution of the prices. pub resolution: u32, // The cache size for the prices. - pub cache_size: u32 -} + pub cache_size: u32, + // The retention config for the contract. Token address and fee amount. + pub retention_config: RetentionConfig, +} \ No newline at end of file diff --git a/src/types/error.rs b/src/types/error.rs index 70350f6..75d3a11 100644 --- a/src/types/error.rs +++ b/src/types/error.rs @@ -20,4 +20,6 @@ pub enum Error { InvalidUpdateLength = 6, // The assets storage is full AssetLimitExceeded = 7, + // The amount is invalid (e.g., negative or zero). + InvalidAmount = 8, } diff --git a/src/types/mod.rs b/src/types/mod.rs index 29a8bf7..fb914d1 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -3,3 +3,4 @@ pub mod asset_type; pub mod config_data; pub mod error; pub mod price_data; +pub mod retention_config; diff --git a/src/types/retention_config.rs b/src/types/retention_config.rs new file mode 100644 index 0000000..3931a8b --- /dev/null +++ b/src/types/retention_config.rs @@ -0,0 +1,8 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RetentionConfig { + Some((Address, i128)), + None +} \ No newline at end of file From b5e43d11b30dcb71ce23e1f861aed3724cd53a25 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Mon, 11 Aug 2025 15:00:15 +0300 Subject: [PATCH 05/55] rename period to history_retention_period --- src/lib.rs | 2 +- src/test.rs | 4 ++-- src/types/config_data.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d811165..32f5494 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -398,7 +398,7 @@ impl PriceOracleContract { e.set_base_asset(&config.base_asset); e.set_decimals(config.decimals); e.set_resolution(config.resolution); - e.set_history_retention_period(config.period); + e.set_history_retention_period(config.history_retention_period); e.set_cache_size(config.cache_size); e.set_retention_config(config.retention_config); //set protocol version to current diff --git a/src/test.rs b/src/test.rs index 0991df7..ffbfaf2 100644 --- a/src/test.rs +++ b/src/test.rs @@ -42,7 +42,7 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config let init_data = ConfigData { admin: admin.clone(), - period: (100 * RESOLUTION).into(), + history_retention_period: (100 * RESOLUTION).into(), assets: generate_assets(&env, 10, 0), base_asset: Asset::Stellar(Address::generate(&env)), decimals: 14, @@ -113,7 +113,7 @@ fn init_test() { assert_eq!(resolution, RESOLUTION / 1000); let period = client.history_retention_period().unwrap(); - assert_eq!(period, init_data.period / 1000); + assert_eq!(period, init_data.history_retention_period / 1000); let decimals = client.decimals(); assert_eq!(decimals, DECIMALS); diff --git a/src/types/config_data.rs b/src/types/config_data.rs index 0083eb9..46d7f77 100644 --- a/src/types/config_data.rs +++ b/src/types/config_data.rs @@ -11,8 +11,8 @@ use super::asset::Asset; pub struct ConfigData { // The admin address. pub admin: Address, - // The retention period for the prices. - pub period: u64, + // The history retention period for the prices. + pub history_retention_period: u64, // The assets supported by the contract. pub assets: Vec, // The base asset for the prices. From 13db7a20a0e90163646315547688a9ec82e5573d Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Tue, 7 Oct 2025 19:05:53 +0300 Subject: [PATCH 06/55] update sdk; refactoring --- Cargo.lock | 289 +++++++++++++++++++++++-------- Cargo.toml | 4 +- src/extensions/env_extensions.rs | 20 +-- src/lib.rs | 62 +++---- src/test.rs | 8 +- 5 files changed, 263 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa3ac59..c1f297a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,15 +170,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.13.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -225,6 +219,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" @@ -331,19 +336,29 @@ dependencies = [ [[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 = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] name = "darling_core" -version = "0.20.8" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -353,13 +368,38 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "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 2.0.101", ] @@ -382,12 +422,12 @@ 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]] @@ -430,6 +470,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +[[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" @@ -588,6 +634,12 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[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" @@ -732,6 +784,23 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "num-bigint" version = "0.4.4" @@ -892,6 +961,26 @@ dependencies = [ "getrandom", ] +[[package]] +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-oracle" version = "6.0.0" @@ -924,6 +1013,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" @@ -945,18 +1069,28 @@ 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", @@ -965,28 +1099,32 @@ dependencies = [ [[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", @@ -994,11 +1132,11 @@ 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 2.0.101", @@ -1006,9 +1144,9 @@ dependencies = [ [[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", @@ -1043,9 +1181,9 @@ checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "soroban-builtin-sdk-macros" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" +checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" dependencies = [ "itertools", "proc-macro2", @@ -1055,9 +1193,9 @@ dependencies = [ [[package]] name = "soroban-env-common" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" +checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" dependencies = [ "arbitrary", "crate-git-revision", @@ -1074,9 +1212,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" +checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" dependencies = [ "soroban-env-common", "static_assertions", @@ -1084,9 +1222,9 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" +checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" dependencies = [ "ark-bls12-381", "ark-ec", @@ -1120,9 +1258,9 @@ dependencies = [ [[package]] name = "soroban-env-macros" -version = "22.1.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" +checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" dependencies = [ "itertools", "proc-macro2", @@ -1135,9 +1273,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "22.0.8" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826e2c9d364edbb2ea112dc861077c74557bdad0a7a00487969088c7c648169" +checksum = "3823372b72cab2e7ff2ced62bbffa11fce8da0713a224f122141558cab174647" dependencies = [ "serde", "serde_json", @@ -1149,12 +1287,13 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "22.0.8" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ac27d7573e62b745513fa1be8dab7a09b9676a7f39db97164f1d458a344749" +checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65" dependencies = [ "arbitrary", "bytes-lit", + "crate-git-revision", "ctor", "derive_arbitrary", "ed25519-dalek", @@ -1171,16 +1310,16 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "22.0.8" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef0d7d62b2584696d306b8766728971c7d0731a03a5e047f1fc68722ac8cf0c" +checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" dependencies = [ - "crate-git-revision", - "darling", + "darling 0.20.11", + "heck", "itertools", + "macro-string", "proc-macro2", "quote", - "rustc_version", "sha2", "soroban-env-common", "soroban-spec", @@ -1191,11 +1330,11 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "22.0.8" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ad0867aec99770ed614fedbec7ac4591791df162ff9e548ab7ebd07cd23a9c" +checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2" dependencies = [ - "base64 0.13.1", + "base64", "stellar-xdr", "thiserror", "wasmparser", @@ -1203,9 +1342,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "22.0.8" +version = "23.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebe31c042adfa2885ec47b67b08fcead8707da80a3fe737eaf2a9ae1a8cfdc3" +checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d" dependencies = [ "prettyplease", "proc-macro2", @@ -1254,36 +1393,38 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stellar-strkey" -version = "0.0.9" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" +checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" dependencies = [ "crate-git-revision", "data-encoding", - "thiserror", ] [[package]] name = "stellar-xdr" -version = "22.1.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" +checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" dependencies = [ "arbitrary", - "base64 0.13.1", + "base64", + "cfg_eval", "crate-git-revision", "escape-bytes", + "ethnum", "hex", "serde", "serde_with", + "sha2", "stellar-strkey", ] [[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" @@ -1335,9 +1476,9 @@ dependencies = [ [[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", @@ -1350,15 +1491,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", diff --git a/Cargo.toml b/Cargo.toml index b8998e4..cdd6dd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,10 @@ codegen-units = 1 lto = true [dependencies] -soroban-sdk = "22.0.8" +soroban-sdk = "23.0.2" [dev-dependencies] -soroban-sdk = { version = "22.0.8", features = ["testutils"] } +soroban-sdk = { version = "23.0.2", features = ["testutils"] } [features] testutils = ["soroban-sdk/testutils"] diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index c318e91..f9bde02 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -89,9 +89,9 @@ pub trait EnvExtensions { fn set_update_ts(&self, timestamp: u64); - fn get_protocol(&self) -> u32; + fn get_protocol_version(&self) -> u32; - fn set_protocol(&self, protocol: u32); + fn set_protocol_version(&self, protocol: u32); } impl EnvExtensions for Env { @@ -137,8 +137,8 @@ impl EnvExtensions for Env { .unwrap_or_default() } - fn set_history_retention_period(&self, rdm_period: u64) { - get_instance_storage(&self).set(&RETENTION_PERIOD, &rdm_period); + fn set_history_retention_period(&self, rtn_period: u64) { + get_instance_storage(&self).set(&RETENTION_PERIOD, &rtn_period); } fn get_price(&self, asset: u8, timestamp: u64) -> Option { @@ -153,11 +153,11 @@ impl EnvExtensions for Env { 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); + let temp_storage = get_temporary_storage(&self); + temp_storage.set(&data_key, &price); if bump_ledgers_count > 16 { - //16 is the minimum number - temps_storage.extend_ttl(&data_key, bump_ledgers_count, bump_ledgers_count) + //16 ledgers is the minimum extension period + temp_storage.extend_ttl(&data_key, bump_ledgers_count, bump_ledgers_count) } } @@ -283,11 +283,11 @@ impl EnvExtensions for Env { get_instance_storage(self).set(&UPDATE_TS, ×tamp); } - fn get_protocol(&self) -> u32 { + fn get_protocol_version(&self) -> u32 { get_instance_storage(self).get(&PROTOCOL).unwrap_or(1) } - fn set_protocol(&self, protocol: u32) { + fn set_protocol_version(&self, protocol: u32) { get_instance_storage(self).set(&PROTOCOL, &protocol); } } diff --git a/src/lib.rs b/src/lib.rs index 32f5494..c655c60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,16 +8,24 @@ use extensions::{ env_extensions::EnvExtensions, i128_extensions::I128Extensions, u64_extensions::U64Extensions, }; use soroban_sdk::token::TokenClient; -use soroban_sdk::{panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contractevent, panic_with_error, Address, BytesN, Env, Val, Vec}; use types::{asset::Asset, error::Error}; use types::{config_data::ConfigData, price_data::PriceData}; use crate::types::retention_config::RetentionConfig; -const REFLECTOR: Symbol = symbol_short!("reflector"); -const INITIAL_EXPIRATION_PERIOD: u32 = 365; //days in year +const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months const CURRENT_PROTOCOL: u32 = 2; //current protocol version +#[contractevent(topics = ["REFLECTOR", "update"])] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpdateEvent { + #[topic] + pub timestamp: u64, + // Fields not marked as topics will appear in the events data section. + pub update_data: Vec<(Val, i128)> +} + #[soroban_sdk::contract] pub struct PriceOracleContract; @@ -281,6 +289,10 @@ impl PriceOracleContract { // # Returns // // Asset expiration timestamp or None if the asset is not supported + // + // # Panics + // + // Panics if the asset is not supported pub fn expires(e: &Env, asset: Asset) -> Option { let asset_index = e.get_asset_index(&asset); if asset_index.is_none() { @@ -328,16 +340,13 @@ impl PriceOracleContract { }; //get minutes to extend - let minutes = amount * 1440 / fee; //minute is min period to extend. fee is value per day, so we divide by minutes in a day - if minutes <= 0 { + let bump = amount * 86400000 / fee; // result in milliseconds + if bump <= 0 { e.panic_with_error(Error::InvalidAmount); } - //get actual price for the extension - let charge = minutes * fee / 1440; - //burn the corresponding amount of fee tokens - TokenClient::new(&e, &token).burn(&sponsor, &charge); + TokenClient::new(&e, &token).burn(&sponsor, &amount); //load expiration info let mut expiration = e.get_expiration(); @@ -351,7 +360,7 @@ impl PriceOracleContract { } //bump expiration asset_expiration = asset_expiration - .checked_add(minutes_to_milliseconds(minutes as u32)) + .checked_add(bump as u64) .unwrap(); //write into the vector that holds expiration dates for all symbols expiration.set(asset_index, asset_expiration); @@ -402,7 +411,7 @@ impl PriceOracleContract { e.set_cache_size(config.cache_size); e.set_retention_config(config.retention_config); //set protocol version to current - e.set_protocol(CURRENT_PROTOCOL); + e.set_protocol_version(CURRENT_PROTOCOL); //add assets add_assets(&e, config.assets); } @@ -461,7 +470,7 @@ impl PriceOracleContract { if expiration.len() > 0 { return; // expiration values for existing price feeds already initialized } - //init expiration, set 365 days for all symbols by default + //init expiration, set INITIAL_EXPIRATION_PERIOD for all symbols by default let exp = now(&e) .checked_add(days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) .unwrap(); @@ -507,7 +516,7 @@ impl PriceOracleContract { //get the last timestamp let last_timestamp = e.get_last_timestamp(); - //store the new prices + //store new prices in v2 format e.set_prices(&updates, timestamp, ledgers_to_live); //update the cache @@ -526,7 +535,7 @@ impl PriceOracleContract { e.set_last_timestamp(timestamp); } - //get the assets + //load all registered assets let assets = e.get_assets(); //event updates let mut event_updates = Vec::new(&e); @@ -545,11 +554,13 @@ impl PriceOracleContract { }; event_updates.push_back((symbol, price)); } + //publish the price updates - e.events().publish( - (REFLECTOR, symbol_short!("update"), timestamp), - event_updates - ); + let event = UpdateEvent { + timestamp, + update_data: event_updates, + }; + e.events().publish_event(&event); } // Updates the contract source code. Can be invoked only by the admin account. @@ -762,12 +773,8 @@ fn days_to_milliseconds(days: u32) -> u64 { (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds } -fn minutes_to_milliseconds(minutes: u32) -> u64 { - (minutes as u64) * 60 * 1000 //convert to milliseconds -} - fn is_current_protocol_version(e: &Env, now: u64) -> bool { - let protocol = e.get_protocol(); + let protocol = e.get_protocol_version(); if protocol == CURRENT_PROTOCOL { return true; } @@ -776,7 +783,7 @@ fn is_current_protocol_version(e: &Env, now: u64) -> bool { e.set_update_ts(now); //set update timestamp to now if not set return false; } else if update_ts + days_to_milliseconds(1) < now { - e.set_protocol(CURRENT_PROTOCOL); //set protocol to current if the update timestamp is older than 1 day + e.set_protocol_version(CURRENT_PROTOCOL); //set protocol to current if the update timestamp is older than 1 day e.set_update_ts(0); // reset update timestamp return true; } @@ -788,15 +795,8 @@ fn update_price_v1(e: &Env, updates: &Vec, timestamp: u64, ledger_timestam if !is_current_protocol_version(e, ledger_timestamp) { return; } - let assets = e.get_assets(); - let mut asset_prices: Vec<(Asset, i128)> = Vec::new(&e); //iterate over the updates for (i, price) in updates.iter().enumerate() { - let asset = assets.get(i as u32); - if asset.is_some() { - //asset can be None if the asset was added but the update is not applied yet - asset_prices.push_back((asset.unwrap(), price)); - } //don't store zero prices if price == 0 { continue; diff --git a/src/test.rs b/src/test.rs index ffbfaf2..e906656 100644 --- a/src/test.rs +++ b/src/test.rs @@ -5,7 +5,7 @@ extern crate std; use super::*; use alloc::string::ToString; use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, token::StellarAssetClient, Address, Env, String, Symbol, TryIntoVal + symbol_short, testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, token::StellarAssetClient, Address, Env, IntoVal, String, Symbol, TryIntoVal }; use std::panic::{self, AssertUnwindSafe}; @@ -135,6 +135,8 @@ fn set_price_test() { //set prices for assets client.set_price(&updates, ×tamp); + + assert_eq!(env.events().all().last().unwrap().1, (symbol_short!("REFLECTOR"), symbol_short!("update"), &600_000u64).into_val(&env)); } #[test] @@ -771,9 +773,9 @@ fn set_retention_config_test() { let symbol_expires = client.expires(&asset).unwrap(); client.extend_asset_ttl(&sponsor, &asset, &10); - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 2057 * 60 * 1000); //2057 minutes you get for 9 XRF tokens + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 9 XRF tokens let fee_token_balance = TokenClient::new(&env, &fee_asset.address()) .balance(&sponsor); - assert_eq!(fee_token_balance, 1); //1 XRF token is left after paying the fee + assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee } \ No newline at end of file From c6b909476dfc700d7f5ad323e784924218911362 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Tue, 7 Oct 2025 19:26:18 +0300 Subject: [PATCH 07/55] rename set_price and get_price to set_price_v1 and get_price_v1 --- src/extensions/env_extensions.rs | 8 ++++---- src/lib.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs index f9bde02..ff066be 100644 --- a/src/extensions/env_extensions.rs +++ b/src/extensions/env_extensions.rs @@ -45,9 +45,9 @@ pub trait EnvExtensions { fn set_history_retention_period(&self, period: u64); - fn get_price(&self, asset: u8, timestamp: u64) -> Option; + fn get_price_v1(&self, asset: u8, timestamp: u64) -> Option; - fn set_price(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32); + fn set_price_v1(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32); fn get_prices(&self, timestamp: u64) -> Option>; @@ -141,14 +141,14 @@ impl EnvExtensions for Env { get_instance_storage(&self).set(&RETENTION_PERIOD, &rtn_period); } - fn get_price(&self, asset: u8, timestamp: u64) -> Option { + fn get_price_v1(&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, bump_ledgers_count: u32) { + fn set_price_v1(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32) { //build the key for the price let data_key = U128Helper::encode_price_record_key(timestamp, asset); diff --git a/src/lib.rs b/src/lib.rs index c655c60..4013595 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -716,7 +716,7 @@ fn get_price_data(e: &Env, asset: Asset, timestamp: u64) -> Option { fn get_price_data_by_index(e: &Env, asset: u32, timestamp: u64, prices: &Option>) -> Option { //if the protocol version is not current, use legacy method if !is_current_protocol_version(e, now(e)) { - let price = e.get_price(asset as u8, timestamp)?; + let price = e.get_price_v1(asset as u8, timestamp)?; return Some(get_normalized_price_data(price, timestamp)); } if prices.is_none() { @@ -803,6 +803,6 @@ fn update_price_v1(e: &Env, updates: &Vec, timestamp: u64, ledger_timestam } let asset = i as u8; //store the new price - e.set_price(asset, price, timestamp, ledgers_to_live); + e.set_price_v1(asset, price, timestamp, ledgers_to_live); } } \ No newline at end of file From 76e05b98f39790840492e7c26f70594bdfbebcd9 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Thu, 9 Oct 2025 13:33:22 +0000 Subject: [PATCH 08/55] Refactor storage and execution logic, group by functionality, cleanup unused code, update comments --- .gitignore | 1 - README.md | 10 +- src/assets.rs | 177 ++++++++ src/auth.rs | 27 ++ src/events.rs | 37 ++ src/extensions/env_extensions.rs | 301 -------------- src/extensions/i128_extensions.rs | 42 -- src/extensions/mod.rs | 4 - src/extensions/u128_helper.rs | 7 - src/extensions/u64_extensions.rs | 17 - src/lib.rs | 647 +++++++----------------------- src/prices.rs | 265 ++++++++++++ src/protocol.rs | 51 +++ src/settings.rs | 95 +++++ src/test.rs | 78 ++-- src/timestamps.rs | 26 ++ src/types/asset.rs | 5 +- src/types/asset_type.rs | 1 + src/types/config_data.rs | 18 +- src/types/error.rs | 24 +- src/types/price_data.rs | 6 +- src/types/retention_config.rs | 1 + 22 files changed, 906 insertions(+), 934 deletions(-) create mode 100644 src/assets.rs create mode 100644 src/auth.rs create mode 100644 src/events.rs delete mode 100644 src/extensions/env_extensions.rs delete mode 100644 src/extensions/i128_extensions.rs delete mode 100644 src/extensions/mod.rs delete mode 100644 src/extensions/u128_helper.rs delete mode 100644 src/extensions/u64_extensions.rs create mode 100644 src/prices.rs create mode 100644 src/protocol.rs create mode 100644 src/settings.rs create mode 100644 src/timestamps.rs diff --git a/.gitignore b/.gitignore index fa1b716..319b14b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /.idea /.soroban /target -/price-oracle/test_snapshots /test_snapshots diff --git a/README.md b/README.md index 218e23f..2d5d4c3 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ pub fn maintain_stable_coin_peg(env: Env, reflector_contract_id: Address, curren } ``` - -## Building the Contracts +## Building Contracts ### 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 +### Building Price Oracle 1. Navigate to the directory of the contract: @@ -127,5 +127,5 @@ pub fn maintain_stable_coin_peg(env: Env, reflector_contract_id: Address, curren 2. Run the build command: ```bash - cargo build --release --target wasm32-unknown-unknown + stellar contract build ``` \ No newline at end of file diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..84a8441 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,177 @@ +use crate::types::{asset::Asset, error::Error, retention_config::RetentionConfig}; +use crate::{settings, timestamps}; +use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env, Vec}; + +const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months +const ASSET_LIMIT: u32 = 1000; //current limit + +//storage keys +const ASSETS_KEY: &str = "assets"; +const EXPIRATION_KEY: &str = "expiration"; + +// 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 { + let index: Option; + match asset { + Asset::Stellar(address) => { + index = e.storage().instance().get(&address); + } + Asset::Other(symbol) => { + index = e.storage().instance().get(&symbol); + } + } + index +} + +// Resolve indexes for a pair of assets +pub fn resolve_asset_pair_indexes( + e: &Env, + base_asset: Asset, + quote_asset: Asset, +) -> Option<(u32, u32)> { + let base_asset = resolve_asset_index(e, &base_asset)?; + let quote_asset = resolve_asset_index(e, "e_asset)?; + Some((base_asset, quote_asset)) +} + +// Add assets to the oracle +pub fn add_assets(e: &Env, assets: Vec) { + //use default expiration period for new assets + let expiration_timestamp = timestamps::ledger_timestamp(&e) + .checked_add(timestamps::days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) + .unwrap(); + //load current state + let mut asset_list = load_all_assets(e); + let mut expiration = load_expiration_records(e); + let is_retention_config_set = settings::get_retention_config(e) != RetentionConfig::None; + //for each new asset + for asset in assets.iter() { + //check if the asset has been already added + if resolve_asset_index(e, &asset).is_some() { + panic_with_error!(&e, Error::AssetAlreadyExists); + } + set_asset_index(e, &asset, asset_list.len()); + asset_list.push_back(asset); + //if the fee is not set, we don't need to set the expiration + if is_retention_config_set { + expiration.push_back(expiration_timestamp); //set expiration + } + } + 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 time 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) { + 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 = timestamps::ledger_timestamp(&e) + .checked_add(timestamps::days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) + .unwrap(); + //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) { + //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) = match settings::get_retention_config(e) { + RetentionConfig::Some(fee_data) => { + if fee_data.1 <= 0 { + e.panic_with_error(Error::InvalidConfigVersion); + } + fee_data + } + RetentionConfig::None => { + e.panic_with_error(Error::InvalidConfigVersion); + } + }; + //burn corresponding amount of fee tokens + TokenClient::new(&e, &xrf).burn(&sponsor, &amount); + //calculate extension period + let bump = amount * 86400000 / fee; // in milliseconds + if bump <= 0 { + e.panic_with_error(Error::InvalidAmount); + } + //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) +} + +// 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) +} + +// Store asset index +#[inline] +fn set_asset_index(e: &Env, asset: &Asset, index: u32) { + match asset { + Asset::Stellar(address) => { + e.storage().instance().set(&address, &index); + } + Asset::Other(symbol) => { + e.storage().instance().set(&symbol, &index); + } + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..548ec30 --- /dev/null +++ b/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::Error::Unauthorized); + } + admin.unwrap().require_auth() +} diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..541ed45 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,37 @@ +use crate::types::{asset::Asset, error::Error}; +use crate::{assets, UpdateEvent}; +use soroban_sdk::{panic_with_error, Env, Vec}; + +// Compose and publish price update event +#[inline] +pub fn publish_update_event(e: &Env, updates: &Vec, timestamp: u64) { + //load all registered assets + let assets = assets::load_all_assets(e); + //validate length + if assets.len() < updates.len() { + panic_with_error!(&e, Error::AssetLimitExceeded); + } + //prepare update event + let mut event_updates = Vec::new(&e); + for (index, asset) in 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/src/extensions/env_extensions.rs b/src/extensions/env_extensions.rs deleted file mode 100644 index ff066be..0000000 --- a/src/extensions/env_extensions.rs +++ /dev/null @@ -1,301 +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::u128_helper::U128Helper; -use crate::types; - -use types::{asset::Asset, error::Error, retention_config::RetentionConfig}; -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"; -const EXPIRATION: &str = "expiration"; -const RETENTION: &str = "retention"; -const CACHE: &str = "cache"; -const CACHE_SIZE: &str = "cache_size"; - -const UPDATE_TS: &str = "update_ts"; -const PROTOCOL: &str = "protocol"; - -const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; -const DEFAULT_RETENTION_FEE: i128 = 100_000_000; - -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_history_retention_period(&self) -> u64; - - fn set_history_retention_period(&self, period: u64); - - fn get_price_v1(&self, asset: u8, timestamp: u64) -> Option; - - fn set_price_v1(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32); - - fn get_prices(&self, timestamp: u64) -> Option>; - - fn set_prices(&self, prices: &Vec, timestamp: u64, bump_ledgers_count: u32); - - fn get_cache(&self) -> Option)>>; - - fn set_cache(&self, prices: Vec<(u64,Vec)>); - - fn get_cache_size(&self) -> u32; - - fn set_cache_size(&self, cache_size: 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 set_expiration(&self, assets: &Vec); - - fn get_expiration(&self) -> Vec; - - fn set_retention_config(&self, retention_config: RetentionConfig); - - fn get_retention_config(&self) -> RetentionConfig; - - fn panic_if_not_admin(&self); - - fn is_initialized(&self) -> bool; - - fn get_update_ts(&self) -> u64; - - fn set_update_ts(&self, timestamp: u64); - - fn get_protocol_version(&self) -> u32; - - fn set_protocol_version(&self, protocol: u32); -} - -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_history_retention_period(&self) -> u64 { - get_instance_storage(&self) - .get(&RETENTION_PERIOD) - .unwrap_or_default() - } - - fn set_history_retention_period(&self, rtn_period: u64) { - get_instance_storage(&self).set(&RETENTION_PERIOD, &rtn_period); - } - - fn get_price_v1(&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_v1(&self, asset: u8, price: i128, timestamp: u64, bump_ledgers_count: u32) { - //build the key for the price - let data_key = U128Helper::encode_price_record_key(timestamp, asset); - - //set the price - let temp_storage = get_temporary_storage(&self); - temp_storage.set(&data_key, &price); - if bump_ledgers_count > 16 { - //16 ledgers is the minimum extension period - temp_storage.extend_ttl(&data_key, bump_ledgers_count, bump_ledgers_count) - } - } - - fn get_prices(&self, timestamp: u64) -> Option> { - //check if the timestamp is in the cache - let cache = self.get_cache(); - 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 - get_temporary_storage(self).get(×tamp) - } - - fn set_prices(&self, prices: &Vec, timestamp: u64, bump_ledgers_count: u32) { - //set the price - let temps_storage = get_temporary_storage(&self); - temps_storage.set(×tamp, prices); - if bump_ledgers_count > 16 { - //16 is the minimum number - temps_storage.extend_ttl(×tamp, bump_ledgers_count, bump_ledgers_count) - } - } - - fn get_cache(&self) -> Option)>> { - get_instance_storage(self).get(&CACHE) - } - - fn set_cache(&self, prices: Vec<(u64,Vec)>) { - get_instance_storage(&self).set(&CACHE, &prices); - } - - fn get_cache_size(&self) -> u32 { - get_instance_storage(self).get(&CACHE_SIZE).unwrap_or(2) - } - - fn set_cache_size(&self, cache_size: u32) { - get_instance_storage(&self).set(&CACHE_SIZE, &cache_size); - } - - 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); - } - } - index - } - - fn set_expiration(&self, expiration: &Vec) { - get_instance_storage(self).set(&EXPIRATION, expiration) - } - - fn get_expiration(&self) -> Vec { - get_instance_storage(self) - .get(&EXPIRATION) - .unwrap_or_else(|| Vec::new(self)) - } - - fn set_retention_config(&self, retention_config: RetentionConfig) { - get_instance_storage(self).set(&RETENTION, &retention_config); - } - - fn get_retention_config(&self) -> RetentionConfig { - get_instance_storage(self) - .get(&RETENTION) - .unwrap_or_else(|| RetentionConfig::Some((Address::from_str(&self, XRF_TOKEN_ADDRESS), DEFAULT_RETENTION_FEE))) - } - - 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_update_ts(&self) -> u64 { - get_instance_storage(self).get(&UPDATE_TS).unwrap_or(0) - } - - fn set_update_ts(&self, timestamp: u64) { - get_instance_storage(self).set(&UPDATE_TS, ×tamp); - } - - fn get_protocol_version(&self) -> u32 { - get_instance_storage(self).get(&PROTOCOL).unwrap_or(1) - } - - fn set_protocol_version(&self, protocol: u32) { - get_instance_storage(self).set(&PROTOCOL, &protocol); - } -} - -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 index 4013595..1c744d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,19 @@ #![no_std] -mod extensions; +mod assets; +mod auth; +mod events; +mod prices; +mod protocol; +mod settings; mod test; +mod timestamps; mod types; -use extensions::{ - env_extensions::EnvExtensions, i128_extensions::I128Extensions, u64_extensions::U64Extensions, -}; -use soroban_sdk::token::TokenClient; use soroban_sdk::{contractevent, panic_with_error, Address, BytesN, Env, Val, Vec}; -use types::{asset::Asset, error::Error}; +use types::{asset::Asset, error::Error, retention_config::RetentionConfig}; use types::{config_data::ConfigData, price_data::PriceData}; -use crate::types::retention_config::RetentionConfig; - -const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months const CURRENT_PROTOCOL: u32 = 2; //current protocol version #[contractevent(topics = ["REFLECTOR", "update"])] @@ -22,8 +21,7 @@ const CURRENT_PROTOCOL: u32 = 2; //current protocol version pub struct UpdateEvent { #[topic] pub timestamp: u64, - // Fields not marked as topics will appear in the events data section. - pub update_data: Vec<(Val, i128)> + pub update_data: Vec<(Val, i128)>, } #[soroban_sdk::contract] @@ -31,75 +29,75 @@ pub struct PriceOracleContract; #[soroban_sdk::contractimpl] impl PriceOracleContract { - // Returns the base asset the price is reported in. + // Return base asset price is reported in // // # Returns // - // Base asset for the contract + // Oracle base asset pub fn base(e: &Env) -> Asset { - e.get_base_asset() + settings::get_base_asset(e) } - // Returns the number of decimal places used to represent price for all assets quoted by the oracle. + // 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 { - e.get_decimals() + settings::get_decimals(e) } - // Returns the default tick period timeframe (in seconds). + // Return default tick period timeframe (in seconds) // // # Returns // // Price feed resolution (in seconds) pub fn resolution(e: &Env) -> u32 { - e.get_resolution() / 1000 + settings::get_resolution(e) / 1000 } - // Returns the historical records retention period (in seconds). + // Return historical records retention period (in seconds) // // # Returns // // History retention period (in seconds) pub fn history_retention_period(e: &Env) -> Option { - let period: u64 = e.get_history_retention_period(); + let period: u64 = settings::get_history_retention_period(e); if period == 0 { - return None; + None } else { - return Some(period / 1000); //convert to seconds + Some(period / 1000) //convert to seconds } } - // Returns the cache size for the prices. + // Return price records cache size // // # Returns // - // Cache size for the prices + // Price records cache size pub fn cache_size(e: &Env) -> u32 { - e.get_cache_size() + settings::get_cache_size(e) } - // Returns all assets quoted by the contract. + // Return all quoted assets // // # Returns // - // Assets quoted by the contract + // Quoted assets pub fn assets(e: &Env) -> Vec { - e.get_assets() + assets::load_all_assets(e) } - // Returns the most recent price update timestamp in seconds. + // Return most recent price update timestamp in seconds // // # Returns // - // Timestamp of the last recorded price update + // Timestamp of last recorded price update pub fn last_timestamp(e: &Env) -> u64 { - e.get_last_timestamp() / 1000 //convert to seconds + prices::get_last_timestamp(e) / 1000 //convert to seconds } - // Returns price in base asset at specific timestamp. + // Returns price for an asset at specific timestamp // // # Arguments // @@ -108,16 +106,16 @@ impl PriceOracleContract { // // # Returns // - // Price record for the given asset at the given timestamp or None if the record was not found + // Price record for given asset at given timestamp or None if 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) + //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 the most recent price for an asset. + // Returns most recent price for an asset // // # Arguments // @@ -125,18 +123,20 @@ impl PriceOracleContract { // // # Returns // - // The most recent price for the given asset or None if the asset is not supported + // 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 timestamp = obtain_record_timestamp(&e); - if timestamp == 0 { + let ts = prices::obtain_last_record_timestamp(&e); + if ts == 0 { return None; } //get the price - get_price_data(&e, asset, timestamp) + let asset = assets::resolve_asset_index(e, &asset)?; + //resolve index for the asset + prices::retrieve_asset_price_data(e, asset, ts) } - // Returns last N price records for the given asset. + // Return last N price records for given asset // // # Arguments // @@ -145,17 +145,17 @@ impl PriceOracleContract { // // # Returns // - // Prices for the given asset or None if the asset is not supported + // Prices for given asset or None if 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 - prices( + let asset_index = assets::resolve_asset_index(e, &asset)?; //get the asset index to avoid multiple calls + prices::load_prices( &e, - |timestamp| get_price_data_by_index(e, asset_index, timestamp, &e.get_prices(timestamp)), + |timestamp| prices::retrieve_asset_price_data(e, asset_index, timestamp), records, ) } - // Returns the most recent cross price record for the pair of assets. + // Returns most recent cross price record for pair of assets // // # Arguments // @@ -164,17 +164,18 @@ impl PriceOracleContract { // // # 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 + // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found pub fn x_last_price(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option { - let timestamp = obtain_record_timestamp(&e); + let timestamp = prices::obtain_last_record_timestamp(&e); if timestamp == 0 { return None; } - let decimals = e.get_decimals(); - get_x_price(&e, base_asset, quote_asset, timestamp, decimals) + let decimals = settings::get_decimals(e); + let asset_pair_indexes = assets::resolve_asset_pair_indexes(e, base_asset, quote_asset)?; + prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals) } - // Returns the cross price for the pair of assets at specific timestamp. + // Return cross price for pair of assets at specific timestamp // // # Arguments // @@ -184,25 +185,27 @@ impl PriceOracleContract { // // # 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 + // Cross price (base_asset_price/quote_asset_price) at given timestamp or None if there were no records found for quoted assets 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) + //convert to milliseconds and normalize + let ts = timestamps::normalize(e, timestamp * 1000); + let decimals = settings::get_decimals(e); + let asset_pair_indexes = assets::resolve_asset_pair_indexes(e, base_asset, quote_asset)?; + prices::load_cross_price(e, asset_pair_indexes, ts, decimals) } - // Returns last N cross price records of for the pair of assets. + // Returns last N cross price records of for pair of assets // // # Arguments // // * `base_asset` - Base asset // * `quote_asset` - Quote asset + // * `records` - Number of records to fetch // // # Returns // @@ -213,18 +216,16 @@ impl PriceOracleContract { quote_asset: Asset, records: u32, ) -> Option> { - let asset_pair_indexes = get_asset_pair_indexes(&e, base_asset, quote_asset)?; - let decimals = e.get_decimals(); - prices( + let asset_pair_indexes = assets::resolve_asset_pair_indexes(&e, base_asset, quote_asset)?; + let decimals = settings::get_decimals(e); + prices::load_prices( &e, - |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes, timestamp, decimals) - }, + |timestamp| prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals), records, ) } - // Returns the time-weighted average price for the given asset over N recent records. + // Returns time-weighted average price for given asset over N recent records // // # Arguments // @@ -233,40 +234,39 @@ impl PriceOracleContract { // // # Returns // - // TWAP for the given asset over N recent records or None if the asset is not supported + // TWAP for the given asset over N recent records or None if 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 - get_twap( + let asset_index = assets::resolve_asset_index(e, &asset)?; //get the asset index to avoid multiple calls + prices::calculate_twap( &e, - |timestamp| get_price_data_by_index(e, asset_index, timestamp, &e.get_prices(timestamp)), + |timestamp| prices::retrieve_asset_price_data(e, asset_index, timestamp), records, ) } - // Returns the time-weighted average cross price for the given asset pair over N recent records. + // Returns time-weighted average cross price for given asset pair over N recent records // // # Arguments // // * `base_asset` - Base asset // * `quote_asset` - Quote asset + // * `records` - Number of records to process // // # Returns // - // TWAP (base_asset_price/quote_asset_price) or None if the assets are not supported. + // TWAP (base_asset_price/quote_asset_price) or None if 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)?; - let decimals = e.get_decimals(); - get_twap( + let asset_pair_indexes = assets::resolve_asset_pair_indexes(&e, base_asset, quote_asset)?; + let decimals = settings::get_decimals(e); + prices::calculate_twap( &e, - |timestamp| { - get_x_price_by_indexes(&e, asset_pair_indexes, timestamp, decimals) - }, + |timestamp| prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals), records, ) } - // Returns current protocol version of the contract. + // Return current contract protocol version // // # Returns // @@ -280,7 +280,7 @@ impl PriceOracleContract { .unwrap() } - // Returns the expiration date for a given asset. + // Return expiration date for a given asset // // # Arguments // @@ -288,173 +288,119 @@ impl PriceOracleContract { // // # Returns // - // Asset expiration timestamp or None if the asset is not supported + // Asset expiration timestamp or None if asset is not supported // // # Panics // - // Panics if the asset is not supported + // Panics if asset is not supported pub fn expires(e: &Env, asset: Asset) -> Option { - let asset_index = e.get_asset_index(&asset); - if asset_index.is_none() { - e.panic_with_error(Error::AssetMissing); - } - let expirations = e.get_expiration(); - expirations.get(asset_index.unwrap() as u32) + assets::expires(e, asset) } // Extends the asset expiration date by a given amount of tokens. // // # Arguments // - // * `sponsor` - Sponsor account address that burns tokens + // * `sponsor` - Address that sponsors price feed // * `asset` - Quoted asset // * `amount` - Amount of tokens to burn for extending the expiration date // // # Panics // - // Panics if the asset is not supported, or if the fee token or fee itself are not set + // 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) { //check sponsor authorization sponsor.require_auth(); - //check if the amount is valid - if amount <= 0 { - e.panic_with_error(Error::InvalidAmount); - } - //ensure that the asset is supported - let asset_index = e.get_asset_index(&asset); - if asset_index.is_none() { - e.panic_with_error(Error::AssetMissing); - } - let asset_index = asset_index.unwrap() as u32; - - let (token, fee) = match e.get_retention_config() { - RetentionConfig::Some(fee_data) => { - if fee_data.1 <= 0 { - e.panic_with_error(Error::InvalidConfigVersion); - } - fee_data - } - RetentionConfig::None => { - e.panic_with_error(Error::InvalidConfigVersion); - } - }; - - //get minutes to extend - let bump = amount * 86400000 / fee; // result in milliseconds - if bump <= 0 { - e.panic_with_error(Error::InvalidAmount); - } - - //burn the corresponding amount of fee tokens - TokenClient::new(&e, &token).burn(&sponsor, &amount); - - //load expiration info - let mut expiration = e.get_expiration(); - let now = now(&e); - let mut asset_expiration = expiration - .get(asset_index) - .unwrap_or_else(|| now + 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 instance - e.set_expiration(&expiration) + assets::extend_ttl(e, sponsor, asset, amount); } - // Returns the fee token address and daily retainer fee amount. + // Return the fee token address daily price feed retainer fee amount // // # Returns // // Fee token address and daily price feed retainer fee amount pub fn retention_config(e: &Env) -> RetentionConfig { - e.get_retention_config() + settings::get_retention_config(e) } - // Returns admin address of the contract. + // Return contract admin address // // # Returns // // Contract admin account address pub fn admin(e: &Env) -> Option
{ - e.get_admin() + auth::get_admin(e) } - //Admin section + /* Admin section */ - // Updates the contract configuration parameters. Can be invoked only by the admin account. - // + // Initializes contract configuration + // Requires admin authorization // # Arguments // - // * `admin` - Admin account address // * `config` - Configuration parameters // // # Panics // - // Panics if the contract is already initialized, or if the version is invalid + // Panics if not authorized or if contract is already initialized pub fn config(e: &Env, config: ConfigData) { + //should be invoked by admin 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_history_retention_period(config.history_retention_period); - e.set_cache_size(config.cache_size); - e.set_retention_config(config.retention_config); - //set protocol version to current - e.set_protocol_version(CURRENT_PROTOCOL); - //add assets - add_assets(&e, config.assets); + //apply settings + settings::init(e, &config); + auth::set_admin(e, &config.admin); + protocol::set_protocol_version(e, CURRENT_PROTOCOL); + //add initial assets + assets::add_assets(&e, config.assets); } + // 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) { - e.panic_if_not_admin(); - e.set_cache_size(cache_size); + auth::panic_if_not_admin(e); + settings::set_cache_size(e, cache_size); } - // Adds given assets to the contract quoted assets list. Can be invoked only by the admin account. + // Adds given assets to the contract quoted assets list + // Requires admin authorization // // # 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 + // Panics if not authorized, any of the assets were added earlier, or assets limit exceeded pub fn add_assets(e: &Env, assets: Vec) { - e.panic_if_not_admin(); - add_assets(&e, assets); + auth::panic_if_not_admin(e); + assets::add_assets(&e, assets); } - // Sets history retention period for the prices. Can be invoked only by the admin account. + // Sets history retention period for the prices + // Requires admin authorization // // # 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 + // Panics if not authorized pub fn set_history_retention_period(e: &Env, period: u64) { - e.panic_if_not_admin(); - e.set_history_retention_period(period); + auth::panic_if_not_admin(e); + settings::set_history_retention_period(e, period); } - // Sets the fee token address and daily asset feed retainer fee amount. - // Can be invoked only by the admin account. + // Set fee token address and daily price feed retainer fee amount + // Requires admin authorization // // # Arguments // @@ -462,347 +408,52 @@ impl PriceOracleContract { // // # Panics // - // Panics if the caller doesn't match admin address, or not initialized yet + // Panics if not authorized or not initialized yet pub fn set_retention_config(e: &Env, retention_config: RetentionConfig) { - e.panic_if_not_admin(); - e.set_retention_config(retention_config); - let mut expiration = e.get_expiration(); - if expiration.len() > 0 { - return; // expiration values for existing price feeds already initialized - } - //init expiration, set INITIAL_EXPIRATION_PERIOD for all symbols by default - let exp = now(&e) - .checked_add(days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) - .unwrap(); - let assets = e.get_assets(); - for _ in 0..assets.len() { - expiration.push_back(exp); - } - e.set_expiration(&expiration); + auth::panic_if_not_admin(e); + settings::set_retention_config(e, &retention_config); + assets::init_expiration_config(e); } - // Record new price feed history snapshot. Can be invoked only by the admin account. + // Record new price feed history snapshot + // Requires admin authorization // // # 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 + // Panics if not authorized or 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); + auth::panic_if_not_admin(e); + if updates.len() == 0 { + return; //skip empty updates } - let timeframe: u64 = e.get_resolution().into(); - let ledger_timestamp = now(&e); - if timestamp == 0 - || !timestamp.is_valid_timestamp(timeframe) - || timestamp > ledger_timestamp - { + //validate record timestamp + let ledger_timestamp = timestamps::ledger_timestamp(&e); + if timestamp == 0 || !timestamps::is_valid(e, timestamp) || timestamp > ledger_timestamp { panic_with_error!(&e, Error::InvalidTimestamp); } - - let retention_period = e.get_history_retention_period(); - - let ledgers_to_live = ((retention_period / 1000 / 5 + 1) * 2) as u32; - - update_price_v1(&e, &updates, timestamp, ledger_timestamp, ledgers_to_live); - - //get the last timestamp - let last_timestamp = e.get_last_timestamp(); - - //store new prices in v2 format - e.set_prices(&updates, timestamp, ledgers_to_live); - - //update the cache - let cache_size = e.get_cache_size(); - if cache_size > 0 { //if cache size is non-empty, store it in the instance - let mut cache = e.get_cache().unwrap_or(Vec::new(&e)); - cache.push_front((timestamp, updates.clone())); - while cache.len() > cache_size { - cache.pop_back(); //remove the oldest record if cache size exceeded - } - e.set_cache(cache); - } - - //update the last timestamp - if timestamp > last_timestamp { - e.set_last_timestamp(timestamp); - } - - //load all registered assets - let assets = e.get_assets(); - //event updates - let mut event_updates = Vec::new(&e); - for (index, asset) in assets.iter().enumerate() { - let price = updates.get(index as u32).unwrap_or(0i128); - if price == 0 { - continue; //skip zero prices - } - let symbol = match asset { - Asset::Stellar(address) => { - address.to_val() - }, - Asset::Other(symbol) => { - symbol.to_val() - } - }; - event_updates.push_back((symbol, price)); - } - - //publish the price updates - let event = UpdateEvent { - timestamp, - update_data: event_updates, - }; - e.events().publish_event(&event); + //prepare and publish update event + events::publish_update_event(e, &updates, timestamp); + //store new prices + prices::store_prices(e, &updates, timestamp); } - // Updates the contract source code. Can be invoked only by the admin account. + // Update contract source code + // Requires admin authorization // // # 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 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) + // 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); } } - -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)?; - get_x_price_by_indexes(e, asset_pair_indexes, timestamp, decimals) -} - -fn get_x_price_by_indexes( - e: &Env, - asset_pair_indexes: (u32, u32), - timestamp: u64, - decimals: u32, -) -> Option { - //get the asset indexes - 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)); - } - - let prices = e.get_prices(timestamp); - - //get the price for base_asset - let base_asset_price = get_price_data_by_index(e, base_asset, timestamp, &prices)?; - - //get the price for quote_asset - let quote_asset_price = get_price_data_by_index(e, quote_asset, timestamp, &prices)?; - - //calculate the cross price - Some(get_normalized_price_data( - base_asset_price - .price - .fixed_div_floor(quote_asset_price.price, decimals), - timestamp, - )) -} - -fn get_asset_pair_indexes(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option<(u32, u32)> { - let base_asset = e.get_asset_index(&base_asset)?; - - let quote_asset = e.get_asset_index("e_asset)?; - - Some((base_asset, quote_asset)) -} - -fn get_price_data(e: &Env, asset: Asset, timestamp: u64) -> Option { - let asset = e.get_asset_index(&asset)?; - get_price_data_by_index(e, asset, timestamp, &e.get_prices(timestamp)) -} - -fn get_price_data_by_index(e: &Env, asset: u32, timestamp: u64, prices: &Option>) -> Option { - //if the protocol version is not current, use legacy method - if !is_current_protocol_version(e, now(e)) { - let price = e.get_price_v1(asset as u8, timestamp)?; - return Some(get_normalized_price_data(price, timestamp)); - } - if prices.is_none() { - return None; - } - let asset = asset as u32; - let prices = prices.as_ref().unwrap(); - if prices.len() <= asset { - return None; - } - let price = prices.get(asset)?; - if price == 0 { - return None; - } - Some(get_normalized_price_data(price, timestamp)) -} - -fn get_normalized_price_data(price: i128, timestamp: u64) -> PriceData { - PriceData { - price, - timestamp: timestamp / 1000, //convert to seconds - } -} - -fn add_assets(e: &Env, assets: Vec) { - //use default expiration period for new assets - let expiration_timestamp = now(&e) - .checked_add(days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) - .unwrap(); - let mut current_assets = e.get_assets(); - let mut expiration = e.get_expiration(); - let is_retention_config_set = e.get_retention_config() != RetentionConfig::None; - 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 the fee is not set, we don't need to set the expiration - if is_retention_config_set { - expiration.push_back(expiration_timestamp); //set expiration - } - } - if current_assets.len() >= 256 { - panic_with_error!(&e, Error::AssetLimitExceeded); - } - e.set_assets(current_assets); - e.set_expiration(&expiration); -} - -fn days_to_milliseconds(days: u32) -> u64 { - (days as u64) * 24 * 60 * 60 * 1000 //convert to milliseconds -} - -fn is_current_protocol_version(e: &Env, now: u64) -> bool { - let protocol = e.get_protocol_version(); - if protocol == CURRENT_PROTOCOL { - return true; - } - let update_ts = e.get_update_ts(); - if update_ts == 0 { - e.set_update_ts(now); //set update timestamp to now if not set - return false; - } else if update_ts + days_to_milliseconds(1) < now { - e.set_protocol_version(CURRENT_PROTOCOL); //set protocol to current if the update timestamp is older than 1 day - e.set_update_ts(0); // reset update timestamp - return true; - } - false -} - -fn update_price_v1(e: &Env, updates: &Vec, timestamp: u64, ledger_timestamp: u64, ledgers_to_live: u32) { - //if the protocol version is current, we can skip the legacy update - if !is_current_protocol_version(e, ledger_timestamp) { - return; - } - //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_v1(asset, price, timestamp, ledgers_to_live); - } -} \ No newline at end of file diff --git a/src/prices.rs b/src/prices.rs new file mode 100644 index 0000000..768b765 --- /dev/null +++ b/src/prices.rs @@ -0,0 +1,265 @@ +use crate::{protocol, timestamps}; +use crate::types::price_data::PriceData; +use crate::settings; +use soroban_sdk::{Env, Vec}; + +const CACHE_KEY: &str = "cache"; +const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; + +// 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 { + //load price data for given timestamp + let prices = get_prices(e, timestamp); + //if the protocol version is not current, 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)); + } + if prices.is_none() { + return None; + } + let prices = prices.as_ref().unwrap(); + if prices.len() <= asset { + return None; + } + let price = prices.get(asset)?; + if price == 0 { + return None; + } + Some(normalize_price_data(price, timestamp)) +} + +fn normalize_price_data(price: i128, timestamp: u64) -> PriceData { + PriceData { + price, + timestamp: timestamp / 1000, //convert to seconds + } +} + +// 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 prices for a given timestamp +pub fn get_prices(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, prices: &Vec, timestamp: u64) { + //get the last timestamp + let last_timestamp = get_last_timestamp(e); + //update the last timestamp + if timestamp > last_timestamp { + set_last_timestamp(e, timestamp); + } + //set the price + let temps_storage = e.storage().temporary(); + temps_storage.set(×tamp, prices); + //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, prices.clone())); + 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, prices, timestamp, ledgers_to_live); + } +} + +// Load requested number of price records with a price function callback +pub fn load_prices Option>( + e: &Env, + get_price_fn: F, + mut 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 + records = records.min(20); + + while records > 0 { + //invoke price fetch callback for each record + if let Some(price) = get_price_fn(timestamp) { + prices.push_back(price); + } + if timestamp < resolution { + break; + } + //decrement remaining records counter in every iteration + records -= 1; + timestamp -= resolution; + } + + if prices.is_empty() { + None + } else { + Some(prices) + } +} + +// Calculate TWAP approximation from loaded price range +pub fn calculate_twap Option>( + e: &Env, + get_price_fn: F, + records: u32, +) -> Option { + let prices = load_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 = settings::get_resolution(e) as u64; + let current_time = timestamps::ledger_timestamp(&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) +} + +// Load prices for a pair of assets +pub fn load_cross_price( + e: &Env, + asset_pair_indexes: (u32, u32), + timestamp: u64, + decimals: u32, +) -> Option { + //get the asset indexes + let (base_asset, quote_asset) = asset_pair_indexes; + //check if the asset are the same + if base_asset == quote_asset { + return Some(normalize_price_data(10i128.pow(decimals), timestamp)); + } + //get the price for base_asset + let base_asset_price = retrieve_asset_price_data(e, base_asset, timestamp)?; + //get the price for quote_asset + let quote_asset_price = retrieve_asset_price_data(e, quote_asset, timestamp)?; + + //calculate the cross price + Some(normalize_price_data( + fixed_div_floor(base_asset_price.price, quote_asset_price.price, decimals), + timestamp, + )) +} + +// 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) -> 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 +} \ No newline at end of file diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..8588ab6 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,51 @@ +use crate::{timestamps, CURRENT_PROTOCOL}; +use soroban_sdk::Env; + +//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/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..73827b7 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,95 @@ +use crate::types::{ + asset::Asset, config_data::ConfigData, error::Error, retention_config::RetentionConfig, +}; +use soroban_sdk::{Address, 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"; + +const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; +const DEFAULT_RETENTION_FEE: i128 = 100_000_000; + +#[inline] +pub fn init(e: &Env, config: &ConfigData) { + //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, &config.base_asset); + instance.set(&DECIMALS_KEY, &config.decimals); + set_resolution(e, config.resolution); + set_history_retention_period(e, config.history_retention_period); + set_cache_size(e, config.cache_size); + set_retention_config(e, &config.retention_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_retention_config(e: &Env, retention_config: &RetentionConfig) { + e.storage() + .instance() + .set(&RETENTION_KEY, &retention_config); +} + +#[inline] +pub fn get_retention_config(e: &Env) -> RetentionConfig { + e.storage() + .instance() + .get(&RETENTION_KEY) + .unwrap_or_else(|| { + RetentionConfig::Some(( + Address::from_str(e, XRF_TOKEN_ADDRESS), + DEFAULT_RETENTION_FEE, + )) + }) +} diff --git a/src/test.rs b/src/test.rs index e906656..6c9c653 100644 --- a/src/test.rs +++ b/src/test.rs @@ -4,12 +4,11 @@ extern crate std; use super::*; use alloc::string::ToString; -use soroban_sdk::{ - symbol_short, testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, token::StellarAssetClient, Address, Env, IntoVal, String, Symbol, TryIntoVal -}; +use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal}; use std::panic::{self, AssertUnwindSafe}; - -use {extensions::i128_extensions::I128Extensions, types::asset::Asset}; +use types::asset::Asset; const RESOLUTION: u32 = 300_000; const DECIMALS: u32 = 14; @@ -136,7 +135,15 @@ fn set_price_test() { //set prices for assets client.set_price(&updates, ×tamp); - assert_eq!(env.events().all().last().unwrap().1, (symbol_short!("REFLECTOR"), symbol_short!("update"), &600_000u64).into_val(&env)); + assert_eq!( + env.events().all().last().unwrap().1, + ( + symbol_short!("REFLECTOR"), + symbol_short!("update"), + &600_000u64 + ) + .into_val(&env) + ); } #[test] @@ -235,16 +242,19 @@ fn prices_test() { assert_ne!(result, None); assert_eq!( result, - Some(Vec::from_array(&env, [ - PriceData { - price: normalize_price(200), - timestamp: convert_to_seconds(900_000) - }, - PriceData { - price: normalize_price(100), - timestamp: convert_to_seconds(600_000) - } - ])) + Some(Vec::from_array( + &env, + [ + PriceData { + price: normalize_price(200), + timestamp: convert_to_seconds(900_000) + }, + PriceData { + price: normalize_price(100), + timestamp: convert_to_seconds(600_000) + } + ] + )) ); } @@ -264,16 +274,19 @@ fn x_prices_test() { assert_ne!(result, None); assert_eq!( result, - Some(Vec::from_array(&env, [ - PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(900_000) - }, - PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(600_000) - } - ])) + Some(Vec::from_array( + &env, + [ + PriceData { + price: normalize_price(1), + timestamp: convert_to_seconds(900_000) + }, + PriceData { + price: normalize_price(1), + timestamp: convert_to_seconds(600_000) + } + ] + )) ); } @@ -345,7 +358,7 @@ fn assets_update_overflow_test() { env.cost_estimate().budget().reset_unlimited(); let mut assets = Vec::new(&env); - for i in 1..=256 { + for i in 1..=1000 { assets.push_back(Asset::Other(Symbol::new( &env, &("Asset".to_string() + &i.to_string()), @@ -732,7 +745,9 @@ fn div_tests() { ]; for (a, b, expected) in test_cases.iter() { - let result = panic::catch_unwind(AssertUnwindSafe(|| a.fixed_div_floor(*b, 14))); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + prices::fixed_div_floor(a.clone(), *b, 14) + })); if expected == &-1 { assert!(result.is_err()); } else { @@ -770,12 +785,11 @@ fn set_retention_config_test() { 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(); client.extend_asset_ttl(&sponsor, &asset, &10); assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 9 XRF tokens - let fee_token_balance = TokenClient::new(&env, &fee_asset.address()) - .balance(&sponsor); + let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee -} \ No newline at end of file +} diff --git a/src/timestamps.rs b/src/timestamps.rs new file mode 100644 index 0000000..24e70ee --- /dev/null +++ b/src/timestamps.rs @@ -0,0 +1,26 @@ +use soroban_sdk::Env; +use crate::settings; + +// 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 == 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/src/types/asset.rs b/src/types/asset.rs index b11bd67..6bfbfd6 100644 --- a/src/types/asset.rs +++ b/src/types/asset.rs @@ -2,7 +2,8 @@ use soroban_sdk::{contracttype, Address, Symbol}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +// Quoted symbol descriptor pub enum Asset { - Stellar(Address), - Other(Symbol), + Stellar(Address), // Stellar asset contract address + Other(Symbol), // Symbol for all other external price sources } diff --git a/src/types/asset_type.rs b/src/types/asset_type.rs index 00c6d34..be3f1aa 100644 --- a/src/types/asset_type.rs +++ b/src/types/asset_type.rs @@ -1,6 +1,7 @@ #[derive(PartialEq)] #[repr(u8)] #[allow(dead_code)] +// Type of feed quoted by oracle contract pub enum AssetType { Stellar = 1, Other = 2, diff --git a/src/types/config_data.rs b/src/types/config_data.rs index 46d7f77..4e481e8 100644 --- a/src/types/config_data.rs +++ b/src/types/config_data.rs @@ -7,22 +7,22 @@ use super::asset::Asset; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -// The configuration parameters for the contract. +// Contract configuration parameters pub struct ConfigData { - // The admin address. + // Admin address pub admin: Address, - // The history retention period for the prices. + // Price history retention period pub history_retention_period: u64, - // The assets supported by the contract. + // List of supported assets pub assets: Vec, - // The base asset for the prices. + // Base asset pub base_asset: Asset, - // The number of decimals for the prices. + // Number of decimals for price records pub decimals: u32, - // The resolution of the prices. + // History timeframe resolution pub resolution: u32, - // The cache size for the prices. + // Number of rounds held in instance cache pub cache_size: u32, - // The retention config for the contract. Token address and fee amount. + // Contract retention config pub retention_config: RetentionConfig, } \ No newline at end of file diff --git a/src/types/error.rs b/src/types/error.rs index 75d3a11..79080f7 100644 --- a/src/types/error.rs +++ b/src/types/error.rs @@ -2,24 +2,22 @@ use soroban_sdk::contracterror; #[contracterror] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -// The error codes for the contract. +// Standard contract errors pub enum Error { - // The contract is already initialized. + // Contract already initialized AlreadyInitialized = 0, - // The caller is not authorized to perform the operation. + // Caller is not authorized to perform operation Unauthorized = 1, - // The config assets doen't contain persistent asset. Delete assets is not supported. + // Config asset list doesn't contain persistent asset AssetMissing = 2, - // The asset is already added to the contract's list of supported assets. + // Asset is already exists in supported assets list AssetAlreadyExists = 3, - // The config version is invalid + // Config version is invalid InvalidConfigVersion = 4, - // The prices timestamp is invalid + // Price timestamp is invalid InvalidTimestamp = 5, - // The assets update length or prices update length is invalid - InvalidUpdateLength = 6, - // The assets storage is full - AssetLimitExceeded = 7, - // The amount is invalid (e.g., negative or zero). - InvalidAmount = 8, + // Maximum assets limit reached + AssetLimitExceeded = 6, + // Amount is invalid (negative or zero). + InvalidAmount = 7, } diff --git a/src/types/price_data.rs b/src/types/price_data.rs index 2edbabf..16f865d 100644 --- a/src/types/price_data.rs +++ b/src/types/price_data.rs @@ -2,10 +2,10 @@ use soroban_sdk::contracttype; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -// The price data for an asset at a given timestamp. +// Asset price data at specific timestamp pub struct PriceData { - // The price in contracts' base asset and decimals. + // Price stored with configured decimals places pub price: i128, - // The timestamp of the price. + // Record timestamp pub timestamp: u64, } diff --git a/src/types/retention_config.rs b/src/types/retention_config.rs index 3931a8b..a94c1ad 100644 --- a/src/types/retention_config.rs +++ b/src/types/retention_config.rs @@ -2,6 +2,7 @@ use soroban_sdk::{contracttype, Address}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +// Oracle retention config containing fee asset and daily retention fee amount pub enum RetentionConfig { Some((Address, i128)), None From eee392ce930641c83c92879eb6c53f3bfd6e4db4 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Tue, 14 Oct 2025 16:45:22 +0300 Subject: [PATCH 09/55] refactor retrieve_asset_price_data --- src/prices.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/prices.rs b/src/prices.rs index 768b765..b4e1ad6 100644 --- a/src/prices.rs +++ b/src/prices.rs @@ -23,17 +23,13 @@ pub fn obtain_last_record_timestamp(e: &Env) -> u64 { // Retrieve price from record for specific asset pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option { - //load price data for given timestamp - let prices = get_prices(e, timestamp); //if the protocol version is not current, 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)); } - if prices.is_none() { - return None; - } - let prices = prices.as_ref().unwrap(); + //load price data for given timestamp + let prices = get_prices(e, timestamp)?; if prices.len() <= asset { return None; } From 775b51c4a936154601fde4b06d85c9684c8d75ed Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Thu, 16 Oct 2025 13:34:07 +0300 Subject: [PATCH 10/55] add reflector-oracle-plus contract; --- .gitignore | 3 + Cargo.lock | 50 ++ Cargo.toml | 27 +- reflector-oracle-plus/Cargo.toml | 15 + reflector-oracle-plus/src/charge.rs | 55 +++ reflector-oracle-plus/src/lib.rs | 416 ++++++++++++++++ reflector-oracle-plus/src/settings.rs | 25 + {src => reflector-oracle-plus/src}/test.rs | 463 +++--------------- .../src/types/config_data.rs | 25 + reflector-oracle-plus/src/types/invocation.rs | 6 + reflector-oracle-plus/src/types/mod.rs | 2 + reflector-oracle/Cargo.toml | 14 + reflector-oracle/src/lib.rs | 371 ++++++++++++++ reflector-oracle/src/test.rs | 418 ++++++++++++++++ .../src}/types/config_data.rs | 7 +- reflector-oracle/src/types/mod.rs | 1 + shared/Cargo.toml | 13 + {src => shared/src}/assets.rs | 8 +- {src => shared/src}/auth.rs | 0 {src => shared/src}/events.rs | 13 +- shared/src/lib.rs | 13 + src/lib.rs => shared/src/price_oracle.rs | 190 ++++--- {src => shared/src}/prices.rs | 45 ++ {src => shared/src}/protocol.rs | 5 +- {src => shared/src}/settings.rs | 24 +- shared/src/test.rs | 37 ++ {src => shared/src}/timestamps.rs | 0 {src => shared/src}/types/asset.rs | 0 {src => shared/src}/types/asset_type.rs | 0 {src => shared/src}/types/error.rs | 0 .../src/types/fee_config.rs | 2 +- {src => shared/src}/types/mod.rs | 3 +- {src => shared/src}/types/price_data.rs | 0 33 files changed, 1713 insertions(+), 538 deletions(-) create mode 100644 reflector-oracle-plus/Cargo.toml create mode 100644 reflector-oracle-plus/src/charge.rs create mode 100644 reflector-oracle-plus/src/lib.rs create mode 100644 reflector-oracle-plus/src/settings.rs rename {src => reflector-oracle-plus/src}/test.rs (51%) create mode 100644 reflector-oracle-plus/src/types/config_data.rs create mode 100644 reflector-oracle-plus/src/types/invocation.rs create mode 100644 reflector-oracle-plus/src/types/mod.rs create mode 100644 reflector-oracle/Cargo.toml create mode 100644 reflector-oracle/src/lib.rs create mode 100644 reflector-oracle/src/test.rs rename {src => reflector-oracle/src}/types/config_data.rs (83%) create mode 100644 reflector-oracle/src/types/mod.rs create mode 100644 shared/Cargo.toml rename {src => shared/src}/assets.rs (96%) rename {src => shared/src}/auth.rs (100%) rename {src => shared/src}/events.rs (77%) create mode 100644 shared/src/lib.rs rename src/lib.rs => shared/src/price_oracle.rs (89%) rename {src => shared/src}/prices.rs (83%) rename {src => shared/src}/protocol.rs (94%) rename {src => shared/src}/settings.rs (73%) create mode 100644 shared/src/test.rs rename {src => shared/src}/timestamps.rs (100%) rename {src => shared/src}/types/asset.rs (100%) rename {src => shared/src}/types/asset_type.rs (100%) rename {src => shared/src}/types/error.rs (100%) rename src/types/retention_config.rs => shared/src/types/fee_config.rs (88%) rename {src => shared/src}/types/mod.rs (59%) rename {src => shared/src}/types/price_data.rs (100%) diff --git a/.gitignore b/.gitignore index 319b14b..a0122ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ /.soroban /target /test_snapshots +/reflector-oracle/test_snapshots +/reflector-oracle-plus/test_snapshots +/shared/test_snapshots diff --git a/Cargo.lock b/Cargo.lock index c1f297a..e36f08d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -985,9 +985,19 @@ dependencies = [ name = "reflector-oracle" version = "6.0.0" dependencies = [ + "shared", "soroban-sdk", ] +[[package]] +name = "reflector-oracle-plus" +version = "6.0.0" +dependencies = [ + "shared", + "soroban-sdk", + "test-case", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1163,6 +1173,13 @@ dependencies = [ "keccak", ] +[[package]] +name = "shared" +version = "6.0.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "signature" version = "2.2.0" @@ -1454,6 +1471,39 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index cdd6dd5..f537de7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ -[package] -name = "reflector-oracle" -version = "6.0.0" -edition = "2021" +[workspace] +resolver = "2" -[lib] -crate-type = ["cdylib"] +members = ["shared", "reflector-oracle", "reflector-oracle-plus"] + +[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 = "23.0.2" - -[dev-dependencies] -soroban-sdk = { version = "23.0.2", features = ["testutils"] } - -[features] -testutils = ["soroban-sdk/testutils"] - -[profile.release-with-logs] -inherits = "release" -debug-assertions = true +[workspace.dependencies.soroban-sdk] +version = "23.0.2" diff --git a/reflector-oracle-plus/Cargo.toml b/reflector-oracle-plus/Cargo.toml new file mode 100644 index 0000000..a193006 --- /dev/null +++ b/reflector-oracle-plus/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "reflector-oracle-plus" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shared = { path = "../shared" } +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" diff --git a/reflector-oracle-plus/src/charge.rs b/reflector-oracle-plus/src/charge.rs new file mode 100644 index 0000000..a9d5d40 --- /dev/null +++ b/reflector-oracle-plus/src/charge.rs @@ -0,0 +1,55 @@ +use crate::{settings, types::invocation::Invocation}; +use shared::types::fee_config::FeeConfig; +use soroban_sdk::{Address, Env}; + +const SCALE: u64 = 1_000_000; +const CROSS_PRICE_KOEF: u64 = 2_000_000; +const TWAP_KOEF: u64 = 1_500_000; +const CROSS_TWAP_KOEF: u64 = 3_000_000; +const ROUND_FEE_KOEF: u64 = 1_100_000; + +fn mul_scaled(value: u64, koef: u64) -> u64 { + value * koef / SCALE +} + +pub fn calc_fee( + base_fee: u64, + invocation: Invocation, + rounds: u32, +) -> u64 { + let mut koef = 1_000_000; + match invocation { + Invocation::Price => {} + Invocation::Twap => { + koef = TWAP_KOEF; + } + Invocation::CrossPrice => { + koef = CROSS_PRICE_KOEF; + } + Invocation::CrossTwap => { + koef = CROSS_TWAP_KOEF; + } + } + let mut fee = mul_scaled(base_fee, koef); + if rounds > 1 { + fee = mul_scaled(fee, ROUND_FEE_KOEF); + } + fee +} + +pub fn charge_fee( + e: &Env, + caller: &Address, + invocation: Invocation, + rounds: u32, +) { + let fee_config = settings::get_invocation_config(e); + match fee_config { + FeeConfig::None => return, + FeeConfig::Some((fee_token, base_fee)) => { + let fee = calc_fee(base_fee as u64, invocation, rounds) as i128; + let token = soroban_sdk::token::Client::new(e, &fee_token); + token.transfer(caller, &e.current_contract_address(), &fee); + }, + } +} \ No newline at end of file diff --git a/reflector-oracle-plus/src/lib.rs b/reflector-oracle-plus/src/lib.rs new file mode 100644 index 0000000..30182fa --- /dev/null +++ b/reflector-oracle-plus/src/lib.rs @@ -0,0 +1,416 @@ +#![no_std] + +mod test; +mod settings; +mod types; +mod charge; + +use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData}}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; + +use crate::types::{config_data::ConfigData, invocation::Invocation}; + +#[contract] +pub struct PriceOracleContract; + +#[contractimpl] +impl PriceOracleContract { + + // 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) + } + + // 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 + // + // # 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) { + PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount); + } + + // Return the fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn retention_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::retention_config(e) + } + + // Return the fee token address and invocation fee amount + // + // # Returns + // + // Fee token address and invocation fee amount + pub fn invocation_config(e: &Env) -> FeeConfig { + settings::get_invocation_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 + // + // * `caller` - Address of the caller + // * `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(); + charge::charge_fee(e, &caller, Invocation::Price, 1); + 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, caller: Address, asset: Asset) -> Option { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::Price, 1); + 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, caller: Address, asset: Asset, records: u32) -> Option> { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::Price, records); + PriceOracleContractBase::prices(e, asset, records) + } + + // Returns most recent cross price record for pair of assets + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // + // # Returns + // + // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found + pub fn x_last_price(e: &Env, caller: Address, base_asset: Asset, quote_asset: Asset) -> Option { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::CrossPrice, 1); + PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) + } + + // Return cross price for 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 given timestamp or None if there were no records found for quoted assets + pub fn x_price( + e: &Env, + caller: Address, + base_asset: Asset, + quote_asset: Asset, + timestamp: u64, + ) -> Option { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::CrossPrice, 1); + PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp) + } + + // Returns last N cross price records of for pair of assets + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // * `records` - Number of records to fetch + // + // # 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, + caller: Address, + base_asset: Asset, + quote_asset: Asset, + records: u32, + ) -> Option> { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::CrossPrice, records); + PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records) + } + + // Returns time-weighted average price for 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 asset is not supported + pub fn twap(e: &Env, caller: Address, asset: Asset, records: u32) -> Option { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::Twap, 1); + PriceOracleContractBase::twap(e, asset, records) + } + + // Returns time-weighted average cross price for given asset pair over N recent records + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // * `records` - Number of records to process + // + // # Returns + // + // TWAP (base_asset_price/quote_asset_price) or None if assets are not supported + pub fn x_twap(e: &Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option { + caller.require_auth(); + charge::charge_fee(e, &caller, Invocation::CrossTwap, records); + PriceOracleContractBase::x_twap(e, base_asset, quote_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.admin, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.retention_config, + config.assets + ); + settings::set_invocation_config(e, &config.invocation_config); + } + + // 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); + } + + // Sets history retention period for the prices + // Requires admin authorization + // + // # Arguments + // + // * `period` - History retention period (in seconds) + // + // # 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_retention_config(e: &Env, retention_config: FeeConfig) { + PriceOracleContractBase::set_retention_config(e, retention_config); + } + + // Set fee token address and invocation 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_invocation_config(e: &Env, invocation_config: FeeConfig) { + settings::set_invocation_config(e, &invocation_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: Vec, 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); + } +} \ No newline at end of file diff --git a/reflector-oracle-plus/src/settings.rs b/reflector-oracle-plus/src/settings.rs new file mode 100644 index 0000000..abee3a5 --- /dev/null +++ b/reflector-oracle-plus/src/settings.rs @@ -0,0 +1,25 @@ +use shared::{settings::XRF_TOKEN_ADDRESS, types::fee_config::FeeConfig}; +use soroban_sdk::{Address, Env}; + +const INVOCATION_KEY: &str = "invocation"; +const DEFAULT_INVOCATION_FEE: i128 = 100_000_000; + +#[inline] +pub fn set_invocation_config(e: &Env, inv_config: &FeeConfig) { + e.storage() + .instance() + .set(&INVOCATION_KEY, &inv_config); +} + +#[inline] +pub fn get_invocation_config(e: &Env) -> FeeConfig { + e.storage() + .instance() + .get(&INVOCATION_KEY) + .unwrap_or_else(|| { + FeeConfig::Some(( + Address::from_str(e, XRF_TOKEN_ADDRESS), + DEFAULT_INVOCATION_FEE, + )) + }) +} \ No newline at end of file diff --git a/src/test.rs b/reflector-oracle-plus/src/test.rs similarity index 51% rename from src/test.rs rename to reflector-oracle-plus/src/test.rs index 6c9c653..4090df0 100644 --- a/src/test.rs +++ b/reflector-oracle-plus/src/test.rs @@ -2,13 +2,19 @@ extern crate alloc; extern crate std; -use super::*; -use alloc::string::ToString; + +use shared::prices; +use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal}; +use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; -use types::asset::Asset; +use alloc::string::ToString; + +use crate::types::{config_data::ConfigData, invocation::Invocation}; +use crate::charge; +use crate::{PriceOracleContract, PriceOracleContractClient}; +use test_case::test_case; const RESOLUTION: u32 = 300_000; const DECIMALS: u32 = 14; @@ -35,7 +41,7 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config )); env.register_at(contract_id, PriceOracleContract, ()); - let client: PriceOracleContractClient<'a> = PriceOracleContractClient::new(&env, contract_id); + let client = PriceOracleContractClient::new(&env, contract_id); env.cost_estimate().budget().reset_unlimited(); @@ -47,7 +53,8 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config decimals: 14, resolution: RESOLUTION, cache_size: 0, - retention_config: RetentionConfig::None, + retention_config: FeeConfig::None, + invocation_config: FeeConfig::None }; env.mock_all_auths(); @@ -194,102 +201,6 @@ fn set_price_future_timestamp_test() { 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 prices_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&get_updates(&env, &assets, normalize_price(100)), &600_000); - client.set_price(&get_updates(&env, &assets, normalize_price(200)), &900_000); - - let result = client.prices(&assets.get_unchecked(1), &2); - assert_ne!(result, None); - assert_eq!( - result, - Some(Vec::from_array( - &env, - [ - PriceData { - price: normalize_price(200), - timestamp: convert_to_seconds(900_000) - }, - PriceData { - price: normalize_price(100), - timestamp: convert_to_seconds(600_000) - } - ] - )) - ); -} - -#[test] -fn x_prices_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - env.mock_all_auths(); - - //set prices for assets - client.set_price(&get_updates(&env, &assets, normalize_price(100)), &600_000); - client.set_price(&get_updates(&env, &assets, normalize_price(200)), &900_000); - - let result = client.x_prices(&assets.get_unchecked(0), &assets.get_unchecked(1), &2); - assert_ne!(result, None); - assert_eq!( - result, - Some(Vec::from_array( - &env, - [ - PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(900_000) - }, - PriceData { - price: normalize_price(1), - timestamp: convert_to_seconds(600_000) - } - ] - )) - ); -} - #[test] fn last_timestamp_test() { let (env, client, init_data) = init_contract_with_admin(); @@ -399,291 +310,6 @@ fn set_period_test() { 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(); @@ -769,12 +395,12 @@ fn set_retention_config_test() { //create fee asset token let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); - let retention_config = RetentionConfig::Some((fee_asset.address(), 7)); + let retention_config = FeeConfig::Some((fee_asset.address(), 7)); client.set_retention_config(&retention_config); let result = client.retention_config(); - assert_ne!(result, RetentionConfig::None); + assert_ne!(result, FeeConfig::None); assert_eq!(result, retention_config); let asset: Asset = init_data.assets.get_unchecked(0); @@ -793,3 +419,62 @@ fn set_retention_config_test() { let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee } + +#[test] +fn set_invocation_config_test() { + let (env, client, init_data) = init_contract_with_admin(); + + //create fee asset token + let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); + + client.set_invocation_config(&FeeConfig::Some((fee_asset.address(), 1_000_000))); + + let result = client.invocation_config(); + assert_ne!(result, FeeConfig::None); + assert_eq!(result, FeeConfig::Some((fee_asset.address(), 1_000_000))); +} + +#[test] +fn 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 fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()).address(); + let invocation_config = FeeConfig::Some((fee_asset.clone(), 1_000_000)); + client.set_invocation_config(&invocation_config); + + let caller = Address::generate(&env); + //mint fee token to caller + let fee_token = StellarAssetClient::new(&env, &fee_asset); + fee_token.mint(&caller, &1_000_000); + //get price for the first asset + let price = client.lastprice(&caller, &init_data.assets.first_unchecked()).unwrap(); + assert_eq!(price.price, normalize_price(100)); + assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + + //check that fee token was deducted + let fee_token_balance = TokenClient::new(&env, &fee_asset).balance(&caller); + assert_eq!(fee_token_balance, 0); +} + +#[test_case(1_000_000, Invocation::Price, 1, 1_000_000 ; "price")] +#[test_case(1_000_000, Invocation::Twap, 1, 1_500_000 ; "twap")] +#[test_case(1_000_000, Invocation::CrossPrice, 1, 2_000_000 ; "cross price")] +#[test_case(1_000_000, Invocation::CrossTwap, 1, 3_000_000 ; "cross twap")] +#[test_case(1_000_000, Invocation::Price, 2, 1_100_000 ; "multi round price")] +#[test_case(1_000_000, Invocation::Twap, 2, 1_650_000 ; "multi round twap")] +#[test_case(1_000_000, Invocation::CrossPrice, 2, 2_200_000 ; "multi round cross price")] +#[test_case(1_000_000, Invocation::CrossTwap, 2, 3_300_000 ; "multi round cross twap")] +fn charge_test(base_fee: u64, invocation: Invocation, rounds: u32, expected_fee: u64) { + let fee = charge::calc_fee(base_fee, invocation, rounds); + assert_eq!(fee, expected_fee); +} \ No newline at end of file diff --git a/reflector-oracle-plus/src/types/config_data.rs b/reflector-oracle-plus/src/types/config_data.rs new file mode 100644 index 0000000..1432ad9 --- /dev/null +++ b/reflector-oracle-plus/src/types/config_data.rs @@ -0,0 +1,25 @@ +use shared::types::{asset::Asset, fee_config::FeeConfig}; +use soroban_sdk::{contracttype, Address, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigData { + // Admin address + pub admin: Address, + // Price history retention period + 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 + pub resolution: u32, + // Number of rounds held in instance cache + pub cache_size: u32, + // Contract retention config + pub retention_config: FeeConfig, + /// Invocation fee + pub invocation_config: FeeConfig +} \ No newline at end of file diff --git a/reflector-oracle-plus/src/types/invocation.rs b/reflector-oracle-plus/src/types/invocation.rs new file mode 100644 index 0000000..8b09d79 --- /dev/null +++ b/reflector-oracle-plus/src/types/invocation.rs @@ -0,0 +1,6 @@ +pub enum Invocation { + Price = 0, + CrossPrice = 1, + Twap = 2, + CrossTwap = 3, +} \ No newline at end of file diff --git a/reflector-oracle-plus/src/types/mod.rs b/reflector-oracle-plus/src/types/mod.rs new file mode 100644 index 0000000..22603ad --- /dev/null +++ b/reflector-oracle-plus/src/types/mod.rs @@ -0,0 +1,2 @@ +pub mod config_data; +pub mod invocation; \ No newline at end of file diff --git a/reflector-oracle/Cargo.toml b/reflector-oracle/Cargo.toml new file mode 100644 index 0000000..24af259 --- /dev/null +++ b/reflector-oracle/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "reflector-oracle" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +shared = { path = "../shared" } +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/reflector-oracle/src/lib.rs b/reflector-oracle/src/lib.rs new file mode 100644 index 0000000..6f40bb9 --- /dev/null +++ b/reflector-oracle/src/lib.rs @@ -0,0 +1,371 @@ +#![no_std] + +mod test; +mod types; + +use crate::types::config_data::ConfigData; + +use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData}}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; + +#[contract] +pub struct PriceOracleContract; + +#[contractimpl] +impl PriceOracleContract { + + // 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) + } + + // 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 + // + // # 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) { + PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount); + } + + // Return the fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn retention_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::retention_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) + } + + // Returns most recent cross price record for pair of assets + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // + // # Returns + // + // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found + pub fn x_last_price(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option { + PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) + } + + // Return cross price for 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 given timestamp or None if there were no records found for quoted assets + pub fn x_price( + e: &Env, + base_asset: Asset, + quote_asset: Asset, + timestamp: u64, + ) -> Option { + PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp) + } + + // Returns last N cross price records of for pair of assets + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // * `records` - Number of records to fetch + // + // # 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> { + PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records) + } + + // Returns time-weighted average price for 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 asset is not supported + pub fn twap(e: &Env, asset: Asset, records: u32) -> Option { + PriceOracleContractBase::twap(e, asset, records) + } + + // Returns time-weighted average cross price for given asset pair over N recent records + // + // # Arguments + // + // * `base_asset` - Base asset + // * `quote_asset` - Quote asset + // * `records` - Number of records to process + // + // # Returns + // + // TWAP (base_asset_price/quote_asset_price) or None if assets are not supported + pub fn x_twap(e: &Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option { + PriceOracleContractBase::x_twap(e, base_asset, quote_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.admin, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.retention_config, + config.assets + ); + } + + // 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); + } + + // Sets history retention period for the prices + // Requires admin authorization + // + // # Arguments + // + // * `period` - History retention period (in seconds) + // + // # 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_retention_config(e: &Env, retention_config: FeeConfig) { + PriceOracleContractBase::set_retention_config(e, retention_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: Vec, 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); + } +} \ No newline at end of file diff --git a/reflector-oracle/src/test.rs b/reflector-oracle/src/test.rs new file mode 100644 index 0000000..fc6fbd3 --- /dev/null +++ b/reflector-oracle/src/test.rs @@ -0,0 +1,418 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + + +use shared::prices; +use shared::types::{asset::Asset, fee_config::FeeConfig}; +use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; +use std::panic::{self, AssertUnwindSafe}; +use alloc::string::ToString; + +use crate::types::config_data::ConfigData; +use crate::{PriceOracleContract, PriceOracleContractClient}; + +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_at(contract_id, PriceOracleContract, ()); + let client = PriceOracleContractClient::new(&env, contract_id); + + env.cost_estimate().budget().reset_unlimited(); + + let init_data = 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, + retention_config: FeeConfig::None + }; + + 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.history_retention_period().unwrap(); + assert_eq!(period, init_data.history_retention_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); + + assert_eq!( + env.events().all().last().unwrap().1, + ( + symbol_short!("REFLECTOR"), + symbol_short!("update"), + &600_000u64 + ) + .into_val(&env) + ); +} + +#[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_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.cost_estimate().budget().reset_unlimited(); + + 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 prices_update_overflow_test() { + let (env, client, _) = init_contract_with_admin(); + + env.mock_all_auths(); + + env.cost_estimate().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_history_retention_period(&period); + + let result = client.history_retention_period().unwrap(); + + assert_eq!(result, convert_to_seconds(period)); +} + +#[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_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 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_history_retention_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(|| { + prices::fixed_div_floor(a.clone(), *b, 14) + })); + if expected == &-1 { + assert!(result.is_err()); + } else { + assert_eq!(result.unwrap(), *expected); + } + } +} + +#[test] +fn set_retention_config_test() { + let (env, client, init_data) = init_contract_with_admin(); + + //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 retention_config = FeeConfig::Some((fee_asset.address(), 7)); + + client.set_retention_config(&retention_config); + + let result = client.retention_config(); + assert_ne!(result, FeeConfig::None); + assert_eq!(result, retention_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(); + client.extend_asset_ttl(&sponsor, &asset, &10); + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 9 XRF tokens + + let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); + assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee +} \ No newline at end of file diff --git a/src/types/config_data.rs b/reflector-oracle/src/types/config_data.rs similarity index 83% rename from src/types/config_data.rs rename to reflector-oracle/src/types/config_data.rs index 4e481e8..eec3963 100644 --- a/src/types/config_data.rs +++ b/reflector-oracle/src/types/config_data.rs @@ -1,8 +1,5 @@ use soroban_sdk::{contracttype, Address, Vec}; - -use crate::types::retention_config::RetentionConfig; - -use super::asset::Asset; +use shared::types::{asset::Asset, fee_config::FeeConfig}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -24,5 +21,5 @@ pub struct ConfigData { // Number of rounds held in instance cache pub cache_size: u32, // Contract retention config - pub retention_config: RetentionConfig, + pub retention_config: FeeConfig } \ No newline at end of file diff --git a/reflector-oracle/src/types/mod.rs b/reflector-oracle/src/types/mod.rs new file mode 100644 index 0000000..480fbbe --- /dev/null +++ b/reflector-oracle/src/types/mod.rs @@ -0,0 +1 @@ +pub mod config_data; \ No newline at end of file diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..470babc --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "shared" +version = "6.0.0" +edition = "2021" + +[lib] +crate-type = ["rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/src/assets.rs b/shared/src/assets.rs similarity index 96% rename from src/assets.rs rename to shared/src/assets.rs index 84a8441..2cec7de 100644 --- a/src/assets.rs +++ b/shared/src/assets.rs @@ -1,4 +1,4 @@ -use crate::types::{asset::Asset, error::Error, retention_config::RetentionConfig}; +use crate::types::{asset::Asset, error::Error, fee_config::FeeConfig}; use crate::{settings, timestamps}; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env, Vec}; @@ -51,7 +51,7 @@ pub fn add_assets(e: &Env, assets: Vec) { //load current state let mut asset_list = load_all_assets(e); let mut expiration = load_expiration_records(e); - let is_retention_config_set = settings::get_retention_config(e) != RetentionConfig::None; + let is_retention_config_set = settings::get_retention_config(e) != FeeConfig::None; //for each new asset for asset in assets.iter() { //check if the asset has been already added @@ -115,13 +115,13 @@ pub fn extend_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) { let asset_index = asset_index.unwrap(); //load required fee amount from retention config let (xrf, fee) = match settings::get_retention_config(e) { - RetentionConfig::Some(fee_data) => { + FeeConfig::Some(fee_data) => { if fee_data.1 <= 0 { e.panic_with_error(Error::InvalidConfigVersion); } fee_data } - RetentionConfig::None => { + FeeConfig::None => { e.panic_with_error(Error::InvalidConfigVersion); } }; diff --git a/src/auth.rs b/shared/src/auth.rs similarity index 100% rename from src/auth.rs rename to shared/src/auth.rs diff --git a/src/events.rs b/shared/src/events.rs similarity index 77% rename from src/events.rs rename to shared/src/events.rs index 541ed45..24bc3e0 100644 --- a/src/events.rs +++ b/shared/src/events.rs @@ -1,6 +1,13 @@ -use crate::types::{asset::Asset, error::Error}; -use crate::{assets, UpdateEvent}; -use soroban_sdk::{panic_with_error, Env, Vec}; +use crate::{assets, types::{asset::Asset, error::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] diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..fbc89ef --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,13 @@ +#![no_std] + +pub mod assets; +pub mod auth; +pub mod events; +pub mod settings; +pub mod timestamps; +pub mod types; +pub mod price_oracle; +pub mod prices; +pub mod protocol; + +pub mod test; \ No newline at end of file diff --git a/src/lib.rs b/shared/src/price_oracle.rs similarity index 89% rename from src/lib.rs rename to shared/src/price_oracle.rs index 1c744d6..e363255 100644 --- a/src/lib.rs +++ b/shared/src/price_oracle.rs @@ -1,34 +1,9 @@ -#![no_std] +use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; +use crate::{assets, auth, events, prices, protocol, settings, timestamps, types::{asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData}}; -mod assets; -mod auth; -mod events; -mod prices; -mod protocol; -mod settings; -mod test; -mod timestamps; -mod types; +pub struct PriceOracleContractBase; -use soroban_sdk::{contractevent, panic_with_error, Address, BytesN, Env, Val, Vec}; -use types::{asset::Asset, error::Error, retention_config::RetentionConfig}; -use types::{config_data::ConfigData, price_data::PriceData}; - -const CURRENT_PROTOCOL: u32 = 2; //current protocol version - -#[contractevent(topics = ["REFLECTOR", "update"])] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UpdateEvent { - #[topic] - pub timestamp: u64, - pub update_data: Vec<(Val, i128)>, -} - -#[soroban_sdk::contract] -pub struct PriceOracleContract; - -#[soroban_sdk::contractimpl] -impl PriceOracleContract { +impl PriceOracleContractBase { // Return base asset price is reported in // // # Returns @@ -97,6 +72,72 @@ impl PriceOracleContract { prices::get_last_timestamp(e) / 1000 //convert to seconds } + // Return current contract protocol version + // + // # Returns + // + // Contract protocol 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 or None if asset is not supported + // + // # Panics + // + // Panics if asset is not supported + pub fn expires(e: &Env, asset: Asset) -> Option { + assets::expires(e, asset) + } + + // 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 + // + // # 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) { + //check sponsor authorization + sponsor.require_auth(); + assets::extend_ttl(e, sponsor, asset, amount); + } + + // Return the fee token address daily price feed retainer fee amount + // + // # Returns + // + // Fee token address and daily price feed retainer fee amount + pub fn retention_config(e: &Env) -> FeeConfig { + settings::get_retention_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 @@ -266,92 +307,33 @@ impl PriceOracleContract { ) } - // Return current contract protocol version - // - // # Returns - // - // Contract protocol 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 or None if asset is not supported - // - // # Panics - // - // Panics if asset is not supported - pub fn expires(e: &Env, asset: Asset) -> Option { - assets::expires(e, asset) - } - - // 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 - // - // # 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) { - //check sponsor authorization - sponsor.require_auth(); - assets::extend_ttl(e, sponsor, asset, amount); - } - - // Return the fee token address daily price feed retainer fee amount - // - // # Returns - // - // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: &Env) -> RetentionConfig { - settings::get_retention_config(e) - } - - // Return contract admin address - // - // # Returns - // - // Contract admin account address - pub fn admin(e: &Env) -> Option
{ - auth::get_admin(e) - } - /* Admin section */ // Initializes contract configuration // Requires admin authorization // # Arguments // - // * `config` - Configuration parameters + // * `admin` - Admin address + // * `base` - Base asset + // * `decimals` - Number of decimals for price records + // * `resolution` - History timeframe resolution (in seconds) + // * `history_retention_period` - Price history retention period (in seconds) + // * `cache_size` - Number of rounds held in instance cache + // * `retention_config` - Contract retention config + // * `assets` - Initial list of supported assets // // # Panics // // Panics if not authorized or if contract is already initialized - pub fn config(e: &Env, config: ConfigData) { + pub fn config(e: &Env, admin: &Address, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_config: &FeeConfig, assets: Vec) { //should be invoked by admin - config.admin.require_auth(); + admin.require_auth(); //apply settings - settings::init(e, &config); - auth::set_admin(e, &config.admin); - protocol::set_protocol_version(e, CURRENT_PROTOCOL); + settings::init(e, base, decimals, resolution, history_retention_period, cache_size, &retention_config); + auth::set_admin(e, admin); + protocol::set_protocol_version(e, protocol::CURRENT_PROTOCOL); //add initial assets - assets::add_assets(&e, config.assets); + assets::add_assets(&e, assets); } // Update contract cache size @@ -409,7 +391,7 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_retention_config(e: &Env, retention_config: RetentionConfig) { + pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { auth::panic_if_not_admin(e); settings::set_retention_config(e, &retention_config); assets::init_expiration_config(e); @@ -438,6 +420,8 @@ impl PriceOracleContract { } //prepare and publish update event events::publish_update_event(e, &updates, timestamp); + //store asset timestamps + prices::set_last_timestamps(e, &updates, timestamp); //store new prices prices::store_prices(e, &updates, timestamp); } diff --git a/src/prices.rs b/shared/src/prices.rs similarity index 83% rename from src/prices.rs rename to shared/src/prices.rs index b4e1ad6..ff331e5 100644 --- a/src/prices.rs +++ b/shared/src/prices.rs @@ -5,6 +5,7 @@ use soroban_sdk::{Env, Vec}; const CACHE_KEY: &str = "cache"; const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; +const ASSET_TIMESTAMPS_KEY: &str = "asset_timestamps"; // Get last known record timestamp pub fn obtain_last_record_timestamp(e: &Env) -> u64 { @@ -61,6 +62,50 @@ pub fn set_last_timestamp(e: &Env, timestamp: u64) { e.storage().instance().set(&LAST_TIMESTAMP_KEY, ×tamp); } +pub fn get_last_timestamps(e: &Env) -> Vec> { + e.storage().instance().get(&ASSET_TIMESTAMPS_KEY).unwrap_or_else(|| Vec::new(e)) +} + +const LIMIT: usize = 145; + +pub fn set_last_timestamps(e: &Env, prices: &Vec, timestamp: u64) { + let last_timestamp = get_last_timestamp(e); + let mut timestamps = get_last_timestamps(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; + } + //shift existing timestamps + for asset_index in 0..prices.len() { + let mut asset_timestamps = timestamps + .get(asset_index) + .unwrap_or_else(|| Vec::from_array(&e, [false; LIMIT])); + + if update_delta > 1 { + //shift missing intervals + for _ in 1..update_delta { + asset_timestamps.push_front(false); + } + } + let has_update = prices + .get(asset_index as u32) + .unwrap_or_default() != 0; + asset_timestamps.push_front(has_update); + while asset_timestamps.len() > LIMIT as u32 { + asset_timestamps.pop_back(); + } + //store back + if timestamps.len() == asset_index { + timestamps.push_back(asset_timestamps.clone()); + } else { + timestamps.set(asset_index as u32, asset_timestamps.clone()); + } + } + e.storage().instance().set(&ASSET_TIMESTAMPS_KEY, ×tamps); +} + // Load prices for a given timestamp pub fn get_prices(e: &Env, timestamp: u64) -> Option> { //check if the timestamp is in the cache diff --git a/src/protocol.rs b/shared/src/protocol.rs similarity index 94% rename from src/protocol.rs rename to shared/src/protocol.rs index 8588ab6..f5fcc09 100644 --- a/src/protocol.rs +++ b/shared/src/protocol.rs @@ -1,6 +1,9 @@ -use crate::{timestamps, CURRENT_PROTOCOL}; +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"; diff --git a/src/settings.rs b/shared/src/settings.rs similarity index 73% rename from src/settings.rs rename to shared/src/settings.rs index 73827b7..539ef4a 100644 --- a/src/settings.rs +++ b/shared/src/settings.rs @@ -1,5 +1,5 @@ use crate::types::{ - asset::Asset, config_data::ConfigData, error::Error, retention_config::RetentionConfig, + asset::Asset, error::Error, fee_config::FeeConfig, }; use soroban_sdk::{Address, Env}; @@ -10,23 +10,23 @@ const RESOLUTION_KEY: &str = "resolution"; const RETENTION_KEY: &str = "retention"; const CACHE_SIZE_KEY: &str = "cache_size"; -const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; +pub const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; const DEFAULT_RETENTION_FEE: i128 = 100_000_000; #[inline] -pub fn init(e: &Env, config: &ConfigData) { +pub fn init(e: &Env, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_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, &config.base_asset); - instance.set(&DECIMALS_KEY, &config.decimals); - set_resolution(e, config.resolution); - set_history_retention_period(e, config.history_retention_period); - set_cache_size(e, config.cache_size); - set_retention_config(e, &config.retention_config); + 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_retention_config(e, retention_config); } #[inline] @@ -75,19 +75,19 @@ pub fn set_cache_size(e: &Env, cache_size: u32) { } #[inline] -pub fn set_retention_config(e: &Env, retention_config: &RetentionConfig) { +pub fn set_retention_config(e: &Env, retention_config: &FeeConfig) { e.storage() .instance() .set(&RETENTION_KEY, &retention_config); } #[inline] -pub fn get_retention_config(e: &Env) -> RetentionConfig { +pub fn get_retention_config(e: &Env) -> FeeConfig { e.storage() .instance() .get(&RETENTION_KEY) .unwrap_or_else(|| { - RetentionConfig::Some(( + FeeConfig::Some(( Address::from_str(e, XRF_TOKEN_ADDRESS), DEFAULT_RETENTION_FEE, )) diff --git a/shared/src/test.rs b/shared/src/test.rs new file mode 100644 index 0000000..7c45515 --- /dev/null +++ b/shared/src/test.rs @@ -0,0 +1,37 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use super::*; +use std::panic::{self, AssertUnwindSafe}; + +#[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(|| { + prices::fixed_div_floor(a.clone(), *b, 14) + })); + if expected == &-1 { + assert!(result.is_err()); + } else { + assert_eq!(result.unwrap(), *expected); + } + } +} \ No newline at end of file diff --git a/src/timestamps.rs b/shared/src/timestamps.rs similarity index 100% rename from src/timestamps.rs rename to shared/src/timestamps.rs diff --git a/src/types/asset.rs b/shared/src/types/asset.rs similarity index 100% rename from src/types/asset.rs rename to shared/src/types/asset.rs diff --git a/src/types/asset_type.rs b/shared/src/types/asset_type.rs similarity index 100% rename from src/types/asset_type.rs rename to shared/src/types/asset_type.rs diff --git a/src/types/error.rs b/shared/src/types/error.rs similarity index 100% rename from src/types/error.rs rename to shared/src/types/error.rs diff --git a/src/types/retention_config.rs b/shared/src/types/fee_config.rs similarity index 88% rename from src/types/retention_config.rs rename to shared/src/types/fee_config.rs index a94c1ad..b5d2186 100644 --- a/src/types/retention_config.rs +++ b/shared/src/types/fee_config.rs @@ -3,7 +3,7 @@ use soroban_sdk::{contracttype, Address}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] // Oracle retention config containing fee asset and daily retention fee amount -pub enum RetentionConfig { +pub enum FeeConfig { Some((Address, i128)), None } \ No newline at end of file diff --git a/src/types/mod.rs b/shared/src/types/mod.rs similarity index 59% rename from src/types/mod.rs rename to shared/src/types/mod.rs index fb914d1..f754e4c 100644 --- a/src/types/mod.rs +++ b/shared/src/types/mod.rs @@ -1,6 +1,5 @@ pub mod asset; pub mod asset_type; -pub mod config_data; pub mod error; pub mod price_data; -pub mod retention_config; +pub mod fee_config; diff --git a/src/types/price_data.rs b/shared/src/types/price_data.rs similarity index 100% rename from src/types/price_data.rs rename to shared/src/types/price_data.rs From 93ce9957394f36c21cea8d0b5cea3c29259b1d8b Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Thu, 16 Oct 2025 18:02:11 +0300 Subject: [PATCH 11/55] add history_timestamps functionality --- reflector-oracle-plus/src/test.rs | 76 +++++++++++++++++++++++++++++- reflector-oracle/src/test.rs | 78 ++++++++++++++++++++++++++++++- shared/src/lib.rs | 1 + shared/src/pos_encoding.rs | 65 ++++++++++++++++++++++++++ shared/src/price_oracle.rs | 2 +- shared/src/prices.rs | 71 +++++++++++++++------------- shared/src/settings.rs | 2 +- shared/src/test.rs | 66 +++++++++++++++++++++++++- 8 files changed, 323 insertions(+), 38 deletions(-) create mode 100644 shared/src/pos_encoding.rs diff --git a/reflector-oracle-plus/src/test.rs b/reflector-oracle-plus/src/test.rs index 4090df0..ef4ef67 100644 --- a/reflector-oracle-plus/src/test.rs +++ b/reflector-oracle-plus/src/test.rs @@ -7,7 +7,7 @@ use shared::prices; use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; +use soroban_sdk::{log, symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; use alloc::string::ToString; @@ -92,6 +92,28 @@ fn get_updates(env: &Env, assets: &Vec, price: i128) -> Vec { updates } +fn get_random_bool() -> bool { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let random_bool = (nanos % 200) == 0; + random_bool +} + +fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + let price = if get_random_bool() { + 0 + } else { + price + }; + updates.push_back(price); + } + updates +} + #[test] fn version_test() { let (_env, client, _init_data) = init_contract_with_admin(); @@ -477,4 +499,56 @@ fn price_test() { fn charge_test(base_fee: u64, invocation: Invocation, rounds: u32, expected_fee: u64) { let fee = charge::calc_fee(base_fee, invocation, rounds); assert_eq!(fee, expected_fee); +} + +#[test] +fn set_price_prices() { + let (env, client, init_data) = init_contract_with_admin(); + + let assets = init_data.assets; + + client.set_cache_size(&256); + + let mut history_prices = Vec::new(&env); + + //set more than 255 prices to check history is overritten correctly + for i in 0..257 { + let timestamp = 600_000 + i * 300_000; + + if timestamp != 900_000 && timestamp != 1200_000 { + let updates = get_updates_with_random(&env, &assets, normalize_price(100)); + history_prices.push_front((timestamp, updates.clone())); + //set prices for assets + client.set_price(&updates, ×tamp); + } else { + //simulate time passage without setting prices to create gaps in updates + let updates = get_updates_with_random(&env, &assets, 0); + history_prices.push_front((timestamp, updates.clone())); + } + let ledger_info = env.ledger().get(); + env.ledger().set(LedgerInfo { + timestamp: timestamp / 1000 + 300, + ..ledger_info + }); + } + + let caller = Address::generate(&env); + + //verify prices + for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { + if history_index > 255 { + break; + } + for (asset_index, asset) in assets.iter().enumerate() { + let price_data = client.price(&caller, &asset, &(timestamp / 1000)); + let expected_price = updates.get(asset_index as u32).unwrap_or_default(); + if expected_price > 0 { + let price = price_data.unwrap(); + assert_eq!(price.price, expected_price); + assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + } else { + assert!(price_data.is_none()); + } + } + } } \ No newline at end of file diff --git a/reflector-oracle/src/test.rs b/reflector-oracle/src/test.rs index fc6fbd3..31a40d3 100644 --- a/reflector-oracle/src/test.rs +++ b/reflector-oracle/src/test.rs @@ -21,6 +21,28 @@ fn convert_to_seconds(timestamp: u64) -> u64 { timestamp / 1000 } +fn get_random_bool() -> bool { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let random_bool = (nanos % 200) == 0; + random_bool +} + +fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + let price = if get_random_bool() { + 0 + } else { + price + }; + updates.push_back(price); + } + updates +} + fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, ConfigData) { let env = Env::default(); @@ -113,10 +135,10 @@ fn init_test() { assert_eq!(base, init_data.base_asset); let resolution = client.resolution(); - assert_eq!(resolution, RESOLUTION / 1000); + assert_eq!(resolution, convert_to_seconds(RESOLUTION.into()) as u32); let period = client.history_retention_period().unwrap(); - assert_eq!(period, init_data.history_retention_period / 1000); + assert_eq!(period, convert_to_seconds(init_data.history_retention_period)); let decimals = client.decimals(); assert_eq!(decimals, DECIMALS); @@ -415,4 +437,56 @@ fn set_retention_config_test() { let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee +} + + + +#[test] +fn set_price_prices() { + let (env, client, init_data) = init_contract_with_admin(); + + let assets = init_data.assets; + + client.set_cache_size(&256); + + let mut history_prices = Vec::new(&env); + + //set more than 255 prices to check history is overritten correctly + for i in 0..257 { + let timestamp = 600_000 + i * 300_000; + + if timestamp != 900_000 && timestamp != 1200_000 { + let updates = get_updates_with_random(&env, &assets, normalize_price(100)); + history_prices.push_front((timestamp, updates.clone())); + //set prices for assets + client.set_price(&updates, ×tamp); + } else { + //simulate time passage without setting prices to create gaps in updates + let updates = get_updates_with_random(&env, &assets, 0); + history_prices.push_front((timestamp, updates.clone())); + } + let ledger_info = env.ledger().get(); + env.ledger().set(LedgerInfo { + timestamp: convert_to_seconds(timestamp) + 300, + ..ledger_info + }); + } + + //verify prices + for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { + if history_index > 255 { + break; + } + for (asset_index, asset) in assets.iter().enumerate() { + let price_data = client.price(&asset, &convert_to_seconds(timestamp)); + let expected_price = updates.get(asset_index as u32).unwrap_or_default(); + if expected_price > 0 { + let price = price_data.unwrap(); + assert_eq!(price.price, expected_price); + assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + } else { + assert!(price_data.is_none()); + } + } + } } \ No newline at end of file diff --git a/shared/src/lib.rs b/shared/src/lib.rs index fbc89ef..c8723d9 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -9,5 +9,6 @@ pub mod types; pub mod price_oracle; pub mod prices; pub mod protocol; +pub mod pos_encoding; pub mod test; \ No newline at end of file diff --git a/shared/src/pos_encoding.rs b/shared/src/pos_encoding.rs new file mode 100644 index 0000000..8c9dec8 --- /dev/null +++ b/shared/src/pos_encoding.rs @@ -0,0 +1,65 @@ +use soroban_sdk::{Bytes, Env, Vec, U256}; + +const RECORD_SIZE: u32 = 32; + +pub fn update_position_mask(e: &Env, mut mask: Bytes, updates: &Vec) -> Bytes { + let one = U256::from_u32(e, 1); + for (asset_index, price) in updates.iter().enumerate() { + let from = asset_index as u32 * RECORD_SIZE; + let to = from + RECORD_SIZE; + let mut bitmask = if mask.len() >= to { + let encoded = mask.slice(from..to); + U256::from_be_bytes(e, &encoded) + } else { + U256::from_u32(e, 0) + }; + bitmask = bitmask.shl(1); + if price > 0 { + //set bit if price found + bitmask = bitmask.add(&one); + } + let encoded = bitmask.to_be_bytes(); + if mask.len() <= from { + mask.append(&encoded); + } else { + for i in 0..RECORD_SIZE { + mask.set(from + i, encoded.get(i).unwrap()); + } + } + } + mask +} + + +pub fn had_update(mask: &Bytes, asset_index: u32, period: u32) -> bool { + let from = asset_index * RECORD_SIZE + (RECORD_SIZE - 1 - period / 8); + let bit = 1 << (period % 8); + let bytemask = mask.get(from).unwrap_or_default(); + bytemask & bit == bit +} + +#[inline] +fn locate_update_record_mask_position(asset_index: u32) -> (u32, u8) { + let byte = asset_index / 8; + let bitmask = 1 << (asset_index % 8); + (byte, bitmask) +} + +pub 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) = locate_update_record_mask_position(asset_index as u32); + let i = byte as usize; + let bytemask = mask[i] | bitmask; + mask[i] = bytemask + } + } + Bytes::from_array(e, &mask) +} + +pub fn check_update_record_mask(mask: &Bytes, asset_index: u32) -> bool { + let (byte, bitmask) = locate_update_record_mask_position(asset_index); + let bytemask = mask.get(byte).unwrap_or_default(); + bytemask & bitmask == bitmask +} diff --git a/shared/src/price_oracle.rs b/shared/src/price_oracle.rs index e363255..287e69b 100644 --- a/shared/src/price_oracle.rs +++ b/shared/src/price_oracle.rs @@ -421,7 +421,7 @@ impl PriceOracleContractBase { //prepare and publish update event events::publish_update_event(e, &updates, timestamp); //store asset timestamps - prices::set_last_timestamps(e, &updates, timestamp); + prices::set_history_timestamps(e, &updates, timestamp); //store new prices prices::store_prices(e, &updates, timestamp); } diff --git a/shared/src/prices.rs b/shared/src/prices.rs index ff331e5..55d918a 100644 --- a/shared/src/prices.rs +++ b/shared/src/prices.rs @@ -1,11 +1,12 @@ +use crate::pos_encoding::{update_position_mask, had_update}; use crate::{protocol, timestamps}; use crate::types::price_data::PriceData; use crate::settings; -use soroban_sdk::{Env, Vec}; +use soroban_sdk::{Bytes, Env, Vec}; const CACHE_KEY: &str = "cache"; const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; -const ASSET_TIMESTAMPS_KEY: &str = "asset_timestamps"; +const HISTORY_TIMESTAMPS_KEY: &str = "history_timestamps"; // Get last known record timestamp pub fn obtain_last_record_timestamp(e: &Env) -> u64 { @@ -29,6 +30,19 @@ pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option< let price = get_price_v1(e, asset as u8, timestamp)?; return Some(normalize_price_data(price, timestamp)); } + let last_timestamp = get_last_timestamp(e); + //get the timestamp index in the bitmask + if last_timestamp < timestamp { + return None; + } + let mut timestamp_index = 0; + if last_timestamp > timestamp { + timestamp_index = (last_timestamp - timestamp) / settings::get_resolution(e) as u64; + } + if timestamp_index > 255 || !has_price(e, asset, timestamp_index as u32) { + //we cannot track more than 256 updates in the bitmask + return None; + } //load price data for given timestamp let prices = get_prices(e, timestamp)?; if prices.len() <= asset { @@ -62,48 +76,41 @@ pub fn set_last_timestamp(e: &Env, timestamp: u64) { e.storage().instance().set(&LAST_TIMESTAMP_KEY, ×tamp); } -pub fn get_last_timestamps(e: &Env) -> Vec> { - e.storage().instance().get(&ASSET_TIMESTAMPS_KEY).unwrap_or_else(|| Vec::new(e)) +pub fn get_history_timestamps(e: &Env) -> Bytes { + e.storage().instance().get(&HISTORY_TIMESTAMPS_KEY).unwrap_or_else(|| Bytes::new(e)) } -const LIMIT: usize = 145; - -pub fn set_last_timestamps(e: &Env, prices: &Vec, timestamp: u64) { +pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { let last_timestamp = get_last_timestamp(e); - let mut timestamps = get_last_timestamps(e); + let mut timestamps = get_history_timestamps(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; } - //shift existing timestamps - for asset_index in 0..prices.len() { - let mut asset_timestamps = timestamps - .get(asset_index) - .unwrap_or_else(|| Vec::from_array(&e, [false; LIMIT])); - - if update_delta > 1 { - //shift missing intervals - for _ in 1..update_delta { - asset_timestamps.push_front(false); + + //add missing intervals + if update_delta > 1 { + for _ in 1..update_delta { + let mut empty_prices = Vec::new(e); + for _ in 0..prices.len() { + empty_prices.push_back(0i128); } - } - let has_update = prices - .get(asset_index as u32) - .unwrap_or_default() != 0; - asset_timestamps.push_front(has_update); - while asset_timestamps.len() > LIMIT as u32 { - asset_timestamps.pop_back(); - } - //store back - if timestamps.len() == asset_index { - timestamps.push_back(asset_timestamps.clone()); - } else { - timestamps.set(asset_index as u32, asset_timestamps.clone()); + timestamps = update_position_mask(e, timestamps, &empty_prices); } } - e.storage().instance().set(&ASSET_TIMESTAMPS_KEY, ×tamps); + + //update the position mask + timestamps = update_position_mask(e, timestamps, prices); + + //store updated timestamps + e.storage().instance().set(&HISTORY_TIMESTAMPS_KEY, ×tamps); +} + +pub fn has_price(e: &Env, asset_index: u32, periods_ago: u32) -> bool { + let timestamps = get_history_timestamps(e); + had_update(×tamps, asset_index, periods_ago) } // Load prices for a given timestamp diff --git a/shared/src/settings.rs b/shared/src/settings.rs index 539ef4a..7feab4c 100644 --- a/shared/src/settings.rs +++ b/shared/src/settings.rs @@ -92,4 +92,4 @@ pub fn get_retention_config(e: &Env) -> FeeConfig { DEFAULT_RETENTION_FEE, )) }) -} +} \ No newline at end of file diff --git a/shared/src/test.rs b/shared/src/test.rs index 7c45515..23b45da 100644 --- a/shared/src/test.rs +++ b/shared/src/test.rs @@ -2,6 +2,8 @@ extern crate alloc; extern crate std; +use soroban_sdk::{log, Bytes, Env, Vec}; + use super::*; use std::panic::{self, AssertUnwindSafe}; @@ -34,4 +36,66 @@ fn div_tests() { assert_eq!(result.unwrap(), *expected); } } -} \ No newline at end of file +} + + +#[test] +fn pos_encoding_bitmask() { + 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 = pos_encoding::update_position_mask(&e, mask, &updates); + } + 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 = pos_encoding::had_update(&mask, asset_index, check_period); + assert_eq!(found, expected); + } + } +} + +#[test] +fn update_record_bitmask() { + let e = Env::default(); + let iterations = 70; + + let mut updates = Vec::from_array(&e, [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.set(asset_index, price); + } + let mask = pos_encoding::generate_update_record_mask(&e, &updates); + //log!(&e, "entire mask", mask); + for (asset_index, price) in updates.iter().enumerate() { + assert_eq!( + pos_encoding::check_update_record_mask(&mask, asset_index as u32), + price > 0 + ); + } + } +} From 1adf394e103eb930df03b8915ce09d73c582299d Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Fri, 17 Oct 2025 00:51:07 +0300 Subject: [PATCH 12/55] implement new update logic --- reflector-oracle-plus/src/lib.rs | 4 +- reflector-oracle-plus/src/test.rs | 39 +++++++++++++---- reflector-oracle/src/lib.rs | 4 +- reflector-oracle/src/test.rs | 54 ++++++++++++++++-------- shared/src/price_oracle.rs | 16 +++---- shared/src/prices.rs | 63 +++++++++++++++++----------- shared/src/types/mod.rs | 1 + shared/src/types/timestamp_prices.rs | 11 +++++ 8 files changed, 131 insertions(+), 61 deletions(-) create mode 100644 shared/src/types/timestamp_prices.rs diff --git a/reflector-oracle-plus/src/lib.rs b/reflector-oracle-plus/src/lib.rs index 30182fa..8c690a7 100644 --- a/reflector-oracle-plus/src/lib.rs +++ b/reflector-oracle-plus/src/lib.rs @@ -5,7 +5,7 @@ mod settings; mod types; mod charge; -use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData}}; +use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; use crate::types::{config_data::ConfigData, invocation::Invocation}; @@ -396,7 +396,7 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, updates: Vec, timestamp: u64) { + pub fn set_price(e: &Env, updates: TimestampPrices, timestamp: u64) { PriceOracleContractBase::set_price(e, updates, timestamp); } diff --git a/reflector-oracle-plus/src/test.rs b/reflector-oracle-plus/src/test.rs index ef4ef67..d9223c1 100644 --- a/reflector-oracle-plus/src/test.rs +++ b/reflector-oracle-plus/src/test.rs @@ -3,11 +3,13 @@ extern crate alloc; extern crate std; +use shared::pos_encoding::generate_update_record_mask; use shared::prices; +use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{log, symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; +use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; use alloc::string::ToString; @@ -84,12 +86,16 @@ fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { assets } -fn get_updates(env: &Env, assets: &Vec, price: i128) -> Vec { +fn get_updates(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { updates.push_back(price); } - updates + let mask = generate_update_record_mask(env, &updates); + TimestampPrices { + prices: updates, + mask: mask, + } } fn get_random_bool() -> bool { @@ -101,7 +107,7 @@ fn get_random_bool() -> bool { random_bool } -fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec { +fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { let price = if get_random_bool() { @@ -111,7 +117,11 @@ fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec 255 { break; } + let all_prices = prices::get_prices_for_assets(&env, &updates, assets.len() + 10 as u32); //+10 to check that out of range assets are ignored for (asset_index, asset) in assets.iter().enumerate() { let price_data = client.price(&caller, &asset, &(timestamp / 1000)); - let expected_price = updates.get(asset_index as u32).unwrap_or_default(); + let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); if expected_price > 0 { let price = price_data.unwrap(); assert_eq!(price.price, expected_price); assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + had_prices = true; } else { assert!(price_data.is_none()); + had_gaps = true; } } } + assert!(had_prices); + assert!(had_gaps); } \ No newline at end of file diff --git a/reflector-oracle/src/lib.rs b/reflector-oracle/src/lib.rs index 6f40bb9..daf561a 100644 --- a/reflector-oracle/src/lib.rs +++ b/reflector-oracle/src/lib.rs @@ -5,7 +5,7 @@ mod types; use crate::types::config_data::ConfigData; -use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData}}; +use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; #[contract] @@ -351,7 +351,7 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, updates: Vec, timestamp: u64) { + pub fn set_price(e: &Env, updates: TimestampPrices, timestamp: u64) { PriceOracleContractBase::set_price(e, updates, timestamp); } diff --git a/reflector-oracle/src/test.rs b/reflector-oracle/src/test.rs index 31a40d3..434dafa 100644 --- a/reflector-oracle/src/test.rs +++ b/reflector-oracle/src/test.rs @@ -3,7 +3,9 @@ extern crate alloc; extern crate std; +use shared::pos_encoding::generate_update_record_mask; use shared::prices; +use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; @@ -21,6 +23,18 @@ fn convert_to_seconds(timestamp: u64) -> u64 { timestamp / 1000 } +fn get_updates(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + TimestampPrices { + prices: updates, + mask: mask, + } +} + fn get_random_bool() -> bool { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -30,7 +44,7 @@ fn get_random_bool() -> bool { random_bool } -fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec { +fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { let price = if get_random_bool() { @@ -40,7 +54,11 @@ fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> Vec() -> (Env, PriceOracleContractClient<'a>, ConfigData) { @@ -103,14 +121,6 @@ fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { 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(); @@ -311,7 +321,12 @@ fn prices_update_overflow_test() { for i in 1..=256 { updates.push_back(normalize_price(i as i128 + 1)); } - client.set_price(&updates, &600_000); + let mask = generate_update_record_mask(&env, &updates); + let update = TimestampPrices { + prices: updates, + mask: mask, + }; + client.set_price(&update, &600_000); } #[test] @@ -439,10 +454,8 @@ fn set_retention_config_test() { assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee } - - #[test] -fn set_price_prices() { +fn prices_test() { let (env, client, init_data) = init_contract_with_admin(); let assets = init_data.assets; @@ -467,26 +480,33 @@ fn set_price_prices() { } let ledger_info = env.ledger().get(); env.ledger().set(LedgerInfo { - timestamp: convert_to_seconds(timestamp) + 300, + timestamp: timestamp / 1000 + 300, ..ledger_info }); } + let mut had_gaps = false; + let mut had_prices = false; //verify prices for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { if history_index > 255 { break; } + let all_prices = prices::get_prices_for_assets(&env, &updates, assets.len() + 10 as u32); //+10 to check that out of range assets are ignored for (asset_index, asset) in assets.iter().enumerate() { - let price_data = client.price(&asset, &convert_to_seconds(timestamp)); - let expected_price = updates.get(asset_index as u32).unwrap_or_default(); + let price_data = client.price(&asset, &(timestamp / 1000)); + let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); if expected_price > 0 { let price = price_data.unwrap(); assert_eq!(price.price, expected_price); assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + had_prices = true; } else { assert!(price_data.is_none()); + had_gaps = true; } } } + assert!(had_prices); + assert!(had_gaps); } \ No newline at end of file diff --git a/shared/src/price_oracle.rs b/shared/src/price_oracle.rs index 287e69b..0b9a716 100644 --- a/shared/src/price_oracle.rs +++ b/shared/src/price_oracle.rs @@ -1,5 +1,5 @@ use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; -use crate::{assets, auth, events, prices, protocol, settings, timestamps, types::{asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData}}; +use crate::{assets, auth, events, prices, protocol, settings, timestamps, types::{asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; pub struct PriceOracleContractBase; @@ -408,9 +408,9 @@ impl PriceOracleContractBase { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, updates: Vec, timestamp: u64) { + pub fn set_price(e: &Env, update: TimestampPrices, timestamp: u64) { auth::panic_if_not_admin(e); - if updates.len() == 0 { + if update.prices.len() == 0 { return; //skip empty updates } //validate record timestamp @@ -418,12 +418,14 @@ impl PriceOracleContractBase { if timestamp == 0 || !timestamps::is_valid(e, timestamp) || timestamp > ledger_timestamp { panic_with_error!(&e, Error::InvalidTimestamp); } + //create vector of all assets prices + let asset_prices = prices::get_prices_for_assets(e, &update, assets::load_all_assets(e).len()); + //store history timestamps for all assets + prices::set_history_timestamps(e, &asset_prices, timestamp); //prepare and publish update event - events::publish_update_event(e, &updates, timestamp); - //store asset timestamps - prices::set_history_timestamps(e, &updates, timestamp); + events::publish_update_event(e, &asset_prices, timestamp); //store new prices - prices::store_prices(e, &updates, timestamp); + prices::store_prices(e, &update, timestamp, &asset_prices); } // Update contract source code diff --git a/shared/src/prices.rs b/shared/src/prices.rs index 55d918a..197bad0 100644 --- a/shared/src/prices.rs +++ b/shared/src/prices.rs @@ -1,6 +1,6 @@ -use crate::pos_encoding::{update_position_mask, had_update}; +use crate::pos_encoding; +use crate::types::{timestamp_prices::TimestampPrices, price_data::PriceData}; use crate::{protocol, timestamps}; -use crate::types::price_data::PriceData; use crate::settings; use soroban_sdk::{Bytes, Env, Vec}; @@ -8,6 +8,13 @@ const CACHE_KEY: &str = "cache"; const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; const HISTORY_TIMESTAMPS_KEY: &str = "history_timestamps"; +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); @@ -43,23 +50,29 @@ pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option< //we cannot track more than 256 updates in the bitmask return None; } - //load price data for given timestamp - let prices = get_prices(e, timestamp)?; - if prices.len() <= asset { - return None; - } - let price = prices.get(asset)?; - if price == 0 { - return None; - } + //load the prices for the timestamp + let timestamp_prices = timestamp_prices(e, timestamp)?; + //get price for the asset index + let price = get_prices_for_assets(e, ×tamp_prices, asset + 1) + .last()?; // as we requested asset+1, the last one is the requested asset Some(normalize_price_data(price, timestamp)) } -fn normalize_price_data(price: i128, timestamp: u64) -> PriceData { - PriceData { - price, - timestamp: timestamp / 1000, //convert to seconds +// Extract prices for all assets from the update record by the assets length +pub fn get_prices_for_assets(e: &Env, timestamp_prices: &TimestampPrices, assets_length: u32) -> Vec { + //normalize prices for internal processing + let mut normalized_vector_prices = Vec::new(&e); + let mut last_price_index = 0; + for asset_index in 0..assets_length { + let mut price = 0; + if pos_encoding::check_update_record_mask(×tamp_prices.mask, asset_index) { + //set price from the update record + price = timestamp_prices.prices.get_unchecked(last_price_index); + last_price_index += 1; + } + normalized_vector_prices.push_back(price); } + normalized_vector_prices } // Load last update timestamp @@ -97,12 +110,12 @@ pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { for _ in 0..prices.len() { empty_prices.push_back(0i128); } - timestamps = update_position_mask(e, timestamps, &empty_prices); + timestamps = pos_encoding::update_position_mask(e, timestamps, &empty_prices); } } //update the position mask - timestamps = update_position_mask(e, timestamps, prices); + timestamps = pos_encoding::update_position_mask(e, timestamps, prices); //store updated timestamps e.storage().instance().set(&HISTORY_TIMESTAMPS_KEY, ×tamps); @@ -110,11 +123,11 @@ pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { pub fn has_price(e: &Env, asset_index: u32, periods_ago: u32) -> bool { let timestamps = get_history_timestamps(e); - had_update(×tamps, asset_index, periods_ago) + pos_encoding::had_update(×tamps, asset_index, periods_ago) } // Load prices for a given timestamp -pub fn get_prices(e: &Env, timestamp: u64) -> Option> { +pub fn timestamp_prices(e: &Env, timestamp: u64) -> Option { //check if the timestamp is in the cache let cache = load_price_records_cache(e); if cache.is_some() { @@ -130,22 +143,23 @@ pub fn get_prices(e: &Env, timestamp: u64) -> Option> { } // Update prices stored in the oracle -pub fn store_prices(e: &Env, prices: &Vec, timestamp: u64) { +pub fn store_prices(e: &Env, update: &TimestampPrices, timestamp: u64, update_v1: &Vec) { //get the last timestamp let last_timestamp = get_last_timestamp(e); //update the last timestamp if timestamp > last_timestamp { set_last_timestamp(e, timestamp); } + //set the price let temps_storage = e.storage().temporary(); - temps_storage.set(×tamp, prices); + 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, prices.clone())); + cache.push_front((timestamp, update.clone())); while cache.len() > cache_size { cache.pop_back(); //remove the oldest record if cache size exceeded } @@ -160,9 +174,10 @@ pub fn store_prices(e: &Env, prices: &Vec, timestamp: u64) { //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, prices, timestamp, ledgers_to_live); + store_price_v1(e, update_v1, timestamp, ledgers_to_live); } } @@ -254,7 +269,7 @@ pub fn load_cross_price( } // Get cached records from the instance storage -fn load_price_records_cache(e: &Env) -> Option)>> { +fn load_price_records_cache(e: &Env) -> Option> { e.storage().instance().get(&CACHE_KEY) } diff --git a/shared/src/types/mod.rs b/shared/src/types/mod.rs index f754e4c..cf5a32b 100644 --- a/shared/src/types/mod.rs +++ b/shared/src/types/mod.rs @@ -3,3 +3,4 @@ pub mod asset_type; pub mod error; pub mod price_data; pub mod fee_config; +pub mod timestamp_prices; \ No newline at end of file diff --git a/shared/src/types/timestamp_prices.rs b/shared/src/types/timestamp_prices.rs new file mode 100644 index 0000000..bb80492 --- /dev/null +++ b/shared/src/types/timestamp_prices.rs @@ -0,0 +1,11 @@ +use soroban_sdk::{contracttype, Bytes, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +// Asset price data at specific timestamp +pub struct TimestampPrices { + // Prices for assets that have been updated + pub prices: Vec, + // Bitmap of assets that have been updated + pub mask: Bytes +} \ No newline at end of file From e3ba3769d2c0aba296937c1d1e1b02e972306614 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Fri, 17 Oct 2025 15:55:24 +0300 Subject: [PATCH 13/55] rename contracts; update sdk; update README; remove unused code --- .gitignore | 4 +- Cargo.lock | 83 ++-- Cargo.toml | 4 +- README.md | 399 ++++++++++++++---- .../Cargo.toml | 2 +- .../src/charge.rs | 0 .../src/lib.rs | 7 + .../src/settings.rs | 0 .../src/test.rs | 17 +- .../src/types/config_data.rs | 0 .../src/types/invocation.rs | 0 .../src/types/mod.rs | 0 .../Cargo.toml | 2 +- .../src/lib.rs | 0 .../src/test.rs | 17 +- .../src/types/config_data.rs | 0 .../src/types/mod.rs | 0 shared/src/pos_encoding.rs | 15 +- shared/src/price_oracle.rs | 3 + shared/src/test.rs | 15 +- shared/src/types/error.rs | 2 + 21 files changed, 423 insertions(+), 147 deletions(-) rename {reflector-oracle-plus => beam-contract}/Cargo.toml (89%) rename {reflector-oracle-plus => beam-contract}/src/charge.rs (100%) rename {reflector-oracle-plus => beam-contract}/src/lib.rs (97%) rename {reflector-oracle-plus => beam-contract}/src/settings.rs (100%) rename {reflector-oracle-plus => beam-contract}/src/test.rs (96%) rename {reflector-oracle-plus => beam-contract}/src/types/config_data.rs (100%) rename {reflector-oracle-plus => beam-contract}/src/types/invocation.rs (100%) rename {reflector-oracle-plus => beam-contract}/src/types/mod.rs (100%) rename {reflector-oracle => pulse-contract}/Cargo.toml (90%) rename {reflector-oracle => pulse-contract}/src/lib.rs (100%) rename {reflector-oracle => pulse-contract}/src/test.rs (96%) rename {reflector-oracle => pulse-contract}/src/types/config_data.rs (100%) rename {reflector-oracle => pulse-contract}/src/types/mod.rs (100%) diff --git a/.gitignore b/.gitignore index a0122ba..aed2521 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ /.soroban /target /test_snapshots -/reflector-oracle/test_snapshots -/reflector-oracle-plus/test_snapshots +/beam-contract/test_snapshots +/pulse-contract/test_snapshots /shared/test_snapshots diff --git a/Cargo.lock b/Cargo.lock index e36f08d..73ee5f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "beam-contract" +version = "6.0.0" +dependencies = [ + "shared", + "soroban-sdk", + "test-case", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -299,14 +308,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" dependencies = [ - "quote", - "syn 2.0.101", + "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.3" @@ -470,6 +485,21 @@ 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" @@ -922,6 +952,14 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulse-contract" +version = "6.0.0" +dependencies = [ + "shared", + "soroban-sdk", +] + [[package]] name = "quote" version = "1.0.40" @@ -981,23 +1019,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "reflector-oracle" -version = "6.0.0" -dependencies = [ - "shared", - "soroban-sdk", -] - -[[package]] -name = "reflector-oracle-plus" -version = "6.0.0" -dependencies = [ - "shared", - "soroban-sdk", - "test-case", -] - [[package]] name = "rfc6979" version = "0.4.0" @@ -1290,9 +1311,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "23.0.2" +version = "23.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3823372b72cab2e7ff2ced62bbffa11fce8da0713a224f122141558cab174647" +checksum = "cdefc9240bddd3ff4d47fd4d8f8dd44784840e25a18e426c6c987db8572d6df9" dependencies = [ "serde", "serde_json", @@ -1304,9 +1325,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "23.0.2" +version = "23.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af0e5bf6702f5952d78c5b2bcd05b0349f9a570cc62028d90dac3710b40cbe65" +checksum = "7cb0dc3eb3661962cb8833513953b5839df14d589d96f8370b5b0c3870a8b3b5" dependencies = [ "arbitrary", "bytes-lit", @@ -1327,9 +1348,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "23.0.2" +version = "23.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38abe20199c5d9fbff232381aa4e8e83302b34e82e38fbb090f41f1284fc920" +checksum = "7eab5f4e5f3836a4b4aeecb2837160e944621b2f8dbad775638a2ab8e10fd5bb" dependencies = [ "darling 0.20.11", "heck", @@ -1347,9 +1368,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "23.0.2" +version = "23.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72526d30f8825b859afa7e0b94549dad05c58a6c928b0763620412744512d7e2" +checksum = "fd257b0365307e0b8d38040ee0364abcc610fc6e61960ff5e26803922d098921" dependencies = [ "base64", "stellar-xdr", @@ -1359,9 +1380,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "23.0.2" +version = "23.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9088cb8307dad026cda494971c4f13c76f9427ab26becb7cd691da95dc5e9b1d" +checksum = "1ec3c72de91fdcf637045f3351df029a98b9de9ad22ced4063f74d0b5873f526" dependencies = [ "prettyplease", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f537de7..99f46e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["shared", "reflector-oracle", "reflector-oracle-plus"] +members = ["shared", "pulse-contract", "beam-contract"] [profile.release-with-logs] inherits = "release" @@ -18,4 +18,4 @@ codegen-units = 1 lto = true [workspace.dependencies.soroban-sdk] -version = "23.0.2" +version = "23.0.3" diff --git a/README.md b/README.md index 2d5d4c3..5e6c5db 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,314 @@ -# Reflector oracle smart contract +# Reflector oracle smart contracts This contract implementation is fully compatible with [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. +### Pulse contract + +The Pulse contract provides free access to the latest asset prices and time-weighted average prices (TWAP) for on-chain applications. + +### Beam contract + +The Beam contract is designed for faster updates of the prices, and charges a fee for each price query. + ## Usage example -### Forced position liquidation +### **Pulse contract** + +### Invocation from consumer contract + +#### Utilize this example to invoke oracles 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::{ReflectorClient, 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 = ReflectorClient::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 +### Interface for Pulse contract + +#### Copy and save it in your smart contract project as "reflector_pulse.rs" file. This is the oracle client.. ```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.rs */ +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +// Oracle contract interface exported as ReflectorClient +#[soroban_sdk::contractclient(name = "ReflectorClient")] +pub trait Contract { + // Base oracle symbol the price is reported in + fn base(e: Env) -> Asset; + // All assets quoted by the contract + fn assets(e: Env) -> Vec; + // Number of decimal places used to represent price for all assets quoted by the oracle + fn decimals(e: Env) -> u32; + // Quotes asset price in base asset at specific timestamp + fn price(e: Env, asset: Asset, timestamp: u64) -> Option; + // Quotes the most recent price for an asset + fn lastprice(e: Env, asset: Asset) -> Option; + // Quotes last N price records for the given asset + fn prices(e: Env, asset: Asset, records: u32) -> Option>; + // Quotes the most recent cross price record for the pair of assets + fn x_last_price(e: Env, base_asset: Asset, quote_asset: Asset) -> Option; + // Quotes the cross price for the pair of assets at specific timestamp + fn x_price(e: Env, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; + // Quotes last N cross price records of for the pair of assets + fn x_prices(e: Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; + // Quotes the time-weighted average price for the given asset over N recent records + fn twap(e: Env, asset: Asset, records: u32) -> Option; + // Quotes the time-weighted average cross price for the given asset pair over N recent records + fn x_twap(e: Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option; + // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) + fn resolution(e: Env) -> u32; + // Historical records retention period, in seconds (24 hours by default) + fn history_retention_period(e: Env) -> Option; + // The most recent price update timestamp + fn last_timestamp(e: Env) -> u64; + // Contract version + fn version(e: Env) -> u32; + // Contract admin address + fn admin(e: Env) -> Option
; + // Extend asset TTL (time-to-live) in the contract storage + fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); + // Get asset expiration timestamp + fn expires(e: &Env, asset: Asset) -> Option; + // Get retention FeeConfig configuration + fn retention_config(e: &Env) -> FeeConfig; +} + +// 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 +} +``` - // calculate average value per position - let average_position_value = total_value / (total_positions as i128); +### **Pulse contract** - // 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 +### Invocation from consumer contract + +#### Utilize this example to invoke oracles from your contract code. + +```rust +/* contract.rs */ +use crate::reflector::{ReflectorClient, 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 = ReflectorClient::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(&env.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 } } ``` -### Algorithmic stablecoin price correction +### Interface for Beam contract + +#### Copy and save it in your smart contract project as "reflector_beam.rs" file. This is the oracle client.. ```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 - } +/* reflector.rs */ +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; + +// Oracle contract interface exported as ReflectorClient +#[soroban_sdk::contractclient(name = "ReflectorClient")] +pub trait Contract { + // Base oracle symbol the price is reported in + fn base(e: Env) -> Asset; + // All assets quoted by the contract + fn assets(e: Env) -> Vec; + // Number of decimal places used to represent price for all assets quoted by the oracle + fn decimals(e: Env) -> u32; + // Quotes asset price in base asset at specific timestamp + fn price(e: Env, caller: Address, asset: Asset, timestamp: u64) -> Option; + // Quotes the most recent price for an asset + fn lastprice(e: Env, caller: Address, asset: Asset) -> Option; + // Quotes last N price records for the given asset + fn prices(e: Env, caller: Address, asset: Asset, records: u32) -> Option>; + // Quotes the most recent cross price record for the pair of assets + fn x_last_price(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset) -> Option; + // Quotes the cross price for the pair of assets at specific timestamp + fn x_price(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; + // Quotes last N cross price records of for the pair of assets + fn x_prices(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; + // Quotes the time-weighted average price for the given asset over N recent records + fn twap(e: Env, caller: Address, asset: Asset, records: u32) -> Option; + // Quotes the time-weighted average cross price for the given asset pair over N recent records + fn x_twap(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option; + // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) + fn resolution(e: Env) -> u32; + // Historical records retention period, in seconds (24 hours by default) + fn history_retention_period(e: Env) -> Option; + // The most recent price update timestamp + fn last_timestamp(e: Env) -> u64; + // Contract version + fn version(e: Env) -> u32; + // Contract admin address + fn admin(e: Env) -> Option
; + // Extend asset TTL (time-to-live) in the contract storage + fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); + // Get asset expiration timestamp + fn expires(e: &Env, asset: Asset) -> Option; + // Get retention FeeConfig configuration + fn retention_config(e: &Env) -> FeeConfig; + // Get invocation FeeConfig configuration + fn invocation_config(e: &Env) -> FeeConfig; +} + +// 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 } ``` +## Testing Contracts + +### Prerequisites + +- Ensure you have Rust installed and set up ([official installation guide](https://www.rust-lang.org/tools/install)) + +### Running All Tests + +1. Navigate to the root directory of the project: + + ```bash + cd ./reflector-contract + ``` +2. Run the tests: + + ```bash + cargo test + ``` + +### Running Specific Contract Tests + +1. Navigate to the directory of the contract: + + ```bash + cd ./reflector-contract + ``` + +2. Run the tests: + + ```bash + cargo test --package pulse-contract + ``` + ## Building Contracts ### Prerequisites @@ -117,15 +316,37 @@ pub fn maintain_stable_coin_peg(env: Env, reflector_contract_id: Address, curren - 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 Price Oracle +### Building All Contracts 1. Navigate to the directory of the contract: ```bash - cd ./price-oracle + cd ./reflector-contract ``` - + 2. Run the build command: ```bash stellar contract build - ``` \ No newline at end of file + ``` + +### Building Specific Contract + +1. Navigate to the directory of the contract: + + ```bash + cd ./reflector-contract + ``` +2. Run the build command for the specific contract: + ```bash + stellar contract build --package pulse-contract + ``` + +### Optimizing WASM + +1. Run stellar optimize command: + ```bash + stellar contract optimize --wasm ./target/wasm32v1-none/release/pulse_contract.wasm + ``` +This will generate an optimized WASM file at `./target/wasm32v1-none/release/pulse_contract.optimized.wasm`. + +**Note**: Make sure to replace `pulse_contract.wasm` with the actual name of the contract you are optimizing. Also, replace the path if your build output directory is different. \ No newline at end of file diff --git a/reflector-oracle-plus/Cargo.toml b/beam-contract/Cargo.toml similarity index 89% rename from reflector-oracle-plus/Cargo.toml rename to beam-contract/Cargo.toml index a193006..ea39d0f 100644 --- a/reflector-oracle-plus/Cargo.toml +++ b/beam-contract/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "reflector-oracle-plus" +name = "beam-contract" version = "6.0.0" edition = "2021" diff --git a/reflector-oracle-plus/src/charge.rs b/beam-contract/src/charge.rs similarity index 100% rename from reflector-oracle-plus/src/charge.rs rename to beam-contract/src/charge.rs diff --git a/reflector-oracle-plus/src/lib.rs b/beam-contract/src/lib.rs similarity index 97% rename from reflector-oracle-plus/src/lib.rs rename to beam-contract/src/lib.rs index 8c690a7..4df90d5 100644 --- a/reflector-oracle-plus/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -168,6 +168,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `asset` - Asset to quote // // # Returns @@ -183,6 +184,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `asset` - Asset to quote // * `records` - Number of records to return // @@ -199,6 +201,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `base_asset` - Base asset // * `quote_asset` - Quote asset // @@ -215,6 +218,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `timestamp` - Timestamp @@ -238,6 +242,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `records` - Number of records to fetch @@ -261,6 +266,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `asset` - Asset to quote // * `records` - Number of records to process // @@ -277,6 +283,7 @@ impl PriceOracleContract { // // # Arguments // + // * `caller` - Address of the caller // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `records` - Number of records to process diff --git a/reflector-oracle-plus/src/settings.rs b/beam-contract/src/settings.rs similarity index 100% rename from reflector-oracle-plus/src/settings.rs rename to beam-contract/src/settings.rs diff --git a/reflector-oracle-plus/src/test.rs b/beam-contract/src/test.rs similarity index 96% rename from reflector-oracle-plus/src/test.rs rename to beam-contract/src/test.rs index d9223c1..dcb321a 100644 --- a/reflector-oracle-plus/src/test.rs +++ b/beam-contract/src/test.rs @@ -2,14 +2,12 @@ extern crate alloc; extern crate std; - -use shared::pos_encoding::generate_update_record_mask; use shared::prices; use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; +use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; use alloc::string::ToString; @@ -25,6 +23,19 @@ fn convert_to_seconds(timestamp: u64) -> u64 { timestamp / 1000 } +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) = shared::pos_encoding::locate_update_record_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 init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, ConfigData) { let env = Env::default(); diff --git a/reflector-oracle-plus/src/types/config_data.rs b/beam-contract/src/types/config_data.rs similarity index 100% rename from reflector-oracle-plus/src/types/config_data.rs rename to beam-contract/src/types/config_data.rs diff --git a/reflector-oracle-plus/src/types/invocation.rs b/beam-contract/src/types/invocation.rs similarity index 100% rename from reflector-oracle-plus/src/types/invocation.rs rename to beam-contract/src/types/invocation.rs diff --git a/reflector-oracle-plus/src/types/mod.rs b/beam-contract/src/types/mod.rs similarity index 100% rename from reflector-oracle-plus/src/types/mod.rs rename to beam-contract/src/types/mod.rs diff --git a/reflector-oracle/Cargo.toml b/pulse-contract/Cargo.toml similarity index 90% rename from reflector-oracle/Cargo.toml rename to pulse-contract/Cargo.toml index 24af259..07314fc 100644 --- a/reflector-oracle/Cargo.toml +++ b/pulse-contract/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "reflector-oracle" +name = "pulse-contract" version = "6.0.0" edition = "2021" diff --git a/reflector-oracle/src/lib.rs b/pulse-contract/src/lib.rs similarity index 100% rename from reflector-oracle/src/lib.rs rename to pulse-contract/src/lib.rs diff --git a/reflector-oracle/src/test.rs b/pulse-contract/src/test.rs similarity index 96% rename from reflector-oracle/src/test.rs rename to pulse-contract/src/test.rs index 434dafa..72e37ab 100644 --- a/reflector-oracle/src/test.rs +++ b/pulse-contract/src/test.rs @@ -2,14 +2,12 @@ extern crate alloc; extern crate std; - -use shared::pos_encoding::generate_update_record_mask; use shared::prices; use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; +use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; use alloc::string::ToString; @@ -23,6 +21,19 @@ fn convert_to_seconds(timestamp: u64) -> u64 { timestamp / 1000 } +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) = shared::pos_encoding::locate_update_record_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 get_updates(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { diff --git a/reflector-oracle/src/types/config_data.rs b/pulse-contract/src/types/config_data.rs similarity index 100% rename from reflector-oracle/src/types/config_data.rs rename to pulse-contract/src/types/config_data.rs diff --git a/reflector-oracle/src/types/mod.rs b/pulse-contract/src/types/mod.rs similarity index 100% rename from reflector-oracle/src/types/mod.rs rename to pulse-contract/src/types/mod.rs diff --git a/shared/src/pos_encoding.rs b/shared/src/pos_encoding.rs index 8c9dec8..5c9fd7c 100644 --- a/shared/src/pos_encoding.rs +++ b/shared/src/pos_encoding.rs @@ -39,25 +39,12 @@ pub fn had_update(mask: &Bytes, asset_index: u32, period: u32) -> bool { } #[inline] -fn locate_update_record_mask_position(asset_index: u32) -> (u32, u8) { +pub fn locate_update_record_mask_position(asset_index: u32) -> (u32, u8) { let byte = asset_index / 8; let bitmask = 1 << (asset_index % 8); (byte, bitmask) } -pub 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) = locate_update_record_mask_position(asset_index as u32); - let i = byte as usize; - let bytemask = mask[i] | bitmask; - mask[i] = bytemask - } - } - Bytes::from_array(e, &mask) -} - pub fn check_update_record_mask(mask: &Bytes, asset_index: u32) -> bool { let (byte, bitmask) = locate_update_record_mask_position(asset_index); let bytemask = mask.get(byte).unwrap_or_default(); diff --git a/shared/src/price_oracle.rs b/shared/src/price_oracle.rs index 0b9a716..36b4ca1 100644 --- a/shared/src/price_oracle.rs +++ b/shared/src/price_oracle.rs @@ -413,6 +413,9 @@ impl PriceOracleContractBase { if update.prices.len() == 0 { return; //skip empty updates } + if update.prices.len() > assets::load_all_assets(e).len() { + panic_with_error!(&e, Error::InvalidPricesUpdate); + } //validate record timestamp let ledger_timestamp = timestamps::ledger_timestamp(&e); if timestamp == 0 || !timestamps::is_valid(e, timestamp) || timestamp > ledger_timestamp { diff --git a/shared/src/test.rs b/shared/src/test.rs index 23b45da..e809033 100644 --- a/shared/src/test.rs +++ b/shared/src/test.rs @@ -7,6 +7,19 @@ use soroban_sdk::{log, Bytes, Env, Vec}; use super::*; use std::panic::{self, AssertUnwindSafe}; +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) = pos_encoding::locate_update_record_mask_position(asset_index as u32); + let i = byte as usize; + let bytemask = mask[i] | bitmask; + mask[i] = bytemask + } + } + Bytes::from_array(e, &mask) +} + #[test] fn div_tests() { let test_cases = [ @@ -89,7 +102,7 @@ fn update_record_bitmask() { }; updates.set(asset_index, price); } - let mask = pos_encoding::generate_update_record_mask(&e, &updates); + let mask = generate_update_record_mask(&e, &updates); //log!(&e, "entire mask", mask); for (asset_index, price) in updates.iter().enumerate() { assert_eq!( diff --git a/shared/src/types/error.rs b/shared/src/types/error.rs index 79080f7..a21cab7 100644 --- a/shared/src/types/error.rs +++ b/shared/src/types/error.rs @@ -20,4 +20,6 @@ pub enum Error { AssetLimitExceeded = 6, // Amount is invalid (negative or zero). InvalidAmount = 7, + // Prices update is invalid + InvalidPricesUpdate = 8, } From 38cb9bcd94695bda36abad9babb5eff8ef98cf06 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Fri, 17 Oct 2025 15:57:43 +0300 Subject: [PATCH 14/55] fix README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e6c5db..9d6a1cc 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ pub enum Error { } ``` -### **Pulse contract** +### **Beam contract** ### Invocation from consumer contract From 6c58ee5e7edfb9337a7d4361694865e3745d8e4d Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Fri, 17 Oct 2025 17:36:17 +0300 Subject: [PATCH 15/55] implement new asset adding/expiration logic --- beam-contract/src/lib.rs | 9 +++++---- beam-contract/src/test.rs | 6 ++++-- pulse-contract/src/lib.rs | 11 +++++++---- pulse-contract/src/test.rs | 4 ++-- shared/src/assets.rs | 28 ++++++++++++++++------------ shared/src/price_oracle.rs | 20 ++++++++++++-------- 6 files changed, 46 insertions(+), 32 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 4df90d5..fac6d0e 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -117,7 +117,7 @@ impl PriceOracleContract { // // 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) { - PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount); + PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, 0); } // Return the fee token address daily price feed retainer fee amount @@ -317,7 +317,8 @@ impl PriceOracleContract { config.history_retention_period, config.cache_size, &config.retention_config, - config.assets + config.assets, + 0 ); settings::set_invocation_config(e, &config.invocation_config); } @@ -347,7 +348,7 @@ impl PriceOracleContract { // // 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); + PriceOracleContractBase::add_assets(e, assets, 0); } // Sets history retention period for the prices @@ -375,7 +376,7 @@ impl PriceOracleContract { // // Panics if not authorized or not initialized yet pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { - PriceOracleContractBase::set_retention_config(e, retention_config); + PriceOracleContractBase::set_retention_config(e, retention_config, 0); } // Set fee token address and invocation fee amount diff --git a/beam-contract/src/test.rs b/beam-contract/src/test.rs index dcb321a..1624225 100644 --- a/beam-contract/src/test.rs +++ b/beam-contract/src/test.rs @@ -461,11 +461,13 @@ fn set_retention_config_test() { fee_token.mint(&sponsor, &10); let symbol_expires = client.expires(&asset).unwrap(); + assert_eq!(symbol_expires, 0); client.extend_asset_ttl(&sponsor, &asset, &10); - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 9 XRF tokens + let ledger_ts = env.ledger().timestamp() * 1000; + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + ledger_ts + 123428571); //initial ttl is 0, so ledger + 123428571 (ms you get for 10 XRF tokens) is expected let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); - assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee + assert_eq!(fee_token_balance, 0); } #[test] diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index daf561a..beb6b67 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -8,6 +8,8 @@ use crate::types::config_data::ConfigData; use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; +const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months + #[contract] pub struct PriceOracleContract; @@ -115,7 +117,7 @@ impl PriceOracleContract { // // 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) { - PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount); + PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, INITIAL_EXPIRATION_PERIOD); } // Return the fee token address daily price feed retainer fee amount @@ -280,7 +282,8 @@ impl PriceOracleContract { config.history_retention_period, config.cache_size, &config.retention_config, - config.assets + config.assets, + INITIAL_EXPIRATION_PERIOD ); } @@ -309,7 +312,7 @@ impl PriceOracleContract { // // 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); + PriceOracleContractBase::add_assets(e, assets, INITIAL_EXPIRATION_PERIOD); } // Sets history retention period for the prices @@ -337,7 +340,7 @@ impl PriceOracleContract { // // Panics if not authorized or not initialized yet pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { - PriceOracleContractBase::set_retention_config(e, retention_config); + PriceOracleContractBase::set_retention_config(e, retention_config, INITIAL_EXPIRATION_PERIOD); } // Record new price feed history snapshot diff --git a/pulse-contract/src/test.rs b/pulse-contract/src/test.rs index 72e37ab..f1576ad 100644 --- a/pulse-contract/src/test.rs +++ b/pulse-contract/src/test.rs @@ -459,10 +459,10 @@ fn set_retention_config_test() { let symbol_expires = client.expires(&asset).unwrap(); client.extend_asset_ttl(&sponsor, &asset, &10); - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 9 XRF tokens + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 10 XRF tokens let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); - assert_eq!(fee_token_balance, 0); //1 XRF token is left after paying the fee + assert_eq!(fee_token_balance, 0); } #[test] diff --git a/shared/src/assets.rs b/shared/src/assets.rs index 2cec7de..4a93333 100644 --- a/shared/src/assets.rs +++ b/shared/src/assets.rs @@ -2,13 +2,21 @@ use crate::types::{asset::Asset, error::Error, fee_config::FeeConfig}; use crate::{settings, timestamps}; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env, Vec}; -const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months const ASSET_LIMIT: u32 = 1000; //current limit //storage keys const ASSETS_KEY: &str = "assets"; const EXPIRATION_KEY: &str = "expiration"; +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() @@ -43,11 +51,9 @@ pub fn resolve_asset_pair_indexes( } // Add assets to the oracle -pub fn add_assets(e: &Env, assets: Vec) { +pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { //use default expiration period for new assets - let expiration_timestamp = timestamps::ledger_timestamp(&e) - .checked_add(timestamps::days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) - .unwrap(); + 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); @@ -61,7 +67,7 @@ pub fn add_assets(e: &Env, assets: Vec) { set_asset_index(e, &asset, asset_list.len()); asset_list.push_back(asset); //if the fee is not set, we don't need to set the expiration - if is_retention_config_set { + if is_retention_config_set && expiration_timestamp > 0 { expiration.push_back(expiration_timestamp); //set expiration } } @@ -84,15 +90,13 @@ pub fn expires(e: &Env, asset: Asset) -> Option { } // Initialize expiration records for all existing assets -pub fn init_expiration_config(e: &Env) { +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 = timestamps::ledger_timestamp(&e) - .checked_add(timestamps::days_to_milliseconds(INITIAL_EXPIRATION_PERIOD)) - .unwrap(); + 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() { @@ -102,7 +106,7 @@ pub fn init_expiration_config(e: &Env) { } // Extend time-to-live for given asset price feed -pub fn extend_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) { +pub fn extend_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128, initial_expiration_period: u32) { //check if the amount is valid if amount <= 0 { e.panic_with_error(Error::InvalidAmount); @@ -137,7 +141,7 @@ pub fn extend_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128) { 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)); + .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; diff --git a/shared/src/price_oracle.rs b/shared/src/price_oracle.rs index 36b4ca1..e08bd6f 100644 --- a/shared/src/price_oracle.rs +++ b/shared/src/price_oracle.rs @@ -110,14 +110,15 @@ impl PriceOracleContractBase { // * `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) // // # 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) { + pub fn extend_asset_ttl(e: &Env, sponsor: Address, asset: Asset, amount: i128, initial_expiration_period: u32) { //check sponsor authorization sponsor.require_auth(); - assets::extend_ttl(e, sponsor, asset, amount); + assets::extend_ttl(e, sponsor, asset, amount, initial_expiration_period); } // Return the fee token address daily price feed retainer fee amount @@ -321,11 +322,12 @@ impl PriceOracleContractBase { // * `cache_size` - Number of rounds held in instance cache // * `retention_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, admin: &Address, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_config: &FeeConfig, assets: Vec) { + pub fn config(e: &Env, admin: &Address, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_config: &FeeConfig, assets: Vec, initial_expiration_period: u32) { //should be invoked by admin admin.require_auth(); //apply settings @@ -333,7 +335,7 @@ impl PriceOracleContractBase { auth::set_admin(e, admin); protocol::set_protocol_version(e, protocol::CURRENT_PROTOCOL); //add initial assets - assets::add_assets(&e, assets); + assets::add_assets(&e, assets, initial_expiration_period); } // Update contract cache size @@ -357,13 +359,14 @@ impl PriceOracleContractBase { // # 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) { + pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { auth::panic_if_not_admin(e); - assets::add_assets(&e, assets); + assets::add_assets(&e, assets, initial_expiration_period); } // Sets history retention period for the prices @@ -387,14 +390,15 @@ impl PriceOracleContractBase { // # 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_retention_config(e: &Env, retention_config: FeeConfig) { + pub fn set_retention_config(e: &Env, retention_config: FeeConfig, initial_expiration_period: u32) { auth::panic_if_not_admin(e); settings::set_retention_config(e, &retention_config); - assets::init_expiration_config(e); + assets::init_expiration_config(e, initial_expiration_period); } // Record new price feed history snapshot From 6e89312cfed46be72bf6e0a876a214677851c9ce Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Fri, 17 Oct 2025 17:42:38 +0300 Subject: [PATCH 16/55] fix formating --- beam-contract/src/charge.rs | 17 ++------ beam-contract/src/lib.rs | 53 ++++++++++++++++--------- beam-contract/src/settings.rs | 6 +-- beam-contract/src/test.rs | 32 ++++++++------- beam-contract/src/types/config_data.rs | 4 +- beam-contract/src/types/invocation.rs | 2 +- beam-contract/src/types/mod.rs | 2 +- pulse-contract/src/lib.rs | 48 ++++++++++++++-------- pulse-contract/src/test.rs | 20 +++++----- pulse-contract/src/types/config_data.rs | 6 +-- pulse-contract/src/types/mod.rs | 2 +- shared/src/assets.rs | 8 +++- shared/src/events.rs | 5 ++- shared/src/lib.rs | 10 ++--- shared/src/pos_encoding.rs | 1 - shared/src/price_oracle.rs | 50 +++++++++++++++++++---- shared/src/prices.rs | 24 +++++++---- shared/src/protocol.rs | 4 +- shared/src/settings.rs | 16 +++++--- shared/src/test.rs | 6 +-- shared/src/timestamps.rs | 4 +- shared/src/types/fee_config.rs | 4 +- shared/src/types/mod.rs | 4 +- shared/src/types/timestamp_prices.rs | 4 +- 24 files changed, 208 insertions(+), 124 deletions(-) diff --git a/beam-contract/src/charge.rs b/beam-contract/src/charge.rs index a9d5d40..6becc23 100644 --- a/beam-contract/src/charge.rs +++ b/beam-contract/src/charge.rs @@ -12,11 +12,7 @@ fn mul_scaled(value: u64, koef: u64) -> u64 { value * koef / SCALE } -pub fn calc_fee( - base_fee: u64, - invocation: Invocation, - rounds: u32, -) -> u64 { +pub fn calc_fee(base_fee: u64, invocation: Invocation, rounds: u32) -> u64 { let mut koef = 1_000_000; match invocation { Invocation::Price => {} @@ -37,12 +33,7 @@ pub fn calc_fee( fee } -pub fn charge_fee( - e: &Env, - caller: &Address, - invocation: Invocation, - rounds: u32, -) { +pub fn charge_fee(e: &Env, caller: &Address, invocation: Invocation, rounds: u32) { let fee_config = settings::get_invocation_config(e); match fee_config { FeeConfig::None => return, @@ -50,6 +41,6 @@ pub fn charge_fee( let fee = calc_fee(base_fee as u64, invocation, rounds) as i128; let token = soroban_sdk::token::Client::new(e, &fee_token); token.transfer(caller, &e.current_contract_address(), &fee); - }, + } } -} \ No newline at end of file +} diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index fac6d0e..400fe1b 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -1,21 +1,26 @@ #![no_std] -mod test; +mod charge; mod settings; +mod test; mod types; -mod charge; -use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; +use shared::{ + price_oracle::PriceOracleContractBase, + types::{ + asset::Asset, fee_config::FeeConfig, price_data::PriceData, + timestamp_prices::TimestampPrices, + }, +}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; use crate::types::{config_data::ConfigData, invocation::Invocation}; #[contract] -pub struct PriceOracleContract; +pub struct PriceOracleContract; #[contractimpl] impl PriceOracleContract { - // Return base asset price is reported in // // # Returns @@ -146,7 +151,7 @@ impl PriceOracleContract { pub fn admin(e: &Env) -> Option
{ PriceOracleContractBase::admin(e) } - + // Returns price for an asset at specific timestamp // // # Arguments @@ -208,7 +213,12 @@ impl PriceOracleContract { // # Returns // // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found - pub fn x_last_price(e: &Env, caller: Address, base_asset: Asset, quote_asset: Asset) -> Option { + pub fn x_last_price( + e: &Env, + caller: Address, + base_asset: Asset, + quote_asset: Asset, + ) -> Option { caller.require_auth(); charge::charge_fee(e, &caller, Invocation::CrossPrice, 1); PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) @@ -291,7 +301,13 @@ impl PriceOracleContract { // # Returns // // TWAP (base_asset_price/quote_asset_price) or None if assets are not supported - pub fn x_twap(e: &Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option { + pub fn x_twap( + e: &Env, + caller: Address, + base_asset: Asset, + quote_asset: Asset, + records: u32, + ) -> Option { caller.require_auth(); charge::charge_fee(e, &caller, Invocation::CrossTwap, records); PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records) @@ -309,16 +325,17 @@ impl PriceOracleContract { // // Panics if not authorized or if contract is already initialized pub fn config(e: &Env, config: ConfigData) { - PriceOracleContractBase::config(e, - &config.admin, - &config.base_asset, - config.decimals, - config.resolution, - config.history_retention_period, - config.cache_size, - &config.retention_config, + PriceOracleContractBase::config( + e, + &config.admin, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.retention_config, config.assets, - 0 + 0, ); settings::set_invocation_config(e, &config.invocation_config); } @@ -421,4 +438,4 @@ impl PriceOracleContract { pub fn update_contract(e: &Env, wasm_hash: BytesN<32>) { PriceOracleContractBase::update_contract(e, wasm_hash); } -} \ No newline at end of file +} diff --git a/beam-contract/src/settings.rs b/beam-contract/src/settings.rs index abee3a5..bc78c6f 100644 --- a/beam-contract/src/settings.rs +++ b/beam-contract/src/settings.rs @@ -6,9 +6,7 @@ const DEFAULT_INVOCATION_FEE: i128 = 100_000_000; #[inline] pub fn set_invocation_config(e: &Env, inv_config: &FeeConfig) { - e.storage() - .instance() - .set(&INVOCATION_KEY, &inv_config); + e.storage().instance().set(&INVOCATION_KEY, &inv_config); } #[inline] @@ -22,4 +20,4 @@ pub fn get_invocation_config(e: &Env) -> FeeConfig { DEFAULT_INVOCATION_FEE, )) }) -} \ No newline at end of file +} diff --git a/beam-contract/src/test.rs b/beam-contract/src/test.rs index 1624225..756fe5a 100644 --- a/beam-contract/src/test.rs +++ b/beam-contract/src/test.rs @@ -2,6 +2,7 @@ extern crate alloc; extern crate std; +use alloc::string::ToString; use shared::prices; use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; @@ -9,10 +10,9 @@ use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, use soroban_sdk::token::{StellarAssetClient, TokenClient}; use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; -use alloc::string::ToString; -use crate::types::{config_data::ConfigData, invocation::Invocation}; use crate::charge; +use crate::types::{config_data::ConfigData, invocation::Invocation}; use crate::{PriceOracleContract, PriceOracleContractClient}; use test_case::test_case; @@ -27,7 +27,8 @@ 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) = shared::pos_encoding::locate_update_record_mask_position(asset_index as u32); + let (byte, bitmask) = + shared::pos_encoding::locate_update_record_mask_position(asset_index as u32); let i = byte as usize; let bytemask = mask[i] | bitmask; mask[i] = bytemask @@ -67,7 +68,7 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config resolution: RESOLUTION, cache_size: 0, retention_config: FeeConfig::None, - invocation_config: FeeConfig::None + invocation_config: FeeConfig::None, }; env.mock_all_auths(); @@ -121,11 +122,7 @@ fn get_random_bool() -> bool { fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { - let price = if get_random_bool() { - 0 - } else { - price - }; + let price = if get_random_bool() { 0 } else { price }; updates.push_back(price); } let mask = generate_update_record_mask(env, &updates); @@ -464,7 +461,10 @@ fn set_retention_config_test() { assert_eq!(symbol_expires, 0); client.extend_asset_ttl(&sponsor, &asset, &10); let ledger_ts = env.ledger().timestamp() * 1000; - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + ledger_ts + 123428571); //initial ttl is 0, so ledger + 123428571 (ms you get for 10 XRF tokens) is expected + assert_eq!( + client.expires(&asset).unwrap(), + symbol_expires + ledger_ts + 123428571 + ); //initial ttl is 0, so ledger + 123428571 (ms you get for 10 XRF tokens) is expected let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); assert_eq!(fee_token_balance, 0); @@ -497,8 +497,10 @@ fn price_test() { //set prices for assets client.set_price(&updates, ×tamp); - - let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()).address(); + + let fee_asset = env + .register_stellar_asset_contract_v2(init_data.admin.clone()) + .address(); let invocation_config = FeeConfig::Some((fee_asset.clone(), 1_000_000)); client.set_invocation_config(&invocation_config); @@ -507,7 +509,9 @@ fn price_test() { let fee_token = StellarAssetClient::new(&env, &fee_asset); fee_token.mint(&caller, &1_000_000); //get price for the first asset - let price = client.lastprice(&caller, &init_data.assets.first_unchecked()).unwrap(); + let price = client + .lastprice(&caller, &init_data.assets.first_unchecked()) + .unwrap(); assert_eq!(price.price, normalize_price(100)); assert_eq!(price.timestamp, convert_to_seconds(timestamp)); @@ -585,4 +589,4 @@ fn prices_test() { } assert!(had_prices); assert!(had_gaps); -} \ No newline at end of file +} diff --git a/beam-contract/src/types/config_data.rs b/beam-contract/src/types/config_data.rs index 1432ad9..ee2e80b 100644 --- a/beam-contract/src/types/config_data.rs +++ b/beam-contract/src/types/config_data.rs @@ -21,5 +21,5 @@ pub struct ConfigData { // Contract retention config pub retention_config: FeeConfig, /// Invocation fee - pub invocation_config: FeeConfig -} \ No newline at end of file + pub invocation_config: FeeConfig, +} diff --git a/beam-contract/src/types/invocation.rs b/beam-contract/src/types/invocation.rs index 8b09d79..e3179e9 100644 --- a/beam-contract/src/types/invocation.rs +++ b/beam-contract/src/types/invocation.rs @@ -3,4 +3,4 @@ pub enum Invocation { CrossPrice = 1, Twap = 2, CrossTwap = 3, -} \ No newline at end of file +} diff --git a/beam-contract/src/types/mod.rs b/beam-contract/src/types/mod.rs index 22603ad..bf1599e 100644 --- a/beam-contract/src/types/mod.rs +++ b/beam-contract/src/types/mod.rs @@ -1,2 +1,2 @@ pub mod config_data; -pub mod invocation; \ No newline at end of file +pub mod invocation; diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index beb6b67..59d9d12 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -5,17 +5,22 @@ mod types; use crate::types::config_data::ConfigData; -use shared::{price_oracle::PriceOracleContractBase, types::{asset::Asset, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; +use shared::{ + price_oracle::PriceOracleContractBase, + types::{ + asset::Asset, fee_config::FeeConfig, price_data::PriceData, + timestamp_prices::TimestampPrices, + }, +}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; const INITIAL_EXPIRATION_PERIOD: u32 = 180; //6 months #[contract] -pub struct PriceOracleContract; +pub struct PriceOracleContract; #[contractimpl] impl PriceOracleContract { - // Return base asset price is reported in // // # Returns @@ -117,7 +122,13 @@ impl PriceOracleContract { // // 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) { - PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, INITIAL_EXPIRATION_PERIOD); + PriceOracleContractBase::extend_asset_ttl( + e, + sponsor, + asset, + amount, + INITIAL_EXPIRATION_PERIOD, + ); } // Return the fee token address daily price feed retainer fee amount @@ -137,7 +148,7 @@ impl PriceOracleContract { pub fn admin(e: &Env) -> Option
{ PriceOracleContractBase::admin(e) } - + // Returns price for an asset at specific timestamp // // # Arguments @@ -274,16 +285,17 @@ impl PriceOracleContract { // // Panics if not authorized or if contract is already initialized pub fn config(e: &Env, config: ConfigData) { - PriceOracleContractBase::config(e, - &config.admin, - &config.base_asset, - config.decimals, - config.resolution, - config.history_retention_period, - config.cache_size, - &config.retention_config, + PriceOracleContractBase::config( + e, + &config.admin, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.retention_config, config.assets, - INITIAL_EXPIRATION_PERIOD + INITIAL_EXPIRATION_PERIOD, ); } @@ -340,7 +352,11 @@ impl PriceOracleContract { // // Panics if not authorized or not initialized yet pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { - PriceOracleContractBase::set_retention_config(e, retention_config, INITIAL_EXPIRATION_PERIOD); + PriceOracleContractBase::set_retention_config( + e, + retention_config, + INITIAL_EXPIRATION_PERIOD, + ); } // Record new price feed history snapshot @@ -371,4 +387,4 @@ impl PriceOracleContract { pub fn update_contract(e: &Env, wasm_hash: BytesN<32>) { PriceOracleContractBase::update_contract(e, wasm_hash); } -} \ No newline at end of file +} diff --git a/pulse-contract/src/test.rs b/pulse-contract/src/test.rs index f1576ad..fe80916 100644 --- a/pulse-contract/src/test.rs +++ b/pulse-contract/src/test.rs @@ -2,6 +2,7 @@ extern crate alloc; extern crate std; +use alloc::string::ToString; use shared::prices; use shared::types::timestamp_prices::TimestampPrices; use shared::types::{asset::Asset, fee_config::FeeConfig}; @@ -9,7 +10,6 @@ use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, use soroban_sdk::token::{StellarAssetClient, TokenClient}; use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; use std::panic::{self, AssertUnwindSafe}; -use alloc::string::ToString; use crate::types::config_data::ConfigData; use crate::{PriceOracleContract, PriceOracleContractClient}; @@ -25,7 +25,8 @@ 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) = shared::pos_encoding::locate_update_record_mask_position(asset_index as u32); + let (byte, bitmask) = + shared::pos_encoding::locate_update_record_mask_position(asset_index as u32); let i = byte as usize; let bytemask = mask[i] | bitmask; mask[i] = bytemask @@ -58,11 +59,7 @@ fn get_random_bool() -> bool { fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { let mut updates = Vec::new(&env); for _ in assets.iter() { - let price = if get_random_bool() { - 0 - } else { - price - }; + let price = if get_random_bool() { 0 } else { price }; updates.push_back(price); } let mask = generate_update_record_mask(env, &updates); @@ -102,7 +99,7 @@ fn init_contract_with_admin<'a>() -> (Env, PriceOracleContractClient<'a>, Config decimals: 14, resolution: RESOLUTION, cache_size: 0, - retention_config: FeeConfig::None + retention_config: FeeConfig::None, }; env.mock_all_auths(); @@ -159,7 +156,10 @@ fn init_test() { 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)); + assert_eq!( + period, + convert_to_seconds(init_data.history_retention_period) + ); let decimals = client.decimals(); assert_eq!(decimals, DECIMALS); @@ -520,4 +520,4 @@ fn prices_test() { } assert!(had_prices); assert!(had_gaps); -} \ No newline at end of file +} diff --git a/pulse-contract/src/types/config_data.rs b/pulse-contract/src/types/config_data.rs index eec3963..97673a6 100644 --- a/pulse-contract/src/types/config_data.rs +++ b/pulse-contract/src/types/config_data.rs @@ -1,5 +1,5 @@ -use soroban_sdk::{contracttype, Address, Vec}; use shared::types::{asset::Asset, fee_config::FeeConfig}; +use soroban_sdk::{contracttype, Address, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -21,5 +21,5 @@ pub struct ConfigData { // Number of rounds held in instance cache pub cache_size: u32, // Contract retention config - pub retention_config: FeeConfig -} \ No newline at end of file + pub retention_config: FeeConfig, +} diff --git a/pulse-contract/src/types/mod.rs b/pulse-contract/src/types/mod.rs index 480fbbe..8ca55cc 100644 --- a/pulse-contract/src/types/mod.rs +++ b/pulse-contract/src/types/mod.rs @@ -1 +1 @@ -pub mod config_data; \ No newline at end of file +pub mod config_data; diff --git a/shared/src/assets.rs b/shared/src/assets.rs index 4a93333..bac9631 100644 --- a/shared/src/assets.rs +++ b/shared/src/assets.rs @@ -106,7 +106,13 @@ pub fn init_expiration_config(e: &Env, initial_expiration_period: u32) { } // 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) { +pub fn extend_ttl( + e: &Env, + sponsor: Address, + asset: Asset, + amount: i128, + initial_expiration_period: u32, +) { //check if the amount is valid if amount <= 0 { e.panic_with_error(Error::InvalidAmount); diff --git a/shared/src/events.rs b/shared/src/events.rs index 24bc3e0..16156ac 100644 --- a/shared/src/events.rs +++ b/shared/src/events.rs @@ -1,4 +1,7 @@ -use crate::{assets, types::{asset::Asset, error::Error}}; +use crate::{ + assets, + types::{asset::Asset, error::Error}, +}; use soroban_sdk::{contractevent, panic_with_error, Env, Val, Vec}; #[contractevent(topics = ["REFLECTOR", "update"])] diff --git a/shared/src/lib.rs b/shared/src/lib.rs index c8723d9..498494a 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -3,12 +3,12 @@ pub mod assets; pub mod auth; pub mod events; -pub mod settings; -pub mod timestamps; -pub mod types; +pub mod pos_encoding; pub mod price_oracle; pub mod prices; pub mod protocol; -pub mod pos_encoding; +pub mod settings; +pub mod timestamps; +pub mod types; -pub mod test; \ No newline at end of file +pub mod test; diff --git a/shared/src/pos_encoding.rs b/shared/src/pos_encoding.rs index 5c9fd7c..84627a4 100644 --- a/shared/src/pos_encoding.rs +++ b/shared/src/pos_encoding.rs @@ -30,7 +30,6 @@ pub fn update_position_mask(e: &Env, mut mask: Bytes, updates: &Vec) -> By mask } - pub fn had_update(mask: &Bytes, asset_index: u32, period: u32) -> bool { let from = asset_index * RECORD_SIZE + (RECORD_SIZE - 1 - period / 8); let bit = 1 << (period % 8); diff --git a/shared/src/price_oracle.rs b/shared/src/price_oracle.rs index e08bd6f..1b1de71 100644 --- a/shared/src/price_oracle.rs +++ b/shared/src/price_oracle.rs @@ -1,5 +1,11 @@ +use crate::{ + assets, auth, events, prices, protocol, settings, timestamps, + types::{ + asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData, + timestamp_prices::TimestampPrices, + }, +}; use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; -use crate::{assets, auth, events, prices, protocol, settings, timestamps, types::{asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData, timestamp_prices::TimestampPrices}}; pub struct PriceOracleContractBase; @@ -115,7 +121,13 @@ impl PriceOracleContractBase { // # 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) { + pub fn extend_asset_ttl( + e: &Env, + sponsor: Address, + asset: Asset, + amount: i128, + initial_expiration_period: u32, + ) { //check sponsor authorization sponsor.require_auth(); assets::extend_ttl(e, sponsor, asset, amount, initial_expiration_period); @@ -138,7 +150,7 @@ impl PriceOracleContractBase { pub fn admin(e: &Env) -> Option
{ auth::get_admin(e) } - + // Returns price for an asset at specific timestamp // // # Arguments @@ -327,11 +339,30 @@ impl PriceOracleContractBase { // # Panics // // Panics if not authorized or if contract is already initialized - pub fn config(e: &Env, admin: &Address, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_config: &FeeConfig, assets: Vec, initial_expiration_period: u32) { + pub fn config( + e: &Env, + admin: &Address, + base: &Asset, + decimals: u32, + resolution: u32, + history_retention_period: u64, + cache_size: u32, + retention_config: &FeeConfig, + assets: Vec, + initial_expiration_period: u32, + ) { //should be invoked by admin admin.require_auth(); //apply settings - settings::init(e, base, decimals, resolution, history_retention_period, cache_size, &retention_config); + settings::init( + e, + base, + decimals, + resolution, + history_retention_period, + cache_size, + &retention_config, + ); auth::set_admin(e, admin); protocol::set_protocol_version(e, protocol::CURRENT_PROTOCOL); //add initial assets @@ -395,7 +426,11 @@ impl PriceOracleContractBase { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_retention_config(e: &Env, retention_config: FeeConfig, initial_expiration_period: u32) { + pub fn set_retention_config( + e: &Env, + retention_config: FeeConfig, + initial_expiration_period: u32, + ) { auth::panic_if_not_admin(e); settings::set_retention_config(e, &retention_config); assets::init_expiration_config(e, initial_expiration_period); @@ -426,7 +461,8 @@ impl PriceOracleContractBase { panic_with_error!(&e, Error::InvalidTimestamp); } //create vector of all assets prices - let asset_prices = prices::get_prices_for_assets(e, &update, assets::load_all_assets(e).len()); + let asset_prices = + prices::get_prices_for_assets(e, &update, assets::load_all_assets(e).len()); //store history timestamps for all assets prices::set_history_timestamps(e, &asset_prices, timestamp); //prepare and publish update event diff --git a/shared/src/prices.rs b/shared/src/prices.rs index 197bad0..9c651c8 100644 --- a/shared/src/prices.rs +++ b/shared/src/prices.rs @@ -1,7 +1,7 @@ use crate::pos_encoding; -use crate::types::{timestamp_prices::TimestampPrices, price_data::PriceData}; -use crate::{protocol, timestamps}; use crate::settings; +use crate::types::{price_data::PriceData, timestamp_prices::TimestampPrices}; +use crate::{protocol, timestamps}; use soroban_sdk::{Bytes, Env, Vec}; const CACHE_KEY: &str = "cache"; @@ -53,13 +53,16 @@ pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option< //load the prices for the timestamp let timestamp_prices = timestamp_prices(e, timestamp)?; //get price for the asset index - let price = get_prices_for_assets(e, ×tamp_prices, asset + 1) - .last()?; // as we requested asset+1, the last one is the requested asset + let price = get_prices_for_assets(e, ×tamp_prices, asset + 1).last()?; // as we requested asset+1, the last one is the requested asset Some(normalize_price_data(price, timestamp)) } // Extract prices for all assets from the update record by the assets length -pub fn get_prices_for_assets(e: &Env, timestamp_prices: &TimestampPrices, assets_length: u32) -> Vec { +pub fn get_prices_for_assets( + e: &Env, + timestamp_prices: &TimestampPrices, + assets_length: u32, +) -> Vec { //normalize prices for internal processing let mut normalized_vector_prices = Vec::new(&e); let mut last_price_index = 0; @@ -90,7 +93,10 @@ pub fn set_last_timestamp(e: &Env, timestamp: u64) { } pub fn get_history_timestamps(e: &Env) -> Bytes { - e.storage().instance().get(&HISTORY_TIMESTAMPS_KEY).unwrap_or_else(|| Bytes::new(e)) + e.storage() + .instance() + .get(&HISTORY_TIMESTAMPS_KEY) + .unwrap_or_else(|| Bytes::new(e)) } pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { @@ -118,7 +124,9 @@ pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { timestamps = pos_encoding::update_position_mask(e, timestamps, prices); //store updated timestamps - e.storage().instance().set(&HISTORY_TIMESTAMPS_KEY, ×tamps); + e.storage() + .instance() + .set(&HISTORY_TIMESTAMPS_KEY, ×tamps); } pub fn has_price(e: &Env, asset_index: u32, periods_ago: u32) -> bool { @@ -325,4 +333,4 @@ pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> i128 { vdivisor /= 10_i128.pow(bshift); } vdividend / vdivisor -} \ No newline at end of file +} diff --git a/shared/src/protocol.rs b/shared/src/protocol.rs index f5fcc09..f94dbde 100644 --- a/shared/src/protocol.rs +++ b/shared/src/protocol.rs @@ -1,8 +1,8 @@ -use crate::{timestamps}; +use crate::timestamps; use soroban_sdk::Env; //current protocol version -pub const CURRENT_PROTOCOL: u32 = 2; +pub const CURRENT_PROTOCOL: u32 = 2; //storage keys const UPDATE_TS_KEY: &str = "protocol_update"; diff --git a/shared/src/settings.rs b/shared/src/settings.rs index 7feab4c..0089893 100644 --- a/shared/src/settings.rs +++ b/shared/src/settings.rs @@ -1,6 +1,4 @@ -use crate::types::{ - asset::Asset, error::Error, fee_config::FeeConfig, -}; +use crate::types::{asset::Asset, error::Error, fee_config::FeeConfig}; use soroban_sdk::{Address, Env}; const RETENTION_PERIOD_KEY: &str = "period"; @@ -14,7 +12,15 @@ pub const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LU const DEFAULT_RETENTION_FEE: i128 = 100_000_000; #[inline] -pub fn init(e: &Env, base: &Asset, decimals: u32, resolution: u32, history_retention_period: u64, cache_size: u32, retention_config: &FeeConfig) { +pub fn init( + e: &Env, + base: &Asset, + decimals: u32, + resolution: u32, + history_retention_period: u64, + cache_size: u32, + retention_config: &FeeConfig, +) { //do not allow to initialize more than once if e.storage().instance().has(&RETENTION_PERIOD_KEY) { e.panic_with_error(Error::AlreadyInitialized); @@ -92,4 +98,4 @@ pub fn get_retention_config(e: &Env) -> FeeConfig { DEFAULT_RETENTION_FEE, )) }) -} \ No newline at end of file +} diff --git a/shared/src/test.rs b/shared/src/test.rs index e809033..5eb4147 100644 --- a/shared/src/test.rs +++ b/shared/src/test.rs @@ -11,7 +11,8 @@ 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) = pos_encoding::locate_update_record_mask_position(asset_index as u32); + let (byte, bitmask) = + pos_encoding::locate_update_record_mask_position(asset_index as u32); let i = byte as usize; let bytemask = mask[i] | bitmask; mask[i] = bytemask @@ -51,7 +52,6 @@ fn div_tests() { } } - #[test] fn pos_encoding_bitmask() { let e = Env::default(); @@ -93,7 +93,7 @@ fn update_record_bitmask() { let e = Env::default(); let iterations = 70; - let mut updates = Vec::from_array(&e, [0i128;254]); + let mut updates = Vec::from_array(&e, [0i128; 254]); for i in 0..iterations { for asset_index in 0..updates.len() { let price = match i & asset_index == 0 { diff --git a/shared/src/timestamps.rs b/shared/src/timestamps.rs index 24e70ee..69ae81a 100644 --- a/shared/src/timestamps.rs +++ b/shared/src/timestamps.rs @@ -1,7 +1,7 @@ -use soroban_sdk::Env; use crate::settings; +use soroban_sdk::Env; -// Normalize timestamp trimming it to the timeframe resolution defined in settings +// 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 { diff --git a/shared/src/types/fee_config.rs b/shared/src/types/fee_config.rs index b5d2186..0aba7dd 100644 --- a/shared/src/types/fee_config.rs +++ b/shared/src/types/fee_config.rs @@ -5,5 +5,5 @@ use soroban_sdk::{contracttype, Address}; // Oracle retention config containing fee asset and daily retention fee amount pub enum FeeConfig { Some((Address, i128)), - None -} \ No newline at end of file + None, +} diff --git a/shared/src/types/mod.rs b/shared/src/types/mod.rs index cf5a32b..3663f8c 100644 --- a/shared/src/types/mod.rs +++ b/shared/src/types/mod.rs @@ -1,6 +1,6 @@ pub mod asset; pub mod asset_type; pub mod error; -pub mod price_data; pub mod fee_config; -pub mod timestamp_prices; \ No newline at end of file +pub mod price_data; +pub mod timestamp_prices; diff --git a/shared/src/types/timestamp_prices.rs b/shared/src/types/timestamp_prices.rs index bb80492..04a15ec 100644 --- a/shared/src/types/timestamp_prices.rs +++ b/shared/src/types/timestamp_prices.rs @@ -7,5 +7,5 @@ pub struct TimestampPrices { // Prices for assets that have been updated pub prices: Vec, // Bitmap of assets that have been updated - pub mask: Bytes -} \ No newline at end of file + pub mask: Bytes, +} From 4a15eb3c47e263d1d3d59600d05e091af53357b3 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 15:29:20 +0000 Subject: [PATCH 17/55] Refactor packages, rename contracts Unify contract config settings Deduplicate tests, split by category Introduce costs config by invocation category Add extract_single_update_record_price fn to optimize retrieval Move all core oracle types declarations into a single file --- .github/workflows/release.yml | 2 +- .gitignore | 7 +- Cargo.lock | 49 +- Cargo.toml | 2 +- README.md | 10 +- beam-contract/Cargo.toml | 4 +- beam-contract/src/charge.rs | 46 -- beam-contract/src/cost.rs | 83 +++ beam-contract/src/lib.rs | 97 ++- beam-contract/src/settings.rs | 23 - beam-contract/src/test.rs | 592 ------------------ beam-contract/src/tests.rs | 119 ++++ beam-contract/src/types/config_data.rs | 25 - beam-contract/src/types/invocation.rs | 6 - beam-contract/src/types/mod.rs | 2 - {shared => oracle}/Cargo.toml | 5 +- {shared => oracle}/src/assets.rs | 8 +- {shared => oracle}/src/auth.rs | 4 +- {shared => oracle}/src/events.rs | 13 +- {shared => oracle}/src/lib.rs | 5 +- oracle/src/mapping.rs | 73 +++ {shared => oracle}/src/price_oracle.rs | 71 +-- {shared => oracle}/src/prices.rs | 100 +-- {shared => oracle}/src/protocol.rs | 0 {shared => oracle}/src/settings.rs | 15 +- oracle/src/tests/contract_admin_tests.rs | 286 +++++++++ oracle/src/tests/contract_interface_tests.rs | 133 ++++ oracle/src/tests/mod.rs | 5 + oracle/src/tests/setup_tests.rs | 126 ++++ .../test.rs => oracle/src/tests/util_tests.rs | 19 +- {shared => oracle}/src/timestamps.rs | 0 oracle/src/types.rs | 83 +++ pulse-contract/Cargo.toml | 4 +- pulse-contract/src/lib.rs | 46 +- pulse-contract/src/test.rs | 523 ---------------- pulse-contract/src/types/config_data.rs | 25 - pulse-contract/src/types/mod.rs | 1 - shared/src/pos_encoding.rs | 51 -- shared/src/types/asset.rs | 9 - shared/src/types/asset_type.rs | 8 - shared/src/types/error.rs | 25 - shared/src/types/fee_config.rs | 9 - shared/src/types/mod.rs | 6 - shared/src/types/price_data.rs | 11 - shared/src/types/timestamp_prices.rs | 11 - 45 files changed, 1108 insertions(+), 1634 deletions(-) delete mode 100644 beam-contract/src/charge.rs create mode 100644 beam-contract/src/cost.rs delete mode 100644 beam-contract/src/settings.rs delete mode 100644 beam-contract/src/test.rs create mode 100644 beam-contract/src/tests.rs delete mode 100644 beam-contract/src/types/config_data.rs delete mode 100644 beam-contract/src/types/invocation.rs delete mode 100644 beam-contract/src/types/mod.rs rename {shared => oracle}/Cargo.toml (60%) rename {shared => oracle}/src/assets.rs (95%) rename {shared => oracle}/src/auth.rs (87%) rename {shared => oracle}/src/events.rs (77%) rename {shared => oracle}/src/lib.rs (77%) create mode 100644 oracle/src/mapping.rs rename {shared => oracle}/src/price_oracle.rs (89%) rename {shared => oracle}/src/prices.rs (78%) rename {shared => oracle}/src/protocol.rs (100%) rename {shared => oracle}/src/settings.rs (86%) create mode 100644 oracle/src/tests/contract_admin_tests.rs create mode 100644 oracle/src/tests/contract_interface_tests.rs create mode 100644 oracle/src/tests/mod.rs create mode 100644 oracle/src/tests/setup_tests.rs rename shared/src/test.rs => oracle/src/tests/util_tests.rs (84%) rename {shared => oracle}/src/timestamps.rs (100%) create mode 100644 oracle/src/types.rs delete mode 100644 pulse-contract/src/test.rs delete mode 100644 pulse-contract/src/types/config_data.rs delete mode 100644 pulse-contract/src/types/mod.rs delete mode 100644 shared/src/pos_encoding.rs delete mode 100644 shared/src/types/asset.rs delete mode 100644 shared/src/types/asset_type.rs delete mode 100644 shared/src/types/error.rs delete mode 100644 shared/src/types/fee_config.rs delete mode 100644 shared/src/types/mod.rs delete mode 100644 shared/src/types/price_data.rs delete mode 100644 shared/src/types/timestamp_prices.rs 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 aed2521..6ad9459 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ /.idea /.soroban -/target -/test_snapshots -/beam-contract/test_snapshots -/pulse-contract/test_snapshots -/shared/test_snapshots +target +test_snapshots diff --git a/Cargo.lock b/Cargo.lock index 73ee5f9..8a65a19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,15 +180,6 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" -[[package]] -name = "beam-contract" -version = "6.0.0" -dependencies = [ - "shared", - "soroban-sdk", - "test-case", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -884,6 +875,14 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "oracle" +version = "6.0.0" +dependencies = [ + "soroban-sdk", + "test-case", +] + [[package]] name = "p256" version = "0.13.2" @@ -952,14 +951,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulse-contract" -version = "6.0.0" -dependencies = [ - "shared", - "soroban-sdk", -] - [[package]] name = "quote" version = "1.0.40" @@ -1019,6 +1010,23 @@ dependencies = [ "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", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -1194,13 +1202,6 @@ dependencies = [ "keccak", ] -[[package]] -name = "shared" -version = "6.0.0" -dependencies = [ - "soroban-sdk", -] - [[package]] name = "signature" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 99f46e8..62fbda1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["shared", "pulse-contract", "beam-contract"] +members = ["oracle", "pulse-contract", "beam-contract"] [profile.release-with-logs] inherits = "release" diff --git a/README.md b/README.md index 9d6a1cc..0a09b06 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ pub trait Contract { // Get asset expiration timestamp fn expires(e: &Env, asset: Asset) -> Option; // Get retention FeeConfig configuration - fn retention_config(e: &Env) -> FeeConfig; + fn fee_config(e: &Env) -> FeeConfig; } // Quoted asset definition @@ -238,10 +238,10 @@ pub trait Contract { fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); // Get asset expiration timestamp fn expires(e: &Env, asset: Asset) -> Option; - // Get retention FeeConfig configuration - fn retention_config(e: &Env) -> FeeConfig; - // Get invocation FeeConfig configuration - fn invocation_config(e: &Env) -> FeeConfig; + // Get fee configuration + fn fee_config(e: &Env) -> FeeConfig; + // Get config with invocation costs + fn invocation_costs(e: &Env) -> Vec; } // Quoted asset definition diff --git a/beam-contract/Cargo.toml b/beam-contract/Cargo.toml index ea39d0f..bdaece4 100644 --- a/beam-contract/Cargo.toml +++ b/beam-contract/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "beam-contract" +name = "reflector-beam-contract" version = "6.0.0" edition = "2021" @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -shared = { path = "../shared" } +oracle = { path = "../oracle" } soroban-sdk = { workspace = true } [dev-dependencies] diff --git a/beam-contract/src/charge.rs b/beam-contract/src/charge.rs deleted file mode 100644 index 6becc23..0000000 --- a/beam-contract/src/charge.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::{settings, types::invocation::Invocation}; -use shared::types::fee_config::FeeConfig; -use soroban_sdk::{Address, Env}; - -const SCALE: u64 = 1_000_000; -const CROSS_PRICE_KOEF: u64 = 2_000_000; -const TWAP_KOEF: u64 = 1_500_000; -const CROSS_TWAP_KOEF: u64 = 3_000_000; -const ROUND_FEE_KOEF: u64 = 1_100_000; - -fn mul_scaled(value: u64, koef: u64) -> u64 { - value * koef / SCALE -} - -pub fn calc_fee(base_fee: u64, invocation: Invocation, rounds: u32) -> u64 { - let mut koef = 1_000_000; - match invocation { - Invocation::Price => {} - Invocation::Twap => { - koef = TWAP_KOEF; - } - Invocation::CrossPrice => { - koef = CROSS_PRICE_KOEF; - } - Invocation::CrossTwap => { - koef = CROSS_TWAP_KOEF; - } - } - let mut fee = mul_scaled(base_fee, koef); - if rounds > 1 { - fee = mul_scaled(fee, ROUND_FEE_KOEF); - } - fee -} - -pub fn charge_fee(e: &Env, caller: &Address, invocation: Invocation, rounds: u32) { - let fee_config = settings::get_invocation_config(e); - match fee_config { - FeeConfig::None => return, - FeeConfig::Some((fee_token, base_fee)) => { - let fee = calc_fee(base_fee as u64, invocation, rounds) as i128; - let token = soroban_sdk::token::Client::new(e, &fee_token); - token.transfer(caller, &e.current_contract_address(), &fee); - } - } -} diff --git a/beam-contract/src/cost.rs b/beam-contract/src/cost.rs new file mode 100644 index 0000000..1d53007 --- /dev/null +++ b/beam-contract/src/cost.rs @@ -0,0 +1,83 @@ +use oracle::settings; +use oracle::types::FeeConfig; +use soroban_sdk::{token, Address, Env, Vec}; + +const COST_CONFIG_KEY: &str = "cost"; +const SCALE: u64 = 10_000_000; + +pub enum InvocationComplexity { + Price = 0, + Twap = 1, + CrossPrice = 2, + CrossTwap = 3, +} + +// 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, + [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], + ) + }) +} + +// Charge per-invocation fee +pub fn charge_invocation_fee( + e: &Env, + caller: &Address, + invocation: InvocationComplexity, + rounds: u32, +) { + let fee_config = settings::get_fee_config(e); + match fee_config { + FeeConfig::None => return, + FeeConfig::Some((fee_token, _)) => { + //load rates + let costs = load_costs_config(e); + //calculate amount to charge + let cost = estimate_invocation_cost(costs, invocation, rounds) as i128; + //init fee token client + let fee_client = token::Client::new(e, &fee_token); + //burn tokens + fee_client.burn(caller, &cost); + } + } +} + +// Calculate invocation cost based on its complexity +pub fn estimate_invocation_cost( + costs: Vec, + invocation: InvocationComplexity, + periods: u32, +) -> u64 { + //resolve base cost based on the invocation type + let i = invocation as u32 + 1; + let mut cost = costs.get(i).unwrap_or_default(); + if cost < 1 { + return 0; + } + //charge additional per each loaded period + if periods > 1 { + let period_cost = costs.get(0).unwrap_or_default(); + if period_cost > 0 { + cost = mul_scaled(cost, SCALE + (periods - 1) as u64 * period_cost); + } + } + cost +} + +// Multiply two scaled values +#[inline(always)] +fn mul_scaled(value: u64, factor: u64) -> u64 { + value * factor / SCALE +} diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 400fe1b..0b3aaef 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -1,26 +1,19 @@ #![no_std] +extern crate alloc; -mod charge; -mod settings; -mod test; -mod types; - -use shared::{ - price_oracle::PriceOracleContractBase, - types::{ - asset::Asset, fee_config::FeeConfig, price_data::PriceData, - timestamp_prices::TimestampPrices, - }, -}; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; +mod cost; +mod tests; -use crate::types::{config_data::ConfigData, invocation::Invocation}; +use cost::{charge_invocation_fee, load_costs_config, set_costs_config, InvocationComplexity}; +use oracle::price_oracle::PriceOracleContractBase; +use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; #[contract] -pub struct PriceOracleContract; +pub struct BeamOracleContract; #[contractimpl] -impl PriceOracleContract { +impl BeamOracleContract { // Return base asset price is reported in // // # Returns @@ -130,17 +123,17 @@ impl PriceOracleContract { // # Returns // // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: &Env) -> FeeConfig { - PriceOracleContractBase::retention_config(e) + pub fn fee_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::fee_config(e) } - // Return the fee token address and invocation fee amount + // Retrieve current invocation costs config // // # Returns // - // Fee token address and invocation fee amount - pub fn invocation_config(e: &Env) -> FeeConfig { - settings::get_invocation_config(e) + // invocation costs config + pub fn invocation_costs(e: &Env) -> Vec { + load_costs_config(e) } // Return contract admin address @@ -156,7 +149,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `asset` - Asset to quote // * `timestamp` - Timestamp in seconds // @@ -165,7 +158,7 @@ impl PriceOracleContract { // 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(); - charge::charge_fee(e, &caller, Invocation::Price, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); PriceOracleContractBase::price(e, asset, timestamp) } @@ -173,7 +166,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `asset` - Asset to quote // // # Returns @@ -181,7 +174,7 @@ impl PriceOracleContract { // 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(); - charge::charge_fee(e, &caller, Invocation::Price, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); PriceOracleContractBase::lastprice(e, asset) } @@ -189,7 +182,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `asset` - Asset to quote // * `records` - Number of records to return // @@ -198,7 +191,7 @@ impl PriceOracleContract { // 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(); - charge::charge_fee(e, &caller, Invocation::Price, records); + charge_invocation_fee(e, &caller, InvocationComplexity::Price, records); PriceOracleContractBase::prices(e, asset, records) } @@ -206,7 +199,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `base_asset` - Base asset // * `quote_asset` - Quote asset // @@ -220,7 +213,7 @@ impl PriceOracleContract { quote_asset: Asset, ) -> Option { caller.require_auth(); - charge::charge_fee(e, &caller, Invocation::CrossPrice, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) } @@ -228,7 +221,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `timestamp` - Timestamp @@ -244,7 +237,7 @@ impl PriceOracleContract { timestamp: u64, ) -> Option { caller.require_auth(); - charge::charge_fee(e, &caller, Invocation::CrossPrice, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp) } @@ -252,7 +245,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `records` - Number of records to fetch @@ -268,7 +261,7 @@ impl PriceOracleContract { records: u32, ) -> Option> { caller.require_auth(); - charge::charge_fee(e, &caller, Invocation::CrossPrice, records); + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, records); PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records) } @@ -276,7 +269,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `asset` - Asset to quote // * `records` - Number of records to process // @@ -285,7 +278,7 @@ impl PriceOracleContract { // TWAP for the given asset over N recent records or None if asset is not supported pub fn twap(e: &Env, caller: Address, asset: Asset, records: u32) -> Option { caller.require_auth(); - charge::charge_fee(e, &caller, Invocation::Twap, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::Twap, 1); PriceOracleContractBase::twap(e, asset, records) } @@ -293,7 +286,7 @@ impl PriceOracleContract { // // # Arguments // - // * `caller` - Address of the caller + // * `caller` - Caller that covers invocation cost // * `base_asset` - Base asset // * `quote_asset` - Quote asset // * `records` - Number of records to process @@ -309,7 +302,7 @@ impl PriceOracleContract { records: u32, ) -> Option { caller.require_auth(); - charge::charge_fee(e, &caller, Invocation::CrossTwap, records); + charge_invocation_fee(e, &caller, InvocationComplexity::CrossTwap, records); PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records) } @@ -325,19 +318,7 @@ impl PriceOracleContract { // // Panics if not authorized or if contract is already initialized pub fn config(e: &Env, config: ConfigData) { - PriceOracleContractBase::config( - e, - &config.admin, - &config.base_asset, - config.decimals, - config.resolution, - config.history_retention_period, - config.cache_size, - &config.retention_config, - config.assets, - 0, - ); - settings::set_invocation_config(e, &config.invocation_config); + PriceOracleContractBase::config(e, config, 0); } // Update contract cache size @@ -392,22 +373,22 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { - PriceOracleContractBase::set_retention_config(e, retention_config, 0); + pub fn set_fee_config(e: &Env, config: FeeConfig) { + PriceOracleContractBase::set_fee_config(e, config, 0); } - // Set fee token address and invocation fee amount + // Update costs configuration per each invocation category // Requires admin authorization // // # Arguments // - // * `fee_config` - Fee token address and fee amount + // * `config` - Invocation costs for different invocation categories // // # Panics // // Panics if not authorized or not initialized yet - pub fn set_invocation_config(e: &Env, invocation_config: FeeConfig) { - settings::set_invocation_config(e, &invocation_config); + pub fn set_invocation_costs_config(e: &Env, config: Vec) { + set_costs_config(e, &config); } // Record new price feed history snapshot @@ -421,7 +402,7 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, updates: TimestampPrices, timestamp: u64) { + pub fn set_price(e: &Env, updates: PriceUpdate, timestamp: u64) { PriceOracleContractBase::set_price(e, updates, timestamp); } diff --git a/beam-contract/src/settings.rs b/beam-contract/src/settings.rs deleted file mode 100644 index bc78c6f..0000000 --- a/beam-contract/src/settings.rs +++ /dev/null @@ -1,23 +0,0 @@ -use shared::{settings::XRF_TOKEN_ADDRESS, types::fee_config::FeeConfig}; -use soroban_sdk::{Address, Env}; - -const INVOCATION_KEY: &str = "invocation"; -const DEFAULT_INVOCATION_FEE: i128 = 100_000_000; - -#[inline] -pub fn set_invocation_config(e: &Env, inv_config: &FeeConfig) { - e.storage().instance().set(&INVOCATION_KEY, &inv_config); -} - -#[inline] -pub fn get_invocation_config(e: &Env) -> FeeConfig { - e.storage() - .instance() - .get(&INVOCATION_KEY) - .unwrap_or_else(|| { - FeeConfig::Some(( - Address::from_str(e, XRF_TOKEN_ADDRESS), - DEFAULT_INVOCATION_FEE, - )) - }) -} diff --git a/beam-contract/src/test.rs b/beam-contract/src/test.rs deleted file mode 100644 index 756fe5a..0000000 --- a/beam-contract/src/test.rs +++ /dev/null @@ -1,592 +0,0 @@ -#![cfg(test)] -extern crate alloc; -extern crate std; - -use alloc::string::ToString; -use shared::prices; -use shared::types::timestamp_prices::TimestampPrices; -use shared::types::{asset::Asset, fee_config::FeeConfig}; -use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; -use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; -use std::panic::{self, AssertUnwindSafe}; - -use crate::charge; -use crate::types::{config_data::ConfigData, invocation::Invocation}; -use crate::{PriceOracleContract, PriceOracleContractClient}; -use test_case::test_case; - -const RESOLUTION: u32 = 300_000; -const DECIMALS: u32 = 14; - -fn convert_to_seconds(timestamp: u64) -> u64 { - timestamp / 1000 -} - -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) = - shared::pos_encoding::locate_update_record_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 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_at(contract_id, PriceOracleContract, ()); - let client = PriceOracleContractClient::new(&env, contract_id); - - env.cost_estimate().budget().reset_unlimited(); - - let init_data = 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, - retention_config: FeeConfig::None, - invocation_config: FeeConfig::None, - }; - - 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) -> TimestampPrices { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - TimestampPrices { - prices: updates, - mask: mask, - } -} - -fn get_random_bool() -> bool { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .subsec_nanos(); - let random_bool = (nanos % 200) == 0; - random_bool -} - -fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - let price = if get_random_bool() { 0 } else { price }; - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - TimestampPrices { - prices: updates, - mask: mask, - } -} - -#[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.history_retention_period().unwrap(); - assert_eq!(period, init_data.history_retention_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); - - assert_eq!( - env.events().all().last().unwrap().1, - ( - symbol_short!("REFLECTOR"), - symbol_short!("update"), - &600_000u64 - ) - .into_val(&env) - ); -} - -#[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_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.cost_estimate().budget().reset_unlimited(); - - 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 prices_update_overflow_test() { - let (env, client, _) = init_contract_with_admin(); - - env.mock_all_auths(); - - env.cost_estimate().budget().reset_unlimited(); - - let mut updates = Vec::new(&env); - for i in 1..=256 { - updates.push_back(normalize_price(i as i128 + 1)); - } - let mask = generate_update_record_mask(&env, &updates); - let update = TimestampPrices { - prices: updates, - mask: mask, - }; - client.set_price(&update, &600_000); -} - -#[test] -fn set_period_test() { - let (env, client, _) = init_contract_with_admin(); - - 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 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_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 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_history_retention_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(|| { - prices::fixed_div_floor(a.clone(), *b, 14) - })); - if expected == &-1 { - assert!(result.is_err()); - } else { - assert_eq!(result.unwrap(), *expected); - } - } -} - -#[test] -fn set_retention_config_test() { - let (env, client, init_data) = init_contract_with_admin(); - - //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 retention_config = FeeConfig::Some((fee_asset.address(), 7)); - - client.set_retention_config(&retention_config); - - let result = client.retention_config(); - assert_ne!(result, FeeConfig::None); - assert_eq!(result, retention_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, 0); - client.extend_asset_ttl(&sponsor, &asset, &10); - let ledger_ts = env.ledger().timestamp() * 1000; - assert_eq!( - client.expires(&asset).unwrap(), - symbol_expires + ledger_ts + 123428571 - ); //initial ttl is 0, so ledger + 123428571 (ms you get for 10 XRF tokens) is expected - - let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); - assert_eq!(fee_token_balance, 0); -} - -#[test] -fn set_invocation_config_test() { - let (env, client, init_data) = init_contract_with_admin(); - - //create fee asset token - let fee_asset = env.register_stellar_asset_contract_v2(init_data.admin.clone()); - - client.set_invocation_config(&FeeConfig::Some((fee_asset.address(), 1_000_000))); - - let result = client.invocation_config(); - assert_ne!(result, FeeConfig::None); - assert_eq!(result, FeeConfig::Some((fee_asset.address(), 1_000_000))); -} - -#[test] -fn 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 fee_asset = env - .register_stellar_asset_contract_v2(init_data.admin.clone()) - .address(); - let invocation_config = FeeConfig::Some((fee_asset.clone(), 1_000_000)); - client.set_invocation_config(&invocation_config); - - let caller = Address::generate(&env); - //mint fee token to caller - let fee_token = StellarAssetClient::new(&env, &fee_asset); - fee_token.mint(&caller, &1_000_000); - //get price for the first asset - let price = client - .lastprice(&caller, &init_data.assets.first_unchecked()) - .unwrap(); - assert_eq!(price.price, normalize_price(100)); - assert_eq!(price.timestamp, convert_to_seconds(timestamp)); - - //check that fee token was deducted - let fee_token_balance = TokenClient::new(&env, &fee_asset).balance(&caller); - assert_eq!(fee_token_balance, 0); -} - -#[test_case(1_000_000, Invocation::Price, 1, 1_000_000 ; "price")] -#[test_case(1_000_000, Invocation::Twap, 1, 1_500_000 ; "twap")] -#[test_case(1_000_000, Invocation::CrossPrice, 1, 2_000_000 ; "cross price")] -#[test_case(1_000_000, Invocation::CrossTwap, 1, 3_000_000 ; "cross twap")] -#[test_case(1_000_000, Invocation::Price, 2, 1_100_000 ; "multi round price")] -#[test_case(1_000_000, Invocation::Twap, 2, 1_650_000 ; "multi round twap")] -#[test_case(1_000_000, Invocation::CrossPrice, 2, 2_200_000 ; "multi round cross price")] -#[test_case(1_000_000, Invocation::CrossTwap, 2, 3_300_000 ; "multi round cross twap")] -fn charge_test(base_fee: u64, invocation: Invocation, rounds: u32, expected_fee: u64) { - let fee = charge::calc_fee(base_fee, invocation, rounds); - assert_eq!(fee, expected_fee); -} - -#[test] -fn prices_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - client.set_cache_size(&256); - - let mut history_prices = Vec::new(&env); - - //set more than 255 prices to check history is overritten correctly - for i in 0..257 { - let timestamp = 600_000 + i * 300_000; - - if timestamp != 900_000 && timestamp != 1200_000 { - let updates = get_updates_with_random(&env, &assets, normalize_price(100)); - history_prices.push_front((timestamp, updates.clone())); - //set prices for assets - client.set_price(&updates, ×tamp); - } else { - //simulate time passage without setting prices to create gaps in updates - let updates = get_updates_with_random(&env, &assets, 0); - history_prices.push_front((timestamp, updates.clone())); - } - let ledger_info = env.ledger().get(); - env.ledger().set(LedgerInfo { - timestamp: timestamp / 1000 + 300, - ..ledger_info - }); - } - - let caller = Address::generate(&env); - let mut had_gaps = false; - let mut had_prices = false; - //verify prices - for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { - if history_index > 255 { - break; - } - let all_prices = prices::get_prices_for_assets(&env, &updates, assets.len() + 10 as u32); //+10 to check that out of range assets are ignored - for (asset_index, asset) in assets.iter().enumerate() { - let price_data = client.price(&caller, &asset, &(timestamp / 1000)); - let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); - if expected_price > 0 { - let price = price_data.unwrap(); - assert_eq!(price.price, expected_price); - assert_eq!(price.timestamp, convert_to_seconds(timestamp)); - had_prices = true; - } else { - assert!(price_data.is_none()); - had_gaps = true; - } - } - } - assert!(had_prices); - assert!(had_gaps); -} diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs new file mode 100644 index 0000000..5c61d5f --- /dev/null +++ b/beam-contract/src/tests.rs @@ -0,0 +1,119 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use crate::cost; +use crate::cost::InvocationComplexity; +use crate::{BeamOracleContract, BeamOracleContractClient}; +use oracle::types::{Asset, ConfigData, FeeConfig}; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{Address, Env, String, Vec}; +use test_case::test_case; + +pub fn init_contract_with_admin<'a>() -> (Env, BeamOracleContractClient<'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 contract_id = &Address::from_string(&String::from_str( + &env, + "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", + )); + + env.register_at(contract_id, BeamOracleContract, ()); + let client = BeamOracleContractClient::new(&env, contract_id); + + env.cost_estimate().budget().reset_unlimited(); + + env.mock_all_auths(); + let init_data = prepare_contract_config(&env); + client.config(&init_data); + + (env, client, init_data) +} + +fn prepare_contract_config(env: &Env) -> ConfigData { + let admin = Address::generate(env); + let mut assets = Vec::new(env); + for _ in 0..10 { + assets.push_back(Asset::Stellar(Address::generate(env))); + } + let resolution = 300_000u32; + ConfigData { + admin: admin.clone(), + history_retention_period: (100 * resolution).into(), + assets, + base_asset: Asset::Stellar(Address::generate(&env)), + decimals: 14, + resolution, + cache_size: 0, + fee_config: FeeConfig::None, + } +} + +#[test] +fn set_invocation_config_test() { + let (env, client, _) = init_contract_with_admin(); + + let costs = Vec::from_array(&env, [10, 20, 30, 40, 50]); + client.set_invocation_costs_config(&costs); + + let result = client.invocation_costs(); + assert_eq!(result, costs); +} + +#[test] +fn invocation_charge_test() { + let (env, client, init_data) = init_contract_with_admin(); + + 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); + + let caller = Address::generate(&env); + //mint fee token to caller + let fee_token = StellarAssetClient::new(&env, &fee_asset); + fee_token.mint(&caller, &100_000_000); + //get price for the first asset + client.lastprice(&caller, &init_data.assets.first_unchecked()); + //get cross price + client.x_twap( + &caller, + &init_data.base_asset, + &init_data.assets.first_unchecked(), + &5, + ); + //check that fee token was deducted + let fee_token_balance = TokenClient::new(&env, &fee_asset).balance(&caller); + assert_eq!(fee_token_balance, 36_000_000); +} + +#[test_case(InvocationComplexity::Price, 1, 10_000_000 ; "price")] +#[test_case(InvocationComplexity::Twap, 1, 15_000_000 ; "twap")] +#[test_case(InvocationComplexity::CrossPrice, 1, 20_000_000 ; "cross price")] +#[test_case(InvocationComplexity::CrossTwap, 1, 30_000_000 ; "cross twap")] +#[test_case(InvocationComplexity::Price, 2, 12_000_000 ; "multi round price")] +#[test_case(InvocationComplexity::Twap, 5, 27_000_000 ; "multi round twap")] +#[test_case(InvocationComplexity::CrossPrice, 2, 24_000_000 ; "multi round cross price")] +#[test_case(InvocationComplexity::CrossTwap, 7, 66_000_000 ; "multi round cross twap")] +fn invocation_charge_estimate_test( + invocation: InvocationComplexity, + rounds: u32, + expected_fee: u64, +) { + let env = Env::default(); + let costs = Vec::from_array( + &env, + [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], + ); + let fee = cost::estimate_invocation_cost(costs, invocation, rounds); + assert_eq!(fee, expected_fee); +} diff --git a/beam-contract/src/types/config_data.rs b/beam-contract/src/types/config_data.rs deleted file mode 100644 index ee2e80b..0000000 --- a/beam-contract/src/types/config_data.rs +++ /dev/null @@ -1,25 +0,0 @@ -use shared::types::{asset::Asset, fee_config::FeeConfig}; -use soroban_sdk::{contracttype, Address, Vec}; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConfigData { - // Admin address - pub admin: Address, - // Price history retention period - 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 - pub resolution: u32, - // Number of rounds held in instance cache - pub cache_size: u32, - // Contract retention config - pub retention_config: FeeConfig, - /// Invocation fee - pub invocation_config: FeeConfig, -} diff --git a/beam-contract/src/types/invocation.rs b/beam-contract/src/types/invocation.rs deleted file mode 100644 index e3179e9..0000000 --- a/beam-contract/src/types/invocation.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub enum Invocation { - Price = 0, - CrossPrice = 1, - Twap = 2, - CrossTwap = 3, -} diff --git a/beam-contract/src/types/mod.rs b/beam-contract/src/types/mod.rs deleted file mode 100644 index bf1599e..0000000 --- a/beam-contract/src/types/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod config_data; -pub mod invocation; diff --git a/shared/Cargo.toml b/oracle/Cargo.toml similarity index 60% rename from shared/Cargo.toml rename to oracle/Cargo.toml index 470babc..13c20ea 100644 --- a/shared/Cargo.toml +++ b/oracle/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "shared" +name = "oracle" version = "6.0.0" edition = "2021" @@ -10,4 +10,5 @@ crate-type = ["rlib"] soroban-sdk = { workspace = true } [dev-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" \ No newline at end of file diff --git a/shared/src/assets.rs b/oracle/src/assets.rs similarity index 95% rename from shared/src/assets.rs rename to oracle/src/assets.rs index bac9631..4c73a2a 100644 --- a/shared/src/assets.rs +++ b/oracle/src/assets.rs @@ -1,4 +1,4 @@ -use crate::types::{asset::Asset, error::Error, fee_config::FeeConfig}; +use crate::types::{Asset, Error, FeeConfig}; use crate::{settings, timestamps}; use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env, Vec}; @@ -57,7 +57,7 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { //load current state let mut asset_list = load_all_assets(e); let mut expiration = load_expiration_records(e); - let is_retention_config_set = settings::get_retention_config(e) != FeeConfig::None; + let is_fee_config_set = settings::get_fee_config(e) != FeeConfig::None; //for each new asset for asset in assets.iter() { //check if the asset has been already added @@ -67,7 +67,7 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { set_asset_index(e, &asset, asset_list.len()); asset_list.push_back(asset); //if the fee is not set, we don't need to set the expiration - if is_retention_config_set && expiration_timestamp > 0 { + if is_fee_config_set && expiration_timestamp > 0 { expiration.push_back(expiration_timestamp); //set expiration } } @@ -124,7 +124,7 @@ pub fn extend_ttl( } let asset_index = asset_index.unwrap(); //load required fee amount from retention config - let (xrf, fee) = match settings::get_retention_config(e) { + let (xrf, fee) = match settings::get_fee_config(e) { FeeConfig::Some(fee_data) => { if fee_data.1 <= 0 { e.panic_with_error(Error::InvalidConfigVersion); diff --git a/shared/src/auth.rs b/oracle/src/auth.rs similarity index 87% rename from shared/src/auth.rs rename to oracle/src/auth.rs index 548ec30..681eec7 100644 --- a/shared/src/auth.rs +++ b/oracle/src/auth.rs @@ -1,4 +1,4 @@ -use crate::types::error; +use crate::types::Error; use soroban_sdk::{panic_with_error, Address, Env}; //storage keys @@ -21,7 +21,7 @@ pub fn set_admin(e: &Env, admin: &Address) { pub fn panic_if_not_admin(e: &Env) { let admin = get_admin(e); if admin.is_none() { - panic_with_error!(e, error::Error::Unauthorized); + panic_with_error!(e, Error::Unauthorized); } admin.unwrap().require_auth() } diff --git a/shared/src/events.rs b/oracle/src/events.rs similarity index 77% rename from shared/src/events.rs rename to oracle/src/events.rs index 16156ac..8026a7c 100644 --- a/shared/src/events.rs +++ b/oracle/src/events.rs @@ -1,7 +1,4 @@ -use crate::{ - assets, - types::{asset::Asset, error::Error}, -}; +use crate::types::{Asset, Error}; use soroban_sdk::{contractevent, panic_with_error, Env, Val, Vec}; #[contractevent(topics = ["REFLECTOR", "update"])] @@ -14,16 +11,14 @@ pub struct UpdateEvent { // Compose and publish price update event #[inline] -pub fn publish_update_event(e: &Env, updates: &Vec, timestamp: u64) { - //load all registered assets - let assets = assets::load_all_assets(e); +pub fn publish_update_event(e: &Env, updates: &Vec, all_assets: &Vec, timestamp: u64) { //validate length - if assets.len() < updates.len() { + 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 assets.iter().enumerate() { + for (index, asset) in all_assets.iter().enumerate() { //retrieve individual price let price = updates.get(index as u32).unwrap_or_default(); if price == 0 { diff --git a/shared/src/lib.rs b/oracle/src/lib.rs similarity index 77% rename from shared/src/lib.rs rename to oracle/src/lib.rs index 498494a..16bf7b1 100644 --- a/shared/src/lib.rs +++ b/oracle/src/lib.rs @@ -1,9 +1,10 @@ #![no_std] +extern crate alloc; pub mod assets; pub mod auth; pub mod events; -pub mod pos_encoding; +pub mod mapping; pub mod price_oracle; pub mod prices; pub mod protocol; @@ -11,4 +12,4 @@ pub mod settings; pub mod timestamps; pub mod types; -pub mod test; +mod tests; diff --git a/oracle/src/mapping.rs b/oracle/src/mapping.rs new file mode 100644 index 0000000..f8e924d --- /dev/null +++ b/oracle/src/mapping.rs @@ -0,0 +1,73 @@ +use soroban_sdk::{Bytes, Env, Vec, U256}; + +// Each history record occupies 32 bytes in history mask, allowing to store information for up to 256 recent periods +const RECORD_SIZE: u32 = 32; + +// Update history records containing a bitmask of all prices recorded within the last update period +pub fn update_history_mask(e: &Env, mut history_mask: Bytes, updates: &Vec) -> Bytes { + let one = U256::from_u32(e, 1); + //iterate through all updates + for (asset_index, price) in updates.iter().enumerate() { + //locate particular asset mask slice position within entire history record + let from = asset_index as u32 * RECORD_SIZE; + let to = from + RECORD_SIZE; + //retrieve previous asset mask + let mut bitmask = if history_mask.len() >= to { + let encoded = history_mask.slice(from..to); + U256::from_be_bytes(e, &encoded) + } else { + U256::from_u32(e, 0) //no previous records for this asset found + }; + //shift existing mask, all mask bits older than 256 periods get evicted + bitmask = bitmask.shl(1); + //set corresponding bit if price found + if price > 0 { + bitmask = bitmask.add(&one); + } + //encode into bytes again + let encoded = bitmask.to_be_bytes(); + //write to the history + if history_mask.len() <= from { + //that's new asset, add to the mask + history_mask.append(&encoded); + } else { + //replace bytes + for i in 0..RECORD_SIZE { + history_mask.set(from + i, encoded.get(i).unwrap()); + } + } + } + history_mask //return updated history +} + +// 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 bytemask = history_mask.get(from).unwrap_or_default(); + //compare with bit mask + bytemask & 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/shared/src/price_oracle.rs b/oracle/src/price_oracle.rs similarity index 89% rename from shared/src/price_oracle.rs rename to oracle/src/price_oracle.rs index 1b1de71..753f13e 100644 --- a/shared/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -1,14 +1,12 @@ -use crate::{ - assets, auth, events, prices, protocol, settings, timestamps, - types::{ - asset::Asset, error::Error, fee_config::FeeConfig, price_data::PriceData, - timestamp_prices::TimestampPrices, - }, -}; -use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; +use crate::types::ConfigData; +use crate::types::{Asset, Error, FeeConfig, PriceData, PriceUpdate}; +use crate::{assets, auth, events, prices, protocol, settings, timestamps}; +use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, Vec}; +#[contract] pub struct PriceOracleContractBase; +#[contractimpl] impl PriceOracleContractBase { // Return base asset price is reported in // @@ -138,8 +136,8 @@ impl PriceOracleContractBase { // # Returns // // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: &Env) -> FeeConfig { - settings::get_retention_config(e) + pub fn fee_config(e: &Env) -> FeeConfig { + settings::get_fee_config(e) } // Return contract admin address @@ -332,41 +330,30 @@ impl PriceOracleContractBase { // * `resolution` - History timeframe resolution (in seconds) // * `history_retention_period` - Price history retention period (in seconds) // * `cache_size` - Number of rounds held in instance cache - // * `retention_config` - Contract retention config + // * `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, - admin: &Address, - base: &Asset, - decimals: u32, - resolution: u32, - history_retention_period: u64, - cache_size: u32, - retention_config: &FeeConfig, - assets: Vec, - initial_expiration_period: u32, - ) { + pub fn config(e: &Env, config: ConfigData, initial_expiration_period: u32) { //should be invoked by admin - admin.require_auth(); + config.admin.require_auth(); //apply settings settings::init( e, - base, - decimals, - resolution, - history_retention_period, - cache_size, - &retention_config, + &config.base_asset, + config.decimals, + config.resolution, + config.history_retention_period, + config.cache_size, + &config.fee_config, ); - auth::set_admin(e, admin); + auth::set_admin(e, &config.admin); protocol::set_protocol_version(e, protocol::CURRENT_PROTOCOL); //add initial assets - assets::add_assets(&e, assets, initial_expiration_period); + assets::add_assets(&e, config.assets, initial_expiration_period); } // Update contract cache size @@ -426,13 +413,9 @@ impl PriceOracleContractBase { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_retention_config( - e: &Env, - retention_config: FeeConfig, - initial_expiration_period: u32, - ) { + pub fn set_fee_config(e: &Env, fee_config: FeeConfig, initial_expiration_period: u32) { auth::panic_if_not_admin(e); - settings::set_retention_config(e, &retention_config); + settings::set_fee_config(e, &fee_config); assets::init_expiration_config(e, initial_expiration_period); } @@ -447,7 +430,7 @@ impl PriceOracleContractBase { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, update: TimestampPrices, timestamp: u64) { + pub fn set_price(e: &Env, update: PriceUpdate, timestamp: u64) { auth::panic_if_not_admin(e); if update.prices.len() == 0 { return; //skip empty updates @@ -460,13 +443,13 @@ impl PriceOracleContractBase { if timestamp == 0 || !timestamps::is_valid(e, timestamp) || timestamp > ledger_timestamp { panic_with_error!(&e, Error::InvalidTimestamp); } - //create vector of all assets prices - let asset_prices = - prices::get_prices_for_assets(e, &update, assets::load_all_assets(e).len()); + //extract prices for all assets from update record + let all = assets::load_all_assets(e); + let asset_prices = prices::extract_update_record_prices(e, &update, all.len()); //store history timestamps for all assets - prices::set_history_timestamps(e, &asset_prices, timestamp); + prices::update_history_mask(e, &asset_prices, timestamp); //prepare and publish update event - events::publish_update_event(e, &asset_prices, timestamp); + events::publish_update_event(e, &asset_prices, &all, timestamp); //store new prices prices::store_prices(e, &update, timestamp, &asset_prices); } diff --git a/shared/src/prices.rs b/oracle/src/prices.rs similarity index 78% rename from shared/src/prices.rs rename to oracle/src/prices.rs index 9c651c8..b47083f 100644 --- a/shared/src/prices.rs +++ b/oracle/src/prices.rs @@ -1,12 +1,10 @@ -use crate::pos_encoding; -use crate::settings; -use crate::types::{price_data::PriceData, timestamp_prices::TimestampPrices}; -use crate::{protocol, timestamps}; +use crate::types::{PriceData, PriceUpdate}; +use crate::{mapping, protocol, settings, timestamps}; use soroban_sdk::{Bytes, Env, Vec}; const CACHE_KEY: &str = "cache"; const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; -const HISTORY_TIMESTAMPS_KEY: &str = "history_timestamps"; +const HISTORY_KEY: &str = "history"; fn normalize_price_data(price: i128, timestamp: u64) -> PriceData { PriceData { @@ -32,50 +30,60 @@ pub fn obtain_last_record_timestamp(e: &Env) -> u64 { // Retrieve price from record for specific asset pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option { - //if the protocol version is not current, use legacy method + //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_timestamp = get_last_timestamp(e); + let last = get_last_timestamp(e); //get the timestamp index in the bitmask - if last_timestamp < timestamp { + if last < timestamp { return None; } - let mut timestamp_index = 0; - if last_timestamp > timestamp { - timestamp_index = (last_timestamp - timestamp) / settings::get_resolution(e) as u64; + let mut period = 0; + if last > timestamp { + period = (last - timestamp) / settings::get_resolution(e) as u64; } - if timestamp_index > 255 || !has_price(e, asset, timestamp_index as u32) { - //we cannot track more than 256 updates in the bitmask - return None; + 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 timestamp_prices = timestamp_prices(e, timestamp)?; + let record = load_history_record(e, timestamp)?; //get price for the asset index - let price = get_prices_for_assets(e, ×tamp_prices, asset + 1).last()?; // as we requested asset+1, the last one is the requested asset + let price = extract_single_update_record_price(&record, asset); Some(normalize_price_data(price, timestamp)) } -// Extract prices for all assets from the update record by the assets length -pub fn get_prices_for_assets( - e: &Env, - timestamp_prices: &TimestampPrices, - assets_length: u32, -) -> Vec { - //normalize prices for internal processing - let mut normalized_vector_prices = Vec::new(&e); - let mut last_price_index = 0; - for asset_index in 0..assets_length { +// Extract prices for all assets from update record +pub(crate) 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 pos_encoding::check_update_record_mask(×tamp_prices.mask, asset_index) { + if mapping::check_period_updated(&update.mask, asset_index) { //set price from the update record - price = timestamp_prices.prices.get_unchecked(last_price_index); - last_price_index += 1; + 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; } - normalized_vector_prices.push_back(price); } - normalized_vector_prices + 0 } // Load last update timestamp @@ -92,23 +100,25 @@ pub fn set_last_timestamp(e: &Env, timestamp: u64) { e.storage().instance().set(&LAST_TIMESTAMP_KEY, ×tamp); } -pub fn get_history_timestamps(e: &Env) -> Bytes { +// 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_TIMESTAMPS_KEY) + .get(&HISTORY_KEY) .unwrap_or_else(|| Bytes::new(e)) } -pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { +// +pub fn update_history_mask(e: &Env, prices: &Vec, timestamp: u64) { + //load state let last_timestamp = get_last_timestamp(e); - let mut timestamps = get_history_timestamps(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; } - //add missing intervals if update_delta > 1 { for _ in 1..update_delta { @@ -116,26 +126,24 @@ pub fn set_history_timestamps(e: &Env, prices: &Vec, timestamp: u64) { for _ in 0..prices.len() { empty_prices.push_back(0i128); } - timestamps = pos_encoding::update_position_mask(e, timestamps, &empty_prices); + history_map = mapping::update_history_mask(e, history_map, &empty_prices); } } //update the position mask - timestamps = pos_encoding::update_position_mask(e, timestamps, prices); + history_map = mapping::update_history_mask(e, history_map, prices); //store updated timestamps - e.storage() - .instance() - .set(&HISTORY_TIMESTAMPS_KEY, ×tamps); + 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_timestamps(e); - pos_encoding::had_update(×tamps, asset_index, periods_ago) + let timestamps = get_history_map(e); + mapping::check_history_updated(×tamps, asset_index, periods_ago) } // Load prices for a given timestamp -pub fn timestamp_prices(e: &Env, timestamp: u64) -> Option { +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() { @@ -151,7 +159,7 @@ pub fn timestamp_prices(e: &Env, timestamp: u64) -> Option { } // Update prices stored in the oracle -pub fn store_prices(e: &Env, update: &TimestampPrices, timestamp: u64, update_v1: &Vec) { +pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &Vec) { //get the last timestamp let last_timestamp = get_last_timestamp(e); //update the last timestamp @@ -277,7 +285,7 @@ pub fn load_cross_price( } // Get cached records from the instance storage -fn load_price_records_cache(e: &Env) -> Option> { +fn load_price_records_cache(e: &Env) -> Option> { e.storage().instance().get(&CACHE_KEY) } diff --git a/shared/src/protocol.rs b/oracle/src/protocol.rs similarity index 100% rename from shared/src/protocol.rs rename to oracle/src/protocol.rs diff --git a/shared/src/settings.rs b/oracle/src/settings.rs similarity index 86% rename from shared/src/settings.rs rename to oracle/src/settings.rs index 0089893..97bb1c9 100644 --- a/shared/src/settings.rs +++ b/oracle/src/settings.rs @@ -1,4 +1,4 @@ -use crate::types::{asset::Asset, error::Error, fee_config::FeeConfig}; +use crate::types::{Asset, Error, FeeConfig}; use soroban_sdk::{Address, Env}; const RETENTION_PERIOD_KEY: &str = "period"; @@ -19,7 +19,7 @@ pub fn init( resolution: u32, history_retention_period: u64, cache_size: u32, - retention_config: &FeeConfig, + fee_config: &FeeConfig, ) { //do not allow to initialize more than once if e.storage().instance().has(&RETENTION_PERIOD_KEY) { @@ -32,7 +32,7 @@ pub fn init( set_resolution(e, resolution); set_history_retention_period(e, history_retention_period); set_cache_size(e, cache_size); - set_retention_config(e, retention_config); + set_fee_config(e, fee_config); } #[inline] @@ -81,19 +81,18 @@ pub fn set_cache_size(e: &Env, cache_size: u32) { } #[inline] -pub fn set_retention_config(e: &Env, retention_config: &FeeConfig) { - e.storage() - .instance() - .set(&RETENTION_KEY, &retention_config); +pub fn set_fee_config(e: &Env, fee_config: &FeeConfig) { + e.storage().instance().set(&RETENTION_KEY, &fee_config); } #[inline] -pub fn get_retention_config(e: &Env) -> FeeConfig { +pub fn get_fee_config(e: &Env) -> FeeConfig { e.storage() .instance() .get(&RETENTION_KEY) .unwrap_or_else(|| { FeeConfig::Some(( + // by default - XRF tokens with 1 XRF base cost Address::from_str(e, XRF_TOKEN_ADDRESS), DEFAULT_RETENTION_FEE, )) diff --git a/oracle/src/tests/contract_admin_tests.rs b/oracle/src/tests/contract_admin_tests.rs new file mode 100644 index 0000000..12cdbdc --- /dev/null +++ b/oracle/src/tests/contract_admin_tests.rs @@ -0,0 +1,286 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use crate::types::{Asset, FeeConfig, PriceUpdate}; +use alloc::string::ToString; +use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; +use test_case::test_case; + +use crate::tests::setup_tests::{ + convert_to_seconds, generate_assets, generate_update_record_mask, generate_updates, + init_contract, normalize_price, DECIMALS, RESOLUTION, +}; + +#[test] +fn init_test() { + let (_env, client, init_data) = init_contract(); + + 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(); + + 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, ×tamp); + + assert_eq!( + env.events().all().last().unwrap().1, + ( + symbol_short!("REFLECTOR"), + symbol_short!("update"), + &600_000u64 + ) + .into_val(&env) + ); +} + +#[test] +#[should_panic] +fn set_price_zero_timestamp_test() { + let (env, client, init_data) = init_contract(); + + 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, ×tamp); +} + +#[test] +#[should_panic] +fn set_price_invalid_timestamp_test() { + let (env, client, init_data) = init_contract(); + + 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, ×tamp); +} + +#[test] +#[should_panic] +fn set_price_future_timestamp_test() { + let (env, client, init_data) = init_contract(); + + 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, ×tamp); +} + +#[test_case(0 ; "with zero expiration period")] +#[test_case(180 ; "with 180d expiration period")] +fn add_assets_test(initial_expiration_period: u32) { + let (env, client, init_data) = init_contract(); + + let assets = generate_assets(&env, 10, init_data.assets.len() - 1); + + env.mock_all_auths(); + + client.add_assets(&assets, &initial_expiration_period); + + 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(); + + 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, &0); +} + +#[test] +#[should_panic] +fn asset_update_overflow_test() { + let (env, client, _) = init_contract(); + + env.mock_all_auths(); + + env.cost_estimate().budget().reset_unlimited(); + + 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, &0); +} + +#[test] +#[should_panic] +fn price_update_overflow_test() { + let (env, client, _) = init_contract(); + + env.mock_all_auths(); + + env.cost_estimate().budget().reset_unlimited(); + + let mut updates = Vec::new(&env); + for i in 1..=256 { + updates.push_back(normalize_price(i as i128 + 1)); + } + let mask = generate_update_record_mask(&env, &updates); + let update = PriceUpdate { + prices: updates, + mask: mask, + }; + client.set_price(&update, &600_000); +} + +#[test] +fn set_history_retention_period_test() { + let (env, client, _) = init_contract(); + + 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(); + + //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); //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, 260100000); // 900s current ledger timestamp + 3 days off initial expiration period + client.extend_asset_ttl(&sponsor, &asset, &10, &0); + //123428571 ms you get for 10 XRF tokens + assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); + + 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(); + + 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(); + 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/oracle/src/tests/contract_interface_tests.rs b/oracle/src/tests/contract_interface_tests.rs new file mode 100644 index 0000000..01d6cb6 --- /dev/null +++ b/oracle/src/tests/contract_interface_tests.rs @@ -0,0 +1,133 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use crate::prices; +use crate::tests::setup_tests::{ + convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, +}; +use crate::types::FeeConfig; +use soroban_sdk::testutils::{Ledger, LedgerInfo}; +use soroban_sdk::Vec; + +#[test] +fn version_test() { + let (_env, client, _) = init_contract(); + let result = client.version(); + let version = env!("CARGO_PKG_VERSION") + .split(".") + .next() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(result, version); +} + +#[test] +fn last_timestamp_test() { + let (env, client, init_data) = init_contract(); + + 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, ×tamp); + + result = client.last_timestamp(); + + assert_eq!(result, convert_to_seconds(600_000)); +} + +#[test] +fn price_test() { + let (env, client, init_data) = init_contract(); + + 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, ×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, &180); + + //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] +fn prices_test() { + let (env, client, init_data) = init_contract(); + + let assets = init_data.assets; + + client.set_cache_size(&256); + + let mut history_prices = Vec::new(&env); + + //set more than 255 prices to check that history mask is overwritten correctly + for i in 0..257 { + let timestamp = 600_000 + i * 300_000; + + if timestamp != 900_000 && timestamp != 1200_000 { + let updates = generate_random_updates(&env, &assets, normalize_price(100)); + history_prices.push_front((timestamp, updates.clone())); + //set prices for assets + client.set_price(&updates, ×tamp); + } else { + //simulate time passage without setting prices to create gaps in updates + let updates = generate_random_updates(&env, &assets, 0); + history_prices.push_front((timestamp, updates.clone())); + } + let ledger_info = env.ledger().get(); + env.ledger().set(LedgerInfo { + timestamp: timestamp / 1000 + 300, + ..ledger_info + }); + } + + let mut had_gaps = false; + let mut had_prices = false; + //verify prices + for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { + if history_index > 255 { + break; + } + let total = assets.len() + 10; //+10 to check that out of range assets are ignored + let all_prices = prices::extract_update_record_prices(&env, &updates, total); + for (asset_index, asset) in assets.iter().enumerate() { + let price_data = client.price(&asset, &(timestamp / 1000)); + let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); + if expected_price > 0 { + let price = price_data.unwrap(); + assert_eq!(price.price, expected_price); + assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + had_prices = true; + } else { + assert!(price_data.is_none()); + had_gaps = true; + } + } + } + assert!(had_prices); + assert!(had_gaps); +} diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs new file mode 100644 index 0000000..189f7c2 --- /dev/null +++ b/oracle/src/tests/mod.rs @@ -0,0 +1,5 @@ +mod setup_tests; + +mod contract_admin_tests; +mod contract_interface_tests; +mod util_tests; diff --git a/oracle/src/tests/setup_tests.rs b/oracle/src/tests/setup_tests.rs new file mode 100644 index 0000000..3405047 --- /dev/null +++ b/oracle/src/tests/setup_tests.rs @@ -0,0 +1,126 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use crate::price_oracle::{PriceOracleContractBase, PriceOracleContractBaseClient}; +use crate::types::{Asset, ConfigData, FeeConfig, PriceUpdate}; +use alloc::string::ToString; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::{Address, Bytes, Env, String, Symbol, Vec}; + +pub(super) const RESOLUTION: u32 = 300_000; +pub(super) const DECIMALS: u32 = 14; + +pub(super) fn init_contract<'a>() -> (Env, PriceOracleContractBaseClient<'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 contract_id = &Address::from_string(&String::from_str( + &env, + "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", + )); + + env.register_at(contract_id, PriceOracleContractBase, ()); + let client = PriceOracleContractBaseClient::new(&env, contract_id); + + env.cost_estimate().budget().reset_unlimited(); + + env.mock_all_auths(); + let init_data = prepare_contract_config(&env); + let initial_expiration_period = 180u32; + client.config(&init_data, &initial_expiration_period); + + (env, client, init_data) +} + +fn prepare_contract_config(env: &Env) -> ConfigData { + let admin = Address::generate(&env); + 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, + } +} + +pub(super) fn convert_to_seconds(timestamp: u64) -> u64 { + timestamp / 1000 +} + +pub(super) fn normalize_price(price: i128) -> i128 { + price * 10i128.pow(DECIMALS) +} + +pub(super) 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) = + crate::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) +} + +pub(super) fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + PriceUpdate { + prices: updates, + mask: mask, + } +} + +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(super) fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + let price = if get_random_bool() { 0 } else { price }; + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + PriceUpdate { + prices: updates, + mask: mask, + } +} + +pub(super) 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/shared/src/test.rs b/oracle/src/tests/util_tests.rs similarity index 84% rename from shared/src/test.rs rename to oracle/src/tests/util_tests.rs index 5eb4147..377c113 100644 --- a/shared/src/test.rs +++ b/oracle/src/tests/util_tests.rs @@ -4,15 +4,14 @@ extern crate std; use soroban_sdk::{log, Bytes, Env, Vec}; -use super::*; +use crate::{mapping, prices}; use std::panic::{self, AssertUnwindSafe}; fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { let mut mask = [0u8; 32]; - for (asset_index, price) in updates.iter().enumerate() { + for (asset, price) in updates.iter().enumerate() { if price > 0 { - let (byte, bitmask) = - pos_encoding::locate_update_record_mask_position(asset_index as u32); + 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 @@ -22,7 +21,7 @@ fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { } #[test] -fn div_tests() { +fn fixed_div_floor_tests() { let test_cases = [ (154467226919499, 133928752749774, 115335373284703), ( @@ -53,7 +52,7 @@ fn div_tests() { } #[test] -fn pos_encoding_bitmask() { +fn position_encoding_bitmask_test() { let e = Env::default(); let mut mask = Bytes::new(&e); let total_assets = 5; @@ -67,7 +66,7 @@ fn pos_encoding_bitmask() { }; updates.push_back(price); } - mask = pos_encoding::update_position_mask(&e, mask, &updates); + mask = mapping::update_history_mask(&e, mask, &updates); } log!(&e, "entire mask", mask); @@ -82,14 +81,14 @@ fn pos_encoding_bitmask() { 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 = pos_encoding::had_update(&mask, asset_index, check_period); + let found = mapping::check_history_updated(&mask, asset_index, check_period); assert_eq!(found, expected); } } } #[test] -fn update_record_bitmask() { +fn update_record_bitmask_test() { let e = Env::default(); let iterations = 70; @@ -106,7 +105,7 @@ fn update_record_bitmask() { //log!(&e, "entire mask", mask); for (asset_index, price) in updates.iter().enumerate() { assert_eq!( - pos_encoding::check_update_record_mask(&mask, asset_index as u32), + mapping::check_period_updated(&mask, asset_index as u32), price > 0 ); } diff --git a/shared/src/timestamps.rs b/oracle/src/timestamps.rs similarity index 100% rename from shared/src/timestamps.rs rename to oracle/src/timestamps.rs diff --git a/oracle/src/types.rs b/oracle/src/types.rs new file mode 100644 index 0000000..f92abd2 --- /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 + 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 + 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 is already exists in supported assets list + AssetAlreadyExists = 3, + // Config version is invalid + InvalidConfigVersion = 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 index 07314fc..bfff3a5 100644 --- a/pulse-contract/Cargo.toml +++ b/pulse-contract/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "pulse-contract" +name = "reflector-pulse-contract" version = "6.0.0" edition = "2021" @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -shared = { path = "../shared" } +oracle = { path = "../oracle" } soroban-sdk = { workspace = true } [dev-dependencies] diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index 59d9d12..d165f67 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -1,26 +1,15 @@ #![no_std] -mod test; -mod types; - -use crate::types::config_data::ConfigData; - -use shared::{ - price_oracle::PriceOracleContractBase, - types::{ - asset::Asset, fee_config::FeeConfig, price_data::PriceData, - timestamp_prices::TimestampPrices, - }, -}; +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 PriceOracleContract; +pub struct PulseOracleContract; #[contractimpl] -impl PriceOracleContract { +impl PulseOracleContract { // Return base asset price is reported in // // # Returns @@ -136,8 +125,8 @@ impl PriceOracleContract { // # Returns // // Fee token address and daily price feed retainer fee amount - pub fn retention_config(e: &Env) -> FeeConfig { - PriceOracleContractBase::retention_config(e) + pub fn fee_config(e: &Env) -> FeeConfig { + PriceOracleContractBase::fee_config(e) } // Return contract admin address @@ -285,18 +274,7 @@ impl PriceOracleContract { // // Panics if not authorized or if contract is already initialized pub fn config(e: &Env, config: ConfigData) { - PriceOracleContractBase::config( - e, - &config.admin, - &config.base_asset, - config.decimals, - config.resolution, - config.history_retention_period, - config.cache_size, - &config.retention_config, - config.assets, - INITIAL_EXPIRATION_PERIOD, - ); + PriceOracleContractBase::config(e, config, INITIAL_EXPIRATION_PERIOD); } // Update contract cache size @@ -351,12 +329,8 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_retention_config(e: &Env, retention_config: FeeConfig) { - PriceOracleContractBase::set_retention_config( - e, - retention_config, - INITIAL_EXPIRATION_PERIOD, - ); + 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 @@ -370,7 +344,7 @@ impl PriceOracleContract { // # Panics // // Panics if not authorized or price snapshot record is invalid - pub fn set_price(e: &Env, updates: TimestampPrices, timestamp: u64) { + pub fn set_price(e: &Env, updates: PriceUpdate, timestamp: u64) { PriceOracleContractBase::set_price(e, updates, timestamp); } diff --git a/pulse-contract/src/test.rs b/pulse-contract/src/test.rs deleted file mode 100644 index fe80916..0000000 --- a/pulse-contract/src/test.rs +++ /dev/null @@ -1,523 +0,0 @@ -#![cfg(test)] -extern crate alloc; -extern crate std; - -use alloc::string::ToString; -use shared::prices; -use shared::types::timestamp_prices::TimestampPrices; -use shared::types::{asset::Asset, fee_config::FeeConfig}; -use soroban_sdk::testutils::{Address as _, Events, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}; -use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, Bytes, Env, IntoVal, String, Symbol, TryIntoVal, Vec}; -use std::panic::{self, AssertUnwindSafe}; - -use crate::types::config_data::ConfigData; -use crate::{PriceOracleContract, PriceOracleContractClient}; - -const RESOLUTION: u32 = 300_000; -const DECIMALS: u32 = 14; - -fn convert_to_seconds(timestamp: u64) -> u64 { - timestamp / 1000 -} - -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) = - shared::pos_encoding::locate_update_record_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 get_updates(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - TimestampPrices { - prices: updates, - mask: mask, - } -} - -fn get_random_bool() -> bool { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .subsec_nanos(); - let random_bool = (nanos % 200) == 0; - random_bool -} - -fn get_updates_with_random(env: &Env, assets: &Vec, price: i128) -> TimestampPrices { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - let price = if get_random_bool() { 0 } else { price }; - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - TimestampPrices { - prices: updates, - mask: mask, - } -} - -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_at(contract_id, PriceOracleContract, ()); - let client = PriceOracleContractClient::new(&env, contract_id); - - env.cost_estimate().budget().reset_unlimited(); - - let init_data = 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, - retention_config: FeeConfig::None, - }; - - 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 -} - -#[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, 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(); - - 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); - - assert_eq!( - env.events().all().last().unwrap().1, - ( - symbol_short!("REFLECTOR"), - symbol_short!("update"), - &600_000u64 - ) - .into_val(&env) - ); -} - -#[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_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.cost_estimate().budget().reset_unlimited(); - - 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 prices_update_overflow_test() { - let (env, client, _) = init_contract_with_admin(); - - env.mock_all_auths(); - - env.cost_estimate().budget().reset_unlimited(); - - let mut updates = Vec::new(&env); - for i in 1..=256 { - updates.push_back(normalize_price(i as i128 + 1)); - } - let mask = generate_update_record_mask(&env, &updates); - let update = TimestampPrices { - prices: updates, - mask: mask, - }; - client.set_price(&update, &600_000); -} - -#[test] -fn set_period_test() { - let (env, client, _) = init_contract_with_admin(); - - 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 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_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 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_history_retention_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(|| { - prices::fixed_div_floor(a.clone(), *b, 14) - })); - if expected == &-1 { - assert!(result.is_err()); - } else { - assert_eq!(result.unwrap(), *expected); - } - } -} - -#[test] -fn set_retention_config_test() { - let (env, client, init_data) = init_contract_with_admin(); - - //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 retention_config = FeeConfig::Some((fee_asset.address(), 7)); - - client.set_retention_config(&retention_config); - - let result = client.retention_config(); - assert_ne!(result, FeeConfig::None); - assert_eq!(result, retention_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(); - client.extend_asset_ttl(&sponsor, &asset, &10); - assert_eq!(client.expires(&asset).unwrap(), symbol_expires + 123428571); //123428571 ms you get for 10 XRF tokens - - let fee_token_balance = TokenClient::new(&env, &fee_asset.address()).balance(&sponsor); - assert_eq!(fee_token_balance, 0); -} - -#[test] -fn prices_test() { - let (env, client, init_data) = init_contract_with_admin(); - - let assets = init_data.assets; - - client.set_cache_size(&256); - - let mut history_prices = Vec::new(&env); - - //set more than 255 prices to check history is overritten correctly - for i in 0..257 { - let timestamp = 600_000 + i * 300_000; - - if timestamp != 900_000 && timestamp != 1200_000 { - let updates = get_updates_with_random(&env, &assets, normalize_price(100)); - history_prices.push_front((timestamp, updates.clone())); - //set prices for assets - client.set_price(&updates, ×tamp); - } else { - //simulate time passage without setting prices to create gaps in updates - let updates = get_updates_with_random(&env, &assets, 0); - history_prices.push_front((timestamp, updates.clone())); - } - let ledger_info = env.ledger().get(); - env.ledger().set(LedgerInfo { - timestamp: timestamp / 1000 + 300, - ..ledger_info - }); - } - - let mut had_gaps = false; - let mut had_prices = false; - //verify prices - for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { - if history_index > 255 { - break; - } - let all_prices = prices::get_prices_for_assets(&env, &updates, assets.len() + 10 as u32); //+10 to check that out of range assets are ignored - for (asset_index, asset) in assets.iter().enumerate() { - let price_data = client.price(&asset, &(timestamp / 1000)); - let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); - if expected_price > 0 { - let price = price_data.unwrap(); - assert_eq!(price.price, expected_price); - assert_eq!(price.timestamp, convert_to_seconds(timestamp)); - had_prices = true; - } else { - assert!(price_data.is_none()); - had_gaps = true; - } - } - } - assert!(had_prices); - assert!(had_gaps); -} diff --git a/pulse-contract/src/types/config_data.rs b/pulse-contract/src/types/config_data.rs deleted file mode 100644 index 97673a6..0000000 --- a/pulse-contract/src/types/config_data.rs +++ /dev/null @@ -1,25 +0,0 @@ -use shared::types::{asset::Asset, fee_config::FeeConfig}; -use soroban_sdk::{contracttype, Address, Vec}; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] - -// Contract configuration parameters -pub struct ConfigData { - // Admin address - pub admin: Address, - // Price history retention period - 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 - pub resolution: u32, - // Number of rounds held in instance cache - pub cache_size: u32, - // Contract retention config - pub retention_config: FeeConfig, -} diff --git a/pulse-contract/src/types/mod.rs b/pulse-contract/src/types/mod.rs deleted file mode 100644 index 8ca55cc..0000000 --- a/pulse-contract/src/types/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod config_data; diff --git a/shared/src/pos_encoding.rs b/shared/src/pos_encoding.rs deleted file mode 100644 index 84627a4..0000000 --- a/shared/src/pos_encoding.rs +++ /dev/null @@ -1,51 +0,0 @@ -use soroban_sdk::{Bytes, Env, Vec, U256}; - -const RECORD_SIZE: u32 = 32; - -pub fn update_position_mask(e: &Env, mut mask: Bytes, updates: &Vec) -> Bytes { - let one = U256::from_u32(e, 1); - for (asset_index, price) in updates.iter().enumerate() { - let from = asset_index as u32 * RECORD_SIZE; - let to = from + RECORD_SIZE; - let mut bitmask = if mask.len() >= to { - let encoded = mask.slice(from..to); - U256::from_be_bytes(e, &encoded) - } else { - U256::from_u32(e, 0) - }; - bitmask = bitmask.shl(1); - if price > 0 { - //set bit if price found - bitmask = bitmask.add(&one); - } - let encoded = bitmask.to_be_bytes(); - if mask.len() <= from { - mask.append(&encoded); - } else { - for i in 0..RECORD_SIZE { - mask.set(from + i, encoded.get(i).unwrap()); - } - } - } - mask -} - -pub fn had_update(mask: &Bytes, asset_index: u32, period: u32) -> bool { - let from = asset_index * RECORD_SIZE + (RECORD_SIZE - 1 - period / 8); - let bit = 1 << (period % 8); - let bytemask = mask.get(from).unwrap_or_default(); - bytemask & bit == bit -} - -#[inline] -pub fn locate_update_record_mask_position(asset_index: u32) -> (u32, u8) { - let byte = asset_index / 8; - let bitmask = 1 << (asset_index % 8); - (byte, bitmask) -} - -pub fn check_update_record_mask(mask: &Bytes, asset_index: u32) -> bool { - let (byte, bitmask) = locate_update_record_mask_position(asset_index); - let bytemask = mask.get(byte).unwrap_or_default(); - bytemask & bitmask == bitmask -} diff --git a/shared/src/types/asset.rs b/shared/src/types/asset.rs deleted file mode 100644 index 6bfbfd6..0000000 --- a/shared/src/types/asset.rs +++ /dev/null @@ -1,9 +0,0 @@ -use soroban_sdk::{contracttype, Address, Symbol}; - -#[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 -} diff --git a/shared/src/types/asset_type.rs b/shared/src/types/asset_type.rs deleted file mode 100644 index be3f1aa..0000000 --- a/shared/src/types/asset_type.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[derive(PartialEq)] -#[repr(u8)] -#[allow(dead_code)] -// Type of feed quoted by oracle contract -pub enum AssetType { - Stellar = 1, - Other = 2, -} diff --git a/shared/src/types/error.rs b/shared/src/types/error.rs deleted file mode 100644 index a21cab7..0000000 --- a/shared/src/types/error.rs +++ /dev/null @@ -1,25 +0,0 @@ -use soroban_sdk::contracterror; - -#[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 is already exists in supported assets list - AssetAlreadyExists = 3, - // Config version is invalid - InvalidConfigVersion = 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/shared/src/types/fee_config.rs b/shared/src/types/fee_config.rs deleted file mode 100644 index 0aba7dd..0000000 --- a/shared/src/types/fee_config.rs +++ /dev/null @@ -1,9 +0,0 @@ -use soroban_sdk::{contracttype, Address}; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -// Oracle retention config containing fee asset and daily retention fee amount -pub enum FeeConfig { - Some((Address, i128)), - None, -} diff --git a/shared/src/types/mod.rs b/shared/src/types/mod.rs deleted file mode 100644 index 3663f8c..0000000 --- a/shared/src/types/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod asset; -pub mod asset_type; -pub mod error; -pub mod fee_config; -pub mod price_data; -pub mod timestamp_prices; diff --git a/shared/src/types/price_data.rs b/shared/src/types/price_data.rs deleted file mode 100644 index 16f865d..0000000 --- a/shared/src/types/price_data.rs +++ /dev/null @@ -1,11 +0,0 @@ -use soroban_sdk::contracttype; - -#[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, -} diff --git a/shared/src/types/timestamp_prices.rs b/shared/src/types/timestamp_prices.rs deleted file mode 100644 index 04a15ec..0000000 --- a/shared/src/types/timestamp_prices.rs +++ /dev/null @@ -1,11 +0,0 @@ -use soroban_sdk::{contracttype, Bytes, Vec}; - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -// Asset price data at specific timestamp -pub struct TimestampPrices { - // Prices for assets that have been updated - pub prices: Vec, - // Bitmap of assets that have been updated - pub mask: Bytes, -} From d6991f1fa8964886ef90bfa18e30e8da745602e6 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 19:48:48 +0000 Subject: [PATCH 18/55] Remove unnecessary alloc refs --- beam-contract/src/lib.rs | 2 -- beam-contract/src/tests.rs | 1 - oracle/src/lib.rs | 2 -- oracle/src/tests/contract_interface_tests.rs | 1 - oracle/src/tests/util_tests.rs | 1 - 5 files changed, 7 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 0b3aaef..96959ef 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -1,6 +1,4 @@ #![no_std] -extern crate alloc; - mod cost; mod tests; diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs index 5c61d5f..aad6e31 100644 --- a/beam-contract/src/tests.rs +++ b/beam-contract/src/tests.rs @@ -1,5 +1,4 @@ #![cfg(test)] -extern crate alloc; extern crate std; use crate::cost; diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs index 16bf7b1..22e9adf 100644 --- a/oracle/src/lib.rs +++ b/oracle/src/lib.rs @@ -1,6 +1,4 @@ #![no_std] -extern crate alloc; - pub mod assets; pub mod auth; pub mod events; diff --git a/oracle/src/tests/contract_interface_tests.rs b/oracle/src/tests/contract_interface_tests.rs index 01d6cb6..a7d1ac3 100644 --- a/oracle/src/tests/contract_interface_tests.rs +++ b/oracle/src/tests/contract_interface_tests.rs @@ -1,5 +1,4 @@ #![cfg(test)] -extern crate alloc; extern crate std; use crate::prices; diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index 377c113..1ee21e2 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -1,5 +1,4 @@ #![cfg(test)] -extern crate alloc; extern crate std; use soroban_sdk::{log, Bytes, Env, Vec}; From 7335f9937f74fb1fce29ab2e7eb309dc06bd59f2 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Sat, 18 Oct 2025 22:56:01 +0300 Subject: [PATCH 19/55] remove duplicate contract attributes --- oracle/src/price_oracle.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index 753f13e..0b089a1 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -1,12 +1,10 @@ use crate::types::ConfigData; use crate::types::{Asset, Error, FeeConfig, PriceData, PriceUpdate}; use crate::{assets, auth, events, prices, protocol, settings, timestamps}; -use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, Vec}; +use soroban_sdk::{panic_with_error, Address, BytesN, Env, Vec}; -#[contract] pub struct PriceOracleContractBase; -#[contractimpl] impl PriceOracleContractBase { // Return base asset price is reported in // From f36a4008f2cc672c11089fa3db86d68942aa362e Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 20:44:25 +0000 Subject: [PATCH 20/55] Remove administrative functions from public oracle interface in Readme --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0a09b06..44634bf 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,6 @@ pub trait Contract { fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); // Get asset expiration timestamp fn expires(e: &Env, asset: Asset) -> Option; - // Get retention FeeConfig configuration - fn fee_config(e: &Env) -> FeeConfig; } // Quoted asset definition @@ -238,10 +236,6 @@ pub trait Contract { fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); // Get asset expiration timestamp fn expires(e: &Env, asset: Asset) -> Option; - // Get fee configuration - fn fee_config(e: &Env) -> FeeConfig; - // Get config with invocation costs - fn invocation_costs(e: &Env) -> Vec; } // Quoted asset definition @@ -306,7 +300,7 @@ pub enum Error { 2. Run the tests: ```bash - cargo test --package pulse-contract + cargo test --package reflector_pulse_contract ``` ## Building Contracts @@ -338,7 +332,7 @@ pub enum Error { ``` 2. Run the build command for the specific contract: ```bash - stellar contract build --package pulse-contract + stellar contract build --package reflector-reflector_pulse_contract ``` ### Optimizing WASM From 84bf5dc58d98a85c860e87bcb476d72becc421bb Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 21:19:28 +0000 Subject: [PATCH 21/55] Reorganize tests --- oracle/src/prices.rs | 2 +- oracle/src/tests/mod.rs | 2 - oracle/src/tests/setup_tests.rs | 120 +---------------- pulse-contract/src/lib.rs | 1 + .../src/tests/contract_admin_tests.rs | 23 ++-- .../src/tests/contract_interface_tests.rs | 6 +- pulse-contract/src/tests/mod.rs | 3 + pulse-contract/src/tests/setup_tests.rs | 125 ++++++++++++++++++ 8 files changed, 146 insertions(+), 136 deletions(-) rename {oracle => pulse-contract}/src/tests/contract_admin_tests.rs (92%) rename {oracle => pulse-contract}/src/tests/contract_interface_tests.rs (97%) create mode 100644 pulse-contract/src/tests/mod.rs create mode 100644 pulse-contract/src/tests/setup_tests.rs diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index b47083f..c4dd4c3 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -58,7 +58,7 @@ pub fn retrieve_asset_price_data(e: &Env, asset: u32, timestamp: u64) -> Option< } // Extract prices for all assets from update record -pub(crate) fn extract_update_record_prices(e: &Env, update: &PriceUpdate, total: u32) -> Vec { +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 { diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index 189f7c2..a10b8fc 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -1,5 +1,3 @@ mod setup_tests; -mod contract_admin_tests; -mod contract_interface_tests; mod util_tests; diff --git a/oracle/src/tests/setup_tests.rs b/oracle/src/tests/setup_tests.rs index 3405047..8ec31d7 100644 --- a/oracle/src/tests/setup_tests.rs +++ b/oracle/src/tests/setup_tests.rs @@ -2,125 +2,11 @@ extern crate alloc; extern crate std; -use crate::price_oracle::{PriceOracleContractBase, PriceOracleContractBaseClient}; -use crate::types::{Asset, ConfigData, FeeConfig, PriceUpdate}; +use crate::types::{Asset, PriceUpdate}; use alloc::string::ToString; -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{Address, Bytes, Env, String, Symbol, Vec}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Bytes, Env, Symbol, Vec}; pub(super) const RESOLUTION: u32 = 300_000; pub(super) const DECIMALS: u32 = 14; -pub(super) fn init_contract<'a>() -> (Env, PriceOracleContractBaseClient<'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 contract_id = &Address::from_string(&String::from_str( - &env, - "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", - )); - - env.register_at(contract_id, PriceOracleContractBase, ()); - let client = PriceOracleContractBaseClient::new(&env, contract_id); - - env.cost_estimate().budget().reset_unlimited(); - - env.mock_all_auths(); - let init_data = prepare_contract_config(&env); - let initial_expiration_period = 180u32; - client.config(&init_data, &initial_expiration_period); - - (env, client, init_data) -} - -fn prepare_contract_config(env: &Env) -> ConfigData { - let admin = Address::generate(&env); - 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, - } -} - -pub(super) fn convert_to_seconds(timestamp: u64) -> u64 { - timestamp / 1000 -} - -pub(super) fn normalize_price(price: i128) -> i128 { - price * 10i128.pow(DECIMALS) -} - -pub(super) 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) = - crate::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) -} - -pub(super) fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - PriceUpdate { - prices: updates, - mask: mask, - } -} - -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(super) fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { - let mut updates = Vec::new(&env); - for _ in assets.iter() { - let price = if get_random_bool() { 0 } else { price }; - updates.push_back(price); - } - let mask = generate_update_record_mask(env, &updates); - PriceUpdate { - prices: updates, - mask: mask, - } -} - -pub(super) 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/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index d165f67..d4ddeb9 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -1,4 +1,5 @@ #![no_std] +mod tests; use oracle::price_oracle::PriceOracleContractBase; use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; diff --git a/oracle/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs similarity index 92% rename from oracle/src/tests/contract_admin_tests.rs rename to pulse-contract/src/tests/contract_admin_tests.rs index 12cdbdc..d71bb14 100644 --- a/oracle/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -2,13 +2,11 @@ extern crate alloc; extern crate std; -use crate::types::{Asset, FeeConfig, PriceUpdate}; use alloc::string::ToString; use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; -use test_case::test_case; - +use oracle::types::{Asset, FeeConfig, PriceUpdate}; use crate::tests::setup_tests::{ convert_to_seconds, generate_assets, generate_update_record_mask, generate_updates, init_contract, normalize_price, DECIMALS, RESOLUTION, @@ -113,16 +111,15 @@ fn set_price_future_timestamp_test() { client.set_price(&updates, ×tamp); } -#[test_case(0 ; "with zero expiration period")] -#[test_case(180 ; "with 180d expiration period")] -fn add_assets_test(initial_expiration_period: u32) { +#[test] +fn add_assets_test() { let (env, client, init_data) = init_contract(); let assets = generate_assets(&env, 10, init_data.assets.len() - 1); env.mock_all_auths(); - client.add_assets(&assets, &initial_expiration_period); + client.add_assets(&assets); let result = client.assets(); @@ -146,7 +143,7 @@ fn add_assets_duplicate_test() { env.mock_all_auths(); - client.add_assets(&assets, &0); + client.add_assets(&assets); } #[test] @@ -166,7 +163,7 @@ fn asset_update_overflow_test() { ))); } - client.add_assets(&assets, &0); + client.add_assets(&assets); } #[test] @@ -185,7 +182,7 @@ fn price_update_overflow_test() { let mask = generate_update_record_mask(&env, &updates); let update = PriceUpdate { prices: updates, - mask: mask, + mask, }; client.set_price(&update, &600_000); } @@ -220,7 +217,7 @@ fn set_fee_config_test() { let fee_config = FeeConfig::Some((fee_asset.address(), 7)); - client.set_fee_config(&fee_config, &3); //3 days + client.set_fee_config(&fee_config); //3 days let result = client.fee_config(); assert_ne!(result, FeeConfig::None); @@ -236,8 +233,8 @@ fn set_fee_config_test() { fee_token.mint(&sponsor, &10); let symbol_expires = client.expires(&asset).unwrap(); - assert_eq!(symbol_expires, 260100000); // 900s current ledger timestamp + 3 days off initial expiration period - client.extend_asset_ttl(&sponsor, &asset, &10, &0); + assert_eq!(symbol_expires, 15552900000); // 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); diff --git a/oracle/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs similarity index 97% rename from oracle/src/tests/contract_interface_tests.rs rename to pulse-contract/src/tests/contract_interface_tests.rs index a7d1ac3..3b582f5 100644 --- a/oracle/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -1,11 +1,11 @@ #![cfg(test)] extern crate std; -use crate::prices; use crate::tests::setup_tests::{ convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, }; -use crate::types::FeeConfig; +use oracle::prices; +use oracle::types::FeeConfig; use soroban_sdk::testutils::{Ledger, LedgerInfo}; use soroban_sdk::Vec; @@ -63,7 +63,7 @@ fn price_test() { .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, &180); + client.set_fee_config(&fee_config); //get price for the first asset let price = client diff --git a/pulse-contract/src/tests/mod.rs b/pulse-contract/src/tests/mod.rs new file mode 100644 index 0000000..07d84dc --- /dev/null +++ b/pulse-contract/src/tests/mod.rs @@ -0,0 +1,3 @@ +mod contract_admin_tests; +mod contract_interface_tests; +mod setup_tests; diff --git a/pulse-contract/src/tests/setup_tests.rs b/pulse-contract/src/tests/setup_tests.rs new file mode 100644 index 0000000..d258569 --- /dev/null +++ b/pulse-contract/src/tests/setup_tests.rs @@ -0,0 +1,125 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; + +use crate::{PulseOracleContract, PulseOracleContractClient}; +use alloc::string::ToString; +use oracle::types::{Asset, ConfigData, FeeConfig, PriceUpdate}; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::{Address, Bytes, Env, String, Symbol, Vec}; + +pub(super) const RESOLUTION: u32 = 300_000; +pub(super) const DECIMALS: u32 = 14; + +pub(super) fn init_contract<'a>() -> (Env, PulseOracleContractClient<'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 contract_id = &Address::from_string(&String::from_str( + &env, + "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", + )); + + env.register_at(contract_id, PulseOracleContract, ()); + let client = PulseOracleContractClient::new(&env, contract_id); + + env.cost_estimate().budget().reset_unlimited(); + + env.mock_all_auths(); + let init_data = prepare_contract_config(&env); + client.config(&init_data); + + (env, client, init_data) +} + +fn prepare_contract_config(env: &Env) -> ConfigData { + let admin = Address::generate(&env); + 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, + } +} + +pub(super) fn convert_to_seconds(timestamp: u64) -> u64 { + timestamp / 1000 +} + +pub(super) fn normalize_price(price: i128) -> i128 { + price * 10i128.pow(DECIMALS) +} + +pub(super) 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) = + oracle::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) +} + +pub(super) fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + PriceUpdate { + prices: updates, + mask, + } +} + +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(super) fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { + let mut updates = Vec::new(&env); + for _ in assets.iter() { + let price = if get_random_bool() { 0 } else { price }; + updates.push_back(price); + } + let mask = generate_update_record_mask(env, &updates); + PriceUpdate { + prices: updates, + mask, + } +} + +pub(super) 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 +} From 5cfba52492868059580b536a7245f382b28ca9fc Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 22:47:32 +0000 Subject: [PATCH 22/55] Cleanup tests --- oracle/src/tests/mod.rs | 2 -- oracle/src/tests/setup_tests.rs | 12 ------------ pulse-contract/src/tests/contract_admin_tests.rs | 10 +++++----- 3 files changed, 5 insertions(+), 19 deletions(-) delete mode 100644 oracle/src/tests/setup_tests.rs diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index a10b8fc..16907c5 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -1,3 +1 @@ -mod setup_tests; - mod util_tests; diff --git a/oracle/src/tests/setup_tests.rs b/oracle/src/tests/setup_tests.rs deleted file mode 100644 index 8ec31d7..0000000 --- a/oracle/src/tests/setup_tests.rs +++ /dev/null @@ -1,12 +0,0 @@ -#![cfg(test)] -extern crate alloc; -extern crate std; - -use crate::types::{Asset, PriceUpdate}; -use alloc::string::ToString; -use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Bytes, Env, Symbol, Vec}; - -pub(super) const RESOLUTION: u32 = 300_000; -pub(super) const DECIMALS: u32 = 14; - diff --git a/pulse-contract/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs index d71bb14..00473d7 100644 --- a/pulse-contract/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -2,15 +2,15 @@ extern crate alloc; extern crate std; -use alloc::string::ToString; -use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; -use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; -use oracle::types::{Asset, FeeConfig, PriceUpdate}; use crate::tests::setup_tests::{ convert_to_seconds, generate_assets, generate_update_record_mask, generate_updates, init_contract, normalize_price, DECIMALS, RESOLUTION, }; +use alloc::string::ToString; +use oracle::types::{Asset, FeeConfig, PriceUpdate}; +use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; #[test] fn init_test() { From e66998ff58934ad69782a26a19c0806652c63e12 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 22:49:29 +0000 Subject: [PATCH 23/55] Prettify cost config storage logic --- beam-contract/src/cost.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/beam-contract/src/cost.rs b/beam-contract/src/cost.rs index 1d53007..84e9ac1 100644 --- a/beam-contract/src/cost.rs +++ b/beam-contract/src/cost.rs @@ -6,11 +6,13 @@ const COST_CONFIG_KEY: &str = "cost"; const SCALE: u64 = 10_000_000; pub enum InvocationComplexity { - Price = 0, - Twap = 1, - CrossPrice = 2, - CrossTwap = 3, + NModifier = 0, + Price = 1, + Twap = 2, + CrossPrice = 3, + CrossTwap = 4, } +//invocation cost config is stored as vector with indexes corresponding to InvocationComplexity // Update invocation costs config #[inline] @@ -25,7 +27,7 @@ pub fn load_costs_config(e: &Env) -> Vec { .get(&COST_CONFIG_KEY) .unwrap_or_else(|| { Vec::from_array( - e, + e, // RecordsModifier, Price, Twap, CrossPrice, CrossTwap [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], ) }) @@ -61,14 +63,15 @@ pub fn estimate_invocation_cost( periods: u32, ) -> u64 { //resolve base cost based on the invocation type - let i = invocation as u32 + 1; - let mut cost = costs.get(i).unwrap_or_default(); + let mut cost = costs.get(invocation as u32).unwrap_or_default(); if cost < 1 { return 0; } //charge additional per each loaded period if periods > 1 { - let period_cost = costs.get(0).unwrap_or_default(); + let period_cost = costs + .get(InvocationComplexity::NModifier as u32) + .unwrap_or_default(); if period_cost > 0 { cost = mul_scaled(cost, SCALE + (periods - 1) as u64 * period_cost); } From 3a50244217d2786e28badaf215b064f3ec333d0b Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sat, 18 Oct 2025 23:39:30 +0000 Subject: [PATCH 24/55] Expose estimate_cost() interface method --- README.md | 15 ++++++- beam-contract/src/cost.rs | 81 +++++++++++++++++++++----------------- beam-contract/src/lib.rs | 13 +++++- beam-contract/src/tests.rs | 17 +++++--- 4 files changed, 83 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 44634bf..655c272 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ impl MyAwesomeContract { ### Interface for Beam contract -#### Copy and save it in your smart contract project as "reflector_beam.rs" file. This is the oracle client.. +#### Copy and save it in your smart contract project as "reflector_beam.rs" file. This is the oracle client. ```rust /* reflector.rs */ @@ -236,6 +236,8 @@ pub trait Contract { fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); // Get asset expiration timestamp fn expires(e: &Env, asset: Asset) -> Option; + // Estimate invocation cost based on its complexity + fn estimate_cost(e: &Env, invocation: InvocationComplexity, rounds: u32) -> i128; } // Quoted asset definition @@ -254,6 +256,17 @@ pub struct PriceData { pub timestamp: u64 // record timestamp } +// Invocation complexity factor +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InvocationComplexity { + NModifier = 0, //multiplier for number of requested periods, not utilized directly for cost calculation + Price = 1, //single asset price record request + Twap = 2, //TWAP approximation over N records + CrossPrice = 3, //cross-price calculation for two assets + CrossTwap = 4, //TWAP approximation over N records for cross-price quote +} + // Possible runtime errors #[soroban_sdk::contracterror(export = false)] #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] diff --git a/beam-contract/src/cost.rs b/beam-contract/src/cost.rs index 84e9ac1..24d6912 100644 --- a/beam-contract/src/cost.rs +++ b/beam-contract/src/cost.rs @@ -1,15 +1,22 @@ use oracle::settings; use oracle::types::FeeConfig; -use soroban_sdk::{token, Address, Env, Vec}; +use soroban_sdk::{contracttype, token, Address, Env, Vec}; const COST_CONFIG_KEY: &str = "cost"; -const SCALE: u64 = 10_000_000; +const SCALE: i128 = 10_000_000; +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum InvocationComplexity { + //Multiplicator for number of requested periods, not utilized directly for cost calculation NModifier = 0, + //Single asset price record request Price = 1, + //TWAP approximation over N records Twap = 2, + //Cross-price calculation for two assets CrossPrice = 3, + //TWAP approximation over N records for cross-price quote CrossTwap = 4, } //invocation cost config is stored as vector with indexes corresponding to InvocationComplexity @@ -38,49 +45,51 @@ pub fn charge_invocation_fee( e: &Env, caller: &Address, invocation: InvocationComplexity, - rounds: u32, + periods: u32, ) { + //load fee config let fee_config = settings::get_fee_config(e); - match fee_config { - FeeConfig::None => return, - FeeConfig::Some((fee_token, _)) => { - //load rates - let costs = load_costs_config(e); - //calculate amount to charge - let cost = estimate_invocation_cost(costs, invocation, rounds) as i128; - //init fee token client - let fee_client = token::Client::new(e, &fee_token); - //burn tokens - fee_client.burn(caller, &cost); + if let FeeConfig::Some((fee_token, _)) = fee_config.clone() { + //calculate amount to charge + let cost = estimate_invocation_cost(e, invocation, 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); } } -// Calculate invocation cost based on its complexity +// Estimate invocation cost based on its complexity and fee config pub fn estimate_invocation_cost( - costs: Vec, + e: &Env, invocation: InvocationComplexity, periods: u32, -) -> u64 { - //resolve base cost based on the invocation type - let mut cost = costs.get(invocation as u32).unwrap_or_default(); - if cost < 1 { - return 0; - } - //charge additional per each loaded period - if periods > 1 { - let period_cost = costs - .get(InvocationComplexity::NModifier as u32) - .unwrap_or_default(); - if period_cost > 0 { - cost = mul_scaled(cost, SCALE + (periods - 1) as u64 * period_cost); + 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(invocation as u32).unwrap_or_default() as i128; + if cost < 1 { + return 0; + } + //charge additional per each loaded period + if periods > 1 { + let period_modifier = costs + .get(InvocationComplexity::NModifier as u32) + .unwrap_or_default() as i128; + if period_modifier > 0 { + cost = cost * (SCALE + (periods - 1) as i128 * period_modifier) / SCALE; + } + } + cost } } - cost -} - -// Multiply two scaled values -#[inline(always)] -fn mul_scaled(value: u64, factor: u64) -> u64 { - value * factor / SCALE } diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 96959ef..8463fe7 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -4,6 +4,7 @@ mod tests; use cost::{charge_invocation_fee, load_costs_config, set_costs_config, InvocationComplexity}; use oracle::price_oracle::PriceOracleContractBase; +use oracle::settings; use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; @@ -129,11 +130,21 @@ impl BeamOracleContract { // // # Returns // - // invocation costs config + // Invocation costs categorized by complexity pub fn invocation_costs(e: &Env) -> Vec { load_costs_config(e) } + // Estimate invocation cost based on its complexity + // + // # Returns + // + // Amount of fee tokens required to pay for the call + pub fn estimate_cost(e: &Env, invocation: InvocationComplexity, rounds: u32) -> i128 { + let fee_config = settings::get_fee_config(e); + cost::estimate_invocation_cost(e, invocation, rounds, fee_config) + } + // Return contract admin address // // # Returns diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs index aad6e31..e22b44c 100644 --- a/beam-contract/src/tests.rs +++ b/beam-contract/src/tests.rs @@ -1,7 +1,6 @@ #![cfg(test)] extern crate std; -use crate::cost; use crate::cost::InvocationComplexity; use crate::{BeamOracleContract, BeamOracleContractClient}; use oracle::types::{Asset, ConfigData, FeeConfig}; @@ -105,14 +104,22 @@ fn invocation_charge_test() { #[test_case(InvocationComplexity::CrossTwap, 7, 66_000_000 ; "multi round cross twap")] fn invocation_charge_estimate_test( invocation: InvocationComplexity, - rounds: u32, - expected_fee: u64, + periods: u32, + expected_fee: i128, ) { - let env = Env::default(); + let (env, client, init_data) = init_contract_with_admin(); + + 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); let costs = Vec::from_array( &env, [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], ); - let fee = cost::estimate_invocation_cost(costs, invocation, rounds); + client.set_invocation_costs_config(&costs); + + let fee = client.estimate_cost(&invocation, &periods); assert_eq!(fee, expected_fee); } From 59aab357f78f27a083dd50a5c6188af78ad0fba7 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Sun, 19 Oct 2025 19:58:55 +0300 Subject: [PATCH 25/55] add auth example for Beam contract --- README.md | 104 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 655c272..92f145a 100644 --- a/README.md +++ b/README.md @@ -67,47 +67,47 @@ impl MyAwesomeContract { ```rust /* reflector.rs */ -use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, Symbol, Vec}; // Oracle contract interface exported as ReflectorClient #[soroban_sdk::contractclient(name = "ReflectorClient")] pub trait Contract { // Base oracle symbol the price is reported in - fn base(e: Env) -> Asset; + fn base() -> Asset; // All assets quoted by the contract - fn assets(e: Env) -> Vec; + fn assets() -> Vec; // Number of decimal places used to represent price for all assets quoted by the oracle - fn decimals(e: Env) -> u32; + fn decimals() -> u32; // Quotes asset price in base asset at specific timestamp - fn price(e: Env, asset: Asset, timestamp: u64) -> Option; + fn price(asset: Asset, timestamp: u64) -> Option; // Quotes the most recent price for an asset - fn lastprice(e: Env, asset: Asset) -> Option; + fn lastprice(asset: Asset) -> Option; // Quotes last N price records for the given asset - fn prices(e: Env, asset: Asset, records: u32) -> Option>; + fn prices(asset: Asset, records: u32) -> Option>; // Quotes the most recent cross price record for the pair of assets - fn x_last_price(e: Env, base_asset: Asset, quote_asset: Asset) -> Option; + fn x_last_price(base_asset: Asset, quote_asset: Asset) -> Option; // Quotes the cross price for the pair of assets at specific timestamp - fn x_price(e: Env, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; + fn x_price(base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; // Quotes last N cross price records of for the pair of assets - fn x_prices(e: Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; + fn x_prices(base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; // Quotes the time-weighted average price for the given asset over N recent records - fn twap(e: Env, asset: Asset, records: u32) -> Option; + fn twap(asset: Asset, records: u32) -> Option; // Quotes the time-weighted average cross price for the given asset pair over N recent records - fn x_twap(e: Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option; + fn x_twap(base_asset: Asset, quote_asset: Asset, records: u32) -> Option; // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) - fn resolution(e: Env) -> u32; + fn resolution() -> u32; // Historical records retention period, in seconds (24 hours by default) - fn history_retention_period(e: Env) -> Option; + fn history_retention_period() -> Option; // The most recent price update timestamp - fn last_timestamp(e: Env) -> u64; + fn last_timestamp() -> u64; // Contract version - fn version(e: Env) -> u32; + fn version() -> u32; // Contract admin address - fn admin(e: Env) -> Option
; + fn admin() -> Option
; // Extend asset TTL (time-to-live) in the contract storage - fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); + fn extend_asset_ttl(sponsor: Address, asset: Asset); // Get asset expiration timestamp - fn expires(e: &Env, asset: Asset) -> Option; + fn expires(asset: Asset) -> Option; } // Quoted asset definition @@ -151,7 +151,7 @@ pub enum Error { ```rust /* contract.rs */ use crate::reflector::{ReflectorClient, Asset as ReflectorAsset}; // Import Reflector interface -use soroban_sdk::{contract, contractimpl, Address, Env, String, Symbol}; +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] @@ -163,6 +163,30 @@ impl MyAwesomeContract { let reflector_client = ReflectorClient::new(&e, &oracle_address); // Ticker to lookup the price let ticker = ReflectorAsset::Other(Symbol::new(&e, &("BTC"))); + // Add fee payment auth + match reflector_client.fee_config() { + FeeConfig::Some((fee_token, _)) => { + let cost = reflector_client.estimate_cost(&InvocationComplexity::Price, &1); + let invocation = InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: fee_token.clone(), + fn_name: symbol_short!("burn"), + args: Vec::from_array( + &env, + [ + env.current_contract_address().to_val(), + cost.into_val(&env), + ], + ), + }, + sub_invocations: Vec::new(&env), + }); + + env.authorize_as_current_contract(Vec::from_array(&env, [invocation])); + }, + FeeConfig::None => {}, + }; + // Fetch the most recent price record for it let recent = reflector_client.lastprice(&env.current_contract_address(), &ticker); // Check the result @@ -195,49 +219,49 @@ impl MyAwesomeContract { ```rust /* reflector.rs */ -use soroban_sdk::{contracttype, Address, Env, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, Symbol, Vec}; // Oracle contract interface exported as ReflectorClient #[soroban_sdk::contractclient(name = "ReflectorClient")] pub trait Contract { // Base oracle symbol the price is reported in - fn base(e: Env) -> Asset; + fn base() -> Asset; // All assets quoted by the contract - fn assets(e: Env) -> Vec; + fn assets() -> Vec; // Number of decimal places used to represent price for all assets quoted by the oracle - fn decimals(e: Env) -> u32; + fn decimals() -> u32; // Quotes asset price in base asset at specific timestamp - fn price(e: Env, caller: Address, asset: Asset, timestamp: u64) -> Option; + fn price(caller: Address, asset: Asset, timestamp: u64) -> Option; // Quotes the most recent price for an asset - fn lastprice(e: Env, caller: Address, asset: Asset) -> Option; + fn lastprice(caller: Address, asset: Asset) -> Option; // Quotes last N price records for the given asset - fn prices(e: Env, caller: Address, asset: Asset, records: u32) -> Option>; + fn prices(caller: Address, asset: Asset, records: u32) -> Option>; // Quotes the most recent cross price record for the pair of assets - fn x_last_price(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset) -> Option; + fn x_last_price(caller: Address, base_asset: Asset, quote_asset: Asset) -> Option; // Quotes the cross price for the pair of assets at specific timestamp - fn x_price(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; + fn x_price(caller: Address, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; // Quotes last N cross price records of for the pair of assets - fn x_prices(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; + fn x_prices(caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; // Quotes the time-weighted average price for the given asset over N recent records - fn twap(e: Env, caller: Address, asset: Asset, records: u32) -> Option; + fn twap(caller: Address, asset: Asset, records: u32) -> Option; // Quotes the time-weighted average cross price for the given asset pair over N recent records - fn x_twap(e: Env, caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option; + fn x_twap(caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option; // Price feed resolution (default tick period timeframe, in seconds - 5 minutes by default) - fn resolution(e: Env) -> u32; + fn resolution() -> u32; // Historical records retention period, in seconds (24 hours by default) - fn history_retention_period(e: Env) -> Option; + fn history_retention_period() -> Option; // The most recent price update timestamp - fn last_timestamp(e: Env) -> u64; + fn last_timestamp() -> u64; // Contract version - fn version(e: Env) -> u32; + fn version() -> u32; // Contract admin address - fn admin(e: Env) -> Option
; + fn admin() -> Option
; // Extend asset TTL (time-to-live) in the contract storage - fn extend_asset_ttl(e: Env, sponsor: Address, asset: Asset); + fn extend_asset_ttl(sponsor: Address, asset: Asset); // Get asset expiration timestamp - fn expires(e: &Env, asset: Asset) -> Option; + fn expires(asset: Asset) -> Option; // Estimate invocation cost based on its complexity - fn estimate_cost(e: &Env, invocation: InvocationComplexity, rounds: u32) -> i128; + fn estimate_cost(invocation: InvocationComplexity, rounds: u32) -> i128; } // Quoted asset definition From 9fe672af2f27dd3e54209b3402fda7f961bf222b Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 20 Oct 2025 01:29:59 +0000 Subject: [PATCH 26/55] Cleanup oracle interface --- beam-contract/src/lib.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 8463fe7..1c03759 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -102,7 +102,7 @@ impl BeamOracleContract { PriceOracleContractBase::expires(e, asset) } - // Extends the asset expiration date by a given amount of tokens. + // Extends asset expiration date by a given amount of tokens. // // # Arguments // @@ -112,12 +112,12 @@ impl BeamOracleContract { // // # Panics // - // Panics if the asset is not supported or if retention config is malformed/missing + // 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) { PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, 0); } - // Return the fee token address daily price feed retainer fee amount + // Return fee token address daily price feed retainer fee amount // // # Returns // @@ -137,12 +137,17 @@ impl BeamOracleContract { // Estimate invocation cost based on its complexity // + // # Arguments + // + // * `invocation` - Invocation type (single price check, cross-price, TWAP, etc.) + // * `periods` - Number of requested history periods + // // # Returns // - // Amount of fee tokens required to pay for the call - pub fn estimate_cost(e: &Env, invocation: InvocationComplexity, rounds: u32) -> i128 { + // Amount of fee tokens required to pay for invocation + pub fn estimate_cost(e: &Env, invocation: InvocationComplexity, periods: u32) -> i128 { let fee_config = settings::get_fee_config(e); - cost::estimate_invocation_cost(e, invocation, rounds, fee_config) + cost::estimate_invocation_cost(e, invocation, periods, fee_config) } // Return contract admin address From 5f89540d919342094cc062d6df52cdf54d6d0e76 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 20 Oct 2025 01:33:57 +0000 Subject: [PATCH 27/55] Update Readme, include general project info and refine dev-related section --- README.md | 285 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 162 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 92f145a..8903479 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,97 @@ -# Reflector oracle smart contracts +# 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 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 minutes 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 implementation are compatible with [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. -### Pulse contract +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, use cross-price calculation, utilize TWAP averaging, 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") -The Pulse contract provides free access to the latest asset prices and time-weighted average prices (TWAP) for on-chain applications. +## DAO -### Beam contract +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. -The Beam contract is designed for faster updates of the prices, and charges a fee for each price query. +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. -## Usage example +Each of DAO members have an 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. -### **Pulse contract** +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. -### Invocation from consumer contract +## Usage -#### Utilize this example to invoke oracles from your contract code. +### ReflectorPulse Contract + +Utilize this example to invoke oracles functions from your contract code. ```rust /* contract.rs */ -use crate::reflector::{ReflectorClient, Asset as ReflectorAsset}; // Import Reflector interface +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 = ReflectorClient::new(&e, &oracle_address); + 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 @@ -47,30 +106,31 @@ impl MyAwesomeContract { // (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 } } ``` -### Interface for Pulse contract +#### Pulse contract interface -#### Copy and save it in your smart contract project as "reflector_pulse.rs" file. This is the oracle client.. +Copy and save it in your smart contract project as "reflector_pulse.rs" file. +This is the oracle client interface definition. ```rust -/* reflector.rs */ +/* reflector_pulse.rs */ use soroban_sdk::{contracttype, Address, Symbol, Vec}; -// Oracle contract interface exported as ReflectorClient -#[soroban_sdk::contractclient(name = "ReflectorClient")] +// 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; @@ -141,54 +201,31 @@ pub enum Error { InvalidPricesUpdate = 8 } ``` +### ReflectorBeam contract -### **Beam contract** - -### Invocation from consumer contract - -#### Utilize this example to invoke oracles from your contract code. +Utilize this example to invoke oracle functions from your contract code. ```rust /* contract.rs */ -use crate::reflector::{ReflectorClient, Asset as ReflectorAsset}; // Import Reflector interface +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 = ReflectorClient::new(&e, &oracle_address); + let reflector_client = ReflectorBeamClient::new(&e, &oracle_address); + // Authorize XRF fee charge for lastprice() invocation + authorize_spend(&e, &reflector_client, &InvocationComplexity::Price, 1); // Ticker to lookup the price let ticker = ReflectorAsset::Other(Symbol::new(&e, &("BTC"))); - // Add fee payment auth - match reflector_client.fee_config() { - FeeConfig::Some((fee_token, _)) => { - let cost = reflector_client.estimate_cost(&InvocationComplexity::Price, &1); - let invocation = InvokerContractAuthEntry::Contract(SubContractInvocation { - context: ContractContext { - contract: fee_token.clone(), - fn_name: symbol_short!("burn"), - args: Vec::from_array( - &env, - [ - env.current_contract_address().to_val(), - cost.into_val(&env), - ], - ), - }, - sub_invocations: Vec::new(&env), - }); - - env.authorize_as_current_contract(Vec::from_array(&env, [invocation])); - }, - FeeConfig::None => {}, - }; - // Fetch the most recent price record for it - let recent = reflector_client.lastprice(&env.current_contract_address(), &ticker); + let recent = reflector_client.lastprice(&e.current_contract_address(), &ticker); // Check the result if recent.is_none() { //panic_with_error!(&e, "price not available"); @@ -199,30 +236,56 @@ impl MyAwesomeContract { // (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, complexity: &InvocationComplexity, periods: u32) { + // How much will it cost + let cost = reflector_client.estimate_cost(&complexity, &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])); +} ``` -### Interface for Beam contract +#### Beam contract interface -#### Copy and save it in your smart contract project as "reflector_beam.rs" file. This is the oracle client. +Copy and save it in your smart contract project as "reflector_beam.rs" file. +This is the oracle client interface definition. ```rust -/* reflector.rs */ +/* reflector_beam.rs */ use soroban_sdk::{contracttype, Address, Symbol, Vec}; -// Oracle contract interface exported as ReflectorClient -#[soroban_sdk::contractclient(name = "ReflectorClient")] +// 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; @@ -261,7 +324,7 @@ pub trait Contract { // Get asset expiration timestamp fn expires(asset: Asset) -> Option; // Estimate invocation cost based on its complexity - fn estimate_cost(invocation: InvocationComplexity, rounds: u32) -> i128; + fn estimate_cost(invocation: InvocationComplexity, periods: u32) -> i128; } // Quoted asset definition @@ -283,12 +346,12 @@ pub struct PriceData { // Invocation complexity factor #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub enum InvocationComplexity { - NModifier = 0, //multiplier for number of requested periods, not utilized directly for cost calculation - Price = 1, //single asset price record request - Twap = 2, //TWAP approximation over N records - CrossPrice = 3, //cross-price calculation for two assets - CrossTwap = 4, //TWAP approximation over N records for cross-price quote +pub enum InvocationComplexity { + NModifier = 0, // multiplier for number of requested periods, not utilized directly for cost calculation + Price = 1, // single asset price record request + Twap = 2, // TWAP approximation over N records + CrossPrice = 3, // cross-price calculation for two assets + CrossTwap = 4, // TWAP approximation over N records for cross-price quote } // Possible runtime errors @@ -307,77 +370,53 @@ pub enum Error { } ``` -## Testing Contracts +## Development ### Prerequisites - 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)) -### Running All Tests - -1. Navigate to the root directory of the project: - - ```bash - cd ./reflector-contract - ``` -2. Run the tests: - - ```bash - cargo test - ``` - -### Running Specific Contract Tests - -1. Navigate to the directory of the contract: - - ```bash - cd ./reflector-contract - ``` +### Compilation -2. Run the tests: +Run `stellar contract build` commands from the project root directory. It will compile all contracts included into the +workspace: - ```bash - cargo test --package reflector_pulse_contract - ``` +```shell +stellar contract build +``` -## Building Contracts +To compile a specific contract, run `stellar contract build` command with the `--package` argument from the project root +directory: -### Prerequisites - -- 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)) +```shell +stellar contract build --package reflector-pulse-contract +stellar contract build --package reflector-beam-contract +``` -### Building All Contracts +#### Optimizing WASM -1. Navigate to the directory of the contract: +Use `stellar contract optimize` CLI command to reduce the contract WASM binary size before deployment. +For example: - ```bash - cd ./reflector-contract - ``` - -2. Run the build command: - ```bash - stellar contract build - ``` +```shell +stellar contract optimize --wasm ./target/wasm32v1-none/release/reflector_pulse_contract.wasm +``` -### Building Specific Contract +This command will generate an optimized WASM file at +`./target/wasm32v1-none/release/reflector_pulse_contract.optimized.wasm`. -1. Navigate to the directory of the contract: +### Testing - ```bash - cd ./reflector-contract - ``` -2. Run the build command for the specific contract: - ```bash - stellar contract build --package reflector-reflector_pulse_contract - ``` +In order to run all workspace tests, execute `cargo test` from the project root directory: -### Optimizing WASM +```shell +cargo test +``` -1. Run stellar optimize command: - ```bash - stellar contract optimize --wasm ./target/wasm32v1-none/release/pulse_contract.wasm - ``` -This will generate an optimized WASM file at `./target/wasm32v1-none/release/pulse_contract.optimized.wasm`. +Or for a specific contract: -**Note**: Make sure to replace `pulse_contract.wasm` with the actual name of the contract you are optimizing. Also, replace the path if your build output directory is different. \ No newline at end of file +```shell +cargo test --package reflector-pulse-contract +cargo test --package reflector-beam-contract +``` From a3ea100fcdac7a217f16b5e9051d1c57c7aa6e2a Mon Sep 17 00:00:00 2001 From: orbitlens Date: Wed, 19 Nov 2025 11:33:43 -0100 Subject: [PATCH 28/55] Replace panic! in fixed_div_floor with Option --- oracle/src/prices.rs | 15 ++++++++------- oracle/src/tests/util_tests.rs | 7 ++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index c4dd4c3..5af96af 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -278,10 +278,11 @@ pub fn load_cross_price( let quote_asset_price = retrieve_asset_price_data(e, quote_asset, timestamp)?; //calculate the cross price - Some(normalize_price_data( - fixed_div_floor(base_asset_price.price, quote_asset_price.price, decimals), - timestamp, - )) + let price = fixed_div_floor(base_asset_price.price, quote_asset_price.price, decimals); + if price.is_none() { + return None; + } + Some(normalize_price_data(price.unwrap(), timestamp)) } // Get cached records from the instance storage @@ -325,9 +326,9 @@ fn format_price_key_v1(asset: u8, timestamp: u64) -> u128 { } // Div+floor with a specified precision -pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> i128 { +pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> Option { if dividend <= 0 || divisor <= 0 { - panic!("invalid division arguments") + return None; } let ashift = core::cmp::min(38 - dividend.ilog10(), decimals); let bshift = core::cmp::max(decimals - ashift, 0); @@ -340,5 +341,5 @@ pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> i128 { if bshift > 0 { vdivisor /= 10_i128.pow(bshift); } - vdividend / vdivisor + Some(vdividend / vdivisor) } diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index 1ee21e2..20bd040 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -4,7 +4,6 @@ extern crate std; use soroban_sdk::{log, Bytes, Env, Vec}; use crate::{mapping, prices}; -use std::panic::{self, AssertUnwindSafe}; fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { let mut mask = [0u8; 32]; @@ -39,11 +38,9 @@ fn fixed_div_floor_tests() { ]; for (a, b, expected) in test_cases.iter() { - let result = panic::catch_unwind(AssertUnwindSafe(|| { - prices::fixed_div_floor(a.clone(), *b, 14) - })); + let result = prices::fixed_div_floor(a.clone(), *b, 14); if expected == &-1 { - assert!(result.is_err()); + assert!(result.is_none()); } else { assert_eq!(result.unwrap(), *expected); } From c02055811978e13b502dbce622f9ae9b29edd3bb Mon Sep 17 00:00:00 2001 From: orbitlens Date: Wed, 19 Nov 2025 11:45:29 -0100 Subject: [PATCH 29/55] Handle `vdivisor` == 0 case --- oracle/src/prices.rs | 3 +++ oracle/src/tests/util_tests.rs | 42 ++++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 5af96af..ad3c857 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -341,5 +341,8 @@ pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> Option 0 { vdivisor /= 10_i128.pow(bshift); } + if vdivisor <= 0 { + return None; + } Some(vdividend / vdivisor) } diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index 20bd040..b77ff67 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -19,31 +19,39 @@ fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { } #[test] -fn fixed_div_floor_tests() { +fn fixed_div_floor_failed_tests() { let test_cases = [ - (154467226919499, 133928752749774, 115335373284703), + (1, 0, 14), + (0, 1, 14), + (0, 0, 14), + (-1, 0, 14), + (0, -1, 14), + (-1, -1, 14), + (1000000000000000000000, 5, 18), + ]; + + for (a, b, decimals) in test_cases.iter() { + let result = prices::fixed_div_floor(a.clone(), *b, *decimals); + assert!(result.is_none()); + } +} + +#[test] +fn fixed_div_floor_success_tests() { + let test_cases = [ + (154467226919499, 133928752749774, 14, 115335373284703), ( i128::MAX / 100, 231731687303715884105728, + 14, 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), + (231731687303715884105728, i128::MAX / 100, 14, 13), ]; - for (a, b, expected) in test_cases.iter() { - let result = prices::fixed_div_floor(a.clone(), *b, 14); - if expected == &-1 { - assert!(result.is_none()); - } else { - assert_eq!(result.unwrap(), *expected); - } + for (a, b, decimals, expected) in test_cases.iter() { + let result = prices::fixed_div_floor(a.clone(), *b, *decimals); + assert_eq!(result.unwrap(), *expected); } } From 12908c60ac34a8f8c58ffb9c4406b245244ec0bd Mon Sep 17 00:00:00 2001 From: orbitlens Date: Wed, 19 Nov 2025 13:55:14 -0100 Subject: [PATCH 30/55] Reorganize fixed_div_floor tests --- oracle/src/tests/util_tests.rs | 52 +++++++++++++--------------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index b77ff67..a7bba54 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -2,6 +2,7 @@ extern crate std; use soroban_sdk::{log, Bytes, Env, Vec}; +use test_case::test_case; use crate::{mapping, prices}; @@ -18,41 +19,26 @@ fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { Bytes::from_array(e, &mask) } -#[test] -fn fixed_div_floor_failed_tests() { - let test_cases = [ - (1, 0, 14), - (0, 1, 14), - (0, 0, 14), - (-1, 0, 14), - (0, -1, 14), - (-1, -1, 14), - (1000000000000000000000, 5, 18), - ]; - - for (a, b, decimals) in test_cases.iter() { - let result = prices::fixed_div_floor(a.clone(), *b, *decimals); - assert!(result.is_none()); - } +#[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(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] -fn fixed_div_floor_success_tests() { - let test_cases = [ - (154467226919499, 133928752749774, 14, 115335373284703), - ( - i128::MAX / 100, - 231731687303715884105728, - 14, - 734216306110962248249052545, - ), - (231731687303715884105728, i128::MAX / 100, 14, 13), - ]; - - for (a, b, decimals, expected) in test_cases.iter() { - let result = prices::fixed_div_floor(a.clone(), *b, *decimals); - assert_eq!(result.unwrap(), *expected); - } +#[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] From c7bf0fe1d369fe511f50d6f9ca5974d3b5868dde Mon Sep 17 00:00:00 2001 From: orbitlens Date: Wed, 19 Nov 2025 19:14:33 -0100 Subject: [PATCH 31/55] Handle potential dividend shift overflow --- oracle/src/prices.rs | 6 +++++- oracle/src/tests/util_tests.rs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index ad3c857..2ca81d6 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -336,7 +336,11 @@ pub fn fixed_div_floor(dividend: i128, divisor: i128, decimals: u32) -> Option 0 { - vdividend *= 10_i128.pow(ashift); + 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); diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index a7bba54..6c020ee 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -26,6 +26,7 @@ fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { #[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); From 1987386002c8a9a26f7c8a5cc196e968ce2d8b9b Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 24 Nov 2025 12:21:35 -0100 Subject: [PATCH 32/55] Replace hardcoded period `1` with `records` argument in `twap()` --- beam-contract/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 1c03759..aa5e360 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -292,7 +292,7 @@ impl BeamOracleContract { // TWAP for the given asset over N recent records or None if asset is not supported pub fn twap(e: &Env, caller: Address, asset: Asset, records: u32) -> Option { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::Twap, 1); + charge_invocation_fee(e, &caller, InvocationComplexity::Twap, records); PriceOracleContractBase::twap(e, asset, records) } From 342f776417f87b15543ef789ecb203680e0814d1 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 24 Nov 2025 12:27:25 -0100 Subject: [PATCH 33/55] Fix missing admin authentication request in `set_invocation_costs_config()` --- beam-contract/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 1c03759..26697cb 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -4,7 +4,7 @@ mod tests; use cost::{charge_invocation_fee, load_costs_config, set_costs_config, InvocationComplexity}; use oracle::price_oracle::PriceOracleContractBase; -use oracle::settings; +use oracle::{auth, settings}; use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; @@ -401,7 +401,8 @@ impl BeamOracleContract { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_invocation_costs_config(e: &Env, config: Vec) { + pub fn set_invocation_costs_config(e: &Env, config: Vec) { + auth::panic_if_not_admin(e); set_costs_config(e, &config); } From 45a6ea0322db9bf2d0d31426ee948e908cff0367 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 24 Nov 2025 12:29:48 -0100 Subject: [PATCH 34/55] Fix formatting --- beam-contract/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 26697cb..d9fcbb0 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -4,8 +4,8 @@ mod tests; use cost::{charge_invocation_fee, load_costs_config, set_costs_config, InvocationComplexity}; use oracle::price_oracle::PriceOracleContractBase; -use oracle::{auth, settings}; use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; +use oracle::{auth, settings}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; #[contract] @@ -401,7 +401,7 @@ impl BeamOracleContract { // # Panics // // Panics if not authorized or not initialized yet - pub fn set_invocation_costs_config(e: &Env, config: Vec) { + pub fn set_invocation_costs_config(e: &Env, config: Vec) { auth::panic_if_not_admin(e); set_costs_config(e, &config); } From 0e6207717bb6df82a4802e72d752a1174c50fcce Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 24 Nov 2025 13:34:58 -0100 Subject: [PATCH 35/55] Ensure that fee config is never set to `None` (prevents turning off fees) --- oracle/src/assets.rs | 4 ++-- oracle/src/settings.rs | 3 +++ oracle/src/types.rs | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 4c73a2a..22e79fd 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -127,12 +127,12 @@ pub fn extend_ttl( let (xrf, fee) = match settings::get_fee_config(e) { FeeConfig::Some(fee_data) => { if fee_data.1 <= 0 { - e.panic_with_error(Error::InvalidConfigVersion); + e.panic_with_error(Error::InvalidConfig); } fee_data } FeeConfig::None => { - e.panic_with_error(Error::InvalidConfigVersion); + e.panic_with_error(Error::InvalidConfig); } }; //burn corresponding amount of fee tokens diff --git a/oracle/src/settings.rs b/oracle/src/settings.rs index 97bb1c9..eb7c742 100644 --- a/oracle/src/settings.rs +++ b/oracle/src/settings.rs @@ -82,6 +82,9 @@ pub fn set_cache_size(e: &Env, cache_size: u32) { #[inline] pub fn set_fee_config(e: &Env, fee_config: &FeeConfig) { + if fee_config == &FeeConfig::None { + e.panic_with_error(Error::InvalidConfig); //prevent using empty fee config + } e.storage().instance().set(&RETENTION_KEY, &fee_config); } diff --git a/oracle/src/types.rs b/oracle/src/types.rs index f92abd2..c49b512 100644 --- a/oracle/src/types.rs +++ b/oracle/src/types.rs @@ -68,10 +68,10 @@ pub enum Error { Unauthorized = 1, // Config asset list doesn't contain persistent asset AssetMissing = 2, - // Asset is already exists in supported assets list + // Asset already exists in supported assets list AssetAlreadyExists = 3, - // Config version is invalid - InvalidConfigVersion = 4, + // Config is invalid + InvalidConfig = 4, // Price timestamp is invalid InvalidTimestamp = 5, // Maximum assets limit reached From af7afa385914538777d375550dd70726c0d5dfc0 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 24 Nov 2025 13:45:06 -0100 Subject: [PATCH 36/55] Move FeeConfig verification logic to price_oracle --- oracle/src/price_oracle.rs | 3 +++ oracle/src/settings.rs | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index 0b089a1..5e645db 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -413,6 +413,9 @@ impl PriceOracleContractBase { // 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); } diff --git a/oracle/src/settings.rs b/oracle/src/settings.rs index eb7c742..97bb1c9 100644 --- a/oracle/src/settings.rs +++ b/oracle/src/settings.rs @@ -82,9 +82,6 @@ pub fn set_cache_size(e: &Env, cache_size: u32) { #[inline] pub fn set_fee_config(e: &Env, fee_config: &FeeConfig) { - if fee_config == &FeeConfig::None { - e.panic_with_error(Error::InvalidConfig); //prevent using empty fee config - } e.storage().instance().set(&RETENTION_KEY, &fee_config); } From bae064c3c5d080faae733bbd7ca295bf6d36d469 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 28 Nov 2025 01:01:25 +0200 Subject: [PATCH 37/55] Fix units mismatch comments, update expires() to return seconds --- beam-contract/src/lib.rs | 4 +-- oracle/src/assets.rs | 2 +- oracle/src/price_oracle.rs | 13 ++++--- oracle/src/types.rs | 4 +-- pulse-contract/src/lib.rs | 4 +-- .../src/tests/contract_admin_tests.rs | 7 ++-- .../src/tests/contract_interface_tests.rs | 34 +++++++++++++++++-- 7 files changed, 52 insertions(+), 16 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 1c03759..c5d9285 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -93,7 +93,7 @@ impl BeamOracleContract { // // # Returns // - // Asset expiration timestamp or None if asset is not supported + // Asset expiration timestamp (in seconds) or None if asset is not supported // // # Panics // @@ -368,7 +368,7 @@ impl BeamOracleContract { // // # Arguments // - // * `period` - History retention period (in seconds) + // * `period` - History retention period (in milliseconds) // // # Panics // diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 4c73a2a..4996dcd 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -79,7 +79,7 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { set_expirations_records(e, &expiration); } -// Retrieve expiration time for given asset +// 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() { diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index 0b089a1..f9b5e9c 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -96,13 +96,16 @@ impl PriceOracleContractBase { // // # Returns // - // Asset expiration timestamp or None if asset is not supported + // 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 { - assets::expires(e, asset) + match assets::expires(e, asset) { + Some(ts) => Some(ts / 1000), //convert to seconds + None => None, + } } // Extends the asset expiration date by a given amount of tokens. @@ -325,8 +328,8 @@ impl PriceOracleContractBase { // * `admin` - Admin address // * `base` - Base asset // * `decimals` - Number of decimals for price records - // * `resolution` - History timeframe resolution (in seconds) - // * `history_retention_period` - Price history retention period (in seconds) + // * `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 @@ -390,7 +393,7 @@ impl PriceOracleContractBase { // // # Arguments // - // * `period` - History retention period (in seconds) + // * `period` - History retention period (in milliseconds) // // # Panics // diff --git a/oracle/src/types.rs b/oracle/src/types.rs index f92abd2..6443086 100644 --- a/oracle/src/types.rs +++ b/oracle/src/types.rs @@ -14,7 +14,7 @@ pub enum Asset { pub struct ConfigData { // Admin address pub admin: Address, - // Price history retention period + // Price history retention period (in milliseconds) pub history_retention_period: u64, // List of supported assets pub assets: Vec, @@ -22,7 +22,7 @@ pub struct ConfigData { pub base_asset: Asset, // Number of decimals for price records pub decimals: u32, - // History timeframe resolution + // History timeframe resolution (in milliseconds) pub resolution: u32, // Number of rounds held in instance cache pub cache_size: u32, diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index d4ddeb9..3b10830 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -311,7 +311,7 @@ impl PulseOracleContract { // // # Arguments // - // * `period` - History retention period (in seconds) + // * `period` - History retention period (in milliseconds) // // # Panics // @@ -340,7 +340,7 @@ impl PulseOracleContract { // # Arguments // // * `updates` - Price feed snapshot - // * `timestamp` - History snapshot timestamp + // * `timestamp` - History snapshot timestamp (in milliseconds) // // # Panics // diff --git a/pulse-contract/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs index 00473d7..3720756 100644 --- a/pulse-contract/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -233,10 +233,13 @@ fn set_fee_config_test() { fee_token.mint(&sponsor, &10); let symbol_expires = client.expires(&asset).unwrap(); - assert_eq!(symbol_expires, 15552900000); // 900s current ledger timestamp + 180 days of initial expiration period + 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); + 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); diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index 3b582f5..dcb6c6d 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -6,8 +6,9 @@ use crate::tests::setup_tests::{ }; use oracle::prices; use oracle::types::FeeConfig; -use soroban_sdk::testutils::{Ledger, LedgerInfo}; -use soroban_sdk::Vec; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{Address, Vec}; #[test] fn version_test() { @@ -130,3 +131,32 @@ fn prices_test() { assert!(had_prices); assert!(had_gaps); } + +#[test] +fn extend_asset_ttl_test() { + let (env, client, init_data) = init_contract(); + + env.mock_all_auths(); + + 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); + + //generate sponsor and mint fee tokens + let sponsor = Address::generate(&env); + let token_client = StellarAssetClient::new(&env, &fee_asset); + token_client.mint(&sponsor, &10_000_000); + + //get initial expiration + let asset = &init_data.assets.first_unchecked(); + let initial_expiration = client.expires(&asset).unwrap(); + + //extend TTL by 10 day (864000 seconds) + client.extend_asset_ttl(&sponsor, &asset, &10_000_000); + + //verify new expiration + let new_expiration = client.expires(&asset).unwrap(); + assert_eq!(new_expiration, initial_expiration + 864000); +} From 287514f153251db875c9a859b0afe316a21b00b7 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 28 Nov 2025 01:03:29 +0200 Subject: [PATCH 38/55] Fix mismatch between vectors length in expirations and assets --- beam-contract/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ oracle/src/assets.rs | 7 ++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs index e22b44c..8e4c8f2 100644 --- a/beam-contract/src/tests.rs +++ b/beam-contract/src/tests.rs @@ -3,6 +3,7 @@ extern crate std; use crate::cost::InvocationComplexity; use crate::{BeamOracleContract, BeamOracleContractClient}; +use oracle::assets; use oracle::types::{Asset, ConfigData, FeeConfig}; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; @@ -123,3 +124,40 @@ fn invocation_charge_estimate_test( let fee = client.estimate_cost(&invocation, &periods); assert_eq!(fee, expected_fee); } + +#[test] +fn check_extending_asset_ttl() { + //initialize contract + let (env, client, init_data) = init_contract_with_admin(); + + //set fee config + let asset_contract = env.register_stellar_asset_contract_v2(init_data.admin.clone()); + let fee_asset = asset_contract.address(); + let fee_config = FeeConfig::Some((fee_asset.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); + let token_client = StellarAssetClient::new(&env, &fee_asset); + token_client.mint(&sponsor, &10_000_000); + + //check the extending + client.extend_asset_ttl(&sponsor, &new_asset, &1_000_000); + assert_eq!(client.expires(&new_asset), Some(87_300_000)); + + //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/oracle/src/assets.rs b/oracle/src/assets.rs index 4996dcd..2e8e1a8 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -57,7 +57,6 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { //load current state let mut asset_list = load_all_assets(e); let mut expiration = load_expiration_records(e); - let is_fee_config_set = settings::get_fee_config(e) != FeeConfig::None; //for each new asset for asset in assets.iter() { //check if the asset has been already added @@ -66,10 +65,8 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { } set_asset_index(e, &asset, asset_list.len()); asset_list.push_back(asset); - //if the fee is not set, we don't need to set the expiration - if is_fee_config_set && expiration_timestamp > 0 { - expiration.push_back(expiration_timestamp); //set expiration - } + //update expiration records + expiration.push_back(expiration_timestamp); } if asset_list.len() >= ASSET_LIMIT { panic_with_error!(&e, Error::AssetLimitExceeded); From bae9e7322a3e6fce688dce080d9359417ad8b8c5 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:27:30 +0200 Subject: [PATCH 39/55] Return `None` if consumer requests more than 20 records, do not charge fees for invocations that return `None` in Beam oracle --- Cargo.lock | 1 + beam-contract/src/lib.rs | 56 +++++++++++++------ beam-contract/src/tests.rs | 10 ++-- oracle/src/prices.rs | 12 ++-- pulse-contract/Cargo.toml | 1 + .../src/tests/contract_interface_tests.rs | 34 ++++++++++- 6 files changed, 85 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a65a19..953cc53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,6 +1025,7 @@ version = "6.0.0" dependencies = [ "oracle", "soroban-sdk", + "test-case", ] [[package]] diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 421443c..ec8da9b 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -172,8 +172,11 @@ impl BeamOracleContract { // 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(); - charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); - PriceOracleContractBase::price(e, asset, timestamp) + let res = PriceOracleContractBase::price(e, asset, timestamp); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); + } + res } // Returns most recent price for an asset @@ -188,8 +191,11 @@ impl BeamOracleContract { // 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(); - charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); - PriceOracleContractBase::lastprice(e, asset) + let res = PriceOracleContractBase::lastprice(e, asset); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); + } + res } // Return last N price records for given asset @@ -205,8 +211,11 @@ impl BeamOracleContract { // 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(); - charge_invocation_fee(e, &caller, InvocationComplexity::Price, records); - PriceOracleContractBase::prices(e, asset, records) + let res = PriceOracleContractBase::prices(e, asset, records); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::Price, records); + } + res } // Returns most recent cross price record for pair of assets @@ -227,8 +236,11 @@ impl BeamOracleContract { quote_asset: Asset, ) -> Option { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); - PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) + let res = PriceOracleContractBase::x_last_price(e, base_asset, quote_asset); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); + } + res } // Return cross price for pair of assets at specific timestamp @@ -251,8 +263,11 @@ impl BeamOracleContract { timestamp: u64, ) -> Option { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); - PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp) + let res = PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); + } + res } // Returns last N cross price records of for pair of assets @@ -275,8 +290,11 @@ impl BeamOracleContract { records: u32, ) -> Option> { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, records); - PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records) + let res = PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, records); + } + res } // Returns time-weighted average price for given asset over N recent records @@ -292,8 +310,11 @@ impl BeamOracleContract { // TWAP for the given asset over N recent records or None if asset is not supported pub fn twap(e: &Env, caller: Address, asset: Asset, records: u32) -> Option { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::Twap, records); - PriceOracleContractBase::twap(e, asset, records) + let res = PriceOracleContractBase::twap(e, asset, records); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::Twap, records); + } + res } // Returns time-weighted average cross price for given asset pair over N recent records @@ -316,8 +337,11 @@ impl BeamOracleContract { records: u32, ) -> Option { caller.require_auth(); - charge_invocation_fee(e, &caller, InvocationComplexity::CrossTwap, records); - PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records) + let res = PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records); + if res.is_some() { + charge_invocation_fee(e, &caller, InvocationComplexity::CrossTwap, records); + } + res } /* Admin section */ diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs index 8e4c8f2..a75c0c1 100644 --- a/beam-contract/src/tests.rs +++ b/beam-contract/src/tests.rs @@ -6,7 +6,7 @@ use crate::{BeamOracleContract, BeamOracleContractClient}; use oracle::assets; use oracle::types::{Asset, ConfigData, FeeConfig}; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::token::StellarAssetClient; use soroban_sdk::{Address, Env, String, Vec}; use test_case::test_case; @@ -68,7 +68,7 @@ fn set_invocation_config_test() { } #[test] -fn invocation_charge_test() { +fn invocation_charge_for_none_result_test() { let (env, client, init_data) = init_contract_with_admin(); let fee_asset = env @@ -91,8 +91,8 @@ fn invocation_charge_test() { &5, ); //check that fee token was deducted - let fee_token_balance = TokenClient::new(&env, &fee_asset).balance(&caller); - assert_eq!(fee_token_balance, 36_000_000); + let fee_token_balance = fee_token.balance(&caller); + assert_eq!(fee_token_balance, 100_000_000); } #[test_case(InvocationComplexity::Price, 1, 10_000_000 ; "price")] @@ -153,7 +153,7 @@ fn check_extending_asset_ttl() { //check the extending client.extend_asset_ttl(&sponsor, &new_asset, &1_000_000); - assert_eq!(client.expires(&new_asset), Some(87_300_000)); + assert_eq!(client.expires(&new_asset), Some(87_300)); //check that expiration records length matches assets length env.as_contract(&client.address, || { diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 2ca81d6..9479af6 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -6,6 +6,8 @@ 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, @@ -201,7 +203,7 @@ pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &V pub fn load_prices Option>( e: &Env, get_price_fn: F, - mut records: u32, + records: u32, ) -> Option> { let mut timestamp = obtain_last_record_timestamp(e); if timestamp == 0 { @@ -212,9 +214,11 @@ pub fn load_prices Option>( let resolution = settings::get_resolution(e) as u64; //limit the number of returned records to 20 - records = records.min(20); + if records > PRICE_RECORDS_LIMIT { + return None; + } - while records > 0 { + for _ in 0..records { //invoke price fetch callback for each record if let Some(price) = get_price_fn(timestamp) { prices.push_back(price); @@ -222,8 +226,6 @@ pub fn load_prices Option>( if timestamp < resolution { break; } - //decrement remaining records counter in every iteration - records -= 1; timestamp -= resolution; } diff --git a/pulse-contract/Cargo.toml b/pulse-contract/Cargo.toml index bfff3a5..9804d66 100644 --- a/pulse-contract/Cargo.toml +++ b/pulse-contract/Cargo.toml @@ -12,3 +12,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +test-case = "*" diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index dcb6c6d..3af728f 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -4,11 +4,12 @@ extern crate std; use crate::tests::setup_tests::{ convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, }; -use oracle::prices; +use oracle::prices::{self, PRICE_RECORDS_LIMIT}; use oracle::types::FeeConfig; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; use soroban_sdk::token::StellarAssetClient; use soroban_sdk::{Address, Vec}; +use test_case::test_case; #[test] fn version_test() { @@ -46,8 +47,35 @@ fn last_timestamp_test() { assert_eq!(result, convert_to_seconds(600_000)); } +#[test_case(2, Some(normalize_price(1)) ; "twap 2 rounds")] +#[test_case(PRICE_RECORDS_LIMIT + 1, None ; "twap exceeds limit")] +fn x_twap_test(records: u32, price: Option) { + let (env, client, init_data) = init_contract(); + + let assets = init_data.assets; + + //set prices for 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, ×tamp); + + let timestamp = 900_000; + let updates = generate_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), &records); + + assert_eq!(result, price); +} + #[test] -fn price_test() { +fn lastprice_test() { let (env, client, init_data) = init_contract(); let assets = &init_data.assets; @@ -75,7 +103,7 @@ fn price_test() { } #[test] -fn prices_test() { +fn prices_update_test() { let (env, client, init_data) = init_contract(); let assets = init_data.assets; From 0806f9c6be3c012ee0048bf4dd860eeccf77cea4 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:31:13 +0200 Subject: [PATCH 40/55] Prevent outdated updates, encapsulate timestamp verification inside `prices` module --- oracle/src/price_oracle.rs | 15 ++--- oracle/src/prices.rs | 18 ++++-- oracle/src/tests/mod.rs | 1 + oracle/src/tests/prices_tests.rs | 94 ++++++++++++++++++++++++++++++++ oracle/src/tests/util_tests.rs | 20 ++++++- oracle/src/timestamps.rs | 2 +- 6 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 oracle/src/tests/prices_tests.rs diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index fb878aa..62af720 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -436,19 +436,12 @@ impl PriceOracleContractBase { // 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); - if update.prices.len() == 0 { - return; //skip empty updates - } - if update.prices.len() > assets::load_all_assets(e).len() { - panic_with_error!(&e, Error::InvalidPricesUpdate); - } - //validate record timestamp - let ledger_timestamp = timestamps::ledger_timestamp(&e); - if timestamp == 0 || !timestamps::is_valid(e, timestamp) || timestamp > ledger_timestamp { - panic_with_error!(&e, Error::InvalidTimestamp); - } //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); diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 9479af6..88cdf2f 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -1,6 +1,6 @@ -use crate::types::{PriceData, PriceUpdate}; +use crate::types::{Error, PriceData, PriceUpdate}; use crate::{mapping, protocol, settings, timestamps}; -use soroban_sdk::{Bytes, Env, Vec}; +use soroban_sdk::{panic_with_error, Bytes, Env, Vec}; const CACHE_KEY: &str = "cache"; const LAST_TIMESTAMP_KEY: &str = "last_timestamp"; @@ -162,13 +162,19 @@ pub fn load_history_record(e: &Env, timestamp: u64) -> Option { // Update prices stored in the oracle pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &Vec) { - //get the last timestamp + //validate timestamp + let ledger_timestamp = timestamps::ledger_timestamp(&e); let last_timestamp = get_last_timestamp(e); - //update the last timestamp - if timestamp > last_timestamp { - set_last_timestamp(e, timestamp); + 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); diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index 16907c5..36898b7 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -1 +1,2 @@ +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..5fed181 --- /dev/null +++ b/oracle/src/tests/prices_tests.rs @@ -0,0 +1,94 @@ +#![cfg(test)] +extern crate std; + +use crate::{price_oracle, prices, tests::util_tests::generate_update_record_mask, types}; +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo}, + 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); + e.ledger().set(LedgerInfo { + timestamp: 9001, + ..e.ledger().get() + }); + }); + + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::set_price( + &e, + types::PriceUpdate { + prices: vec![&e, 12345678i128], + mask: generate_update_record_mask(&e, &vec![&e, 12345678i128]), + }, + ts, + ); + }); +} + +#[test] +fn 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); + e.ledger().set(LedgerInfo { + timestamp: 9001, + ..e.ledger().get() + }); + }); + + e.as_contract(&contract.address(), || { + price_oracle::PriceOracleContractBase::set_price( + &e, + types::PriceUpdate { + prices: vec![&e, 12345678i128], + mask: generate_update_record_mask(&e, &vec![&e, 12345678i128]), + }, + 900_000, + ); + }); +} diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index 6c020ee..1b8be96 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -1,12 +1,12 @@ #![cfg(test)] extern crate std; -use soroban_sdk::{log, Bytes, Env, Vec}; +use soroban_sdk::{log, testutils::Address as _, Address, Bytes, Env, Vec}; use test_case::test_case; -use crate::{mapping, prices}; +use crate::{mapping, prices, settings}; -fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { +pub fn generate_update_record_mask(e: &Env, updates: &Vec) -> Bytes { let mut mask = [0u8; 32]; for (asset, price) in updates.iter().enumerate() { if price > 0 { @@ -102,3 +102,17 @@ fn update_record_bitmask_test() { } } } + +#[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/timestamps.rs b/oracle/src/timestamps.rs index 69ae81a..4319838 100644 --- a/oracle/src/timestamps.rs +++ b/oracle/src/timestamps.rs @@ -12,7 +12,7 @@ pub fn normalize(e: &Env, value: u64) -> u64 { // Whether the timestamp is valid pub fn is_valid(e: &Env, value: u64) -> bool { - value == normalize(e, value) + value > 0 && value == normalize(e, value) } // Convert days to milliseconds From 5806987e86bbf033ce8367754881cd3b91fa0d4b Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 5 Jan 2026 05:02:02 -0100 Subject: [PATCH 41/55] Fix comment to match actual constant --- oracle/src/settings.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/oracle/src/settings.rs b/oracle/src/settings.rs index 97bb1c9..eed2738 100644 --- a/oracle/src/settings.rs +++ b/oracle/src/settings.rs @@ -92,9 +92,8 @@ pub fn get_fee_config(e: &Env) -> FeeConfig { .get(&RETENTION_KEY) .unwrap_or_else(|| { FeeConfig::Some(( - // by default - XRF tokens with 1 XRF base cost - Address::from_str(e, XRF_TOKEN_ADDRESS), - DEFAULT_RETENTION_FEE, + Address::from_str(e, XRF_TOKEN_ADDRESS), // by default - XRF tokens + DEFAULT_RETENTION_FEE, // with DEFAULT_RETENTION_FEE base cost )) }) } From c8cc6eec3c0040514c24229f09d0ec79cef7039b Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 5 Jan 2026 05:09:48 -0100 Subject: [PATCH 42/55] Address ambiguity between contract version and protocol version in comments --- beam-contract/src/lib.rs | 4 ++-- oracle/src/price_oracle.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index ec8da9b..c5cdb47 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -76,11 +76,11 @@ impl BeamOracleContract { PriceOracleContractBase::last_timestamp(e) } - // Return current contract protocol version + // Return current contract version (from package) // // # Returns // - // Contract protocol version + // Contract version pub fn version(e: &Env) -> u32 { PriceOracleContractBase::version(e) } diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index 62af720..eb793e0 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -74,11 +74,11 @@ impl PriceOracleContractBase { prices::get_last_timestamp(e) / 1000 //convert to seconds } - // Return current contract protocol version + // Return current contract version (from package) // // # Returns // - // Contract protocol version + // Contract version pub fn version(_e: &Env) -> u32 { env!("CARGO_PKG_VERSION") .split(".") From bdf513d402ad12c35da6f3e368a8e5a899caf8ee Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 5 Jan 2026 05:46:43 -0100 Subject: [PATCH 43/55] Update the constant to match the actual assets limit --- oracle/src/assets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 77c42ee..1ccee8c 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -2,7 +2,7 @@ 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 = 1000; //current limit +const ASSET_LIMIT: u32 = 256; //storage keys const ASSETS_KEY: &str = "assets"; From dbd0c027641e67640a60aa8ea8f446c5c1aa437f Mon Sep 17 00:00:00 2001 From: orbitlens Date: Thu, 8 Jan 2026 01:26:11 -0100 Subject: [PATCH 44/55] Fix asset limit exception condition (> instead of >=) --- oracle/src/assets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 1ccee8c..8b5689a 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -68,7 +68,7 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { //update expiration records expiration.push_back(expiration_timestamp); } - if asset_list.len() >= ASSET_LIMIT { + if asset_list.len() > ASSET_LIMIT { panic_with_error!(&e, Error::AssetLimitExceeded); } //update assets list and expirations vector From 5d6b240b92eb34528747dd344206726efa56f4e9 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 9 Jan 2026 04:22:47 +0200 Subject: [PATCH 45/55] Refactor history mask update routines to use price gaps based on periods delta --- oracle/src/mapping.rs | 20 +++++- oracle/src/prices.rs | 15 +---- oracle/src/tests/util_tests.rs | 2 +- .../src/tests/contract_interface_tests.rs | 64 ++++++++++++++----- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/oracle/src/mapping.rs b/oracle/src/mapping.rs index f8e924d..d8a1b90 100644 --- a/oracle/src/mapping.rs +++ b/oracle/src/mapping.rs @@ -4,8 +4,21 @@ use soroban_sdk::{Bytes, Env, Vec, U256}; const RECORD_SIZE: u32 = 32; // Update history records containing a bitmask of all prices recorded within the last update period -pub fn update_history_mask(e: &Env, mut history_mask: Bytes, updates: &Vec) -> Bytes { +pub fn update_history_mask( + e: &Env, + mut history_mask: Bytes, + updates: &Vec, + mut updates_delta: u32, +) -> Bytes { let one = U256::from_u32(e, 1); + //wipe entire history if the gap between updates is too large + if updates_delta > 255 { + history_mask = Bytes::new(e); //start with an empty mask + updates_delta = 1; + } + if updates_delta < 1 { + updates_delta = 1; //this should never happen, but just in case + } //iterate through all updates for (asset_index, price) in updates.iter().enumerate() { //locate particular asset mask slice position within entire history record @@ -18,8 +31,9 @@ pub fn update_history_mask(e: &Env, mut history_mask: Bytes, updates: &Vec } else { U256::from_u32(e, 0) //no previous records for this asset found }; - //shift existing mask, all mask bits older than 256 periods get evicted - bitmask = bitmask.shl(1); + //shift existing mask to the left by the number of periods since the last update + //all mask bits older than 256 periods get evicted + bitmask = bitmask.shl(updates_delta); //set corresponding bit if price found if price > 0 { bitmask = bitmask.add(&one); diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 88cdf2f..5e11fd7 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -110,7 +110,6 @@ fn get_history_map(e: &Env) -> Bytes { .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); @@ -120,21 +119,11 @@ pub fn update_history_mask(e: &Env, prices: &Vec, timestamp: u64) { let mut update_delta = 0; if last_timestamp > 0 && timestamp > last_timestamp { update_delta = (timestamp - last_timestamp) / resolution; - } - //add missing intervals - if update_delta > 1 { - for _ in 1..update_delta { - let mut empty_prices = Vec::new(e); - for _ in 0..prices.len() { - empty_prices.push_back(0i128); - } - history_map = mapping::update_history_mask(e, history_map, &empty_prices); - } + update_delta = core::cmp::min(update_delta, 256); //max 256 periods tracked } //update the position mask - history_map = mapping::update_history_mask(e, history_map, prices); - + history_map = mapping::update_history_mask(e, history_map, prices, update_delta as u32); //store updated timestamps e.storage().instance().set(&HISTORY_KEY, &history_map); } diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index 1b8be96..c039f59 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -57,7 +57,7 @@ fn position_encoding_bitmask_test() { }; updates.push_back(price); } - mask = mapping::update_history_mask(&e, mask, &updates); + mask = mapping::update_history_mask(&e, mask, &updates, 1); } log!(&e, "entire mask", mask); diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index 3af728f..daf9f75 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -1,14 +1,13 @@ #![cfg(test)] -extern crate std; use crate::tests::setup_tests::{ convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, }; use oracle::prices::{self, PRICE_RECORDS_LIMIT}; -use oracle::types::FeeConfig; +use oracle::types::{FeeConfig, PriceData}; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{Address, Vec}; +use soroban_sdk::{log, Address, Env, Vec}; use test_case::test_case; #[test] @@ -102,21 +101,24 @@ fn lastprice_test() { assert_eq!(price.timestamp, convert_to_seconds(timestamp)); } -#[test] -fn prices_update_test() { +#[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(); let assets = init_data.assets; - client.set_cache_size(&256); + client.set_cache_size(&3); let mut history_prices = Vec::new(&env); - //set more than 255 prices to check that history mask is overwritten correctly - for i in 0..257 { + //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 timestamp != 900_000 && timestamp != 1200_000 { + if i < 1 || i > gap { let updates = generate_random_updates(&env, &assets, normalize_price(100)); history_prices.push_front((timestamp, updates.clone())); //set prices for assets @@ -132,32 +134,60 @@ fn prices_update_test() { ..ledger_info }); } + //prepare an array with zero prices + let mut zero_prices = Vec::new(&env); + for _ in 0..assets.len() { + zero_prices.push_back(0i128); + } + //verify let mut had_gaps = false; let mut had_prices = false; - //verify prices + let mut iterations = 0; + for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { + let all_prices; if history_index > 255 { - break; + all_prices = zero_prices.clone(); + } else { + let total = assets.len() + 10; //+10 to check that out of range assets are ignored + //get records from generated updates + all_prices = prices::extract_update_record_prices(&env, &updates, total); } - let total = assets.len() + 10; //+10 to check that out of range assets are ignored - let all_prices = prices::extract_update_record_prices(&env, &updates, total); + + //match price with mask for each asset in update for (asset_index, asset) in assets.iter().enumerate() { - let price_data = client.price(&asset, &(timestamp / 1000)); + //get oracle-quoted price + let oracle_price = client.price(&asset, &(timestamp / 1000)); + //get expected price (from generated data) let expected_price = all_prices.get(asset_index as u32).unwrap_or_default(); if expected_price > 0 { - let price = price_data.unwrap(); - assert_eq!(price.price, expected_price); + let price = oracle_price.unwrap_or_else(|| PriceData { + price: 0, + timestamp: 0, + }); + assert_eq!( + price.price, expected_price, + "asset {} at timestamp {}", + asset_index, timestamp + ); assert_eq!(price.timestamp, convert_to_seconds(timestamp)); had_prices = true; } else { - assert!(price_data.is_none()); + assert!( + oracle_price.is_none(), + "asset {} at timestamp {}", + asset_index, + timestamp + ); had_gaps = true; } } + iterations += 1; } assert!(had_prices); assert!(had_gaps); + log!(&env, "{} iterations", iterations); } #[test] From 75ebee6d351d81d545bafbfbe800ea14119f6d46 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 9 Jan 2026 04:43:36 +0200 Subject: [PATCH 46/55] Ensure that prices range can be interpolated if some timestamps are missing --- oracle/src/prices.rs | 13 +++- oracle/src/tests/fetch_prices_tests.rs | 101 +++++++++++++++++++++++++ oracle/src/tests/mod.rs | 1 + 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 oracle/src/tests/fetch_prices_tests.rs diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 5e11fd7..9b098df 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -213,15 +213,22 @@ pub fn load_prices Option>( return None; } - for _ in 0..records { + //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 { //invoke price fetch callback for each record if let Some(price) = get_price_fn(timestamp) { prices.push_back(price); + last_included = timestamp; } + timestamp -= resolution; //walk back in time if timestamp < resolution { - break; + break; //reached 0 timestamp - never happens in real life } - timestamp -= resolution; } if prices.is_empty() { diff --git a/oracle/src/tests/fetch_prices_tests.rs b/oracle/src/tests/fetch_prices_tests.rs new file mode 100644 index 0000000..c98e8f6 --- /dev/null +++ b/oracle/src/tests/fetch_prices_tests.rs @@ -0,0 +1,101 @@ +#![cfg(test)] +extern crate alloc; +extern crate std; +use alloc::string::ToString; + +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; +use soroban_sdk::{Address, Bytes, Env, Symbol, Vec}; + +use test_case::test_case; + +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; "5 rounds skipped")] +#[test_case(600_000, 30, 600_000, 2; "30 rounds skipped")] +fn prices_test( + first_timestamp: u64, + rounds_gap: u64, + expected_first_price_ts: u64, + expected_prices_count: u32, +) { + let e = Env::default(); + + let mut assets = Vec::new(&e); + for i in 0..10 { + 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()); + //store history timestamps for all assets + prices::update_history_mask(e, &asset_prices, timestamp); + prices::store_prices(e, &updates, timestamp, &updates.prices.clone()); + } + + let mut timestamp = first_timestamp; + set_price(&e, timestamp, &assets); + timestamp += timeframe * rounds_gap; + set_price(&e, timestamp, &assets); + + e.ledger().set(LedgerInfo { + timestamp: timestamp / 1000, + ..e.ledger().get() + }); + + let prices = prices::load_prices( + &e, + |timestamp| prices::retrieve_asset_price_data(&e, 0, timestamp), + 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 + ); + }); +} diff --git a/oracle/src/tests/mod.rs b/oracle/src/tests/mod.rs index 36898b7..cbcd4ef 100644 --- a/oracle/src/tests/mod.rs +++ b/oracle/src/tests/mod.rs @@ -1,2 +1,3 @@ +mod fetch_prices_tests; mod prices_tests; mod util_tests; From a377282521ed0aa58fbe238f8c649c77da943eba Mon Sep 17 00:00:00 2001 From: orbitlens Date: Sun, 18 Jan 2026 00:18:26 -0100 Subject: [PATCH 47/55] Move interface functions x_price, x_last_price, x_prices, twap, x_twap to external lib --- README.md | 48 ++----- beam-contract/src/cost.rs | 8 +- beam-contract/src/lib.rs | 126 ------------------ beam-contract/src/tests.rs | 13 -- oracle/src/assets.rs | 11 -- oracle/src/price_oracle.rs | 117 +--------------- oracle/src/prices.rs | 59 +------- oracle/src/tests/fetch_prices_tests.rs | 18 +-- pulse-contract/src/lib.rs | 83 ------------ .../src/tests/contract_interface_tests.rs | 29 +--- 10 files changed, 27 insertions(+), 485 deletions(-) diff --git a/README.md b/README.md index 8903479..7d81fef 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ account always has all node public keys as co-signers with >50% multisig thresho 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 node's private key, and then shares it with other peers via +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. +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 @@ -26,11 +26,11 @@ stored in the oracle contract and becomes immutable once it is written to the co Reflector offers two data access models for Stellar on-chain oracles: -- **ReflectorPulse** oracles with a uniform 5 minutes update interval provide free access to published price feeds +- **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 implementation are compatible with +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. @@ -44,8 +44,7 @@ contracts to check the `timestamp` field of the returned values against the curr reported quotes are not stale. Other contracts interact with oracle contracts, retrieving data stored earlier by Reflector consensus. -Consumers can fetch historical ranges, use cross-price calculation, utilize TWAP averaging, or simply pull the most -recent token price depending on the use-case. +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: @@ -62,7 +61,7 @@ resources to aggregate, validate, certify, and publish token price information. 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 have an equal voting power, and Reflector governance decisions are enacted by a simple majority of +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. @@ -75,7 +74,7 @@ representing spent computational resources equivalent. ### ReflectorPulse Contract -Utilize this example to invoke oracles functions from your contract code. +Follow this example to invoke oracles functions from your contract code. ```rust /* contract.rs */ @@ -122,7 +121,7 @@ impl MyAwesomeContract { #### Pulse contract interface -Copy and save it in your smart contract project as "reflector_pulse.rs" file. +Copy and save it in your smart contract project as `reflector_pulse.rs` file. This is the oracle client interface definition. ```rust @@ -144,16 +143,6 @@ pub trait Contract { fn lastprice(asset: Asset) -> Option; // Quotes last N price records for the given asset fn prices(asset: Asset, records: u32) -> Option>; - // Quotes the most recent cross price record for the pair of assets - fn x_last_price(base_asset: Asset, quote_asset: Asset) -> Option; - // Quotes the cross price for the pair of assets at specific timestamp - fn x_price(base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; - // Quotes last N cross price records of for the pair of assets - fn x_prices(base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; - // Quotes the time-weighted average price for the given asset over N recent records - fn twap(asset: Asset, records: u32) -> Option; - // Quotes the time-weighted average cross price for the given asset pair over N recent records - fn x_twap(base_asset: Asset, quote_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) @@ -203,7 +192,7 @@ pub enum Error { ``` ### ReflectorBeam contract -Utilize this example to invoke oracle functions from your contract code. +Follow this example to invoke oracle functions from your contract code. ```rust /* contract.rs */ @@ -277,7 +266,7 @@ fn authorize_spend(e: &Env, reflector_client: &ReflectorBeamClient, complexity: #### Beam contract interface -Copy and save it in your smart contract project as "reflector_beam.rs" file. +Copy and save it in your smart contract project as `reflector_beam.rs` file. This is the oracle client interface definition. ```rust @@ -299,16 +288,6 @@ pub trait Contract { 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>; - // Quotes the most recent cross price record for the pair of assets - fn x_last_price(caller: Address, base_asset: Asset, quote_asset: Asset) -> Option; - // Quotes the cross price for the pair of assets at specific timestamp - fn x_price(caller: Address, base_asset: Asset, quote_asset: Asset, timestamp: u64) -> Option; - // Quotes last N cross price records of for the pair of assets - fn x_prices(caller: Address, base_asset: Asset, quote_asset: Asset, records: u32) -> Option>; - // Quotes the time-weighted average price for the given asset over N recent records - fn twap(caller: Address, asset: Asset, records: u32) -> Option; - // Quotes the time-weighted average cross price for the given asset pair over N recent records - fn x_twap(caller: Address, base_asset: Asset, quote_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) @@ -348,10 +327,7 @@ pub struct PriceData { #[derive(Clone, Debug, Eq, PartialEq)] pub enum InvocationComplexity { NModifier = 0, // multiplier for number of requested periods, not utilized directly for cost calculation - Price = 1, // single asset price record request - Twap = 2, // TWAP approximation over N records - CrossPrice = 3, // cross-price calculation for two assets - CrossTwap = 4, // TWAP approximation over N records for cross-price quote + Price = 1, // regular asset price record request } // Possible runtime errors @@ -408,7 +384,7 @@ This command will generate an optimized WASM file at ### Testing -In order to run all workspace tests, execute `cargo test` from the project root directory: +To run all workspace tests, execute `cargo test` from the project root directory: ```shell cargo test diff --git a/beam-contract/src/cost.rs b/beam-contract/src/cost.rs index 24d6912..bd23ecd 100644 --- a/beam-contract/src/cost.rs +++ b/beam-contract/src/cost.rs @@ -10,14 +10,8 @@ const SCALE: i128 = 10_000_000; pub enum InvocationComplexity { //Multiplicator for number of requested periods, not utilized directly for cost calculation NModifier = 0, - //Single asset price record request + //Regular asset price record request Price = 1, - //TWAP approximation over N records - Twap = 2, - //Cross-price calculation for two assets - CrossPrice = 3, - //TWAP approximation over N records for cross-price quote - CrossTwap = 4, } //invocation cost config is stored as vector with indexes corresponding to InvocationComplexity diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index c5cdb47..2c2f6d9 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -218,132 +218,6 @@ impl BeamOracleContract { res } - // Returns most recent cross price record for pair of assets - // - // # Arguments - // - // * `caller` - Caller that covers invocation cost - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found - pub fn x_last_price( - e: &Env, - caller: Address, - base_asset: Asset, - quote_asset: Asset, - ) -> Option { - caller.require_auth(); - let res = PriceOracleContractBase::x_last_price(e, base_asset, quote_asset); - if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); - } - res - } - - // Return cross price for pair of assets at specific timestamp - // - // # Arguments - // - // * `caller` - Caller that covers invocation cost - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `timestamp` - Timestamp - // - // # Returns - // - // Cross price (base_asset_price/quote_asset_price) at given timestamp or None if there were no records found for quoted assets - pub fn x_price( - e: &Env, - caller: Address, - base_asset: Asset, - quote_asset: Asset, - timestamp: u64, - ) -> Option { - caller.require_auth(); - let res = PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp); - if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, 1); - } - res - } - - // Returns last N cross price records of for pair of assets - // - // # Arguments - // - // * `caller` - Caller that covers invocation cost - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to fetch - // - // # 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, - caller: Address, - base_asset: Asset, - quote_asset: Asset, - records: u32, - ) -> Option> { - caller.require_auth(); - let res = PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records); - if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::CrossPrice, records); - } - res - } - - // Returns time-weighted average price for given asset over N recent records - // - // # Arguments - // - // * `caller` - Caller that covers invocation cost - // * `asset` - Asset to quote - // * `records` - Number of records to process - // - // # Returns - // - // TWAP for the given asset over N recent records or None if asset is not supported - pub fn twap(e: &Env, caller: Address, asset: Asset, records: u32) -> Option { - caller.require_auth(); - let res = PriceOracleContractBase::twap(e, asset, records); - if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::Twap, records); - } - res - } - - // Returns time-weighted average cross price for given asset pair over N recent records - // - // # Arguments - // - // * `caller` - Caller that covers invocation cost - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to process - // - // # Returns - // - // TWAP (base_asset_price/quote_asset_price) or None if assets are not supported - pub fn x_twap( - e: &Env, - caller: Address, - base_asset: Asset, - quote_asset: Asset, - records: u32, - ) -> Option { - caller.require_auth(); - let res = PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records); - if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::CrossTwap, records); - } - res - } - /* Admin section */ // Initializes contract configuration diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs index a75c0c1..1558df0 100644 --- a/beam-contract/src/tests.rs +++ b/beam-contract/src/tests.rs @@ -83,26 +83,13 @@ fn invocation_charge_for_none_result_test() { fee_token.mint(&caller, &100_000_000); //get price for the first asset client.lastprice(&caller, &init_data.assets.first_unchecked()); - //get cross price - client.x_twap( - &caller, - &init_data.base_asset, - &init_data.assets.first_unchecked(), - &5, - ); //check that fee token was deducted let fee_token_balance = fee_token.balance(&caller); assert_eq!(fee_token_balance, 100_000_000); } #[test_case(InvocationComplexity::Price, 1, 10_000_000 ; "price")] -#[test_case(InvocationComplexity::Twap, 1, 15_000_000 ; "twap")] -#[test_case(InvocationComplexity::CrossPrice, 1, 20_000_000 ; "cross price")] -#[test_case(InvocationComplexity::CrossTwap, 1, 30_000_000 ; "cross twap")] #[test_case(InvocationComplexity::Price, 2, 12_000_000 ; "multi round price")] -#[test_case(InvocationComplexity::Twap, 5, 27_000_000 ; "multi round twap")] -#[test_case(InvocationComplexity::CrossPrice, 2, 24_000_000 ; "multi round cross price")] -#[test_case(InvocationComplexity::CrossTwap, 7, 66_000_000 ; "multi round cross twap")] fn invocation_charge_estimate_test( invocation: InvocationComplexity, periods: u32, diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 8b5689a..844fadb 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -39,17 +39,6 @@ pub fn resolve_asset_index(e: &Env, asset: &Asset) -> Option { index } -// Resolve indexes for a pair of assets -pub fn resolve_asset_pair_indexes( - e: &Env, - base_asset: Asset, - quote_asset: Asset, -) -> Option<(u32, u32)> { - let base_asset = resolve_asset_index(e, &base_asset)?; - let quote_asset = resolve_asset_index(e, "e_asset)?; - Some((base_asset, quote_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 diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index eb793e0..a6dae37 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -201,122 +201,7 @@ impl PriceOracleContractBase { // 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, - |timestamp| prices::retrieve_asset_price_data(e, asset_index, timestamp), - records, - ) - } - - // Returns most recent cross price record for pair of assets - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found - pub fn x_last_price(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option { - let timestamp = prices::obtain_last_record_timestamp(&e); - if timestamp == 0 { - return None; - } - let decimals = settings::get_decimals(e); - let asset_pair_indexes = assets::resolve_asset_pair_indexes(e, base_asset, quote_asset)?; - prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals) - } - - // Return cross price for 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 given timestamp or None if there were no records found for quoted assets - pub fn x_price( - e: &Env, - base_asset: Asset, - quote_asset: Asset, - timestamp: u64, - ) -> Option { - //convert to milliseconds and normalize - let ts = timestamps::normalize(e, timestamp * 1000); - let decimals = settings::get_decimals(e); - let asset_pair_indexes = assets::resolve_asset_pair_indexes(e, base_asset, quote_asset)?; - prices::load_cross_price(e, asset_pair_indexes, ts, decimals) - } - - // Returns last N cross price records of for pair of assets - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to fetch - // - // # 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 = assets::resolve_asset_pair_indexes(&e, base_asset, quote_asset)?; - let decimals = settings::get_decimals(e); - prices::load_prices( - &e, - |timestamp| prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals), - records, - ) - } - - // Returns time-weighted average price for 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 asset is not supported - pub fn twap(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::calculate_twap( - &e, - |timestamp| prices::retrieve_asset_price_data(e, asset_index, timestamp), - records, - ) - } - - // Returns time-weighted average cross price for given asset pair over N recent records - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to process - // - // # Returns - // - // TWAP (base_asset_price/quote_asset_price) or None if 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 = assets::resolve_asset_pair_indexes(&e, base_asset, quote_asset)?; - let decimals = settings::get_decimals(e); - prices::calculate_twap( - &e, - |timestamp| prices::load_cross_price(&e, asset_pair_indexes, timestamp, decimals), - records, - ) + prices::load_prices(&e, asset_index, records) } /* Admin section */ diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 9b098df..8fc7bad 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -195,11 +195,7 @@ pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &V } // Load requested number of price records with a price function callback -pub fn load_prices Option>( - e: &Env, - get_price_fn: F, - records: u32, -) -> Option> { +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; @@ -221,7 +217,7 @@ pub fn load_prices Option>( //(required for further interpolation if the value at lower_boundary is not available) while last_included > lower_boundary { //invoke price fetch callback for each record - if let Some(price) = get_price_fn(timestamp) { + if let Some(price) = retrieve_asset_price_data(e, asset_index, timestamp) { prices.push_back(price); last_included = timestamp; } @@ -238,57 +234,6 @@ pub fn load_prices Option>( } } -// Calculate TWAP approximation from loaded price range -pub fn calculate_twap Option>( - e: &Env, - get_price_fn: F, - records: u32, -) -> Option { - let prices = load_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 = settings::get_resolution(e) as u64; - let current_time = timestamps::ledger_timestamp(&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) -} - -// Load prices for a pair of assets -pub fn load_cross_price( - e: &Env, - asset_pair_indexes: (u32, u32), - timestamp: u64, - decimals: u32, -) -> Option { - //get the asset indexes - let (base_asset, quote_asset) = asset_pair_indexes; - //check if the asset are the same - if base_asset == quote_asset { - return Some(normalize_price_data(10i128.pow(decimals), timestamp)); - } - //get the price for base_asset - let base_asset_price = retrieve_asset_price_data(e, base_asset, timestamp)?; - //get the price for quote_asset - let quote_asset_price = retrieve_asset_price_data(e, quote_asset, timestamp)?; - - //calculate the cross price - let price = fixed_div_floor(base_asset_price.price, quote_asset_price.price, decimals); - if price.is_none() { - return None; - } - Some(normalize_price_data(price.unwrap(), timestamp)) -} - // Get cached records from the instance storage fn load_price_records_cache(e: &Env) -> Option> { e.storage().instance().get(&CACHE_KEY) diff --git a/oracle/src/tests/fetch_prices_tests.rs b/oracle/src/tests/fetch_prices_tests.rs index c98e8f6..4237f18 100644 --- a/oracle/src/tests/fetch_prices_tests.rs +++ b/oracle/src/tests/fetch_prices_tests.rs @@ -35,9 +35,9 @@ fn generate_updates(env: &Env, assets: &Vec, price: i128) -> types } } -#[test_case(600_000, 8, 600_000, 2; "5 rounds skipped")] -#[test_case(600_000, 30, 600_000, 2; "30 rounds skipped")] -fn prices_test( +#[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, @@ -45,6 +45,12 @@ fn prices_test( ) { let e = Env::default(); + let ledger_info = e.ledger().get(); + e.ledger().set(LedgerInfo { + timestamp: 600_000, + ..ledger_info + }); + let mut assets = Vec::new(&e); for i in 0..10 { assets.push_back(types::Asset::Other(Symbol::new( @@ -78,11 +84,7 @@ fn prices_test( ..e.ledger().get() }); - let prices = prices::load_prices( - &e, - |timestamp| prices::retrieve_asset_price_data(&e, 0, timestamp), - 3, - ); + let prices = prices::load_prices(&e, 0, 3); assert_ne!(prices, None); let prices = prices.unwrap(); assert_eq!(prices.len(), expected_prices_count); diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index 3b10830..d5075e9 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -180,89 +180,6 @@ impl PulseOracleContract { PriceOracleContractBase::prices(e, asset, records) } - // Returns most recent cross price record for pair of assets - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // - // # Returns - // - // Recent cross price (base_asset_price/quote_asset_price) for given assets or None if there were no records found - pub fn x_last_price(e: &Env, base_asset: Asset, quote_asset: Asset) -> Option { - PriceOracleContractBase::x_last_price(e, base_asset, quote_asset) - } - - // Return cross price for 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 given timestamp or None if there were no records found for quoted assets - pub fn x_price( - e: &Env, - base_asset: Asset, - quote_asset: Asset, - timestamp: u64, - ) -> Option { - PriceOracleContractBase::x_price(e, base_asset, quote_asset, timestamp) - } - - // Returns last N cross price records of for pair of assets - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to fetch - // - // # 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> { - PriceOracleContractBase::x_prices(e, base_asset, quote_asset, records) - } - - // Returns time-weighted average price for 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 asset is not supported - pub fn twap(e: &Env, asset: Asset, records: u32) -> Option { - PriceOracleContractBase::twap(e, asset, records) - } - - // Returns time-weighted average cross price for given asset pair over N recent records - // - // # Arguments - // - // * `base_asset` - Base asset - // * `quote_asset` - Quote asset - // * `records` - Number of records to process - // - // # Returns - // - // TWAP (base_asset_price/quote_asset_price) or None if assets are not supported - pub fn x_twap(e: &Env, base_asset: Asset, quote_asset: Asset, records: u32) -> Option { - PriceOracleContractBase::x_twap(e, base_asset, quote_asset, records) - } - /* Admin section */ // Initializes contract configuration diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index daf9f75..7c156ec 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -3,7 +3,7 @@ use crate::tests::setup_tests::{ convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, }; -use oracle::prices::{self, PRICE_RECORDS_LIMIT}; +use oracle::prices::{self}; use oracle::types::{FeeConfig, PriceData}; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; use soroban_sdk::token::StellarAssetClient; @@ -46,33 +46,6 @@ fn last_timestamp_test() { assert_eq!(result, convert_to_seconds(600_000)); } -#[test_case(2, Some(normalize_price(1)) ; "twap 2 rounds")] -#[test_case(PRICE_RECORDS_LIMIT + 1, None ; "twap exceeds limit")] -fn x_twap_test(records: u32, price: Option) { - let (env, client, init_data) = init_contract(); - - let assets = init_data.assets; - - //set prices for 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, ×tamp); - - let timestamp = 900_000; - let updates = generate_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), &records); - - assert_eq!(result, price); -} - #[test] fn lastprice_test() { let (env, client, init_data) = init_contract(); From 1051f255e045ecca362f6474acd496d8a408e79c Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:33:44 +0200 Subject: [PATCH 48/55] Unify invocation complexity and refactor tests --- README.md | 18 +-- beam-contract/Cargo.toml | 1 + beam-contract/src/cost.rs | 38 +---- beam-contract/src/lib.rs | 19 +-- beam-contract/src/tests.rs | 150 ------------------ beam-contract/src/tests/contract_tests.rs | 99 ++++++++++++ beam-contract/src/tests/mod.rs | 1 + oracle/Cargo.toml | 3 + oracle/src/lib.rs | 4 + oracle/src/tests/fetch_prices_tests.rs | 15 +- oracle/src/tests/prices_tests.rs | 19 +-- oracle/src/tests/util_tests.rs | 15 +- oracle/src/testutils/constants.rs | 2 + oracle/src/testutils/env.rs | 19 +++ .../src/testutils/generators.rs | 86 +++------- oracle/src/testutils/helpers.rs | 9 ++ oracle/src/testutils/macros.rs | 19 +++ oracle/src/testutils/mod.rs | 10 ++ pulse-contract/Cargo.toml | 1 + pulse-contract/src/lib.rs | 4 +- .../src/tests/contract_admin_tests.rs | 48 ++++-- .../src/tests/contract_interface_tests.rs | 62 +++++--- pulse-contract/src/tests/mod.rs | 1 - 23 files changed, 300 insertions(+), 343 deletions(-) delete mode 100644 beam-contract/src/tests.rs create mode 100644 beam-contract/src/tests/contract_tests.rs create mode 100644 beam-contract/src/tests/mod.rs create mode 100644 oracle/src/testutils/constants.rs create mode 100644 oracle/src/testutils/env.rs rename pulse-contract/src/tests/setup_tests.rs => oracle/src/testutils/generators.rs (50%) create mode 100644 oracle/src/testutils/helpers.rs create mode 100644 oracle/src/testutils/macros.rs create mode 100644 oracle/src/testutils/mod.rs diff --git a/README.md b/README.md index 7d81fef..4de24f2 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ impl MyAwesomeContract { // 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, &InvocationComplexity::Price, 1); + 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 @@ -239,9 +239,9 @@ impl MyAwesomeContract { } // Authorization is required to spend XRF tokens that cover invocation cost -fn authorize_spend(e: &Env, reflector_client: &ReflectorBeamClient, complexity: &InvocationComplexity, periods: u32) { +fn authorize_spend(e: &Env, reflector_client: &ReflectorBeamClient, periods: u32) { // How much will it cost - let cost = reflector_client.estimate_cost(&complexity, &periods); + let cost = reflector_client.estimate_cost(&periods); // XRF token address on Mainnet let xrf = Address::from_str(&e, "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"); // Build authorization request @@ -302,8 +302,8 @@ pub trait Contract { fn extend_asset_ttl(sponsor: Address, asset: Asset); // Get asset expiration timestamp fn expires(asset: Asset) -> Option; - // Estimate invocation cost based on its complexity - fn estimate_cost(invocation: InvocationComplexity, periods: u32) -> i128; + // Estimate invocation cost based on periods + fn estimate_cost(periods: u32) -> i128; } // Quoted asset definition @@ -322,14 +322,6 @@ pub struct PriceData { pub timestamp: u64 // record timestamp } -// Invocation complexity factor -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum InvocationComplexity { - NModifier = 0, // multiplier for number of requested periods, not utilized directly for cost calculation - Price = 1, // regular asset price record request -} - // Possible runtime errors #[soroban_sdk::contracterror(export = false)] #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] diff --git a/beam-contract/Cargo.toml b/beam-contract/Cargo.toml index bdaece4..5d03d4b 100644 --- a/beam-contract/Cargo.toml +++ b/beam-contract/Cargo.toml @@ -13,3 +13,4 @@ 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 index bd23ecd..7e0360d 100644 --- a/beam-contract/src/cost.rs +++ b/beam-contract/src/cost.rs @@ -1,20 +1,10 @@ use oracle::settings; use oracle::types::FeeConfig; -use soroban_sdk::{contracttype, token, Address, Env, Vec}; +use soroban_sdk::{token, Address, Env, Vec}; const COST_CONFIG_KEY: &str = "cost"; const SCALE: i128 = 10_000_000; -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum InvocationComplexity { - //Multiplicator for number of requested periods, not utilized directly for cost calculation - NModifier = 0, - //Regular asset price record request - Price = 1, -} -//invocation cost config is stored as vector with indexes corresponding to InvocationComplexity - // Update invocation costs config #[inline] pub fn set_costs_config(e: &Env, costs: &Vec) { @@ -28,24 +18,19 @@ pub fn load_costs_config(e: &Env) -> Vec { .get(&COST_CONFIG_KEY) .unwrap_or_else(|| { Vec::from_array( - e, // RecordsModifier, Price, Twap, CrossPrice, CrossTwap - [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], + e, // RecordsModifier, Price + [2_000_000, 10_000_000], ) }) } // Charge per-invocation fee -pub fn charge_invocation_fee( - e: &Env, - caller: &Address, - invocation: InvocationComplexity, - periods: u32, -) { +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, invocation, periods, fee_config); + let cost = estimate_invocation_cost(e, periods, fee_config); if cost <= 0 { return; } @@ -57,12 +42,7 @@ pub fn charge_invocation_fee( } // Estimate invocation cost based on its complexity and fee config -pub fn estimate_invocation_cost( - e: &Env, - invocation: InvocationComplexity, - periods: u32, - fee_config: FeeConfig, -) -> i128 { +pub fn estimate_invocation_cost(e: &Env, periods: u32, fee_config: FeeConfig) -> i128 { match fee_config { FeeConfig::None => 0, FeeConfig::Some(_) => { @@ -70,15 +50,13 @@ pub fn estimate_invocation_cost( let costs = load_costs_config(e); //calculate amount to charge //resolve base cost based on the invocation type - let mut cost = costs.get(invocation as u32).unwrap_or_default() as i128; + 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(InvocationComplexity::NModifier as u32) - .unwrap_or_default() as i128; + 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; } diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index 2c2f6d9..a5f4027 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -1,8 +1,7 @@ #![no_std] mod cost; -mod tests; -use cost::{charge_invocation_fee, load_costs_config, set_costs_config, InvocationComplexity}; +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}; @@ -130,7 +129,7 @@ impl BeamOracleContract { // // # Returns // - // Invocation costs categorized by complexity + // Invocation costs. 0 index - records modifier, 1 index - price invocation cost pub fn invocation_costs(e: &Env) -> Vec { load_costs_config(e) } @@ -139,15 +138,14 @@ impl BeamOracleContract { // // # Arguments // - // * `invocation` - Invocation type (single price check, cross-price, TWAP, etc.) // * `periods` - Number of requested history periods // // # Returns // // Amount of fee tokens required to pay for invocation - pub fn estimate_cost(e: &Env, invocation: InvocationComplexity, periods: u32) -> i128 { + pub fn estimate_cost(e: &Env, periods: u32) -> i128 { let fee_config = settings::get_fee_config(e); - cost::estimate_invocation_cost(e, invocation, periods, fee_config) + cost::estimate_invocation_cost(e, periods, fee_config) } // Return contract admin address @@ -174,7 +172,7 @@ impl BeamOracleContract { caller.require_auth(); let res = PriceOracleContractBase::price(e, asset, timestamp); if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); + charge_invocation_fee(e, &caller, 1); } res } @@ -193,7 +191,7 @@ impl BeamOracleContract { caller.require_auth(); let res = PriceOracleContractBase::lastprice(e, asset); if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::Price, 1); + charge_invocation_fee(e, &caller, 1); } res } @@ -213,7 +211,7 @@ impl BeamOracleContract { caller.require_auth(); let res = PriceOracleContractBase::prices(e, asset, records); if res.is_some() { - charge_invocation_fee(e, &caller, InvocationComplexity::Price, records); + charge_invocation_fee(e, &caller, records); } res } @@ -333,3 +331,6 @@ impl BeamOracleContract { PriceOracleContractBase::update_contract(e, wasm_hash); } } + +#[cfg(test)] +mod tests; diff --git a/beam-contract/src/tests.rs b/beam-contract/src/tests.rs deleted file mode 100644 index 1558df0..0000000 --- a/beam-contract/src/tests.rs +++ /dev/null @@ -1,150 +0,0 @@ -#![cfg(test)] -extern crate std; - -use crate::cost::InvocationComplexity; -use crate::{BeamOracleContract, BeamOracleContractClient}; -use oracle::assets; -use oracle::types::{Asset, ConfigData, FeeConfig}; -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{Address, Env, String, Vec}; -use test_case::test_case; - -pub fn init_contract_with_admin<'a>() -> (Env, BeamOracleContractClient<'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 contract_id = &Address::from_string(&String::from_str( - &env, - "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", - )); - - env.register_at(contract_id, BeamOracleContract, ()); - let client = BeamOracleContractClient::new(&env, contract_id); - - env.cost_estimate().budget().reset_unlimited(); - - env.mock_all_auths(); - let init_data = prepare_contract_config(&env); - client.config(&init_data); - - (env, client, init_data) -} - -fn prepare_contract_config(env: &Env) -> ConfigData { - let admin = Address::generate(env); - let mut assets = Vec::new(env); - for _ in 0..10 { - assets.push_back(Asset::Stellar(Address::generate(env))); - } - let resolution = 300_000u32; - ConfigData { - admin: admin.clone(), - history_retention_period: (100 * resolution).into(), - assets, - base_asset: Asset::Stellar(Address::generate(&env)), - decimals: 14, - resolution, - cache_size: 0, - fee_config: FeeConfig::None, - } -} - -#[test] -fn set_invocation_config_test() { - let (env, client, _) = init_contract_with_admin(); - - let costs = Vec::from_array(&env, [10, 20, 30, 40, 50]); - 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(); - - 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); - - let caller = Address::generate(&env); - //mint fee token to caller - let fee_token = StellarAssetClient::new(&env, &fee_asset); - fee_token.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.balance(&caller); - assert_eq!(fee_token_balance, 100_000_000); -} - -#[test_case(InvocationComplexity::Price, 1, 10_000_000 ; "price")] -#[test_case(InvocationComplexity::Price, 2, 12_000_000 ; "multi round price")] -fn invocation_charge_estimate_test( - invocation: InvocationComplexity, - periods: u32, - expected_fee: i128, -) { - let (env, client, init_data) = init_contract_with_admin(); - - 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); - let costs = Vec::from_array( - &env, - [2_000_000, 10_000_000, 15_000_000, 20_000_000, 30_000_000], - ); - client.set_invocation_costs_config(&costs); - - let fee = client.estimate_cost(&invocation, &periods); - assert_eq!(fee, expected_fee); -} - -#[test] -fn check_extending_asset_ttl() { - //initialize contract - let (env, client, init_data) = init_contract_with_admin(); - - //set fee config - let asset_contract = env.register_stellar_asset_contract_v2(init_data.admin.clone()); - let fee_asset = asset_contract.address(); - let fee_config = FeeConfig::Some((fee_asset.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); - let token_client = StellarAssetClient::new(&env, &fee_asset); - token_client.mint(&sponsor, &10_000_000); - - //check the extending - client.extend_asset_ttl(&sponsor, &new_asset, &1_000_000); - assert_eq!(client.expires(&new_asset), Some(87_300)); - - //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/contract_tests.rs b/beam-contract/src/tests/contract_tests.rs new file mode 100644 index 0000000..d3fed13 --- /dev/null +++ b/beam-contract/src/tests/contract_tests.rs @@ -0,0 +1,99 @@ +#![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}; +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); + + //check the extending + client.extend_asset_ttl(&sponsor, &new_asset, &1_000_000); + assert_eq!(client.expires(&new_asset), Some(87_300)); + + //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 index 13c20ea..ecfd32b 100644 --- a/oracle/Cargo.toml +++ b/oracle/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [lib] crate-type = ["rlib"] +[features] +testutils = [] + [dependencies] soroban-sdk = { workspace = true } diff --git a/oracle/src/lib.rs b/oracle/src/lib.rs index 22e9adf..b316eee 100644 --- a/oracle/src/lib.rs +++ b/oracle/src/lib.rs @@ -10,4 +10,8 @@ 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/tests/fetch_prices_tests.rs b/oracle/src/tests/fetch_prices_tests.rs index 4237f18..228af08 100644 --- a/oracle/src/tests/fetch_prices_tests.rs +++ b/oracle/src/tests/fetch_prices_tests.rs @@ -3,11 +3,11 @@ extern crate alloc; extern crate std; use alloc::string::ToString; -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{Address, Bytes, Env, Symbol, Vec}; +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 { @@ -45,11 +45,7 @@ fn store_prices_test( ) { let e = Env::default(); - let ledger_info = e.ledger().get(); - e.ledger().set(LedgerInfo { - timestamp: 600_000, - ..ledger_info - }); + set_ledger_timestamp(&e, 600_000); let mut assets = Vec::new(&e); for i in 0..10 { @@ -79,10 +75,7 @@ fn store_prices_test( timestamp += timeframe * rounds_gap; set_price(&e, timestamp, &assets); - e.ledger().set(LedgerInfo { - timestamp: timestamp / 1000, - ..e.ledger().get() - }); + set_ledger_timestamp(&e, timestamp / 1000); let prices = prices::load_prices(&e, 0, 3); assert_ne!(prices, None); diff --git a/oracle/src/tests/prices_tests.rs b/oracle/src/tests/prices_tests.rs index 5fed181..71c9308 100644 --- a/oracle/src/tests/prices_tests.rs +++ b/oracle/src/tests/prices_tests.rs @@ -1,11 +1,10 @@ #![cfg(test)] extern crate std; -use crate::{price_oracle, prices, tests::util_tests::generate_update_record_mask, types}; -use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo}, - vec, Address, Env, Symbol, -}; +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] @@ -35,10 +34,7 @@ fn invalid_timestamp_update_test(ts: u64) { 100, ); prices::set_last_timestamp(&e, 600_000); - e.ledger().set(LedgerInfo { - timestamp: 9001, - ..e.ledger().get() - }); + set_ledger_timestamp(&e, 9001); }); e.as_contract(&contract.address(), || { @@ -75,10 +71,7 @@ fn price_update_test() { 100, ); prices::set_last_timestamp(&e, 600_000); - e.ledger().set(LedgerInfo { - timestamp: 9001, - ..e.ledger().get() - }); + set_ledger_timestamp(&e, 9001); }); e.as_contract(&contract.address(), || { diff --git a/oracle/src/tests/util_tests.rs b/oracle/src/tests/util_tests.rs index c039f59..1183e56 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -4,20 +4,7 @@ extern crate std; use soroban_sdk::{log, testutils::Address as _, Address, Bytes, Env, Vec}; use test_case::test_case; -use crate::{mapping, prices, settings}; - -pub fn generate_update_record_mask(e: &Env, updates: &Vec) -> 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) -} +use crate::{mapping, prices, settings, testutils::generate_update_record_mask}; #[test_case(1, 0, 14)] #[test_case(0, 1, 14)] 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/pulse-contract/src/tests/setup_tests.rs b/oracle/src/testutils/generators.rs similarity index 50% rename from pulse-contract/src/tests/setup_tests.rs rename to oracle/src/testutils/generators.rs index d258569..ebcea25 100644 --- a/pulse-contract/src/tests/setup_tests.rs +++ b/oracle/src/testutils/generators.rs @@ -1,46 +1,31 @@ -#![cfg(test)] extern crate alloc; extern crate std; -use crate::{PulseOracleContract, PulseOracleContractClient}; +use super::constants::RESOLUTION; +use crate::{ + mapping, + types::{Asset, ConfigData, FeeConfig, PriceUpdate}, +}; use alloc::string::ToString; -use oracle::types::{Asset, ConfigData, FeeConfig, PriceUpdate}; -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{Address, Bytes, Env, String, Symbol, Vec}; +use soroban_sdk::{testutils::Address as _, Address, Bytes, Env, Symbol, Vec}; -pub(super) const RESOLUTION: u32 = 300_000; -pub(super) const DECIMALS: u32 = 14; - -pub(super) fn init_contract<'a>() -> (Env, PulseOracleContractClient<'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 contract_id = &Address::from_string(&String::from_str( - &env, - "CDXHQTB7FGRMWTLJJLNI3XPKVC6SZDB5SFGZUYDPEGQQNC4G6CKE4QRC", - )); - - env.register_at(contract_id, PulseOracleContract, ()); - let client = PulseOracleContractClient::new(&env, contract_id); - - env.cost_estimate().budget().reset_unlimited(); - - env.mock_all_auths(); - let init_data = prepare_contract_config(&env); - client.config(&init_data); - - (env, client, init_data) +pub fn generate_update_record_mask(e: &Env, updates: &Vec) -> 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) } -fn prepare_contract_config(env: &Env) -> ConfigData { +pub fn generate_test_env() -> (ConfigData, Env) { + let env = Env::default(); let admin = Address::generate(&env); - ConfigData { + let config = ConfigData { admin: admin.clone(), history_retention_period: (100 * RESOLUTION).into(), assets: generate_assets(&env, 10, 0), @@ -49,32 +34,11 @@ fn prepare_contract_config(env: &Env) -> ConfigData { resolution: RESOLUTION, cache_size: 0, fee_config: FeeConfig::None, - } -} - -pub(super) fn convert_to_seconds(timestamp: u64) -> u64 { - timestamp / 1000 -} - -pub(super) fn normalize_price(price: i128) -> i128 { - price * 10i128.pow(DECIMALS) -} - -pub(super) 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) = - oracle::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) + }; + (config, env) } -pub(super) fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { +pub fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { let mut updates = Vec::new(&env); for _ in assets.iter() { updates.push_back(price); @@ -96,7 +60,7 @@ fn get_random_bool() -> bool { random_bool } -pub(super) fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { +pub fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { let mut updates = Vec::new(&env); for _ in assets.iter() { let price = if get_random_bool() { 0 } else { price }; @@ -109,7 +73,7 @@ pub(super) fn generate_random_updates(env: &Env, assets: &Vec, price: i12 } } -pub(super) fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { +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 { 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/pulse-contract/Cargo.toml b/pulse-contract/Cargo.toml index 9804d66..b85e49d 100644 --- a/pulse-contract/Cargo.toml +++ b/pulse-contract/Cargo.toml @@ -13,3 +13,4 @@ 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 index d5075e9..deff1b7 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -1,5 +1,4 @@ #![no_std] -mod tests; use oracle::price_oracle::PriceOracleContractBase; use oracle::types::{Asset, ConfigData, FeeConfig, PriceData, PriceUpdate}; @@ -280,3 +279,6 @@ impl PulseOracleContract { 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 index 3720756..fc859c2 100644 --- a/pulse-contract/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -2,19 +2,23 @@ extern crate alloc; extern crate std; -use crate::tests::setup_tests::{ +use alloc::string::ToString; +use oracle::init_contract_with_admin; +use oracle::testutils::{ convert_to_seconds, generate_assets, generate_update_record_mask, generate_updates, - init_contract, normalize_price, DECIMALS, RESOLUTION, + normalize_price, DECIMALS, RESOLUTION, }; -use alloc::string::ToString; use oracle::types::{Asset, FeeConfig, PriceUpdate}; use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; +use crate::{PulseOracleContract, PulseOracleContractClient}; + #[test] fn init_test() { - let (_env, client, init_data) = init_contract(); + let (_env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let address = client.admin(); assert_eq!(address.unwrap(), init_data.admin.clone()); @@ -40,7 +44,8 @@ fn init_test() { #[test] fn set_price_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -66,7 +71,8 @@ fn set_price_test() { #[test] #[should_panic] fn set_price_zero_timestamp_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -82,7 +88,8 @@ fn set_price_zero_timestamp_test() { #[test] #[should_panic] fn set_price_invalid_timestamp_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -98,7 +105,8 @@ fn set_price_invalid_timestamp_test() { #[test] #[should_panic] fn set_price_future_timestamp_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -113,7 +121,8 @@ fn set_price_future_timestamp_test() { #[test] fn add_assets_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = generate_assets(&env, 10, init_data.assets.len() - 1); @@ -134,7 +143,8 @@ fn add_assets_test() { #[test] #[should_panic] fn add_assets_duplicate_test() { - let (env, client, _) = init_contract(); + 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"))); @@ -149,7 +159,8 @@ fn add_assets_duplicate_test() { #[test] #[should_panic] fn asset_update_overflow_test() { - let (env, client, _) = init_contract(); + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); env.mock_all_auths(); @@ -169,7 +180,8 @@ fn asset_update_overflow_test() { #[test] #[should_panic] fn price_update_overflow_test() { - let (env, client, _) = init_contract(); + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); env.mock_all_auths(); @@ -189,7 +201,8 @@ fn price_update_overflow_test() { #[test] fn set_history_retention_period_test() { - let (env, client, _) = init_contract(); + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let period = 100_000; @@ -204,7 +217,8 @@ fn set_history_retention_period_test() { #[test] fn set_fee_config_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); //emulate old contract state env.as_contract(&client.address, || { @@ -247,7 +261,8 @@ fn set_fee_config_test() { #[test] fn authorization_successful_test() { - let (env, client, config_data) = init_contract(); + let (env, client, config_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let period: u64 = 100; //set prices for assets @@ -267,7 +282,8 @@ fn authorization_successful_test() { #[test] #[should_panic] fn authorization_failed_test() { - let (env, client, _) = init_contract(); + let (env, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let account = Address::generate(&env); let period: u64 = 100; diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index 7c156ec..e89c399 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -1,18 +1,21 @@ #![cfg(test)] -use crate::tests::setup_tests::{ - convert_to_seconds, generate_random_updates, generate_updates, init_contract, normalize_price, -}; +use oracle::init_contract_with_admin; use oracle::prices::{self}; +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 _, Ledger, LedgerInfo}; -use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{log, Address, Env, Vec}; +use soroban_sdk::{log, testutils::Address as _, Address, Env, Vec}; use test_case::test_case; +use crate::{PulseOracleContract, PulseOracleContractClient}; + #[test] fn version_test() { - let (_env, client, _) = init_contract(); + let (_, client, _) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let result = client.version(); let version = env!("CARGO_PKG_VERSION") .split(".") @@ -23,9 +26,26 @@ fn version_test() { 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(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -48,7 +68,8 @@ fn last_timestamp_test() { #[test] fn lastprice_test() { - let (env, client, init_data) = init_contract(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = &init_data.assets; @@ -79,7 +100,8 @@ fn lastprice_test() { #[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(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); let assets = init_data.assets; @@ -101,11 +123,7 @@ fn prices_update_test(gap: u64, _description: &str) { let updates = generate_random_updates(&env, &assets, 0); history_prices.push_front((timestamp, updates.clone())); } - let ledger_info = env.ledger().get(); - env.ledger().set(LedgerInfo { - timestamp: timestamp / 1000 + 300, - ..ledger_info - }); + set_ledger_timestamp(&env, timestamp / 1000 + 300); } //prepare an array with zero prices let mut zero_prices = Vec::new(&env); @@ -165,20 +183,16 @@ fn prices_update_test(gap: u64, _description: &str) { #[test] fn extend_asset_ttl_test() { - let (env, client, init_data) = init_contract(); - - env.mock_all_auths(); + let (env, client, init_data) = + init_contract_with_admin!(PulseOracleContract, PulseOracleContractClient, true); - 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)); + 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); - let token_client = StellarAssetClient::new(&env, &fee_asset); - token_client.mint(&sponsor, &10_000_000); + fee_token.mint(&sponsor, &10_000_000); //get initial expiration let asset = &init_data.assets.first_unchecked(); diff --git a/pulse-contract/src/tests/mod.rs b/pulse-contract/src/tests/mod.rs index 07d84dc..0178334 100644 --- a/pulse-contract/src/tests/mod.rs +++ b/pulse-contract/src/tests/mod.rs @@ -1,3 +1,2 @@ mod contract_admin_tests; mod contract_interface_tests; -mod setup_tests; From 3e6bfa08faca321e79616ab624fa55648c3ec51b Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen <49230725+hawthorne-abendsen@users.noreply.github.com> Date: Mon, 2 Feb 2026 03:29:56 +0200 Subject: [PATCH 49/55] Update Soroban SDK to v25.0.2 --- Cargo.lock | 116 ++++++++++++++---- Cargo.toml | 2 +- .../src/tests/contract_admin_tests.rs | 26 ++-- 3 files changed, 110 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 953cc53..e18e25b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,17 @@ dependencies = [ "ark-std", ] +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + [[package]] name = "ark-ec" version = "0.4.2" @@ -195,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" @@ -634,6 +651,15 @@ 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" @@ -655,6 +681,16 @@ 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" @@ -1221,9 +1257,9 @@ checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] name = "soroban-builtin-sdk-macros" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9336adeabcd6f636a4e0889c8baf494658ef5a3c4e7e227569acd2ce9091e85" +checksum = "7192e3a5551a7aeee90d2110b11b615798e81951fd8c8293c87ea7f88b0168f5" dependencies = [ "itertools", "proc-macro2", @@ -1233,9 +1269,9 @@ dependencies = [ [[package]] name = "soroban-env-common" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00067f52e8bbf1abf0de03fe3e2fbb06910893cfbe9a7d9093d6425658833ff3" +checksum = "bfc49a80a68fc1005847308e63b9fce39874de731940b1807b721d472de3ff01" dependencies = [ "arbitrary", "crate-git-revision", @@ -1252,9 +1288,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd1e40963517b10963a8e404348d3fe6caf9c278ac47a6effd48771297374d6" +checksum = "ea2334ba1cfe0a170ab744d96db0b4ca86934de9ff68187ceebc09dc342def55" dependencies = [ "soroban-env-common", "static_assertions", @@ -1262,11 +1298,12 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9766c5ad78e9d8ae10afbc076301f7d610c16407a1ebb230766dbe007a48725" +checksum = "43af5d53c57bc2f546e122adc0b1cca6f93942c718977379aa19ddd04f06fcec" dependencies = [ "ark-bls12-381", + "ark-bn254", "ark-ec", "ark-ff", "ark-serialize", @@ -1292,15 +1329,15 @@ dependencies = [ "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey", + "stellar-strkey 0.0.13", "wasmparser", ] [[package]] name = "soroban-env-macros" -version = "23.0.1" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e6a1c5844257ce96f5f54ef976035d5bd0ee6edefaf9f5e0bcb8ea4b34228c" +checksum = "a989167512e3592d455b1e204d703cfe578a36672a77ed2f9e6f7e1bbfd9cc5c" dependencies = [ "itertools", "proc-macro2", @@ -1313,9 +1350,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "23.0.3" +version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdefc9240bddd3ff4d47fd4d8f8dd44784840e25a18e426c6c987db8572d6df9" +checksum = "ab9d1bfa6f7d57307bf8241789b13d3703438e7afa0527aa098a357ef757d3a2" dependencies = [ "serde", "serde_json", @@ -1327,9 +1364,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "23.0.3" +version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cb0dc3eb3661962cb8833513953b5839df14d589d96f8370b5b0c3870a8b3b5" +checksum = "9953e782d6da30974eea520c2b5f624c28bbc518c3bb926ec581242dd3f9d2a2" dependencies = [ "arbitrary", "bytes-lit", @@ -1345,14 +1382,15 @@ dependencies = [ "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey", + "stellar-strkey 0.0.16", + "visibility", ] [[package]] name = "soroban-sdk-macros" -version = "23.0.3" +version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eab5f4e5f3836a4b4aeecb2837160e944621b2f8dbad775638a2ab8e10fd5bb" +checksum = "8a8cecb6acc735670dad3303c6a9d2b47e51adfb11224ad5a8ced55fd7b0a600" dependencies = [ "darling 0.20.11", "heck", @@ -1370,9 +1408,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "23.0.3" +version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd257b0365307e0b8d38040ee0364abcc610fc6e61960ff5e26803922d098921" +checksum = "c79501d0636f86fe2c9b1dd7e88b9397415b3493a59b34f466abd7758c84b92b" dependencies = [ "base64", "stellar-xdr", @@ -1382,9 +1420,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "23.0.3" +version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec3c72de91fdcf637045f3351df029a98b9de9ad22ced4063f74d0b5873f526" +checksum = "b520b5fb013fde70796d9a6057591f53817aa0c38f8bad460126f97f59394af9" dependencies = [ "prettyplease", "proc-macro2", @@ -1425,6 +1463,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" @@ -1441,11 +1485,22 @@ dependencies = [ "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 = "23.0.0" +version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d2848e1694b0c8db81fd812bfab5ea71ee28073e09ccc45620ef3cf7a75a9b" +checksum = "10d20dafed80076b227d4b17c0c508a4bbc4d5e4c3d4c1de7cd42242df4b1eaf" dependencies = [ "arbitrary", "base64", @@ -1457,7 +1512,7 @@ dependencies = [ "serde", "serde_with", "sha2", - "stellar-strkey", + "stellar-strkey 0.0.13", ] [[package]] @@ -1596,6 +1651,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" diff --git a/Cargo.toml b/Cargo.toml index 62fbda1..c2de536 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,4 @@ codegen-units = 1 lto = true [workspace.dependencies.soroban-sdk] -version = "23.0.3" +version = "25.0.2" diff --git a/pulse-contract/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs index fc859c2..3f4520c 100644 --- a/pulse-contract/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -11,7 +11,7 @@ use oracle::testutils::{ use oracle::types::{Asset, FeeConfig, PriceUpdate}; use soroban_sdk::testutils::{Address as _, Events, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{StellarAssetClient, TokenClient}; -use soroban_sdk::{symbol_short, Address, IntoVal, Symbol, TryIntoVal, Vec}; +use soroban_sdk::{Address, Event, Symbol, TryIntoVal, Vec}; use crate::{PulseOracleContract, PulseOracleContractClient}; @@ -57,14 +57,24 @@ fn set_price_test() { //set prices for assets client.set_price(&updates, ×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().last().unwrap().1, - ( - symbol_short!("REFLECTOR"), - symbol_short!("update"), - &600_000u64 - ) - .into_val(&env) + env.events().all().events().last().unwrap(), + &expected_event.to_xdr(&env, &client.address) ); } From aca84e81c92b6e47895c9353c7896487e877296d Mon Sep 17 00:00:00 2001 From: orbitlens Date: Wed, 4 Feb 2026 09:57:46 -0100 Subject: [PATCH 50/55] Add Code4rena audit report --- ...flector_code4rena_audit_beam_pulse_2025.pdf | Bin 0 -> 703390 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 audits/reflector_code4rena_audit_beam_pulse_2025.pdf 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 0000000000000000000000000000000000000000..9f8333bdf831fe1632374c440f01f56a664a2295 GIT binary patch literal 703390 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 RkhpT@5H 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

Duu}%nC3C=wI|F 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ͅRLZ3O_0+uzzZdnA9mN_)ChV`9*9vWJ(r-&u)kh-V zg3w2zS`zxPY)=E2LWp8idOw>6NI=uLHZq=^vbDjV%1=$Uy_1+R=g4|A*zmAX+|^^x z%>9-2k{(XuIH`Den6vx z(FtZg&o+8*P<3tQ=?rZM){=Ru_w}hfuc9IA-8UO*EJ3kNsjyd9 zQ)t_;+^*aq6dt1!uhB-YkkmhszDdKhH69iaR*9!ZRAfB*Mx6h#$19Hx7Z=!!;RJ>h z_ICl#r@7qYKld>{N8A}36vXVci>O8{Vay4>-(r67z!s}Z1NyYhM2q4^n)tU$IRa{x zk6UiO6c`U(p0z?etMU?vLlUmw1l`fsdUVaNI*Q5^9zax@q{AfV0Df$T6(_nqJwfu- zbo2S=T1@HpyrI)ukYSPetbht30uXgP!ZOzO5q%fk+*Ts9?=Xw5t=tuM44=ec*91qr zTjoFYO<8|p;rZf=(XwISWu+Wf1wGdE6crl#s26SKCqgCe7l|hY>IwS7NkO!9&snKa zc34?CU2S+u;dhCy=I;vNobyy25qN4#p6T=Gq8|`-HbvrZn_v7iXAfdiZh35NvAn)5 z)GH@$EK#%R^Tf7litNr{Pcl{Xmeb3F4}|rIL4n-w$F~~<6$s}7%FFn z#nBrdxj-Lm4L(KMk{glk8>fEhX`}F25y}00|95yv)#@yc<$*j7gjOEJF$yImy}Txk})-h*2Kh zxC(V01JL6V7 zP7&pOO4lH1%6nVOl!SHVjl2f-5;9}K#ZmTGq6$wZaZ8E3woU6DwucG+Aq_1ZzCv%l zi78e()VjvgaK{g(OYf^YE&+dS_eUZOWRrUpQogf9{{Cd177>5cuDCNMXZTea>f-wnpCSaYJ)~FQ*>c#rYf)*EF>bFimCmlD^g+&JxaQjFD$V9LHBXM^RDDns_dv4gYlKFR zu4A}kO7pnoliw8#+3sUL*)IKY;L0@DRpEozvde?-9Jakv?Sm##7H-s-V2KMcf!R(Yr%p;wVx>)M86}4e~wHE1Rtp# zi+z-d-<6>crnr6mon{qRa4#GCCSmry+^HkuSzp7$yQ8>>be-2x&lMhb^7nNg-%cd{ z;#vrAPy?ywNZBh8raJyFMJ|h^|I&e|h&UiO4o=<3gbckqt5h*1b zUll#cYuFn)BDK_?XQnowQ?E7DYPZuQj{4;^my^6o7Qs8zHLh+H{ozo*78jrTpi_O> zxa0UrDSfZG`^G9W;qf>}1|jEkhFg9w`&HQ*W`X-+y~dwjz#deZq4eYrhqiv#%dhc^ zm-|vlt|;-TnPd$u`kDWp5^MX!Q2;uyX9R!Bl1ZI(!&pil@7~jb&+<+WXeA?s#*07> z>A2io@7_m7PE#HS5glf1Y>%6xbJc{yoO$%b@uLavjiwZ;@&gOQ`M!%Tb%*E&^9Muf zzw_L+4j1qrCQv=7tn-je80j1PVCD2|dUi=`BJB}r`mnfFa8^y}L1EU|u}Wb^P9FU2?|!a%@RA?K_9Mw(B%Oqgy_fLP82uS4yZ2jQb*VpsNGz$G zzVJkr?{}cyYh|sC>lMkYoW7HpV{49c+2gAv3Gb2uqj54&W86DGtsBidBkyo;v?xxe zi0f&*Hv2hW+PU@nX_J*slK&OMUp~Q+1B*KT&PBQ5Utb{khbQwZ1NHE&rOc1~%747; zOzO6+s~x$)Zj{xT#2;}jwg2V!qVPig+1QYzT0ZEX}wNU z2LrI$7iuiH64zSSD(M~(c}AZ)BbPsMcnKaJCbex8RWf=%Xt*}B(Yh`fKV8doFMK|U zq^kyR`n3>o06dHx!*A(oC5A!=P8=Tf7o8VnjeO5NHAS!_!eJZ~w}RF)#bW7oG#m5@ zKSng+3v6j0;02Ik_wj@nki_ixQA=nY$PlJt8JhPwi6%1`cNsI6Mcb4;Ne{D<62dhh zpnN2UC#k-8rzhwZfj);}wMtM>{}XD`7QJiwRCKHXcj8D|Q5bUxdCo6sZf>@OBED|2 zVR=jZ0uA@>z9?wPM|Dx~S6?4Y{f&W*&k=HUBH)bfWBtU;Y^{Vy@#2>;J0@(S7*(u{ zIZ#uX^v~^7RC|oNm|(mFqTR~R{7&!U?N9!~J|>s>ZjD%H6Wpq5bfB%Gvm0!qf<0*x zd{VzL#aP2gq)w6+s(|lGZx~rRJ zq~!`|4KD_UgP4flR6-r7uLhMv{$BL_Um7Pp<4s;(t&kaodo`J!&o z$l*U;aJ|dX?TzwQC36$sx6>d2_uQ8W62z}Ffx$>>IfYmh+x$|%_dQx)h2@8lTSq#) z%!h(&vDlt5NYh#oFcK@-gnb3y$)#Swh-B*7KuL3m=_u;#;>$_RO^xs}Vkk{8!^*v| z{0KeO@-U|^3l)jwUuqI}%% z&WbM>*(IMLs!K!pP(DQeZfyE}(AAErFPxL+wHr4HlD z!$Y0zdqX5aG27+yRX8c#N?P~HpUT5BLX)<$zRCZ#t7|lXCz6-m)}*Mst|FakAOio` zl5c7kUGjrZjO}x*n5>`1tkRW=mfv6xYtkZs%+j>Y&3LqFckoePHzg~a=7kLo*f|KC zc8`#|tMJSM|tc z9?M7;%M!sy@`O9WLw=$Ocl|O`*vuo^=8{$@+zA9IEJ3tIHKa|hn1upIpLb4J&S3-&N@U&;*D7byBXP({qDc+(MR?R(8MvyY{+v|N6I-iKQ74x9HiSyAQ2w^bKro zsh_eLct+uDB>0bhUS7nLBN&MP&gM74qTpo3xWWyQAYxI$NxWE zysT%XZve9~u+-twv$EjUvDY`Tv*m_>AaG7Fh||Wv&>Z-&RyLdtP;Og$T?-RiTN5iw zTW$qTFcix60QhqT^IyjR!~eQF{r}eCM}VP-^BsPUB5U_?EpR91=+rS5HKtw1>PDUq zHqZtzXHsd--a7SitzR_^A5(zVj{R))67~K%^wu(Wm4h+}9+^{eWGlb7E32?&)Hb)> zYDIeL3R1A)xo6b^J$lpcPSlW;DZl;xL3icp#+%-wNi7eLy#?+aV9T1z`=W@QPs=|R zfKK{)k$MvISHSj`w$7*Wr!r{1}h|bFgBDO6OalO0a3@6J+l}Qz+Yd(m`*Fp>C8%?{X z503Yj2CzC6ATl(wl_QQ=^nJsf$*yy*hp0dlots3Icwu<=#4cC?y5O4Kf@oeD2{$d=}eF8ztjyt^i z)cpD7T|0qay}NPycsIU>?(!@?Heuul5=>7rdcd+ySalyet-^7Vcq2s2_Jr8NXI=Dw z)pdj1m%HMs-fuXeRvn*sBd+E#UsD}1M|cJGJK8K{*Ev9hyym~ zrzJO~Qq*{)sEFcSB3DC-pldf9PIxjjV#zZZ9Ev=*TIw>dBuEaICI<@zr_UW1^U>?1 zihk{t{AO(RT?az=dImU#VBZW`{e*p~ZT2zaFS6;GrpFpIDLpr^q-nTYnZ7#v?nlWi z1q&SdDwhjTpbm{+NCm_@xh~qbCOnhM2|d z8~tg&*tpw**;o=a1W!nW*~2_;gWT zM#iJ|H=M)j4O(Kr7a-d*Va@`~QHv99WMK6(SMV64k|` z&sH?6-e%jJ5dHjE8r{HZ-|@r>EkYKPTask`u3^S5#k2E%XM^SN5KDf>>#}9+!dfN9 z$GK0cSM#IGAlwpW*`|=EQ2VFqW(hAeamYr3M%)BH@dW?8wp+D>nek0GwTk^PTPg3S zB*D{O>mPnfA~;5Da?Bw0*j%oxt;JIB%6%nv9jW1ESF@}u5d|aJ*UOffv-_nRCi%^2 z%Ikkh%sOQhVC!ZmJr#++Hn0YKJSw~2vHT;jjaohG89C0v_d2@XrRm{Cx{NjBiTlO# z%FJzM`q*ZPIf9w~0TJW-?SY?aElFi#x3B7DIFT%apc-OLt3-)GS23<$Y04QWB_Sn@ zd-|gt{*#ccQ8A@2YYAYr!DH$V2>0g7w|%o4<5a8IeM@)AB7{|IRn0Vd91MC(ltyZ^ z7KsGby~*I&Hl*OK6t$O090={;H;s0Ysl30a%7M9uzE_5LLz7lvVKizJ z+lw;>t6m|q_R7pGC2)&*qSs+Mx#PP&Sz5=_iTBA>o5M~uRDJTj%9TQm42H zTz^{Me^-y{aP;+a1~sw0-@YY${9wYGpH?zFnkgBj@-2P!=>Qt;aZ~ zS#f}&llPWmnb|y~xu>CV^?AmW$xmVT3L&tAkZP)StsKnh61naNL>x?H*jWG-Ol84@+dPM(A zVVA33SCS_SRm*hqS%_Zyubns(rXbd^+t;}6qI^SCIjZ7h)#A3z`lI)qsZ-r-~9q--gVM=D-kPv-ANH03Wlt9@m96~Ii->iEd z8z+$Ht}fgiQTm?!d&>mG*Ziy3fql?puiTxYU&E&IoO{%5h@d?F8u!r^`=WOix4sWp zO{Uh3iac^~|Lyv~MRk%p>8jILB6i%Pmj%<#6(1fC%)Tm@Job30K(4&v`6|Pc6fr@2 z^WgzGvjvTi{BC%~uh)^tJ#u<)r}>vGvDM#*a_(105EGOUwH?yQRPtRTKMi{Op3=N- zHBfsnMR!_J+ue64(}spj9m~8nrpUf!s9kQ2uU!vRWIt;$+@WVBmOa6SU<3JQwT=uX zAC&RE`fcN`8mHLw5xWzT*YKUG{k>oT!Sh)EPN ztjzn}?fEM z4K!7rY54va#ME#~ncJzXok1@I*%fgQZ6}OwiBOryF8#*QRUpE^s|!wL8V(Po6>g>> z!BD6nAlIA1%q4C!fE(20-1O#>l4A|Se~Y36swPYDJ`H0QoJu2+XK{iIkf(lq$ihMKzjUg-XG* z$5@7(&h8Ur*FP5!CO_DaYOW9H@R9aWNWR1TR6^i&$$L`U#EFmg)$SjlPUfK>ra2`e zUhC(BGB%5ehuV42T7bZot6yV!gz??ec)#DL(vc|_dd-L%EqHzd*3Wd--=GY_)8 zn@m-msEJ0>ou6uH6<8;xbPvr|&qi_`(iTIHjQ#F&*ogb`ys2h{R1Th=ZS7*w$ z&MO`y&Q|FxA=?x!59%hrYn*wy_X>nzc{S)+T^+bGX*NK_lTcSt!j4J%;yOTg8jn>_WL z-P6(h<4krBDNEJ2}m^Z*IQ5 z_|Eyui{}zC#1*P$UnrhfI}GwpEcTPIP<8ATWbeU$-x&^1h27p6cvvocn~(6M)_J9g;r6ERSY_HpHPBIsHy?VNuF{NS)!hGz zl;QUhwl2v5^OISvCg^~xPU6X`>eUU7hW>Kl+03T9=zjXE#4}ODUO`7*E*xneLmy{+ zPpK|1fO!{^9mzCASHGxIlUsDyz+g?9YgK5+xliX`9@^`$XEstEMCdNNS z3`pxY)Fs=+LA$?KihoOg;!8uTb|$!4zkDG+lX;vkBrelGmNHOoL-I@H!QC6D#dB`L z$@?KzePbHw!=pzjn{o2lZx=8Q_PttvVt^EDsAV)5@TrxuP-Emh6tQ_u={51}s~Cz3 zAL+=b<|tyJA2}ymTNFxUG&D5>kBR!6Qe-vA38*Q6?Nd>tl(C-<+TOI{flaVv`f3oUYUAQ_#c_Zne+Z`O+pvA(kehYmvnFHJ zX0T%dv}5g9*!ZIwOXvZ6N^Q1-naxft=ue|>H#l!)SM;g(d-q@|X?l$ptis;(Fu_mX zP?OKS?j+TyRl|^s%P3j_*$SEGAHOWHE3D3p8AOn078KK`6&M#yKIUAta8z#fvfNIieDESrNiy{&MP^!G$d~@)C7EJKF((MW&4Rd%*j!or>gRFFSgOH~Rdm_e+#v2Q zX)m<4b;Z!=dYpFXm+*M3Ev%QaW<+h65@GqkCt_pZ6~;gR>lI~H4!Co;rEH+i{tRg^ zC(IS#`*sT2n%ZZt8li}#n*Uhk>c@#YwwmSlr)uTsYfKS5aQ&%>rmc#B zlf7wl`~Vo?Q#zVcuwFViOp*28+^ei7H;s#ZGlQrN9`>k$Z4E zmN`CE1>K*?KcZr}@9Y)UxPJU$hdW2CUI=_3mR8oT0Pz+VR+UjmDd==OG?!>o9hDA1 zXNaonb~yLN#4eFAVb&Jb(SgK%ESJ{bSKnkvdr-o3OIWnKjYNgbx5$%THu_AINYQ3ZR*gWolQk!7l(bv-pViz*d&yt+;EIW1JP!MSK~gpk|`?*5MT1zvby zVirw@Sxn=E?TWD$)pe6{u_`XNY7xt<-v}o3@>LV99_M(SacfCwiT*`tGkHzMd_^3A zJqVuvP2QODzz#tsSIN0X&AAMJccgK{n8(%khcv6tuf(pqj3(xQW!!hlkShDf@>9<~ z6cM?-Y~AW!?w zPWgr3zf(-AlG0mGO@-MZo4 z)rmc&q(jWM_~EHfe4Zt8{g6zS+?oImvP$ZTkeEHuDW=utvac@A_CB;bvg{uPG~T>5 z{ut{_yUJy(FTAH$@*aDQ1Zi)RdBQks@u@jB4^k;snaH>-w(@@#DvXz zS{I9PaYD;4`Hr{8w>rXKo#Nb^gVchn+Z4SG23E&VwNC@WSbSP7B3!=B$`c@xWWHO-Z zuRVBom(yW=>|6cS>OxM3BUeJlUS0q9Bb2;951s|ld#|W1Uwb~H+c?rw&je4rNsw=q zGre*oKLWfGcn;1;(IC@&Vg1z;VLN+H2ZBPg9N{Vp;AIxT%fwxZOmc+f2-3}RuvLIZ zr_a=uyBdKXg!z_|mye`q(B$sS98)k264-WNij0Vnw^Xm;o7`b-(16Gjgd3^EN?)XRv6N!gu&vru1tnc4|F2Y?_W zZg_a}8*bt8l>w~L{6WHIOZDYz_riJ{I34DR5+KzD06astWwqs~3>z*h03M8YtN?gg z=Rmh=I-$NoAn>+&pS{NX?7e9&kp}*0h))A=P}MdIN(`&%(e-}-yhV$)%cPFQi>P6z zp<0mN4>B;*a*kTWP!ZT_5FbK1q)utMrncN>^Y~d%qNsaDiY0D4Ln1tx$G!iiCX*rV zNa0|dQjS$l?fupag^ba%&>Wh++0d`P1NHsi9B~bZXQE>`?N!6Ie&{x)J}Lc4x&NBdze!Y?0c)4x=ZUC#Fa`ip zSZ>i}>6T-&0;I3;Qy~LpuscQZA&pekXO95OgGCISXQ3dI)fe*G+VZBQi=a%4FST8T zpRNj*a1(dd9;?&Z6?eA=zBml}%I@~Tgg)>2?f7qr9W8l%cD<^O@gs%sAo2r7GXraD zMmkFN7i~JY#)tFSx%qB(7@Xhu5Wpwm8$%*Nay7ARJR`rm!EBL{?~*hg*CDGQb1^h* z_`nsrrwU&^dpbqT#vWv3BpBWB-*;EXa_+bkRYUtF3$ zC4TjfxeUnvS}qLWvgblB_>)#P?!K8W3J)!|tQp|FUof3!DfYCVG4>Uh2rRq+8NYV- zX_SOkQ^~cGcdB3N%I;pXGHU+BArswA!R_9(8D=g_w}{JsRL`6=x%F{FYn--{;`SR5 zDbzud(CjWWB0%l+y; z@qx^fBRWjl9Hk+h(t86It)-}wAV^P*gXD`Z)9xWDlgqsFZm%HifodCXWOovZ&0i6> zJL+s2H*I-K!je7Q{gVzcm{M{n&8!-_%N^)m)H_ila|`ia{J7m9tQ$VJp)NUn3{Sne z#a(;_$w2h#_hM1zc(`koev9wBA7bV@1+MOuu@u&R2WGp|yE+0rLUfLYhxG3GfQCam z!A7;Vk(-x(SG^7YAWuYuG~GSt+qaD$INvyV8jk{#2h2+Sjh;q4+~{QQ5WC4zGGC5` zDN$fgsu~T`i>t%`Le}qCMay^4YBdWSqu9ZS%G&GoNb8*zXz419O6?w4x|`v~wZU(| z!Kw66Dkir!a?50H?`mCh0%dw za4rxW3_}1=AV@AW6arl22myn+P$&clati?ibHS0o=;nVMo&qQ0f1CdNwZG%PX*>uD z!G#9FpydrDJEdW%d{O5 z0y*cJs0+I$6ouph!x7-WZ3hD*fh7os1BfrtGa*p$Idtd?qx&;-MF6V}j)30+qu^W+ z6bgQZ7kC7MAuna-ibnkt8{*;vWiS-N1wuoS@LO;+hzkaR!VtH>Xb2Y)Sc*VQ0l~OH z2q^gf1)Y(Jow2>{zsAXD!(~q3|Fnet?;~YyU2`j4ZU`8GL>NN#p>U{yJ`!R8hw1~5 z4A45j7f^5*1Ofr;fc11C+*UR^dgcb)wl;d)&uvWf3~aec2h_7^%B2+RdTBGKSWMF;%U zbLcKkabSQGBEayo#rAKspt=7IpL4e(JA ziVH}85cpZB0V)qL67fHu&lNb4uyY&^d2zA=2E%~p54a#;y?^6_fPiWd0j$SMg$F^N zE1i%Rrz>YAE00Q011P6h^&IQejla;@MCIZO?M?(;R#1{+&G9F+&;ARmp(7k{FJ*dlwnn0)P z9KMUw6)+S)1w$jwqUK)!|6M0sDn8`7l#aYOVF9C|0I>v$xxe8-fHDv9Uu*9Y&4)yv z&k7f3EN8NJ5E2R`fIr5A0MP~sM}yB|>E$^m_U4xDY6S zpj|3F1o#{wLtUJ)K!HN;49Gv09uxs&LI@0Ks$Hh_5OBmlLksXrT{!C@&-z0U7*H$x zu^tQo0|+Mw=s;X1JfNp@4&KEn3k(>fK;fvf9PkGo9N?QMpq+QQYYzoGSI43*&RPCS zd_d;`s1blh6{zEcciFfRF

hEFK$T-^Y)SudL0 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

SIe{Zd{h&`FAXw{F@zph)n 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

W!zw(HsW%86;d}6eN016Re5#427z*oWryq7?zgb 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 z zLF++#^TZ;-m(wy?a3HP@T27(lVAP;}d*Uo~tivcWx8fu8ah<9Rp*Wz!4t1?Irwoia z+RBGQEVFbSpr7ycNh8gnG?fON4YEuqj)`PKNTo>T9a4JG$e@t?u)b+|_!pGUT;Gqq z+PYTQ&FOLC{GNdTcxQ1=baaAVH%Jv#Bo0hqP8kKG$pVYX>ql}WfYeu2?p}ZddJa8Y z<`!WS_kTAVLPta*-yC*?pw2B_DEc2)$zrDn?n5MIn3c zPb0G}q!c;W-a+VQOLj%E+4P5Mn&mPU&V?CKTEI*fG`NW#5r2@ofc`wa6o%{4-|R>m*7~IRhD;sg9Jr z=Ib7bszej*c|((xzJc60lW8x$M_ODl$nVn^alcB#f6H|bZq^qcQL&diM*1dU^D#;( zr=aoxXOx(FH?^j@XG@+<<@{N0LVjJ$rY`jxahnlFwmmF|Cod92)b@NSRo=BsF}^MZ zo(bDAROl2rTkZLC>c1A37pfdZe>rQZK*n9UiRI%0y=>_2BA+lAvZ=T(YJ+=}UW0!j zGYfY8>ARPUyd6JpE(s{BO6Ta6@2r95^jN-oMrUa-Go=TEBu{NG8fW@D@3@JXYz50Fx!Wq|&XgUPR}jrT z;hl16X+f*J+nTwo!dbGKDGFg{7tgE@f1$s~?#`J7M|0J4{pMt=rq4cV6qdNJ>Q)Ga z>&1Pm!k2w}ou18mvG1Oarn;ckt0(N?v#zJ-_;!$}me06F9d*m)_h%`2zvrx3V!5ne z?@?0cImzX8J;FK0wV7|0SYFR-YZjT?bBi%~USh4;0nKyVtKSFoz0W?_-l{iWeRA=K z+pm}Rp9|(yKS1SHD$0vcSA0Grtn${Lpgks9z7uC;3(L=cA;>M}=66rZA>V+&P*{dGE&C^;$Q6HeSB_>I%P-H!6!gOVW0}y2h38dXu?-@2Yv?GOi-m z+3W6=9DkB~PTpoq=iQK1>BP_0d58Qs`N_}qj|x6f87Q2!G4O$uci>IHQs;H2y`3LoGmQT!QQRj zVb&P+WKp3+diLcukpoNKsjGy25^WTGr(Ve39kTm~n9#!31N#r`Xk5^kA1yt-BOv-v z+94g;lR`0Wv-*y}AErq1YsaN*Q8A8no0T&yWQfLbRn%zV#GXU4I|Q0}CmH8DTox)Z zG-|lk5n1T2(eq(a#q1+~F78hyEZ3D^eIH3~l8h7mq!NU{>j=nvi6v}y7@OYD?Wj*Ps9$suGj((vDGUlzFi?_6ggZ058P^vEZoIHn!&!+ zTitw#U~jLd6vD&tMs@PsT5$H$=6z$(_>j0V|s1FYf*yInW6h1gFWUuEfP?cQ@xuw z-qL+ z74L;7k69%H8#RYQ@&g;#9$xe1zj&-v`h~hNe;K!s*-Uno$}i5F%~|2}OWkmZy{Qwm z7CkDlKUWvl=vaGIF6UWcH4CLVWajA|l}xW$)EHyK{<`m04r6A< zc*NkS(y>1%G&J$1Y367tBDeZGsi&f$;q2rrqho39>0sstOpb$@wYvy#W2@XPES)?= z7!06ym~UYIK?aHgfspgCblfOHpitT5l+9e$SlU|Kz+chi;jh#^%p7bjRykQaSc;6< zqz$wh5x6xfR5LL~_GmLnGhmG@q3&UXRsyDp*&$|rjX@h#_3%eG#z|!TD2-AwbGKx^ z?;m#R_Y|G7x(f3_=1ZYl@FzXz)#@r5W-cOf>f4})wjMi1#JW4&p_A%PR?Z^Jm&?K6 z*t&bT?GRbK3L0oB$tI`ew$0Mb*2!9A@ozr^p{}Qki-V;jbD*F?A4bhUgeuScx8M8q zlf@EH4mJ9sY~#bFb_Mb?Rr?2n4bL2ESe>}~j)0)VRJp<9nu@n$i_?$C1}%~M(jP;1 zV&h%oacM%edVJ=wZ6cZ8v>TTPbLP)H zIrDk--S$VaMsc6rxA2EXOw~-AtH74zQCfAmW8acPFCQ#8`{KbIsctPT^I*>v-|RxK z2uoXsHx0}7_xde-#wsPNVtM>=*c&CinfiMkSR7YgtH-@!>e5sdEfwE|K3!RM`!{rM z*6%TD8rD)3bWl}t7BZlN)E>*)j=GQ0yOx z%0Cp~C(G*pwlpQ&T>m@6`)8akVeHXYj&SApZINkH`r)?c=nu0p`)w0sd%r%YvQL$VCO?P`61*;>F~kz0sdHkEr8(#4e*Jm5m4SCZ-q3orSnj3!YK1+1O{Tu< z<$c^j)@`a=$WGtET{b7_imid?mF$B`R##ue?sZ>nF>^D^z5Y(wgEac`Yw``+_YA!5 zC5zMjOa{*lQHq_D4s$QA%589YNV{=lJBbuIng0iCGQj0A$3aQSdAo=a?R&}y zs>XjhWi+C#I^SRN$JmQ7Mh*l4`Ol_|pb~^A|EtO}ek#L;hiyWt7w3)6;- zO&pCJp79fl^zXBKqcW<0W<}tP!ZF7aIc`)BTf|_`AL1Qc$=0ZOBXw#Z!{ghxrK{96 zQ}<*w&&T!u6-^T?S9oQcv)dr4NOu2RAgo zpHnT~mom7Xp7*4^O?CawwR)AOwTfcY%6C>i*rmR3Xen_)M)JXi-V2`XZAGHqgRzx#a;v6)tQMY{z8w1RhtAckM||5>2bPVP_KdcDa3|lIqgKn*%0swZD#u4Y_Wk zIa_7oos~w-Jef`4q4g#L z^+V(f_2~sRy6VTT2d3Q$mQMRHY)08kpVz7vCG~#5bJH|R<|E4sjTPM*vtr4wR>spi zj&v`0Frj( ziogGrUI8)U&Nqb4#az=5C=hdG9xKYJ`cl;NJWQ?DiLhEp*jzB3PdFaM|&p>A8B2vw4H-q z)z|Yv-`q1T(Z6`QwWk?g3pk4hwF}-@B9h2v1 z-SrJxUT{QYlhvi^jn&j^0#a#0`VJ?I6DO8xhZ%dA#^0Z0Bwx_zbHh1t!b6V-aZa?d zaFK?69~bpszgn<`@0HZmD4p`I2kWecT73Ek#H87?4a&RBjfg6zn(}vve?0P`%iGV? z`1V{*(&vSj>h%P#-E&$~Eqy6LDBDzg&d>?n?HpS3LhkJ#N;m-%j@}+&x5{>*RlO)BIUEu8MQ! z3tf;p_8{!BFJXDY-mBH$TrRIDc3s}_{M51gikBkq8n=bjZ@6A>y?TH85`j>iHmTPnj@xD!IS-U0-usDvl|8(R?E6lI|;W58^(El7- z(f%c9W#niPtXF?sg$r#oT7ff4zE`*~{sp))vLj&}xbh?L{&~0pp+AvXwfcKF1!48~ z(Ec|G1XZ?HR+es-P8OCBQ2hov{%`+DApC=KcSbM?KrU-|0zk`1&CZx=mysJ~{;NUD zPtplA(pbzAy^eWP4V+UPBrC$xEm6U|RrUVx72AlO1Da*!Qzg@?hL4*nJh$~TNq6<3 zei|vxGXDt?yPc^=|XF%OI9bxQF%NE z=(_jZJvXfD$Sqtpd2(XS%do>A1PVMOsT}$2B+sNbt6hDXT!uUyEcpj&3pzav_bJ6n zMD?weoUaq>d*{H}n4yanw=W-gZ(??;xZN*{YQAaDF#`{F;ns=sTokN!)$NUFPFf|O zVz%~Sgn6@ZjF#)7Ss^A9K6C7+`m%iBIV|#?c(*z{k7c_|L56hU1kuN?ED@pn7MT;u z1U` zIphJ|!G59%b-j)Y?M#ODf`x(iqghgX(@uV!f0F;x2M#u#9p`NXYvayuESwP9p3Bdr zbMZ!WiH3)n9;Z6b{@Yw9pxYU0=zRUGx20t>mQI911R})OMZ^**nej z!103{y87Nu+SZ&L!Rhio)%29VH&yi6%qed^RK0&m^jbc}C0MmMR-dKFRdvz4IX2te zC1zSAuB=P1Ej_Z-Dz~(d<9zBg>Lu^l%{R6PM<+I=p1obp`)2>7eFxtv=Sa3}A$?Yu zeKlT4>nxQ;pi7l%xLwi7^~R=eG_Q*al6v9iov3rl+`Ak^OdCFSrz7qJx!RX}eO@c*%bjnz}!yj z%+IfJU24to7j6phR&zfn{=a(3l7 z48h3s{7Vc0GYtR5rKO{tH36zxnJl=njhWj9H)jiXCX)`EJQ*W?^}FMRjg)kJGsnng z;2)a!TbU_?Mj*n_{#ltRl|07Hz1w6DWu~6V*ZSWFHuqd=ey`Kq;~E!N@v@Y+D9+|! z%0^#r^|gL7!m(a`2fT{*izX6z<1e>}Uph+O6}{`eSA?+U+mkDIi7wvqP{*rGK-28_ zZi_2w_5=H?#HTv&sLs9YAM!*-@V5BSQD1M#;Mi@fWyem|-?`y#)%x(E_f3Yo)q7&O zfP2&F%&ga&{O(;Ud2P1d?WC6joxqrFG{?v?gm!WZZMnO(M^kpF|LV^Yi?3*wcfNb^ zk*hyc$)B&K_eIe|G3!wGgM>L2na&k2ublJ|&ek3(eR6Yq`->v3Z{1gJSv}-8JJ);K zJ>FOD#7*|5ry}0TMn0z9hofE%I~NBlu5#`CqGWZm{#rj7%2<87cIegKD)`FY(sk;^ z_OFc(Vq*QXZm_=e7Rb(#m8r1gmYywNTp!Bt-%fCEiM*`+iM!XL(x3|tZL#U$_{v!< zzM)?(F1E|KZqU7Y%8uk`a>gcCd&|Sar|kYF^+3@&sr$Ul#s>#ZDQyfav{1IlemFh% zgK}b@@-oE-#Yc{6oKiI}>m&~?=ZhC%O;LY8CfZ4ymW*ZmP1E z*P5l@c^~yq$E>&SF0IpfvGas&vmO*x)vS^bllm5Nyd*31OLuz8f#u05J$_4`uWjLw z$d=l)q@^Nv!&Ce6{c{W!hF6re&U@3JA^Ws;MdTLl7Y#!o@u&n-Ith^@n@Zs!>*;bhI_ zD-KL~cP_5X;<9G7Re73IMf&>$_cp<)^X?9LO>?w<1!I}8!=<4}|Rn#wxuzR?XYrRAZme{dtGznSUbeN>kyLZ_-P`DO26e2T3q!Z~yyj~+sEK;w66H~& zq2sk|=U|I^^0ULq522=diDc(DQ#Z*nn)t`w^}E{ZKJ;~cd&;Fi*wgk#ql2qUYPElb ztJcetyS_?)5*`Tl+4{luNuPhr&8=pg7FW`=I;ASUOl%GQ=3Fu`*x#b~;fr8$+4IYf z-%-`&UhKDgQ5|rY@5z1cs0zYC_jY=e=q|NAg%Ylb+0S_f_qsmsj5#6eefuSk3v1*T ziA1@cgfjmalW)uU4i6G)C%=Cj^C<6Jz|-u!Z`W7G^2`&S$6K#L)HyWqZE<{=xb=}G zw9sW&PiC@TX^K35X}Z5?=GmjJCW*RP?z$4Z>AgPmj|MwBZuAwYYVJ1Y&A;2;8|1)I zCX=A-?tP7Qhk^C{exJ45*I7Gjd1qIf_WStxHH6(~^IheaYi{S??6CFKiz;PZ-Tj;z zT6v#xL)E*SzPcaJ_P4b1%27PLbO%wDYiE>T5T)n~X=5p;+Tan<=iBaolFydR(%3Om zV8>K}i5{#f$IsR`NlUx(^3pfST=YCrdpAEd(dDyLj(pABwG%FK7S#_MizRu_}u z&Xz>3`|rAAhXXB1?@vduhVl1g5?hw$FC|>^8Teq#qIxrOc=g-@$8OS>Hs$?O_rIQ< znmhNi|88H!w+B7U+gnI0^=VFRwUX8jTOvD3E`!;crU6!(rD>pqodcT%x>`2R$&eA3*2vMpAY~yO3_f1u>Ue(iYHl=1 z2yA>YC1_=@LtwYf@<}2&6E1&#ov5#Gki7o%g@VtWfrstVCu~>vmUGQo@{HsMajtp3 zs;!YpuLO9{d~~_ZvMg9sJ%>A2S?p9#-;E?^b=BZGJG*=@2diCqX&8E>M!(L|=Vs0M zYcl)2CHAv9BwOs1&?*-apwr6~g&aFKZ+_u->}*w4`lYh)JDQ4qc~foHc56Hrn*RRC zv>AoO;FeNw@flL39WOjTH0qexeGA}!tT0c7A*|rHMR@aE!7X#M>Q9`=%{{TfW%2gA zyoN`UwuEl#QgJtTk*^BrZ*M9ryL4V_plkP;q7dt8bE!d1Z%rm0VfCJDqU5zASzmu} zd5dP=!fd|!#(PE2WJ$Zsny$=zQ2(w{P5XG3X3yZv+D}IfJq53naD93gt|n0-Cmvq0 z%&J-0x4(~5B`@5w`@-xCPgIP&SMSWMH=KEhLsUGRyf2}nV{_-Hgs=6VUQ?CUf2$l; z+iYdxe&v4TmjiGbL2yo0r&p}_w~JyaA0$-Ho>Y=Js&DdM>>}Ikvo;q!h3*QGyyLli z!%{6~bA@>^)=hoomwnoT;S&-U>Jg?$&id?{X~v#iZu<0%;v%kf`HjUBuf9_zieJ1k zO~>JL#OC<3`q7)u-jC%0cVtIF8pHL7o>6*ZU8JbB6D{5aUuCQMk zZEt&+m8Q5*qR}$Z@`IG=-5cr|6V5EG7OY~M&t@>e^5&ARQXp6|+2A`*zz!iMh84E;q!Hm!wQ`p#P@ESm4gzlGn<=aTW7 z>wJ}y-YHc-{cN9AQhIVSv&-(!O>tL@Fh zYcfnNWKIR&RUwAXjd{kV-XWGwd#9DEBdJ)+Eo-5f*`8v0Dn)Q*ouX97nv8j64$+qw zk7_e7Wv4&!6BpaQIOc(2gif1TIG;w&jKm9*_B@K-qrti8Fb}7URMKW1>D{vJUS_oR zsk1x$s$HoWI*a1pUTJu2bgz9NDRT;o4fzShlf&I|nS+L@ja0nm0O@ghN-SH7-mG}_ zlVw`@&Brg#*e}Z#Qn@tX#Qp2DRb?{n7+g&Jp1bui@p77Y-9xPz3-uIN zwrZQ(=B$61aB_g}rRpLd!Z**yUS3jF8i5yh^Nt9XEZHR!!FKoHy1sLwMMZw%)v^yf z(lRm@Q^Foin3<_`GGy)J#WC@3TZ&g&ic3n-j}$z->zML1=d#p`xP)tYEz;xsM!Ae;ArT){2U2{NY*=9mNw zW%9Y{E{qo&Gr9H_`>RHi{*dw(k$~mt)aihghSaIvS;?j+$GDzBUub)0D-@#j|m*t+i zb7F1kBmSX+maJb);yiVe45#y4hb8t{ce3#|NCK@%Z&ob{?oWr{* z!txER1D6bmJKve#61vdo%)fK%8Mcz-z`^;O7Pi{!6m-Z3P2S+U&CzkwJT-Rp#s=jl zMF*dhxE^FFV4a!gcaqzV?XGQ9daUQAw8ccF8TMux`a?DHt#{@jjd^Ti$LdR6BH<|sCk zRjOi) zCdZ(9_E?*e>iP4GlAvm?gsHiSDktg`QaB!`Tn-L=&J#)CdmKPx@4;t)<1mie)ja?QsHNN3h&7oB*@b9G|J^%CwDJezl8JP+C}jnn%3(N z>ArSg?KgcTbn}Ggvtw6JP<$TWyzTi*~R7A#lnxWgXs1>#=ErQguW{8Ak?Y2p4+ON_UyIX9zD)B z`AHx4Y)Foo70tPC(&n(ZB+7Fh!HH8$?4LvR?0l11d^c)aRy%7e2EV?Vs?@oE`I$q@ zB0^^$a;-ei`MRMjn{pyb`H<^@sKKQc$}_oV(x>YVE~VL@S#X3xN+GeE#d@r{8s)Ku zuC`QEu;$_dzPS;R5mzNo_u9Y6ST^x~#xixEq!is0>$R!s711|W`JD0C*BYX{=5dnf zHXBXu@<&Oc4-c15+q>h@0<%3HYc`3_=Pr-GY93LZcy-Nn)5eAEva|Wsz2Og=iLQq| zstQ#ftq_-ujJ@Of?b9R^;<|;Cx)Wo!3p83Te7Qtb`#Qh1qo#Ecx4QMyC$AYthbniN z`aT&di>zx37Sjo5U2^!zw`onyJU1;5rhIT*CmPhX(%Z#1pS3J9{cD6+6#>>;2u4=bG3oZ@XcV$ECOrf%ZAxUGs*F+yL zO>%?a?a+Im69KgU2|h=CUq?`dMFgm2=iT9PO8@ zWRszO)>9^U__|twVyd%Bt>B7;IU8IAZ)bQ&$s|uPs(4!wcELS8L1E1tc3MK)!)4Ph z+)gs`?(PUWR~TBztL^+~k>1HYlRLh#76W_l)^{-YL75 z)A2Enl8A=Uy&}#f&skD(AI;$_p}$wH-<2EVDIdrod~2Kft&`Uol(m}=)IYpubh&b! z7`tZEOP9UU^(p4+Y)Wo<8@7M0wB@_j?3KKPGqhv%MxF^(dAbtzR-5$A^BZR$G6`L| z=qbm>W1erX-mo7IWs#aekX|>lHt$$6pi+)$+G~txP(fR6ANP^Jn_$0}?4tDYgLXs8 zvamC{v~8-Ln$wGljz*r~{?@xq*|sLx-?Mms+iHHHlDb@Wj|I=BEM2mhFKz7sPL)F& zr!0Mcpj?HwN^FBM>oey`M|Bovct1FhR=-0l+IMZPo}9)(P8C-H`*y|8M_UOyHA9wq?qO3G{TI5t=f5GW@~??rPcYszC8D|B4MogIlOM4A0pH zhrH@;*DboL@peIfoYkd@?43Jp&fSa-Of%Me#IN0$?qz0DKq$LtvEGhstyNjt)}iEZ zwPGRHt`Bc!r`2!w5nlUV@^;=^7u%Ecch9Xq^;OM!sqXmnl9Et`->v0#%EyMLygZ&f zAxBCh@%Uv+9@XTP7OOt4yE&sz{q@<+Ex|_YIdZxcA8Nx{%a53Kzc9J#!YZ|CEBnMO zotW^tJIR98&mwou93;ken(8|TI8NGakh=a93u(Hb z?%PaJ-QwU){MD8@JsDG<@k+L?qGcJMd2vhWap$cd)x$E+rX=VqWcl3OJ7^KJ?l^t1 z=d!Ttw0)^J!y8430<18b|`}-C!-|eQHMoG_`Ha--9sO_ zVlqh4`1)mx+)?&p{r;IE(#IUu8T{W(P+2Wrn#e|&KQ*@};PQ^xi)M~Y@D2a<}pSWzxnOnl+ofZe@ z)BaS=IgvQ#Xe{H1GUl#`yUGBI$%dI4 z7ExV|S?UkBHu_GVmUr?>IJMX)DS&z}VJc6I4n?ocT_&Pau}j@!SlVZew&z3TclrxV zO!j>Y=-Oyyr5W^qcWu;*JF{+b@%r7<+vQy+;{0;FDIAr@{v(t5*X3t*e@%XN++O{4 zB~fEGCBc@&vkI3K((ySjpG!Bfi9N9wWozv&b1PHxUk}#?>gClFXqlm zL*(gMA=z7NMSAH=xu4EDM{?D*q(yNmiC;BpJD|HRkl&L-#QFZwMHe%d8NO;VxhHM3 zZS|tgn-#{7UUJ`+(_Awv+GcJhJta}WW8lebI4AT(4xAIZLbO%Rbt}u=OK&>rinkwf zubgW5_`u1H8G8oZY$rz!w>hdP9ufY2PH25b z{>-Y$|5Y}PSxxefEE+UzWShyzIo=GoCt}1NpjPZBcXj-YR_rGun7_x0joMB8XI6|# z7z51UY;W!ta0w9XiPxbEf?C(USbBJ$xbK_o^RFNAkX>cMae|k_e(E*X$+x$ew`p!TFlFn0)6MI??U};sa{2Vs-b9(o#^`nPr`=N>^vrDXaqXRaC;4jP z-Qvq$9sz_tQMLxri$@>3Ikm@W49+-n>8;E9z|FJ_&F2kw<)v4de%3wK?YnNrd|sZo zSp_jL0z1 zd%nF~ReVOemfS0$kk>tRq*te=U;W}dGb$&~)Bld+b0dkc$wXPAPug!8;lpYFOD*nHkJ!#+XyW8Gb!yeqy>8#y@&w60N&lfT{AS21+S z+Dl@0QtjIt$70SNxtkVn)UlH7P{w=ZWzQyS%~~$TXEdqg%O*|{`+IeL_hu-NAs`TH58G~;0x~^L$1(S9U~~Ai|tO&P18zM6RGpB4}7!V zx;Nrv#|o~Ti)`J!#3X*d9O4XjDZL9D5}G-~K6s`zD4*%coA|=hxoupZJ1X4z!#n)} zWxqq)|J>FJ{a@j8Nh5d7{xgE=KUn8U_&!YEH-i3%BPj6Cf5GSexLpDXBtVfzDk6Wv z=Za8hf7zCb-!DBGH=ck-j_jBC!~6c&C<6X^71J4y><<~en*wB`BE3IbpMRe-hJ7Hv zJDVINGyYtJ^!U=B@nmbQ4eQeMPUMrm1zL1aAimJZT zwkCGxExQhKcI7wr(6<9S_8u-<`C85fr@ym%Vd!$6wGA%8X^4+>`2R?TvooRm7_aWj;D*4>{lchEC`sUG; zd;K4++Md%p`Bkb;Twf)-IbHhsS&=%~=TaR{7Z+FXgm{1Os|(5LHH|a*LY|kpXrxy7}OijF(X*bcw=}lUZO%>{EExKp41J#UeS6f z9~LHkY!~o3dHu2FW64~-^|rSb%{&sYrLJOuwaAv5eGaLOU&C9>+jv|1LJ2)4o5R)) z1UJ;0o;`ZcYyYq>iM?d<{pSh+W-=EA#8UPI-F@G1!?I$JZr&q~<9fXb^;gc7W|i$u zyfOWQiFe~1zD;RwS!Q>?t=>KHh0_YI4<9*l+vUE^2^35XeP&-FIIU>~YeuV&t9j?< zt@kh76{AhmHFzNGVji>aRh9Rt+0|9Yb{MAf8az$Esy0K&gXLwM?qIUx zqh;G%qTOCyE%9zG(R##sQ{O-Q+pX%^Y2!JWk&DcKLR;ZBtT88@L}=I*%Cp0!i=f!Y zMGd0a9QzhniZJ(Fu(~*iQOLR4Nvcj;>*k!P30*aA&Ge|cxBjl|H*k=5!A+bDlz{!ay8Xh$4vOI zkCF-h%~3MpC!=Hnam;m%j-l(gCoOkW_*9x(e8CSn;%v4iNG9J{wrCA*k zVz*t|{EhbN>k=-SdUjz)wy1B4GT$n1!P2B;k~0v?ciLEvLO1=&!~1{`&8aNfv?S5# zPKs@+`p3K_I_25Jn&rYmuFvi#`Q;9t?K|lI$l%)EL)&k&BqvBI%o^7U{cHe58gu`& zaYO|(xnA70i*0_MvTNi!0ru0UBMQD|I&d}AuC?3baEiV^e|TG}M`mECLho>)#C><~ z_WX4sii?j3%?>Fyx+`{W+svs+N&KEH)$tw%Ewt<~CE^R`b$73Qw3z8QLqzdfp6Ig% zmn^f}5({44U&v6r^Lpq-_WK$Ee;2i5$GO+;DLO21qhsKVHRNF|tgm%F^4iu~(Ve5Z z_skm~EsKC6F}uK&;k_r%OHFy#moeFA=weN0p?pH3xL0zmp?*b9<6u^eTu|P!p1t`k zixLbE()Agt2crV%;oqkyzc`LE_$5Z0JPHH<5gq<@+~tpuY-}6_ zd*J>Bqy3NKD8f(2QOrJ5Nz}jIY=1Z1%KyN0j~L?bwtB>L{}EFuGfnq6F%{vzKBj{7 z+JA3MMf}Me(*G_m{NLq;|F2(ONPrs!er_oeNn=l+L}Iejd8PtZOCwbMZJx2vb0g=@ zeZIJS;gyS>?7DC6s&U=WTCSd)x30~2(r3*(y+=$v>bYgj!~*3Tn)B1b@cC41f~9tb3T56Z$ug0dBh z;C7{D5R~OEVg_Y0%%IE{f-+fVP$n@FlwC9!3CgS__Ej;1vV%wU`!-2mIMzRy?m}Cg z9C0OoHA#9_s#NM?SGSpM-Mh{Un4jqxnsPeb#c(_aPyEGE*`{CN;K`#i`@h1$kGaM0 zH=^`kxW({?-i{ZQjZ6^y#m_`#OgHow-emZlE@is+-~RWH?lJ^NJ~l!d@sQuWz6!Yc z5ub$QF^T{65gPHoIYJ};WQ6v=yBGg=_u~JrcQ1nB`FF$rv6OAlg#fc7-1kl@lHW}f zG*agXb>y)={M^86pQMM`sr~g5$)XpB-MdEM0s9eCw|ediW{U#l8#{l82ZkO1510q# z+&C8S9Uf4sI8zUCEcyWt=4fuM`S&>6Jk9C7pSE6P9B?JRN~qld+Xq)v2fuxvc8ty2 zoypnyj^=E;esH#DR9uY4MF+n){5SkH=wOr`{}t$9%pHusp@VHD{Z(@XAjZLqPxYzF(L6wOS#-Rd$@cyYl`|nGylKz{i0MbuV0sl1z zpA+Oq=U4tLU5&+aV-6G5@Oh)U*RoWJ^JVOr;fZPz>6K~gA1?TI(=Jw~_xQl}u6fA? zaxazxEAgJ&mKO}?iY6^noGLCh;oH6Ukv4$^b^9iUhuk`>>b5JO zpyqVN^{p0P?#$Ua=}z>dLl()s8BH1$)AG$z%dK9WeizNWEthZBvy8+Wq0{-|(l{bN z%_d6jowVbI)V}(!ADz5h4Hl<2o~W2AkZb zAD5Yt>F|f|H=)6qqRb95>zK$RWkA2MqG`F7i)C%Ktlc z{(q;=|9^YxJVAc+T_1n8hW{BmBXuCt!SLRR=Z`Kc-hLy>J2&PaqxqFVSyqq`k8sz{ z0{4N_zEDkf`<3L_YC3C;8O&I6 z;SN6sX}IvkZP&yTA};I3Lk#3!%+4tN62vgN{OMP)Gh>dQ{~a;>D_em_^maVNFfu{^ zB0clBuNA8SO7! zo`#J99d;#Q&(UEl$G%UV!H{QO20Z2)1ky-N#Aq2AW{-Kn>lhi4KwzG7F;+$=Qjsww zlVQe?@dFVJ9cMC?F3(K6jCmhTp2FP5HdaPtFgN^h}BcsDmA^JyQkZEu(!I?0d zq*1Ae9)OI_ycKlpZyCTbV=@x65DIyY0%zKwG8z@pXNb8NbW{dkkjG_YbS=QGSyXiG zgN%-SgDJ$`2a6hQAAvw&W3UBd9e-K6Mq6e4O* z;W;wuk3dFaR?ClVGptr*tr7@S_%He#m4xXjfl6f_?K1X#R60JtGO3T!00z<4Aiz02y_B!pFu{VVPi$7Fb^FZ+h#hQS(HCk#u#B05E+qzjUO00Y|a@p z<~b$EZyA_Q66FbG^nFAiA<_AOm5TZg0ug*S+7ECGm|Y_hh!k|3i3GgOL;?+)OR#Eq zo8iZ(-osf7sGlLg-ImNdj>e1ukqBS_m61j=++&|35wW=>lE~Ox!e>z|Kp=vv#o7my z9ySJK5@zp-WB@v7`(UV0TSBCOlSiKevxcr|B880ENg`|<#M(k(p#B`-Bp6Th8&u4P z!gHA42N@0XmqbuW?7K7~69JDMdm0(DyF?llv+YD01GBp@y(ngY?QkT_CK3UwV11(l zk{H_-5@;IwTPmz_RG-0Gk!a|AkmZRe&LNP&4B^kgV2%AQJO_&(l@aj1fm%_}@gswg zMfC+-0ESD*L}(!T4Y-^Rog*@cP`x5Utbz4|2mu57KIRBwGTgqvQo!_uOrm2t4v_|? z<78+I)@Cvp^KWD_AWF0?0PB!BCy*%w%qNopvSQzWKm{9n3LUe5WGWa>^t)8hN=ych z6_e4J2ab$cA7n5&*mr3J6nle(C1L)TOoQMGeIJdEtwAzfo{G&SeVjJKl0oeinGTB@ z{RW+Z;VUwjNvyvRZeer8pfZo#89fFhz!s>DfPHls&ZdIyqWA^Cq&zl$Fm0IqgP3HT z=Wx3QrW5@Jv=5y8*fvvP-Y`5)rBbjMf(qsU)k!LCJ(ULk z9orW8Egf6GRIr`ccfod|dH|LfTOZ(2u=PP@U|a!#$^b8lwuM2){2(wHm_4OJ(2lkb zTnc7$07YQB2Jti%9Rr#?3LG;rNmn@KcO{~%(gqWVmr1HeM;6_rAyqS%p2fm273 zwF`4osF6(fXnh8Pi;lL1z>o(oI(ClWw^&>Y2!w`>J>36@Vs!W|omo0H=DXm! zQJW3Rl0-$ulqgTeVmgq~QU6AQ4x_#nm;wrFi$M$IQ5^yq5smjjMnUx+WHeOoL5A5_ zfZi~hWBUs-%*KKYT8KSIM|F)zATiUYW5y3;By^lXMnUZm$Y>}o0oll@u%qV#&^^Xw zfQ*d#C}tFZ`T&rP(`LA?4Qn&dO^6RA5-32}p?w3zLhKbhH*&7+*fvv{hkzjC0=y{p zThIh#eZUOSQ9A^o5NHDWKAbax=U~-f&(Y{u-{>O;X^m|&ENX0AfTF<0fDYS|(QnYf zxT7)#1bUc^gxUz0TndUOK}N&Y2NB3?^cw(K(e(xc51tkIE)nQwbo_|&43sY>!lFSj zI><<<-31vKC-fT-@S}4MjX`|{kmy*<4k9wjWq^#vJkD&)9Kj5swjC@npda)(5*B|j zLt-?B02v*PkDw_S9s(H=#giZ-Gf!|H+dc>lusH%ujkOOjHL9l&VdHZIm>L@c3LW*0 zL?YxDP(2084WA<_1;vi=TNfr()1u;+^fsGkwG6nA&AP{T~0)2pDZ&)z++6PyL#ue}!=4VKt&Zz!TX-uz-w2y-G ziA?GN<+MOXXI9FL=^Jzd#eYN!@QJ9tfO*FJAmmff7#4mDfD-)%s1+{5{UD%3Y~Fw_ z!Nv;M1I(^LQ&3I;h%YP-1sNbpw0*!QqH|6GJ`o#d;1jWaK&XL@De#HtT7@_TTSpWI z5nD$vd??pKqyV3YzYlKnMEU_q7Bpspr3wHGdk%~D;W@m`)bIJzG5SJfGE?aL!0}_> z0Fa4}9~JmSbgZa=8qmH`nZzFY4HyON8$dXr^FakXj=c}~MC^Uw8nJ!=HG+Ku%p}$i zGRCU_8Yf}c0)kyMmSm!Kl(z*6594h?1_1~<1{5;3E&+UF>k{BDhEE~$g!u(PaCn<> zP9M-I?gPM3qPPSS%m9hTwwVev2L0aD@IL1Guw!g2FbY^~MPjCjk>?--4vDp~ zzXjRIp|_*gAjlw*gFT0>1!xNcwO2qhKyZ)z7SJxrA3(AZSZ?Gwh(hr>Cs9!D27U_@ zg8dez36o*ClSE`-xfpPQBReI>i~(d8QLF;bF;Ke!fd z;np}ZEOvp%Xz2QdUxJlIzXACL?7NT#L+6M&%-C~~{>7hzL>>AKpcGJjft8QV2Q(SA zP0$T2p8^#Lbo_ll4`JVBkWqX9RS$q*koJ)w4~FUnP|YZ2C6OV-LDvTv0vhancsxx4 zCKA(o;IlD(fv6G1DkNa_P`m*$ECz$=1tmiJ2BaV^W1w*qWVbNR7i7S?q3@&PHUcaY znzIHa#cNIgv7$B+)C$;tsq9!-mcWd=(p0DkQT|8v)n?#WW`h{(aj>6&ow4Qb1!^us9U_eFO$(n;75&(7r)8 zusH|FfbxILsw$LcB{3j9gSD9o2pa7NB(qSxVn9%W%dj{KpbAz`2rCt<{Qwz`xtQrP zbl#w}5!C}Ys13^%fec8avHf5|B(%RE!|X1|7$~<1NgN0}(f1Lt*b3@xp{fIY4(i;n z-$EK2nM*)Ym`wy3mX9Js?Ik*wAj9%FWI($p&ITEVEkFi=3fg7}{;+;9pb8Rw4ipQq z?cgmT+C`p&0$$YSkeM}*h;1iBt_by809k?IMc)U!GO~W5lmrSD(C0u+v92M=BG!>uTYx5Sb~i+ICRv;LQIV5Bn9*ydk%~?CSzvRk?(@$VsiwsE*cYqVZv(PKn6)U z^nJh?p*jTV8MFoiQdC%+2QsXt2xQ~51t2im4`y-<{Vt^QurUS718XyA3!2XZf(OI* zAj4u}&>+Z0BVzzsg<@c!Q1JBzGRzjk&LzyRLhOh27rKVo1AzS4{6c8}HZA~jur@rV!zv_!*u9x(|B} z_uT-MG5-e4CK}^_<^uRZ-v>AhwQHc^n6HKS5{<<{S+Ke^$gtqp2kM~EJQ$b_EceMY zC1}hJ0VfbnNSi@T(HI93cQlk!0BR1h*645X{Y>y2w*P}!%8BMBL59Z$;CirehHXVC zHv%S+g13(WPa^FD_8HBq0V7Sv<_Jnoux~&-h>ag0B&>ZWLPd0WMC1-_80ckVS6M%2Cx--4&!nmbBEb0a38Qy3H>dT97bhej1hYZ z*?1bN4K&pUsLiz^r49X7y-H!QgCIye?^8g7z`V4&^ z)IlM>0-nQaIY9=S8PVS|qY_L8Ap<7E#{eQkG#-R)m<&K6CW8VtR7S&U z#i6PNlpK8yHZr4Y6}pSfIan8b?4jHdYYSi%bZvphLv1XiKVY{i(iWhQQCtGgVRjdg zGIS38EdE7y@>G5b-v{d|=-N%#V!=U<<5o3;;xEoAEdRP%%VE$a6qep&TB-cA$*W=b#7@ zm(ft}3re?Op`*Wrz!7aT1b^6C9Vb`G%m|~gGlUT1v=8tky0##viShy?Wn@@C0N|r~ z1^ZpFIS25F`gmpu8p?UVMh_yI?*V^3p=fmaRxJl)=M!{ zhG@(QGHm}PvsxL=gMrq|qx==fpd=3cE^{*gDgz1yoeyBcFf72FBJ6!&{!o1eHWAYy z$U$Q71J8)z1E~AL)*xhsu<-+y5W_iC;2F@q0rJ6gjY@<74jBU|M?zz4*zAJ)RVExl z*9;X_D~cJQz6l$9r~t>-D#W!IR)M${vw!eYlxv3aL?~)Q`vE2qlL6<3%dmYrka@!D zHbDkO7|1thfCbPo0O}AM7sx@Pm>Ni1K;P*5fZ{>d7UUK&Uk16hab!?*hrAC;*)biW z!(v7C6e3+ThJZppEN24+i@+iy-+)p{RPP};f>k~CIgnv_U65fl$IS9c6lcSd!MI_N z0cb$K0q2MyV*o$I@;xBK>VjYiV*4vVhPN5YG0~g|C=0e?pMc_VFzp014geWc-k|-3 z5D^_ez{S{e!1ALQ6Uuy{suFo0cx2S4K$#3MXXtaVFBO+zaU(OMiRmO1#9?|5xERGl zRPb6T&H-X=-^jI>qw71FG87jxORUj&A7tZvgUO_$-(@m;sBD~l z%)n$|^9I>6OrOaB5KtWg42;G&RH$@8{Tq~DVReVhk}TA}fehDiz_!@>04R#}4FWTi zJ7hv6ye*LZM8^uSEgGl5{!sWoWc*;LF#ijxi>+y>n!{`poRWazAu1E^qWS`#K)DBi zb@+N4S*%F=V1Zy`3iWU(e+5+v*f}B~g9V4ak68qX$!J*LnDzIlZG!qbye-f`>>Ci) zV!s7C7PXVG@-Ykos~5$D;LLH~2+=RfMFQE5uQv$K(Ht&3hsEpwpRxT+Baj*$D_|J0 zabda{RIeaP!F)XI@5X#QlO9LyALMM%_#9R$EG1;#fUZNiNElLV4h!rRB(>45eFC|XosAdiFjBM8MY>;o_s^Oq30U|0Z3 z2r&JF{0z!B!BKj!jvY$i*4t7*`vT_!IH6;QUaIm?1n7Mh3oao3rl!{1Y6BAR_Qe*o+#m{kW literal 0 HcmV?d00001 From 6c6abe27166eb27dcbdaeaa33fe097440bb80168 Mon Sep 17 00:00:00 2001 From: hawthorne-abendsen Date: Wed, 4 Feb 2026 19:12:24 +0200 Subject: [PATCH 51/55] remove default retention fee config --- oracle/src/settings.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/oracle/src/settings.rs b/oracle/src/settings.rs index eed2738..596c9b0 100644 --- a/oracle/src/settings.rs +++ b/oracle/src/settings.rs @@ -1,5 +1,5 @@ use crate::types::{Asset, Error, FeeConfig}; -use soroban_sdk::{Address, Env}; +use soroban_sdk::Env; const RETENTION_PERIOD_KEY: &str = "period"; const BASE_KEY: &str = "base_asset"; @@ -8,9 +8,6 @@ const RESOLUTION_KEY: &str = "resolution"; const RETENTION_KEY: &str = "retention"; const CACHE_SIZE_KEY: &str = "cache_size"; -pub const XRF_TOKEN_ADDRESS: &str = "CBLLEW7HD2RWATVSMLAGWM4G3WCHSHDJ25ALP4DI6LULV5TU35N2CIZA"; -const DEFAULT_RETENTION_FEE: i128 = 100_000_000; - #[inline] pub fn init( e: &Env, @@ -90,10 +87,5 @@ pub fn get_fee_config(e: &Env) -> FeeConfig { e.storage() .instance() .get(&RETENTION_KEY) - .unwrap_or_else(|| { - FeeConfig::Some(( - Address::from_str(e, XRF_TOKEN_ADDRESS), // by default - XRF tokens - DEFAULT_RETENTION_FEE, // with DEFAULT_RETENTION_FEE base cost - )) - }) + .unwrap_or_else(|| FeeConfig::None) } From 3bc6b9a3201274065339e6fab731d5ab69c377c9 Mon Sep 17 00:00:00 2001 From: orbitlens <33724849+orbitlens@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:22:20 -0100 Subject: [PATCH 52/55] Optimize memory allocations, replace U256 with in-place bit operations, remove asset index mapping Co-authored-by: hawthorne-abendsen --- oracle/src/assets.rs | 27 +----- oracle/src/mapping.rs | 88 +++++++++++-------- oracle/src/price_oracle.rs | 2 +- oracle/src/prices.rs | 9 +- oracle/src/tests/fetch_prices_tests.rs | 7 +- oracle/src/tests/prices_tests.rs | 12 ++- oracle/src/tests/util_tests.rs | 8 +- oracle/src/testutils/generators.rs | 61 +++++++++---- .../src/tests/contract_admin_tests.rs | 20 ++--- .../src/tests/contract_interface_tests.rs | 59 ++++++------- 10 files changed, 158 insertions(+), 135 deletions(-) diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 844fadb..27fa29f 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -27,16 +27,7 @@ pub fn load_all_assets(e: &Env) -> Vec { // Load asset index pub fn resolve_asset_index(e: &Env, asset: &Asset) -> Option { - let index: Option; - match asset { - Asset::Stellar(address) => { - index = e.storage().instance().get(&address); - } - Asset::Other(symbol) => { - index = e.storage().instance().get(&symbol); - } - } - index + load_all_assets(e).first_index_of(asset) } // Add assets to the oracle @@ -49,10 +40,9 @@ pub fn add_assets(e: &Env, assets: Vec, initial_expiration_period: u32) { //for each new asset for asset in assets.iter() { //check if the asset has been already added - if resolve_asset_index(e, &asset).is_some() { + if asset_list.first_index_of(&asset).is_some() { panic_with_error!(&e, Error::AssetAlreadyExists); } - set_asset_index(e, &asset, asset_list.len()); asset_list.push_back(asset); //update expiration records expiration.push_back(expiration_timestamp); @@ -158,16 +148,3 @@ fn load_expiration_records(e: &Env) -> Vec { fn set_expirations_records(e: &Env, expiration: &Vec) { e.storage().instance().set(&EXPIRATION_KEY, expiration) } - -// Store asset index -#[inline] -fn set_asset_index(e: &Env, asset: &Asset, index: u32) { - match asset { - Asset::Stellar(address) => { - e.storage().instance().set(&address, &index); - } - Asset::Other(symbol) => { - e.storage().instance().set(&symbol, &index); - } - } -} diff --git a/oracle/src/mapping.rs b/oracle/src/mapping.rs index d8a1b90..e058e80 100644 --- a/oracle/src/mapping.rs +++ b/oracle/src/mapping.rs @@ -1,57 +1,67 @@ -use soroban_sdk::{Bytes, Env, Vec, U256}; +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( - e: &Env, - mut history_mask: Bytes, + history_mask: Bytes, updates: &Vec, mut updates_delta: u32, ) -> Bytes { - let one = U256::from_u32(e, 1); - //wipe entire history if the gap between updates is too large - if updates_delta > 255 { - history_mask = Bytes::new(e); //start with an empty mask - updates_delta = 1; - } + //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 } - //iterate through all updates + 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() { - //locate particular asset mask slice position within entire history record - let from = asset_index as u32 * RECORD_SIZE; - let to = from + RECORD_SIZE; - //retrieve previous asset mask - let mut bitmask = if history_mask.len() >= to { - let encoded = history_mask.slice(from..to); - U256::from_be_bytes(e, &encoded) - } else { - U256::from_u32(e, 0) //no previous records for this asset found - }; - //shift existing mask to the left by the number of periods since the last update - //all mask bits older than 256 periods get evicted - bitmask = bitmask.shl(updates_delta); - //set corresponding bit if price found - if price > 0 { - bitmask = bitmask.add(&one); + //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) + }; } - //encode into bytes again - let encoded = bitmask.to_be_bytes(); - //write to the history - if history_mask.len() <= from { - //that's new asset, add to the mask - history_mask.append(&encoded); - } else { - //replace bytes - for i in 0..RECORD_SIZE { - history_mask.set(from + i, encoded.get(i).unwrap()); + + //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()); } - history_mask //return updated history + + //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 @@ -61,9 +71,9 @@ pub fn check_history_updated(history_mask: &Bytes, asset_index: u32, period: u32 //and calculate specific bit that we need to check let bit = 1 << (period % 8); //retrieve byte from array - let bytemask = history_mask.get(from).unwrap_or_default(); + let encoded_byte = history_mask.get(from).unwrap_or_default(); //compare with bit mask - bytemask & bit == bit + encoded_byte & bit == bit } // Check whether price update record contains update for given asset by its index diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index a6dae37..946a171 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -333,7 +333,7 @@ impl PriceOracleContractBase { //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); + prices::store_prices(e, update, timestamp, asset_prices); } // Update contract source code diff --git a/oracle/src/prices.rs b/oracle/src/prices.rs index 8fc7bad..fcc4cd7 100644 --- a/oracle/src/prices.rs +++ b/oracle/src/prices.rs @@ -123,7 +123,7 @@ pub fn update_history_mask(e: &Env, prices: &Vec, timestamp: u64) { } //update the position mask - history_map = mapping::update_history_mask(e, history_map, prices, update_delta as u32); + history_map = mapping::update_history_mask(history_map, prices, update_delta as u32); //store updated timestamps e.storage().instance().set(&HISTORY_KEY, &history_map); } @@ -150,7 +150,7 @@ pub fn load_history_record(e: &Env, timestamp: u64) -> Option { } // Update prices stored in the oracle -pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &Vec) { +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); @@ -172,7 +172,7 @@ pub fn store_prices(e: &Env, update: &PriceUpdate, timestamp: u64, update_v1: &V 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.clone())); + cache.push_front((timestamp, update)); while cache.len() > cache_size { cache.pop_back(); //remove the oldest record if cache size exceeded } @@ -216,6 +216,7 @@ pub fn load_prices(e: &Env, asset_index: u32, records: u32) -> Option 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); @@ -240,7 +241,7 @@ fn load_price_records_cache(e: &Env) -> Option> { } // Update price in legacy format (deprecated) -pub fn store_price_v1(e: &Env, updates: &Vec, timestamp: u64, ledgers_to_live: u32) { +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 diff --git a/oracle/src/tests/fetch_prices_tests.rs b/oracle/src/tests/fetch_prices_tests.rs index 228af08..b25343d 100644 --- a/oracle/src/tests/fetch_prices_tests.rs +++ b/oracle/src/tests/fetch_prices_tests.rs @@ -48,7 +48,7 @@ fn store_prices_test( set_ledger_timestamp(&e, 600_000); let mut assets = Vec::new(&e); - for i in 0..10 { + for i in 0..255 { assets.push_back(types::Asset::Other(Symbol::new( &e, &("ASSET_".to_string() + &i.to_string()), @@ -65,9 +65,10 @@ fn store_prices_test( 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, &updates.prices.clone()); + prices::store_prices(e, updates, timestamp, legacy_update); } let mut timestamp = first_timestamp; @@ -93,4 +94,6 @@ fn store_prices_test( expected_first_price_ts / 1000 ); }); + + e.cost_estimate().budget().print(); } diff --git a/oracle/src/tests/prices_tests.rs b/oracle/src/tests/prices_tests.rs index 71c9308..3c33130 100644 --- a/oracle/src/tests/prices_tests.rs +++ b/oracle/src/tests/prices_tests.rs @@ -42,7 +42,10 @@ fn invalid_timestamp_update_test(ts: u64) { &e, types::PriceUpdate { prices: vec![&e, 12345678i128], - mask: generate_update_record_mask(&e, &vec![&e, 12345678i128]), + mask: generate_update_record_mask( + &e, + &std::collections::VecDeque::from([12345678i128]), + ), }, ts, ); @@ -50,7 +53,7 @@ fn invalid_timestamp_update_test(ts: u64) { } #[test] -fn price_update_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)); @@ -79,7 +82,10 @@ fn price_update_test() { &e, types::PriceUpdate { prices: vec![&e, 12345678i128], - mask: generate_update_record_mask(&e, &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 index 1183e56..443aead 100644 --- a/oracle/src/tests/util_tests.rs +++ b/oracle/src/tests/util_tests.rs @@ -44,7 +44,7 @@ fn position_encoding_bitmask_test() { }; updates.push_back(price); } - mask = mapping::update_history_mask(&e, mask, &updates, 1); + mask = mapping::update_history_mask(mask, &updates, 1); } log!(&e, "entire mask", mask); @@ -70,21 +70,21 @@ fn update_record_bitmask_test() { let e = Env::default(); let iterations = 70; - let mut updates = Vec::from_array(&e, [0i128; 254]); + 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.set(asset_index, price); + 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 + price > &0 ); } } diff --git a/oracle/src/testutils/generators.rs b/oracle/src/testutils/generators.rs index ebcea25..44f89d0 100644 --- a/oracle/src/testutils/generators.rs +++ b/oracle/src/testutils/generators.rs @@ -8,11 +8,12 @@ use crate::{ }; 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: &Vec) -> Bytes { +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 { + 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; @@ -38,16 +39,25 @@ pub fn generate_test_env() -> (ConfigData, Env) { (config, env) } -pub fn generate_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { - let mut updates = Vec::new(&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: updates, - mask, - } + ( + PriceUpdate { + prices: filtered_price, + mask, + }, + updates, + ) } fn get_random_bool() -> bool { @@ -60,17 +70,38 @@ fn get_random_bool() -> bool { random_bool } -pub fn generate_random_updates(env: &Env, assets: &Vec, price: i128) -> PriceUpdate { - let mut updates = Vec::new(&env); +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() { - let price = if get_random_bool() { 0 } else { price }; + //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: updates, - mask, - } + ( + PriceUpdate { + prices: filtered_price, + mask, + }, + updates, + ) } pub fn generate_assets(e: &Env, count: usize, start_index: u32) -> Vec { diff --git a/pulse-contract/src/tests/contract_admin_tests.rs b/pulse-contract/src/tests/contract_admin_tests.rs index 3f4520c..1f0ca8d 100644 --- a/pulse-contract/src/tests/contract_admin_tests.rs +++ b/pulse-contract/src/tests/contract_admin_tests.rs @@ -55,7 +55,7 @@ fn set_price_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); //build expected event let expected_event = oracle::events::UpdateEvent { @@ -92,7 +92,7 @@ fn set_price_zero_timestamp_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); } #[test] @@ -109,7 +109,7 @@ fn set_price_invalid_timestamp_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); } #[test] @@ -126,7 +126,7 @@ fn set_price_future_timestamp_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); } #[test] @@ -174,8 +174,6 @@ fn asset_update_overflow_test() { env.mock_all_auths(); - env.cost_estimate().budget().reset_unlimited(); - let mut assets = Vec::new(&env); for i in 1..=1000 { assets.push_back(Asset::Other(Symbol::new( @@ -195,15 +193,13 @@ fn price_update_overflow_test() { env.mock_all_auths(); - env.cost_estimate().budget().reset_unlimited(); - - let mut updates = Vec::new(&env); + let mut raw_prices = std::collections::VecDeque::new(); for i in 1..=256 { - updates.push_back(normalize_price(i as i128 + 1)); + raw_prices.push_back(normalize_price(i as i128 + 1)); } - let mask = generate_update_record_mask(&env, &updates); + let mask = generate_update_record_mask(&env, &raw_prices); let update = PriceUpdate { - prices: updates, + prices: Vec::from_iter(&env, raw_prices.into_iter()), mask, }; client.set_price(&update, &600_000); diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index e89c399..8447d79 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -1,15 +1,17 @@ #![cfg(test)] use oracle::init_contract_with_admin; -use oracle::prices::{self}; 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::{log, testutils::Address as _, Address, Env, Vec}; +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] @@ -59,7 +61,7 @@ fn last_timestamp_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); result = client.last_timestamp(); @@ -79,7 +81,7 @@ fn lastprice_test() { env.mock_all_auths(); //set prices for assets - client.set_price(&updates, ×tamp); + client.set_price(&updates.0, ×tamp); let fee_asset = env .register_stellar_asset_contract_v2(init_data.admin.clone()) @@ -107,29 +109,26 @@ fn prices_update_test(gap: u64, _description: &str) { client.set_cache_size(&3); - let mut history_prices = Vec::new(&env); + 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)); - history_prices.push_front((timestamp, updates.clone())); //set prices for assets - client.set_price(&updates, ×tamp); + 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 - let updates = generate_random_updates(&env, &assets, 0); - history_prices.push_front((timestamp, updates.clone())); + history_prices.push_front((timestamp, None)); } set_ledger_timestamp(&env, timestamp / 1000 + 300); } - //prepare an array with zero prices - let mut zero_prices = Vec::new(&env); - for _ in 0..assets.len() { - zero_prices.push_back(0i128); - } + + println!("verifying prices..."); //verify let mut had_gaps = false; @@ -137,32 +136,32 @@ fn prices_update_test(gap: u64, _description: &str) { let mut iterations = 0; for (history_index, (timestamp, updates)) in history_prices.iter().enumerate() { - let all_prices; - if history_index > 255 { - all_prices = zero_prices.clone(); - } else { - let total = assets.len() + 10; //+10 to check that out of range assets are ignored - //get records from generated updates - all_prices = prices::extract_update_record_prices(&env, &updates, total); - } - //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 = all_prices.get(asset_index as u32).unwrap_or_default(); - if expected_price > 0 { + 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 {}", - asset_index, timestamp + price.price, *expected_price, + "asset {} at timestamp {} history index {}", + asset_index, timestamp, history_index ); - assert_eq!(price.timestamp, convert_to_seconds(timestamp)); + assert_eq!(price.timestamp, convert_to_seconds(*timestamp)); had_prices = true; } else { assert!( @@ -178,7 +177,7 @@ fn prices_update_test(gap: u64, _description: &str) { } assert!(had_prices); assert!(had_gaps); - log!(&env, "{} iterations", iterations); + println!("{} iterations", iterations); } #[test] From 0445615760f5b91bc88aa055a9c023174858c5a0 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Tue, 3 Mar 2026 13:50:29 -0100 Subject: [PATCH 53/55] Add integration tests to Pulse oracle --- pulse-contract/src/tests/integration_tests.rs | 158 ++++++++++++++++++ pulse-contract/src/tests/mod.rs | 1 + 2 files changed, 159 insertions(+) create mode 100644 pulse-contract/src/tests/integration_tests.rs 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 index 0178334..919a367 100644 --- a/pulse-contract/src/tests/mod.rs +++ b/pulse-contract/src/tests/mod.rs @@ -1,2 +1,3 @@ mod contract_admin_tests; mod contract_interface_tests; +mod integration_tests; From dff9a003b79652f535abda6ccf57452e73d64361 Mon Sep 17 00:00:00 2001 From: orbitlens Date: Tue, 3 Mar 2026 17:48:05 -0100 Subject: [PATCH 54/55] Introduce estimate_retention_cost() for TTL bump calculation Return current asset expiration timestamp from extend_asset_ttl() --- beam-contract/src/lib.rs | 25 +++++++++- beam-contract/src/tests/contract_tests.rs | 17 +++++-- oracle/src/assets.rs | 48 ++++++++++++------- oracle/src/price_oracle.rs | 28 +++++++++-- pulse-contract/src/lib.rs | 25 +++++++++- .../src/tests/contract_interface_tests.rs | 14 ++++-- 6 files changed, 126 insertions(+), 31 deletions(-) diff --git a/beam-contract/src/lib.rs b/beam-contract/src/lib.rs index a5f4027..ea62436 100644 --- a/beam-contract/src/lib.rs +++ b/beam-contract/src/lib.rs @@ -101,6 +101,23 @@ impl BeamOracleContract { 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 @@ -109,11 +126,15 @@ impl BeamOracleContract { // * `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) { - PriceOracleContractBase::extend_asset_ttl(e, sponsor, asset, amount, 0); + 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 diff --git a/beam-contract/src/tests/contract_tests.rs b/beam-contract/src/tests/contract_tests.rs index d3fed13..ba24bf2 100644 --- a/beam-contract/src/tests/contract_tests.rs +++ b/beam-contract/src/tests/contract_tests.rs @@ -4,7 +4,7 @@ extern crate std; use crate::{BeamOracleContract, BeamOracleContractClient}; use oracle::testutils::register_token; use oracle::types::{Asset, FeeConfig}; -use oracle::{assets, init_contract_with_admin}; +use oracle::{assets, init_contract_with_admin, timestamps}; use soroban_sdk::{testutils::Address as _, Address, Vec}; use test_case::test_case; @@ -87,9 +87,18 @@ fn check_extending_asset_ttl() { let sponsor = Address::generate(&env); fee_token_client.mint(&sponsor, &10_000_000); - //check the extending - client.extend_asset_ttl(&sponsor, &new_asset, &1_000_000); - assert_eq!(client.expires(&new_asset), Some(87_300)); + //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, || { diff --git a/oracle/src/assets.rs b/oracle/src/assets.rs index 27fa29f..71c4df3 100644 --- a/oracle/src/assets.rs +++ b/oracle/src/assets.rs @@ -7,6 +7,7 @@ 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 { @@ -88,7 +89,7 @@ pub fn extend_ttl( asset: Asset, amount: i128, initial_expiration_period: u32, -) { +) -> u64 { //check if the amount is valid if amount <= 0 { e.panic_with_error(Error::InvalidAmount); @@ -100,24 +101,14 @@ pub fn extend_ttl( } let asset_index = asset_index.unwrap(); //load required fee amount from retention config - let (xrf, fee) = 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); - } - }; - //burn corresponding amount of fee tokens - TokenClient::new(&e, &xrf).burn(&sponsor, &amount); + let (xrf, fee) = load_fee_settings(e); //calculate extension period - let bump = amount * 86400000 / fee; // in milliseconds + 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); @@ -133,7 +124,32 @@ pub fn extend_ttl( //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) + 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 diff --git a/oracle/src/price_oracle.rs b/oracle/src/price_oracle.rs index 946a171..f1e1d30 100644 --- a/oracle/src/price_oracle.rs +++ b/oracle/src/price_oracle.rs @@ -96,7 +96,7 @@ impl PriceOracleContractBase { // // # Returns // - // Asset expiration timestamp (in seconds) or None if asset is not supported + // Asset expiration timestamp (in seconds) or None if asset is expired // // # Panics // @@ -108,6 +108,23 @@ impl PriceOracleContractBase { } } + // 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 @@ -117,6 +134,10 @@ impl PriceOracleContractBase { // * `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 @@ -126,10 +147,11 @@ impl PriceOracleContractBase { asset: Asset, amount: i128, initial_expiration_period: u32, - ) { + ) -> u64 { //check sponsor authorization sponsor.require_auth(); - assets::extend_ttl(e, sponsor, asset, amount, initial_expiration_period); + //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 diff --git a/pulse-contract/src/lib.rs b/pulse-contract/src/lib.rs index deff1b7..2964384 100644 --- a/pulse-contract/src/lib.rs +++ b/pulse-contract/src/lib.rs @@ -99,6 +99,23 @@ impl PulseOracleContract { 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 @@ -107,17 +124,21 @@ impl PulseOracleContract { // * `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) { + 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 diff --git a/pulse-contract/src/tests/contract_interface_tests.rs b/pulse-contract/src/tests/contract_interface_tests.rs index 8447d79..ac7cc1d 100644 --- a/pulse-contract/src/tests/contract_interface_tests.rs +++ b/pulse-contract/src/tests/contract_interface_tests.rs @@ -197,10 +197,16 @@ fn extend_asset_ttl_test() { let asset = &init_data.assets.first_unchecked(); let initial_expiration = client.expires(&asset).unwrap(); - //extend TTL by 10 day (864000 seconds) - client.extend_asset_ttl(&sponsor, &asset, &10_000_000); + //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 - let new_expiration = client.expires(&asset).unwrap(); - assert_eq!(new_expiration, initial_expiration + 864000); + assert_eq!(ttl, initial_expiration + 864000); } From 1b64d4578f357c746ced8a6374002f09476b5aaa Mon Sep 17 00:00:00 2001 From: orbitlens Date: Mon, 9 Mar 2026 09:21:12 -0100 Subject: [PATCH 55/55] Bump SDK to the latest version --- Cargo.lock | 21 +++++++++++---------- Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e18e25b..fef722f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1350,9 +1350,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d1bfa6f7d57307bf8241789b13d3703438e7afa0527aa098a357ef757d3a2" +checksum = "760124fb65a2acdea7d241b8efdfab9a39287ae8dc5bf8feb6fd9dfb664c1ad5" dependencies = [ "serde", "serde_json", @@ -1364,9 +1364,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9953e782d6da30974eea520c2b5f624c28bbc518c3bb926ec581242dd3f9d2a2" +checksum = "5fb27e93f8d3fc3a815d24c60ec11e893c408a36693ec9c823322f954fa096ae" dependencies = [ "arbitrary", "bytes-lit", @@ -1388,9 +1388,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a8cecb6acc735670dad3303c6a9d2b47e51adfb11224ad5a8ced55fd7b0a600" +checksum = "dec603a62a90abdef898f8402471a24d8b58a0043b9a998ed6a607a19a5dabe1" dependencies = [ "darling 0.20.11", "heck", @@ -1408,11 +1408,12 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79501d0636f86fe2c9b1dd7e88b9397415b3493a59b34f466abd7758c84b92b" +checksum = "24718fac3af127fc6910eb6b1d3ccd8403201b6ef0aca73b5acabe4bc3dd42ed" dependencies = [ "base64", + "sha2", "stellar-xdr", "thiserror", "wasmparser", @@ -1420,9 +1421,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "25.0.2" +version = "25.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b520b5fb013fde70796d9a6057591f53817aa0c38f8bad460126f97f59394af9" +checksum = "93c558bca7a693ec8ed67d2d8c8f5b300f3772141d619a4a694ad5dd48461256" dependencies = [ "prettyplease", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index c2de536..b75a864 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,4 @@ codegen-units = 1 lto = true [workspace.dependencies.soroban-sdk] -version = "25.0.2" +version = "25.3.0"