diff --git a/.ci/commit-check.awk b/.ci/commit-check.awk new file mode 100644 index 0000000..2d5530e --- /dev/null +++ b/.ci/commit-check.awk @@ -0,0 +1 @@ +$0 !~ /(.*@levana\.exchange$)|(noreply@github\.com$)|(.*@users\.noreply\.github\.com$)/ { print "Invalid email found in your git commit: " $0; exit 1 } diff --git a/.ci/contracts.sh b/.ci/contracts.sh new file mode 100755 index 0000000..d24c2b8 --- /dev/null +++ b/.ci/contracts.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +SCRIPT=$(readlink -f "$0") +SCRIPTPATH=$(dirname "$SCRIPT") +cd "$SCRIPTPATH" +cd .. + +WASM_DIR="$(pwd)/wasm" +TARGET_CACHE="$WASM_DIR/target" +REGISTRY_CACHE="$WASM_DIR/registry" +CARGO_GIT_CACHE="$WASM_DIR/git" +ARTIFACTS="$WASM_DIR/artifacts" + +OPTIMIZER_VERSION="cosmwasm/rust-optimizer":0.15.1 + +mkdir -p "$TARGET_CACHE" "$REGISTRY_CACHE" "$ARTIFACTS" "$CARGO_GIT_CACHE" + +# Delete the old file to avoid false positives if the compilation fails +rm -f "$WASM_DIR/artifacts/gitrev" + +docker run --rm --tty \ +-u "$(id -u)":"$(id -g)" \ +-v "$(pwd)/contract":/code \ +-v "$TARGET_CACHE":/target \ +-v "$ARTIFACTS":/code/artifacts \ +-v "$REGISTRY_CACHE":/usr/local/cargo/registry \ +-v "$CARGO_GIT_CACHE":/usr/local/cargo/git \ +$OPTIMIZER_VERSION + +# not sure how this was created since we mapped the tool's /code/artifacts +# but it's empty (the real artifacts are in wasm/artifacts) +rm -rf ./artifacts + +# Only write the gitrev file on success +git rev-parse HEAD > "$WASM_DIR/artifacts/gitrev" diff --git a/.ci/make-source-tarball.sh b/.ci/make-source-tarball.sh new file mode 100755 index 0000000..8560ccf --- /dev/null +++ b/.ci/make-source-tarball.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -euxo pipefail + +if [ -z ${1+x} ] +then + echo "Please provide a Git hash or other tree-ish" + exit 1 +fi + +rm -rf tmp +mkdir -p tmp +git archive -o tmp/source.tar "$1" + +pushd tmp +tar xf source.tar +rm source.tar +popd + +DIR=levana-predict-$1 +rm -rf "$DIR" +mkdir -p "$DIR" + +mv -i tmp/.ci/contracts.sh tmp/contract/build.sh +mv -i tmp/contract "$DIR" + +rm -rf tmp + +pushd "$DIR/contract" +cargo test +rm -rf target +./build.sh +popd + +mkdir -p source-tarballs +cp "$DIR/wasm/artifacts/checksums.txt" "source-tarballs/$DIR-checksums.txt" +rm -rf "$DIR/wasm" +tar czfv "source-tarballs/$DIR.tar.gz" "$DIR" +rm -rf "$DIR" diff --git a/.github/workflows/contracts.yaml b/.github/workflows/contracts.yaml new file mode 100644 index 0000000..f52ee14 --- /dev/null +++ b/.github/workflows/contracts.yaml @@ -0,0 +1,56 @@ +name: Rust + +on: + push: + branches: [develop,main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUST_BACKTRACE: short + RUSTUP_MAX_RETRIES: 10 + +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: taiki-e/install-action@v2 + with: + tool: just@1.16.0 + - name: Check commit + run: just check-commits + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.75.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + contract + - name: Cache contract builds + uses: actions/cache@v4 + with: + key: contracts-${{ hashFiles('Cargo.toml') }}-${{hashFiles('Cargo.lock')}} + restore-keys: | + contracts-${{ hashFiles('Cargo.toml') }} + contracts + path: | + wasm + - name: Compile + run: just cargo-compile + - name: Run tests + run: just cargo-test + - name: Clippy + run: just cargo-clippy-check + working-directory: contract + - name: Rustfmt + run: just cargo-fmt-check + working-directory: contract + - name: Build contracts + run: just build-contracts + - name: Print contract sizes + run: ls -l wasm/artifacts/*.wasm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adfde28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +wasm +.envrc +/source-tarballs/ diff --git a/contract/.gitignore b/contract/.gitignore new file mode 100644 index 0000000..67869a9 --- /dev/null +++ b/contract/.gitignore @@ -0,0 +1,2 @@ +/target +/schema diff --git a/contract/Cargo.lock b/contract/Cargo.lock new file mode 100644 index 0000000..acb69b7 --- /dev/null +++ b/contract/Cargo.lock @@ -0,0 +1,850 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "anyhow" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cosmwasm-crypto" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b4c3f9c4616d6413d4b5fc4c270a4cc32a374b9be08671e80e1a019f805d8f" +dependencies = [ + "digest 0.10.7", + "ecdsa", + "ed25519-zebra", + "k256", + "rand_core 0.6.4", + "thiserror", +] + +[[package]] +name = "cosmwasm-derive" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c586ced10c3b00e809ee664a895025a024f60d65d34fe4c09daed4a4db68a3f3" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-schema" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3e3a2136e2a60e8b6582f5dffca5d1a683ed77bf38537d330bc1dfccd69010" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d803bea6bd9ed61bd1ee0b4a2eb09ee20dbb539cc6e0b8795614d20952ebb1" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-std" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712fe58f39d55c812f7b2c84e097cdede3a39d520f89b6dc3153837e31741927" +dependencies = [ + "base64", + "bech32", + "bnum", + "cosmwasm-crypto", + "cosmwasm-derive", + "derivative", + "forward_ref", + "hex", + "schemars", + "serde", + "serde-json-wasm", + "sha2 0.10.8", + "static_assertions", + "thiserror", +] + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +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 = "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-multi-test" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fff029689ae89127cf6d7655809a68d712f3edbdb9686c70b018ba438b26ca" +dependencies = [ + "anyhow", + "bech32", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "derivative", + "itertools 0.12.1", + "prost", + "schemars", + "serde", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "cw-storage-plus" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5ff29294ee99373e2cd5fd21786a3c0ced99a52fec2ca347d565489c61b723c" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-utils" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4a657e5caacc3a0d00ee96ca8618745d050b8f757c709babafb81208d4239c" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw2", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cw2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c120b24fbbf5c3bedebb97f2cc85fbfa1c3287e09223428e7e597b5293c1fa" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +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", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "forward_ref" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[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 = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "k256" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.8", + "signature", +] + +[[package]] +name = "levana-predict" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "cw2", + "schemars", + "semver", + "serde", + "strum", + "strum_macros", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c289cda302b98a28d40c8b3b90498d6e526dd24ac2ecea73e4e491685b94a" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" +dependencies = [ + "anyhow", + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[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 = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustversion" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "schemars" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a28f4c49489add4ce10783f7911893516f15afe45d015608d41faca6bc4d29" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c767fd6fa65d9ccf9cf026122c1b555f2ef9a4f0cea69da4d7dbc3e258d30967" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[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.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.48", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[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.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/contract/Cargo.toml b/contract/Cargo.toml new file mode 100644 index 0000000..1e2fdc6 --- /dev/null +++ b/contract/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "levana-predict" +version = "0.1.0" +authors = ["Levana Team"] +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false +overflow-checks = true + +[dependencies] +cosmwasm-std = "1.5.4" +cw2 = "1.1.2" +cw-storage-plus = "1.2.0" +schemars = "0.8.15" +serde = { version = "1.0.189", default-features = false, features = ["derive"] } +thiserror = "1.0.58" +strum = "0.26.2" +strum_macros = "0.26.2" +semver = "1.0.23" + +[dev-dependencies] +cosmwasm-schema = "1.5.0" +cw-multi-test = "0.20.0" diff --git a/contract/TODO.md b/contract/TODO.md new file mode 100644 index 0000000..c0ded34 --- /dev/null +++ b/contract/TODO.md @@ -0,0 +1,2 @@ +* Keep track of wallet count per outcome +* Add tests diff --git a/contract/rust-toolchain.toml b/contract/rust-toolchain.toml new file mode 100644 index 0000000..bdbeb14 --- /dev/null +++ b/contract/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.75.0" +targets = ["wasm32-unknown-unknown"] diff --git a/contract/src/api.rs b/contract/src/api.rs new file mode 100644 index 0000000..035be1b --- /dev/null +++ b/contract/src/api.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeMap; + +use crate::prelude::*; + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +pub struct InstantiateMsg { + pub admin: String, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + AddMarket { + params: Box, + }, + /// Place a bet on an outcome + Deposit { + id: MarketId, + outcome: OutcomeId, + }, + /// Withdraw funds bet on an outcome + Withdraw { + id: MarketId, + outcome: OutcomeId, + tokens: Token, + }, + /// Declare the winner of a market + SetWinner { + id: MarketId, + outcome: OutcomeId, + }, + /// Collect winnings from a market + Collect { + id: MarketId, + }, + /// Appoint a new admin + AppointAdmin { + addr: String, + }, + /// Accept admin privileges + AcceptAdmin {}, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct AddMarketParams { + pub title: String, + pub description: String, + /// Wallet address + pub arbitrator: String, + pub outcomes: Vec, + /// Denom of collateral for this market. + /// + /// Arguably this is unnecessary, it can be picked up from submitted funds. + /// But it's a double-check, and makes the internal code a bit tidier. + pub denom: String, + /// Given as a ratio, e.g. 0.01 means 1% + pub deposit_fee: Decimal256, + pub withdrawal_fee: Decimal256, + pub withdrawal_stop_date: Timestamp, + pub deposit_stop_date: Timestamp, + /// Which wallet receives house winnings. + pub house: String, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct OutcomeDef { + pub label: String, + pub initial_amount: Collateral, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Returns [GlobalInfo] + GlobalInfo {}, + /// Returns [MarketResp] + Market { id: MarketId }, + /// Returns [PositionsResp] + Positions { id: MarketId, addr: String }, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct GlobalInfo { + pub latest_market_id: Option, + pub admin: Addr, +} + +pub type MarketResp = StoredMarket; + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct OutcomeInfo { + pub label: String, + pub tokens: Token, + pub wallet_count: u32, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct PositionsResp { + pub outcomes: BTreeMap, +} + +#[derive(Serialize, Deserialize, JsonSchema, Debug)] +pub struct MigrateMsg {} diff --git a/contract/src/constants.rs b/contract/src/constants.rs new file mode 100644 index 0000000..5e35934 --- /dev/null +++ b/contract/src/constants.rs @@ -0,0 +1,2 @@ +pub const CONTRACT_NAME: &str = "levana.finance:predict"; +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/contract/src/cpmm.rs b/contract/src/cpmm.rs new file mode 100644 index 0000000..5889732 --- /dev/null +++ b/contract/src/cpmm.rs @@ -0,0 +1,90 @@ +use crate::prelude::*; + +impl StoredMarket { + /// Adds liquidity to the market without changing prices of assets. + pub fn add_liquidity(&mut self, funds: Collateral) { + let new_total = self.pool_size + funds; + let scale = new_total / self.pool_size; + self.pool_size = new_total; + for outcome in &mut self.outcomes { + let old = outcome.pool_tokens; + outcome.pool_tokens *= scale; + outcome.total_tokens += outcome.pool_tokens - old; + } + } + + /// Place a bet on the given outcome. + /// + /// Returns the number of tokens purchased + pub fn buy(&mut self, selected_outcome: OutcomeId, funds: Collateral) -> Result { + let new_funds = self.pool_size + funds; + let mut product_others = Decimal256::one(); + let mut invariant = Decimal256::one(); + + for (idx, outcome) in self.outcomes.iter_mut().enumerate() { + let id = OutcomeId(u8::try_from(idx + 1)?); + + // Calculate the invariant _before_ scaling up the token counts. + invariant *= outcome.pool_tokens.0; + + let old_tokens = outcome.pool_tokens; + outcome.pool_tokens *= new_funds / self.pool_size; + outcome.total_tokens += outcome.pool_tokens - old_tokens; + if id != selected_outcome { + product_others *= outcome.pool_tokens.0; + } + } + + let pool_selected = Token(invariant / product_others); + + let outcome = self + .outcomes + .get_mut(usize::from(selected_outcome.0 - 1)) + .unwrap(); + let returned = outcome.pool_tokens - pool_selected; + outcome.pool_tokens = pool_selected; + self.pool_size = new_funds; + + Ok(returned) + } + + /// Burns the given number of tokens for the given outcome. + /// + /// Returns the amount of liquidity freed up. + pub fn sell(&mut self, selected_outcome: OutcomeId, tokens: Token) -> Result { + self.assert_valid_outcome(selected_outcome)?; + + let mut invariant = Decimal256::one(); + let mut product = Decimal256::one(); + for (idx, outcome) in self.outcomes.iter_mut().enumerate() { + invariant *= outcome.pool_tokens.0; + let id = OutcomeId(u8::try_from(idx + 1)?); + if id == selected_outcome { + outcome.pool_tokens += tokens; + } + product *= outcome.pool_tokens.0; + } + + let scale = if self.outcomes.len() == 2 { + (invariant / product).sqrt() + } else { + panic!("Only supports 2 outcomes at the moment") + }; + for outcome in &mut self.outcomes { + let old = outcome.pool_tokens; + outcome.pool_tokens *= scale; + outcome.total_tokens -= old - outcome.pool_tokens; + } + let new_funds = (self.pool_size * scale)?; + let returned = self.pool_size - new_funds; + self.pool_size = new_funds; + Ok(returned) + } + + /// Winnings for the given number of tokens in the given winner. + pub(crate) fn winnings_for(&self, winner: OutcomeId, tokens: Token) -> Result { + self.assert_valid_outcome(winner)?; + let outcome = self.outcomes.get(usize::from(winner.0 - 1)).unwrap(); + (self.pool_size * (tokens / outcome.total_tokens)).map_err(Error::from) + } +} diff --git a/contract/src/error.rs b/contract/src/error.rs new file mode 100644 index 0000000..a06d8df --- /dev/null +++ b/contract/src/error.rs @@ -0,0 +1,99 @@ +use std::num::TryFromIntError; + +use cosmwasm_std::{ConversionOverflowError, OverflowError}; + +use crate::prelude::*; + +pub type Result = std::result::Result; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Std(#[from] StdError), + #[error(transparent)] + ConversionOverflow(#[from] ConversionOverflowError), + #[error(transparent)] + Overflow(#[from] OverflowError), + #[error(transparent)] + TryFromInt(#[from] TryFromIntError), + #[error( + "Multiple assets provided, this contract only supports 0 or 1 assets attached per message" + )] + MultipleAssetsProvided, + #[error("Message requires no funds, but {amount}{denom} was provided.")] + UnexpectedFunds { denom: String, amount: Uint128 }, + #[error("Incorrect funds denomination. Received {amount}{actual_denom}, but {required_denom} is expected.")] + IncorrectFundsDenom { + actual_denom: String, + amount: Uint128, + required_denom: String, + }, + #[error("No funds provided, but this method requires sending {denom}")] + MissingRequiredFunds { denom: String }, + #[error("The sender address is not the admin of the contract")] + Unauthorized, + #[error("Incorrect funds specified per outcome. Total provided: {provided}. Total specified: {specified}.")] + IncorrectFundsPerOutcome { + provided: Collateral, + specified: Collateral, + }, + #[error("Deposit stop date ({deposit_stop_date}) is before the withdrawal stop date ({withdrawal_stop_date})")] + DepositStopDateBeforeWithdrawalStop { + withdrawal_stop_date: Timestamp, + deposit_stop_date: Timestamp, + }, + #[error("Withdrawal stop date ({withdrawal_stop_date}) is in the past. Current time: {now}.")] + WithdrawalStopDateInPast { + now: Timestamp, + withdrawal_stop_date: Timestamp, + }, + #[error("Market not found: {}", id.0)] + MarketNotFound { id: MarketId }, + #[error("No positions found for sending wallet address on market {id}")] + NoPositionsOnMarket { id: MarketId }, + #[error("No tokens found for sending wallet address on market {id}, outcome {outcome}")] + NoTokensFound { id: MarketId, outcome: OutcomeId }, + #[error("Insufficient tokens on market {id}, outcome {outcome}. Requested: {requested}. Available: {available}.")] + InsufficientTokens { + id: MarketId, + outcome: OutcomeId, + requested: Token, + available: Token, + }, + #[error("Withdrawals for market {id} have been stopped. Stop time: {withdrawal_stop_date}. Current time: {now}.")] + WithdrawalsStopped { + id: MarketId, + now: Timestamp, + withdrawal_stop_date: Timestamp, + }, + #[error("Deposits for market {id} have been stopped. Stop time: {deposit_stop_date}. Current time: {now}.")] + DepositsStopped { + id: MarketId, + now: Timestamp, + deposit_stop_date: Timestamp, + }, + #[error("The market {id} is still active. Time is currently {now}, and deposits will be stopped at {deposit_stop_date}.")] + MarketStillActive { + id: MarketId, + now: Timestamp, + deposit_stop_date: Timestamp, + }, + #[error( + "Invalid outcome {outcome} specified for market {id}. Total outcome count: {outcome_count}." + )] + InvalidOutcome { + id: MarketId, + outcome_count: u32, + outcome: OutcomeId, + }, + #[error("Winner already set for market {id}")] + WinnerAlreadySet { id: MarketId }, + #[error("No winner set for market {id}")] + NoWinnerSet { id: MarketId }, + #[error("You already claimed winnings for market {id}")] + AlreadyClaimedWinnings { id: MarketId }, + #[error("No appointed admin set")] + NoAppointedAdmin {}, + #[error("You are not the appointed admin")] + NotAppointedAdmin {}, +} diff --git a/contract/src/execute.rs b/contract/src/execute.rs new file mode 100644 index 0000000..200599b --- /dev/null +++ b/contract/src/execute.rs @@ -0,0 +1,344 @@ +use cosmwasm_std::{BankMsg, CosmosMsg, Event}; + +use crate::{ + prelude::*, + util::{assert_is_admin, Funds}, +}; + +#[entry_point] +pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> Result { + let funds = Funds::from_message_info(&info)?; + + match msg { + ExecuteMsg::AddMarket { params } => { + assert_is_admin(deps.storage, &info)?; + add_market(deps, env, *params, funds) + } + ExecuteMsg::Deposit { id, outcome } => deposit(deps, env, info, id, outcome, funds), + ExecuteMsg::Withdraw { + id, + outcome, + tokens, + } => { + funds.require_none()?; + withdraw(deps, env, info, id, outcome, tokens) + } + ExecuteMsg::SetWinner { id, outcome } => { + funds.require_none()?; + set_winner(deps, env, info, id, outcome) + } + ExecuteMsg::Collect { id } => { + funds.require_none()?; + collect(deps, info, id) + } + ExecuteMsg::AppointAdmin { addr } => { + funds.require_none()?; + assert_is_admin(deps.storage, &info)?; + appoint_admin(deps, addr) + } + ExecuteMsg::AcceptAdmin {} => { + funds.require_none()?; + accept_admin(deps, info) + } + } +} + +fn add_market( + deps: DepsMut, + env: Env, + AddMarketParams { + title, + description, + arbitrator, + outcomes, + denom, + deposit_fee, + withdrawal_fee, + withdrawal_stop_date, + deposit_stop_date, + house, + }: AddMarketParams, + funds: Funds, +) -> Result { + if env.block.time >= withdrawal_stop_date { + return Err(Error::WithdrawalStopDateInPast { + now: env.block.time, + withdrawal_stop_date, + }); + } + if withdrawal_stop_date > deposit_stop_date { + return Err(Error::DepositStopDateBeforeWithdrawalStop { + withdrawal_stop_date, + deposit_stop_date, + }); + } + + let funds = funds.require_funds(&denom)?; + let id = LAST_MARKET_ID + .may_load(deps.storage)? + .map_or_else(MarketId::one, MarketId::next); + LAST_MARKET_ID.save(deps.storage, &id)?; + let arbitrator = deps.api.addr_validate(&arbitrator)?; + let mut total = Collateral::zero(); + let outcomes = outcomes + .into_iter() + .enumerate() + .map( + |( + idx, + OutcomeDef { + label, + initial_amount, + }, + )| { + total += initial_amount; + let id = OutcomeId(u8::try_from(idx + 1).unwrap()); + let pool_tokens = Token(Decimal256::from_ratio(initial_amount.0, 1u8)); + StoredOutcome { + id, + label, + pool_tokens, + total_tokens: pool_tokens, + } + }, + ) + .collect(); + if total != funds { + return Err(Error::IncorrectFundsPerOutcome { + provided: funds, + specified: total, + }); + } + MARKETS.save( + deps.storage, + id, + &StoredMarket { + id, + title, + description, + arbitrator, + outcomes, + denom, + deposit_fee, + withdrawal_fee, + pool_size: total, + deposit_stop_date, + withdrawal_stop_date, + winner: None, + house: deps.api.addr_validate(&house)?, + }, + )?; + + Ok(Response::new() + .add_event(Event::new("add-market").add_attribute("market-id", id.0.to_string()))) +} + +fn deposit( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: MarketId, + outcome: OutcomeId, + funds: Funds, +) -> Result { + let mut market = StoredMarket::load(deps.storage, id)?; + + if env.block.time >= market.deposit_stop_date { + return Err(Error::DepositsStopped { + id, + now: env.block.time, + deposit_stop_date: market.deposit_stop_date, + }); + } + + let deposit_amount = funds.require_funds(&market.denom)?; + let fee = Decimal256::from_ratio(deposit_amount.0, 1u8) * market.deposit_fee; + let fee = Collateral(Uint128::try_from(fee.to_uint_ceil())?); + market.add_liquidity(fee); + let funds = deposit_amount.checked_sub(fee)?; + let tokens = market.buy(outcome, funds)?; + MARKETS.save(deps.storage, id, &market)?; + let mut share_info = SHARES + .may_load(deps.storage, (id, &info.sender))? + .unwrap_or_default(); + *share_info + .outcomes + .entry(outcome) + .or_insert_with(Token::zero) += tokens; + SHARES.save(deps.storage, (id, &info.sender), &share_info)?; + Ok(Response::new().add_event( + Event::new("deposit") + .add_attribute("market-id", id.0.to_string()) + .add_attribute("outcome-id", outcome.0.to_string()) + .add_attribute("tokens", tokens.0.to_string()) + .add_attribute("deposit-amount", deposit_amount.to_string()) + .add_attribute("fee", fee.to_string()), + )) +} + +fn withdraw( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: MarketId, + outcome: OutcomeId, + tokens: Token, +) -> Result { + let mut market = StoredMarket::load(deps.storage, id)?; + + if env.block.time >= market.withdrawal_stop_date { + return Err(Error::WithdrawalsStopped { + id, + now: env.block.time, + withdrawal_stop_date: market.withdrawal_stop_date, + }); + } + + let mut share_info = SHARES + .may_load(deps.storage, (id, &info.sender))? + .ok_or(Error::NoPositionsOnMarket { id })?; + + let user_tokens = share_info + .outcomes + .get_mut(&outcome) + .ok_or(Error::NoTokensFound { id, outcome })?; + + if *user_tokens < tokens { + return Err(Error::InsufficientTokens { + id, + outcome, + requested: tokens, + available: *user_tokens, + }); + } + + SHARES.save(deps.storage, (id, &info.sender), &share_info)?; + + let funds = market.sell(outcome, tokens)?; + + let fee = Decimal256::from_ratio(funds.0, 1u8) * market.withdrawal_fee; + let fee = Collateral(Uint128::try_from(fee.to_uint_ceil())?); + market.add_liquidity(fee); + let funds = funds.checked_sub(fee)?; + MARKETS.save(deps.storage, id, &market)?; + Ok(Response::new() + .add_event( + Event::new("deposit") + .add_attribute("market-id", id.0.to_string()) + .add_attribute("outcome-id", outcome.0.to_string()) + .add_attribute("tokens", tokens.0.to_string()) + .add_attribute("fee", fee.to_string()) + .add_attribute("withdrawal", funds.to_string()), + ) + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.into_string(), + amount: vec![Coin { + denom: market.denom, + amount: funds.0, + }], + }))) +} + +fn set_winner( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: MarketId, + outcome: OutcomeId, +) -> Result { + let mut market = StoredMarket::load(deps.storage, id)?; + + if env.block.time < market.withdrawal_stop_date { + return Err(Error::MarketStillActive { + id, + now: env.block.time, + deposit_stop_date: market.deposit_stop_date, + }); + } + + if info.sender != market.arbitrator { + return Err(Error::Unauthorized); + } + + if market.winner.is_some() { + return Err(Error::WinnerAlreadySet { id }); + } + + market.assert_valid_outcome(outcome)?; + market.winner = Some(outcome); + MARKETS.save(deps.storage, id, &market)?; + + let house_winnings = market.winnings_for( + outcome, + market.outcomes[usize::from(outcome.0 - 1)].pool_tokens, + )?; + + Ok(Response::new() + .add_event( + Event::new("set-winner") + .add_attribute("market-id", id.to_string()) + .add_attribute("outcome-id", outcome.to_string()), + ) + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: market.house.into_string(), + amount: vec![Coin { + denom: market.denom, + amount: house_winnings.0, + }], + }))) +} + +fn collect(deps: DepsMut, info: MessageInfo, id: MarketId) -> Result { + let market = StoredMarket::load(deps.storage, id)?; + let winner = market.winner.ok_or(Error::NoWinnerSet { id })?; + let mut share_info = SHARES + .may_load(deps.storage, (id, &info.sender))? + .ok_or(Error::NoPositionsOnMarket { id })?; + if share_info.claimed_winnings { + return Err(Error::AlreadyClaimedWinnings { id }); + } + share_info.claimed_winnings = true; + let tokens = share_info + .outcomes + .get(&winner) + .ok_or(Error::NoTokensFound { + id, + outcome: winner, + })?; + SHARES.save(deps.storage, (id, &info.sender), &share_info)?; + let winnings = market.winnings_for(winner, *tokens)?; + + Ok(Response::new() + .add_event( + Event::new("collect") + .add_attribute("market-id", id.to_string()) + .add_attribute("winner", winner.to_string()) + .add_attribute("tokens", tokens.to_string()), + ) + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.into_string(), + amount: vec![Coin { + denom: market.denom, + amount: winnings.0, + }], + }))) +} + +fn appoint_admin(deps: DepsMut, addr: String) -> Result { + let addr = deps.api.addr_validate(&addr)?; + APPOINTED_ADMIN.save(deps.storage, &addr)?; + Ok(Response::new() + .add_event(Event::new("appoint-admin").add_attribute("new-admin", addr.into_string()))) +} + +fn accept_admin(deps: DepsMut, info: MessageInfo) -> Result { + let appointed = APPOINTED_ADMIN + .may_load(deps.storage)? + .ok_or(Error::NoAppointedAdmin {})?; + if appointed != info.sender { + return Err(Error::NotAppointedAdmin {}); + } + APPOINTED_ADMIN.remove(deps.storage); + ADMIN.save(deps.storage, &appointed)?; + Ok(Response::new().add_event(Event::new("accept-admin").add_attribute("new-admin", appointed))) +} diff --git a/contract/src/instantiate.rs b/contract/src/instantiate.rs new file mode 100644 index 0000000..6fb662d --- /dev/null +++ b/contract/src/instantiate.rs @@ -0,0 +1,16 @@ +use crate::prelude::*; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + InstantiateMsg { admin }: InstantiateMsg, +) -> Result { + let admin = deps.api.addr_validate(&admin)?; + ADMIN.save(deps.storage, &admin)?; + + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new()) +} diff --git a/contract/src/lib.rs b/contract/src/lib.rs new file mode 100644 index 0000000..d2ad58d --- /dev/null +++ b/contract/src/lib.rs @@ -0,0 +1,21 @@ +#![deny(clippy::as_conversions)] + +mod api; +mod constants; +mod cpmm; +mod error; +mod execute; +mod instantiate; +mod migrate; +mod prelude; +mod query; +mod state; +#[cfg(test)] +mod tests; +mod types; +mod util; + +pub use execute::execute; +pub use instantiate::instantiate; +pub use migrate::migrate; +pub use query::query; diff --git a/contract/src/migrate.rs b/contract/src/migrate.rs new file mode 100644 index 0000000..cb4f367 --- /dev/null +++ b/contract/src/migrate.rs @@ -0,0 +1,34 @@ +use crate::prelude::*; + +#[entry_point] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let current_version = cw2::get_contract_version(deps.storage)?; + let current = current_version + .version + .parse::() + .map_err(|x| StdError::generic_err(x.to_string()))?; + + let new = CONTRACT_VERSION + .parse::() + .map_err(|x| StdError::generic_err(x.to_string()))?; + + if current_version.contract != CONTRACT_NAME { + return Err(StdError::generic_err(format!( + "Contract name mismatch. Current: {}, New: {}", + current_version.contract, CONTRACT_NAME + )) + .into()); + } + + if current >= new { + return Err(StdError::generic_err(format!( + "Current contract version is older or equivalent to the new one. Current: {}, New: {}", + current, new + )) + .into()); + } + + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::default()) +} diff --git a/contract/src/prelude.rs b/contract/src/prelude.rs new file mode 100644 index 0000000..f61bc35 --- /dev/null +++ b/contract/src/prelude.rs @@ -0,0 +1,9 @@ +pub use crate::{api::*, constants::*, error::*, state::*, types::*}; +pub use cosmwasm_std::{ + entry_point, Addr, Api, Binary, Coin, Decimal256, Deps, DepsMut, Env, MessageInfo, Response, + StdError, StdResult, Storage, Timestamp, Uint128, +}; +pub use cw_storage_plus::{Item, Map}; +pub use schemars::JsonSchema; +pub use semver::Version; +pub use serde::{Deserialize, Serialize}; diff --git a/contract/src/query.rs b/contract/src/query.rs new file mode 100644 index 0000000..7672159 --- /dev/null +++ b/contract/src/query.rs @@ -0,0 +1,33 @@ +use cosmwasm_std::to_json_binary; + +use crate::prelude::*; + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::GlobalInfo {} => to_json_binary(&global_info(deps)?), + QueryMsg::Market { id } => to_json_binary(&market(deps, id)?), + QueryMsg::Positions { id, addr } => to_json_binary(&positions(deps, id, addr)?), + } + .map_err(Error::from) +} + +fn global_info(deps: Deps) -> Result { + Ok(GlobalInfo { + latest_market_id: LAST_MARKET_ID.may_load(deps.storage)?, + admin: ADMIN.load(deps.storage)?, + }) +} + +fn market(deps: Deps, id: MarketId) -> Result { + StoredMarket::load(deps.storage, id) +} + +fn positions(deps: Deps, id: MarketId, addr: String) -> Result { + let addr = deps.api.addr_validate(&addr)?; + let outcomes = SHARES + .may_load(deps.storage, (id, &addr))? + .unwrap_or_default() + .outcomes; + Ok(PositionsResp { outcomes }) +} diff --git a/contract/src/state.rs b/contract/src/state.rs new file mode 100644 index 0000000..34da005 --- /dev/null +++ b/contract/src/state.rs @@ -0,0 +1,65 @@ +use std::collections::BTreeMap; + +use crate::prelude::*; + +pub const ADMIN: Item = Item::new("admin"); + +pub const APPOINTED_ADMIN: Item = Item::new("appointed-admin"); + +pub const LAST_MARKET_ID: Item = Item::new("last-market-id"); + +pub const MARKETS: Map = Map::new("markets"); + +pub const SHARES: Map<(MarketId, &Addr), ShareInfo> = Map::new("shares"); + +#[derive(Serialize, Deserialize)] +pub struct StoredMarket { + pub id: MarketId, + pub title: String, + pub description: String, + pub arbitrator: Addr, + pub outcomes: Vec, + pub denom: String, + pub deposit_fee: Decimal256, + pub withdrawal_fee: Decimal256, + pub pool_size: Collateral, + pub deposit_stop_date: Timestamp, + pub withdrawal_stop_date: Timestamp, + pub winner: Option, + pub house: Addr, +} + +impl StoredMarket { + pub fn load(store: &dyn Storage, id: MarketId) -> Result { + MARKETS + .may_load(store, id)? + .ok_or(Error::MarketNotFound { id }) + } + + pub(crate) fn assert_valid_outcome(&self, outcome_id: OutcomeId) -> Result<()> { + let outcome = usize::from(outcome_id.0); + if outcome == 0 || outcome > self.outcomes.len() { + Err(Error::InvalidOutcome { + id: self.id, + outcome_count: u32::try_from(self.outcomes.len())?, + outcome: outcome_id, + }) + } else { + Ok(()) + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct StoredOutcome { + pub id: OutcomeId, + pub label: String, + pub pool_tokens: Token, + pub total_tokens: Token, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct ShareInfo { + pub outcomes: BTreeMap, + pub claimed_winnings: bool, +} diff --git a/contract/src/tests.rs b/contract/src/tests.rs new file mode 100644 index 0000000..642388a --- /dev/null +++ b/contract/src/tests.rs @@ -0,0 +1,155 @@ +use cosmwasm_std::Addr; +use cw_multi_test::{error::AnyResult, App, AppResponse, ContractWrapper, Executor}; + +use crate::prelude::*; + +struct Predict { + app: App, + contract: Addr, + admin: Addr, + arbitrator: Addr, + house: Addr, + id: MarketId, +} + +const DENOM: &str = "satoshi"; + +impl Predict { + fn new() -> Self { + let admin = Addr::unchecked("admin"); + let arbitrator = Addr::unchecked("arbitrator"); + let house = Addr::unchecked("house"); + let mut app = App::new(|router, _, storage| { + router + .bank + .init_balance( + storage, + &admin, + vec![Coin { + denom: DENOM.to_owned(), + amount: 1_000_000_000u32.into(), + }], + ) + .unwrap(); + }); + let wrapper = Box::new(ContractWrapper::new( + crate::execute, + crate::instantiate, + crate::query, + )); + let id = app.store_code(wrapper); + let contract = app + .instantiate_contract( + id, + admin.clone(), + &InstantiateMsg { + admin: admin.clone().into_string(), + }, + &[], + "predict", + None, + ) + .unwrap(); + let params = AddMarketParams { + title: "Test market".to_owned(), + description: "Test description".to_owned(), + arbitrator: arbitrator.clone().into_string(), + outcomes: vec![ + OutcomeDef { + label: "Yes".to_owned(), + initial_amount: Collateral(100u16.into()), + }, + OutcomeDef { + label: "No".to_owned(), + initial_amount: Collateral(900u16.into()), + }, + ], + denom: DENOM.to_owned(), + deposit_fee: "0.01".parse().unwrap(), + withdrawal_fee: "0.01".parse().unwrap(), + withdrawal_stop_date: app.block_info().time.plus_days(1), + deposit_stop_date: app.block_info().time.plus_days(2), + house: house.clone().into_string(), + }; + app.execute_contract( + admin.clone(), + contract.clone(), + &ExecuteMsg::AddMarket { + params: params.into(), + }, + &[Coin { + denom: DENOM.to_owned(), + amount: 1000u16.into(), + }], + ) + .unwrap(); + Predict { + app, + admin, + contract, + arbitrator, + house, + id: MarketId(1), + } + } + + fn execute( + &mut self, + sender: &Addr, + msg: &ExecuteMsg, + funds: Option, + ) -> AnyResult { + let mut helper = |funds| { + self.app + .execute_contract(sender.clone(), self.contract.clone(), msg, funds) + }; + match funds { + Some(funds) => helper(&[Coin { + denom: DENOM.to_owned(), + amount: funds.into(), + }]), + None => helper(&[]), + } + } +} + +#[test] +fn sanity() { + let mut app = Predict::new(); + app.app.update_block(|b| { + b.height += 200; + b.time = b.time.plus_days(3); + }); + let id = app.id; + let admin = app.admin.clone(); + app.execute( + &admin, + &ExecuteMsg::SetWinner { + id, + outcome: OutcomeId(1), + }, + None, + ) + .unwrap_err(); + let arb = app.arbitrator.clone(); + + app.execute( + &arb, + &ExecuteMsg::SetWinner { + id, + outcome: OutcomeId(1), + }, + None, + ) + .unwrap(); + + let Coin { + denom: _, + amount: amount_after, + } = app + .app + .wrap() + .query_balance(app.house.clone(), DENOM) + .unwrap(); + assert_eq!(Uint128::from(1000u16), amount_after); +} diff --git a/contract/src/types.rs b/contract/src/types.rs new file mode 100644 index 0000000..fbda3e7 --- /dev/null +++ b/contract/src/types.rs @@ -0,0 +1,175 @@ +use std::{ + fmt::Display, + ops::{Add, AddAssign, Div, Mul, MulAssign, Sub, SubAssign}, +}; + +use cosmwasm_std::{ConversionOverflowError, OverflowError}; +use cw_storage_plus::{Key, KeyDeserialize, Prefixer, PrimaryKey}; + +use crate::prelude::*; + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Copy, PartialEq, Eq)] +pub struct Collateral(pub Uint128); +impl Collateral { + pub(crate) fn zero() -> Self { + Collateral(Uint128::zero()) + } + + pub(crate) fn checked_sub(&self, rhs: Collateral) -> Result { + self.0.checked_sub(rhs.0).map(Collateral) + } +} + +impl Add for Collateral { + type Output = Collateral; + + fn add(self, rhs: Self) -> Self::Output { + Collateral(self.0 + rhs.0) + } +} + +impl AddAssign for Collateral { + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } +} + +impl Sub for Collateral { + type Output = Collateral; + + fn sub(self, rhs: Self) -> Self::Output { + Collateral(self.0 - rhs.0) + } +} + +impl Mul for Collateral { + type Output = Result; + + fn mul(self, rhs: Decimal256) -> Self::Output { + let uint256 = (Decimal256::from_ratio(self.0, 1u8) * rhs).to_uint_floor(); + uint256.try_into().map(Collateral) + } +} + +impl Div for Collateral { + type Output = Decimal256; + + fn div(self, rhs: Self) -> Self::Output { + Decimal256::from_ratio(self.0, rhs.0) + } +} + +impl Display for Collateral { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive( + Clone, Serialize, Deserialize, JsonSchema, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, +)] +pub struct Token(pub Decimal256); + +impl Sub for Token { + type Output = Token; + + fn sub(self, rhs: Self) -> Self::Output { + Token(self.0 - rhs.0) + } +} + +impl SubAssign for Token { + fn sub_assign(&mut self, rhs: Self) { + self.0 -= rhs.0; + } +} + +impl MulAssign for Token { + fn mul_assign(&mut self, rhs: Decimal256) { + self.0 *= rhs; + } +} + +impl Div for Token { + type Output = Decimal256; + + fn div(self, rhs: Self) -> Self::Output { + self.0 / rhs.0 + } +} + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Token { + pub fn zero() -> Self { + Token(Decimal256::zero()) + } +} + +impl AddAssign for Token { + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug, Copy)] +pub struct MarketId(pub u32); + +impl Display for MarketId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl MarketId { + pub fn one() -> Self { + MarketId(1) + } + + pub fn next(self) -> Self { + MarketId(self.0 + 1) + } +} + +impl<'a> PrimaryKey<'a> for MarketId { + type Prefix = (); + type SubPrefix = (); + type Suffix = MarketId; + type SuperSuffix = MarketId; + + #[inline] + fn key(&self) -> Vec { + PrimaryKey::key(&self.0) + } +} + +impl KeyDeserialize for MarketId { + type Output = Self; + + #[inline] + fn from_vec(value: Vec) -> StdResult { + ::from_vec(value).map(Self) + } +} + +impl<'a> Prefixer<'a> for MarketId { + #[inline] + fn prefix(&self) -> Vec { + ::prefix(&self.0) + } +} + +#[derive( + Clone, Serialize, Deserialize, JsonSchema, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, +)] +pub struct OutcomeId(pub u8); + +impl Display for OutcomeId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/contract/src/util.rs b/contract/src/util.rs new file mode 100644 index 0000000..88192ec --- /dev/null +++ b/contract/src/util.rs @@ -0,0 +1,61 @@ +use crate::prelude::*; + +#[must_use] +pub enum Funds { + NoFunds, + Funds { denom: String, amount: Uint128 }, +} + +impl Funds { + pub fn from_message_info(info: &MessageInfo) -> Result { + let mut iter = info.funds.iter(); + let Coin { denom, amount } = match iter.next() { + Some(coin) => coin, + None => return Ok(Funds::NoFunds), + }; + if iter.next().is_some() { + Err(Error::MultipleAssetsProvided) + } else { + Ok(Funds::Funds { + denom: denom.clone(), + amount: *amount, + }) + } + } + + pub fn require_none(self) -> Result<()> { + match self { + Funds::NoFunds => Ok(()), + Funds::Funds { denom, amount } => Err(Error::UnexpectedFunds { denom, amount }), + } + } + + pub fn require_funds(self, required_denom: &str) -> Result { + match self { + Funds::NoFunds => Err(Error::MissingRequiredFunds { + denom: required_denom.to_owned(), + }), + Funds::Funds { denom, amount } => { + if denom == required_denom { + Ok(Collateral(amount)) + } else { + Err(Error::IncorrectFundsDenom { + actual_denom: denom, + amount, + required_denom: required_denom.to_owned(), + }) + } + } + } + } +} + +pub fn assert_is_admin(storage: &dyn Storage, info: &MessageInfo) -> Result<()> { + let admin = ADMIN.load(storage)?; + + if admin == info.sender { + Ok(()) + } else { + Err(Error::Unauthorized) + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..a5bc27d --- /dev/null +++ b/justfile @@ -0,0 +1,29 @@ +# List all recipes +default: + just --list --unsorted + +# Build contracts with Cosmos Docker tooling +build-contracts: + ./.ci/contracts.sh + +# cargo clippy check +cargo-clippy-check: + cd contract && cargo clippy --no-deps --locked --tests --benches --examples -- -Dwarnings + +# cargo fmt check +cargo-fmt-check: + cd contract && cargo fmt --all --check + +# cargo compile +cargo-compile: + cd contract && cargo test --no-run --locked + +# cargo test +cargo-test: + cd contract && cargo test --locked + +# Check commits +check-commits: + git fetch origin main --depth=1 + git log --pretty=format:"%ae" $(git branch --show-current)...origin/main > email + awk -f ./.ci/commit-check.awk email