diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..78028f3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown --locked --workspace --exclude margined-testing" +wasm-debug = "build --lib --target wasm32-unknown-unknown --locked --workspace --exclude margined-testing" +unit-test = "test --lib --workspace --exclude margined-protocol --exclude margined-testing --exclude mock-query" +integration-test = "test --test integration" +fmt-check = "fmt --all -- --check" +lint = "clippy -- -D warnings" +schema = "run schema" +coverage = "llvm-cov --workspace --lcov --output-path lcov.info" + +[target.wasm32-unknown-unknown] +rustflags = ["-C", "link-arg=-s"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dcb8a06 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Test Wasm Binaries + +on: + push: + pull_request: + types: [opened] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Integration tests + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.69.0 + target: wasm32-unknown-unknown + profile: minimal + override: true + + - name: Compile contracts + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --lib --target wasm32-unknown-unknown --locked --workspace --exclude margined-testing + env: + RUSTFLAGS: "-C link-arg=-s" + + - name: Run Test Tube Integration Tests + uses: actions-rs/cargo@v1 + with: + command: test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..84fe7dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI Tests + +on: + push: + pull_request: + types: [opened] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Integration tests + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Set up cargo cache + uses: actions/cache@v3 + continue-on-error: false + with: + path: | + ~/.cargo/crates.toml + ~/.cargo/crates2.json + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-ci-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-ci- + + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: -- -D warnings diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..fbf73ac --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,32 @@ +on: [push] + +name: Code Coverage + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - name: Install toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.69.0 + target: wasm32-unknown-unknown + profile: minimal + override: true + + - name: Compile contracts + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --lib --target wasm32-unknown-unknown --locked --workspace --exclude margined-testing + env: + RUSTFLAGS: "-C link-arg=-s" + - run: cargo install cargo-tarpaulin + - run: cargo tarpaulin --out Xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0b63c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build results +/target +/artifacts +/scripts/node_modules +/scripts/yarn.lock +/schema +/schemas +/scripts/src/types/generated + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea +.vscode diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9ac6ec5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2853 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "anyhow" +version = "1.0.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" + +[[package]] +name = "async-trait" +version = "0.1.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a564d521dd56509c4c47480d00b80ee55f7e385ae48db5744c67ad50c92d2ebf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bigint" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e8c8a600052b52482eff2cf4d810e462fdff1f656ac1ecb6232132a1ed7def" +dependencies = [ + "byteorder", + "crunchy 0.1.6", +] + +[[package]] +name = "bindgen" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062dddbc1ba4aca46de6338e2bf87771414c335f7b2f2036e8f3e9befebf88e6" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "clap", + "env_logger", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "which", +] + +[[package]] +name = "bip32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30ed1d6f8437a487a266c8293aeb95b61a23261273e3e02912cdb8b68bf798b" +dependencies = [ + "bs58", + "hmac", + "k256", + "once_cell", + "pbkdf2", + "rand_core 0.6.4", + "ripemd", + "sha2 0.10.7", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bnum" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "845141a4fade3f790628b7daaaa298a25b204fb28907eb54febe5142db6ce653" + +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" +dependencies = [ + "sha2 0.9.9", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" + +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +dependencies = [ + "android-tzdata", + "num-traits", +] + +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags", + "clap_lex", + "indexmap", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "const-oid" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6340df57935414636969091153f35f68d9f00bbc8fb4a9c6054706c213e6c6bc" + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "cosmos-sdk-proto" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b42021d8488665b1a0d9748f1f81df7235362d194f44481e2e61bf376b77b4" +dependencies = [ + "prost", + "prost-types", + "tendermint-proto", +] + +[[package]] +name = "cosmrs" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3903590099dcf1ea580d9353034c9ba1dbf55d1389a5bd2ade98535c3445d1f9" +dependencies = [ + "bip32", + "cosmos-sdk-proto", + "ecdsa", + "eyre", + "getrandom", + "k256", + "rand_core 0.6.4", + "serde", + "serde_json", + "subtle-encoding", + "tendermint", + "tendermint-rpc", + "thiserror", +] + +[[package]] +name = "cosmwasm-crypto" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871ce1d5a4b00ed1741f84b377eec19fadd81a904a227bc1e268d76539d26f5e" +dependencies = [ + "digest 0.10.7", + "ed25519-zebra", + "k256", + "rand_core 0.6.4", + "thiserror", +] + +[[package]] +name = "cosmwasm-derive" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce8b44b45a7c8c6d6f770cd0a51458c2445c7c15b6115e1d215fa35c77b305c" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-schema" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99222fa0401ee36389550d8a065700380877a2299c3043d24c38d705708c9d9d" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b74eaf9e585ef8e5e3486b240b13ee593cb0f658b5879696937d8c22243d4fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-std" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da78abcf059181e8cb01e95e5003cf64fe95dde6c72b3fe37e5cabc75cdba32a" +dependencies = [ + "base64", + "bnum", + "cosmwasm-crypto", + "cosmwasm-derive", + "derivative", + "forward_ref", + "hex", + "schemars", + "serde", + "serde-json-wasm", + "sha2 0.10.7", + "thiserror", +] + +[[package]] +name = "cpufeatures" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2f4a431c5c9f662e1200b7c7f02c34e91361150e382089a8f2dec3ba680cbda" + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-logs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a816186fa68d9e426e3cb4ae4dff1fcd8e4a2c34b781bf7a822574a0d0aac8" +dependencies = [ + "sct", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "cw-controllers" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d8edce4b78785f36413f67387e4be7d0cb7d032b5d4164bcc024f9c3f3f2ea" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-storage-plus" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0e92a069d62067f3472c62e30adedb4cab1754725c0f2a682b3128d2bf3c79" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c80e93d1deccb8588db03945016a292c3c631e6325d349ebb35d2db6f4f946f7" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw2", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cw2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ac2dc7a55ad64173ca1e0a46697c31b7a5c51342f55a1e84a724da4eb99908" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[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 = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b0cf012f1230e43cd00ebb729c6bb58707ecfa8ad08b52ef3a4ccd2697fc30" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-zebra" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c24f403d068ad0b359e577a77f92392118be3f3c927538f2bb544a5ecd828c6" +dependencies = [ + "curve25519-dalek", + "hashbrown", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint", + "der", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "ethbloom" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3932e82d64d347a045208924002930dc105a138995ccdc1479d0f05f0359f17c" +dependencies = [ + "crunchy 0.2.2", + "fixed-hash", + "impl-rlp", + "impl-serde", + "tiny-keccak", +] + +[[package]] +name = "ethereum-types" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b054df51e53f253837ea422681215b42823c02824bde982699d0dceecf6165a1" +dependencies = [ + "crunchy 0.2.2", + "ethbloom", + "ethereum-types-serialize", + "fixed-hash", + "serde", + "uint", +] + +[[package]] +name = "ethereum-types-serialize" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1873d77b32bc1891a79dad925f2acbc318ee942b38b9110f9dbc5fbeffcea350" +dependencies = [ + "serde", +] + +[[package]] +name = "eyre" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fixed-hash" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1a683d1234507e4f3bf2736eeddf0de1dc65996dc0164d57eba0a74bcf29489" +dependencies = [ + "byteorder", + "heapsize", + "rand", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "eyre", + "paste", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forward_ref" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "headers" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" +dependencies = [ + "base64", + "bitflags", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + +[[package]] +name = "heapsize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1679e6ea370dee694f91f1dc469bf94cf8f52051d147aec3e1f9497c6fc22461" +dependencies = [ + "winapi", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-proxy" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc" +dependencies = [ + "bytes", + "futures", + "headers", + "http", + "hyper", + "hyper-rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki", +] + +[[package]] +name = "hyper-rustls" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9f7a97316d44c0af9b0301e65010573a853a9fc97046d7331d7f6bc0fd5a64" +dependencies = [ + "ct-logs", + "futures-util", + "hyper", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "webpki", + "webpki-roots", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "impl-rlp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f7a72f11830b52333f36e3b09a288333888bf54380fd0ac0790a3c31ab0f3c5" +dependencies = [ + "rlp", +] + +[[package]] +name = "impl-serde" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e3cae7e99c7ff5a995da2cf78dd0a5383740eda71d98cf7b1910c301ac69b8" +dependencies = [ + "serde", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "injective-math" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9f45c0c8d453c157aa06c7d129b04f295286472b2f6bf59d44ac7b90e100d9" +dependencies = [ + "bigint", + "cosmwasm-std", + "ethereum-types", + "num", + "schemars", + "serde", + "subtle-encoding", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b02a5381cc465bd3041d84623d0fa3b66738b52b8e2fc3bab8ad63ab032f4a" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c1e0b51e7ec0a97369623508396067a486bd0cbed95a2659a4b863d28cfc8b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2 0.10.7", + "sha3", +] + +[[package]] +name = "keccak" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f6d5ed8676d904364de097082f4e7d240b571b67989ced0240f08b7f966f940" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "log" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4" + +[[package]] +name = "margined-collector" +version = "0.1.0" +dependencies = [ + "cosmrs", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw2", + "margined-common", + "margined-protocol", + "margined-testing", + "osmosis-std 0.16.2", + "osmosis-test-tube", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "margined-common" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "osmosis-std 0.16.2", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "margined-power" +version = "0.1.0" +dependencies = [ + "cosmrs", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "injective-math", + "margined-common", + "margined-protocol", + "margined-testing", + "mock-query", + "num", + "osmosis-std 0.16.2", + "osmosis-test-tube", + "schemars", + "serde", +] + +[[package]] +name = "margined-protocol" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "thiserror", +] + +[[package]] +name = "margined-query" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw2", + "margined-protocol", + "margined-testing", + "osmosis-std 0.16.2", + "osmosis-test-tube", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "margined-staking" +version = "0.1.0" +dependencies = [ + "cosmrs", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "margined-common", + "margined-protocol", + "margined-testing", + "mock-query", + "osmosis-std 0.16.2", + "osmosis-test-tube", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "margined-testing" +version = "0.1.0" +dependencies = [ + "cosmrs", + "cosmwasm-std", + "margined-protocol", + "mock-query", + "osmosis-std 0.16.2", + "osmosis-test-tube", + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "mock-query" +version = "0.1.0" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "margined-protocol", + "osmosis-std 0.16.2", + "schemars", + "serde", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43db66d1170d347f9a065114077f7dccb00c1b9478c89384490a3425279a4606" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.2", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "os_str_bytes" +version = "6.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" + +[[package]] +name = "osmosis-std" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75895e4db1a81ca29118e366365744f64314938327e4eedba8e6e462fb15e94f" +dependencies = [ + "chrono", + "cosmwasm-std", + "osmosis-std-derive", + "prost", + "prost-types", + "schemars", + "serde", + "serde-cw-value", +] + +[[package]] +name = "osmosis-std" +version = "0.17.0-rc0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b022b748710ecdf1adc6a124c3bef29f17ef05e7fa1260a08889d1d53f9cc5" +dependencies = [ + "chrono", + "cosmwasm-std", + "osmosis-std-derive", + "prost", + "prost-types", + "schemars", + "serde", + "serde-cw-value", +] + +[[package]] +name = "osmosis-std-derive" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47f0b2f22adb341bb59e5a3a1b464dde033181954bd055b9ae86d6511ba465b" +dependencies = [ + "itertools", + "proc-macro2", + "prost-types", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "osmosis-test-tube" +version = "17.0.0-rc0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977a2b4f088dd704a47e96b5914e28465cfcdb1cb1145a1f9b45c219a9b145c5" +dependencies = [ + "base64", + "bindgen", + "cosmrs", + "cosmwasm-std", + "osmosis-std 0.17.0-rc0", + "prost", + "serde", + "serde_json", + "test-tube", + "thiserror", +] + +[[package]] +name = "paste" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b27ab7be369122c218afc2079489cdcb4b517c0a3fc386ff11e1fedfcc2b35" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "peg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c0b841ea54f523f7aa556956fbd293bcbe06f2e67d2eb732b7278aaf1d166a" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aa52829b8decbef693af90202711348ab001456803ba2a98eb4ec8fb70844c" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c719dcf55f09a3a7e764c6649ab594c18a177e3599c467983cdf644bfc0a4088" + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pin-project" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030ad2bc4db10a8944cb0d837f158bdfec4d4a4873ab701a95046770d11f8842" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec2e072ecce94ec471b13398d5402c188e76ac03cf74dd1a975161b23a3f6d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78803b62cbf1f46fde80d7c0e803111524b9877184cfe7c3033659490ac7a7da" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c618c47cd3ebd209790115ab837de41425723956ad3ce2e6a7f09890947cacb9" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "winapi", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d3daa6976cffb758ec878f108ba0e062a45b2d6ca3a2cca965338855476caf" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab07dc67230e4a4718e70fd5c20055a4334b121f1f9db8fe63ef39ce9b8c846" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "ripemd160" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eca4ecc81b7f313189bf73ce724400a07da2a6dac19588b03c8bd76a2dcc251" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "rlp" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1190dcc8c3a512f1eef5d09bb8c84c7f39e1054e174d1795482e18f5272f2e73" +dependencies = [ + "rustc-hex", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64", + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +dependencies = [ + "openssl-probe", + "rustls", + "schannel", + "security-framework", +] + +[[package]] +name = "ryu" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe232bdf6be8c8de797b22184ee71118d63780ea42ac85b61d1baa6d3b782ae9" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "schemars" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c613288622e5f0c3fdc5dbd4db1c5fbe752746b1d1a56a0630b78fd00de44f" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109da1e6b197438deb6db99952990c7f959572794b80ff93707d55a232545e7c" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" + +[[package]] +name = "serde" +version = "1.0.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-cw-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75d32da6b8ed758b7d850b6c3c08f1d7df51a4df3cb201296e63e34a78e99d4" +dependencies = [ + "serde", +] + +[[package]] +name = "serde-json-wasm" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a62a1fad1e1828b24acac8f2b468971dade7b8c3c2e672bcadefefb1f8c137" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a16be4fe5320ade08736447e3198294a5ea9a6d44dde6f35f0a5e06859c427a" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1e14e89be7aa4c4b78bdbdc9eb5bf8517829a600ae8eaa39a6e1d960b5185c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89a8107374290037607734c0b73a85db7ed80cae314b3c5791f192a496e731" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19be23126415861cb3a23e501d34a708f7f9b2183c5252d690941c2e69199d5" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "subtle-encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcb1ed7b8330c5eed5441052651dd7a12c75e2ed88f2ec024ae1fa3a5e59945" +dependencies = [ + "zeroize", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e3fc8c0c74267e2df136e5e5fb656a464158aa57624053375eb9c8c6e25ae2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tendermint" +version = "0.23.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467f82178deeebcd357e1273a0c0b77b9a8a0313ef7c07074baebe99d87851f4" +dependencies = [ + "async-trait", + "bytes", + "ed25519", + "ed25519-dalek", + "flex-error", + "futures", + "k256", + "num-traits", + "once_cell", + "prost", + "prost-types", + "ripemd160", + "serde", + "serde_bytes", + "serde_json", + "serde_repr", + "sha2 0.9.9", + "signature", + "subtle", + "subtle-encoding", + "tendermint-proto", + "time", + "zeroize", +] + +[[package]] +name = "tendermint-config" +version = "0.23.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42ee0abc27ef5fc34080cce8d43c189950d331631546e7dfb983b6274fa327" +dependencies = [ + "flex-error", + "serde", + "serde_json", + "tendermint", + "toml", + "url", +] + +[[package]] +name = "tendermint-proto" +version = "0.23.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce80bf536476db81ecc9ebab834dc329c9c1509a694f211a73858814bfe023" +dependencies = [ + "bytes", + "flex-error", + "num-derive", + "num-traits", + "prost", + "prost-types", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + +[[package]] +name = "tendermint-rpc" +version = "0.23.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f14aafe3528a0f75e9f3f410b525617b2de16c4b7830a21f717eee62882ec60" +dependencies = [ + "async-trait", + "bytes", + "flex-error", + "futures", + "getrandom", + "http", + "hyper", + "hyper-proxy", + "hyper-rustls", + "peg", + "pin-project", + "serde", + "serde_bytes", + "serde_json", + "subtle-encoding", + "tendermint", + "tendermint-config", + "tendermint-proto", + "thiserror", + "time", + "tokio", + "tracing", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "test-tube" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b1f7cafdf7738331999fb1465d2d3032f08ac61940e1ef4601dbbef21d6a5e" +dependencies = [ + "base64", + "cosmrs", + "cosmwasm-std", + "osmosis-std 0.17.0-rc0", + "prost", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "textwrap" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" + +[[package]] +name = "thiserror" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "time" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +dependencies = [ + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "tiny-keccak" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8a021c69bb74a44ccedb824a046447e2c84a01df9e5c20779750acb38e11b2" +dependencies = [ + "crunchy 0.2.2", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" +dependencies = [ + "autocfg", + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + +[[package]] +name = "tokio-rustls" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "uint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "082df6964410f6aa929a61ddfafc997e4f32c62c22490e439ac351cec827f436" +dependencies = [ + "byteorder", + "crunchy 0.2.2", + "heapsize", + "rustc-hex", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.25", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +dependencies = [ + "webpki", +] + +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +dependencies = [ + "either", + "libc", + "once_cell", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +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.25", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..84da676 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,71 @@ +[workspace] +members = [ + "contracts/margined-collector", + "contracts/margined-power", + "contracts/margined-query", + "contracts/margined-staking", + "contracts/mocks/*", + "packages/*", +] + +[workspace.package] +authors = [ "Friedrich Grabner " ] +documentation = "https://docs.margined.io/" +edition = "2021" +homepage = "https://margined.io" +keywords = [ "cosmos", "cosmwasm", "margined" ] +license = "GPL-3.0-or-later" +repository = "https://github.com/margined-protocol/power" +version = "0.1.0" + +[workspace.dependencies] +anyhow = "1.0.69" +cosmrs = { version = "0.9.0", features = [ "cosmwasm" ] } +cosmwasm-schema = "1.3.3" +cosmwasm-std = "1.3.3" +cosmwasm-storage = "1.3.3" +cw-controllers = "1.1.0" +cw-item-set = { version = "0.7.1", default-features = false, features = [ "iterator" ] } +cw-paginate = "0.2.1" +cw-storage-plus = "1.0.1" +cw-utils = "1.0.1" +cw2 = "1.0.1" +cw721 = "0.16.0" +cw721-base = { version = "0.16.0", features = [ "library" ] } +injective-math = "= 0.1.13" +itertools = "0.10.5" +num = "0.4.0" +osmosis-std = "0.16.2" +osmosis-test-tube = "17.0.0-rc0" +schemars = "0.8.12" +serde = { version = "1.0.155", default-features = false, features = [ "derive" ] } +serde-wasm-bindgen = "0.5.0" +serde_json = "1.0.94" +thiserror = "1.0.39" +wasm-bindgen = "0.2.84" + +# packages +margined-common = { version = "0.1.0", path = "packages/margined_common" } +margined-protocol = { version = "0.1.0", path = "packages/margined_protocol" } +margined-testing = { version = "0.1.0", path = "packages/margined_testing" } + +# contracts +margined-collector = { version = "0.1.0", path = "contracts/margined-collector" } +margined-crab = { version = "0.1.0", path = "contracts/margined-crab" } +margined-power = { version = "0.1.0", path = "contracts/margined-power" } +margined-query = { version = "0.1.0", path = "contracts/margined-query" } +margined-staking = { version = "0.1.0", path = "contracts/margined-staking" } + +# mocks +mock-query = { version = "0.1.0", path = "contracts/mocks/mock-query" } + +[profile.release] +codegen-units = 1 +debug = false +debug-assertions = false +incremental = false +lto = true +opt-level = 3 +overflow-checks = true +panic = "abort" +rpath = false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a93afc5 --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ +Copyright 2023 Max + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5719c12 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Power + +[![Continuous Integration](https://github.com/margined-protocol/power/actions/workflows/ci.yml/badge.svg)](https://github.com/margined-protocol/power/actions/workflows/ci.yml) [![codecov](https://codecov.io/github/margined-protocol/power/branch/dev/graph/badge.svg?token=6QDFXS25fj)](https://codecov.io/github/margined-protocol/power) + +This repo contains Margined Power Perpetuals a decentralized power perps protocol to be deployed on [Osmosis](https://osmosis.zone/). + +## Contracts + +| Contract | Reference | Description | +| ------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Power | [doc](./contracts/margined-power) | The controller contract that enables user's to open and close power perpetual positions | +| Staking | [doc](./contracts/margined-staking) | Token holders stake `$MRG` into the staking contract which then mints `$stakedMRG` to which fees are distributed | +| Fee Collector | [doc](./contracts/margined-collector) | Fee collector accrues the fees generated by protocol to be redistributed to `$MRG` token holders | +| Query | [doc](./contracts/margined_fee_pool) | Pass-through contract to make testing easier | + +## Background + +- [Power Perpetuals](https://www.paradigm.xyz/2021/08/power-perpetuals) +- [Margined Power Perpetual Docs](https://docs.margined.io/overview/power) + +## Get started + +### Environment Setup + +#### Pre-Requisites + +- Rust v1.69.\* +- `wasm32-unknown-unknown` target +- Docker + +#### Instructions + +- Install `rustup` via https://rustup.rs/ +- Run the following: + +```sh +rustup default stable +rustup target add wasm32-unknown-unknown +``` + +- Make sure [Docker](https://www.docker.com/) is installed + +### Build + +Clone this repository and build the source code: + +```sh +git clone git@github.com:margined-protocol/power.git +cd power +cargo build +``` + +### Unit / Integration Tests + +To run the tests after installing pre-requisites do the following: + +Compile contracts: + +```sh +./build_release.sh +``` + +Run the tests: + +```sh +cargo test +``` diff --git a/build_release.sh b/build_release.sh new file mode 100755 index 0000000..a7dee73 --- /dev/null +++ b/build_release.sh @@ -0,0 +1,11 @@ +#!/bin/sh +ARCH="" + +if [ "$(uname -m)" = "arm64" ]; then + ARCH=-arm64 +fi + +docker run --rm -v "$(pwd)":/code -v "$HOME/.cargo/git":/usr/local/cargo/git \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/workspace-optimizer${ARCH}:0.13.0 diff --git a/contracts/margined-collector/Cargo.toml b/contracts/margined-collector/Cargo.toml new file mode 100644 index 0000000..7b64b7d --- /dev/null +++ b/contracts/margined-collector/Cargo.toml @@ -0,0 +1,40 @@ +[package] +authors = [ "Margined Protocol" ] +edition = "2021" +name = "margined-collector" +version = "0.1.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = [ "cdylib", "rlib" ] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] +# use library feature to disable all instantiate/execute/query exports +library = [ ] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +margined-common = { workspace = true } +margined-protocol = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmrs = { workspace = true } +margined-testing = { workspace = true } +osmosis-test-tube = { workspace = true } diff --git a/contracts/margined-collector/README.md b/contracts/margined-collector/README.md new file mode 100644 index 0000000..722fd1d --- /dev/null +++ b/contracts/margined-collector/README.md @@ -0,0 +1,3 @@ +# Margined Protocol Fee Collector + +The Fee Collector contract accrues the fees generated by the protocol, enabling either the contract owner or whitelisted spender to withdraw tokens. diff --git a/contracts/margined-collector/src/bin/collector_schema.rs b/contracts/margined-collector/src/bin/collector_schema.rs new file mode 100644 index 0000000..e77fb02 --- /dev/null +++ b/contracts/margined-collector/src/bin/collector_schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use margined_protocol::collector::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/margined-collector/src/contract.rs b/contracts/margined-collector/src/contract.rs new file mode 100644 index 0000000..7a65f56 --- /dev/null +++ b/contracts/margined-collector/src/contract.rs @@ -0,0 +1,96 @@ +use crate::{ + handle::{add_token, remove_token, send_token, update_whitelist}, + query::{ + query_all_token, query_is_token, query_owner, query_token_list_length, query_whitelist, + }, + state::{OWNER, OWNERSHIP_PROPOSAL, WHITELIST_ADDRESS}, +}; + +use cosmwasm_std::{ + entry_point, to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, +}; +use cw2::set_contract_version; +use margined_common::{ + errors::ContractError, + ownership::{ + get_ownership_proposal, handle_claim_ownership, handle_ownership_proposal, + handle_ownership_proposal_rejection, + }, +}; +use margined_protocol::collector::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + _msg: InstantiateMsg, +) -> Result { + set_contract_version( + deps.storage, + format!("crates.io:{CONTRACT_NAME}"), + CONTRACT_VERSION, + )?; + + WHITELIST_ADDRESS.save(deps.storage, &info.sender)?; + + OWNER.set(deps, Some(info.sender))?; + + Ok(Response::new().add_attribute("action", "instantiate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddToken { token } => add_token(deps, info, token), + ExecuteMsg::RemoveToken { token } => remove_token(deps, info, token), + ExecuteMsg::UpdateWhitelist { address } => update_whitelist(deps, info, address), + ExecuteMsg::SendToken { + token, + amount, + recipient, + } => send_token(deps.as_ref(), env, info, token, amount, recipient), + ExecuteMsg::ProposeNewOwner { + new_owner, + duration, + } => handle_ownership_proposal( + deps, + info, + env, + new_owner, + duration, + OWNER, + OWNERSHIP_PROPOSAL, + ), + ExecuteMsg::RejectOwner {} => { + handle_ownership_proposal_rejection(deps, info, OWNER, OWNERSHIP_PROPOSAL) + } + ExecuteMsg::ClaimOwnership {} => { + handle_claim_ownership(deps, info, env, OWNER, OWNERSHIP_PROPOSAL) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Owner {} => { + to_binary(&query_owner(deps).map_err(|err| StdError::generic_err(err.to_string()))?) + } + QueryMsg::GetWhitelist {} => to_binary(&query_whitelist(deps)?), + QueryMsg::IsToken { token } => to_binary(&query_is_token(deps, token)?), + QueryMsg::GetTokenList { limit } => to_binary(&query_all_token(deps, limit)?), + QueryMsg::GetTokenLength {} => to_binary(&query_token_list_length(deps)?), + QueryMsg::GetOwnershipProposal {} => { + to_binary(&get_ownership_proposal(deps, OWNERSHIP_PROPOSAL)?) + } + } +} diff --git a/contracts/margined-collector/src/handle.rs b/contracts/margined-collector/src/handle.rs new file mode 100644 index 0000000..3fa4d60 --- /dev/null +++ b/contracts/margined-collector/src/handle.rs @@ -0,0 +1,119 @@ +use cosmwasm_std::{ + ensure, BankMsg, Coin, CosmosMsg, Deps, DepsMut, Env, Event, MessageInfo, Response, Uint128, +}; +use margined_common::{common::check_denom_metadata, errors::ContractError}; +use osmosis_std::types::cosmos::bank::v1beta1::BankQuerier; +use std::str::FromStr; + +use crate::state::{ + is_token, remove_token as remove_token_from_list, save_token, OWNER, WHITELIST_ADDRESS, +}; + +pub fn add_token( + deps: DepsMut, + info: MessageInfo, + token: String, +) -> Result { + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + check_denom_metadata(deps.as_ref(), &token) + .map_err(|_| ContractError::InvalidDenom(token.clone()))?; + + save_token(deps, token.clone())?; + + Ok(Response::default() + .add_event(Event::new("add_token").add_attributes([("denom", token.as_str())]))) +} + +pub fn remove_token( + deps: DepsMut, + info: MessageInfo, + token: String, +) -> Result { + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + remove_token_from_list(deps, token.clone())?; + + Ok(Response::default() + .add_event(Event::new("remove_token").add_attributes([("denom", token.as_str())]))) +} + +pub fn update_whitelist( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let address = deps.api.addr_validate(&address)?; + + WHITELIST_ADDRESS.save(deps.storage, &address)?; + + Ok(Response::default() + .add_event(Event::new("update_whitelist").add_attributes([("address", address.as_str())]))) +} + +pub fn send_token( + deps: Deps, + env: Env, + info: MessageInfo, + token: String, + amount: Uint128, + recipient: String, +) -> Result { + if amount.is_zero() { + return Err(ContractError::ZeroTransfer {}); + } + + if !OWNER.is_admin(deps, &info.sender)? + && info.sender != WHITELIST_ADDRESS.load(deps.storage)? + { + return Err(ContractError::Unauthorized {}); + } + + let valid_recipient = deps.api.addr_validate(&recipient)?; + + if !is_token(deps.storage, token.clone()) { + return Err(ContractError::TokenUnsupported(token)); + }; + + let bank = BankQuerier::new(&deps.querier); + + let balance = match bank + .balance(env.contract.address.to_string(), token.clone()) + .unwrap() + .balance + { + Some(balance) => Uint128::from_str(balance.amount.as_str()).unwrap(), + None => Uint128::zero(), + }; + + if balance < amount { + return Err(ContractError::InsufficientBalance {}); + } + + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: valid_recipient.to_string(), + amount: vec![Coin { + denom: token.clone(), + amount, + }], + }); + + Ok(Response::default() + .add_message(msg) + .add_event(Event::new("send_token").add_attributes([ + ("amount", &amount.to_string()), + ("denom", &token), + ("recipient", &info.sender.to_string()), + ]))) +} diff --git a/contracts/margined-collector/src/lib.rs b/contracts/margined-collector/src/lib.rs new file mode 100644 index 0000000..f619092 --- /dev/null +++ b/contracts/margined-collector/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +mod handle; +mod query; +mod state; + +#[cfg(test)] +mod testing; diff --git a/contracts/margined-collector/src/query.rs b/contracts/margined-collector/src/query.rs new file mode 100644 index 0000000..80cb568 --- /dev/null +++ b/contracts/margined-collector/src/query.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::{Addr, Deps, StdResult}; +use margined_common::errors::ContractError; +use margined_protocol::collector::{ + AllTokenResponse, TokenLengthResponse, TokenResponse, WhitelistResponse, +}; + +use crate::state::{is_token, read_token_list, OWNER, TOKEN_LIMIT, WHITELIST_ADDRESS}; + +const DEFAULT_PAGINATION_LIMIT: u32 = 10u32; +const MAX_PAGINATION_LIMIT: u32 = TOKEN_LIMIT as u32; + +pub fn query_owner(deps: Deps) -> Result { + if let Some(owner) = OWNER.get(deps)? { + Ok(owner) + } else { + Err(ContractError::NoOwner {}) + } +} + +pub fn query_whitelist(deps: Deps) -> StdResult { + let address = WHITELIST_ADDRESS.may_load(deps.storage)?; + + Ok(WhitelistResponse { address }) +} + +pub fn query_is_token(deps: Deps, token: String) -> StdResult { + let token_bool = is_token(deps.storage, token); + + Ok(TokenResponse { + is_token: token_bool, + }) +} + +pub fn query_all_token(deps: Deps, limit: Option) -> StdResult { + let limit = limit + .unwrap_or(DEFAULT_PAGINATION_LIMIT) + .min(MAX_PAGINATION_LIMIT) as usize; + + let list = read_token_list(deps, limit)?; + Ok(AllTokenResponse { token_list: list }) +} + +pub fn query_token_list_length(deps: Deps) -> StdResult { + let limit = TOKEN_LIMIT; + + let list_length = read_token_list(deps, limit)?.len(); + Ok(TokenLengthResponse { + length: list_length, + }) +} diff --git a/contracts/margined-collector/src/state.rs b/contracts/margined-collector/src/state.rs new file mode 100644 index 0000000..3d2aa9d --- /dev/null +++ b/contracts/margined-collector/src/state.rs @@ -0,0 +1,79 @@ +use cosmwasm_std::{Addr, Deps, DepsMut, StdError::GenericErr, StdResult, Storage}; +use cw_controllers::Admin; +use cw_storage_plus::Item; +use margined_common::ownership::OwnerProposal; + +pub const OWNER: Admin = Admin::new("owner"); +pub const OWNERSHIP_PROPOSAL: Item = Item::new("ownership_proposals"); + +pub const WHITELIST_ADDRESS: Item = Item::new("whitelist-address"); +pub const TOKEN_LIST: Item> = Item::new("token-list"); +pub const TOKEN_LIMIT: usize = 3usize; + +pub fn save_token(deps: DepsMut, denom: String) -> StdResult<()> { + let mut token_list = match TOKEN_LIST.may_load(deps.storage)? { + None => vec![], + Some(list) => list, + }; + + if token_list.contains(&denom) { + return Err(GenericErr { + msg: "This token is already added".to_string(), + }); + }; + + if token_list.len() >= TOKEN_LIMIT { + return Err(GenericErr { + msg: "The token capacity is already reached".to_string(), + }); + }; + + token_list.push(denom); + + TOKEN_LIST.save(deps.storage, &token_list) +} + +pub fn read_token_list(deps: Deps, limit: usize) -> StdResult> { + let list = match TOKEN_LIST.may_load(deps.storage)? { + None => Vec::new(), + Some(list) => { + let take = limit.min(list.len()); + list[..take].to_vec() + } + }; + + Ok(list) +} + +pub fn is_token(storage: &dyn Storage, token: String) -> bool { + match TOKEN_LIST.may_load(storage).unwrap() { + None => false, + Some(list) => list.contains(&token), + } +} + +pub fn remove_token(deps: DepsMut, denom: String) -> StdResult<()> { + let mut token_list = match TOKEN_LIST.may_load(deps.storage)? { + None => { + return Err(GenericErr { + msg: "No tokens are stored".to_string(), + }) + } + Some(value) => value, + }; + + if !token_list.contains(&denom) { + return Err(GenericErr { + msg: "This token has not been added".to_string(), + }); + } + + let index = token_list + .clone() + .iter() + .position(|x| x.eq(&denom)) + .unwrap(); + token_list.swap_remove(index); + + TOKEN_LIST.save(deps.storage, &token_list) +} diff --git a/contracts/margined-collector/src/testing/mod.rs b/contracts/margined-collector/src/testing/mod.rs new file mode 100644 index 0000000..18324c9 --- /dev/null +++ b/contracts/margined-collector/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod ownership_test; +mod tests; diff --git a/contracts/margined-collector/src/testing/ownership_test.rs b/contracts/margined-collector/src/testing/ownership_test.rs new file mode 100644 index 0000000..780e8de --- /dev/null +++ b/contracts/margined-collector/src/testing/ownership_test.rs @@ -0,0 +1,175 @@ +use cosmwasm_std::Addr; +use margined_protocol::collector::{ExecuteMsg, OwnerProposalResponse, QueryMsg}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{Account, Module, RunnerError, Wasm}; + +const PROPOSAL_DURATION: u64 = 1000; + +#[test] +fn test_update_owner() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // claim before a proposal is made + { + let err = wasm + .execute( + &fee_collector, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Proposal not found: execute wasm contract failed".to_string() + } + ); + } + + // propose new owner + wasm.execute( + &fee_collector, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // reject claim by incorrect new owner + { + let err = wasm + .execute( + &fee_collector, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // let proposal expire + env.app.increase_time(PROPOSAL_DURATION + 1); + + // proposal fails due to expiry + { + let err = wasm + .execute( + &fee_collector, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Expired: execute wasm contract failed".to_string() + } + ); + } + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // propose new owner + wasm.execute( + &fee_collector, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // proposal fails due to expiry + { + let err = wasm + .execute( + &fee_collector, + &ExecuteMsg::RejectOwner {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // proposal fails due to expiry + { + wasm.execute( + &fee_collector, + &ExecuteMsg::RejectOwner {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // propose new owner + wasm.execute( + &fee_collector, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let block_time = env.app.get_block_time_seconds(); + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // query ownership proposal + { + let proposal: OwnerProposalResponse = wasm + .query(&fee_collector, &QueryMsg::GetOwnershipProposal {}) + .unwrap(); + + assert_eq!(proposal.owner, env.traders[0].address()); + assert_eq!(proposal.expiry, block_time as u64 + PROPOSAL_DURATION); + } + + // claim ownership + { + wasm.execute( + &fee_collector, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap(); + } + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.traders[0].address()); +} diff --git a/contracts/margined-collector/src/testing/tests.rs b/contracts/margined-collector/src/testing/tests.rs new file mode 100644 index 0000000..249caf4 --- /dev/null +++ b/contracts/margined-collector/src/testing/tests.rs @@ -0,0 +1,711 @@ +use cosmwasm_std::{Addr, Uint128}; +use margined_protocol::collector::{ + AllTokenResponse, ExecuteMsg, QueryMsg, TokenLengthResponse, TokenResponse, WhitelistResponse, +}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::{bank::v1beta1::MsgSend, base::v1beta1::Coin}, + Account, Bank, Module, Wasm, +}; + +#[test] +fn test_instantiation() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + let owner: Addr = wasm.query(&fee_collector, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); +} + +#[test] +fn test_update_whitelist() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // update the whitelist + wasm.execute( + &fee_collector, + &ExecuteMsg::UpdateWhitelist { + address: env.traders[0].address(), + }, + &[], + &env.signer, + ) + .unwrap(); + + let res: WhitelistResponse = wasm + .query(&fee_collector, &QueryMsg::GetWhitelist {}) + .unwrap(); + assert_eq!(res.address, Some(Addr::unchecked(env.traders[0].address()))); +} + +#[test] +fn test_query_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // add token to tokenlist here + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // query if the token has been added + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(is_token); +} + +#[test] +fn test_query_all_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // check to see that there are no tokens listed + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert!(res.token_list.is_empty()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // add another token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // check for the added tokens + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert_eq!( + res.token_list, + vec!["uusdc".to_string(), "uosmo".to_string(),] + ); +} + +#[test] +fn test_add_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // check to see that there are no tokens listed + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert!(res.token_list.is_empty()); + + // query the token we want to add + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(!is_token); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // check for the added token + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(is_token); +} + +#[test] +fn test_add_token_twice() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // add a token again! + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap_err(); + + assert_eq!( + res.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: This token is already added: execute wasm contract failed" + ); +} + +#[test] +fn test_add_second_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // check to see that there are no tokens listed + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert!(res.token_list.is_empty()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // add another token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // check for the added token + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uosmo".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(is_token); +} + +#[test] +fn test_remove_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // check to see that there are no tokens listed + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert!(res.token_list.is_empty()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(is_token); + + // remove the first token + wasm.execute( + &fee_collector, + &ExecuteMsg::RemoveToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // check that the first token is not there + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(!is_token); +} + +#[test] +fn test_remove_non_existing_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "uusdc".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(is_token); + + // check that the first token is not there + let res: TokenResponse = wasm + .query( + &fee_collector, + &QueryMsg::IsToken { + token: "token2".to_string(), + }, + ) + .unwrap(); + let is_token = res.is_token; + + assert!(!is_token); + + // remove a token which isn't stored + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::RemoveToken { + token: "token2".to_string(), + }, + &[], + &env.signer, + ) + .unwrap_err(); + + assert_eq!( + res.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: This token has not been added: execute wasm contract failed" + ) +} + +#[test] +fn test_token_capacity() { + // for the purpose of this test, TOKEN_LIMIT is set to 3 (so four exceeds it!) + // instantiate contract here + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + let tokens: Vec = vec![ + "uusdc".to_string(), + "uosmo".to_string(), + "token3".to_string(), + "token4".to_string(), + ]; + + // add three tokens + for n in 1..4 { + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: tokens[n - 1].clone(), + }, + &[], + &env.signer, + ) + .unwrap(); + } + + // try to add a fourth token + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "token5".to_string(), + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + res.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: The token capacity is already reached: execute wasm contract failed" + ); +} + +#[test] +fn test_token_length() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // check to see that there are no tokens listed + let res: AllTokenResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenList { limit: None }) + .unwrap(); + + assert!(res.token_list.is_empty()); + + // add a token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uusdc".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // add another token + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // check for the second added token + let res: TokenLengthResponse = wasm + .query(&fee_collector, &QueryMsg::GetTokenLength {}) + .unwrap(); + + assert_eq!(res.length, 2usize); +} + +#[test] +fn test_send_native_token() { + // Using the native token, we only work to 6dp + + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // give funds to the fee pool contract + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: fee_collector.clone(), + amount: vec![Coin { + amount: (5_000 * 10u128.pow(6)).to_string(), + denom: "uosmo".to_string(), + }], + }, + &env.signer, + ) + .unwrap(); + + // add the token so we can send funds with it + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // query balance of bob + let balance = env.get_balance(env.empty.address(), "uosmo".to_string()); + assert_eq!(balance, Uint128::zero()); + + // query balance of contract + let balance = env.get_balance(fee_collector.clone(), "uosmo".to_string()); + assert_eq!(balance, Uint128::from(5_000u128 * 10u128.pow(6))); + + // send token + wasm.execute( + &fee_collector, + &ExecuteMsg::SendToken { + token: "uosmo".to_string(), + amount: Uint128::from(1000u128 * 10u128.pow(6)), + recipient: env.empty.address(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // query new balance of intended recipient + let balance = env.get_balance(env.empty.address(), "uosmo".to_string()); + assert_eq!(balance, Uint128::from(1_000u128 * 10u128.pow(6))); + + // Query new contract balance + let balance = env.get_balance(fee_collector, "uosmo".to_string()); + assert_eq!(balance, Uint128::from(4000u128 * 10u128.pow(6))); +} + +#[test] +fn test_send_native_token_unsupported_token() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // give funds to the fee pool contract + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: fee_collector.clone(), + amount: vec![Coin { + amount: (5_000u128 * 10u128.pow(6)).to_string(), + denom: "uosmo".to_string(), + }], + }, + &env.signer, + ) + .unwrap(); + + // try to send token - note this fails because we have not added the token to the token list, so it is not accepted/supported yet + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::SendToken { + token: "uosmo".to_string(), + amount: Uint128::from(1000u128 * 10u128.pow(6)), + recipient: env.empty.address(), + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + "execute error: failed to execute message; message index: 0: Token denom 'uosmo' is not supported: execute wasm contract failed", + res.to_string() + ); +} + +#[test] +fn test_send_native_token_insufficient_balance() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // give funds to the fee pool contract + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: fee_collector.clone(), + amount: vec![Coin { + amount: (1_000u128 * 10u128.pow(6)).to_string(), + denom: "uosmo".to_string(), + }], + }, + &env.signer, + ) + .unwrap(); + + // add the token so we can send funds with it + wasm.execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // query balance of bob + let balance = env.get_balance(env.empty.address(), "uosmo".to_string()); + assert_eq!(balance, Uint128::zero()); + + // query balance of contract + let balance = env.get_balance(fee_collector.clone(), "uosmo".to_string()); + assert_eq!(balance, Uint128::from(1000u128 * 10u128.pow(6))); + + // send token + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::SendToken { + token: "uosmo".to_string(), + amount: Uint128::from(2000u128 * 10u128.pow(6)), + recipient: env.empty.address(), + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + "execute error: failed to execute message; message index: 0: Insufficient balance: execute wasm contract failed".to_string(), + res.to_string() + ); + // query new balance of intended recipient + let balance = env.get_balance(env.empty.address(), "uosmo".to_string()); + assert_eq!(balance, Uint128::zero()); + + // Query new contract balance + let balance = env.get_balance(fee_collector, "uosmo".to_string()); + assert_eq!(balance, Uint128::from(1000u128 * 10u128.pow(6))); +} + +#[test] +fn test_not_owner() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + + // try to add a token + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::AddToken { + token: "uosmo".to_string(), + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(res.to_string(), "execute error: failed to execute message; message index: 0: Unauthorized: execute wasm contract failed"); + + // try to remove a token + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::RemoveToken { + token: "uusdc".to_string(), + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(res.to_string(), "execute error: failed to execute message; message index: 0: Unauthorized: execute wasm contract failed"); + + // try to send money + let res = wasm + .execute( + &fee_collector, + &ExecuteMsg::SendToken { + token: "uosmo".to_string(), + amount: Uint128::from(2000u128 * 10u128.pow(6)), + recipient: env.traders[0].address(), + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!("execute error: failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string(), res.to_string()); +} diff --git a/contracts/margined-power/Cargo.toml b/contracts/margined-power/Cargo.toml new file mode 100644 index 0000000..18c9bcf --- /dev/null +++ b/contracts/margined-power/Cargo.toml @@ -0,0 +1,43 @@ +[package] +authors = [ "Friedrich Grabner " ] +edition = "2021" +name = "margined-power" +version = "0.1.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = [ "cdylib", "rlib" ] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] +# use library feature to disable all instantiate/execute/query exports +library = [ ] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +injective-math = { workspace = true } +margined-common = { workspace = true } +margined-protocol = { workspace = true } +num = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +cosmrs = { workspace = true } +margined-testing = { workspace = true } +mock-query = { workspace = true } +osmosis-test-tube = { workspace = true } diff --git a/contracts/margined-power/README.md b/contracts/margined-power/README.md new file mode 100644 index 0000000..e818415 --- /dev/null +++ b/contracts/margined-power/README.md @@ -0,0 +1,5 @@ +# Margined Power Contract + +Margined Power contract allows users to gain exposure to the value of base denom squared (base^2). Like a traditional perpetual power contract but tracking the index price of base^2. + +Margined Power integrates with Osmosis Concentrated Liquidity orderbooks to allow the market to give true price discovery. \ No newline at end of file diff --git a/contracts/margined-power/src/bin/power_schema.rs b/contracts/margined-power/src/bin/power_schema.rs new file mode 100644 index 0000000..8bf2f61 --- /dev/null +++ b/contracts/margined-power/src/bin/power_schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use margined_protocol::power::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/margined-power/src/contract.rs b/contracts/margined-power/src/contract.rs new file mode 100644 index 0000000..03bfee2 --- /dev/null +++ b/contracts/margined-power/src/contract.rs @@ -0,0 +1,221 @@ +use crate::{ + handle::{ + handle_apply_funding, handle_burn_power_perp, handle_close_short, handle_deposit, + handle_liquidation, handle_mint_power_perp, handle_open_contract, handle_open_short, + handle_pause, handle_unpause, handle_update_config, handle_withdrawal, + }, + query::{ + get_check_vault, get_denormalised_mark, get_denormalised_mark_for_funding, get_index, + get_next_vault_id, get_normalisation_factor, get_unscaled_index, get_user_vaults, + get_vault, query_config, query_owner, query_state, + }, + reply::{handle_close_short_reply, handle_open_short_reply, handle_open_short_swap_reply}, + state::{Config, State, CONFIG, OWNER, OWNERSHIP_PROPOSAL, STATE}, +}; + +use cosmwasm_std::{ + entry_point, to_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Reply, Response, + StdError, StdResult, +}; +use cw2::set_contract_version; +use margined_common::{ + common::{check_denom_exists_in_pool, check_denom_metadata}, + errors::ContractError, + ownership::{ + get_ownership_proposal, handle_claim_ownership, handle_ownership_proposal, + handle_ownership_proposal_rejection, + }, +}; +use margined_protocol::power::{ + ExecuteMsg, InstantiateMsg, MigrateMsg, Pool, QueryMsg, FUNDING_PERIOD, +}; +use std::str::FromStr; + +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const OPEN_SHORT_REPLY_ID: u64 = 1u64; +pub const OPEN_SHORT_SWAP_REPLY_ID: u64 = 2u64; +pub const CLOSE_SHORT_REPLY_ID: u64 = 3u64; +pub const CLOSE_SHORT_SWAP_REPLY_ID: u64 = 4u64; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version( + deps.storage, + format!("crates.io:{CONTRACT_NAME}"), + CONTRACT_VERSION, + )?; + + let config = Config { + fee_rate: Decimal::from_str(&msg.fee_rate)?, + fee_pool_contract: deps.api.addr_validate(&msg.fee_pool)?, + query_contract: deps.api.addr_validate(&msg.query_contract)?, + power_denom: msg.power_denom.clone(), + base_denom: msg.base_denom, + base_pool: Pool { + id: msg.base_pool_id, + quote_denom: msg.base_pool_quote, + }, + power_pool: Pool { + id: msg.power_pool_id, + quote_denom: msg.power_denom, + }, + funding_period: FUNDING_PERIOD, + base_decimals: msg.base_decimals, + power_decimals: msg.power_decimals, + }; + + config.validate()?; + + CONFIG.save(deps.storage, &config)?; + + // validate denoms exist + check_denom_metadata(deps.as_ref(), &config.base_denom) + .map_err(|_| ContractError::InvalidDenom(config.base_denom.clone()))?; + check_denom_metadata(deps.as_ref(), &config.power_denom) + .map_err(|_| ContractError::InvalidDenom(config.power_denom.clone()))?; + + // validate denoms are present in pool + check_denom_exists_in_pool(deps.as_ref(), config.base_pool.id, &config.base_denom) + .map_err(ContractError::Std)?; + check_denom_exists_in_pool(deps.as_ref(), config.power_pool.id, &config.base_denom) + .map_err(ContractError::Std)?; + check_denom_exists_in_pool(deps.as_ref(), config.power_pool.id, &config.power_denom) + .map_err(ContractError::Std)?; + + STATE.save( + deps.storage, + &State { + is_open: false, + is_paused: true, + last_pause: env.block.time, + normalisation_factor: Decimal::one(), + last_funding_update: env.block.time, + }, + )?; + + OWNER.set(deps, Some(info.sender))?; + + Ok(Response::new().add_attribute("action", "instantiate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result { + match msg.id { + OPEN_SHORT_REPLY_ID => handle_open_short_reply(deps, env, msg), + OPEN_SHORT_SWAP_REPLY_ID => handle_open_short_swap_reply(deps, env, msg), + CLOSE_SHORT_REPLY_ID => handle_close_short_reply(deps, env, msg), + + _ => Err(ContractError::UnknownReplyId(msg.id)), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::SetOpen {} => handle_open_contract(deps, env, info), + ExecuteMsg::MintPowerPerp { + amount, + vault_id, + rebase, + } => handle_mint_power_perp(deps, env, info, amount, vault_id, rebase), + ExecuteMsg::BurnPowerPerp { + amount_to_withdraw, + vault_id, + } => handle_burn_power_perp(deps, env, info, amount_to_withdraw, vault_id), + ExecuteMsg::OpenShort { amount, vault_id } => { + handle_open_short(deps, env, info, amount, vault_id) + } + ExecuteMsg::CloseShort { + amount_to_burn, + amount_to_withdraw, + vault_id, + } => handle_close_short( + deps, + env, + info, + amount_to_burn, + amount_to_withdraw, + vault_id, + ), + ExecuteMsg::Deposit { vault_id } => handle_deposit(deps, env, info, vault_id), + ExecuteMsg::Withdraw { amount, vault_id } => { + handle_withdrawal(deps, env, info, amount, vault_id) + } + ExecuteMsg::Liquidate { + vault_id, + max_debt_amount, + } => handle_liquidation(deps, env, info, max_debt_amount, vault_id), + ExecuteMsg::ApplyFunding { .. } => handle_apply_funding(deps, env, info), + ExecuteMsg::UpdateConfig { fee_rate, fee_pool } => { + handle_update_config(deps, info, fee_rate, fee_pool) + } + ExecuteMsg::Pause {} => handle_pause(deps, env, info), + ExecuteMsg::UnPause {} => handle_unpause(deps, env, info), + ExecuteMsg::ProposeNewOwner { + new_owner, + duration, + } => handle_ownership_proposal( + deps, + info, + env, + new_owner, + duration, + OWNER, + OWNERSHIP_PROPOSAL, + ), + ExecuteMsg::RejectOwner {} => { + handle_ownership_proposal_rejection(deps, info, OWNER, OWNERSHIP_PROPOSAL) + } + ExecuteMsg::ClaimOwnership {} => { + handle_claim_ownership(deps, info, env, OWNER, OWNERSHIP_PROPOSAL) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_binary(&query_config(deps)?), + QueryMsg::State {} => to_binary(&query_state(deps)?), + QueryMsg::Owner {} => { + to_binary(&query_owner(deps).map_err(|err| StdError::generic_err(err.to_string()))?) + } + QueryMsg::GetNormalisationFactor {} => to_binary(&get_normalisation_factor(deps, env)?), + QueryMsg::GetIndex { period } => to_binary(&get_index(deps, env, period)?), + QueryMsg::GetUnscaledIndex { period } => to_binary(&get_unscaled_index(deps, env, period)?), + QueryMsg::GetDenormalisedMark { period } => { + to_binary(&get_denormalised_mark(deps, env, period)?) + } + QueryMsg::GetDenormalisedMarkFunding { period } => { + to_binary(&get_denormalised_mark_for_funding(deps, env, period)?) + } + QueryMsg::GetVault { vault_id } => to_binary(&get_vault(deps, vault_id)?), + QueryMsg::GetNextVaultId {} => to_binary(&get_next_vault_id(deps)?), + QueryMsg::GetUserVaults { + user, + start_after, + limit, + } => to_binary(&get_user_vaults(deps, user, start_after, limit)?), + QueryMsg::GetOwnershipProposal {} => { + to_binary(&get_ownership_proposal(deps, OWNERSHIP_PROPOSAL)?) + } + QueryMsg::CheckVault { vault_id } => to_binary(&get_check_vault(deps, env, vault_id)?), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::new()) +} diff --git a/contracts/margined-power/src/funding.rs b/contracts/margined-power/src/funding.rs new file mode 100644 index 0000000..53b5c76 --- /dev/null +++ b/contracts/margined-power/src/funding.rs @@ -0,0 +1,76 @@ +use crate::{ + helpers::{calculate_denormalized_mark, calculate_index, wrapped_pow}, + state::STATE, +}; + +use cosmwasm_std::{Decimal, Deps, DepsMut, Env, StdResult}; +use margined_protocol::power::FUNDING_PERIOD; +use num::Zero; + +pub const MAX_TWAP_PERIOD: u64 = 48 * 60 * 60; // TWAP from pool can be no longer than 48 hours + +pub fn apply_funding_rate(deps: DepsMut, env: Env) -> StdResult { + let state = STATE.load(deps.storage).unwrap(); + + // normalisation factor is only updated onces per block + if state.last_funding_update.seconds() == env.block.time.seconds() { + return Ok(state.normalisation_factor); + } + + let normalisation_factor = calculate_normalisation_factor(deps.as_ref(), env.clone())?; + + STATE.update(deps.storage, |mut state| -> StdResult<_> { + state.normalisation_factor = normalisation_factor; + state.last_funding_update = env.block.time; + + Ok(state) + })?; + + Ok(normalisation_factor) +} + +pub fn calculate_normalisation_factor(deps: Deps, env: Env) -> StdResult { + let state = STATE.load(deps.storage).unwrap(); + + let funding_period = env + .block + .time + .minus_seconds(state.last_funding_update.seconds()); + + let period = funding_period.seconds().min(MAX_TWAP_PERIOD); + + // NOTE: pools must have a TWAP available for past 48 hours + let start_time = if period < MAX_TWAP_PERIOD { + state.last_funding_update + } else { + env.block.time.minus_seconds(MAX_TWAP_PERIOD) + }; + + if period.is_zero() { + return Ok(state.normalisation_factor); + }; + + let mut mark = + calculate_denormalized_mark(deps, start_time, state.normalisation_factor).unwrap(); + + let index = calculate_index(deps, start_time).unwrap(); + + let r_funding = Decimal::from_ratio(funding_period.seconds(), FUNDING_PERIOD); + + // check that the mark price is between upper and lower bounds of 140% and 80% of the index price + let lower_bound = index * Decimal::percent(80); + let upper_bound = index * Decimal::percent(140); + + if mark < lower_bound { + mark = lower_bound; + } else if mark > upper_bound { + mark = upper_bound; + }; + + // normFactor(new) = multiplier * normFactor(old) + // multiplier = (index/mark)^rFunding + let base = index.checked_div(mark).unwrap(); + let multiplier = wrapped_pow(base, r_funding).unwrap(); + + Ok(multiplier * state.normalisation_factor) +} diff --git a/contracts/margined-power/src/handle.rs b/contracts/margined-power/src/handle.rs new file mode 100644 index 0000000..56730c8 --- /dev/null +++ b/contracts/margined-power/src/handle.rs @@ -0,0 +1,421 @@ +use crate::{ + contract::CLOSE_SHORT_REPLY_ID, + funding::apply_funding_rate, + helpers::{ + create_apply_funding_event, create_swap_exact_amount_out_message, get_liquidation_results, + }, + operations::{burn, mint}, + queries::{get_balance, get_denom_authority, get_total_supply}, + state::{Config, State, TmpCacheValues, CONFIG, OWNER, STATE, TMP_CACHE, WEEK_IN_SECONDS}, + vault::{add_collateral, burn_vault, check_can_burn, check_vault, subtract_collateral, VAULTS}, +}; + +use cosmwasm_std::{ + coin, ensure, ensure_eq, BankMsg, CosmosMsg, Decimal, DepsMut, Env, Event, MessageInfo, + ReplyOn, Response, StdResult, SubMsg, Uint128, +}; +use cw_utils::{must_pay, nonpayable}; +use margined_common::errors::ContractError; +use osmosis_std::types::{cosmos::base::v1beta1::Coin, osmosis::tokenfactory::v1beta1::MsgBurn}; +use std::str::FromStr; + +pub fn handle_open_contract( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let state: State = STATE.load(deps.storage)?; + ensure!(!state.is_open, ContractError::IsOpen {}); + + let config: Config = CONFIG.load(deps.storage)?; + + // get power token denom authority + let admin = get_denom_authority(deps.as_ref(), config.power_denom).unwrap(); + ensure_eq!(admin, env.contract.address, ContractError::NotTokenAdmin {}); + + // set the contract to open + STATE.update(deps.storage, |mut state| -> StdResult<_> { + state.is_open = true; + state.is_paused = false; + Ok(state) + })?; + + Ok(Response::new().add_event(Event::new("open_contract"))) +} + +pub fn handle_update_config( + deps: DepsMut, + info: MessageInfo, + fee_rate: Option, + fee_pool: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let mut event = Event::new("update_config"); + if let Some(fee_rate) = fee_rate { + config.fee_rate = Decimal::from_str(&fee_rate)?; + event = event.add_attribute("fee_rate", fee_rate); + } + + if let Some(fee_pool) = fee_pool { + config.fee_pool_contract = deps.api.addr_validate(&fee_pool)?; + event = event.add_attribute("fee_pool", fee_pool); + } + + config.validate()?; + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default().add_event(event)) +} + +pub fn handle_pause(deps: DepsMut, env: Env, info: MessageInfo) -> Result { + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let mut state = STATE.load(deps.storage)?; + + state.is_open_and_unpaused()?; + + state.is_paused = true; + state.last_pause = env.block.time; + + STATE.save(deps.storage, &state)?; + + let event = Event::new("pause"); + + Ok(Response::default().add_event( + event + .add_attribute("is_paused", state.is_paused.to_string()) + .add_attribute("last_pause", state.last_pause.to_string()), + )) +} + +pub fn handle_unpause( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let mut state = STATE.load(deps.storage)?; + + if !state.is_open { + return Err(ContractError::NotOpen {}); + } + + let unpause_time = if !OWNER.is_admin(deps.as_ref(), &info.sender)? { + state.last_pause.seconds() + WEEK_IN_SECONDS + } else { + state.last_pause.seconds() + }; + + if env.block.time.seconds() < unpause_time { + return Err(ContractError::NotExpired {}); + } + + state.is_paused = false; + + STATE.save(deps.storage, &state)?; + + let event = Event::new("unpause"); + + Ok(Response::default().add_event( + event + .add_attribute("is_paused", state.is_paused.to_string()) + .add_attribute("last_pause", state.last_pause.to_string()), + )) +} + +pub fn handle_mint_power_perp( + deps: DepsMut, + env: Env, + info: MessageInfo, + mint_amount: Uint128, + vault_id: Option, + rebase: bool, +) -> Result { + mint(deps, env, info, mint_amount, vault_id, rebase, false) +} + +pub fn handle_burn_power_perp( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount_to_withdraw: Option, + vault_id: u64, +) -> Result { + burn(deps, env, info, amount_to_withdraw, vault_id) +} + +pub fn handle_open_short( + deps: DepsMut, + env: Env, + info: MessageInfo, + mint_amount: Uint128, + vault_id: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + let total_supply = get_total_supply(deps.as_ref(), config.power_denom)?; + + TMP_CACHE.save( + deps.storage, + &TmpCacheValues { + total_supply: Some(total_supply), + sender: Some(info.sender.clone()), + vault_id, + ..Default::default() + }, + )?; + + mint(deps, env, info, mint_amount, vault_id, false, true) +} + +pub fn handle_close_short( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount_to_burn: Uint128, + amount_to_withdraw: Option, + vault_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + check_can_burn( + deps.as_ref().storage, + vault_id, + info.sender.clone(), + amount_to_burn, + amount_to_withdraw.unwrap_or(Uint128::zero()), + )?; + + let power_balance = get_balance( + deps.as_ref(), + env.contract.address.to_string(), + config.power_denom.clone(), + )?; + + let amount_to_swap = + must_pay(&info, &config.base_denom).map_err(|_| ContractError::InvalidFunds {})?; + + let swap_msg = create_swap_exact_amount_out_message( + env.contract.address.to_string(), + config.power_pool.id, + config.base_denom, + config.power_denom, + amount_to_burn.to_string(), + amount_to_swap.to_string(), + ); + + let swap_submsg: SubMsg = SubMsg { + id: CLOSE_SHORT_REPLY_ID, + msg: swap_msg.into(), + gas_limit: None, + reply_on: ReplyOn::Success, + }; + + TMP_CACHE.save( + deps.storage, + &TmpCacheValues { + balance: Some(power_balance), + amount_to_swap: Some(amount_to_swap), + amount_to_withdraw, + sender: Some(info.sender), + vault_id: Some(vault_id), + ..Default::default() + }, + )?; + + Ok(Response::new().add_submessage(swap_submsg)) +} + +pub fn handle_liquidation( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + max_debt_amount: Uint128, + vault_id: u64, +) -> Result { + STATE.load(deps.storage)?.is_open_and_unpaused()?; + + let config: Config = CONFIG.load(deps.storage)?; + if !VAULTS.has(deps.storage, &vault_id) { + return Err(ContractError::VaultDoesNotExist {}); + }; + + // liquidator does not require to send funds, rather it is burnt directly + nonpayable(&info).map_err(|_| ContractError::NonPayable {})?; + + let cached_normalisation_factor = apply_funding_rate(deps.branch(), env.clone())?; + + let (is_safe, _) = check_vault( + deps.as_ref(), + config.clone(), + vault_id, + cached_normalisation_factor, + env.block.time, + )?; + + ensure!(!is_safe, ContractError::SafeVault {}); + + let vault = VAULTS.load(deps.storage, &vault_id)?; + + let (liquidation_amount, collateral_to_pay) = + get_liquidation_results(deps.as_ref(), env.clone(), max_debt_amount, vault.clone()); + + if max_debt_amount < liquidation_amount { + return Err(ContractError::InvalidLiquidation {}); + } + + burn_vault( + deps.storage, + vault_id, + vault.operator, + collateral_to_pay, + liquidation_amount, + )?; + + // burn power perp token + let msg_burn: CosmosMsg = MsgBurn { + sender: env.contract.address.to_string(), + amount: Some(Coin { + denom: config.power_denom, + amount: liquidation_amount.to_string(), + }), + burn_from_address: info.sender.to_string(), + } + .into(); + + // transfer collateral to sender + let msg_transfer: CosmosMsg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![coin(collateral_to_pay.u128(), config.base_denom)], + }); + + let liquidation_event = Event::new("liquidation").add_attributes([ + ("liquidation_amount", &liquidation_amount.to_string()), + ("collateral_to_pay", &collateral_to_pay.to_string()), + ("vault_id", &vault_id.to_string()), + ]); + + let funding_event = create_apply_funding_event(&cached_normalisation_factor.to_string()); + + Ok(Response::new() + .add_messages(vec![msg_burn, msg_transfer]) + .add_events([liquidation_event, funding_event])) +} + +pub fn handle_deposit( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + vault_id: u64, +) -> Result { + STATE.load(deps.storage)?.is_open_and_unpaused()?; + + let config: Config = CONFIG.load(deps.storage)?; + + if !VAULTS.has(deps.storage, &vault_id) { + return Err(ContractError::VaultDoesNotExist {}); + }; + + let deposit_amount = + must_pay(&info, &config.base_denom).map_err(|_| ContractError::InvalidFunds {})?; + + let cached_normalisation_factor = apply_funding_rate(deps.branch(), env.clone())?; + + add_collateral(deps.storage, vault_id, info.sender, deposit_amount)?; + + let (is_safe, min_collateral) = check_vault( + deps.as_ref(), + config, + vault_id, + cached_normalisation_factor, + env.block.time, + )?; + + ensure!(is_safe, ContractError::UnsafeVault {}); + ensure!(min_collateral, ContractError::BelowMinCollateralAmount {}); + + let deposit_event = Event::new("deposit").add_attributes([ + ("collateral_deposited", &deposit_amount.to_string()), + ("vault_id", &vault_id.to_string()), + ]); + + let funding_event = create_apply_funding_event(&cached_normalisation_factor.to_string()); + + Ok(Response::new().add_events([deposit_event, funding_event])) +} + +pub fn handle_withdrawal( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, + vault_id: u64, +) -> Result { + STATE.load(deps.storage)?.is_open_and_unpaused()?; + + let config: Config = CONFIG.load(deps.storage)?; + + if !VAULTS.has(deps.storage, &vault_id) { + return Err(ContractError::VaultDoesNotExist {}); + }; + + nonpayable(&info).map_err(|_| ContractError::NonPayable {})?; + + let cached_normalisation_factor = apply_funding_rate(deps.branch(), env.clone())?; + + subtract_collateral(deps.storage, vault_id, info.sender.clone(), amount)?; + + let (is_safe, min_collateral) = check_vault( + deps.as_ref(), + config.clone(), + vault_id, + cached_normalisation_factor, + env.block.time, + )?; + + ensure!(is_safe, ContractError::UnsafeVault {}); + ensure!(min_collateral, ContractError::BelowMinCollateralAmount {}); + + // transfer base to sender + let msg_transfer: CosmosMsg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![coin(amount.u128(), config.base_denom)], + }); + + let withdrawal_event = Event::new("withdraw").add_attributes([ + ("collateral_withdrawn", &amount.to_string()), + ("vault_id", &vault_id.to_string()), + ]); + + let funding_event = create_apply_funding_event(&cached_normalisation_factor.to_string()); + + Ok(Response::new() + .add_messages(vec![msg_transfer]) + .add_events([withdrawal_event, funding_event])) +} + +pub fn handle_apply_funding( + deps: DepsMut, + env: Env, + _info: MessageInfo, +) -> Result { + let funding = apply_funding_rate(deps, env)?; + + let funding_event = create_apply_funding_event(&funding.to_string()); + + Ok(Response::new().add_event(funding_event)) +} diff --git a/contracts/margined-power/src/helpers.rs b/contracts/margined-power/src/helpers.rs new file mode 100644 index 0000000..9255ca3 --- /dev/null +++ b/contracts/margined-power/src/helpers.rs @@ -0,0 +1,300 @@ +use crate::{ + contract::OPEN_SHORT_REPLY_ID, + queries::{get_pool_twap, get_scaled_pool_twap}, + state::{CONFIG, LIQUIDATION_BOUNTY, TWAP_PERIOD}, + vault::{subtract_collateral, Vault}, +}; + +use cosmwasm_std::{ + Addr, Binary, Decimal, Deps, DepsMut, Env, Event, ReplyOn, Response, StdResult, SubMsg, + SubMsgResponse, SubMsgResult, Timestamp, Uint128, +}; +use injective_math::FPDecimal; +use margined_common::errors::ContractError; +use num::pow::Pow; +use osmosis_std::types::{ + cosmos::base::v1beta1::Coin, + osmosis::poolmanager::v1beta1::{ + MsgSwapExactAmountIn, MsgSwapExactAmountOut, SwapAmountInRoute, SwapAmountOutRoute, + }, + osmosis::tokenfactory::v1beta1::MsgMint, +}; +use std::str::FromStr; + +pub fn wrapped_pow(base: Decimal, exponent: Decimal) -> StdResult { + let fp_base = FPDecimal::from_str(&base.to_string()).unwrap(); + let fp_exponent = FPDecimal::from_str(&exponent.to_string()).unwrap(); + + let result = fp_base.pow(fp_exponent); + + Ok(Decimal::from_str(&result.to_string()).unwrap()) +} + +pub fn decimal_to_fixed(value: Decimal, decimal_places: u32) -> Uint128 { + value + .atomics() + .checked_div(Uint128::new( + 10u128.pow(Decimal::DECIMAL_PLACES - decimal_places), + )) + .unwrap() +} + +pub fn calculate_fee( + deps: DepsMut, + env: Env, + sender: Addr, + vault_id: u64, + power_amount: Decimal, + deposit_amount: Decimal, +) -> StdResult<(Decimal, Decimal)> { + let config = CONFIG.load(deps.storage).unwrap(); + + if config.fee_rate.is_zero() { + return Ok((Decimal::zero(), deposit_amount)); + } + + let base_amount_value = calculate_debt_in_base(deps.as_ref(), env, power_amount).unwrap(); + + let fee_amount = base_amount_value.checked_mul(config.fee_rate).unwrap(); + + // if the deposit is unsufficient to cover the fee, use the collateral deposited + let deposit_post_fees = if deposit_amount > fee_amount { + deposit_amount.checked_sub(fee_amount).unwrap() + } else { + subtract_collateral( + deps.storage, + vault_id, + sender, + decimal_to_fixed(fee_amount, config.base_decimals), + )?; + deposit_amount + }; + + Ok((fee_amount, deposit_post_fees)) +} + +pub fn calculate_index(deps: Deps, start_time: Timestamp) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let quote_price = get_scaled_pool_twap( + &deps, + config.base_pool.id, + config.base_denom.clone(), + config.base_pool.quote_denom, + start_time, + ) + .unwrap(); + + let index = quote_price + .checked_mul(quote_price) + .unwrap() + .checked_div(Decimal::one()) + .unwrap(); + + Ok(index) +} + +pub fn calculate_denormalized_mark( + deps: Deps, + start_time: Timestamp, + normalisation_factor: Decimal, +) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let quote_price = get_scaled_pool_twap( + &deps, + config.base_pool.id, + config.base_denom.clone(), + config.base_pool.quote_denom.clone(), + start_time, + ) + .unwrap(); + + let power_price = get_pool_twap( + &deps, + config.power_pool.id, + config.power_denom, + config.base_denom, + start_time, + ) + .unwrap(); + + let mark = quote_price + .checked_mul(power_price) + .unwrap() + .checked_div(normalisation_factor) + .unwrap(); + + Ok(mark) +} + +pub fn calculate_debt_in_base(deps: Deps, env: Env, debt_amount: Decimal) -> StdResult { + let start_time = env.block.time.minus_seconds(TWAP_PERIOD); + let config = CONFIG.load(deps.storage).unwrap(); + + let power_price = get_pool_twap( + &deps, + config.power_pool.id, + config.power_denom, + config.base_denom, + start_time, + ) + .unwrap(); + + let debt_value = debt_amount.checked_mul(power_price).unwrap(); + + Ok(debt_value) +} + +pub fn create_mint_message( + response: Response, + contract_address: String, + denom: String, + amount: String, + sender: String, + should_sell: bool, +) -> Response { + match should_sell { + true => { + let mint_submsg: SubMsg = SubMsg { + id: OPEN_SHORT_REPLY_ID, + msg: MsgMint { + sender: contract_address.clone(), + amount: Some(Coin { denom, amount }), + mint_to_address: contract_address, + } + .into(), + gas_limit: None, + reply_on: ReplyOn::Success, + }; + + response.add_submessage(mint_submsg) + } + false => response.add_message(MsgMint { + sender: contract_address, + amount: Some(Coin { denom, amount }), + mint_to_address: sender, + }), + } +} + +pub fn create_swap_exact_amount_in_message( + sender: String, + pool_id: u64, + token_in_denom: String, + token_out_denom: String, + amount: String, +) -> MsgSwapExactAmountIn { + MsgSwapExactAmountIn { + sender, + routes: vec![SwapAmountInRoute { + pool_id, + token_out_denom, + }], + token_in: Some(Coin { + denom: token_in_denom, + amount, + }), + token_out_min_amount: "1".to_string(), + } +} + +pub fn create_swap_exact_amount_out_message( + sender: String, + pool_id: u64, + token_in_denom: String, + token_out_denom: String, + amount_out: String, + token_in_max_amount: String, +) -> MsgSwapExactAmountOut { + MsgSwapExactAmountOut { + sender, + routes: vec![SwapAmountOutRoute { + pool_id, + token_in_denom, + }], + token_out: Some(Coin { + denom: token_out_denom, + amount: amount_out, + }), + token_in_max_amount, + } +} + +pub fn get_liquidation_results( + deps: Deps, + env: Env, + max_repayment_amount: Uint128, + vault: Vault, +) -> (Uint128, Uint128) { + let config = CONFIG.load(deps.storage).unwrap(); + + // first try just to liquidate half + let max_liquidateable_amount = vault.short_amount.checked_div(2u128.into()).unwrap(); + + let (mut liquidation_amount, mut collateral_to_pay) = get_liquidation_amount( + deps, + env.clone(), + max_repayment_amount, + max_liquidateable_amount, + ); + + let half_base_denom = Uint128::from(10u128.pow(config.base_decimals)) + .checked_div(2u128.into()) + .unwrap(); + + if vault.collateral > collateral_to_pay + && vault.collateral.checked_sub(collateral_to_pay).unwrap() < half_base_denom + { + (liquidation_amount, collateral_to_pay) = + get_liquidation_amount(deps, env, max_repayment_amount, vault.short_amount); + } + + if collateral_to_pay > vault.collateral { + liquidation_amount = vault.short_amount; + collateral_to_pay = vault.collateral; + }; + + (liquidation_amount, collateral_to_pay) +} + +pub fn get_liquidation_amount( + deps: Deps, + env: Env, + input_amount: Uint128, + max_liquidatable_amount: Uint128, +) -> (Uint128, Uint128) { + let config = CONFIG.load(deps.storage).unwrap(); + + let amount_to_liquidate = if input_amount > max_liquidatable_amount { + max_liquidatable_amount + } else { + input_amount + }; + + let decimals_amount_to_liquidate = + Decimal::from_atomics(amount_to_liquidate, config.power_decimals).unwrap(); + let mut collateral_to_repay = + calculate_debt_in_base(deps, env, decimals_amount_to_liquidate).unwrap(); + + // 10% liquidation bounty + collateral_to_repay = collateral_to_repay.checked_mul(LIQUIDATION_BOUNTY).unwrap(); + + let collateral_to_repay = decimal_to_fixed(collateral_to_repay, config.base_decimals); + + (amount_to_liquidate, collateral_to_repay) +} + +pub fn parse_response_result_data(result: SubMsgResult) -> Result { + match result { + SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) => Ok(b), + SubMsgResult::Ok(SubMsgResponse { data: None, .. }) => { + Err(ContractError::SubMsgError("No data in reply".to_string())) + } + SubMsgResult::Err(err) => Err(ContractError::SubMsgError(err)), + } +} + +pub fn create_apply_funding_event(funding_rate: &str) -> Event { + Event::new("apply_funding").add_attribute("funding_rate", funding_rate) +} diff --git a/contracts/margined-power/src/lib.rs b/contracts/margined-power/src/lib.rs new file mode 100644 index 0000000..3f9d17d --- /dev/null +++ b/contracts/margined-power/src/lib.rs @@ -0,0 +1,13 @@ +pub mod contract; +pub mod funding; +pub mod handle; +pub mod helpers; +pub mod operations; +pub mod queries; +pub mod query; +pub mod reply; +pub mod state; +pub mod vault; + +#[cfg(test)] +mod testing; diff --git a/contracts/margined-power/src/operations.rs b/contracts/margined-power/src/operations.rs new file mode 100644 index 0000000..c023c21 --- /dev/null +++ b/contracts/margined-power/src/operations.rs @@ -0,0 +1,203 @@ +use crate::{ + funding::apply_funding_rate, + helpers::{calculate_fee, create_apply_funding_event, create_mint_message, decimal_to_fixed}, + state::{Config, CONFIG, STATE}, + vault::{burn_vault, check_vault, create_vault, update_vault, VAULTS}, +}; + +use cosmwasm_std::{ + coin, ensure, BankMsg, CosmosMsg, Decimal, DepsMut, Env, Event, MessageInfo, Response, Uint128, +}; +use cw_utils::{may_pay, must_pay}; +use margined_common::errors::ContractError; +use osmosis_std::types::{cosmos::base::v1beta1::Coin, osmosis::tokenfactory::v1beta1::MsgBurn}; + +pub fn mint( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + mint_amount: Uint128, + vault_id: Option, + rebase: bool, + should_sell: bool, +) -> Result { + STATE.load(deps.storage)?.is_open_and_unpaused()?; + + let config: Config = CONFIG.load(deps.storage)?; + + ensure!(!mint_amount.is_zero(), ContractError::ZeroMint {}); + + let collateral_sent = + may_pay(&info, &config.base_denom).map_err(|_| ContractError::InvalidFunds {})?; + + let cached_normalisation_factor = apply_funding_rate(deps.branch(), env.clone())?; + + let mint_amount = match rebase { + true => { + let fixed_normalisation_factor = + decimal_to_fixed(cached_normalisation_factor, config.base_decimals); + + mint_amount + .checked_mul(Uint128::from(10u128.pow(config.base_decimals))) + .unwrap() + .checked_div(fixed_normalisation_factor) + .unwrap() + } + false => mint_amount, + }; + + let vault_id = match vault_id { + Some(vault_id) => { + if !VAULTS.has(deps.storage, &vault_id) { + return Err(ContractError::VaultDoesNotExist {}); + }; + + vault_id + } + None => create_vault(deps.storage, info.sender.clone())?, + }; + + let (fee_amount, collateral_with_fee) = calculate_fee( + deps.branch(), + env.clone(), + info.sender.clone(), + vault_id, + Decimal::from_atomics(mint_amount, config.power_decimals).unwrap(), + Decimal::from_atomics(collateral_sent, config.base_decimals).unwrap(), + )?; + + update_vault( + deps.storage, + vault_id, + info.sender.clone(), + decimal_to_fixed(collateral_with_fee, config.base_decimals), + mint_amount, + )?; + + let (is_safe, min_collateral) = check_vault( + deps.as_ref(), + config.clone(), + vault_id, + cached_normalisation_factor, + env.block.time, + )?; + + ensure!(is_safe, ContractError::UnsafeVault {}); + ensure!(min_collateral, ContractError::BelowMinCollateralAmount {}); + + let mut response: Response = Response::new(); + if !mint_amount.is_zero() { + response = create_mint_message( + response, + env.contract.address.to_string(), + config.power_denom.clone(), + mint_amount.to_string(), + info.sender.to_string(), + should_sell, + ); + } + + if !fee_amount.is_zero() { + let fixed_fee_amount = decimal_to_fixed(fee_amount, config.base_decimals); + + let msg_fee_transfer = BankMsg::Send { + to_address: config.fee_pool_contract.to_string(), + amount: vec![coin(fixed_fee_amount.u128(), config.base_denom)], + }; + + response = response.clone().add_message(msg_fee_transfer); + } + + let mint_event = Event::new("mint").add_attributes([ + ("collateral_deposited", &collateral_sent.to_string()), + ("mint_amount", &mint_amount.to_string()), + ("fee_amount", &fee_amount.to_string()), + ("vault_id", &vault_id.to_string()), + ]); + + let funding_event = create_apply_funding_event(&cached_normalisation_factor.to_string()); + + Ok(response.add_events([mint_event, funding_event])) +} + +pub fn burn( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + amount_to_withdraw: Option, + vault_id: u64, +) -> Result { + STATE.load(deps.storage)?.is_open_and_unpaused()?; + + let config: Config = CONFIG.load(deps.storage)?; + + if !VAULTS.has(deps.storage, &vault_id) { + return Err(ContractError::VaultDoesNotExist {}); + }; + + let amount_to_withdraw = amount_to_withdraw.unwrap_or(Uint128::zero()); + + let amount_to_burn = + must_pay(&info, &config.power_denom).map_err(|_| ContractError::InvalidFunds {})?; + + if amount_to_burn.is_zero() { + return Err(ContractError::InvalidFunds {}); + } + + let cached_normalisation_factor = apply_funding_rate(deps.branch(), env.clone())?; + + burn_vault( + deps.storage, + vault_id, + info.sender.clone(), + amount_to_withdraw, + amount_to_burn, + )?; + + let (is_safe, _) = check_vault( + deps.as_ref(), + config.clone(), + vault_id, + cached_normalisation_factor, + env.block.time, + )?; + + ensure!(is_safe, ContractError::UnsafeVault {}); + + let mut messages = Vec::::new(); + + // burn power perp token + let msg_burn: CosmosMsg = MsgBurn { + sender: env.contract.address.to_string(), + amount: Some(Coin { + denom: config.power_denom.clone(), + amount: amount_to_burn.to_string(), + }), + burn_from_address: env.contract.address.to_string(), + } + .into(); + + messages.push(msg_burn); + + // transfer base to sender + if !amount_to_withdraw.is_zero() { + let msg_transfer = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![coin(amount_to_withdraw.u128(), config.base_denom)], + }); + + messages.push(msg_transfer); + }; + + let burn_event = Event::new("burn").add_attributes([ + ("collateral_burnt", &amount_to_burn.to_string()), + ("withdrawn", &amount_to_withdraw.to_string()), + ("vault_id", &vault_id.to_string()), + ]); + + let funding_event = create_apply_funding_event(&cached_normalisation_factor.to_string()); + + Ok(Response::new() + .add_messages(messages) + .add_events([burn_event, funding_event])) +} diff --git a/contracts/margined-power/src/queries.rs b/contracts/margined-power/src/queries.rs new file mode 100644 index 0000000..b637400 --- /dev/null +++ b/contracts/margined-power/src/queries.rs @@ -0,0 +1,99 @@ +use crate::state::{CONFIG, INDEX_SCALE}; + +use cosmwasm_std::{ + to_binary, Decimal, Deps, QueryRequest, StdError, StdResult, Timestamp, Uint128, WasmQuery, +}; +use margined_protocol::query::QueryMsg; +use osmosis_std::types::cosmos::bank::v1beta1::BankQuerier; +use std::str::FromStr; + +pub fn get_pool_twap( + deps: &Deps, + pool_id: u64, + base_asset: String, + quote_asset: String, + start_time: Timestamp, +) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let price: Decimal = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: config.query_contract.to_string(), + msg: to_binary(&QueryMsg::GetArithmeticTwapToNow { + pool_id, + base_asset, + quote_asset, + start_time, + })?, + }))?; + + Ok(price) +} + +pub fn get_scaled_pool_twap( + deps: &Deps, + pool_id: u64, + base_asset: String, + quote_asset: String, + start_time: Timestamp, +) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let price: Decimal = deps + .querier + .query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: config.query_contract.to_string(), + msg: to_binary(&QueryMsg::GetArithmeticTwapToNow { + pool_id, + base_asset, + quote_asset, + start_time, + })?, + })) + .unwrap(); + + Ok(price / Decimal::from_atomics(INDEX_SCALE, 0).unwrap()) +} + +pub fn get_denom_authority(deps: Deps, denom: String) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let res: Option = deps + .querier + .query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: config.query_contract.to_string(), + msg: to_binary(&QueryMsg::GetDenomAuthority { denom })?, + })) + .unwrap(); + + if res.is_none() { + return Err(StdError::generic_err("No pool authority found")); + } + + Ok(res.unwrap()) +} + +pub fn get_total_supply(deps: Deps, denom: String) -> StdResult { + let bank = BankQuerier::new(&deps.querier); + + let res = bank.supply_of(denom)?; + + let amount = match res.amount { + Some(amount) => Uint128::from_str(&amount.amount)?, + None => return Err(StdError::generic_err("No supply found")), + }; + + Ok(amount) +} + +pub fn get_balance(deps: Deps, address: String, denom: String) -> StdResult { + let bank = BankQuerier::new(&deps.querier); + + let res = bank.balance(address, denom)?; + + let amount = match res.balance { + Some(amount) => Uint128::from_str(&amount.amount)?, + None => return Err(StdError::generic_err("No balance found")), + }; + + Ok(amount) +} diff --git a/contracts/margined-power/src/query.rs b/contracts/margined-power/src/query.rs new file mode 100644 index 0000000..6f90be3 --- /dev/null +++ b/contracts/margined-power/src/query.rs @@ -0,0 +1,170 @@ +use crate::{ + funding::calculate_normalisation_factor, + helpers::calculate_denormalized_mark, + queries::{get_pool_twap, get_scaled_pool_twap}, + state::{CONFIG, OWNER, STATE}, + vault::{is_vault_safe, VAULTS, VAULTS_COUNTER}, +}; + +use cosmwasm_std::{Addr, Decimal, Deps, Env, Order, StdError, StdResult, Timestamp}; +use cw_storage_plus::Bound; +use margined_common::errors::ContractError; +use margined_protocol::power::{ConfigResponse, StateResponse, UserVaultsResponse, VaultResponse}; + +const DEFAULT_LIMIT: u32 = 10; +const MAX_LIMIT: u32 = 50; + +fn calculate_start_time(env: Env, period: u64) -> Timestamp { + env.block.time.minus_seconds(period) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + Ok(ConfigResponse { + query_contract: config.query_contract, + fee_pool_contract: config.fee_pool_contract, + fee_rate: config.fee_rate, + power_denom: config.power_denom, + base_denom: config.base_denom, + base_pool: config.base_pool, + power_pool: config.power_pool, + funding_period: config.funding_period, + base_decimals: config.base_decimals, + power_decimals: config.power_decimals, + }) +} + +pub fn query_state(deps: Deps) -> StdResult { + let state = STATE.load(deps.storage).unwrap(); + + Ok(StateResponse { + is_open: state.is_open, + is_paused: state.is_paused, + last_pause: state.last_pause, + normalisation_factor: state.normalisation_factor, + last_funding_update: state.last_funding_update, + }) +} + +pub fn query_owner(deps: Deps) -> Result { + if let Some(owner) = OWNER.get(deps)? { + Ok(owner) + } else { + Err(ContractError::NoOwner {}) + } +} + +pub fn get_normalisation_factor(deps: Deps, env: Env) -> StdResult { + let res = calculate_normalisation_factor(deps, env)?; + + Ok(res) +} + +pub fn get_index(deps: Deps, env: Env, period: u64) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let start_time = calculate_start_time(env, period); + + let quote_price = get_scaled_pool_twap( + &deps, + config.base_pool.id, + config.base_denom.clone(), + config.base_pool.quote_denom, + start_time, + ) + .unwrap(); + + let index = quote_price.checked_mul(quote_price).unwrap(); + + Ok(index) +} + +pub fn get_unscaled_index(deps: Deps, env: Env, period: u64) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + let start_time = calculate_start_time(env, period); + + let quote_price = get_pool_twap( + &deps, + config.base_pool.id, + config.base_denom.clone(), + config.base_pool.quote_denom, + start_time, + ) + .unwrap(); + + let index = quote_price.checked_mul(quote_price).unwrap(); + + Ok(index) +} + +pub fn get_denormalised_mark(deps: Deps, env: Env, period: u64) -> StdResult { + let start_time = calculate_start_time(env.clone(), period); + + let normalisation_factor = calculate_normalisation_factor(deps, env)?; + + let result = calculate_denormalized_mark(deps, start_time, normalisation_factor)?; + + Ok(result) +} + +pub fn get_denormalised_mark_for_funding(deps: Deps, env: Env, period: u64) -> StdResult { + let start_time = calculate_start_time(env, period); + + let state = STATE.load(deps.storage).unwrap(); + + let result = calculate_denormalized_mark(deps, start_time, state.normalisation_factor)?; + + Ok(result) +} + +pub fn get_check_vault(deps: Deps, env: Env, vault_id: u64) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + let normalisation_factor = calculate_normalisation_factor(deps, env.clone())?; + + let result = + is_vault_safe(deps, config, vault_id, normalisation_factor, env.block.time).unwrap(); + + Ok(result) +} + +pub fn get_vault(deps: Deps, vault_id: u64) -> StdResult { + let vault = VAULTS.may_load(deps.storage, &vault_id)?; + if let Some(vault) = vault { + Ok(VaultResponse { + operator: vault.operator, + collateral: vault.collateral, + short_amount: vault.short_amount, + }) + } else { + Err(StdError::generic_err("Vault not found")) + } +} + +pub fn get_next_vault_id(deps: Deps) -> StdResult { + let current_index = VAULTS_COUNTER.may_load(deps.storage)?.unwrap_or(0); + + Ok(current_index + 1u64) +} + +pub fn get_user_vaults( + deps: Deps, + owner: String, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(|s| Bound::ExclusiveRaw(s.to_be_bytes().into())); + + let owner_addr = deps.api.addr_validate(&owner)?; + let vaults: Vec = VAULTS + .idx + .owner + .prefix(owner_addr) + .keys(deps.storage, start, None, Order::Ascending) + .take(limit) + .collect::>>()?; + + Ok(UserVaultsResponse { vaults }) +} diff --git a/contracts/margined-power/src/reply.rs b/contracts/margined-power/src/reply.rs new file mode 100644 index 0000000..d461afb --- /dev/null +++ b/contracts/margined-power/src/reply.rs @@ -0,0 +1,134 @@ +use crate::{ + contract::OPEN_SHORT_SWAP_REPLY_ID, + helpers::{create_swap_exact_amount_in_message, parse_response_result_data}, + operations::burn, + queries::{get_balance, get_total_supply}, + state::{Config, CONFIG, TMP_CACHE}, +}; + +use cosmwasm_std::{ + coin, coins, BankMsg, CosmosMsg, DepsMut, Env, MessageInfo, Reply, ReplyOn, Response, SubMsg, + Uint128, +}; +use margined_common::errors::ContractError; +use osmosis_std::types::{ + cosmos::bank::v1beta1::MsgSend, + cosmos::base::v1beta1::Coin, + osmosis::poolmanager::v1beta1::{MsgSwapExactAmountInResponse, MsgSwapExactAmountOutResponse}, +}; +use std::str::FromStr; + +pub fn handle_open_short_reply( + deps: DepsMut, + env: Env, + _msg: Reply, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + let current_total_supply = get_total_supply(deps.as_ref(), config.power_denom.clone())?; + let cache = TMP_CACHE.load(deps.storage)?; + + let prev_total_supply = cache.total_supply.unwrap_or(Uint128::zero()); + + let mint_amount = current_total_supply.checked_sub(prev_total_supply).unwrap(); + + let swap_msg = create_swap_exact_amount_in_message( + env.contract.address.to_string(), + config.power_pool.id, + config.power_denom, + config.base_denom, + mint_amount.to_string(), + ); + + let swap_submsg: SubMsg = SubMsg { + id: OPEN_SHORT_SWAP_REPLY_ID, + msg: swap_msg.into(), + gas_limit: None, + reply_on: ReplyOn::Success, + }; + + Ok(Response::new().add_submessage(swap_submsg)) +} + +pub fn handle_open_short_swap_reply( + deps: DepsMut, + env: Env, + msg: Reply, +) -> Result { + let data = parse_response_result_data(msg.result)?; + + let response: MsgSwapExactAmountInResponse = data.try_into().map_err(ContractError::Std)?; + + let config: Config = CONFIG.load(deps.storage)?; + let cache = TMP_CACHE.load(deps.storage)?; + + let send_msg = MsgSend { + from_address: env.contract.address.to_string(), + to_address: cache.sender.unwrap().to_string(), + amount: vec![Coin { + amount: response.token_out_amount, + denom: config.base_denom, + }], + }; + + TMP_CACHE.remove(deps.storage); + + Ok(Response::new().add_message(send_msg)) +} + +pub fn handle_close_short_reply( + deps: DepsMut, + env: Env, + msg: Reply, +) -> Result { + let data = parse_response_result_data(msg.result)?; + + let response: MsgSwapExactAmountOutResponse = data.try_into().map_err(ContractError::Std)?; + + let token_in_amount = Uint128::from_str(&response.token_in_amount).unwrap(); + + let config: Config = CONFIG.load(deps.storage)?; + let current_balance = get_balance( + deps.as_ref(), + env.contract.address.to_string(), + config.power_denom.clone(), + )?; + + let cache = TMP_CACHE.load(deps.storage)?; + + let sender = cache.sender.unwrap(); + let burn_amount = current_balance.checked_sub(cache.balance.unwrap()).unwrap(); + + let info = MessageInfo { + sender: sender.clone(), + funds: coins(burn_amount.u128(), config.power_denom), + }; + + let vault_id = match cache.vault_id { + Some(id) => id, + None => { + return Err(ContractError::VaultDoesNotExist {}); + } + }; + + TMP_CACHE.remove(deps.storage); + + let mut response = burn(deps, env, info, cache.amount_to_withdraw, vault_id)?; + + if cache.amount_to_swap.unwrap() > token_in_amount { + let refund = cache + .amount_to_swap + .unwrap() + .checked_sub(token_in_amount) + .unwrap(); + + let msg_transfer = CosmosMsg::Bank(BankMsg::Send { + to_address: sender.to_string(), + amount: vec![coin(refund.u128(), config.base_denom)], + }); + + response = response.add_message(msg_transfer); + }; + + Ok(response) +} diff --git a/contracts/margined-power/src/state.rs b/contracts/margined-power/src/state.rs new file mode 100644 index 0000000..79a7923 --- /dev/null +++ b/contracts/margined-power/src/state.rs @@ -0,0 +1,111 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ensure, ensure_ne, Addr, Decimal, StdError, StdResult, Timestamp, Uint128}; +use cw_controllers::Admin; +use cw_storage_plus::Item; +use margined_common::ownership::OwnerProposal; +use margined_protocol::power::{Pool, FUNDING_PERIOD}; + +pub const OWNER: Admin = Admin::new("owner"); +pub const OWNERSHIP_PROPOSAL: Item = Item::new("ownership_proposals"); + +pub const CONFIG: Item = Item::new("config"); +pub const STATE: Item = Item::new("state"); + +pub const TMP_CACHE: Item = Item::new("tmp_cache"); + +pub const LIQUIDATION_BOUNTY: Decimal = Decimal::raw(1_100_000_000_000_000_000u128); // 110% +pub const INDEX_SCALE: u128 = 10_000u128; // 1e4 + +pub const WEEK_IN_SECONDS: u64 = 7 * 24 * 60 * 60; // 24 hours +pub const TWAP_PERIOD: u64 = 420; // 420 seconds (7 minutes) + +#[cw_serde] +pub struct Config { + pub query_contract: Addr, // The contract that wraps the querier interface, useful for testing + pub fee_pool_contract: Addr, // The address where fees are sent + pub fee_rate: Decimal, // The fee rate + pub power_denom: String, // Subdenom of the power perp native token, e.g. atom^2 + pub base_denom: String, // Subdenom of the underlying native token, e.g. atom + pub base_pool: Pool, // Pool of the underlying to quote, e.g. atom:usdc, defined on instantiation + pub power_pool: Pool, // Pool of the underlying to power, e.g. atom:atom^2, defined during contract opening + pub funding_period: u64, // Funding period in seconds + pub base_decimals: u32, // Decimals of the underying token + pub power_decimals: u32, // Decimals of the power perp token +} + +impl Config { + pub fn validate(&self) -> StdResult<()> { + ensure!( + self.base_decimals > 0 && self.base_decimals <= 18, + StdError::generic_err("Invalid base decimals") + ); + + ensure!( + self.power_decimals > 0 && self.power_decimals <= 18, + StdError::generic_err("Invalid power decimals") + ); + + ensure!( + self.fee_rate < Decimal::one(), + StdError::generic_err("Invalid fee rate") + ); + + ensure!( + self.funding_period > 0 && self.funding_period <= 2 * FUNDING_PERIOD, + StdError::generic_err(format!( + "Invalid funding period, must be between 0 and {} seconds", + (2 * FUNDING_PERIOD) + )) + ); + + ensure_ne!( + self.power_denom, + self.base_denom, + StdError::generic_err("Invalid base and power denom must be different") + ); + + ensure_ne!( + self.power_pool.id, + self.base_pool.id, + StdError::generic_err("Invalid base and power pool id must be different") + ); + + Ok(()) + } +} + +#[cw_serde] +pub struct State { + pub is_open: bool, // Whether the contract is open + pub is_paused: bool, // Whether the contract is paused + pub last_pause: Timestamp, // Last time contract was paused + pub normalisation_factor: Decimal, // Normalisation factor + pub last_funding_update: Timestamp, // Last funding update timestamp +} + +impl State { + pub fn is_open_and_unpaused(&self) -> StdResult<()> { + ensure!( + self.is_open, + StdError::generic_err("Cannot perform action as contract is not open") + ); + + ensure!( + !self.is_paused, + StdError::generic_err("Cannot perform action as contract is paused") + ); + + Ok(()) + } +} + +#[cw_serde] +#[derive(Default)] +pub struct TmpCacheValues { + pub total_supply: Option, + pub balance: Option, + pub amount_to_swap: Option, + pub amount_to_withdraw: Option, + pub sender: Option, + pub vault_id: Option, +} diff --git a/contracts/margined-power/src/testing/execute_test.rs b/contracts/margined-power/src/testing/execute_test.rs new file mode 100644 index 0000000..06ba4ab --- /dev/null +++ b/contracts/margined-power/src/testing/execute_test.rs @@ -0,0 +1,145 @@ +use crate::{contract::CONTRACT_NAME, state::State, testing::test_utils::MOCK_FEE_POOL_ADDR}; + +use cosmwasm_std::{coin, Addr}; +use margined_protocol::power::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use margined_testing::{helpers::store_code, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::osmosis::tokenfactory::v1beta1::MsgChangeAdmin, Account, Module, + RunnerError, TokenFactory, Wasm, +}; + +#[test] +fn test_initialise_contract() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let token = TokenFactory::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap() + .data + .address; + + wasm.execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap_err(); + + token + .change_admin( + MsgChangeAdmin { + sender: env.signer.address(), + new_admin: address.clone(), + denom: env.denoms["power"].clone(), + }, + &env.signer, + ) + .unwrap(); + + wasm.execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert!(state.is_open); + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); +} + +#[test] +fn test_initialise_contract_base_does_not_exist() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: "wBTC".to_string(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid denom wBTC not found: instantiate wasm contract failed".to_string() + } + ); +} + +#[test] +fn test_initialise_contract_power_does_not_exist() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: "wBTC".to_string(), + base_denom: env.denoms["power"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid denom wBTC not found: instantiate wasm contract failed".to_string() + } + ); +} diff --git a/contracts/margined-power/src/testing/helpers_test.rs b/contracts/margined-power/src/testing/helpers_test.rs new file mode 100644 index 0000000..d742170 --- /dev/null +++ b/contracts/margined-power/src/testing/helpers_test.rs @@ -0,0 +1,12 @@ +use crate::helpers::decimal_to_fixed; + +use cosmwasm_std::{Decimal, Uint128}; + +#[test] +fn test_decimal_to_fixed() { + let input = Decimal::from_atomics(1_234_567u128, 6).unwrap(); + let expected_result = Uint128::new(1_234_567u128); + + let result = decimal_to_fixed(input, 6); + assert_eq!(result, expected_result); +} diff --git a/contracts/margined-power/src/testing/instantiation_test.rs b/contracts/margined-power/src/testing/instantiation_test.rs new file mode 100644 index 0000000..e085d46 --- /dev/null +++ b/contracts/margined-power/src/testing/instantiation_test.rs @@ -0,0 +1,295 @@ +use crate::{ + contract::CONTRACT_NAME, + state::{Config, State}, + testing::test_utils::{MOCK_FEE_POOL_ADDR, MOCK_QUERY_ADDR}, +}; + +use cosmwasm_std::{coin, Addr, Decimal}; +use margined_protocol::power::{InstantiateMsg, Pool, QueryMsg}; +use margined_testing::{helpers::store_code, power_env::PowerEnv}; +use osmosis_test_tube::{Account, Module, Wasm}; +use std::str::FromStr; + +#[test] +fn test_instantiation() { + let PowerEnv { + app, + signer, + denoms, + base_pool_id, + power_pool_id, + .. // other fields + } = PowerEnv::new(); + + let wasm = Wasm::new(&app); + + let code_id = store_code(&wasm, &signer, CONTRACT_NAME.to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: denoms["power"].clone(), + base_denom: denoms["base"].clone(), + base_pool_id, + base_pool_quote: denoms["quote"].clone(), + power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap() + .data + .address; + + let timestamp = app.get_block_timestamp(); + + let config: Config = wasm.query(&address, &QueryMsg::Config {}).unwrap(); + assert_eq!( + config, + Config { + query_contract: Addr::unchecked(MOCK_QUERY_ADDR.to_string()), + base_denom: denoms["base"].clone(), + power_denom: denoms["power"].clone(), + base_pool: Pool { + id: base_pool_id, + quote_denom: denoms["quote"].clone(), + }, + power_pool: Pool { + id: power_pool_id, + quote_denom: denoms["power"].clone(), + }, + funding_period: 1512000u64, + fee_pool_contract: Addr::unchecked(MOCK_FEE_POOL_ADDR.to_string()), + fee_rate: Decimal::from_str("0.1".to_string().as_str()).unwrap(), + base_decimals: 6u32, + power_decimals: 6u32, + } + ); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: false, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: timestamp, + } + ); +} + +#[test] +fn test_fail_instantiation_unknown_denom() { + let PowerEnv { + app, + signer, + denoms, + base_pool_id, + power_pool_id, + .. // other fields + } = PowerEnv::new(); + + let wasm = Wasm::new(&app); + + let code_id = store_code(&wasm, &signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: "unknown".to_string(), + base_denom: denoms["base"].clone(), + base_pool_id, + base_pool_quote: denoms["quote"].clone(), + power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "execute error: failed to execute message; message index: 0: Invalid denom unknown not found: instantiate wasm contract failed" + ); +} + +#[test] +fn test_fail_instantiation_token_not_in_pool() { + let PowerEnv { + app, + signer, + denoms, + base_pool_id, + power_pool_id, + .. // other fields + } = PowerEnv::new(); + + let wasm = Wasm::new(&app); + + let code_id = store_code(&wasm, &signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: denoms["base"].clone(), + base_denom: denoms["power"].clone(), + base_pool_id, + base_pool_quote: denoms["quote"].clone(), + power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + format!("execute error: failed to execute message; message index: 0: Generic error: Denom \"factory/{}/squosmo\" in pool id: 1: instantiate wasm contract failed", signer.address()) + ); +} + +#[test] +fn test_fail_instantiation_zero_liquidity_in_pool() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let new_power_pool_id = env.create_new_pool( + env.denoms["power"].clone(), + env.denoms["base"].clone(), + &env.owner, + ); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: env.denoms["base"].clone(), + base_denom: env.denoms["power"].clone(), + base_pool_id: new_power_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: No liquidity in pool id: 3: instantiate wasm contract failed" + ); +} + +#[test] +fn test_fail_instantiation_identical_base_and_power_pool_ids() { + let PowerEnv { + app, + signer, + denoms, + base_pool_id, + .. // other fields + } = PowerEnv::new(); + + let wasm = Wasm::new(&app); + + let code_id = store_code(&wasm, &signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: denoms["power"].clone(), + base_denom: denoms["base"].clone(), + base_pool_id, + base_pool_quote: denoms["quote"].clone(), + power_pool_id: base_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: Invalid base and power pool id must be different: instantiate wasm contract failed" + ); +} + +#[test] +fn test_fail_instantiation_identical_base_and_power() { + let PowerEnv { + app, + signer, + denoms, + base_pool_id, + power_pool_id, + .. // other fields + } = PowerEnv::new(); + + let wasm = Wasm::new(&app); + + let code_id = store_code(&wasm, &signer, CONTRACT_NAME.to_string()); + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + power_denom: denoms["base"].clone(), + base_denom: denoms["base"].clone(), + base_pool_id, + base_pool_quote: denoms["power"].clone(), + power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "execute error: failed to execute message; message index: 0: Generic error: Invalid base and power denom must be different: instantiate wasm contract failed" + ); +} diff --git a/contracts/margined-power/src/testing/integration_tests/close_short_test.rs b/contracts/margined-power/src/testing/integration_tests/close_short_test.rs new file mode 100644 index 0000000..8040744 --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/close_short_test.rs @@ -0,0 +1,1191 @@ +use crate::contract::CONTRACT_NAME; + +use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg, StateResponse, VaultResponse}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::{ + osmosis::concentratedliquidity::v1beta1 as CLTypes, + osmosis::poolmanager::v1beta1 as PMTypes, + }, + Account, ConcentratedLiquidity, Module, PoolManager, RunnerError, Wasm, +}; +use std::str::FromStr; + +const VAULT_COLLATERAL: u128 = 910_000u128; +const VAULT_MINT_AMOUNT: u128 = 2_000_000u128; + +#[test] +fn test_close_short() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // get traders initial balances + let trader_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + let pnl: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + pnl = amount_to_swap + .checked_sub(power_exposure.u128()) + .unwrap() + .into(); + + wasm.execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(VAULT_COLLATERAL.into()), + vault_id, + }, + &[coin(amount_to_swap, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + vault, + VaultResponse { + operator: Addr::unchecked(env.traders[1].address()), + collateral: Uint128::zero(), + short_amount: Uint128::from(1u128), // dust is left over + } + ); + + let trader_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + assert_eq!(trader_base_balance_end, trader_base_balance_start - pnl); + assert_eq!(trader_power_balance_end, trader_power_balance_start); +} + +#[test] +fn test_close_short_no_withdrawal() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // get traders initial balances + let trader_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + let pnl: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + pnl = amount_to_swap + .checked_sub(power_exposure.u128()) + .unwrap() + .into(); + + wasm.execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: None, + vault_id, + }, + &[coin(amount_to_swap, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + vault, + VaultResponse { + operator: Addr::unchecked(env.traders[1].address()), + collateral: Uint128::from(VAULT_COLLATERAL), + short_amount: Uint128::from(1u128), // dust is left over + } + ); + + let trader_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + assert_eq!( + trader_base_balance_end, + trader_base_balance_start - pnl - Uint128::from(VAULT_COLLATERAL) + ); + assert_eq!(trader_power_balance_end, trader_power_balance_start); +} + +#[test] +fn test_close_short_additional_funds_sent() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // get traders initial balances + let trader_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + let pnl: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + pnl = amount_to_swap + .checked_sub(power_exposure.u128()) + .unwrap() + .into(); + + wasm.execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(VAULT_COLLATERAL.into()), + vault_id, + }, + &[coin( + amount_to_swap + 1_000_000u128, + env.denoms["base"].clone(), + )], + &env.traders[1], + ) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + vault, + VaultResponse { + operator: Addr::unchecked(env.traders[1].address()), + collateral: Uint128::zero(), + short_amount: Uint128::from(1u128), // dust is left over + } + ); + + let trader_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + assert_eq!(trader_base_balance_end, trader_base_balance_start - pnl); + assert_eq!(trader_power_balance_end, trader_power_balance_start); +} + +#[test] +fn test_fail_close_short_insufficient_funds() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(VAULT_COLLATERAL.into()), + vault_id, + }, + &[coin(amount_to_swap - 1u128, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: format!("failed to execute message; message index: 0: dispatch: submessages: token amount calculated ({}) is greater than max amount ({})", amount_to_swap, power_exposure) + }); + } +} + +#[test] +fn test_fail_close_short_burn_greater_than_vault() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(50), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(150), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT) + Uint128::one(), + amount_to_withdraw: None, + vault_id, + }, + &[coin(amount_to_swap, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot burn more funds or collateral than in vault: execute wasm contract failed".to_string() + }); + } +} + +#[test] +fn test_fail_close_short_withdraw_greater_than_vault() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(50), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(150), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ); + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + // close short + { + env.app.increase_time(1u64); + + let res = pool_manager + .query_single_pool_swap_exact_amount_out( + &PMTypes::EstimateSinglePoolSwapExactAmountOutRequest { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + token_out: format!("{}{}", vault.short_amount, env.denoms["power"].clone()), + }, + ) + .unwrap(); + + let amount_to_swap = u128::from_str(&res.token_in_amount).unwrap(); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(Uint128::from(VAULT_COLLATERAL) + Uint128::one()), + vault_id, + }, + &[coin(amount_to_swap, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot burn more funds or collateral than in vault: execute wasm contract failed".to_string() + }); + } +} + +#[test] +fn test_fail_close_short_incorrect_sender() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // close short + { + env.app.increase_time(1u64); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(VAULT_COLLATERAL.into()), + vault_id, + }, + &[coin(power_exposure.u128(), env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + }); + } +} + +#[test] +fn test_fail_close_short_incorrect_funds() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + let power_exposure: Uint128; + + // open short + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // close short + { + env.app.increase_time(1u64); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::CloseShort { + amount_to_burn: Uint128::from(VAULT_MINT_AMOUNT), + amount_to_withdraw: Some(VAULT_COLLATERAL.into()), + vault_id, + }, + &[coin(power_exposure.u128(), env.denoms["gas"].clone())], + &env.traders[1], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid funds: execute wasm contract failed".to_string() + }); + } +} diff --git a/contracts/margined-power/src/testing/integration_tests/end_to_end_test.rs b/contracts/margined-power/src/testing/integration_tests/end_to_end_test.rs new file mode 100644 index 0000000..83c3996 --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/end_to_end_test.rs @@ -0,0 +1,599 @@ +use crate::{contract::CONTRACT_NAME, vault::Vault}; + +use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use margined_protocol::power::{ + ConfigResponse, ExecuteMsg, Pool, QueryMsg, StateResponse, VaultResponse, +}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::{ + cosmos::bank::v1beta1::MsgSend, + cosmos::base::v1beta1::Coin, + osmosis::concentratedliquidity::v1beta1 as CLTypes, + osmosis::poolmanager::v1beta1::{ + MsgSwapExactAmountIn, MsgSwapExactAmountOut, SpotPriceRequest, SwapAmountInRoute, + SwapAmountOutRoute, TotalPoolLiquidityRequest, + }, + }, + Account, Bank, ConcentratedLiquidity, Module, PoolManager, Wasm, +}; +use std::str::FromStr; + +const COLLATERAL_AMOUNT: u128 = 100_000_000u128; // 100.0 +const SHORT_AMOUNT: u128 = 166_666_667u128; // 166.666667 + +#[test] +fn test_end_to_end_flow() { + let env: PowerEnv = PowerEnv::new(); + + // move two days forward + env.app.increase_time(172800u64); + + let bank = Bank::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + let wasm = Wasm::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, query_address) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let vault_id_short: u64; + + // get traders initial balances + let trader_long_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_long_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + let trader_short_base_balance_start: Uint128 = + env.get_balance(env.traders[0].address(), env.denoms["base"].clone()); + let trader_short_power_balance_start: Uint128 = + env.get_balance(env.traders[0].address(), env.denoms["power"].clone()); + + // update the config + { + wasm.execute( + &perp_address, + &ExecuteMsg::UpdateConfig { + fee_rate: Some("0.01".to_string()), + fee_pool: None, + }, + &[], + &env.signer, + ) + .unwrap(); + } + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + concentrated_liquidity + .create_position( + CLTypes::MsgCreatePosition { + pool_id: env.power_pool_id, + sender: env.owner.address(), + lower_tick: i64::from_str(&lower_tick).unwrap(), + upper_tick: i64::from_str(&upper_tick).unwrap(), + tokens_provided: vec![ + Coin { + denom: env.denoms["power"].clone(), + amount: "1_000_000_000".to_string(), + }, + Coin { + denom: env.denoms["base"].clone(), + amount: "3_000_000_000".to_string(), + }, + ], + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + }, + &env.owner, + ) + .unwrap(); + + let res = pool_manager + .query_spot_price(&SpotPriceRequest { + pool_id: env.power_pool_id, + base_asset_denom: env.denoms["base"].clone(), + quote_asset_denom: env.denoms["power"].clone(), + }) + .unwrap(); + println!("res: {:#?}", res); + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + // user goes short - fails as zero collateral, vault not safe + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(SHORT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + } + + // user goes short - succeeds + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(SHORT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(COLLATERAL_AMOUNT, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id_short = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let res: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert!(res); + } + + let vault_initial: VaultResponse = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + + // user withdraws all the collateral - fails, as vault is unsafe + { + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(COLLATERAL_AMOUNT), + vault_id: vault_id_short, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + + let res: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert!(res); + } + + let norm: Decimal = wasm + .query(&perp_address, &QueryMsg::GetNormalisationFactor {}) + .unwrap(); + println!("norm: {:#?}", norm); + + // user withdraws some collateral - success as vault remains safe + { + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(17_000_000u128), + vault_id: vault_id_short, + }, + &[], + &env.traders[0], + ) + .unwrap(); + + let res: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert!(res); + } + + // user withdraws additional collateral - fails as vault is unsafe + { + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(3_000_000u128), + vault_id: vault_id_short, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + } + + // user withdraws additional collateral - succeeds as it is the exact limit + { + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(2_000_000u128), + vault_id: vault_id_short, + }, + &[], + &env.traders[0], + ) + .unwrap(); + } + + // user deposits some collateral + { + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::Deposit { + vault_id: vault_id_short, + }, + &[coin(19_000_000u128, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + let res: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert!(res); + } + + let vault_after: VaultResponse = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert_eq!(vault_initial, vault_after); + + // user sells the power token + { + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.traders[0].address(), + routes: vec![SwapAmountInRoute { + pool_id: env.power_pool_id, + token_out_denom: env.denoms["base"].clone(), + }], + token_in: Some(Coin { + amount: SHORT_AMOUNT.to_string(), + denom: env.denoms["power"].clone(), + }), + token_out_min_amount: SHORT_AMOUNT.checked_div(4u128).unwrap().to_string(), + }, + &env.traders[0], + ) + .unwrap(); + + let balance_after_liquidity_provision = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + assert!(balance_after_liquidity_provision.is_zero()); + } + + // trader buys the power token + { + let balance_before = env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + assert!(balance_before.is_zero()); + + let liquidity_to_buy = SHORT_AMOUNT.checked_div(3u128).unwrap(); + + pool_manager + .swap_exact_amount_out( + MsgSwapExactAmountOut { + sender: env.traders[1].address(), + routes: vec![SwapAmountOutRoute { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + }], + token_out: Some(Coin { + amount: liquidity_to_buy.to_string(), + denom: env.denoms["power"].clone(), + }), + token_in_max_amount: liquidity_to_buy.checked_div(3u128).unwrap().to_string(), + }, + &env.traders[1], + ) + .unwrap(); + } + + // make vault unsafe by pushing base price higher 2x + { + let res = pool_manager + .query_total_liquidity(&TotalPoolLiquidityRequest { + pool_id: env.base_pool_id, + }) + .unwrap(); + + let liquidity_to_sell = Uint128::from_str( + res.liquidity + .iter() + .find(|l| l.denom == env.denoms["quote"]) + .unwrap() + .amount + .as_str(), + ) + .unwrap() + .checked_div(24u128.into()) + .unwrap() + .checked_mul(10u128.into()) + .unwrap(); + + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.signer.address(), + routes: vec![SwapAmountInRoute { + pool_id: env.base_pool_id, + token_out_denom: env.denoms["base"].clone(), + }], + token_in: Some(Coin { + amount: liquidity_to_sell.to_string(), + denom: env.denoms["quote"].clone(), + }), + token_out_min_amount: "1".to_string(), + }, + &env.signer, + ) + .unwrap(); + + let res = pool_manager + .query_spot_price(&SpotPriceRequest { + pool_id: env.base_pool_id, + base_asset_denom: env.denoms["base"].clone(), + quote_asset_denom: env.denoms["quote"].clone(), + }) + .unwrap(); + println!("res base: {:#?}", res); + + // increase time to affect the TWAP + env.app.increase_time(3600u64); + + let vault_is_safe: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + assert!(!vault_is_safe); + } + + // liquidate the short vault + { + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + + let amount_to_send = vault_before.short_amount.checked_div(2u128.into()).unwrap(); + bank.send( + MsgSend { + from_address: env.owner.address(), + to_address: env.traders[2].address(), + amount: vec![Coin { + denom: env.denoms["power"].clone(), + amount: amount_to_send.to_string(), + }], + }, + &env.owner, + ) + .unwrap(); + + let balance = env.get_balance(env.traders[2].address(), env.denoms["power"].clone()); + println!("balance: {:#?}", balance); + assert_eq!(balance, amount_to_send); + + wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_short, + max_debt_amount: vault_before.short_amount, + }, + &[], + &env.traders[2], + ) + .unwrap(); + + let balance = env.get_balance(env.traders[2].address(), env.denoms["power"].clone()); + assert!(balance.is_zero()); + } + + // short trader closes vault + { + let vault: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_short, + }, + ) + .unwrap(); + + pool_manager + .swap_exact_amount_out( + MsgSwapExactAmountOut { + sender: env.traders[0].address(), + routes: vec![SwapAmountOutRoute { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + }], + token_out: Some(Coin { + amount: vault.short_amount.to_string(), + denom: env.denoms["power"].clone(), + }), + token_in_max_amount: "10000000000".to_string(), + }, + &env.traders[0], + ) + .unwrap(); + + let balance = env.get_balance(env.traders[0].address(), env.denoms["power"].clone()); + assert_eq!(balance, vault.short_amount); + + wasm.execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + vault_id: vault_id_short, + amount_to_withdraw: None, + }, + &[coin(vault.short_amount.u128(), env.denoms["power"].clone())], + &env.traders[0], + ) + .unwrap(); + + let balance = env.get_balance(env.traders[0].address(), env.denoms["power"].clone()); + assert!(balance.is_zero()); + } + + // long trader sells all remaining power tokens + { + let balance = env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.traders[1].address(), + routes: vec![SwapAmountInRoute { + pool_id: env.power_pool_id, + token_out_denom: env.denoms["base"].clone(), + }], + token_in: Some(Coin { + amount: balance.to_string(), + denom: env.denoms["power"].clone(), + }), + token_out_min_amount: "1".to_string(), + }, + &env.traders[1], + ) + .unwrap(); + } + + // check final balances + { + let trader_long_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_long_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + let trader_short_base_balance_end: Uint128 = + env.get_balance(env.traders[0].address(), env.denoms["base"].clone()); + let trader_short_power_balance_end: Uint128 = + env.get_balance(env.traders[0].address(), env.denoms["power"].clone()); + + // trader who went long should have gained funds + assert!(trader_long_base_balance_start < trader_long_base_balance_end); + assert!(trader_long_power_balance_start.is_zero()); + assert!(trader_long_power_balance_end.is_zero()); + + // trader who went short should have lost funds + assert!(trader_short_base_balance_end < trader_short_base_balance_start); + assert!(trader_short_power_balance_start.is_zero()); + assert!(trader_short_power_balance_end.is_zero()); + } + + // Validate all queries work as anticipated + { + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + assert_eq!( + config, + ConfigResponse { + fee_rate: Decimal::percent(1u64), + fee_pool_contract: Addr::unchecked(env.fee_pool.address()), + query_contract: Addr::unchecked(query_address), + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool: Pool { + id: env.base_pool_id, + quote_denom: env.denoms["quote"].clone() + }, + power_pool: Pool { + id: env.power_pool_id, + quote_denom: env.denoms["power"].clone() + }, + funding_period: 1512000u64, + base_decimals: 6u32, + power_decimals: 6u32, + } + ); + } +} diff --git a/contracts/margined-power/src/testing/integration_tests/liquidation_test.rs b/contracts/margined-power/src/testing/integration_tests/liquidation_test.rs new file mode 100644 index 0000000..c79c7aa --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/liquidation_test.rs @@ -0,0 +1,607 @@ +use crate::{contract::CONTRACT_NAME, vault::Vault}; + +use cosmwasm_std::{coin, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg}; +use margined_testing::{ + helpers::{is_similar, parse_event_attribute}, + power_env::PowerEnv, +}; +use osmosis_test_tube::{ + osmosis_std::{ + shim::Timestamp, + types::{ + cosmos::base::v1beta1::Coin, + osmosis::poolmanager::v1beta1::{ + MsgSwapExactAmountIn, MsgSwapExactAmountOut, SpotPriceRequest, SwapAmountInRoute, + SwapAmountOutRoute, TotalPoolLiquidityRequest, + }, + osmosis::twap::v1beta1 as TwapTypes, + }, + }, + Account, Module, PoolManager, Twap, Wasm, +}; +use std::str::FromStr; + +const VAULT_0_COLLATERAL: u128 = 45_100_000u128; +const VAULT_0_MINT_AMOUNT: u128 = 100_000_000u128; + +const VAULT_1_COLLATERAL: u128 = 910_000u128; +const VAULT_1_MINT_AMOUNT: u128 = 2_000_000u128; + +const VAULT_2_COLLATERAL: u128 = 700_000u128; +const VAULT_2_MINT_AMOUNT: u128 = 1_000_000u128; + +#[test] +fn test_liquidate_normal_vault_when_price_is_2x() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let twap = Twap::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let vault_id_0: u64; + let vault_id_1: u64; + let vault_id_2: u64; + + // open vault id 0 + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_0_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_0_COLLATERAL, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id_0 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // open vault id 1 + { + env.app.increase_time(1u64); + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_1_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_1_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id_1 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // open vault id 2 + { + env.app.increase_time(1u64); + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_2_MINT_AMOUNT), + vault_id: None, + rebase: true, + }, + &[coin(VAULT_2_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id_2 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // validate spot prices + { + let timestamp = + cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + let start_time = Timestamp { + seconds: timestamp.seconds() as i64 - 5i64, + nanos: timestamp.subsec_nanos() as i32, + }; + + let new_base_price = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.base_pool_id, + base_asset: env.denoms["base"].clone(), + quote_asset: env.denoms["quote"].clone(), + start_time: Some(start_time.clone()), + }) + .unwrap(); + + let new_power_price = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.power_pool_id, + base_asset: env.denoms["power"].clone(), + quote_asset: env.denoms["base"].clone(), + start_time: Some(start_time), + }) + .unwrap(); + + assert_eq!(new_base_price.arithmetic_twap, "3000000000000000000000"); + assert_eq!(new_power_price.arithmetic_twap, "300000000000000000"); + } + + // push power price higher 2x + { + pool_manager + .query_spot_price(&SpotPriceRequest { + pool_id: env.power_pool_id, + base_asset_denom: env.denoms["base"].clone(), + quote_asset_denom: env.denoms["power"].clone(), + }) + .unwrap(); + + let res = pool_manager + .query_total_liquidity(&TotalPoolLiquidityRequest { + pool_id: env.power_pool_id, + }) + .unwrap(); + + let liquidity_to_buy = Uint128::from_str( + res.liquidity + .iter() + .find(|l| l.denom == env.denoms["power"]) + .unwrap() + .amount + .as_str(), + ) + .unwrap() + .checked_div(2u128.into()) + .unwrap(); + + pool_manager + .swap_exact_amount_out( + MsgSwapExactAmountOut { + sender: env.signer.address(), + routes: vec![SwapAmountOutRoute { + pool_id: env.power_pool_id, + token_in_denom: env.denoms["base"].clone(), + }], + token_out: Some(Coin { + amount: liquidity_to_buy.to_string(), + denom: env.denoms["power"].clone(), + }), + token_in_max_amount: "10000000000".to_string(), + }, + &env.signer, + ) + .unwrap(); + } + + // push base price higher 2x + { + let res = pool_manager + .query_total_liquidity(&TotalPoolLiquidityRequest { + pool_id: env.base_pool_id, + }) + .unwrap(); + + let liquidity_to_sell = Uint128::from_str( + res.liquidity + .iter() + .find(|l| l.denom == env.denoms["quote"]) + .unwrap() + .amount + .as_str(), + ) + .unwrap() + .checked_div(24u128.into()) + .unwrap() + .checked_mul(10u128.into()) + .unwrap(); + + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.signer.address(), + routes: vec![SwapAmountInRoute { + pool_id: env.base_pool_id, + token_out_denom: env.denoms["base"].clone(), + }], + token_in: Some(Coin { + amount: liquidity_to_sell.to_string(), + denom: env.denoms["quote"].clone(), + }), + token_out_min_amount: "1".to_string(), + }, + &env.signer, + ) + .unwrap(); + } + + // increase block time to ensure TWAP is updated + { + env.app.increase_time(3600u64); + + let now = cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + let now = Timestamp { + seconds: now.seconds() as i64 - 3_600i64, + nanos: now.subsec_nanos() as i32, + }; + + let new_base_price = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.base_pool_id, + base_asset: env.denoms["base"].clone(), + quote_asset: env.denoms["quote"].clone(), + start_time: Some(now.clone()), + }) + .unwrap(); + + let new_power_price = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.power_pool_id, + base_asset: env.denoms["power"].clone(), + quote_asset: env.denoms["base"].clone(), + start_time: Some(now), + }) + .unwrap(); + + assert_eq!(new_base_price.arithmetic_twap, "6003124998050000000000"); + assert_eq!(new_power_price.arithmetic_twap, "600147863638629204"); + } + + // prepare liqudiator to liquidate vault 0 and vault 1 + { + let timestamp = + cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + let start_time = Timestamp { + seconds: timestamp.seconds() as i64 - 600i64, + nanos: timestamp.subsec_nanos() as i32, + }; + + let new_base_price = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.base_pool_id, + base_asset: env.denoms["base"].clone(), + quote_asset: env.denoms["quote"].clone(), + start_time: Some(start_time), + }) + .unwrap(); + + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_0, + }, + ) + .unwrap(); + + let mint_amount = vault_before.short_amount.checked_mul(2u128.into()).unwrap(); + let collateral_required = mint_amount + .checked_mul(new_base_price.arithmetic_twap.parse::().unwrap()) + .unwrap() + .checked_div(1_000_000_000_000_000_000u128.into()) + .unwrap() + .checked_mul(2u128.into()) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: mint_amount, + vault_id: None, + rebase: false, + }, + &[coin(collateral_required.into(), env.denoms["base"].clone())], + &env.liquidator, + ) + .unwrap(); + } + + // liquidate vault 0 + { + let timestamp = + cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + let start_time = Timestamp { + seconds: timestamp.seconds() as i64 - 600i64, + nanos: timestamp.subsec_nanos() as i32, + }; + + let twap_response = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.power_pool_id, + base_asset: env.denoms["power"].clone(), + quote_asset: env.denoms["base"].clone(), + start_time: Some(start_time), + }) + .unwrap(); + let new_power_price = Uint128::from_str(twap_response.arithmetic_twap.as_str()) + .unwrap() + .checked_div(1_000_000_000_000u128.into()) + .unwrap(); + + println!("new_power_price: {}", new_power_price); + + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_0, + }, + ) + .unwrap(); + + // state before liquidation + let liquidator_power_before = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_before = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + // check vault is unsafe + let is_safe: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_0, + }, + ) + .unwrap(); + assert!(!is_safe); + + let power_to_liquidate = vault_before.short_amount.checked_div(2u128.into()).unwrap(); + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_0, + max_debt_amount: power_to_liquidate, + }, + &[], + &env.liquidator, + ) + .unwrap(); + + let collateral_to_receive = new_power_price + .checked_mul(power_to_liquidate) + .unwrap() + .checked_div(1_000_000u128.into()) + .unwrap() + .checked_mul(11u128.into()) + .unwrap() + .checked_div(10u128.into()) + .unwrap(); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_0, + }, + ) + .unwrap(); + + // state after liquidation + let liquidator_power_after = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_after = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + assert!(is_similar( + collateral_to_receive, + liquidator_base_after + .checked_sub(liquidator_base_before) + .unwrap(), + 100u128.into() + )); + assert_eq!( + vault_before + .short_amount + .checked_sub(vault_after.short_amount) + .unwrap(), + liquidator_power_before + .checked_sub(liquidator_power_after) + .unwrap() + ); + } + + // liquidate vault 1, get full collateral amount from the vault + { + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + + // state before liquidation + let liquidator_power_before = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_before = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + // check vault is unsafe + let is_safe: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + assert!(!is_safe); + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_1, + max_debt_amount: vault_before.short_amount, + }, + &[], + &env.liquidator, + ) + .unwrap(); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + + // state after liquidation + let liquidator_power_after = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_after = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + assert_eq!( + vault_before.collateral, + liquidator_base_after + .checked_sub(liquidator_base_before) + .unwrap(), + ); + assert_eq!( + vault_before + .short_amount + .checked_sub(vault_after.short_amount) + .unwrap(), + liquidator_power_before + .checked_sub(liquidator_power_after) + .unwrap() + ); + } + + // liquidate vault 2, get expected payout + { + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + + // state before liquidation + let liquidator_power_before = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_before = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + // check vault is unsafe + let is_safe: bool = wasm + .query( + &perp_address, + &QueryMsg::CheckVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + assert!(!is_safe); + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_2, + max_debt_amount: vault_before.short_amount, + }, + &[], + &env.liquidator, + ) + .unwrap(); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + + // state after liquidation + let liquidator_power_after = + env.get_balance(env.liquidator.address(), env.denoms["power"].clone()); + let liquidator_base_after = + env.get_balance(env.liquidator.address(), env.denoms["base"].clone()); + + let timestamp = + cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + let start_time = Timestamp { + seconds: timestamp.seconds() as i64 - 600i64, + nanos: timestamp.subsec_nanos() as i32, + }; + + let twap_response = twap + .query_arithmetic_twap_to_now(&TwapTypes::ArithmeticTwapToNowRequest { + pool_id: env.power_pool_id, + base_asset: env.denoms["power"].clone(), + quote_asset: env.denoms["base"].clone(), + start_time: Some(start_time), + }) + .unwrap(); + let new_power_price = Uint128::from_str(twap_response.arithmetic_twap.as_str()) + .unwrap() + .checked_div(1_000_000_000_000u128.into()) + .unwrap(); + + let collateral_to_receive = new_power_price + .checked_mul(vault_before.short_amount) + .unwrap() + .checked_div(1_000_000u128.into()) + .unwrap() + .checked_mul(11u128.into()) + .unwrap() + .checked_div(10u128.into()) + .unwrap(); + + assert!(is_similar( + collateral_to_receive, + liquidator_base_after + .checked_sub(liquidator_base_before) + .unwrap(), + 10u128.into() + )); + assert_eq!(vault_after.short_amount, Uint128::zero()); + assert_eq!( + vault_before + .short_amount + .checked_sub(vault_after.short_amount) + .unwrap(), + liquidator_power_before + .checked_sub(liquidator_power_after) + .unwrap() + ); + assert_eq!(vault_after.collateral, Uint128::from(39_838u128)); + } +} diff --git a/contracts/margined-power/src/testing/integration_tests/mod.rs b/contracts/margined-power/src/testing/integration_tests/mod.rs new file mode 100644 index 0000000..4952373 --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/mod.rs @@ -0,0 +1,5 @@ +mod close_short_test; +mod end_to_end_test; +mod liquidation_test; +mod open_short_test; +mod oracle_attack_test; diff --git a/contracts/margined-power/src/testing/integration_tests/open_short_test.rs b/contracts/margined-power/src/testing/integration_tests/open_short_test.rs new file mode 100644 index 0000000..066ca6b --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/open_short_test.rs @@ -0,0 +1,658 @@ +use crate::contract::CONTRACT_NAME; + +use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg, StateResponse, VaultResponse}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::{ + cosmos::bank::v1beta1::MsgSend, cosmos::base::v1beta1::Coin, + osmosis::concentratedliquidity::v1beta1 as CLTypes, + }, + Account, Bank, ConcentratedLiquidity, Module, RunnerError, Wasm, +}; +use std::str::FromStr; + +const VAULT_COLLATERAL: u128 = 910_000u128; +const VAULT_MINT_AMOUNT: u128 = 2_000_000u128; + +#[test] +fn test_open_short() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // get traders initial balances + let trader_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ); + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + // open vault id 1 + { + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + let power_exposure = Uint128::from_str( + &parse_event_attribute( + open_short_response.events.clone(), + "token_swapped", + "tokens_out", + ) + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + let vault_id = u64::from_str(&parse_event_attribute( + open_short_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + vault, + VaultResponse { + operator: Addr::unchecked(env.traders[1].address()), + collateral: Uint128::from(VAULT_COLLATERAL), + short_amount: Uint128::from(VAULT_MINT_AMOUNT), + } + ); + + let trader_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + assert_eq!( + trader_base_balance_end, + trader_base_balance_start - Uint128::from(VAULT_COLLATERAL) + power_exposure + ); + assert!(trader_power_balance_end.is_zero()); + assert!(trader_power_balance_start.is_zero()); + } +} + +#[test] +fn test_open_short_existing_vault() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // get traders initial balances + let trader_base_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_start: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + // open vault id 1 + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // perform open short to the original vault + { + env.app.increase_time(1u64); + + let open_short_response = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: Some(vault_id), + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + let power_exposure = Uint128::from_str( + &parse_event_attribute(open_short_response.events, "token_swapped", "tokens_out") + .replace(&env.denoms["base"], ""), + ) + .unwrap(); + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + vault, + VaultResponse { + operator: Addr::unchecked(env.traders[1].address()), + collateral: Uint128::from(VAULT_COLLATERAL * 2), + short_amount: Uint128::from(VAULT_MINT_AMOUNT * 2), + } + ); + + let trader_base_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["base"].clone()); + let trader_power_balance_end: Uint128 = + env.get_balance(env.traders[1].address(), env.denoms["power"].clone()); + + assert_eq!( + trader_base_balance_end, + trader_base_balance_start - Uint128::from(VAULT_COLLATERAL * 2) + power_exposure + ); + assert_eq!(trader_power_balance_end, Uint128::from(VAULT_MINT_AMOUNT)); + assert!(trader_power_balance_start.is_zero()); + } +} + +#[test] +fn test_fail_open_short_existing_vault_incorrect_user() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + let vault_id: u64; + // open vault id 1 + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // perform open short to the original vault + { + env.app.increase_time(1u64); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: Some(vault_id), + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + }); + } +} + +#[test] +fn test_fail_open_short_insufficient_funds() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let trader = env + .app + .init_account(&[coin(1_000_000_000_000_000_000, "uosmo")]) + .unwrap(); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: trader.address(), + amount: vec![Coin { + denom: env.denoms["base"].to_string(), + amount: VAULT_COLLATERAL.to_string(), + }], + }, + &env.signer, + ) + .unwrap(); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + // open vault id 1 + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL + 1u128, env.denoms["base"].clone())], + &trader, + ) + .unwrap_err(); + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: 910000ubase is smaller than 910001ubase: insufficient funds".to_string() + }); + } +} + +#[test] +fn test_fail_open_short_incorrect_funds() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // owner closes position and makes a much liquid one + { + let res = concentrated_liquidity + .query_user_positions(&CLTypes::UserPositionsRequest { + pool_id: env.power_pool_id, + address: env.owner.address(), + pagination: None, + }) + .unwrap(); + + let position = res.positions[0].clone().position.unwrap(); + + concentrated_liquidity + .withdraw_position( + CLTypes::MsgWithdrawPosition { + position_id: position.position_id, + sender: env.owner.address(), + liquidity_amount: position.liquidity, + }, + &env.owner, + ) + .unwrap(); + + env.app.increase_time(10u64); + + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let target_price_power = env.calculate_target_power_price(state.normalisation_factor); + + let target_price = Decimal::one().checked_div(target_price_power).unwrap(); + + let lower_tick = env.price_to_tick(target_price * Decimal::percent(90), 100u128.into()); + let upper_tick = env.price_to_tick(target_price * Decimal::percent(110), 100u128.into()); + + // lower tick: 3.1 = 1/3.1 = 0.32258 + // lower tick: 3.4 = 1/3.4 = 0.29412 = + env.create_position( + lower_tick, + upper_tick, + "3_000_000_000".to_string(), + "1_000_000_000".to_string(), + ) + } + + // we increase time else the functions get unhappy + env.app.increase_time(200000u64); + + // open vault id 1 + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL + 1u128, env.denoms["gas"].clone())], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid funds: execute wasm contract failed".to_string() + }); + } +} + +#[test] +fn test_fail_open_short_insufficient_liquidity() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let trader = env + .app + .init_account(&[coin(1_000_000_000_000_000_000, "uosmo")]) + .unwrap(); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: trader.address(), + amount: vec![Coin { + denom: env.denoms["base"].to_string(), + amount: VAULT_COLLATERAL.to_string(), + }], + }, + &env.signer, + ) + .unwrap(); + + // apply funding + { + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + } + + // open vault id 1 + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::OpenShort { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &trader, + ) + .unwrap_err(); + assert_eq!(err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: dispatch: submessages: reply: dispatch: submessages: ran out of ticks for pool (2) during swap".to_string() + }); + } +} diff --git a/contracts/margined-power/src/testing/integration_tests/oracle_attack_test.rs b/contracts/margined-power/src/testing/integration_tests/oracle_attack_test.rs new file mode 100644 index 0000000..1c61e85 --- /dev/null +++ b/contracts/margined-power/src/testing/integration_tests/oracle_attack_test.rs @@ -0,0 +1,352 @@ +use crate::contract::CONTRACT_NAME; + +use cosmwasm_std::{coin, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::{ + cosmos::base::v1beta1::Coin, + osmosis::poolmanager::v1beta1::{ + MsgSwapExactAmountIn, SwapAmountInRoute, TotalPoolLiquidityRequest, + }, + }, + Account, Module, PoolManager, Wasm, +}; +use std::str::FromStr; + +const MIN_COLLATERAL: u128 = 45_000_000u128; +const VAULT_COLLATERAL: u128 = 60_000_000u128; +const VAULT_MINT_AMOUNT: u128 = 100_000_000u128; + +#[test] +fn test_scenario_base_price_spikes_100_percent() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let vault_id: u64; + + // prepare the vault with collateral ratio 2x + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // push base price higher 2x + { + let res = pool_manager + .query_total_liquidity(&TotalPoolLiquidityRequest { + pool_id: env.base_pool_id, + }) + .unwrap(); + + let liquidity_to_sell = Uint128::from_str( + res.liquidity + .iter() + .find(|l| l.denom == env.denoms["quote"]) + .unwrap() + .amount + .as_str(), + ) + .unwrap() + .checked_div(24u128.into()) + .unwrap() + .checked_mul(10u128.into()) + .unwrap(); + + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.signer.address(), + routes: vec![SwapAmountInRoute { + pool_id: env.base_pool_id, + token_out_denom: env.denoms["base"].clone(), + }], + token_in: Some(Coin { + amount: liquidity_to_sell.to_string(), + denom: env.denoms["quote"].clone(), + }), + token_out_min_amount: "1".to_string(), + }, + &env.signer, + ) + .unwrap(); + + env.app.increase_time(1u64); + } + + // 1 second post base price spike + { + // index price is updated if requesting with period 1 + { + let new_index_price: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 1 }) + .unwrap(); + + assert_eq!( + new_index_price, + Decimal::from_str("36037509.7422128125038025").unwrap() + ); + } + + // vaults remains safes because of TWAP + { + let is_safe: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(is_safe); + } + + // can still mint with the same amount of collateral (becase of TWAP) + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + } + + // can still mint with the same amount of collateral (becase of TWAP) + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(MIN_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + } + } + + // 3 minutes post base price spike + { + // increase time + env.app.increase_time(3 * 60); + + // index price is updated if requesting with period 1 + { + let new_index_price: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 180 }) + .unwrap(); + + assert_eq!( + new_index_price, + Decimal::from_str("36037509.7422128125038025").unwrap() + ); + } + + // vaults becomes unsafe + { + let is_safe: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(!is_safe); + } + + // should revert when trying to mint with same amount of collateral as before + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + } + } +} + +#[test] +fn test_scenario_base_price_crashes_50_percent() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let pool_manager = PoolManager::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), false); + + let vault_id: u64; + + // prepare the vault with collateral ratio 2x + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(VAULT_MINT_AMOUNT), + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // drop base price lower 0.5x + { + let res = pool_manager + .query_total_liquidity(&TotalPoolLiquidityRequest { + pool_id: env.base_pool_id, + }) + .unwrap(); + + let liquidity_to_buy = Uint128::from_str( + res.liquidity + .iter() + .find(|l| l.denom == env.denoms["base"]) + .unwrap() + .amount + .as_str(), + ) + .unwrap() + .checked_div(24u128.into()) + .unwrap() + .checked_mul(10u128.into()) + .unwrap(); + + pool_manager + .swap_exact_amount_in( + MsgSwapExactAmountIn { + sender: env.signer.address(), + routes: vec![SwapAmountInRoute { + pool_id: env.base_pool_id, + token_out_denom: env.denoms["quote"].clone(), + }], + token_in: Some(Coin { + amount: liquidity_to_buy.to_string(), + denom: env.denoms["base"].clone(), + }), + token_out_min_amount: "1".to_string(), + }, + &env.signer, + ) + .unwrap(); + + env.app.increase_time(1u64); + } + + // 1 second post base price crash + { + // index price is updated if requesting with period 1 + { + let new_index_price: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 1 }) + .unwrap(); + + assert_eq!( + new_index_price, + Decimal::from_str("2247658.1219443176555625").unwrap() + ); + } + + // vaults remains safes because of TWAP + { + let is_safe: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(is_safe); + } + + // can still mint with the same amount of collateral (becase of TWAP) + { + let attack_mint_amount = Uint128::from(VAULT_MINT_AMOUNT) + .checked_mul(101u128.into()) + .unwrap() + .checked_mul(100u128.into()) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: attack_mint_amount, + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + } + } + + // 1 minute post base price crash + { + // increase time + env.app.increase_time(60); + + // index price is updated if requesting with period 60 + { + let new_index_price: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 60 }) + .unwrap(); + + assert_eq!( + new_index_price, + Decimal::from_str("2247658.1219443176555625").unwrap() + ); + } + + // will be able to mint more power + { + let attack_super_high_mint_amount = Uint128::from(VAULT_MINT_AMOUNT) + .checked_mul(120u128.into()) + .unwrap() + .checked_mul(100u128.into()) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: attack_super_high_mint_amount, + vault_id: None, + rebase: false, + }, + &[coin(VAULT_COLLATERAL, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap_err(); + } + } +} diff --git a/contracts/margined-power/src/testing/mod.rs b/contracts/margined-power/src/testing/mod.rs new file mode 100644 index 0000000..d6f5d32 --- /dev/null +++ b/contracts/margined-power/src/testing/mod.rs @@ -0,0 +1,8 @@ +mod execute_test; +mod helpers_test; +mod instantiation_test; +mod integration_tests; +mod ownership_test; +mod query_test; +mod test_utils; +mod unit_tests; diff --git a/contracts/margined-power/src/testing/ownership_test.rs b/contracts/margined-power/src/testing/ownership_test.rs new file mode 100644 index 0000000..24cc56b --- /dev/null +++ b/contracts/margined-power/src/testing/ownership_test.rs @@ -0,0 +1,182 @@ +use crate::{contract::CONTRACT_NAME, testing::test_utils::MOCK_FEE_POOL_ADDR}; + +use cosmwasm_std::{coin, Addr}; +use margined_protocol::power::{ExecuteMsg, InstantiateMsg, OwnerProposalResponse, QueryMsg}; +use margined_testing::{helpers::store_code, power_env::PowerEnv}; +use osmosis_test_tube::{Account, Module, RunnerError, Wasm}; + +const PROPOSAL_DURATION: u64 = 1000; + +#[test] +fn test_update_owner() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap() + .data + .address; + + // claim before a proposal is made + { + let err = wasm + .execute(&address, &ExecuteMsg::ClaimOwnership {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Proposal not found: execute wasm contract failed".to_string() + } + ); + } + + // propose new owner + wasm.execute( + &address, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // reject claim by incorrect new owner + { + let err = wasm + .execute(&address, &ExecuteMsg::ClaimOwnership {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // let proposal expire + env.app.increase_time(PROPOSAL_DURATION + 1); + + // proposal fails due to expiry + { + let err = wasm + .execute( + &address, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Expired: execute wasm contract failed".to_string() + } + ); + } + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // propose new owner + wasm.execute( + &address, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // proposal fails due to expiry + { + let err = wasm + .execute(&address, &ExecuteMsg::RejectOwner {}, &[], &env.traders[0]) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // proposal fails due to expiry + { + wasm.execute(&address, &ExecuteMsg::RejectOwner {}, &[], &env.signer) + .unwrap(); + } + + // propose new owner + wasm.execute( + &address, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let block_time = env.app.get_block_time_seconds(); + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // query ownership proposal + { + let proposal: OwnerProposalResponse = wasm + .query(&address, &QueryMsg::GetOwnershipProposal {}) + .unwrap(); + + assert_eq!(proposal.owner, env.traders[0].address()); + assert_eq!(proposal.expiry, block_time as u64 + PROPOSAL_DURATION); + } + + // claim ownership + { + wasm.execute( + &address, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap(); + } + + let owner: Addr = wasm.query(&address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.traders[0].address()); +} diff --git a/contracts/margined-power/src/testing/query_test.rs b/contracts/margined-power/src/testing/query_test.rs new file mode 100644 index 0000000..c12913b --- /dev/null +++ b/contracts/margined-power/src/testing/query_test.rs @@ -0,0 +1,201 @@ +use crate::{contract::CONTRACT_NAME, state::Config}; + +use cosmwasm_std::{coins, Decimal, Uint128}; +use margined_protocol::power::{ + ConfigResponse, ExecuteMsg, QueryMsg, StateResponse, UserVaultsResponse, +}; +use margined_testing::power_env::{PowerEnv, BASE_PRICE, SCALED_POWER_PRICE}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{Account, Module, Wasm}; +use std::str::FromStr; + +#[test] +fn test_query() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + + // add prices to mock pools + { + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3030.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + } + + let index: Decimal = wasm + .query(&perp_address, &QueryMsg::GetIndex { period: 1u64 }) + .unwrap(); + assert_eq!(index, Decimal::from_str("0.09").unwrap()); + + let unscaled_index: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 1u64 }) + .unwrap(); + assert_eq!(unscaled_index, Decimal::from_str("9000000").unwrap()); + + let denomalised_mark: Decimal = wasm + .query( + &perp_address, + &QueryMsg::GetDenormalisedMark { period: 1u64 }, + ) + .unwrap(); + assert_eq!( + denomalised_mark, + Decimal::from_str("909.004045530105750834").unwrap() + ); + + let denomalised_mark_funding: Decimal = wasm + .query( + &perp_address, + &QueryMsg::GetDenormalisedMarkFunding { period: 1u64 }, + ) + .unwrap(); + assert_eq!(denomalised_mark_funding, Decimal::from_str("909").unwrap()); +} + +#[test] +fn test_vault_queries() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + // check next vault id + { + let next_vault_id: u64 = wasm + .query(&perp_address, &QueryMsg::GetNextVaultId {}) + .unwrap(); + assert_eq!(next_vault_id, 1u64); + } + + // mint 20 vaults for user + { + (0..20).for_each(|_| { + env.app.increase_time(5u64); + + let collateral = coins(1_000_000u128, &env.denoms["base"]); + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(1_000_000u128), + vault_id: None, + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap(); + }); + } + + // check next vault id + { + let next_vault_id: u64 = wasm + .query(&perp_address, &QueryMsg::GetNextVaultId {}) + .unwrap(); + assert_eq!(next_vault_id, 21u64); + } + + // get user vaults + { + let response: UserVaultsResponse = wasm + .query( + &perp_address, + &QueryMsg::GetUserVaults { + user: env.traders[0].address(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + let expected_result: Vec = (1..=10).collect(); + assert_eq!(response.vaults, expected_result); + } + + // get user vaults - start after + { + let response: UserVaultsResponse = wasm + .query( + &perp_address, + &QueryMsg::GetUserVaults { + user: env.traders[0].address(), + start_after: Some(10u64), + limit: None, + }, + ) + .unwrap(); + let expected_result: Vec = (11..=20).collect(); + assert_eq!(response.vaults, expected_result); + } + + // get user vaults - limit + { + let response: UserVaultsResponse = wasm + .query( + &perp_address, + &QueryMsg::GetUserVaults { + user: env.traders[0].address(), + start_after: Some(5u64), + limit: Some(5u32), + }, + ) + .unwrap(); + let expected_result: Vec = (6..=10).collect(); + assert_eq!(response.vaults, expected_result); + } +} diff --git a/contracts/margined-power/src/testing/test_utils.rs b/contracts/margined-power/src/testing/test_utils.rs new file mode 100644 index 0000000..d5442bf --- /dev/null +++ b/contracts/margined-power/src/testing/test_utils.rs @@ -0,0 +1,2 @@ +pub const MOCK_FEE_POOL_ADDR: &str = "osmo10ehq249f2k77hk6wuzzhfq6jfu9zpvksl8fgad"; +pub const MOCK_QUERY_ADDR: &str = "osmo1ueghmhx6gzeky68wn8em0mz0k303e7ds35m8m5"; diff --git a/contracts/margined-power/src/testing/unit_tests/combined_test.rs b/contracts/margined-power/src/testing/unit_tests/combined_test.rs new file mode 100644 index 0000000..17edb29 --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/combined_test.rs @@ -0,0 +1,679 @@ +use crate::contract::CONTRACT_NAME; + +use cosmwasm_std::{coin, coins, Decimal, Uint128}; +use margined_protocol::power::{ + ConfigResponse, ExecuteMsg, QueryMsg, StateResponse, VaultResponse, +}; +use margined_testing::{ + helpers::parse_event_attribute, + power_env::{PowerEnv, BASE_PRICE, SCALED_POWER_PRICE}, +}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::bank::v1beta1::QueryBalanceRequest, Account, Bank, Module, + RunnerError, Wasm, +}; +use std::str::FromStr; + +#[test] +fn test_combined_actions() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let mut vault_id: u64; + + // Open, deposit and mint + { + // should revert if the vault has too little collateral + { + let mint_amount = Uint128::from(100_000u128); + let collateral_amount = Uint128::from(450_000u128); + + env.app.increase_time(1u64); + + let funds = coins(collateral_amount.u128(), &env.denoms["base"]); + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: mint_amount, + vault_id: None, + rebase: false, + }, + &funds, + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault is below minimum collateral amount (0.5 base denom): execute wasm contract failed".to_string() + } + ); + } + + // should open vault, deposit and mint in the same transaction (or not for us) + { + let amount = 100_000_000u128; + let collateral = coins(45_000_000u128, &env.denoms["base"]); + let power_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[0].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + env.app.increase_time(5u64); + + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: None, + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap(); + + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[0].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + amount + u128::from_str(&power_balance_before).unwrap(), + u128::from_str(&power_balance_after).unwrap() + ); + assert_eq!(Uint128::from(amount), vault.short_amount); + } + } + + // Deposit and mint + { + // should revert if tries to deposit collateral using mint power perp + { + env.app.increase_time(5u64); + + let collateral = coins(45_000_000u128, &env.denoms["base"]); + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::zero(), + vault_id: Some(vault_id), + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Zero mint not supported: execute wasm contract failed".to_string() + } + ); + } + + // should deposit and mint in same transaction + { + env.app.increase_time(5u64); + + let amount = 1_000_000u128; + let collateral = coins(4_500_000u128, &env.denoms["base"]); + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap(); + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let power_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[0].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!(Uint128::from(101_000_000u128), vault.short_amount); + assert_eq!(Uint128::from(49_500_000u128), vault.collateral); + assert_eq!( + Uint128::from(101_000_000u128), + Uint128::from_str(&power_balance).unwrap() + ); + } + + // should only mint if deposit is 0 + { + env.app.increase_time(5u64); + + let amount = 1_000u128; + + let before: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap(); + + let after: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + before.short_amount + Uint128::from(amount), + after.short_amount + ); + assert_eq!(before.collateral, after.collateral); + } + + // should not deposit if mint is zero + { + env.app.increase_time(5u64); + + let collateral = coins(100_000u128, &env.denoms["base"]); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::zero(), + vault_id: Some(vault_id), + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Zero mint not supported: execute wasm contract failed".to_string() + } + ); + } + + // nothing happens if both zero + { + env.app.increase_time(5u64); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::zero(), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Zero mint not supported: execute wasm contract failed".to_string() + } + ); + } + } + + // Burn and withdraw + { + // mint power for trader 1 to withdraw + env.app.increase_time(5u64); + + let amount = 100_000_000u128; + let collateral = coins(45_000_000u128, &env.denoms["base"]); + + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: None, + rebase: false, + }, + &collateral, + &env.traders[1], + ) + .unwrap(); + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + // should burn and withdraw + let before: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let burn_amount = 50_000_000u128; + let withdraw_amount = 22_500_000u128; + + let collateral = vec![coin(burn_amount, &env.denoms["power"])]; + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(withdraw_amount.into()), + vault_id, + }, + &collateral, + &env.traders[1], + ) + .unwrap(); + + let after: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + before.short_amount - Uint128::from(burn_amount), + after.short_amount + ); + assert_eq!( + before.collateral - Uint128::from(withdraw_amount), + after.collateral + ); + } +} + +#[test] +fn test_deposit_and_withdraw_with_fee() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: StateResponse = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let mut vault_id: u64; + + // Should be able to set the fee rate + { + wasm.execute( + &perp_address, + &ExecuteMsg::UpdateConfig { + fee_rate: Some("0.001".to_string()), + fee_pool: None, + }, + &[], + &env.signer, + ) + .unwrap(); + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + + assert_eq!(config.fee_rate, Decimal::from_str("0.001").unwrap()); + } + + // Should revert if vault is unable to pay fee amount from attached amount or vault collateral + { + let amount = 500_000u128; + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: None, + rebase: false, + }, + &[], + &env.traders[1], + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot subtract more collateral than deposited: execute wasm contract failed".to_string() + } + ); + } + + // Should charge fee on mint power perp amount from deposit amount + { + let amount = 500_000u128; + let expect_fees = SCALED_POWER_PRICE * amount / 1_000_000u128 / 1_000u128; + let collateral = coins(550_000u128 + expect_fees, &env.denoms["base"]); + + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: None, + rebase: false, + }, + &collateral, + &env.traders[1], + ) + .unwrap(); + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!(vault.short_amount, Uint128::from(amount)); + assert_eq!(vault.collateral, Uint128::from(550_000u128)); + assert_eq!( + Uint128::from_str(&fee_pool_balance).unwrap(), + Uint128::from(expect_fees) + ); + } + + // Should charge fee on mint power perp amount from vault collateral + { + let vault_before: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let amount = 500_000u128; + let expect_fees = SCALED_POWER_PRICE * amount / 1_000_000u128 / 1_000u128; + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[1], + ) + .unwrap(); + + // should burn and withdraw + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!( + vault_before.short_amount + Uint128::from(amount), + vault.short_amount + ); + assert_eq!( + Uint128::from_str(&fee_pool_balance_before).unwrap() + Uint128::from(expect_fees), + Uint128::from_str(&fee_pool_balance).unwrap() + ); + } + + // Should charge fee on mint power perp amount from deposit amount - 0.1 + { + let amount = 100_000u128; + let expect_fees = SCALED_POWER_PRICE * amount / 1_000_000u128 / 1_000u128; + let collateral = coins(550_000u128 + expect_fees, &env.denoms["base"]); + + let fee_pool_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + env.app.increase_time(5u64); + + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: None, + rebase: false, + }, + &collateral, + &env.traders[0], + ) + .unwrap(); + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!(vault.short_amount, Uint128::from(amount)); + assert_eq!(vault.collateral, Uint128::from(550_000u128)); + assert_eq!( + Uint128::from_str(&fee_pool_balance).unwrap(), + Uint128::from_str(&fee_pool_balance_before).unwrap() + Uint128::from(expect_fees) + ); + } + + // Should charge fee on mint power perp amount from vault collateral - 0.1 + { + let vault_before: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let amount = 100_000u128; + let expect_fees = SCALED_POWER_PRICE * amount / 1_000_000u128 / 1_000u128; + + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap(); + + // should burn and withdraw + let vault: VaultResponse = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let fee_pool_balance = bank + .query_balance(&QueryBalanceRequest { + address: env.fee_pool.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!( + vault_before.short_amount + Uint128::from(amount), + vault.short_amount + ); + assert_eq!( + Uint128::from_str(&fee_pool_balance_before).unwrap() + Uint128::from(expect_fees), + Uint128::from_str(&fee_pool_balance).unwrap() + ); + } + + // Should set fee to 0 + { + wasm.execute( + &perp_address, + &ExecuteMsg::UpdateConfig { + fee_rate: Some("0.0".to_string()), + fee_pool: None, + }, + &[], + &env.signer, + ) + .unwrap(); + let config: ConfigResponse = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + + assert_eq!(config.fee_rate, Decimal::zero()); + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/config_test.rs b/contracts/margined-power/src/testing/unit_tests/config_test.rs new file mode 100644 index 0000000..371fae8 --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/config_test.rs @@ -0,0 +1,141 @@ +use crate::state::Config; + +use cosmwasm_std::{Addr, Decimal}; +use margined_protocol::power::Pool; + +#[test] +fn test_config_validation() { + // invalid base decimals + { + let config = Config { + fee_rate: Decimal::percent(0), + fee_pool_contract: Addr::unchecked("fee_pool".to_string()), + query_contract: Addr::unchecked("query".to_string()), + power_denom: "power".to_string(), + base_denom: "base".to_string(), + base_pool: Pool { + id: 1, + quote_denom: "base_quote".to_string(), + }, + power_pool: Pool { + id: 2, + quote_denom: "power_quote".to_string(), + }, + funding_period: 100, + base_decimals: 60, + power_decimals: 6, + }; + + let err = config.validate().unwrap_err(); + assert_eq!(err.to_string(), "Generic error: Invalid base decimals"); + } + + // invalid power decimals + { + let config = Config { + fee_rate: Decimal::percent(0), + fee_pool_contract: Addr::unchecked("fee_pool".to_string()), + query_contract: Addr::unchecked("query".to_string()), + power_denom: "power".to_string(), + base_denom: "base".to_string(), + base_pool: Pool { + id: 1, + quote_denom: "base_quote".to_string(), + }, + power_pool: Pool { + id: 2, + quote_denom: "power_quote".to_string(), + }, + funding_period: 100, + base_decimals: 6, + power_decimals: 19, + }; + + let err = config.validate().unwrap_err(); + assert_eq!(err.to_string(), "Generic error: Invalid power decimals"); + } + + // invalid funding period + { + let config = Config { + fee_rate: Decimal::percent(0), + fee_pool_contract: Addr::unchecked("fee_pool".to_string()), + query_contract: Addr::unchecked("query".to_string()), + power_denom: "power".to_string(), + base_denom: "base".to_string(), + base_pool: Pool { + id: 1, + quote_denom: "base_quote".to_string(), + }, + power_pool: Pool { + id: 2, + quote_denom: "power_quote".to_string(), + }, + funding_period: 0, + base_decimals: 6, + power_decimals: 6, + }; + + let err = config.validate().unwrap_err(); + assert_eq!( + err.to_string(), + "Generic error: Invalid funding period, must be between 0 and 3024000 seconds" + ); + } + + // invalid base and power denom + { + let config = Config { + fee_rate: Decimal::percent(0), + fee_pool_contract: Addr::unchecked("fee_pool".to_string()), + query_contract: Addr::unchecked("query".to_string()), + power_denom: "power".to_string(), + base_denom: "power".to_string(), + base_pool: Pool { + id: 1, + quote_denom: "base_quote".to_string(), + }, + power_pool: Pool { + id: 2, + quote_denom: "base_quote".to_string(), + }, + funding_period: 100, + base_decimals: 6, + power_decimals: 6, + }; + + let err = config.validate().unwrap_err(); + assert_eq!( + err.to_string(), + "Generic error: Invalid base and power denom must be different" + ); + } + + // invalid base and power id + { + let config = Config { + fee_rate: Decimal::percent(0), + fee_pool_contract: Addr::unchecked("fee_pool".to_string()), + query_contract: Addr::unchecked("query".to_string()), + power_denom: "power".to_string(), + base_denom: "base".to_string(), + base_pool: Pool { + id: 1, + quote_denom: "base_quote".to_string(), + }, + power_pool: Pool { + id: 1, + quote_denom: "base_power".to_string(), + }, + funding_period: 100, + base_decimals: 6, + power_decimals: 6, + }; + + let err = config.validate().unwrap_err(); + assert_eq!( + err.to_string(), + "Generic error: Invalid base and power pool id must be different" + ); + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/funding_test.rs b/contracts/margined-power/src/testing/unit_tests/funding_test.rs new file mode 100644 index 0000000..2272974 --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/funding_test.rs @@ -0,0 +1,561 @@ +use crate::{ + contract::CONTRACT_NAME, + state::{Config, State}, +}; + +use cosmwasm_std::{coins, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{Module, Wasm}; +use std::str::FromStr; + +#[test] +fn test_funding_actions() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3030.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + // NORMALISATION FACTOR TESTS + { + // should apply the correct normalisation factor for funding + { + let state_before: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + assert_eq!( + Decimal::raw(999_994_436_783_524_723u128), + state_before.normalisation_factor + ); + + env.app.increase_time(10_795u64); // 3 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(997_593_962_860_445_984u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + + // normalisation factor changes should be bounded above + { + // set the prices + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("2000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + env.app.increase_time(10_785u64); // 3 hours (minus 15 seconds as there are 3 preceeding blocks) + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(995_199_251_244_479_588u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + + // normalisation factor changes should be bounded below + { + // set the prices + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("6000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + env.app.increase_time(10_785u64); // 3 hours (minus 15 seconds as there are 3 preceeding blocks) + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(992_810_288_103_280_623u128); + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + + // calling apply funding with small time delta should not affect the normalisation factor + { + // set the prices + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3030.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // apply funding 0 + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let expected_normalisation_factor: Decimal = Decimal::raw(992_806_974_302_083_222u128); + + assert_eq!(expected_normalisation_factor, state.normalisation_factor); + + // apply funding 1 + env.app.increase_time(10u64); + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let expected_normalisation_factor: Decimal = Decimal::raw(992_803_660_511_946_624u128); + + assert_eq!(expected_normalisation_factor, state.normalisation_factor); + + // apply funding 2 + env.app.increase_time(10u64); + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let expected_normalisation_factor: Decimal = Decimal::raw(992_800_346_732_870_791u128); + + assert_eq!(expected_normalisation_factor, state.normalisation_factor); + } + } + + // FUNDING COLLATERALISATION TESTS + { + let mut vault_id: u64; + // Set prices to original values + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3030.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + // Max power to mint = eth:usd * collateral_ratio + let collateral_amount = Uint128::from(50_000_000u128); // 50@6dp + let max_power_to_mint = Uint128::from(111_111_112u128); // 111.111112@6dp + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: max_power_to_mint, + vault_id: None, + rebase: false, + }, + &coins(collateral_amount.u128(), env.denoms["base"].to_string()), + &env.traders[0], + ) + .unwrap(); + + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + let expected_amount_can_mint = Uint128::from(1_345_538u128); + // should revert if minting too much power after funding + { + env.app.increase_time(21600u64); // 6hours + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: expected_amount_can_mint + Uint128::from(1u128), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + } + + // should mint more wpower after funding + { + env.app.increase_time(1u64); + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: expected_amount_can_mint, + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.traders[0], + ) + .unwrap(); + } + + env.app.increase_time(5u64); + + // Prepare vault + let res = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: max_power_to_mint, + vault_id: None, + rebase: false, + }, + &coins(collateral_amount.u128(), env.denoms["base"].to_string()), + &env.traders[0], + ) + .unwrap(); + + vault_id = + u64::from_str(&parse_event_attribute(res.events, "wasm-mint", "vault_id")).unwrap(); + + env.app.increase_time(10795u64); // 3hours + + let max_collateral_to_remove = Uint128::from(717_000u128); + // should revert when attempting to withdraw too much collateral + { + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: max_collateral_to_remove + Uint128::from(1u128), + vault_id, + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + } + + // should be able to withdraw more collateral after funding + { + // move one block forward + env.app.increase_time(5u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: max_collateral_to_remove, + vault_id, + }, + &[], + &env.traders[0], + ) + .unwrap(); + } + } + + // EXTREME CASES FOR NORMALISATION FACTOR + { + // Should get capped normalisation factor when mark = 0 + { + let state_before: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + assert_eq!( + Decimal::raw(985_657_792_683_450_661u128), + state_before.normalisation_factor + ); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("0.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + env.app.increase_time(10_790u64); // 3 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(987_230_796_556_633_421u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + + // Should get capped normalisation factor when index = 0 + { + let state_before: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + assert_eq!( + Decimal::raw(987_230_796_556_633_421u128), + state_before.normalisation_factor + ); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("0.0001").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3000").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + env.app.increase_time(10_790u64); // 3 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(984_859_865_721_874_489u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + + // calling appyfunding() 2 * 12hrs should be equivocal to 1 * 24hrs + { + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3024.177466").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + env.app.increase_time(86_390u64); // 24 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(966_103_783_878_107_701u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + + env.app.increase_time(43_195u64); // 12 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(956_860_653_194_208_855u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + + env.app.increase_time(43_195u64); // 12 hours + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + let expected_normalisation_factor: Decimal = Decimal::raw(947_705_955_519_542_909u128); + + assert_eq!( + expected_normalisation_factor, + state_after.normalisation_factor + ); + } + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/general_test.rs b/contracts/margined-power/src/testing/unit_tests/general_test.rs new file mode 100644 index 0000000..0d56d6a --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/general_test.rs @@ -0,0 +1,882 @@ +use crate::{ + contract::CONTRACT_NAME, + state::{Config, State}, + vault::Vault, +}; + +use cosmwasm_std::{coin, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use margined_testing::{ + helpers::{parse_event_attribute, store_code}, + power_env::{PowerEnv, ONE, SCALE_FACTOR}, +}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::bank::v1beta1::QueryBalanceRequest, Account, Bank, Module, + RunnerError, Wasm, +}; +use std::str::FromStr; + +#[test] +fn test_deployment() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_str("3010.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + // check power deployment + { + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + assert_eq!(env.denoms["power"], config.power_denom); + } +} + +#[test] +fn test_fail_deployment_invalid_config() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, true); + + let code_id = store_code(&wasm, &env.signer, "margined-power".to_string()); + + // invalid config + let err = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: env.fee_pool.address(), + fee_rate: "0.0".to_string(), // 0% + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 160u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[], + &env.signer, + ) + .unwrap_err(); + + assert_eq!("execute error: failed to execute message; message index: 0: Generic error: Invalid base decimals: instantiate wasm contract failed", err.to_string()); +} + +#[test] +fn test_basic_actions() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_str("3000.0").unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + const SCALED_POWER_PRICE: u128 = 3_010 * ONE / SCALE_FACTOR; + const SCALED_BASE_PRICE: u128 = 3_000 * ONE / SCALE_FACTOR; + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.signer, + ) + .unwrap(); + + // read_basic_properties + { + // should be able to get normalisation factor + { + // increase timestamp + env.app.increase_time(30u64); + + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let normalisation_factor: Decimal = wasm + .query(&perp_address, &QueryMsg::GetNormalisationFactor {}) + .unwrap(); + assert!(state.normalisation_factor > normalisation_factor); + + // increase timestamp + env.app.increase_time(30u64); + + let normalisation_factor_after: Decimal = wasm + .query(&perp_address, &QueryMsg::GetNormalisationFactor {}) + .unwrap(); + assert!(normalisation_factor > normalisation_factor_after); + } + + // should allow anyone to call apply funding + { + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let expected_normalisation_factor: Decimal = wasm + .query(&perp_address, &QueryMsg::GetNormalisationFactor {}) + .unwrap(); + assert!(state.normalisation_factor > expected_normalisation_factor); + + wasm.execute( + &perp_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &env.traders[1], // one of the trading accounts should be able to call this + ) + .unwrap(); + + let state_after: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert!( + state_after + .normalisation_factor + .abs_diff(expected_normalisation_factor) + < Decimal::from_str("0.00001").unwrap() + ); + assert!(state.normalisation_factor > state_after.normalisation_factor); + } + + // TODO: need to add multiple function execution + // // should not update funding twice in a single block + // { + // wasm.execute_multiple(contract, msg, funds, signer) + // } + + // should be able to get index and mark price used for funding + { + env.app.increase_time(30u64); + + let mark_price: Decimal = wasm + .query( + &perp_address, + &QueryMsg::GetDenormalisedMark { period: 30u64 }, + ) + .unwrap(); + let mark_price_funding: Decimal = wasm + .query( + &perp_address, + &QueryMsg::GetDenormalisedMarkFunding { period: 30u64 }, + ) + .unwrap(); + + let expected_mark = Decimal::from_atomics(SCALED_BASE_PRICE, 6u32).unwrap() + * Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(); + + assert!(mark_price.abs_diff(expected_mark) < Decimal::from_atomics(3u128, 0).unwrap()); + assert!( + mark_price_funding.abs_diff(expected_mark) + < Decimal::from_atomics(3u128, 0).unwrap() + ); + } + + // should be able to get scaled index + { + let index: Decimal = wasm + .query(&perp_address, &QueryMsg::GetUnscaledIndex { period: 30u64 }) + .unwrap(); + + let eth_squared = + Decimal::from_str("3000.0").unwrap() * Decimal::from_str("3000.0").unwrap(); + + assert_eq!(index, eth_squared); + } + + // TODO: should revert when sending eth to controller from an EOA + // not sure this is quite possible in CosmWasm + } + + let vault_id: u64; + // mint: open vault + { + // should be able to open a vaults + { + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(500_000u128), + vault_id: None, + rebase: false, + }, + &[coin(500_000u64.into(), env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let res: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(res); + } + } + + // deposit: deposit collateral + { + // should revert when trying to deposit to vault 0 + { + wasm.execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id: 0u64 }, + &[coin(1u64.into(), env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap_err(); + } + + // should revert when trying to deposit to non-existent vault + { + wasm.execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id: 10u64 }, + &[coin(1u64.into(), env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap_err(); + } + + // should revert when trying to mint to non-existent vault + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(100u128), + vault_id: Some(10u64), + rebase: false, + }, + &[coin(100u64.into(), env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap_err(); + } + + // should revert when trying to deposit to vault where info.sender is not operator + { + wasm.execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id }, + &[coin(1u64.into(), env.denoms["base"].to_string())], + &env.traders[0], + ) + .unwrap_err(); + } + + // should be able to deposit collateral + { + let deposit_amount = 45_000_000u128; + let power_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: perp_address.clone(), + denom: env.denoms["base"].to_string(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_before: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id }, + &[coin(deposit_amount, env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: perp_address.clone(), + denom: env.denoms["base"].to_string(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_after: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + deposit_amount + u128::from_str(&power_balance_before).unwrap(), + u128::from_str(&power_balance_after).unwrap() + ); + assert_eq!( + Uint128::from(deposit_amount) + vault_before.collateral, + vault_after.collateral + ); + } + + // should not be able to deposit zero collateral + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id }, + &[coin(0u128, env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "sentFunds: invalid coins".to_string() + } + ); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Deposit { vault_id }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid funds: execute wasm contract failed".to_string() + } + ); + } + } + + // mint: mint power tokens + { + // should revert if not called by operator + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(100u128), + vault_id: Some(vault_id), + rebase: false, + }, + &[coin(100u128, env.denoms["base"].to_string())], + &env.traders[0], + ) + .unwrap_err(); + } + + // should revert if vault does not exist + { + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(100u128), + vault_id: Some(110u64), + rebase: false, + }, + &[coin(100u128, env.denoms["base"].to_string())], + &env.signer, + ) + .unwrap_err(); + } + + // should be able to mint power token + { + let amount = 100_000_000u128; + let power_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_before: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.signer, + ) + .unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_after: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + assert_eq!( + amount + u128::from_str(&power_balance_before).unwrap(), + u128::from_str(&power_balance_after).unwrap() + ); + assert_eq!( + Uint128::from(amount) + vault_before.short_amount, + vault_after.short_amount + ); + } + + // should revert if minting more than collateral ratio + { + let amount = 100_000_000u128; + + wasm.execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(amount), + vault_id: Some(vault_id), + rebase: false, + }, + &[], + &env.signer, + ) + .unwrap_err(); + } + } + + // burn: burn power token + { + // should revert if no funds are sent + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: None, + vault_id, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Invalid funds: execute wasm contract failed".to_string() + } + ); + } + + // should revert when trying to burn for vault 0 + { + let funds = vec![coin(100u128, env.denoms["power"].clone())]; + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(Uint128::from(100u128)), + vault_id: 0u64, + }, + &funds, + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault does not exist, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // should revert when trying to burn more than minted + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let funds = vec![coin( + vault.short_amount.u128() + 1u128, + env.denoms["power"].clone(), + )]; + + // dont assert this error as the address change + wasm.execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(Uint128::from(1u128)), + vault_id, + }, + &funds, + &env.signer, + ) + .unwrap_err(); + } + + // should revert when if not operator of vault + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let funds = vec![coin(vault.short_amount.u128(), env.denoms["power"].clone())]; + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(Uint128::from(1u128)), + vault_id, + }, + &funds, + &env.owner, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + } + ); + } + + // should revert if vault is underwater + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let funds = vec![coin( + vault.short_amount.u128() / 2, + env.denoms["power"].clone(), + )]; + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(Uint128::from(vault.collateral.u128())), + vault_id, + }, + &funds, + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault is not safe, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // TODO: should revert if vault after burning is dust + {} + + // should revert if trying to withdraw would make vault underwater + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: vault.collateral, + vault_id, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault is not safe, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // should revert if different account tries to burn power token, with balance + { + let funds = vec![coin(1000u128, env.denoms["power"].clone())]; + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(Uint128::from(1u128)), + vault_id, + }, + &funds, + &env.owner, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + } + ); + } + + // should revert if different account tries to withdraw collateral + { + let funds = vec![coin(1000u128, env.denoms["power"].clone())]; + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: None, + vault_id, + }, + &funds, + &env.owner, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + } + ); + } + + // should be able to burn power token + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + let power_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let burn_amount = vault.short_amount.u128(); + let withdraw_amount = 5u128; + + let funds = vec![coin(burn_amount, env.denoms["power"].clone())]; + + wasm.execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: Some(withdraw_amount.into()), + vault_id, + }, + &funds, + &env.signer, + ) + .unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!( + u128::from_str(&power_balance_after).unwrap(), + u128::from_str(&power_balance_before).unwrap() - burn_amount + ); + } + } + + // withdraw: remove collateral + { + // should revert if removing from vault 0 + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(100u128), + vault_id: 0u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault does not exist, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // should revert if caller is not the operator + { + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(100u128), + vault_id, + }, + &[], + &env.owner, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: operator does not match: execute wasm contract failed".to_string() + } + ); + } + + // should revert if withdrawing more collateral than deposited + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + + let err = wasm + .execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: vault.collateral + Uint128::from(1u128), + vault_id, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot subtract more collateral than deposited: execute wasm contract failed".to_string() + } + ); + } + + // TODO: should revert if withdrawing would leave dust + {} + + // should be able to remove collateral + { + let vault: Vault = wasm + .query(&perp_address, &QueryMsg::GetVault { vault_id }) + .unwrap(); + let balance_before = bank + .query_balance(&QueryBalanceRequest { + address: perp_address.clone(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let withdraw_amount = vault.collateral.checked_div(2u128.into()).unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::Withdraw { + amount: withdraw_amount, + vault_id, + }, + &[], + &env.signer, + ) + .unwrap(); + + let balance_after = bank + .query_balance(&QueryBalanceRequest { + address: perp_address, + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + assert_eq!( + u128::from_str(&balance_after).unwrap() + withdraw_amount.u128(), + u128::from_str(&balance_before).unwrap() + ); + } + + // TODO: close when empty, need to think really how to do this or why + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/liquidation_test.rs b/contracts/margined-power/src/testing/unit_tests/liquidation_test.rs new file mode 100644 index 0000000..89c843e --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/liquidation_test.rs @@ -0,0 +1,653 @@ +use crate::{ + contract::CONTRACT_NAME, + state::{Config, State}, + vault::Vault, +}; + +use cosmwasm_std::{coin, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg}; +use margined_testing::{ + helpers::parse_event_attribute, + power_env::{PowerEnv, BASE_PRICE, ONE, SCALE_FACTOR}, +}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::bank::v1beta1::QueryBalanceRequest, Account, Bank, Module, + RunnerError, Wasm, +}; +use std::str::FromStr; + +#[test] +fn test_liquidation_profitable() { + pub const SCALED_POWER_PRICE: u128 = 303_000 * ONE / SCALE_FACTOR; + + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + let vault_id_1: u64; + let vault_id_2: u64; + + // open vault id 1 + { + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + let mint_amount = 100_000_000u128; + let deposit_amount = 45_000_049u128; + let power_balance_before = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[0].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(mint_amount), + vault_id: None, + rebase: true, + }, + &[coin(deposit_amount, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id_1 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[0].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + + assert_eq!( + 100_000_600u128 + u128::from_str(&power_balance_before).unwrap(), + u128::from_str(&power_balance_after).unwrap() + ); + assert_eq!(Uint128::from(100_000_600u128), vault_after.short_amount); + } + + // open vault id 2 + { + env.app.increase_time(1u64); + let mint_amount = 2_000_000u128; + let deposit_amount = 900_000u128; + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(mint_amount), + vault_id: None, + rebase: true, + }, + &[coin(deposit_amount, env.denoms["base"].clone())], + &env.traders[1], + ) + .unwrap(); + + vault_id_2 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let power_balance_after = bank + .query_balance(&QueryBalanceRequest { + address: env.traders[1].address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount; + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + + assert_eq!(2_000_014u128, u128::from_str(&power_balance_after).unwrap()); + assert_eq!(Uint128::from(2_000_014u128), vault_after.short_amount); + } + + // should revert liquidating vault id 0 + { + let liquidate_response = wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: 0, + max_debt_amount: Uint128::from(1_000_000u128), + }, + &[], + &env.signer, + ); + + assert!(liquidate_response.is_err()); + assert_eq!( + liquidate_response.unwrap_err(), + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault does not exist, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // should revert liquidate vault id greater than max vaults + { + let liquidate_response = wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: 10, + max_debt_amount: Uint128::from(1_000_000u128), + }, + &[], + &env.signer, + ); + + assert!(liquidate_response.is_err()); + assert_eq!( + liquidate_response.unwrap_err(), + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault does not exist, cannot perform operation: execute wasm contract failed".to_string() + } + ); + } + + // should revert liquidating a safu vault + { + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + + let liquidate_response = wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_1, + max_debt_amount: vault_before.short_amount, + }, + &[], + &env.signer, + ); + + assert!(liquidate_response.is_err()); + assert_eq!( + liquidate_response.unwrap_err(), + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Vault is safe, cannot be liquidated: execute wasm contract failed".to_string() + } + ); + } + + // set base price to make the vault underwater + { + pub const SCALED_POWER_PRICE: u128 = 4_040 * ONE / SCALE_FACTOR; + pub const BASE_PRICE: u128 = 4_000_000_000; + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + } + + // should revert if the vault becomes dust after liqudiation + { + // TODO: we don't have the concept of dust, (yet) + } + + // should allow liquidation a whole vault if only liquidating half will make it a dust vault + { + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + let max_debt_amount = vault_before.short_amount; + + let liquidate_response = wasm + .execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_2, + max_debt_amount, + }, + &[], + &env.signer, + ) + .unwrap(); + + let collateral_to_pay = Uint128::from_str(&parse_event_attribute( + liquidate_response.events.clone(), + "wasm-liquidation", + "collateral_to_pay", + )) + .unwrap(); + assert_eq!(Uint128::from(888_806u128), collateral_to_pay); + + let liquidation_amount = Uint128::from_str(&parse_event_attribute( + liquidate_response.events, + "wasm-liquidation", + "liquidation_amount", + )) + .unwrap(); + assert_eq!(Uint128::from(2_000_014u128), liquidation_amount); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_2, + }, + ) + .unwrap(); + + assert_eq!(Uint128::zero(), vault_after.short_amount); + assert_eq!(Uint128::from(11_194u128), vault_after.collateral); + } + + // liquidate unsafe vault (vault id 1) + { + env.app.increase_time(1u64); + let liquidator_power_before = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + + let vault_before: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + let max_debt_amount = vault_before.short_amount.checked_div(2u128.into()).unwrap(); + + let liquidate_response = wasm + .execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_1, + max_debt_amount: max_debt_amount + Uint128::from(10u128), + }, + &[], + &env.signer, + ) + .unwrap(); + + let collateral_to_pay = Uint128::from_str(&parse_event_attribute( + liquidate_response.events.clone(), + "wasm-liquidation", + "collateral_to_pay", + )) + .unwrap(); + assert_eq!(Uint128::from(22_220_133u128), collateral_to_pay); + + let liquidation_amount = Uint128::from_str(&parse_event_attribute( + liquidate_response.events, + "wasm-liquidation", + "liquidation_amount", + )) + .unwrap(); + assert_eq!(Uint128::from(50_000_300u128), liquidation_amount); + + let liquidator_power_after = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + + assert_eq!(Uint128::from(50_000_300u128), vault_after.short_amount); + assert_eq!(Uint128::from(22_779_916u128), vault_after.collateral); + // assert_eq!( + // liquidator_base_before + collateral_to_pay, + // liquidator_base_after + // ); + assert_eq!( + liquidator_power_after + max_debt_amount, + liquidator_power_before + ); + } +} + +#[test] +fn test_liquidation_unprofitable() { + pub const SCALED_POWER_PRICE: u128 = 303_000 * ONE / SCALE_FACTOR; + + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + let vault_id_1: u64; + // open vault id 1 + { + let state: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + + assert_eq!(Decimal::one(), state.normalisation_factor); + let mint_amount = 100_000_000u128; + let deposit_amount = 45_000_049u128; + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(mint_amount), + vault_id: None, + rebase: true, + }, + &[coin(deposit_amount, env.denoms["base"].clone())], + &env.traders[0], + ) + .unwrap(); + + vault_id_1 = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + } + + // set price to be unprofitable + { + pub const SCALED_POWER_PRICE: u128 = 909_000 * ONE / SCALE_FACTOR; + pub const BASE_PRICE: u128 = 9_000_000_000_000; + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.base_pool_id, + price: Decimal::from_atomics(BASE_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + + wasm.execute( + config.query_contract.as_ref(), + &MockQueryExecuteMsg::AppendPrice { + pool_id: env.power_pool_id, + price: Decimal::from_atomics(SCALED_POWER_PRICE, 6u32).unwrap(), + }, + &[], + &env.signer, + ) + .unwrap(); + } + + // should revert if vault is paying out all collateral, but there is debt remaining + { + env.app.increase_time(1u64); + let vault: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + let max_debt_amount = vault.short_amount.checked_sub(11u128.into()).unwrap(); + + wasm.execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_1, + max_debt_amount: max_debt_amount + Uint128::from(10u128), + }, + &[], + &env.signer, + ) + .unwrap_err(); + } + + // can fully liquidate an underwater vault even if it is not profitable + { + env.app.increase_time(1u64); + let vault: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + let max_debt_amount = vault.short_amount; + + let liquidator_base_before = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + + let liquidator_power_before = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + + let liquidate_response = wasm + .execute( + &perp_address, + &ExecuteMsg::Liquidate { + vault_id: vault_id_1, + max_debt_amount, + }, + &[], + &env.signer, + ) + .unwrap(); + + let collateral_to_pay = Uint128::from_str(&parse_event_attribute( + liquidate_response.events.clone(), + "wasm-liquidation", + "collateral_to_pay", + )) + .unwrap(); + assert_eq!(Uint128::from(45_000_049u128), collateral_to_pay); + + let liquidation_amount = Uint128::from_str(&parse_event_attribute( + liquidate_response.events, + "wasm-liquidation", + "liquidation_amount", + )) + .unwrap(); + assert_eq!(Uint128::from(100_000_600u128), liquidation_amount); + + let liquidator_base_after = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["base"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + let liquidator_power_after = Uint128::from_str( + &bank + .query_balance(&QueryBalanceRequest { + address: env.signer.address(), + denom: env.denoms["power"].clone(), + }) + .unwrap() + .balance + .unwrap() + .amount, + ) + .unwrap(); + + let vault_after: Vault = wasm + .query( + &perp_address, + &QueryMsg::GetVault { + vault_id: vault_id_1, + }, + ) + .unwrap(); + assert_eq!(Uint128::zero(), vault_after.short_amount); + assert_eq!(Uint128::zero(), vault_after.collateral); + assert_eq!( + liquidator_power_before + .checked_sub(liquidator_power_after) + .unwrap(), + max_debt_amount + ); + assert_eq!( + liquidator_base_after + .checked_sub(liquidator_base_before) + .unwrap(), + collateral_to_pay + ); + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/mod.rs b/contracts/margined-power/src/testing/unit_tests/mod.rs new file mode 100644 index 0000000..38115ea --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/mod.rs @@ -0,0 +1,7 @@ +mod combined_test; +mod config_test; +mod funding_test; +mod general_test; +mod liquidation_test; +mod permissions_test; +mod vault_test; diff --git a/contracts/margined-power/src/testing/unit_tests/permissions_test.rs b/contracts/margined-power/src/testing/unit_tests/permissions_test.rs new file mode 100644 index 0000000..07419d9 --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/permissions_test.rs @@ -0,0 +1,594 @@ +use crate::{contract::CONTRACT_NAME, state::State, testing::test_utils::MOCK_FEE_POOL_ADDR}; + +use cosmwasm_std::{coin, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use margined_testing::{helpers::store_code, power_env::PowerEnv}; +use osmosis_test_tube::{ + osmosis_std::types::osmosis::tokenfactory::v1beta1::MsgChangeAdmin, Account, Module, + RunnerError, TokenFactory, Wasm, +}; + +#[test] +fn test_permissions() { + let env = PowerEnv::new(); + + let token = TokenFactory::new(&env.app); + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap() + .data + .address; + + let timestamp = env.app.get_block_timestamp(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: false, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: timestamp, + } + ); + + // check permissions when contract is not open and is paused + { + // pause should fail, contract not set open + { + let err = wasm + .execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // unpause should fail, contract not set open + { + let err = wasm + .execute(&address, &ExecuteMsg::UnPause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // mint should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(1_000_000u128), + vault_id: None, + rebase: false, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // burn should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: None, + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // deposit should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Deposit { vault_id: 1u64 }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // withdraw should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(1_000_000u64), + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + // liquidation should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Liquidate { + max_debt_amount: Uint128::from(1_000_000u64), + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + } + + // set contract open, should fail as not admin + { + let err = wasm + .execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Contract is not admin of the power token: execute wasm contract failed".to_string() + } + ); + } + + // set contract open + { + token + .change_admin( + MsgChangeAdmin { + sender: env.signer.address(), + new_admin: address.clone(), + denom: env.denoms["power"].clone(), + }, + &env.signer, + ) + .unwrap(); + + wasm.execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: false, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: timestamp, + } + ); + } + + // set contract open, should fail as already open + { + let err = wasm + .execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Contract is already open: execute wasm contract failed".to_string() + } + ); + } + + // check permissions when contract is open and is paused + { + // pause contract + { + wasm.execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap(); + + let latest_timestamp = env.app.get_block_timestamp(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: latest_timestamp, + } + ); + } + + // pause should fail, contract not set open + { + let err = wasm + .execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + + // mint should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::MintPowerPerp { + amount: Uint128::from(1_000_000u128), + vault_id: None, + rebase: false, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + + // burn should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: None, + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + + // deposit should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Deposit { vault_id: 1u64 }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + + // withdraw should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Withdraw { + amount: Uint128::from(1_000_000u64), + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + // liquidation should fail, contract not set open + { + let err = wasm + .execute( + &address, + &ExecuteMsg::Liquidate { + max_debt_amount: Uint128::from(1_000_000u64), + vault_id: 1u64, + }, + &[], + &env.signer, + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + } +} + +#[test] +fn test_pause_unpause() { + let env = PowerEnv::new(); + + let token = TokenFactory::new(&env.app); + let wasm = Wasm::new(&env.app); + + let query_address = env.deploy_query_contracts(&wasm, false); + + let code_id = store_code(&wasm, &env.signer, CONTRACT_NAME.to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: MOCK_FEE_POOL_ADDR.to_string(), + fee_rate: "0.1".to_string(), + query_contract: query_address, + power_denom: env.denoms["power"].clone(), + base_denom: env.denoms["base"].clone(), + base_pool_id: env.base_pool_id, + base_pool_quote: env.denoms["quote"].clone(), + power_pool_id: env.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[coin(10_000_000, "uosmo")], + &env.signer, + ) + .unwrap() + .data + .address; + + let timestamp = env.app.get_block_timestamp(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: false, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: timestamp, + } + ); + + // check permissions when contract is not open and is paused + { + // pause should fail, contract not set open + { + let err = wasm + .execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + + // unpause should fail, contract not set open + { + let err = wasm + .execute(&address, &ExecuteMsg::UnPause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Cannot perform action as contract is not open: execute wasm contract failed".to_string() + } + ); + } + } + + // set contract open + { + token + .change_admin( + MsgChangeAdmin { + sender: env.signer.address(), + new_admin: address.clone(), + denom: env.denoms["power"].clone(), + }, + &env.signer, + ) + .unwrap(); + + wasm.execute(&address, &ExecuteMsg::SetOpen {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: false, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: timestamp, + } + ); + } + + // check permissions when contract is open and is paused + let latest_timestamp = env.app.get_block_timestamp(); + + // pause contract + { + wasm.execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: latest_timestamp.plus_seconds(5u64), + } + ); + } + + // pause should fail, contract already paused + { + let err = wasm + .execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Generic error: Cannot perform action as contract is paused: execute wasm contract failed".to_string() + } + ); + } + + // unpause should fail, timer has not expired + { + let err = wasm + .execute(&address, &ExecuteMsg::UnPause {}, &[], &env.traders[0]) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unpause delay not expired: execute wasm contract failed".to_string() + } + ); + } + + env.app.increase_time(7 * 24 * 60 * 60); + + // unpause should pass + { + wasm.execute(&address, &ExecuteMsg::UnPause {}, &[], &env.traders[0]) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: false, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: latest_timestamp.plus_seconds(5u64), + } + ); + } + + // check permissions when contract is open and is paused using the admin + let latest_timestamp = env.app.get_block_timestamp(); + + // pause contract + { + wasm.execute(&address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: true, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: latest_timestamp.plus_seconds(5u64), + } + ); + } + // unpause should pass + { + wasm.execute(&address, &ExecuteMsg::UnPause {}, &[], &env.signer) + .unwrap(); + + let state: State = wasm.query(&address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: true, + is_paused: false, + normalisation_factor: Decimal::one(), + last_funding_update: timestamp, + last_pause: latest_timestamp.plus_seconds(5u64), + } + ); + } +} diff --git a/contracts/margined-power/src/testing/unit_tests/vault_test.rs b/contracts/margined-power/src/testing/unit_tests/vault_test.rs new file mode 100644 index 0000000..10de035 --- /dev/null +++ b/contracts/margined-power/src/testing/unit_tests/vault_test.rs @@ -0,0 +1,159 @@ +use crate::{ + contract::CONTRACT_NAME, + state::{Config, State}, +}; + +use cosmwasm_std::{coin, Decimal, Uint128}; +use margined_protocol::power::{ExecuteMsg, QueryMsg}; +use margined_testing::{helpers::parse_event_attribute, power_env::PowerEnv}; +use osmosis_test_tube::{Module, Wasm}; +use std::str::FromStr; + +#[test] +fn test_check_vault_is_safe() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + let (perp_address, _) = env.deploy_power(&wasm, CONTRACT_NAME.to_string(), true); + + let config: Config = wasm.query(&perp_address, &QueryMsg::Config {}).unwrap(); + let state_initial: State = wasm.query(&perp_address, &QueryMsg::State {}).unwrap(); + let mut vault_id = 0u64; + + assert_eq!(Decimal::one(), state_initial.normalisation_factor); + + env.set_oracle_price( + &wasm, + config.query_contract.to_string(), + env.base_pool_id, + Decimal::from_str("3000.0").unwrap(), + ); + env.set_oracle_price( + &wasm, + config.query_contract.to_string(), + env.power_pool_id, + Decimal::from_str("3030.0").unwrap(), + ); + + // should return true if vault does not exist + { + let is_safe: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(is_safe); + } + + // should return true if vault has no short + { + let mint_amount = Uint128::from(45_000_000u128); + let collateral_amount = Uint128::from(45_000_000u128); + + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: mint_amount, + + vault_id: None, + rebase: false, + }, + &[coin( + collateral_amount.into(), + env.denoms["base"].to_string(), + )], + &env.signer, + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + env.app.increase_time(1u64); + + wasm.execute( + &perp_address, + &ExecuteMsg::BurnPowerPerp { + amount_to_withdraw: None, + vault_id, + }, + &[coin(mint_amount.into(), env.denoms["power"].to_string())], + &env.signer, + ) + .unwrap(); + + let res: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(res); + } + + // should mint perfect amount + { + env.app.increase_time(1u64); + let mint_amount = Uint128::from(100_000_000u128); + let collateral_amount = Uint128::from(45_000_000u128); + let mint_response = wasm + .execute( + &perp_address, + &ExecuteMsg::MintPowerPerp { + amount: mint_amount, + vault_id: None, + rebase: false, + }, + &[coin( + collateral_amount.into(), + env.denoms["base"].to_string(), + )], + &env.signer, + ) + .unwrap(); + + vault_id = u64::from_str(&parse_event_attribute( + mint_response.events, + "wasm-mint", + "vault_id", + )) + .unwrap(); + + let res: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(res); + } + + // increase the price and make vault insolvent + { + env.set_oracle_price( + &wasm, + config.query_contract.to_string(), + env.base_pool_id, + Decimal::from_str("3010.0").unwrap(), + ); + + let res: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(!res); + } + + // Funding rate should make the vault solvent as time passes + { + env.set_oracle_price( + &wasm, + config.query_contract.to_string(), + env.base_pool_id, + Decimal::from_str("3030.0").unwrap(), + ); + + env.app.increase_time(89856); // 1.04 * 86400 (1.04 days in seconds) + + let res: bool = wasm + .query(&perp_address, &QueryMsg::CheckVault { vault_id }) + .unwrap(); + assert!(res); + } +} diff --git a/contracts/margined-power/src/vault.rs b/contracts/margined-power/src/vault.rs new file mode 100644 index 0000000..eb9c6db --- /dev/null +++ b/contracts/margined-power/src/vault.rs @@ -0,0 +1,542 @@ +use crate::{ + helpers::decimal_to_fixed, + queries::get_scaled_pool_twap, + state::{Config, CONFIG, TWAP_PERIOD}, +}; + +use cosmwasm_std::{ + ensure_eq, Addr, Decimal, Deps, StdError, StdResult, Storage, Timestamp, Uint128, +}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const COLLATERAL_RATIO_NUMERATOR: Decimal = Decimal::raw(3_000_000_000_000_000_000u128); // 3 +pub const COLLATERAL_RATIO_DENOMINATOR: Decimal = Decimal::raw(2_000_000_000_000_000_000u128); // 2 +pub const MIN_COLLATERAL: Decimal = Decimal::raw(500_000_000_000_000_000u128); // 0.5 + +pub const VAULTS: IndexedMap<&u64, Vault, VaultIndexes> = IndexedMap::new("vaults", INDEXES); +pub const VAULTS_COUNTER: Item = Item::new("vaults_counter"); + +pub const INDEXES: VaultIndexes<'_> = VaultIndexes { + owner: MultiIndex::new(vault_operator_idx, "vaults", "vault__owner"), +}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Vault { + pub operator: Addr, + pub collateral: Uint128, + pub short_amount: Uint128, +} + +pub struct VaultIndexes<'a> { + pub owner: MultiIndex<'a, Addr, Vault, u64>, +} + +impl<'a> IndexList for VaultIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.owner]; + Box::new(v.into_iter()) + } +} + +pub fn vault_operator_idx(_pk: &[u8], d: &Vault) -> Addr { + d.operator.clone() +} + +impl Vault { + pub fn new(operator: Addr) -> Self { + Vault { + operator, + collateral: Uint128::zero(), + short_amount: Uint128::zero(), + } + } + + pub fn is_empty(&self) -> bool { + self.collateral.is_zero() && self.short_amount.is_zero() + } +} + +pub fn check_vault( + deps: Deps, + config: Config, + vault_id: u64, + normalisation_factor: Decimal, + block_time: Timestamp, +) -> StdResult<(bool, bool)> { + get_vault_status(deps, config, vault_id, normalisation_factor, block_time) +} + +pub fn is_vault_safe( + deps: Deps, + config: Config, + vault_id: u64, + normalisation_factor: Decimal, + block_time: Timestamp, +) -> StdResult { + let (is_safe, _) = get_vault_status(deps, config, vault_id, normalisation_factor, block_time)?; + + Ok(is_safe) +} + +pub fn get_vault_status( + deps: Deps, + config: Config, + vault_id: u64, + normalisation_factor: Decimal, + block_time: Timestamp, +) -> StdResult<(bool, bool)> { + let start_time = block_time.minus_seconds(TWAP_PERIOD); + + let quote_price = get_scaled_pool_twap( + &deps, + config.base_pool.id, + config.base_denom.clone(), + config.base_pool.quote_denom, + start_time, + ) + .unwrap(); + + get_status(&deps, vault_id, normalisation_factor, quote_price) +} + +pub fn create_vault(storage: &mut dyn Storage, operator: Addr) -> StdResult { + let current_vault_count = VAULTS_COUNTER.load(storage).unwrap_or(0); + let nonce = current_vault_count + 1; + + let vault = Vault::new(operator); + + VAULTS.save(storage, &nonce, &vault)?; + VAULTS_COUNTER.save(storage, &nonce)?; + + Ok(nonce) +} + +pub fn update_vault( + storage: &mut dyn Storage, + vault_id: u64, + operator: Addr, + collateral: Uint128, + short_amount: Uint128, +) -> StdResult<()> { + let current_vault = VAULTS.load(storage, &vault_id).unwrap(); + + ensure_eq!( + operator, + current_vault.operator, + StdError::generic_err("operator does not match") + ); + + let vault = Vault { + operator, + collateral: current_vault.collateral + collateral, + short_amount: current_vault.short_amount + short_amount, + }; + + VAULTS.save(storage, &vault_id, &vault)?; + + Ok(()) +} + +pub fn check_can_burn( + storage: &dyn Storage, + vault_id: u64, + operator: Addr, + amount_to_burn: Uint128, + collateral_to_withdraw: Uint128, +) -> StdResult<()> { + let current_vault = VAULTS.load(storage, &vault_id).unwrap(); + + ensure_eq!( + operator, + current_vault.operator, + StdError::generic_err("operator does not match") + ); + + if collateral_to_withdraw > current_vault.collateral + || amount_to_burn > current_vault.short_amount + { + return Err(StdError::generic_err( + "Cannot burn more funds or collateral than in vault", + )); + } + + Ok(()) +} + +pub fn burn_vault( + storage: &mut dyn Storage, + vault_id: u64, + operator: Addr, + collateral: Uint128, + short_amount: Uint128, +) -> StdResult<()> { + let current_vault = VAULTS.load(storage, &vault_id).unwrap(); + + ensure_eq!( + operator, + current_vault.operator, + StdError::generic_err("operator does not match") + ); + + if collateral > current_vault.collateral || short_amount > current_vault.short_amount { + return Err(StdError::generic_err( + "Cannot burn more funds or collateral than in vault", + )); + } + + let vault = Vault { + operator, + collateral: current_vault.collateral - collateral, + short_amount: current_vault.short_amount - short_amount, + }; + + VAULTS.save(storage, &vault_id, &vault)?; + + Ok(()) +} + +pub fn add_collateral( + storage: &mut dyn Storage, + vault_id: u64, + operator: Addr, + collateral: Uint128, +) -> StdResult<()> { + let current_vault = VAULTS.load(storage, &vault_id).unwrap(); + + ensure_eq!( + operator, + current_vault.operator, + StdError::generic_err("operator does not match") + ); + + let vault = Vault { + operator, + collateral: current_vault.collateral + collateral, + short_amount: current_vault.short_amount, + }; + + VAULTS.save(storage, &vault_id, &vault)?; + + Ok(()) +} + +pub fn subtract_collateral( + storage: &mut dyn Storage, + vault_id: u64, + operator: Addr, + collateral: Uint128, +) -> StdResult<()> { + let current_vault = VAULTS.load(storage, &vault_id).unwrap(); + + ensure_eq!( + operator, + current_vault.operator, + StdError::generic_err("operator does not match") + ); + + if collateral > current_vault.collateral { + return Err(StdError::generic_err( + "Cannot subtract more collateral than deposited", + )); + } + + let vault = Vault { + operator, + collateral: current_vault.collateral - collateral, + short_amount: current_vault.short_amount, + }; + + VAULTS.save(storage, &vault_id, &vault)?; + + Ok(()) +} + +pub fn get_status( + deps: &Deps, + vault_id: u64, + normalisation_factor: Decimal, + quote_price: Decimal, +) -> StdResult<(bool, bool)> { + let config = CONFIG.load(deps.storage)?; + + let vault = VAULTS.may_load(deps.storage, &vault_id)?; + if vault.is_none() { + return Ok((true, false)); + } + + let vault = vault.unwrap(); + + Ok(calculate_status( + config.base_decimals, + config.power_decimals, + vault, + normalisation_factor, + quote_price, + )) +} + +fn calculate_status( + base_decimals: u32, + power_decimals: u32, + vault: Vault, + normalisation_factor: Decimal, + quote_price: Decimal, +) -> (bool, bool) { + let decimal_short_amount = Decimal::from_ratio( + vault.short_amount, + Uint128::from(10u128.pow(power_decimals)), + ); + + let debt_value = decimal_short_amount + .checked_mul(normalisation_factor) + .unwrap() + .checked_mul(quote_price) + .unwrap(); + + let decimal_collateral = + Decimal::from_ratio(vault.collateral, Uint128::from(10u128.pow(base_decimals))); + + let adjusted_collateral = decimal_collateral + .checked_mul(COLLATERAL_RATIO_DENOMINATOR) + .unwrap(); + let adjusted_debt = debt_value.checked_mul(COLLATERAL_RATIO_NUMERATOR).unwrap(); + + // Return to fixed point to remove rounding errors + let adjusted_collateral = decimal_to_fixed(adjusted_collateral, base_decimals); + let adjusted_debt = decimal_to_fixed(adjusted_debt, base_decimals); + + let min_collateral = decimal_to_fixed(MIN_COLLATERAL, base_decimals); + + let above_min_collateral = min_collateral <= vault.collateral; + let is_solvent = adjusted_collateral >= adjusted_debt; + + (is_solvent, above_min_collateral) +} + +#[cfg(test)] +mod tests { + use crate::vault::{calculate_status, Vault}; + + use cosmwasm_std::{Addr, Decimal}; + + const INDEX_SCALE_FACTOR: Decimal = Decimal::raw(10_000_000_000_000_000_000_000u128); // 10,000.0 + + #[test] + fn test_calculate_status() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 45_000_000u128.into(), // 45.0 + short_amount: 100_000_000u128.into(), // 100.0 + }; + + let normalization_factor = Decimal::from_atomics(1u128, 0u32).unwrap(); + + // price is 3000.0 + let scaled_quote_price = Decimal::from_atomics(3_000_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(solvent); + assert!(above_min_collateral); + } + + #[test] + fn test_calculate_status_price_doubles() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 45_000_000u128.into(), // 45.0 + short_amount: 100_000_000u128.into(), // 100.0 + }; + + let normalization_factor = Decimal::from_atomics(1u128, 0u32).unwrap(); + + // price is 6000.0 + let scaled_quote_price = Decimal::from_atomics(6_000_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(!solvent); + assert!(above_min_collateral); + } + + #[test] + fn test_calculate_status_price_halves() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 45_000_000u128.into(), // 45.0 + short_amount: 100_000_000u128.into(), // 100.0 + }; + + let normalization_factor = Decimal::from_atomics(1u128, 0u32).unwrap(); + + // price is 1500.0 + let scaled_quote_price = Decimal::from_atomics(1_500_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(solvent); + assert!(above_min_collateral); + } + + #[test] + fn test_calculate_status_normalization_factor_makes_vault_solvent() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 45_000_000u128.into(), // 45.0 + short_amount: 100_000_000u128.into(), // 100.0 + }; + + let normalization_factor = Decimal::from_atomics(750_000u128, 6u32).unwrap(); + + // price is 4000.0 + let scaled_quote_price = Decimal::from_atomics(4_000_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(solvent); + assert!(above_min_collateral); + } + + #[test] + fn test_calculate_status_vault_below_min_collateral() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 450_000u128.into(), // 0.45 + short_amount: 1_000_000u128.into(), // 1.0 + }; + + let normalization_factor = Decimal::from_atomics(750_000u128, 6u32).unwrap(); + + // price is 4000.0 + let scaled_quote_price = Decimal::from_atomics(4_000_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(solvent); + assert!(!above_min_collateral); + } + + #[test] + fn test_calculate_status_vault_below_min_collateral_and_insolvent() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 450_000u128.into(), // 0.45 + short_amount: 1_000_000u128.into(), // 1.0 + }; + + let normalization_factor = Decimal::from_atomics(750_000u128, 6u32).unwrap(); + + // price is 6000.0 + let scaled_quote_price = Decimal::from_atomics(6_000_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(!solvent); + assert!(!above_min_collateral); + } + + #[test] + fn test_calculate_status_worked_example() { + let base_decimals = 6u32; + let power_decimals = 6u32; + + let vault = Vault { + operator: Addr::unchecked(""), + collateral: 20_000_000u128.into(), // 20.0 + short_amount: 102_561_000u128.into(), // 10.0 + }; + + let normalization_factor = Decimal::from_atomics(1u128, 0u32).unwrap(); + + // price is 975.0 + let scaled_quote_price = Decimal::from_atomics(975_000_000u128, base_decimals) + .unwrap() + .checked_div(INDEX_SCALE_FACTOR) + .unwrap(); + + let (solvent, above_min_collateral) = calculate_status( + base_decimals, + power_decimals, + vault, + normalization_factor, + scaled_quote_price, + ); + + assert!(solvent); + assert!(above_min_collateral); + } +} diff --git a/contracts/margined-query/Cargo.toml b/contracts/margined-query/Cargo.toml new file mode 100644 index 0000000..4364a80 --- /dev/null +++ b/contracts/margined-query/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "margined-query" +version = "0.1.0" +authors = ["Friedrich Grabner "] +edition = "2021" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +margined-protocol = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +osmosis-test-tube = { workspace = true } +margined-testing = { workspace = true } diff --git a/contracts/margined-query/README.md b/contracts/margined-query/README.md new file mode 100644 index 0000000..1cc0f37 --- /dev/null +++ b/contracts/margined-query/README.md @@ -0,0 +1,3 @@ +# Margined Protocol Query + +This basically wraps some interaction with the native modules of the chain so that they can be more easily mocked, in comparison with having them as a library within the main codebase. diff --git a/contracts/margined-query/src/bin/query_schema.rs b/contracts/margined-query/src/bin/query_schema.rs new file mode 100644 index 0000000..b0982ea --- /dev/null +++ b/contracts/margined-query/src/bin/query_schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use margined_protocol::query::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/margined-query/src/contract.rs b/contracts/margined-query/src/contract.rs new file mode 100644 index 0000000..b73711f --- /dev/null +++ b/contracts/margined-query/src/contract.rs @@ -0,0 +1,56 @@ +use crate::query::{get_arithmetic_twap_now, get_denom_authority}; + +use cosmwasm_std::{ + entry_point, to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult, +}; +use cw2::set_contract_version; +use margined_protocol::query::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +// version info for migration info +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, +) -> StdResult { + set_contract_version( + deps.storage, + format!("crates.io:{CONTRACT_NAME}"), + CONTRACT_VERSION, + )?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: ExecuteMsg, +) -> StdResult { + unimplemented!() +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetArithmeticTwapToNow { + pool_id, + base_asset, + quote_asset, + start_time, + } => to_binary(&get_arithmetic_twap_now( + deps, + pool_id, + base_asset, + quote_asset, + start_time, + )?), + QueryMsg::GetDenomAuthority { denom } => to_binary(&get_denom_authority(deps, denom)?), + } +} diff --git a/contracts/margined-query/src/lib.rs b/contracts/margined-query/src/lib.rs new file mode 100644 index 0000000..e09757b --- /dev/null +++ b/contracts/margined-query/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +mod query; + +#[cfg(test)] +mod testing; diff --git a/contracts/margined-query/src/query.rs b/contracts/margined-query/src/query.rs new file mode 100644 index 0000000..e654f26 --- /dev/null +++ b/contracts/margined-query/src/query.rs @@ -0,0 +1,42 @@ +use cosmwasm_std::{Decimal, Deps, StdResult, Timestamp}; +use osmosis_std::{ + shim::Timestamp as OsmosisTimestamp, + types::osmosis::tokenfactory::v1beta1::{DenomAuthorityMetadata, TokenfactoryQuerier}, + types::osmosis::twap::v1beta1::TwapQuerier, +}; +use std::str::FromStr; + +pub fn get_arithmetic_twap_now( + deps: Deps, + pool_id: u64, + base_asset: String, + quote_asset: String, + start_time: Timestamp, +) -> StdResult { + let querier = TwapQuerier::new(&deps.querier); + + let start_time = OsmosisTimestamp { + seconds: start_time.seconds() as i64, + nanos: start_time.subsec_nanos() as i32, + }; + + let res = querier.arithmetic_twap_to_now(pool_id, base_asset, quote_asset, Some(start_time))?; + + let price = Decimal::from_str(&res.arithmetic_twap).unwrap(); + + Ok(price) +} + +pub fn get_denom_authority(deps: Deps, denom: String) -> StdResult> { + let querier = TokenfactoryQuerier::new(&deps.querier); + + let result = querier + .denom_authority_metadata(denom) + .unwrap() + .authority_metadata; + + match result { + Some(DenomAuthorityMetadata { admin }) => Ok(Some(admin)), + None => Ok(None), + } +} diff --git a/contracts/margined-query/src/testing/mod.rs b/contracts/margined-query/src/testing/mod.rs new file mode 100644 index 0000000..14f0038 --- /dev/null +++ b/contracts/margined-query/src/testing/mod.rs @@ -0,0 +1 @@ +mod tests; diff --git a/contracts/margined-query/src/testing/tests.rs b/contracts/margined-query/src/testing/tests.rs new file mode 100644 index 0000000..0f40bc9 --- /dev/null +++ b/contracts/margined-query/src/testing/tests.rs @@ -0,0 +1,29 @@ +use cosmwasm_std::Decimal; +use margined_protocol::query::QueryMsg; +use margined_testing::power_env::PowerEnv; +use osmosis_test_tube::{Module, Wasm}; +use std::str::FromStr; + +#[test] +fn test_instantiation_and_queries() { + let env = PowerEnv::new(); + + let wasm = Wasm::new(&env.app); + + let contract_address = env.deploy_query_contracts(&wasm, false); + + let cw_now = cosmwasm_std::Timestamp::from_nanos((env.app.get_block_time_nanos()) as u64); + + let index: Decimal = wasm + .query( + &contract_address, + &QueryMsg::GetArithmeticTwapToNow { + pool_id: 1, + base_asset: env.denoms["base"].clone(), + quote_asset: env.denoms["quote"].clone(), + start_time: cw_now, + }, + ) + .unwrap(); + assert_eq!(index, Decimal::from_str("3000.0").unwrap()); +} diff --git a/contracts/margined-staking/Cargo.toml b/contracts/margined-staking/Cargo.toml new file mode 100644 index 0000000..bc255f5 --- /dev/null +++ b/contracts/margined-staking/Cargo.toml @@ -0,0 +1,42 @@ +[package] +authors = [ "Friedrich Grabner " ] +edition = "2021" +name = "margined-staking" +version = "0.1.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = [ "cdylib", "rlib" ] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] +# use library feature to disable all instantiate/execute/query exports +library = [ ] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +margined-common = { workspace = true } +margined-protocol = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmrs = { workspace = true } +margined-testing = { workspace = true } +mock-query = { workspace = true } +osmosis-test-tube = { workspace = true } diff --git a/contracts/margined-staking/README.md b/contracts/margined-staking/README.md new file mode 100644 index 0000000..81944e3 --- /dev/null +++ b/contracts/margined-staking/README.md @@ -0,0 +1,3 @@ +# Margined Staking Contract + +The Margined Staking contract allows `$MRG` token holders to stake their token and earn a portion of the rewards generated through revenue of fees by protocol users. \ No newline at end of file diff --git a/contracts/margined-staking/src/bin/staking_schema.rs b/contracts/margined-staking/src/bin/staking_schema.rs new file mode 100644 index 0000000..ed23a84 --- /dev/null +++ b/contracts/margined-staking/src/bin/staking_schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use margined_protocol::staking::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/margined-staking/src/contract.rs b/contracts/margined-staking/src/contract.rs new file mode 100644 index 0000000..16fb921 --- /dev/null +++ b/contracts/margined-staking/src/contract.rs @@ -0,0 +1,133 @@ +use crate::{ + handle::{ + handle_claim, handle_pause, handle_stake, handle_unpause, handle_unstake, + handle_update_config, handle_update_rewards, + }, + query::{ + query_claimable, query_config, query_owner, query_state, query_total_staked_amount, + query_user_staked_amount, + }, + state::{ + Config, State, CONFIG, OWNER, OWNERSHIP_PROPOSAL, REWARDS_PER_TOKEN, STATE, TOTAL_STAKED, + }, +}; + +use cosmwasm_std::{ + entry_point, to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, + Uint128, +}; +use cw2::set_contract_version; +use margined_common::{ + common::check_denom_metadata, + errors::ContractError, + ownership::{ + get_ownership_proposal, handle_claim_ownership, handle_ownership_proposal, + handle_ownership_proposal_rejection, + }, +}; +use margined_protocol::staking::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +pub const INSTANTIATE_REPLY_ID: u64 = 1u64; + +// version info for migration info +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version( + deps.storage, + format!("crates.io:{CONTRACT_NAME}"), + CONTRACT_VERSION, + )?; + + CONFIG.save( + deps.storage, + &Config { + fee_collector: deps.api.addr_validate(&msg.fee_collector)?, + deposit_denom: msg.deposit_denom.clone(), + reward_denom: msg.reward_denom.clone(), + deposit_decimals: msg.deposit_decimals, + reward_decimals: msg.reward_decimals, + tokens_per_interval: msg.tokens_per_interval, + }, + )?; + + check_denom_metadata(deps.as_ref(), &msg.deposit_denom)?; + check_denom_metadata(deps.as_ref(), &msg.reward_denom)?; + + STATE.save( + deps.storage, + &State { + is_open: false, + last_distribution: env.block.time, + }, + )?; + + TOTAL_STAKED.save(deps.storage, &Uint128::zero())?; + REWARDS_PER_TOKEN.save(deps.storage, &Uint128::zero())?; + + OWNER.set(deps, Some(info.sender))?; + + Ok(Response::new().add_attribute("action", "instantiate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateConfig { + tokens_per_interval, + } => handle_update_config(deps, info, tokens_per_interval), + ExecuteMsg::UpdateRewards {} => handle_update_rewards(deps, env), + ExecuteMsg::Stake {} => handle_stake(deps, env, info), + ExecuteMsg::Unstake { amount } => handle_unstake(deps, env, info, amount), + ExecuteMsg::Claim { recipient } => handle_claim(deps, env, info, recipient), + ExecuteMsg::Pause {} => handle_pause(deps, info), + ExecuteMsg::Unpause {} => handle_unpause(deps, info), + ExecuteMsg::ProposeNewOwner { + new_owner, + duration, + } => handle_ownership_proposal( + deps, + info, + env, + new_owner, + duration, + OWNER, + OWNERSHIP_PROPOSAL, + ), + ExecuteMsg::RejectOwner {} => { + handle_ownership_proposal_rejection(deps, info, OWNER, OWNERSHIP_PROPOSAL) + } + ExecuteMsg::ClaimOwnership {} => { + handle_claim_ownership(deps, info, env, OWNER, OWNERSHIP_PROPOSAL) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_binary(&query_config(deps)?), + QueryMsg::State {} => to_binary(&query_state(deps)?), + QueryMsg::Owner {} => { + to_binary(&query_owner(deps).map_err(|err| StdError::generic_err(err.to_string()))?) + } + QueryMsg::GetClaimable { user } => to_binary(&query_claimable(deps, env, user)?), + QueryMsg::GetUserStakedAmount { user } => to_binary(&query_user_staked_amount(deps, user)?), + QueryMsg::GetTotalStakedAmount {} => to_binary(&query_total_staked_amount(deps)?), + QueryMsg::GetOwnershipProposal {} => { + to_binary(&get_ownership_proposal(deps, OWNERSHIP_PROPOSAL)?) + } + } +} diff --git a/contracts/margined-staking/src/distributor.rs b/contracts/margined-staking/src/distributor.rs new file mode 100644 index 0000000..a2731c0 --- /dev/null +++ b/contracts/margined-staking/src/distributor.rs @@ -0,0 +1,91 @@ +use crate::{ + helper::get_bank_balance, + query::query_pending_rewards, + state::{CONFIG, REWARDS_PER_TOKEN, STATE, TOTAL_STAKED, USER_STAKE}, +}; + +use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdResult, Storage, Uint128}; + +pub fn calculate_rewards(deps: Deps, env: Env) -> Uint128 { + let config = CONFIG.load(deps.storage).unwrap(); + + let block_rewards = query_pending_rewards(deps, env).unwrap(); + + let balance = get_bank_balance(deps, config.fee_collector.to_string(), config.reward_denom); + + block_rewards.min(balance) +} + +pub fn update_distribution_time(storage: &mut dyn Storage, env: Env) -> StdResult<()> { + STATE + .update(storage, |mut s| -> StdResult<_> { + s.last_distribution = env.block.time; + Ok(s) + }) + .unwrap(); + + Ok(()) +} + +pub fn update_rewards(deps: DepsMut, env: Env, account: Addr) -> StdResult<(DepsMut, Uint128)> { + let config = CONFIG.load(deps.storage).unwrap(); + let decimal_places = 10u128.pow(config.reward_decimals); + + let block_rewards = calculate_rewards(deps.as_ref(), env.clone()); + update_distribution_time(deps.storage, env.clone()).unwrap(); + + if block_rewards.is_zero() { + return Ok((deps, block_rewards)); + } + + let supply = TOTAL_STAKED.load(deps.storage)?; + + let mut cumulative_rewards = REWARDS_PER_TOKEN.load(deps.storage).unwrap(); + if !supply.is_zero() && !block_rewards.is_zero() { + cumulative_rewards = cumulative_rewards + .checked_add( + block_rewards + .checked_mul(decimal_places.into()) + .unwrap() + .checked_div(supply) + .unwrap(), + ) + .unwrap(); + REWARDS_PER_TOKEN.save(deps.storage, &cumulative_rewards)?; + } + + if account == env.contract.address { + return Ok((deps, block_rewards)); + } + + let mut user = USER_STAKE + .load(deps.storage, account.clone()) + .unwrap_or_default(); + + let delta_rewards = cumulative_rewards + .checked_sub(user.previous_cumulative_rewards_per_token) + .unwrap(); + + let account_reward = user + .staked_amounts + .checked_mul(delta_rewards) + .unwrap() + .checked_div(decimal_places.into()) + .unwrap(); + + user.claimable_rewards = user.claimable_rewards.checked_add(account_reward).unwrap(); + user.previous_cumulative_rewards_per_token = cumulative_rewards; + + if !user.claimable_rewards.is_zero() && !user.staked_amounts.is_zero() { + let next_cumulative_reward = user + .cumulative_rewards + .checked_add(user.claimable_rewards) + .unwrap(); + + user.cumulative_rewards = next_cumulative_reward; + } + + USER_STAKE.save(deps.storage, account, &user)?; + + Ok((deps, block_rewards)) +} diff --git a/contracts/margined-staking/src/handle.rs b/contracts/margined-staking/src/handle.rs new file mode 100644 index 0000000..2552e65 --- /dev/null +++ b/contracts/margined-staking/src/handle.rs @@ -0,0 +1,261 @@ +use crate::{ + distributor::update_rewards, + helper::create_distribute_message_and_update_response, + state::{UserStake, CONFIG, OWNER, STATE, TOTAL_STAKED, USER_STAKE}, +}; + +use cosmwasm_std::{ensure, DepsMut, Env, Event, MessageInfo, Response, StdResult, Uint128}; +use cw_utils::{must_pay, nonpayable}; +use margined_common::errors::ContractError; +use osmosis_std::types::{cosmos::bank::v1beta1::MsgSend, cosmos::base::v1beta1::Coin}; + +pub fn handle_update_config( + deps: DepsMut, + info: MessageInfo, + tokens_per_interval: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let event = Event::new("update_config"); + + if let Some(tokens_per_interval) = tokens_per_interval { + config.tokens_per_interval = tokens_per_interval; + + event + .clone() + .add_attribute("Tokens per interval", tokens_per_interval); + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default().add_event(event)) +} + +pub fn handle_update_rewards(deps: DepsMut, env: Env) -> Result { + let config = CONFIG.load(deps.storage)?; + + let (_, rewards) = update_rewards(deps, env.clone(), env.contract.address.clone())?; + + let response = create_distribute_message_and_update_response( + Response::new(), + config.fee_collector.to_string(), + config.reward_denom, + rewards, + env.contract.address.to_string(), + ) + .unwrap(); + + Ok(response.add_event(Event::new("update_rewards"))) +} + +pub fn handle_pause(deps: DepsMut, info: MessageInfo) -> Result { + let mut state = STATE.load(deps.storage)?; + + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + if !state.is_open { + return Err(ContractError::Paused {}); + } + state.is_open = false; + + STATE.save(deps.storage, &state)?; + + Ok(Response::default().add_event(Event::new("paused"))) +} + +pub fn handle_unpause(deps: DepsMut, info: MessageInfo) -> Result { + let mut state = STATE.load(deps.storage)?; + + ensure!( + OWNER.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + if state.is_open { + return Err(ContractError::NotPaused {}); + } + + state.is_open = true; + + STATE.save(deps.storage, &state)?; + + Ok(Response::default().add_event(Event::new("unpaused"))) +} + +pub fn handle_claim( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let state = STATE.load(deps.storage)?; + + let sender = info.sender.clone(); + + nonpayable(&info).map_err(|_| ContractError::InvalidFunds {})?; + + ensure!(state.is_open, ContractError::Paused {}); + + let recipient = match recipient { + Some(recipient) => { + deps.api.addr_validate(recipient.as_str())?; + recipient + } + None => sender.to_string(), + }; + + let (deps, rewards) = update_rewards(deps, env.clone(), sender.clone())?; + + let mut claimable_amount = Uint128::zero(); + USER_STAKE.update(deps.storage, sender.clone(), |res| -> StdResult<_> { + let mut stake = match res { + Some(stake) => stake, + None => UserStake::default(), + }; + + claimable_amount = stake.claimable_rewards; + stake.claimable_rewards = Uint128::zero(); + + Ok(stake) + })?; + + let mut response = create_distribute_message_and_update_response( + Response::new(), + config.fee_collector.to_string(), + config.reward_denom.clone(), + rewards, + env.contract.address.to_string(), + ) + .unwrap(); + + if !claimable_amount.is_zero() { + let msg_claim = MsgSend { + from_address: env.contract.address.to_string(), + to_address: recipient, + amount: vec![Coin { + denom: config.reward_denom, + amount: claimable_amount.into(), + }], + }; + response = response.add_message(msg_claim); + } + + Ok(response.add_event(Event::new("claim").add_attributes([ + ("amount", &claimable_amount.to_string()), + ("user", &sender.to_string()), + ]))) +} + +pub fn handle_stake(deps: DepsMut, env: Env, info: MessageInfo) -> Result { + let config = CONFIG.load(deps.storage)?; + let state = STATE.load(deps.storage)?; + + ensure!(state.is_open, ContractError::Paused {}); + + let sent_funds: Uint128 = + must_pay(&info, &config.deposit_denom).map_err(|_| ContractError::InvalidFunds {})?; + + let sender = info.sender; + + let (deps, rewards) = update_rewards(deps, env.clone(), sender.clone())?; + + USER_STAKE.update(deps.storage, sender.clone(), |res| -> StdResult<_> { + let mut stake = match res { + Some(stake) => stake, + None => UserStake::default(), + }; + + stake.staked_amounts = stake.staked_amounts.checked_add(sent_funds).unwrap(); + + Ok(stake) + })?; + + TOTAL_STAKED + .update(deps.storage, |balance| -> StdResult { + Ok(balance.checked_add(sent_funds).unwrap()) + }) + .unwrap(); + + let response = create_distribute_message_and_update_response( + Response::new(), + config.fee_collector.to_string(), + config.reward_denom, + rewards, + env.contract.address.to_string(), + ) + .unwrap(); + + Ok(response.add_event(Event::new("stake").add_attributes([ + ("amount", &sent_funds.to_string()), + ("user", &sender.to_string()), + ]))) +} + +pub fn handle_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let state = STATE.load(deps.storage)?; + + ensure!(state.is_open, ContractError::Paused {}); + + let sender = info.sender.clone(); + + nonpayable(&info).map_err(|_| ContractError::InvalidFunds {})?; + + let (deps, rewards) = update_rewards(deps, env.clone(), sender.clone())?; + + USER_STAKE.update(deps.storage, sender.clone(), |res| -> StdResult<_> { + let mut stake = match res { + Some(stake) => stake, + None => UserStake::default(), + }; + + stake.staked_amounts = stake.staked_amounts.checked_sub(amount).unwrap(); + + Ok(stake) + })?; + + TOTAL_STAKED + .update(deps.storage, |balance| -> StdResult { + Ok(balance.checked_sub(amount).unwrap()) + }) + .unwrap(); + + let response = create_distribute_message_and_update_response( + Response::new(), + config.fee_collector.to_string(), + config.reward_denom, + rewards, + env.contract.address.to_string(), + ) + .unwrap(); + + let msg_unstake = MsgSend { + from_address: env.contract.address.to_string(), + to_address: sender.to_string(), + amount: vec![Coin { + denom: config.deposit_denom, + amount: amount.into(), + }], + }; + + Ok(response + .add_message(msg_unstake) + .add_event(Event::new("unstake").add_attributes([ + ("amount", &amount.to_string()), + ("user", &sender.to_string()), + ]))) +} diff --git a/contracts/margined-staking/src/helper.rs b/contracts/margined-staking/src/helper.rs new file mode 100644 index 0000000..a40e6ec --- /dev/null +++ b/contracts/margined-staking/src/helper.rs @@ -0,0 +1,38 @@ +use cosmwasm_std::{to_binary, CosmosMsg, Deps, Response, StdResult, Uint128, WasmMsg}; +use margined_protocol::collector::ExecuteMsg as FeeExecuteMsg; +use osmosis_std::types::cosmos::bank::v1beta1::BankQuerier; +use std::str::FromStr; + +pub fn get_bank_balance(deps: Deps, address: String, denom: String) -> Uint128 { + let bank = BankQuerier::new(&deps.querier); + + match bank.balance(address, denom).unwrap().balance { + Some(balance) => Uint128::from_str(balance.amount.as_str()).unwrap(), + None => Uint128::zero(), + } +} + +pub fn create_distribute_message_and_update_response( + mut response: Response, + fee_collector: String, + token: String, + amount: Uint128, + recipient: String, +) -> StdResult { + if !amount.is_zero() { + let distribute_msg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: fee_collector, + msg: to_binary(&FeeExecuteMsg::SendToken { + token, + amount, + recipient, + }) + .unwrap(), + funds: vec![], + }); + + response = response.add_message(distribute_msg); + }; + + Ok(response) +} diff --git a/contracts/margined-staking/src/lib.rs b/contracts/margined-staking/src/lib.rs new file mode 100644 index 0000000..bbc84d3 --- /dev/null +++ b/contracts/margined-staking/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +pub mod distributor; +pub mod handle; +pub mod helper; +pub mod query; +pub mod state; + +#[cfg(test)] +mod testing; diff --git a/contracts/margined-staking/src/query.rs b/contracts/margined-staking/src/query.rs new file mode 100644 index 0000000..5219a25 --- /dev/null +++ b/contracts/margined-staking/src/query.rs @@ -0,0 +1,117 @@ +use crate::state::{CONFIG, OWNER, REWARDS_PER_TOKEN, STATE, TOTAL_STAKED, USER_STAKE}; + +use cosmwasm_std::{Addr, Deps, Env, StdResult, Uint128}; +use margined_common::errors::ContractError; +use margined_protocol::staking::{ + ConfigResponse, StateResponse, TotalStakedResponse, UserStakedResponse, +}; + +pub fn query_owner(deps: Deps) -> Result { + if let Some(owner) = OWNER.get(deps)? { + Ok(owner) + } else { + Err(ContractError::NoOwner {}) + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + + Ok(ConfigResponse { + fee_collector: config.fee_collector, + deposit_denom: config.deposit_denom, + deposit_decimals: config.deposit_decimals, + reward_denom: config.reward_denom, + reward_decimals: config.reward_decimals, + tokens_per_interval: config.tokens_per_interval, + }) +} + +pub fn query_state(deps: Deps) -> StdResult { + let state = STATE.load(deps.storage).unwrap(); + + Ok(StateResponse { + is_open: state.is_open, + last_distribution: state.last_distribution, + }) +} + +pub fn query_total_staked_amount(deps: Deps) -> StdResult { + let total_staked = TOTAL_STAKED.load(deps.storage)?; + + Ok(TotalStakedResponse { + amount: total_staked, + }) +} + +pub fn query_user_staked_amount(deps: Deps, address: String) -> StdResult { + let user = deps.api.addr_validate(&address)?; + let user_stake = USER_STAKE.may_load(deps.storage, user)?; + + match user_stake { + Some(stake) => Ok(UserStakedResponse { + staked_amounts: stake.staked_amounts, + claimable_rewards: stake.claimable_rewards, + previous_cumulative_rewards_per_token: stake.previous_cumulative_rewards_per_token, + cumulative_rewards: stake.cumulative_rewards, + }), + None => Ok(UserStakedResponse { + staked_amounts: Uint128::zero(), + claimable_rewards: Uint128::zero(), + previous_cumulative_rewards_per_token: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + }), + } +} + +pub fn query_pending_rewards(deps: Deps, env: Env) -> StdResult { + let state = STATE.load(deps.storage).unwrap(); + let config = CONFIG.load(deps.storage).unwrap(); + + if state.last_distribution == env.block.time { + return Ok(Uint128::zero()); + }; + + let delta = + Uint128::from((env.block.time.seconds() - state.last_distribution.seconds()) as u128); + + let pending_rewards = delta.checked_mul(config.tokens_per_interval).unwrap(); + + Ok(pending_rewards) +} + +pub fn query_claimable(deps: Deps, env: Env, address: String) -> StdResult { + let config = CONFIG.load(deps.storage).unwrap(); + let decimal_places = 10u128.pow(config.reward_decimals); + + let user = deps.api.addr_validate(&address)?; + + let stake = USER_STAKE.load(deps.storage, user).unwrap_or_default(); + if stake.staked_amounts.is_zero() { + return Ok(Uint128::zero()); + }; + + let pending_rewards = query_pending_rewards(deps, env)? + .checked_mul(decimal_places.into()) + .unwrap(); + + let total_staked = TOTAL_STAKED.load(deps.storage)?; + let rewards_per_token = REWARDS_PER_TOKEN.load(deps.storage)?; + + let next_reward_per_token = rewards_per_token + .checked_add(pending_rewards.checked_div(total_staked).unwrap()) + .unwrap(); + + let latest_rewards = stake + .staked_amounts + .checked_mul( + next_reward_per_token + .checked_sub(stake.previous_cumulative_rewards_per_token) + .unwrap(), + ) + .unwrap() + .checked_div(decimal_places.into()) + .unwrap(); + + Ok(stake.claimable_rewards.checked_add(latest_rewards).unwrap()) +} diff --git a/contracts/margined-staking/src/state.rs b/contracts/margined-staking/src/state.rs new file mode 100644 index 0000000..c59f075 --- /dev/null +++ b/contracts/margined-staking/src/state.rs @@ -0,0 +1,56 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Timestamp, Uint128}; +use cw_controllers::Admin; +use cw_storage_plus::{Item, Map}; +use margined_common::ownership::OwnerProposal; + +pub const OWNER: Admin = Admin::new("owner"); +pub const OWNERSHIP_PROPOSAL: Item = Item::new("ownership_proposals"); + +pub const CONFIG: Item = Item::new("config"); +pub const STATE: Item = Item::new("state"); + +pub const TOTAL_STAKED: Item = Item::new("total_staked"); +pub const REWARDS_PER_TOKEN: Item = Item::new("rewards_per_token"); +pub const USER_STAKE: Map = Map::new("staked_amounts"); + +#[cw_serde] +pub struct Config { + pub fee_collector: Addr, + pub deposit_denom: String, + pub deposit_decimals: u32, + pub reward_denom: String, + pub reward_decimals: u32, + pub tokens_per_interval: Uint128, +} + +#[cw_serde] +pub struct State { + pub is_open: bool, + pub last_distribution: Timestamp, +} + +#[cw_serde] +pub struct UserStake { + pub staked_amounts: Uint128, + pub claimable_rewards: Uint128, + pub previous_cumulative_rewards_per_token: Uint128, + pub cumulative_rewards: Uint128, +} + +impl Default for UserStake { + fn default() -> Self { + Self { + staked_amounts: Uint128::zero(), + claimable_rewards: Uint128::zero(), + previous_cumulative_rewards_per_token: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + } +} + +#[cw_serde] +pub struct Pool { + pub id: u64, + pub quote_denom: String, +} diff --git a/contracts/margined-staking/src/testing/execution_test.rs b/contracts/margined-staking/src/testing/execution_test.rs new file mode 100644 index 0000000..3c6e87d --- /dev/null +++ b/contracts/margined-staking/src/testing/execution_test.rs @@ -0,0 +1,548 @@ +use crate::state::{Config, State, UserStake}; + +use cosmwasm_std::{coin, Uint128}; +use margined_protocol::staking::{ExecuteMsg, QueryMsg, UserStakedResponse}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::{bank::v1beta1::MsgSend, base::v1beta1::Coin}, + Account, Bank, Module, Wasm, +}; + +const DEPOSIT_DENOM: &str = "umrg"; + +#[test] +fn test_unpause() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert!(!state.is_open); + + // cannot pause already paused + { + let err = wasm + .execute(&staking_address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Cannot perform action as contract is paused: execute wasm contract failed"); + } + + // cannot unpause if not owner + { + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Unpause {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Unauthorized: execute wasm contract failed"); + } + + // cannot stake if paused + { + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(1_000u128, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Cannot perform action as contract is paused: execute wasm contract failed"); + } + + // cannot unstake if paused + { + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: Uint128::zero(), + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Cannot perform action as contract is paused: execute wasm contract failed"); + } + + // cannot claim if paused + { + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Claim { recipient: None }, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Cannot perform action as contract is paused: execute wasm contract failed"); + } + + // should be able to unpause if owner + { + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + } + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert!(state.is_open); +} + +#[test] +fn test_pause() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + // should be able to unpause if owner + { + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + } + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert!(state.is_open); + + // cannot pause if not owner + { + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Pause {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Unauthorized: execute wasm contract failed"); + } + + // should be able to pause if owner + { + wasm.execute(&staking_address, &ExecuteMsg::Pause {}, &[], &env.signer) + .unwrap(); + } + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert!(!state.is_open); +} + +#[test] +fn test_update_config() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + let config_before: Config = wasm.query(&staking_address, &QueryMsg::Config {}).unwrap(); + + // should update config if owner + { + wasm.execute( + &staking_address, + &ExecuteMsg::UpdateConfig { + tokens_per_interval: Some(128u128.into()), + }, + &[], + &env.signer, + ) + .unwrap(); + + let config_after: Config = wasm.query(&staking_address, &QueryMsg::Config {}).unwrap(); + assert_eq!(Uint128::from(128u128), config_after.tokens_per_interval); + assert_ne!( + config_before.tokens_per_interval, + config_after.tokens_per_interval, + ); + } + + // returns error if not owner + { + wasm.execute( + &staking_address, + &ExecuteMsg::UpdateConfig { + tokens_per_interval: Some(128u128.into()), + }, + &[], + &env.traders[0], + ) + .unwrap_err(); + } +} + +#[test] +fn test_staking() { + let env = StakingEnv::new(); + + let bank = Bank::new(&env.app); + let wasm = Wasm::new(&env.app); + + let (staking_address, collector_address) = env.deploy_staking_contracts(&wasm); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: collector_address, + amount: [Coin { + amount: 1_000_000_000u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + // returns error with wrong asset + { + let amount_to_stake = 1_000_000u128; + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["reward"].to_string())], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Invalid funds: execute wasm contract failed"); + } + + // returns error with insufficient funds + { + let amount_to_stake = 1_000_000_000_000u128; + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: 1000000000umrg is smaller than 1000000000000umrg: insufficient funds"); + } + + // should be able to stake + { + let balance_before = + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, DEPOSIT_DENOM)], + &env.traders[0], + ) + .unwrap(); + + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + stake, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::zero(), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + ); + + let balance_after = + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()); + let staked_balance: UserStakedResponse = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + + assert_eq!( + balance_before - Uint128::from(amount_to_stake), + balance_after + ); + assert_eq!( + staked_balance.staked_amounts, + Uint128::from(amount_to_stake) + ); + } + + // account should be default before staking + { + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[1].address(), + }, + ) + .unwrap(); + assert_eq!(stake, UserStake::default()); + } +} + +#[test] +fn test_unstaking() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let (staking_address, _) = env.deploy_staking_contracts(&wasm); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, DEPOSIT_DENOM)], + &env.traders[0], + ) + .unwrap(); + + // returns error if tokens are sent + { + let amount_to_stake = 1_000u128; + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_stake.into(), + }, + &[coin(amount_to_stake, DEPOSIT_DENOM)], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Invalid funds: execute wasm contract failed"); + } + + // should unstake half + { + let balance_before = + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()); + let balance_before_staked: UserStakedResponse = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + + let amount_to_unstake = 500_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[0], + ) + .unwrap(); + + let balance_after = + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()); + let balance_after_staked: UserStakedResponse = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + + assert_eq!( + balance_before + Uint128::from(amount_to_unstake), + balance_after + ); + assert_eq!( + balance_before_staked.staked_amounts - Uint128::from(amount_to_unstake), + balance_after_staked.staked_amounts + ); + } +} + +#[test] +fn test_claim() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + let bank = Bank::new(&env.app); + + let (staking_address, collector_address) = env.deploy_staking_contracts(&wasm); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: collector_address, + amount: [Coin { + amount: 1_000_000_000u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, DEPOSIT_DENOM)], + &env.traders[0], + ) + .unwrap(); + + // should all be zero staking + { + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + stake, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::zero(), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + ); + } + + // returns error if tokens are sent + { + let amount = 1_000u128; + let err = wasm + .execute( + &staking_address, + &ExecuteMsg::Claim { recipient: None }, + &[coin(amount, DEPOSIT_DENOM)], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!(err.to_string(), "execute error: failed to execute message; message index: 0: Invalid funds: execute wasm contract failed"); + } + + env.app.increase_time(90u64); + + // should update distribution time + { + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + let previous_distribution_time = state.last_distribution; + + wasm.execute( + &staking_address, + &ExecuteMsg::UpdateRewards {}, + &[], + &env.traders[1], + ) + .unwrap(); + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + let distribution_time = state.last_distribution; + + assert_eq!( + distribution_time.seconds() - previous_distribution_time.seconds(), + 100u64 + ); + + // 100 seconds passed, 1 reward per second, 1_000_000 staked + // 100 * 1_000_000 * + let expected_claimable = Uint128::from(100_000_000u128); + let claimable_amount: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(claimable_amount, expected_claimable); + + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + stake, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::zero(), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + ); + } + + // does nothing except consume gas if user has nothing to claim + { + env.app.increase_time(1u64); + let balance_before = + env.get_balance(env.traders[1].address(), env.denoms["reward"].to_string()); + wasm.execute( + &staking_address, + &ExecuteMsg::Claim { recipient: None }, + &[], + &env.traders[1], + ) + .unwrap(); + + let balance_after = + env.get_balance(env.traders[1].address(), env.denoms["reward"].to_string()); + assert_eq!(balance_before, balance_after); + } + + // should claim all rewards + { + env.app.increase_time(1u64); + let balance_before = + env.get_balance(env.traders[0].address(), env.denoms["reward"].to_string()); + let expected_claimable = Uint128::from(112_000_000u128); + + wasm.execute( + &staking_address, + &ExecuteMsg::Claim { recipient: None }, + &[], + &env.traders[0], + ) + .unwrap(); + + let balance_after = + env.get_balance(env.traders[0].address(), env.denoms["reward"].to_string()); + + assert_eq!(balance_before + expected_claimable, balance_after); + } +} diff --git a/contracts/margined-staking/src/testing/instantiation_test.rs b/contracts/margined-staking/src/testing/instantiation_test.rs new file mode 100644 index 0000000..f2cc257 --- /dev/null +++ b/contracts/margined-staking/src/testing/instantiation_test.rs @@ -0,0 +1,44 @@ +use crate::state::{Config, State}; + +use cosmwasm_std::{Addr, Timestamp}; +use margined_protocol::staking::QueryMsg; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{Account, Module, Wasm}; + +const DEPOSIT_DENOM: &str = "umrg"; +const REWARD_DENOM: &str = "uusdc"; + +#[test] +fn test_instantiation() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + let config: Config = wasm.query(&staking_address, &QueryMsg::Config {}).unwrap(); + assert_eq!( + config, + Config { + fee_collector: Addr::unchecked(env.signer.address()), + deposit_denom: DEPOSIT_DENOM.to_string(), + deposit_decimals: 6u32, + reward_denom: REWARD_DENOM.to_string(), + reward_decimals: 6u32, + tokens_per_interval: 1_000_000u128.into(), + } + ); + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: false, + last_distribution: Timestamp::from_nanos(env.app.get_block_time_nanos() as u64), + } + ); + + let owner: Addr = wasm.query(&staking_address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, Addr::unchecked(env.signer.address())); +} diff --git a/contracts/margined-staking/src/testing/integration_test.rs b/contracts/margined-staking/src/testing/integration_test.rs new file mode 100644 index 0000000..6bfff55 --- /dev/null +++ b/contracts/margined-staking/src/testing/integration_test.rs @@ -0,0 +1,300 @@ +use crate::state::{Config, UserStake}; + +use cosmwasm_std::{coin, Uint128}; +use margined_protocol::staking::{ExecuteMsg, QueryMsg, UserStakedResponse}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::{bank::v1beta1::MsgSend, base::v1beta1::Coin}, + Account, Bank, Module, Wasm, +}; + +#[test] +fn test_stake_unstake_claim() { + let env = StakingEnv::new(); + + let bank = Bank::new(&env.app); + let wasm = Wasm::new(&env.app); + + let (staking_address, collector_address) = env.deploy_staking_contracts(&wasm); + + // fund the fee collector + { + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: collector_address, + amount: [Coin { + amount: 1_000_000_000_000u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + } + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + // update tokens per interval + { + let new_tokens_per_interval = 20_668u128; // 0.020668@6dp esTOKEN per second + wasm.execute( + &staking_address, + &ExecuteMsg::UpdateConfig { + tokens_per_interval: Some(new_tokens_per_interval.into()), + }, + &[], + &env.signer, + ) + .unwrap(); + + let config: Config = wasm.query(&staking_address, &QueryMsg::Config {}).unwrap(); + assert_eq!( + config.tokens_per_interval, + Uint128::from(new_tokens_per_interval) + ); + } + + // stake then increase time by one day + { + let amount_to_stake = 1_000_000_000u128; // 1,000@6dp esTOKEN + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap(); + + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + stake, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::zero(), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + ); + + env.app.increase_time(24 * 60 * 60); + + let claimable: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(claimable, Uint128::from(1_785_715_000u128)); + } + + // stake then increase time by one day + { + let amount_to_stake = 500_000_000u128; // 500@6dp esTOKEN + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[1], + ) + .unwrap(); + + // check trader 0 + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(stake.staked_amounts, Uint128::from(1_000_000_000u128),); + + // check trader 1 + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[1].address(), + }, + ) + .unwrap(); + assert_eq!(stake.staked_amounts, Uint128::from(500_000_000u128),); + + env.app.increase_time(24 * 60 * 60); + + // check claimable + { + let claimable: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + claimable, + Uint128::from(1_785_715_000u128 + 1_190_579_000u128) + ); + + let claimable: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[1].address(), + }, + ) + .unwrap(); + assert_eq!(claimable, Uint128::from(595_238_000u128)); + } + + // unstake reverts + { + let amount_to_unstake = 1_000_000_001u128; // 1000.000001@6dp stakedTOKEN + let res = wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[0], + ); + assert!(res.is_err()); + + let amount_to_unstake = 500_000_001u128; // 500.000001@6dp stakedTOKEN + let res = wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[1], + ); + assert!(res.is_err()); + } + + // unstake successfully and check user stake + { + assert_eq!( + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()), + Uint128::zero() + ); + + let amount_to_unstake = 1_000_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[0], + ) + .unwrap(); + + assert_eq!( + env.get_balance(env.traders[0].address(), env.denoms["deposit"].to_string()), + Uint128::from(amount_to_unstake) + ); + + let stake: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + stake, + UserStake { + staked_amounts: Uint128::zero(), + previous_cumulative_rewards_per_token: Uint128::from(2_976_501u128), + claimable_rewards: Uint128::from(2_976_501_000u128), + cumulative_rewards: Uint128::from(2_976_501_000u128), + } + ); + } + + // unstake reverts + { + let amount_to_unstake = 1u128; + let res = wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[0], + ); + assert!(res.is_err()); + } + + // claim and check user balance + { + let user_balance: UserStakedResponse = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(user_balance.staked_amounts, Uint128::zero()); + + wasm.execute( + &staking_address, + &ExecuteMsg::Claim { + recipient: Some(env.empty.address()), + }, + &[], + &env.traders[0], + ) + .unwrap(); + + assert_eq!( + env.get_balance(env.empty.address(), env.denoms["reward"].to_string()), + Uint128::from(2_976_501_000u128) + ); + } + + env.app.increase_time(24 * 60 * 60); + + // check claimable + { + let claimable: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(claimable, Uint128::zero()); + + let claimable: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[1].address(), + }, + ) + .unwrap(); + assert_eq!( + claimable, + Uint128::from(595_238_000u128 + 1_786_025_000u128) + ); + } + } +} diff --git a/contracts/margined-staking/src/testing/mod.rs b/contracts/margined-staking/src/testing/mod.rs new file mode 100644 index 0000000..ea5d39f --- /dev/null +++ b/contracts/margined-staking/src/testing/mod.rs @@ -0,0 +1,6 @@ +#[cfg(test)] +mod execution_test; +mod instantiation_test; +mod integration_test; +mod ownership_test; +mod query_test; diff --git a/contracts/margined-staking/src/testing/ownership_test.rs b/contracts/margined-staking/src/testing/ownership_test.rs new file mode 100644 index 0000000..94c3e33 --- /dev/null +++ b/contracts/margined-staking/src/testing/ownership_test.rs @@ -0,0 +1,156 @@ +use cosmwasm_std::Addr; +use margined_protocol::staking::{ExecuteMsg, OwnerProposalResponse, QueryMsg}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{Account, Module, RunnerError, Wasm}; + +const PROPOSAL_DURATION: u64 = 1000; + +#[test] +fn test_update_owner_staking() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let fee_collector = env.deploy_fee_collector_contract(&wasm, "margined-collector".to_string()); + let staking = env.deploy_staking_contract(&wasm, "margined-staking".to_string(), fee_collector); + + // claim before a proposal is made + { + let err = wasm + .execute(&staking, &ExecuteMsg::ClaimOwnership {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Proposal not found: execute wasm contract failed".to_string() + } + ); + } + + // propose new owner + wasm.execute( + &staking, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&staking, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // reject claim by incorrect new owner + { + let err = wasm + .execute(&staking, &ExecuteMsg::ClaimOwnership {}, &[], &env.signer) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // let proposal expire + env.app.increase_time(PROPOSAL_DURATION + 1); + + // proposal fails due to expiry + { + let err = wasm + .execute( + &staking, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Expired: execute wasm contract failed".to_string() + } + ); + } + + let owner: Addr = wasm.query(&staking, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // propose new owner + wasm.execute( + &staking, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let owner: Addr = wasm.query(&staking, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // proposal fails due to expiry + { + let err = wasm + .execute(&staking, &ExecuteMsg::RejectOwner {}, &[], &env.traders[0]) + .unwrap_err(); + assert_eq!( + err, + RunnerError::ExecuteError { + msg: "failed to execute message; message index: 0: Unauthorized: execute wasm contract failed".to_string() + } + ); + } + + // proposal fails due to expiry + { + wasm.execute(&staking, &ExecuteMsg::RejectOwner {}, &[], &env.signer) + .unwrap(); + } + + // propose new owner + wasm.execute( + &staking, + &ExecuteMsg::ProposeNewOwner { + new_owner: env.traders[0].address(), + duration: PROPOSAL_DURATION, + }, + &[], + &env.signer, + ) + .unwrap(); + + let block_time = env.app.get_block_time_seconds(); + + let owner: Addr = wasm.query(&staking, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.signer.address()); + + // query ownership proposal + { + let proposal: OwnerProposalResponse = wasm + .query(&staking, &QueryMsg::GetOwnershipProposal {}) + .unwrap(); + + assert_eq!(proposal.owner, env.traders[0].address()); + assert_eq!(proposal.expiry, block_time as u64 + PROPOSAL_DURATION); + } + + // claim ownership + { + wasm.execute( + &staking, + &ExecuteMsg::ClaimOwnership {}, + &[], + &env.traders[0], + ) + .unwrap(); + } + + let owner: Addr = wasm.query(&staking, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, env.traders[0].address()); +} diff --git a/contracts/margined-staking/src/testing/query_test.rs b/contracts/margined-staking/src/testing/query_test.rs new file mode 100644 index 0000000..0dde9fe --- /dev/null +++ b/contracts/margined-staking/src/testing/query_test.rs @@ -0,0 +1,281 @@ +use crate::state::{Config, State, UserStake}; + +use cosmwasm_std::{coin, Addr, Timestamp, Uint128}; +use margined_protocol::staking::{ExecuteMsg, QueryMsg, TotalStakedResponse}; +use margined_testing::staking_env::StakingEnv; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::{bank::v1beta1::MsgSend, base::v1beta1::Coin}, + Account, Bank, Module, Wasm, +}; + +#[test] +fn test_query_config() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + let config: Config = wasm.query(&staking_address, &QueryMsg::Config {}).unwrap(); + assert_eq!( + config, + Config { + fee_collector: Addr::unchecked(env.signer.address()), + deposit_denom: env.denoms["deposit"].to_string(), + deposit_decimals: 6u32, + reward_denom: env.denoms["reward"].to_string(), + reward_decimals: 6u32, + tokens_per_interval: 1_000_000u128.into(), + } + ); +} + +#[test] +fn test_query_state() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + let state: State = wasm.query(&staking_address, &QueryMsg::State {}).unwrap(); + assert_eq!( + state, + State { + is_open: false, + last_distribution: Timestamp::from_nanos(env.app.get_block_time_nanos() as u64), + } + ); +} + +#[test] +fn test_query_owner() { + let env = StakingEnv::new(); + + let wasm = Wasm::new(&env.app); + + let staking_address = + env.deploy_staking_contract(&wasm, "margined-staking".to_string(), env.signer.address()); + + let owner: Addr = wasm.query(&staking_address, &QueryMsg::Owner {}).unwrap(); + assert_eq!(owner, Addr::unchecked(env.signer.address())); +} + +#[test] +fn test_query_get_claimable() { + let env = StakingEnv::new(); + + let bank = Bank::new(&env.app); + let wasm = Wasm::new(&env.app); + + let (staking_address, _) = env.deploy_staking_contracts(&wasm); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: staking_address.clone(), + amount: [Coin { + amount: 10u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + + let amount: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(amount, Uint128::zero()); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap(); + + env.app.increase_time(5u64); + + let amount: Uint128 = wasm + .query( + &staking_address, + &QueryMsg::GetClaimable { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(amount, Uint128::from(5_000_000u128)); +} + +#[test] +fn test_query_get_user_staked_amount() { + let env = StakingEnv::new(); + + let bank = Bank::new(&env.app); + let wasm = Wasm::new(&env.app); + + let (staking_address, collector_address) = env.deploy_staking_contracts(&wasm); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: collector_address, + amount: [Coin { + amount: 1_000_000_000u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + + let amount: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!(amount, UserStake::default()); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap(); + + env.app.increase_time(5u64); + + let amount: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + amount, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::zero(), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::zero(), + } + ); + + wasm.execute( + &staking_address, + &ExecuteMsg::Claim { recipient: None }, + &[], + &env.traders[0], + ) + .unwrap(); + + let amount: UserStake = wasm + .query( + &staking_address, + &QueryMsg::GetUserStakedAmount { + user: env.traders[0].address(), + }, + ) + .unwrap(); + assert_eq!( + amount, + UserStake { + staked_amounts: amount_to_stake.into(), + previous_cumulative_rewards_per_token: Uint128::from(10_000_000u128), + claimable_rewards: Uint128::zero(), + cumulative_rewards: Uint128::from(10_000_000u128), + } + ); +} + +#[test] +fn test_query_get_total_staked_amount() { + let env = StakingEnv::new(); + + let bank = Bank::new(&env.app); + let wasm = Wasm::new(&env.app); + + let (staking_address, collector_address) = env.deploy_staking_contracts(&wasm); + + bank.send( + MsgSend { + from_address: env.signer.address(), + to_address: collector_address, + amount: [Coin { + amount: 1_000_000_000u128.to_string(), + denom: env.denoms["reward"].to_string(), + }] + .to_vec(), + }, + &env.signer, + ) + .unwrap(); + + let res: TotalStakedResponse = wasm + .query(&staking_address, &QueryMsg::GetTotalStakedAmount {}) + .unwrap(); + assert_eq!(res.amount, Uint128::zero()); + + wasm.execute(&staking_address, &ExecuteMsg::Unpause {}, &[], &env.signer) + .unwrap(); + + let amount_to_stake = 1_000_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Stake {}, + &[coin(amount_to_stake, env.denoms["deposit"].to_string())], + &env.traders[0], + ) + .unwrap(); + + env.app.increase_time(5u64); + + let res: TotalStakedResponse = wasm + .query(&staking_address, &QueryMsg::GetTotalStakedAmount {}) + .unwrap(); + assert_eq!(res.amount, Uint128::from(amount_to_stake)); + + let amount_to_unstake = 500_000u128; + wasm.execute( + &staking_address, + &ExecuteMsg::Unstake { + amount: amount_to_unstake.into(), + }, + &[], + &env.traders[0], + ) + .unwrap(); + + let res: TotalStakedResponse = wasm + .query(&staking_address, &QueryMsg::GetTotalStakedAmount {}) + .unwrap(); + assert_eq!( + res.amount, + Uint128::from(amount_to_stake - amount_to_unstake) + ); +} diff --git a/contracts/mocks/mock-query/Cargo.toml b/contracts/mocks/mock-query/Cargo.toml new file mode 100644 index 0000000..664b144 --- /dev/null +++ b/contracts/mocks/mock-query/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors = [ "Margined Protocol" ] +edition = "2021" +name = "mock-query" +version = "0.1.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = [ "cdylib", "rlib" ] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] +# use library feature to disable all instantiate/execute/query exports +library = [ ] + +[dependencies] +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +margined-protocol = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } diff --git a/contracts/mocks/mock-query/README.md b/contracts/mocks/mock-query/README.md new file mode 100644 index 0000000..ecf7fd6 --- /dev/null +++ b/contracts/mocks/mock-query/README.md @@ -0,0 +1,3 @@ +# Mock Price Feed + +This contract is simply a dummy or mock price feed with no TWAP logic used for testing. diff --git a/contracts/mocks/mock-query/src/contract.rs b/contracts/mocks/mock-query/src/contract.rs new file mode 100644 index 0000000..1bbdac5 --- /dev/null +++ b/contracts/mocks/mock-query/src/contract.rs @@ -0,0 +1,99 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::{ + entry_point, to_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, + StdResult, +}; +use cw_storage_plus::Map; +use margined_protocol::query::QueryMsg; +use osmosis_std::types::osmosis::tokenfactory::v1beta1::{ + DenomAuthorityMetadata, TokenfactoryQuerier, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const KEY_PRICES: Map = Map::new("prices"); + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct InstantiateMsg {} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + AppendPrice { pool_id: u64, price: Decimal }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct ConfigResponse { + pub owner: Addr, +} + +#[cfg(not(tarpaulin_include))] +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, +) -> StdResult { + Ok(Response::default()) +} + +#[cfg(not(tarpaulin_include))] +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + match msg { + ExecuteMsg::AppendPrice { pool_id, price } => append_price(deps, info, pool_id, price), + } +} + +/// this is a mock function that enables storage of data +/// by the contract owner will be replaced by integration +/// with on-chain price oracles in the future. +#[cfg(not(tarpaulin_include))] +pub fn append_price( + deps: DepsMut, + _info: MessageInfo, + pool_id: u64, + price: Decimal, +) -> StdResult { + KEY_PRICES.save(deps.storage, pool_id, &price)?; + + Ok(Response::default()) +} + +#[cfg(not(tarpaulin_include))] +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetArithmeticTwapToNow { pool_id, .. } => { + to_binary(&get_arithmetic_twap_now(deps, pool_id)?) + } + QueryMsg::GetDenomAuthority { denom } => to_binary(&get_denom_authority(deps, denom)?), + } +} + +/// Queries latest price for pair stored with key +#[cfg(not(tarpaulin_include))] +pub fn get_arithmetic_twap_now(deps: Deps, pool_id: u64) -> StdResult { + KEY_PRICES.load(deps.storage, pool_id) +} + +#[cfg(not(tarpaulin_include))] +pub fn get_denom_authority(deps: Deps, denom: String) -> StdResult> { + let querier = TokenfactoryQuerier::new(&deps.querier); + + let result = querier + .denom_authority_metadata(denom) + .unwrap() + .authority_metadata; + + match result { + Some(DenomAuthorityMetadata { admin }) => Ok(Some(admin)), + None => Ok(None), + } +} diff --git a/contracts/mocks/mock-query/src/lib.rs b/contracts/mocks/mock-query/src/lib.rs new file mode 100644 index 0000000..cb312db --- /dev/null +++ b/contracts/mocks/mock-query/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; + +#[cfg(test)] +mod testing; diff --git a/contracts/mocks/mock-query/src/testing/mod.rs b/contracts/mocks/mock-query/src/testing/mod.rs new file mode 100644 index 0000000..1c71331 --- /dev/null +++ b/contracts/mocks/mock-query/src/testing/mod.rs @@ -0,0 +1 @@ +// mod tests; diff --git a/contracts/mocks/mock-query/src/testing/tests.rs b/contracts/mocks/mock-query/src/testing/tests.rs new file mode 100644 index 0000000..2423d0c --- /dev/null +++ b/contracts/mocks/mock-query/src/testing/tests.rs @@ -0,0 +1,73 @@ +use crate::contract::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +use cosmwasm_std::{coin, Decimal}; +use margined_testing::setup::Setup; +use osmosis_test_tube::{Module, OsmosisTestApp, SigningAccount, Wasm}; +use std::str::FromStr; + +#[test] +fn test_instantiation_and_query() { + let Setup { + app, + signer, + base_pool_id, + .. // other fields + } = Setup::new(); + + let wasm = Wasm::new(&app); + + // let timestamp_now = app.get_block_time_seconds(); + // let start_time = timestamp_now - 1i64; + + let code_id = store_code(&wasm, &signer, "mock_query".to_string()); + let address = wasm + .instantiate( + code_id, + &InstantiateMsg {}, + None, + Some("mock-query-contract"), + &[coin(10_000_000, "uosmo")], + &signer, + ) + .unwrap() + .data + .address; + + let pool_id = 1u64; + let price = Decimal::from_str("1.5").unwrap(); + wasm.execute( + &address, + &ExecuteMsg::AppendPrice { pool_id, price }, + &[], + &signer, + ) + .unwrap(); + + let price: Decimal = wasm + .query( + &address, + &QueryMsg::GetArithmeticTwapToNow { + pool_id: base_pool_id, + base_asset: "uosmo".to_string(), + quote_asset: "usdc".to_string(), + start_time: start_time, + }, + ) + .unwrap(); + assert_eq!(price, Decimal::from_str("1.5").unwrap()); +} + +fn wasm_file(contract_name: String) -> String { + let artifacts_dir = + std::env::var("ARTIFACTS_DIR_PATH").unwrap_or_else(|_| "artifacts".to_string()); + let snaked_name = contract_name.replace('-', "_"); + format!("../../../{artifacts_dir}/{snaked_name}-aarch64.wasm") +} + +fn store_code(wasm: &Wasm, owner: &SigningAccount, contract_name: String) -> u64 { + let wasm_byte_code = std::fs::read(wasm_file(contract_name)).unwrap(); + wasm.store_code(&wasm_byte_code, None, owner) + .unwrap() + .data + .code_id +} diff --git a/packages/margined_common/Cargo.toml b/packages/margined_common/Cargo.toml new file mode 100644 index 0000000..fd5a986 --- /dev/null +++ b/packages/margined_common/Cargo.toml @@ -0,0 +1,24 @@ +[package] +authors = [ "Margined Protocol" ] +description = "Common package used by all margined contracts" +edition = "2021" +name = "margined-common" +version = "0.1.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +# for quicker tests, cargo test --lib +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-storage-plus = { workspace = true } +osmosis-std = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/packages/margined_common/src/common.rs b/packages/margined_common/src/common.rs new file mode 100644 index 0000000..8597ec8 --- /dev/null +++ b/packages/margined_common/src/common.rs @@ -0,0 +1,117 @@ +use crate::errors::ContractError; +use cosmwasm_std::{ + Binary, Coin, Decimal, Deps, Event, MessageInfo, StdError, StdResult, SubMsgResponse, + SubMsgResult, Uint128, +}; +use osmosis_std::types::{ + cosmos::bank::v1beta1::BankQuerier, osmosis::poolmanager::v1beta1::PoolmanagerQuerier, +}; + +pub fn parse_funds(funds: Vec, expected_denom: String) -> StdResult { + if funds.is_empty() { + return Ok(Uint128::zero()); + }; + + if funds.len() != 1 || funds[0].denom != expected_denom { + return Err(StdError::generic_err("Invalid Funds")); + } + + Ok(funds[0].amount) +} + +pub fn check_denom_metadata(deps: Deps, denom: &str) -> StdResult<()> { + let querier = BankQuerier::new(&deps.querier); + + querier.denom_metadata(denom.to_string())?; + + Ok(()) +} + +pub fn check_denom_exists_in_pool(deps: Deps, pool_id: u64, denom: &str) -> StdResult<()> { + let querier = PoolmanagerQuerier::new(&deps.querier); + + let res = querier.total_pool_liquidity(pool_id)?; + + if res.liquidity.is_empty() { + return Err(StdError::generic_err(format!( + "No liquidity in pool id: {}", + pool_id + ))); + } + + res.liquidity + .iter() + .find(|x| x.denom == denom) + .ok_or_else(|| { + StdError::generic_err(format!("Denom \"{}\" in pool id: {}", denom, pool_id)) + })?; + + Ok(()) +} + +pub fn decimal_to_fixed(value: Decimal, decimal_places: u32) -> Uint128 { + value + .atomics() + .checked_div(Uint128::new( + 10u128.pow(Decimal::DECIMAL_PLACES - decimal_places), + )) + .unwrap() +} + +pub fn parse_event_attribute(events: Vec, event: &str, key: &str) -> String { + events + .iter() + .find(|e| e.ty == event) + .unwrap() + .attributes + .iter() + .find(|e| e.key == key) + .unwrap() + .value + .clone() +} + +pub fn parse_response_result_data(result: SubMsgResult) -> Result { + match result { + SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) => Ok(b), + SubMsgResult::Ok(SubMsgResponse { data: None, .. }) => { + Err(ContractError::SubMsgError("No data in reply".to_string())) + } + SubMsgResult::Err(err) => Err(ContractError::SubMsgError(err)), + } +} + +pub fn must_pay_two_denoms( + info: &MessageInfo, + first_denom: &str, + second_denom: &str, +) -> Result<(Uint128, Uint128), String> { + if info.funds.is_empty() { + Err("No funds sent".to_string()) + } else if info.funds.len() == 1 && info.funds[0].denom == first_denom { + Err(format!("Missing denom: {}", second_denom)) + } else if info.funds.len() == 1 && info.funds[0].denom == second_denom { + Err(format!("Missing denom: {}", first_denom)) + } else if info.funds.len() == 2 { + let base = match info.funds.iter().find(|c| c.denom == first_denom) { + Some(c) => c, + None => return Err(format!("Missing denom: {}", first_denom)), + }; + + let quote = match info.funds.iter().find(|c| c.denom == second_denom) { + Some(c) => c, + None => return Err(format!("Missing denom: {}", second_denom)), + }; + + Ok((base.amount, quote.amount)) + } else { + // find first mis-match + let wrong = info + .funds + .iter() + .find(|c| c.denom != first_denom && c.denom != second_denom) + .unwrap(); + + Err(format!("Extra incorrect denom: {}", wrong.denom)) + } +} diff --git a/packages/margined_common/src/errors.rs b/packages/margined_common/src/errors.rs new file mode 100644 index 0000000..6ea145c --- /dev/null +++ b/packages/margined_common/src/errors.rs @@ -0,0 +1,119 @@ +use cosmwasm_std::{StdError, Uint128}; +use cw_controllers::AdminError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Admin(#[from] AdminError), + + #[error("Vault is below minimum collateral amount (0.5 base denom)")] + BelowMinCollateralAmount {}, + + #[error("Strategy denom not initialised")] + DenomNotInitialized {}, + + #[error("Zero Division Error")] + DivideByZero {}, + + #[error("Expired")] + Expired {}, + + #[error("Event '{0}' not found")] + EventNotFound(String), + + #[error("Invalid funds")] + InvalidFunds {}, + + #[error("Invalid liquidation")] + InvalidLiquidation {}, + + #[error("Invalid denom {0} not found")] + InvalidDenom(String), + + #[error("Contract is already open")] + IsOpen {}, + + #[error("Invalid duration cannot be greater than {0}")] + InvalidDuration(u64), + + #[error("Invalid ownership, new owner cannot be the same as existing")] + InvalidOwnership {}, + + #[error("Invalid reply id")] + InvalidReplyId, + + #[error("Insufficient balance")] + InsufficientBalance {}, + + #[error("Insufficient denom {0}. {1} required")] + InsufficientPower(String, Uint128), + + #[error("Non-payable entry point")] + NonPayable {}, + + #[error("Unpause delay not expired")] + NotExpired {}, + + #[error("Cannot perform action as contract is not open")] + NotOpen {}, + + #[error("Invalid denom {0} not found in pool {1}")] + NotFoundInPool(String, String), + + #[error("Owner not set")] + NoOwner {}, + + #[error("Contract is not paused")] + NotPaused {}, + + #[error("Contract is not admin of the power token")] + NotTokenAdmin {}, + + #[error("Proposal not found")] + ProposalNotFound {}, + + #[error("Cannot perform action as contract is paused")] + Paused {}, + + #[error("Vault is safe, cannot be liquidated")] + SafeVault {}, + + #[error("Error in submessage: '{0}'")] + SubMsgError(String), + + #[error("Strategy Cap Exceeded")] + StrategyCapExceeded {}, + + #[error("Token denom '{0}' is not supported")] + TokenUnsupported(String), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Unknown reply-id: '{0}'")] + UnknownReplyId(u64), + + #[error("Vault is not safe, cannot perform operation")] + UnsafeVault {}, + + #[error("Vault does not exist, cannot perform operation")] + VaultDoesNotExist {}, + + #[error("Zero mint not supported")] + ZeroMint {}, + + #[error("Zero transfer not supported")] + ZeroTransfer {}, + // Add any other custom errors you like here. + // Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details. +} + +impl ContractError { + pub fn generic_err(msg: impl Into) -> ContractError { + ContractError::Std(StdError::generic_err(msg)) + } +} diff --git a/packages/margined_common/src/lib.rs b/packages/margined_common/src/lib.rs new file mode 100644 index 0000000..c30bb29 --- /dev/null +++ b/packages/margined_common/src/lib.rs @@ -0,0 +1,4 @@ +pub mod common; +pub mod errors; +pub mod messages; +pub mod ownership; diff --git a/packages/margined_common/src/messages.rs b/packages/margined_common/src/messages.rs new file mode 100644 index 0000000..f33208f --- /dev/null +++ b/packages/margined_common/src/messages.rs @@ -0,0 +1,70 @@ +use osmosis_std::types::{ + cosmos::base::v1beta1::Coin, + osmosis::poolmanager::v1beta1::{ + MsgSwapExactAmountIn, MsgSwapExactAmountOut, SwapAmountInRoute, SwapAmountOutRoute, + }, +}; + +pub fn create_swap_message( + sender: String, + pool_id: u64, + token_in_denom: String, + token_out_denom: String, + amount: String, +) -> MsgSwapExactAmountIn { + MsgSwapExactAmountIn { + sender, + routes: vec![SwapAmountInRoute { + pool_id, + token_out_denom, + }], + token_in: Some(Coin { + denom: token_in_denom, + amount, + }), + token_out_min_amount: "1".to_string(), + } +} + +pub fn create_swap_exact_amount_in_message( + sender: String, + pool_id: u64, + token_in_denom: String, + token_out_denom: String, + amount: String, +) -> MsgSwapExactAmountIn { + MsgSwapExactAmountIn { + sender, + routes: vec![SwapAmountInRoute { + pool_id, + token_out_denom, + }], + token_in: Some(Coin { + denom: token_in_denom, + amount, + }), + token_out_min_amount: "1".to_string(), + } +} + +pub fn create_swap_exact_amount_out_message( + sender: String, + pool_id: u64, + token_in_denom: String, + token_out_denom: String, + amount_out: String, + token_in_max_amount: String, +) -> MsgSwapExactAmountOut { + MsgSwapExactAmountOut { + sender, + routes: vec![SwapAmountOutRoute { + pool_id, + token_in_denom, + }], + token_out: Some(Coin { + denom: token_out_denom, + amount: amount_out, + }), + token_in_max_amount, + } +} diff --git a/packages/margined_common/src/ownership.rs b/packages/margined_common/src/ownership.rs new file mode 100644 index 0000000..0574c8e --- /dev/null +++ b/packages/margined_common/src/ownership.rs @@ -0,0 +1,115 @@ +use crate::errors::ContractError; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + attr, ensure, ensure_eq, Addr, Deps, DepsMut, Env, Event, MessageInfo, Response, StdResult, +}; +use cw_controllers::Admin; +use cw_storage_plus::Item; + +pub const MAX_DURATION: u64 = 604800u64; + +#[cw_serde] +pub struct OwnerProposal { + pub owner: Addr, + pub expiry: u64, +} + +pub fn handle_ownership_proposal( + deps: DepsMut, + info: MessageInfo, + env: Env, + proposed_owner: String, + duration: u64, + owner: Admin, + proposal: Item, +) -> Result { + ensure!( + owner.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + let proposed_owner = deps.api.addr_validate(proposed_owner.as_str())?; + + ensure!( + !owner.is_admin(deps.as_ref(), &proposed_owner)?, + ContractError::InvalidOwnership {} + ); + + if MAX_DURATION < duration { + return Err(ContractError::InvalidDuration(MAX_DURATION)); + } + + let expiry = env.block.time.seconds() + duration; + + proposal.save( + deps.storage, + &OwnerProposal { + owner: proposed_owner.clone(), + expiry, + }, + )?; + + let proposal_event = Event::new("propose_proposed_owner").add_attributes(vec![ + attr("proposed_owner", proposed_owner), + attr("expiry", expiry.to_string()), + ]); + + Ok(Response::new().add_event(proposal_event)) +} + +pub fn handle_ownership_proposal_rejection( + deps: DepsMut, + info: MessageInfo, + owner: Admin, + proposal: Item, +) -> Result { + ensure!( + owner.is_admin(deps.as_ref(), &info.sender)?, + ContractError::Unauthorized {} + ); + + proposal.remove(deps.storage); + + let reject_proposal_event = Event::new("reject_ownership"); + + Ok(Response::new().add_event(reject_proposal_event)) +} + +pub fn handle_claim_ownership( + deps: DepsMut, + info: MessageInfo, + env: Env, + owner: Admin, + proposal: Item, +) -> Result { + let p = proposal + .load(deps.storage) + .map_err(|_| ContractError::ProposalNotFound {})?; + + ensure_eq!(p.owner, &info.sender, ContractError::Unauthorized {}); + + if env.block.time.seconds() > p.expiry { + return Err(ContractError::Expired {}); + } + + let new_owner = p.owner; + + proposal.remove(deps.storage); + + owner.set(deps, Some(new_owner.clone()))?; + + let reject_proposal_event = + Event::new("update_owner").add_attribute("new_owner", new_owner.to_string()); + + Ok(Response::new().add_event(reject_proposal_event)) +} + +pub fn get_ownership_proposal( + deps: Deps, + proposal: Item, +) -> StdResult { + let res = proposal.load(deps.storage)?; + + Ok(res) +} diff --git a/packages/margined_protocol/Cargo.toml b/packages/margined_protocol/Cargo.toml new file mode 100644 index 0000000..90bd694 --- /dev/null +++ b/packages/margined_protocol/Cargo.toml @@ -0,0 +1,14 @@ +[package] +authors = [ "Friedrich Grabner " ] +edition = "2021" +name = "margined-protocol" +version = "0.1.0" + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +thiserror = { workspace = true } diff --git a/packages/margined_protocol/src/collector.rs b/packages/margined_protocol/src/collector.rs new file mode 100644 index 0000000..34db66a --- /dev/null +++ b/packages/margined_protocol/src/collector.rs @@ -0,0 +1,72 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Uint128}; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum ExecuteMsg { + AddToken { + token: String, + }, + RemoveToken { + token: String, + }, + UpdateWhitelist { + address: String, + }, + SendToken { + token: String, + amount: Uint128, + recipient: String, + }, + ProposeNewOwner { + new_owner: String, + duration: u64, + }, + RejectOwner {}, + ClaimOwnership {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Addr)] + Owner {}, + #[returns(WhitelistResponse)] + GetWhitelist {}, + #[returns(TokenResponse)] + IsToken { token: String }, + #[returns(TokenLengthResponse)] + GetTokenLength {}, + #[returns(AllTokenResponse)] + GetTokenList { limit: Option }, + #[returns(OwnerProposalResponse)] + GetOwnershipProposal {}, +} + +#[cw_serde] +pub struct WhitelistResponse { + pub address: Option, +} + +#[cw_serde] +pub struct TokenResponse { + pub is_token: bool, +} + +#[cw_serde] +pub struct AllTokenResponse { + pub token_list: Vec, +} + +#[cw_serde] +pub struct TokenLengthResponse { + pub length: usize, +} + +#[cw_serde] +pub struct OwnerProposalResponse { + pub owner: Addr, + pub expiry: u64, +} diff --git a/packages/margined_protocol/src/crab.rs b/packages/margined_protocol/src/crab.rs new file mode 100644 index 0000000..59f06e2 --- /dev/null +++ b/packages/margined_protocol/src/crab.rs @@ -0,0 +1,135 @@ +use crate::power::Pool; +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Decimal, Timestamp, Uint128}; + +#[cw_serde] +pub struct InstantiateMsg { + pub power_contract: String, + pub query_contract: String, + pub fee_pool_contract: String, + pub fee_rate: String, + pub power_denom: String, + pub base_denom: String, + pub base_pool_id: u64, + pub base_pool_quote: String, + pub power_pool_id: u64, + pub power_pool_quote: String, + pub base_decimals: u32, + pub power_decimals: u32, +} + +#[cw_serde] +#[allow(clippy::large_enum_variant)] +pub enum ExecuteMsg { + ClaimOwnership {}, + Deposit {}, + FlashDeposit {}, + FlashWithdraw {}, + HedgeOTC {}, + Pause {}, + ProposeNewOwner { new_owner: String, duration: u64 }, + RedeemShortShutdown {}, + RejectOwner {}, + SetOpen {}, + TransferVault {}, + Withdraw {}, + WithdrawShutdown {}, + UpdateConfig { new_config: UpdateConfig }, + UnPause {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(StateResponse)] + State {}, + #[returns(StubResponse)] + CheckPriceHedge {}, + #[returns(StubResponse)] + CheckTimeHedge {}, + #[returns(StubResponse)] + DomainSeparator {}, + #[returns(OwnerProposalResponse)] + GetVaultDetails {}, + #[returns(StubResponse)] + GetOwnershipProposal {}, + #[returns(StubResponse)] + GetWsqueethFromCrabAmount {}, + #[returns(StubResponse)] + Nonce {}, + #[returns(Addr)] + Owner {}, +} + +#[cw_serde] +pub struct MigrateMsg {} + +#[cw_serde] +pub struct ConfigResponse { + pub power_contract: Addr, + pub query_contract: Addr, + pub fee_pool_contract: Addr, + pub power_denom: String, + pub base_denom: String, + pub base_pool: Pool, + pub power_pool: Pool, + pub base_decimals: u32, + pub power_decimals: u32, + pub fee_rate: Decimal, + pub hedge_price_threshold: Uint128, + pub hedge_time_threshold: u64, + pub hedging_twap_period: u64, + pub strategy_cap: Uint128, + pub strategy_denom: String, +} + +#[derive(Default)] +#[cw_serde] +pub struct UpdateConfig { + pub power_contract: Option, + pub query_contract: Option, + pub fee_pool_contract: Option, + pub power_denom: Option, + pub base_denom: Option, + pub base_pool: Option, + pub power_pool: Option, + pub base_decimals: Option, + pub power_decimals: Option, + pub fee_rate: Option, + pub hedge_price_threshold: Option, + pub hedge_time_threshold: Option, + pub hedging_twap_period: Option, + pub strategy_cap: Option, +} + +#[cw_serde] +pub struct StateResponse { + pub is_open: bool, + pub is_paused: bool, + pub last_pause: Timestamp, + pub time_at_last_hedge: Timestamp, + pub price_at_last_hedge: Decimal, + pub strategy_vault_id: u64, +} + +#[derive(Default)] +#[cw_serde] +pub struct UpdateStateResponse { + pub is_open: Option, + pub is_paused: Option, + pub last_pause: Option, + pub time_at_last_hedge: Option, + pub price_at_last_hedge: Option, + pub strategy_vault_id: Option, +} + +#[cw_serde] +pub struct OwnerProposalResponse { + pub owner: Addr, + pub expiry: u64, +} + +#[cw_serde] +pub struct StubResponse {} diff --git a/packages/margined_protocol/src/lib.rs b/packages/margined_protocol/src/lib.rs new file mode 100644 index 0000000..fe0728e --- /dev/null +++ b/packages/margined_protocol/src/lib.rs @@ -0,0 +1,5 @@ +pub mod collector; +pub mod crab; +pub mod power; +pub mod query; +pub mod staking; diff --git a/packages/margined_protocol/src/power.rs b/packages/margined_protocol/src/power.rs new file mode 100644 index 0000000..441b766 --- /dev/null +++ b/packages/margined_protocol/src/power.rs @@ -0,0 +1,150 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Decimal, Timestamp, Uint128}; + +pub const FUNDING_PERIOD: u64 = 420 * 60 * 60; // 420 hours + +#[cw_serde] +pub struct InstantiateMsg { + pub fee_rate: String, // rate of fees charge, must be less than 1 + pub fee_pool: String, // address of fee pool contract + pub query_contract: String, // query contract that wraps native querier + pub base_denom: String, // denom of the underlying token, e.g. atom + pub power_denom: String, // denom of the power token, e.g. atom^2 + pub base_pool_id: u64, // id of the pool of the underlying to quote, e.g. atom:usdc + pub base_pool_quote: String, // denom of the base pool quote asset, e.g. usdc + pub power_pool_id: u64, // id of the pool of the underlying to power, e.g. atom:atom^2 + pub base_decimals: u32, // decimals of the underlying token + pub power_decimals: u32, // decimals of the power perp token +} + +#[cw_serde] +pub enum ExecuteMsg { + SetOpen {}, + MintPowerPerp { + amount: Uint128, + vault_id: Option, + rebase: bool, + }, + BurnPowerPerp { + amount_to_withdraw: Option, + vault_id: u64, + }, + OpenShort { + amount: Uint128, + vault_id: Option, + }, + CloseShort { + amount_to_burn: Uint128, + amount_to_withdraw: Option, + vault_id: u64, + }, + Deposit { + vault_id: u64, + }, + Withdraw { + amount: Uint128, + vault_id: u64, + }, + Liquidate { + max_debt_amount: Uint128, + vault_id: u64, + }, + ApplyFunding {}, + UpdateConfig { + fee_rate: Option, + fee_pool: Option, + }, + Pause {}, + UnPause {}, + ProposeNewOwner { + new_owner: String, + duration: u64, + }, + RejectOwner {}, + ClaimOwnership {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(StateResponse)] + State {}, + #[returns(Addr)] + Owner {}, + #[returns(Decimal)] + GetNormalisationFactor {}, + #[returns(Decimal)] + GetIndex { period: u64 }, + #[returns(Decimal)] + GetUnscaledIndex { period: u64 }, + #[returns(Decimal)] + GetDenormalisedMark { period: u64 }, + #[returns(Decimal)] + GetDenormalisedMarkFunding { period: u64 }, + #[returns(VaultResponse)] + GetVault { vault_id: u64 }, + #[returns(UserVaultsResponse)] + GetUserVaults { + user: String, + start_after: Option, + limit: Option, + }, + #[returns(u64)] + GetNextVaultId {}, + #[returns(OwnerProposalResponse)] + GetOwnershipProposal {}, + #[returns(bool)] + CheckVault { vault_id: u64 }, +} + +#[cw_serde] +pub struct MigrateMsg {} + +#[cw_serde] +pub struct VaultResponse { + pub operator: Addr, + pub collateral: Uint128, + pub short_amount: Uint128, +} + +#[cw_serde] +pub struct UserVaultsResponse { + pub vaults: Vec, +} + +#[cw_serde] +pub struct ConfigResponse { + pub query_contract: Addr, + pub fee_pool_contract: Addr, + pub fee_rate: Decimal, + pub power_denom: String, + pub base_denom: String, + pub base_pool: Pool, + pub power_pool: Pool, + pub funding_period: u64, + pub base_decimals: u32, + pub power_decimals: u32, +} + +#[cw_serde] +pub struct StateResponse { + pub is_open: bool, + pub is_paused: bool, + pub last_pause: Timestamp, + pub normalisation_factor: Decimal, + pub last_funding_update: Timestamp, +} + +#[cw_serde] +pub struct OwnerProposalResponse { + pub owner: Addr, + pub expiry: u64, +} + +#[cw_serde] +pub struct Pool { + pub id: u64, + pub quote_denom: String, +} diff --git a/packages/margined_protocol/src/query.rs b/packages/margined_protocol/src/query.rs new file mode 100644 index 0000000..5008abf --- /dev/null +++ b/packages/margined_protocol/src/query.rs @@ -0,0 +1,22 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Decimal, Timestamp}; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub struct ExecuteMsg {} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Decimal)] + GetArithmeticTwapToNow { + pool_id: u64, + base_asset: String, + quote_asset: String, + start_time: Timestamp, + }, + #[returns(Option)] + GetDenomAuthority { denom: String }, +} diff --git a/packages/margined_protocol/src/staking.rs b/packages/margined_protocol/src/staking.rs new file mode 100644 index 0000000..cb712ee --- /dev/null +++ b/packages/margined_protocol/src/staking.rs @@ -0,0 +1,89 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Timestamp, Uint128}; + +#[cw_serde] +pub struct InstantiateMsg { + pub fee_collector: String, + pub deposit_denom: String, + pub reward_denom: String, + pub deposit_decimals: u32, + pub reward_decimals: u32, + pub tokens_per_interval: Uint128, +} + +#[cw_serde] +pub enum ExecuteMsg { + UpdateConfig { + tokens_per_interval: Option, + }, + UpdateRewards {}, + Stake {}, + Unstake { + amount: Uint128, + }, + Claim { + recipient: Option, + }, + Pause {}, + Unpause {}, + ProposeNewOwner { + new_owner: String, + duration: u64, + }, + RejectOwner {}, + ClaimOwnership {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ConfigResponse)] + Config {}, + #[returns(StateResponse)] + State {}, + #[returns(Addr)] + Owner {}, + #[returns(Uint128)] + GetClaimable { user: String }, + #[returns(UserStakedResponse)] + GetUserStakedAmount { user: String }, + #[returns(TotalStakedResponse)] + GetTotalStakedAmount {}, + #[returns(OwnerProposalResponse)] + GetOwnershipProposal {}, +} + +#[cw_serde] +pub struct TotalStakedResponse { + pub amount: Uint128, +} + +#[cw_serde] +pub struct UserStakedResponse { + pub staked_amounts: Uint128, + pub claimable_rewards: Uint128, + pub previous_cumulative_rewards_per_token: Uint128, + pub cumulative_rewards: Uint128, +} + +#[cw_serde] +pub struct ConfigResponse { + pub fee_collector: Addr, + pub deposit_denom: String, + pub deposit_decimals: u32, + pub reward_denom: String, + pub reward_decimals: u32, + pub tokens_per_interval: Uint128, +} + +#[cw_serde] +pub struct StateResponse { + pub is_open: bool, + pub last_distribution: Timestamp, +} + +#[cw_serde] +pub struct OwnerProposalResponse { + pub owner: Addr, + pub expiry: u64, +} diff --git a/packages/margined_testing/Cargo.toml b/packages/margined_testing/Cargo.toml new file mode 100644 index 0000000..cfbfe65 --- /dev/null +++ b/packages/margined_testing/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors = [ "Margined Protocol" ] +edition = "2021" +name = "margined-testing" +version = "0.1.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +# for quicker tests, cargo test --lib +# for more explicit tests, cargo test --features=backtraces +backtraces = [ "cosmwasm-std/backtraces" ] + +[dependencies] +cosmrs = { workspace = true } +cosmwasm-std = { workspace = true } +margined-protocol = { workspace = true } +mock-query = { workspace = true } +osmosis-std = { workspace = true } +osmosis-test-tube = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/packages/margined_testing/src/crab_env.rs b/packages/margined_testing/src/crab_env.rs new file mode 100644 index 0000000..ba96c09 --- /dev/null +++ b/packages/margined_testing/src/crab_env.rs @@ -0,0 +1,92 @@ +use crate::helpers::store_code; + +use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use margined_protocol::crab::InstantiateMsg; +use osmosis_test_tube::{OsmosisTestApp, SigningAccount, Wasm}; +pub const MOCK_POWER_ADDR: &str = "osmo1cnj84q49sp4sd3tsacdw9p4zvyd8y46f2248ndq2edve3fqa8krs9jds9g"; +pub const MOCK_QUERY_ADDR: &str = "osmo1cnj84q49sp4sd3tsacdw9p4zvyd8y46f2248ndq2edve3fqa8krs9jds9g"; +pub const MOCK_FEE_POOL_CONTRACT: &str = "osmo1tj5a2z96vfy8av78926pgs3x774dvhzgxayue0"; +pub const MOCK_POWER_DENOM: &str = "factory/osmo1qc5pen6am58wxuj58vw97m72vv5tp74remsul7/uosmoexp"; +pub const MOCK_BASE_POOL_ID: u64 = 5u64; +pub const MOCK_POWER_POOL_ID: u64 = 75u64; +pub const MOCK_BASE_DECIMALS: u32 = 6u32; +pub const MOCK_POWER_DECIMALS: u32 = 6u32; +pub const MOCK_BASE_POOL_QUOTE_DENOM: &str = + "ibc/6F34E1BD664C36CE49ACC28E60D62559A5F96C4F9A6CCE4FC5A67B2852E24CFE"; +pub const MOCK_FEE_RATE: Decimal = Decimal::zero(); +pub const MOCK_BASE_DENOM: &str = "uosmo"; +pub const MOCK_HEDGING_TWAP_PERIOD: u64 = 420u64; +pub const MOCK_HEDGE_PRICE_THRESHOLD: Uint128 = Uint128::new(200_000_000_000_000_000u128); +pub const MOCK_HEDGE_TIME_THRESHOLD: u64 = 1800u64; +pub const MOCK_STRATEGY_CAP: Uint128 = Uint128::new(10000000000000000000000u128); + +pub struct ContractInfo { + pub addr: Addr, + pub id: u64, +} + +pub struct CrabEnv { + pub app: OsmosisTestApp, + pub signer: SigningAccount, + pub traders: Vec, +} + +impl CrabEnv { + pub fn new() -> Self { + let app = OsmosisTestApp::new(); + + let signer = app + .init_account(&[coin(1_000_000_000_000_000_000, "uosmo")]) + .unwrap(); + + let mut traders: Vec = Vec::new(); + for _ in 0..10 { + traders.push( + app.init_account(&[coin(1_000_000_000_000_000_000, "uosmo")]) + .unwrap(), + ); + } + + Self { + app, + signer, + traders, + } + } + + pub fn deploy_crab_contract(&self, wasm: &Wasm) -> String { + let code_id = store_code(wasm, &self.signer, "margined_crab".to_string()); + let msg = InstantiateMsg { + power_contract: MOCK_POWER_ADDR.to_string(), + query_contract: MOCK_QUERY_ADDR.to_string(), + fee_pool_contract: MOCK_QUERY_ADDR.to_string(), + fee_rate: MOCK_FEE_RATE.to_string(), + power_denom: MOCK_POWER_DENOM.to_string(), + base_denom: MOCK_BASE_DENOM.to_string(), + base_pool_id: MOCK_BASE_POOL_ID, + base_pool_quote: MOCK_BASE_POOL_QUOTE_DENOM.to_string(), + power_pool_id: MOCK_POWER_POOL_ID, + power_pool_quote: MOCK_POWER_DENOM.to_string(), + base_decimals: MOCK_BASE_DECIMALS, + power_decimals: MOCK_POWER_DECIMALS, + }; + + wasm.instantiate( + code_id, + &msg, + None, + Some("margined-crab-contract"), + &[], + &self.signer, + ) + .unwrap() + .data + .address + } +} + +impl Default for CrabEnv { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/margined_testing/src/helpers.rs b/packages/margined_testing/src/helpers.rs new file mode 100644 index 0000000..fde929a --- /dev/null +++ b/packages/margined_testing/src/helpers.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::{Event, Uint128}; +use osmosis_test_tube::{OsmosisTestApp, SigningAccount, Wasm}; + +pub fn wasm_file(contract_name: String) -> String { + let snaked_name = contract_name.replace('-', "_"); + + let target = format!("../../target/wasm32-unknown-unknown/release/{snaked_name}.wasm"); + if std::path::Path::new(&target).exists() { + target + } else { + let arch = std::env::consts::ARCH; + + let artifacts_dir = + std::env::var("ARTIFACTS_DIR_PATH").unwrap_or_else(|_| "artifacts".to_string()); + format!("../../{artifacts_dir}/{snaked_name}-{arch}.wasm") + } +} + +pub fn store_code( + wasm: &Wasm, + owner: &SigningAccount, + contract_name: String, +) -> u64 { + let wasm_byte_code = std::fs::read(wasm_file(contract_name)).unwrap(); + wasm.store_code(&wasm_byte_code, None, owner) + .unwrap() + .data + .code_id +} + +pub fn is_similar(a: Uint128, b: Uint128, epsilon: Uint128) -> bool { + if a < b { + (b - a) < epsilon + } else { + (a - b) < epsilon + } +} + +pub fn parse_event_attribute(events: Vec, event: &str, key: &str) -> String { + events + .iter() + .find(|e| e.ty == event) + .unwrap() + .attributes + .iter() + .find(|e| e.key == key) + .unwrap() + .value + .clone() +} diff --git a/packages/margined_testing/src/lib.rs b/packages/margined_testing/src/lib.rs new file mode 100644 index 0000000..b877ef4 --- /dev/null +++ b/packages/margined_testing/src/lib.rs @@ -0,0 +1,4 @@ +pub mod crab_env; +pub mod helpers; +pub mod power_env; +pub mod staking_env; diff --git a/packages/margined_testing/src/power_env.rs b/packages/margined_testing/src/power_env.rs new file mode 100644 index 0000000..e720b84 --- /dev/null +++ b/packages/margined_testing/src/power_env.rs @@ -0,0 +1,628 @@ +use crate::helpers::store_code; + +use cosmrs::proto::{ + cosmos::params::v1beta1::{ParamChange, ParameterChangeProposal}, + traits::Message, +}; +use cosmwasm_std::{coin, Addr, Decimal, Uint128}; +use margined_protocol::{ + crab::InstantiateMsg as CrabInstantiateMsg, + power::{ExecuteMsg, InstantiateMsg}, + query::{InstantiateMsg as QueryInstantiateMsg, QueryMsg as QueryQueryMsg}, +}; +use mock_query::contract::ExecuteMsg as MockQueryExecuteMsg; +use osmosis_test_tube::{ + osmosis_std::types::{ + cosmos::{ + bank::v1beta1::{MsgSend, QueryBalanceRequest, QueryTotalSupplyRequest}, + base::v1beta1::Coin, + }, + osmosis::{ + concentratedliquidity::v1beta1::{ + CreateConcentratedLiquidityPoolsProposal, MsgCreatePosition, Pool, PoolRecord, + PoolsRequest, + }, + poolmanager::v1beta1::SpotPriceRequest, + tokenfactory::v1beta1::{MsgChangeAdmin, MsgCreateDenom}, + }, + }, + Account, Bank, ConcentratedLiquidity, Gamm, GovWithAppAccess, Module, OsmosisTestApp, + PoolManager, SigningAccount, TokenFactory, Wasm, +}; +use std::{collections::HashMap, str::FromStr}; + +pub const ONE: u128 = 1_000_000; // 1.0@6dp +pub const SCALE_FACTOR: u128 = 10_000; +pub const BASE_PRICE: u128 = 3_000_000_000; // 3000.0@6dp +pub const POWER_PRICE: u128 = 3_010_000_000; // 3010.0@6dp +pub const SCALED_POWER_PRICE: u128 = 30_100_000; // 0.3010@6dp +pub const MAX_TWAP_PERIOD: u64 = 48 * 60 * 60; +pub struct ContractInfo { + pub addr: Addr, + pub id: u64, +} + +pub struct PowerEnv { + pub app: OsmosisTestApp, + pub signer: SigningAccount, + pub owner: SigningAccount, // owns the pools + pub fee_pool: SigningAccount, + pub traders: Vec, + pub liquidator: SigningAccount, + pub base_pool_id: u64, + pub power_pool_id: u64, + pub denoms: HashMap, +} + +impl PowerEnv { + pub fn new() -> Self { + let app = OsmosisTestApp::new(); + + let bank = Bank::new(&app); + let concentrated_liquidity = ConcentratedLiquidity::new(&app); + let gamm = Gamm::new(&app); + let gov = GovWithAppAccess::new(&app); + let token = TokenFactory::new(&app); + + let mut denoms = HashMap::new(); + denoms.insert("quote".to_string(), "usdc".to_string()); + denoms.insert("base".to_string(), "ubase".to_string()); + denoms.insert("gas".to_string(), "uosmo".to_string()); + + let signer = app + .init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000_000, "usdc"), + coin(1_000_000_000_000_000_000_000_000, "ubase"), + ]) + .unwrap(); + + let fee_pool = app.init_account(&[]).unwrap(); + + let mut traders: Vec = Vec::new(); + for _ in 0..10 { + traders.push( + app.init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000, "usdc"), + coin(1_000_000_000_000_000, "ubase"), + ]) + .unwrap(), + ); + } + + let liquidator = app + .init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000, "usdc"), + coin(1_000_000_000_000_000, "ubase"), + ]) + .unwrap(); + + let power_denom = token + .create_denom( + MsgCreateDenom { + sender: signer.address(), + subdenom: "squosmo".to_string(), + }, + &signer, + ) + .unwrap() + .data + .new_token_denom; + denoms.insert("power".to_string(), power_denom); + + let owner = app + .init_account(&[ + coin(1_000_000_000_000_000_000, denoms["gas"].clone()), + coin(1_000_000_000_000, denoms["base"].clone()), + coin(3_000_000_000_000, denoms["quote"].clone()), + coin(3_000_000_000_000, denoms["power"].clone()), + ]) + .unwrap(); + + bank.send( + MsgSend { + to_address: signer.address(), + from_address: owner.address(), + amount: vec![Coin { + amount: "1000000000000".to_string(), + denom: denoms["power"].clone(), + }], + }, + &owner, + ) + .unwrap(); + + let base_pool_id = gamm + .create_basic_pool( + &[ + coin(1_000_000_000, denoms["base"].clone()), + coin(3_000_000_000_000, denoms["quote"].clone()), + ], + &owner, + ) + .unwrap() + .data + .pool_id; + + // update the parameters so we can have no gas paying token as base + gov.propose_and_execute( + "/cosmos.params.v1beta1.ParameterChangeProposal".to_string(), + ParameterChangeProposal { + title: "Update authorized quote denoms".to_string(), + description: "Add ubase as an authorized quote denom".to_string(), + changes: vec![ParamChange { + subspace: "concentratedliquidity".to_string(), + key: "AuthorizedQuoteDenoms".to_string(), + value: "[\"usdc\", \"ubase\"]".to_string(), + }], + }, + owner.address(), + false, + &owner, + ) + .unwrap(); + + gov.propose_and_execute( + CreateConcentratedLiquidityPoolsProposal::TYPE_URL.to_string(), + CreateConcentratedLiquidityPoolsProposal { + title: "Create concentrated uosmo:expuosmo pool".to_string(), + description: "Create concentrated uosmo:expuosmo pool, so that we can trade it" + .to_string(), + pool_records: vec![PoolRecord { + denom0: denoms["power"].clone(), // base + denom1: denoms["base"].clone(), // quote + tick_spacing: 100, + spread_factor: "0".to_string(), + }], + }, + owner.address(), + false, + &owner, + ) + .unwrap(); + + let pools = concentrated_liquidity + .query_pools(&PoolsRequest { pagination: None }) + .unwrap(); + + let pool = Pool::decode(pools.pools[0].value.as_slice()).unwrap(); + let power_pool_id = pool.id; + + // LP from 0.2 to 0.65 + // 1 - (0.000001 * 800000) = 0.2 @-800000 + // 1 - (0.000001 * 350000) = 0.6 @-350000 + concentrated_liquidity + .create_position( + MsgCreatePosition { + pool_id: power_pool_id, + sender: owner.address(), + lower_tick: -7500000i64, + upper_tick: 750000i64, + tokens_provided: vec![ + Coin { + denom: denoms["power"].clone(), + amount: "1_000_000".to_string(), + }, + Coin { + denom: denoms["base"].clone(), + amount: "300_000".to_string(), + }, + ], + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + }, + &owner, + ) + .unwrap(); + + Self { + app, + signer, + fee_pool, + owner, + traders, + liquidator, + base_pool_id, + power_pool_id, + denoms, + } + } + + // TODO: this is potentially not the best way to do this, it could + // be better to have a base and implementations like apollo zappers + // but it's fine for now. + pub fn deploy_query_contracts(&self, wasm: &Wasm, is_mock: bool) -> String { + let code_id = if is_mock { + store_code(wasm, &self.signer, "mock_query".to_string()) + } else { + store_code(wasm, &self.signer, "margined_query".to_string()) + }; + + wasm.instantiate( + code_id, + &QueryInstantiateMsg {}, + None, + Some("margined-query-contract"), + &[coin(10_000_000, "uosmo")], + &self.signer, + ) + .unwrap() + .data + .address + } + + pub fn get_power_price(&self, wasm: &Wasm, query_address: String) -> Decimal { + wasm.query( + &query_address, + &QueryQueryMsg::GetArithmeticTwapToNow { + pool_id: self.base_pool_id, + base_asset: self.denoms["base"].to_string(), + quote_asset: self.denoms["quote"].to_string(), + start_time: self.app.get_block_timestamp(), + }, + ) + .unwrap() + } + + pub fn deploy_crab( + &self, + wasm: &Wasm, + power_address: String, + query_address: String, + ) -> String { + let code_id = store_code(wasm, &self.signer, "margined_crab".to_string()); + wasm.instantiate( + code_id, + &CrabInstantiateMsg { + power_contract: power_address, + query_contract: query_address, + fee_pool_contract: self.fee_pool.address(), + fee_rate: "0.0".to_string(), // 0% + power_denom: self.denoms["power"].clone(), + base_denom: self.denoms["base"].clone(), + base_pool_id: self.base_pool_id, + base_pool_quote: self.denoms["quote"].clone(), + power_pool_id: self.power_pool_id, + power_pool_quote: "usdc".to_string(), + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-crab-contract"), + &[coin(300_000_000u128, "ubase")], // 300.00 + //&[], + &self.signer, + ) + .unwrap() + .data + .address + } + + // - Deploy power + // - Deploy crab + // - Set fee_rate on power to 0.0 + // - Apply funding + // - Set crab to open + pub fn setup_crab( + &self, + wasm: &Wasm, + is_mock: bool, + power_fee: String, + ) -> (String, String, String) { + let concentrated_liquidity = ConcentratedLiquidity::new(&self.app); + + // Add more liquidity for testing + // LP from 0.2 to 0.65 + // 1 - (0.000001 * 800000) = 0.2 @-800000 + // 1 - (0.000001 * 350000) = 0.6 @-350000 + concentrated_liquidity + .create_position( + MsgCreatePosition { + pool_id: self.power_pool_id, + sender: self.owner.address(), + lower_tick: -7500000i64, + upper_tick: 750000i64, + tokens_provided: vec![ + Coin { + denom: self.denoms["power"].clone(), + amount: "1_000_000_000_000".to_string(), + }, + Coin { + denom: self.denoms["base"].clone(), + amount: "300_000_000_000".to_string(), + }, + ], + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + }, + &self.owner, + ) + .unwrap(); + let (power_address, query_address) = + self.deploy_power(wasm, "margined-power".to_string(), is_mock); + + if is_mock { + // Set the oracle price to 300_000 (0.3) + wasm.execute( + &query_address, + &MockQueryExecuteMsg::AppendPrice { + pool_id: self.base_pool_id, + price: Decimal::from_atomics(300_000u128, 6u32).unwrap(), + }, + &[], + &self.signer, + ) + .unwrap(); + wasm.execute( + &query_address, + &MockQueryExecuteMsg::AppendPrice { + pool_id: self.power_pool_id, + price: Decimal::from_atomics(300_000u128, 6u32).unwrap(), + }, + &[], + &self.signer, + ) + .unwrap(); + } + self.app.increase_time(MAX_TWAP_PERIOD + 1); + + wasm.execute( + &power_address, + &ExecuteMsg::UpdateConfig { + fee_rate: Some(power_fee), + fee_pool: None, + }, + &[], + &self.signer, + ) + .unwrap(); + + wasm.execute( + &power_address, + &ExecuteMsg::ApplyFunding {}, + &[], + &self.signer, + ) + .unwrap(); + + let crab_address = self.deploy_crab(wasm, power_address.clone(), query_address.clone()); + + wasm.execute(&crab_address, &ExecuteMsg::SetOpen {}, &[], &self.signer) + .unwrap(); + + (power_address, query_address, crab_address) + } + + pub fn create_new_pool(&self, denom0: String, denom1: String, owner: &SigningAccount) -> u64 { + let gov = GovWithAppAccess::new(&self.app); + let concentrated_liquidity = ConcentratedLiquidity::new(&self.app); + + gov.propose_and_execute( + CreateConcentratedLiquidityPoolsProposal::TYPE_URL.to_string(), + CreateConcentratedLiquidityPoolsProposal { + title: "Create concentrated uosmo:expuosmo pool".to_string(), + description: "Create concentrated uosmo:expuosmo pool, so that we can trade it" + .to_string(), + pool_records: vec![PoolRecord { + denom0, // base + denom1, // quote + tick_spacing: 100, + spread_factor: "0".to_string(), + }], + }, + owner.address(), + false, + owner, + ) + .unwrap(); + + let pools = concentrated_liquidity + .query_pools(&PoolsRequest { pagination: None }) + .unwrap(); + + let pool = Pool::decode(pools.pools.last().unwrap().value.as_slice()).unwrap(); + + pool.id + } + + pub fn create_position( + &self, + lower_tick: String, + upper_tick: String, + base_amount: String, + power_amount: String, + ) { + let concentrated_liquidity = ConcentratedLiquidity::new(&self.app); + + concentrated_liquidity + .create_position( + MsgCreatePosition { + pool_id: self.power_pool_id, + sender: self.owner.address(), + lower_tick: i64::from_str(&lower_tick).unwrap(), + upper_tick: i64::from_str(&upper_tick).unwrap(), + tokens_provided: vec![ + Coin { + denom: self.denoms["power"].clone(), + amount: power_amount, + }, + Coin { + denom: self.denoms["base"].clone(), + amount: base_amount, + }, + ], + token_min_amount0: "0".to_string(), + token_min_amount1: "0".to_string(), + }, + &self.owner, + ) + .unwrap(); + } + + pub fn deploy_power( + &self, + wasm: &Wasm, + contract_name: String, + is_mock: bool, + ) -> (String, String) { + let token = TokenFactory::new(&self.app); + + let query_address = self.deploy_query_contracts(wasm, is_mock); + + let code_id = store_code(wasm, &self.signer, contract_name); + let perp_address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_pool: self.fee_pool.address(), + fee_rate: "0.0".to_string(), // 0% + query_contract: query_address.clone(), + power_denom: self.denoms["power"].clone(), + base_denom: self.denoms["base"].clone(), + base_pool_id: self.base_pool_id, + base_pool_quote: self.denoms["quote"].clone(), + power_pool_id: self.power_pool_id, + base_decimals: 6u32, + power_decimals: 6u32, + }, + None, + Some("margined-power-contract"), + &[], + &self.signer, + ) + .unwrap() + .data + .address; + + token + .change_admin( + MsgChangeAdmin { + sender: self.signer.address(), + new_admin: perp_address.clone(), + denom: self.denoms["power"].clone(), + }, + &self.signer, + ) + .unwrap(); + + wasm.execute(&perp_address, &ExecuteMsg::SetOpen {}, &[], &self.signer) + .unwrap(); + + (perp_address, query_address) + } + + pub fn set_oracle_price( + &self, + wasm: &Wasm, + query_address: String, + pool_id: u64, + price: Decimal, + ) { + wasm.execute( + &query_address, + &MockQueryExecuteMsg::AppendPrice { pool_id, price }, + &[], + &self.signer, + ) + .unwrap(); + } + + pub fn get_balance(&self, address: String, denom: String) -> Uint128 { + let bank = Bank::new(&self.app); + + let response = bank + .query_balance(&QueryBalanceRequest { address, denom }) + .unwrap(); + + match response.balance { + Some(balance) => Uint128::from_str(&balance.amount).unwrap(), + None => Uint128::zero(), + } + } + + pub fn get_total_supply(&self, denom: String) -> Uint128 { + let bank = Bank::new(&self.app); + + let response = bank + .query_total_supply(&QueryTotalSupplyRequest { pagination: None }) + .unwrap() + .supply + .into_iter() + .find(|coin| coin.denom == denom) + .unwrap(); + + Uint128::from_str(&response.amount).unwrap_or(Uint128::zero()) + } + + pub fn calculate_target_power_price(&self, normalisation_factor: Decimal) -> Decimal { + let pool_manager = PoolManager::new(&self.app); + + let res = pool_manager + .query_spot_price(&SpotPriceRequest { + pool_id: self.base_pool_id, + base_asset_denom: self.denoms["base"].clone(), + quote_asset_denom: self.denoms["quote"].clone(), + }) + .unwrap(); + let index_price = Decimal::from_str(&res.spot_price).unwrap(); + + let mark_price = index_price * index_price; + let scale_factor = Decimal::from_atomics(SCALE_FACTOR, 0u32).unwrap(); + + (index_price / mark_price) * scale_factor / normalisation_factor + } + + pub fn price_to_tick(&self, price: Decimal, tick_interval: Uint128) -> String { + let mut exponent = Decimal::from_str("0.000001").unwrap(); + let mut tick = Decimal::zero(); + let ticks_per_increment = Decimal::from_atomics(9_000_000u128, 0u32).unwrap(); + let ten = Decimal::from_atomics(10u128, 0u32).unwrap(); + + // NOTE: this is unfinished and probably doesnt work for less than 1 + let result = match price.cmp(&Decimal::one()) { + std::cmp::Ordering::Greater => { + let mut total_price = Decimal::one(); + + while total_price < price { + total_price += ticks_per_increment * exponent; + + tick += ticks_per_increment; + exponent *= ten; + } + + let delta = price.abs_diff(total_price); + + tick - (delta / (exponent / ten)) + } + std::cmp::Ordering::Less => { + let mut total_price = Decimal::one(); + + while total_price < price { + total_price += ticks_per_increment * exponent; + tick += ticks_per_increment; + exponent /= ten; + } + + let delta = price.abs_diff(total_price); + tick + (delta / (exponent / ten)) + } + std::cmp::Ordering::Equal => Decimal::zero(), + }; + + let value = (result.to_uint_floor() / tick_interval) * tick_interval; + + if price < Decimal::one() { + format!("{}{}", "-", value) + } else { + value.to_string() + } + } +} + +impl Default for PowerEnv { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/margined_testing/src/staking_env.rs b/packages/margined_testing/src/staking_env.rs new file mode 100644 index 0000000..bab2f55 --- /dev/null +++ b/packages/margined_testing/src/staking_env.rs @@ -0,0 +1,214 @@ +use crate::helpers::store_code; + +use cosmwasm_std::{coin, Addr, Uint128}; +use margined_protocol::{ + collector::{ + ExecuteMsg as FeeCollectorExecuteMsg, InstantiateMsg as FeeCollectorInstantiateMsg, + }, + staking::InstantiateMsg, +}; +use osmosis_test_tube::{ + osmosis_std::types::cosmos::bank::v1beta1::QueryBalanceRequest, Bank, Module, OsmosisTestApp, + SigningAccount, Wasm, +}; +use std::{collections::HashMap, str::FromStr}; + +pub const ONE: u128 = 1_000_000; // 1.0@6dp +pub const SCALE_FACTOR: u128 = 10_000; +pub const BASE_PRICE: u128 = 3_000_000_000; // 3000.0@6dp +pub const POWER_PRICE: u128 = 3_010_000_000; // 3010.0@6dp +pub const SCALED_POWER_PRICE: u128 = 30_100_000; // 0.3010@6dp + +pub struct ContractInfo { + pub addr: Addr, + pub id: u64, +} + +pub struct StakingEnv { + pub app: OsmosisTestApp, + pub signer: SigningAccount, + pub handler: SigningAccount, + pub empty: SigningAccount, + pub traders: Vec, + pub denoms: HashMap, +} + +impl StakingEnv { + pub fn new() -> Self { + let app = OsmosisTestApp::new(); + + let mut denoms = HashMap::new(); + denoms.insert("base".to_string(), "uosmo".to_string()); + denoms.insert("reward".to_string(), "uusdc".to_string()); + denoms.insert("deposit".to_string(), "umrg".to_string()); + + let signer = app + .init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000, "uusdc"), + coin(1_000_000_000_000, "token3"), + coin(1_000_000_000_000, "token4"), + coin(1_000_000_000_000, "token5"), + ]) + .unwrap(); + + let handler = app.init_account(&[]).unwrap(); + + let trader1 = app + .init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000, "uusdc"), + coin(1_000_000_000, "umrg"), + ]) + .unwrap(); + + let trader2 = app + .init_account(&[ + coin(1_000_000_000_000_000_000, "uosmo"), + coin(1_000_000_000_000, "uusdc"), + coin(1_000_000_000, "umrg"), + ]) + .unwrap(); + + let empty = app.init_account(&[coin(1_000_000_000, "umrg")]).unwrap(); + + Self { + app, + signer, + handler, + empty, + traders: vec![trader1, trader2], + denoms, + } + } + + pub fn deploy_staking_contracts(&self, wasm: &Wasm) -> (String, String) { + let code_id = store_code(wasm, &self.signer, "margined_collector".to_string()); + let fee_collector_address = wasm + .instantiate( + code_id, + &FeeCollectorInstantiateMsg {}, + None, + Some("margined-fee-collector"), + &[coin(1_000_000_000_000, self.denoms["base"].clone())], + &self.signer, + ) + .unwrap() + .data + .address; + + let code_id = store_code(wasm, &self.signer, "margined_staking".to_string()); + let staking_address = wasm + .instantiate( + code_id, + &InstantiateMsg { + fee_collector: fee_collector_address.clone(), + deposit_denom: self.denoms["deposit"].clone(), + reward_denom: self.denoms["reward"].clone(), + deposit_decimals: 6u32, + reward_decimals: 6u32, + tokens_per_interval: 1_000_000u128.into(), + }, + None, + Some("margined-staking-contract"), + &[coin(1_000_000_000_000, self.denoms["base"].clone())], + &self.signer, + ) + .unwrap() + .data + .address; + + // add the reward token as a token + { + wasm.execute( + fee_collector_address.as_str(), + &FeeCollectorExecuteMsg::AddToken { + token: self.denoms["reward"].clone(), + }, + &[], + &self.signer, + ) + .unwrap(); + } + + // update the collector to have the staking contract as an auth + { + wasm.execute( + fee_collector_address.as_str(), + &FeeCollectorExecuteMsg::UpdateWhitelist { + address: staking_address.clone(), + }, + &[], + &self.signer, + ) + .unwrap(); + } + + (staking_address, fee_collector_address) + } + + pub fn deploy_staking_contract( + &self, + wasm: &Wasm, + contract_name: String, + fee_collector: String, + ) -> String { + let code_id = store_code(wasm, &self.signer, contract_name); + wasm.instantiate( + code_id, + &InstantiateMsg { + fee_collector, + deposit_denom: self.denoms["deposit"].clone(), + reward_denom: self.denoms["reward"].clone(), + deposit_decimals: 6u32, + reward_decimals: 6u32, + tokens_per_interval: 1_000_000u128.into(), + }, + None, + Some("margined-staking-contract"), + &[coin(1_000_000_000_000, self.denoms["base"].clone())], + &self.signer, + ) + .unwrap() + .data + .address + } + + pub fn deploy_fee_collector_contract( + &self, + wasm: &Wasm, + contract_name: String, + ) -> String { + let code_id = store_code(wasm, &self.signer, contract_name); + wasm.instantiate( + code_id, + &FeeCollectorInstantiateMsg {}, + None, + Some("margined-collector-contract"), + &[], + &self.signer, + ) + .unwrap() + .data + .address + } + + pub fn get_balance(&self, address: String, denom: String) -> Uint128 { + let bank = Bank::new(&self.app); + + let response = bank + .query_balance(&QueryBalanceRequest { address, denom }) + .unwrap(); + + match response.balance { + Some(balance) => Uint128::from_str(&balance.amount).unwrap(), + None => Uint128::zero(), + } + } +} + +impl Default for StakingEnv { + fn default() -> Self { + Self::new() + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..946440a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.69.0" +targets = [ "wasm32-unknown-unknown"]