diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index d37ce6b..2218055 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -24,6 +24,7 @@ jobs: artifacts/cw_placeholder.wasm artifacts/cw_splitter.wasm artifacts/gauge_adapter.wasm + artifacts/lp_converter.wasm artifacts/nominated_trader.wasm artifacts/wyndex_multi_hop.wasm artifacts/wyndex_stake.wasm diff --git a/Cargo.lock b/Cargo.lock index 0c1984d..f06f7c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,58 +15,67 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.20" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" dependencies = [ "memchr", ] [[package]] name = "anstream" -version = "0.2.6" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342258dd14006105c2b75ab1bd7543a03bdf0cfc94383303ac212a04939dff6f" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" dependencies = [ "anstyle", "anstyle-parse", + "anstyle-query", "anstyle-wincon", - "concolor-override", - "concolor-query", + "colorchoice", "is-terminal", "utf8parse", ] [[package]] name = "anstyle" -version = "0.3.5" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ea9e81bd02e310c216d080f6223c179012256e5151c41db88d12c88a1684d2" +checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" [[package]] name = "anstyle-parse" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7d1bb534e9efed14f3e5f44e7dd1a4f709384023a4165199a4241e18dff0116" +checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "anstyle-wincon" -version = "0.2.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3127af6145b149f3287bb9a0d10ad9c5692dba8c53ad48285e5bec4063834fa" +checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] name = "anyhow" -version = "1.0.70" +version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4" +checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" [[package]] name = "autocfg" @@ -157,9 +166,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.2.1" +version = "4.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046ae530c528f252094e4a77886ee1374437744b2bff1497aa898bbddbbb29b3" +checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938" dependencies = [ "clap_builder", "clap_derive", @@ -168,9 +177,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.2.1" +version = "4.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223163f58c9a40c3b0a43e1c4b50a9ce09f007ea2cb1ec258a687945b4b7929f" +checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd" dependencies = [ "anstream", "anstyle", @@ -188,7 +197,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.16", ] [[package]] @@ -198,19 +207,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" [[package]] -name = "concolor-override" +name = "colorchoice" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a855d4a1978dc52fb0536a04d384c2c0c1aa273597f08b77c8c4d3b2eec6037f" - -[[package]] -name = "concolor-query" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf" -dependencies = [ - "windows-sys", -] +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "const-oid" @@ -220,9 +220,9 @@ checksum = "520fbf3c07483f94e3e3ca9d0cfd913d7718ef2483d2cfd91c0d9e91474ab913" [[package]] name = "cosmwasm-crypto" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f22add0f9b2a5416df98c1d0248a8d8eedb882c38fbf0c5052b64eebe865df6d" +checksum = "75836a10cb9654c54e77ee56da94d592923092a10b369cdb0dbd56acefc16340" dependencies = [ "digest 0.10.6", "ed25519-zebra", @@ -233,18 +233,18 @@ dependencies = [ [[package]] name = "cosmwasm-derive" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e64f710a18ef90d0a632cf27842e98ffc2d005a38a6f76c12fd0bc03bc1a2d" +checksum = "1c9f7f0e51bfc7295f7b2664fe8513c966428642aa765dad8a74acdab5e0c773" dependencies = [ "syn 1.0.109", ] [[package]] name = "cosmwasm-schema" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5ad2e23a971b9e4cd57b20cee3e2e79c33799bed4b128e473aca3702bfe5dd" +checksum = "0f00b363610218eea83f24bbab09e1a7c3920b79f068334fdfcc62f6129ef9fc" dependencies = [ "cosmwasm-schema-derive", "schemars", @@ -255,9 +255,9 @@ dependencies = [ [[package]] name = "cosmwasm-schema-derive" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2926d159a9bb1a716a592b40280f1663f2491a9de3b6da77c0933cee2a2655b8" +checksum = "ae38f909b2822d32b275c9e2db9728497aa33ffe67dd463bc67c6a3b7092785c" dependencies = [ "proc-macro2", "quote", @@ -266,9 +266,9 @@ dependencies = [ [[package]] name = "cosmwasm-std" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76fee88ff5bf7bef55bd37ac0619974701b99bf6bd4b16cf56ee8810718abd71" +checksum = "a49b85345e811c8e80ec55d0d091e4fcb4f00f97ab058f9be5f614c444a730cb" dependencies = [ "base64", "cosmwasm-crypto", @@ -286,9 +286,9 @@ dependencies = [ [[package]] name = "cosmwasm-storage" -version = "1.2.3" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639bc36408bc1ac45e3323166ceeb8f0b91b55a941c4ad59d389829002fbbd94" +checksum = "a3737a3aac48f5ed883b5b73bfb731e77feebd8fc6b43419844ec2971072164d" dependencies = [ "cosmwasm-std", "serde", @@ -296,9 +296,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" +checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58" dependencies = [ "libc", ] @@ -375,9 +375,9 @@ dependencies = [ [[package]] name = "cw-multi-test" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2eb84554bbfa6b66736abcd6a9bfdf237ee0ecb83910f746dff7f799093c80a" +checksum = "2a18afd2e201221c6d72a57f0886ef2a22151bbc9e6db7af276fde8a91081042" dependencies = [ "anyhow", "cosmwasm-std", @@ -394,7 +394,7 @@ dependencies = [ [[package]] name = "cw-placeholder" -version = "2.0.3" +version = "2.1.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -405,7 +405,7 @@ dependencies = [ [[package]] name = "cw-splitter" -version = "2.0.3" +version = "2.1.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -726,13 +726,13 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50d6a0976c999d473fe89ad888d5a284e55366d9dc9038b1ba2aa15128c4afa0" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" dependencies = [ "errno-dragonfly", "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -778,7 +778,7 @@ checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" [[package]] name = "gauge-adapter" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -791,11 +791,11 @@ dependencies = [ "cw20-base 1.0.1", "thiserror", "wynd-utils", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-pair", "wyndex-pair-lsd", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] @@ -810,9 +810,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4" dependencies = [ "cfg-if", "libc", @@ -877,25 +877,25 @@ dependencies = [ [[package]] name = "io-lifetimes" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09270fd4fa1111bc614ed2246c7ef56239a3063d5be0d1ec3b589c505d400aeb" +checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] name = "is-terminal" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256017f749ab3117e93acb91063009e1f1bb56d03965b14c2c8df4eb02c524d8" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" dependencies = [ "hermit-abi", "io-lifetimes", "rustix", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -915,7 +915,7 @@ checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" [[package]] name = "junoswap-staking" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -933,10 +933,10 @@ dependencies = [ "stake-cw20", "thiserror", "wasmswap", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] @@ -959,21 +959,21 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.141" +version = "0.2.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5" +checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1" [[package]] name = "libm" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "linux-raw-sys" -version = "0.3.1" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f" +checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f" [[package]] name = "log" @@ -984,6 +984,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "lp-converter" +version = "2.1.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.0.1", + "cw2 1.0.1", + "cw20 1.0.1", + "cw20-base 1.0.1", + "thiserror", + "wynd-lsd-hub", + "wyndex 2.1.0", + "wyndex-factory", + "wyndex-pair", + "wyndex-stake 2.1.0", +] + [[package]] name = "memchr" version = "2.5.0" @@ -992,7 +1012,7 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] name = "nominated-trader" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1004,12 +1024,12 @@ dependencies = [ "cw20 1.0.1", "cw20-base 1.0.1", "thiserror", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-multi-hop", "wyndex-pair", "wyndex-pair-lsd", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] @@ -1076,9 +1096,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.56" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435" +checksum = "fa1fb82fc0c281dd9671101b66b771ebbe1eaf967b96ac8740dcba4b70005ca8" dependencies = [ "unicode-ident", ] @@ -1098,7 +1118,7 @@ dependencies = [ "rand", "rand_chacha", "rand_xorshift", - "regex-syntax", + "regex-syntax 0.6.29", "rusty-fork", "tempfile", "unarray", @@ -1152,9 +1172,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" +checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500" dependencies = [ "proc-macro2", ] @@ -1206,7 +1226,7 @@ dependencies = [ [[package]] name = "raw-migration" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1224,10 +1244,10 @@ dependencies = [ "stake-cw20", "thiserror", "wasmswap", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] @@ -1241,13 +1261,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.7.3" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.7.1", ] [[package]] @@ -1256,6 +1276,12 @@ version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +[[package]] +name = "regex-syntax" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" + [[package]] name = "rfc6979" version = "0.3.1" @@ -1269,16 +1295,16 @@ dependencies = [ [[package]] name = "rustix" -version = "0.37.7" +version = "0.37.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aae838e49b3d63e9274e1c01833cc8139d3fec468c3b84688c628f44b1ae11d" +checksum = "acf8729d8542766f1b2cf77eb034d52f40d375bb8b615d0b147089946e16613d" dependencies = [ "bitflags", "errno", "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1345,31 +1371,31 @@ checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" [[package]] name = "serde" -version = "1.0.159" +version = "1.0.163" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c04e8343c3daeec41f58990b9d77068df31209f2af111e059e9fe9646693065" +checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" dependencies = [ "serde_derive", ] [[package]] name = "serde-json-wasm" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15bee9b04dd165c3f4e142628982ddde884c2022a89e8ddf99c4829bf2c3a58" +checksum = "16a62a1fad1e1828b24acac8f2b468971dade7b8c3c2e672bcadefefb1f8c137" dependencies = [ "serde", ] [[package]] name = "serde_derive" -version = "1.0.159" +version = "1.0.163" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" +checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.16", ] [[package]] @@ -1385,9 +1411,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" +checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" dependencies = [ "itoa", "ryu", @@ -1495,9 +1521,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.13" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9da457c5285ac1f936ebd076af6dac17a61cfe7826f2076b4d015cf47bc8ec" +checksum = "a6f671d4b5ffdb8eadec19c0ae67fe2639df8684bd7bc4b83d986b8db549cf01" dependencies = [ "proc-macro2", "quote", @@ -1514,7 +1540,7 @@ dependencies = [ "fastrand", "redox_syscall", "rustix", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] @@ -1549,11 +1575,11 @@ dependencies = [ "cw-multi-test", "cw20 1.0.1", "cw20-base 1.0.1", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-multi-hop", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] @@ -1573,7 +1599,7 @@ checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.13", + "syn 2.0.16", ] [[package]] @@ -1656,7 +1682,16 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-targets", + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", ] [[package]] @@ -1665,13 +1700,28 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 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", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", ] [[package]] @@ -1680,42 +1730,101 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[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.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "wynd-lsd-hub" +version = "1.3.0" +source = "git+https://github.com/wynddao/wynd-lsd.git#cbb30b5c521f80848209a36f572e9554b74b74e0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.0.1", + "cw-storage-plus 1.0.1", + "cw-utils 1.0.1", + "cw2 1.0.1", + "cw20 1.0.1", + "cw20-base 1.0.1", + "semver", + "thiserror", +] + [[package]] name = "wynd-utils" version = "1.6.0" @@ -1729,7 +1838,23 @@ dependencies = [ [[package]] name = "wyndex" -version = "2.0.3" +version = "2.0.2" +source = "git+https://github.com/wynddao/wynddex?tag=v2.0.2#592cee00712aa0c7840afc2dbfcaeb4fec1c57d9" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.0.1", + "cw-utils 1.0.1", + "cw20 1.0.1", + "cw20-base 1.0.1", + "itertools", + "thiserror", + "uint", +] + +[[package]] +name = "wyndex" +version = "2.1.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1744,7 +1869,7 @@ dependencies = [ [[package]] name = "wyndex-factory" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1758,14 +1883,14 @@ dependencies = [ "cw20-base 1.0.1", "itertools", "thiserror", - "wyndex", + "wyndex 2.1.0", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] name = "wyndex-multi-hop" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1777,15 +1902,15 @@ dependencies = [ "cw20 1.0.1", "cw20-base 1.0.1", "thiserror", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] name = "wyndex-pair" -version = "2.0.3" +version = "2.1.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1796,14 +1921,14 @@ dependencies = [ "cw20 1.0.1", "cw20-base 1.0.1", "proptest", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", - "wyndex-stake", + "wyndex-stake 2.1.0", ] [[package]] name = "wyndex-pair-lsd" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1817,15 +1942,33 @@ dependencies = [ "derivative", "itertools", "proptest", - "wyndex", + "wyndex 2.1.0", "wyndex-factory", "wyndex-pair", - "wyndex-stake", + "wyndex-stake 2.1.0", +] + +[[package]] +name = "wyndex-stake" +version = "2.0.2" +source = "git+https://github.com/wynddao/wynddex?tag=v2.0.2#592cee00712aa0c7840afc2dbfcaeb4fec1c57d9" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.0.1", + "cw-storage-plus 1.0.1", + "cw-utils 1.0.1", + "cw2 1.0.1", + "cw20 1.0.1", + "serde", + "thiserror", + "wynd-utils", + "wyndex 2.0.2", ] [[package]] name = "wyndex-stake" -version = "2.0.3" +version = "2.1.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1837,11 +1980,14 @@ dependencies = [ "cw2 1.0.1", "cw20 1.0.1", "cw20-base 1.0.1", + "lp-converter", "serde", "test-case", "thiserror", "wynd-utils", - "wyndex", + "wyndex 2.0.2", + "wyndex 2.1.0", + "wyndex-stake 2.0.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a59d35c..30f93bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["packages/*", "contracts/*", "tests", "utils/*"] [workspace.package] -version = "2.0.3" +version = "2.1.0" edition = "2021" license = "GPL 3.0" repository = "https://github.com/cosmorama/wynddex" @@ -18,10 +18,15 @@ wyndex-pair = { path = "./contracts/pair" } wyndex-pair-lsd = { path = "./contracts/pair_lsd" } wyndex-multi-hop = { path = "./contracts/multi-hop" } wyndex-stake = { path = "./contracts/stake" } +wynd-lsd-hub = { git = "https://github.com/wynddao/wynd-lsd.git", version = "1.2.1", features = [ + "library", +] } +lp-converter = { path = "./contracts/lp-converter", features = ["library"] } cosmwasm-schema = "1.1" cosmwasm-std = "1.1" cw2 = "1.0" cw20 = "1.0" +semver = "1" cw-controllers = "1.0" cw-multi-test = "0.16" cw-storage-plus = "1.0" @@ -33,6 +38,9 @@ serde = { version = "1", default-features = false, features = ["derive"] } thiserror = "1" test-case = "2.2.1" uint = "0.9" +wyndex-stake-2_0_0 = { package = "wyndex-stake", git = "https://github.com/wynddao/wynddex", tag = "v2.1.0" } +#wyndex-2_0_0 = { package = "wyndex", git = "https://github.com/wynddao/wynddex", version = "2.0.2" } + [profile.release.package.wyndex-factory] codegen-units = 1 @@ -70,6 +78,10 @@ incremental = false codegen-units = 1 incremental = false +[profile.release.package.lp-converter] +codegen-units = 1 +incremental = false + [profile.release.package.cw-splitter] codegen-units = 1 incremental = false diff --git a/contracts/factory/src/contract.rs b/contracts/factory/src/contract.rs index d70059d..1109000 100644 --- a/contracts/factory/src/contract.rs +++ b/contracts/factory/src/contract.rs @@ -630,7 +630,7 @@ pub fn deregister_pool_and_staking( .unwrap_or_default() .iter() .cloned() - .filter(|pair| pair != &pair_addr) + .filter(|pair| pair != pair_addr) .collect::>()) }, )?; diff --git a/contracts/factory/src/testing.rs b/contracts/factory/src/testing.rs index 7e45e42..70d8019 100644 --- a/contracts/factory/src/testing.rs +++ b/contracts/factory/src/testing.rs @@ -28,6 +28,7 @@ fn default_stake_config() -> DefaultStakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![1], max_distributions: 6, + converter: None, } } diff --git a/contracts/factory/tests/factory_helper.rs b/contracts/factory/tests/factory_helper.rs index 31b27e9..61dfae4 100644 --- a/contracts/factory/tests/factory_helper.rs +++ b/contracts/factory/tests/factory_helper.rs @@ -100,6 +100,7 @@ impl FactoryHelper { min_bond: Uint128::new(1000), unbonding_periods: vec![1, 2, 3], max_distributions: 6, + converter: None, }, trading_starts: None, }; diff --git a/contracts/factory/tests/integration.rs b/contracts/factory/tests/integration.rs index 6e6be62..2083100 100644 --- a/contracts/factory/tests/integration.rs +++ b/contracts/factory/tests/integration.rs @@ -49,6 +49,7 @@ fn default_stake_config() -> DefaultStakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![1], max_distributions: 6, + converter: None, } } @@ -149,6 +150,7 @@ fn update_config() { min_bond: Uint128::new(10_000), unbonding_periods: vec![1, 2, 3], // same as before max_distributions: u32::MAX, + converter: None, }, raw_config.default_stake_config ); diff --git a/contracts/gauge-adapter/src/contract.rs b/contracts/gauge-adapter/src/contract.rs index 62f2347..733645f 100644 --- a/contracts/gauge-adapter/src/contract.rs +++ b/contracts/gauge-adapter/src/contract.rs @@ -8,11 +8,9 @@ use cw2::set_contract_version; use cw20::Cw20ExecuteMsg; use cw_placeholder::contract::CONTRACT_NAME as PLACEHOLDER_CONTRACT_NAME; -use wynd_curve_utils::ScalableCurve; use wyndex::asset::{AssetInfoValidated, AssetValidated}; -use wyndex_stake::msg::{ - ExecuteMsg as StakeExecuteMsg, ReceiveDelegationMsg as StakeReceiveDelegationMsg, -}; +use wyndex::stake::{FundingInfo, ReceiveMsg as StakeReceiveDelegationMsg}; +use wyndex_stake::msg::ExecuteMsg as StakeExecuteMsg; use crate::error::ContractError; use crate::msg::{AdapterQueryMsg, ExecuteMsg, InstantiateMsg, MigrateMsg}; @@ -31,11 +29,15 @@ pub fn instantiate( ) -> Result { set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + if msg.epoch_length == 0u64 { + return Err(ContractError::ZeroDistributionDuration {}); + }; + let config = Config { factory: deps.api.addr_validate(&msg.factory)?, owner: deps.api.addr_validate(&msg.owner)?, rewards_asset: msg.rewards_asset.validate(deps.api)?, - distribution_curve: ScalableCurve::linear((0, 100), (msg.epoch_length, 0)), + distribution_duration: msg.epoch_length, }; CONFIG.save(deps.storage, &config)?; @@ -127,8 +129,9 @@ mod query { factory: _, owner: _, rewards_asset, - distribution_curve, + distribution_duration, } = CONFIG.load(deps.storage)?; + Ok(SampleGaugeMsgsResponse { execute: selected .into_iter() @@ -137,7 +140,7 @@ mod query { info: rewards_asset.info.clone(), amount: rewards_asset.amount * weight, }; - create_distribute_msgs(&env, rewards_asset, option, distribution_curve.clone()) + create_distribute_msgs(&env, rewards_asset, option, distribution_duration) .unwrap() }) .collect(), @@ -147,23 +150,28 @@ mod query { /// Creates the necessary messages to distribute the given asset to the given staking contract fn create_distribute_msgs( - _env: &Env, + env: &Env, asset: AssetValidated, staking_contract: String, - curve: ScalableCurve, + distribution_duration: u64, ) -> Result, ContractError> { if asset.amount.is_zero() { return Ok(vec![]); } + let funding_info = FundingInfo { + // start time is set equal to execution time. + start_time: env.block.time.seconds(), + amount: asset.amount, + distribution_duration, + }; + match &asset.info { AssetInfoValidated::Token(_) => Ok(vec![WasmMsg::Execute { contract_addr: asset.info.to_string(), msg: to_binary(&Cw20ExecuteMsg::Send { contract: staking_contract, amount: asset.amount, - msg: to_binary(&StakeReceiveDelegationMsg::Fund { - curve: curve.scale(asset.amount), - })?, + msg: to_binary(&StakeReceiveDelegationMsg::Fund { funding_info })?, })?, funds: vec![], } @@ -172,9 +180,7 @@ fn create_distribute_msgs( let funds = coins(asset.amount.u128(), denom); Ok(vec![WasmMsg::Execute { contract_addr: staking_contract, - msg: to_binary(&StakeExecuteMsg::FundDistribution { - curve: curve.scale(asset.amount), - })?, + msg: to_binary(&StakeExecuteMsg::FundDistribution { funding_info })?, funds, } .into()]) @@ -220,9 +226,9 @@ pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result = Item::new("config"); diff --git a/contracts/junoswap-staking/src/contract.rs b/contracts/junoswap-staking/src/contract.rs index eaa24ee..ce87e78 100644 --- a/contracts/junoswap-staking/src/contract.rs +++ b/contracts/junoswap-staking/src/contract.rs @@ -129,7 +129,7 @@ pub fn migrate_tokens( // ensure the requested target pool is valid let w_pool = deps.api.addr_validate(&wynddex_pool)?; if let Some(ref target) = migration.wynddex_pool { - if target != &w_pool { + if target != w_pool { return Err(ContractError::InvalidDestination(wynddex_pool)); } } @@ -231,7 +231,7 @@ pub fn migrate_stakers( let batch_lp: Uint128 = staker_lps.iter().map(|(_, x)| x).sum(); // bonding has full info on who receives the delegation - let bond_msg = wyndex_stake::msg::ReceiveDelegationMsg::MassDelegate { + let bond_msg = wyndex::stake::ReceiveMsg::MassDelegate { unbonding_period: migration.unbonding_period, delegate_to: staker_lps, }; diff --git a/contracts/junoswap-staking/src/multitest/suite.rs b/contracts/junoswap-staking/src/multitest/suite.rs index c709f37..c8ee1c5 100644 --- a/contracts/junoswap-staking/src/multitest/suite.rs +++ b/contracts/junoswap-staking/src/multitest/suite.rs @@ -266,6 +266,7 @@ impl SuiteBuilder { min_bond: Uint128::new(1000), unbonding_periods: self.unbonding_periods.clone(), max_distributions: 6, + converter: None, }, trading_starts: None, }, @@ -304,6 +305,7 @@ impl SuiteBuilder { min_bond: None, unbonding_periods: None, max_distributions: None, + converter: None, }, }, &[], diff --git a/contracts/lp-converter/.cargo/config b/contracts/lp-converter/.cargo/config new file mode 100644 index 0000000..de2d36a --- /dev/null +++ b/contracts/lp-converter/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/lp-converter/Cargo.toml b/contracts/lp-converter/Cargo.toml new file mode 100644 index 0000000..d9a676e --- /dev/null +++ b/contracts/lp-converter/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "lp-converter" +description = "Wyndex LP token converter - mechanism, which takes bonded token from one staking contract and allows them to be moved to another staking contract" +version = { workspace = true } +authors = ["Cosmorama "] +edition = { workspace = true } +license = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } +wyndex = { workspace = true } +wynd-lsd-hub = { workspace = true } +cw20 = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +cw20-base = { workspace = true } +wyndex-factory = { workspace = true } +wyndex-pair = { workspace = true } +wyndex-stake = { workspace = true } +wynd-lsd-hub = { workspace = true } diff --git a/contracts/lp-converter/README.md b/contracts/lp-converter/README.md new file mode 100644 index 0000000..b62039d --- /dev/null +++ b/contracts/lp-converter/README.md @@ -0,0 +1,3 @@ +# WyndDex Token Converter + +See [LP Converter Documentation](../../docs/lp-converter.md) \ No newline at end of file diff --git a/contracts/lp-converter/src/bin/schema.rs b/contracts/lp-converter/src/bin/schema.rs new file mode 100644 index 0000000..c5752d0 --- /dev/null +++ b/contracts/lp-converter/src/bin/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; + +use lp_converter::msg::{InstantiateMsg, QueryMsg}; +use wyndex::lp_converter::ExecuteMsg; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/lp-converter/src/contract.rs b/contracts/lp-converter/src/contract.rs new file mode 100644 index 0000000..76e5ef0 --- /dev/null +++ b/contracts/lp-converter/src/contract.rs @@ -0,0 +1,302 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult}; +use cw2::set_contract_version; +use wynd_lsd_hub::msg::{ + ConfigResponse as HubConfigResponse, QueryMsg as HubQueryMsg, SupplyResponse, +}; +use wyndex::lp_converter::ExecuteMsg; + +use crate::error::ContractError; +use crate::msg::{InstantiateMsg, QueryMsg}; +use crate::state::{Config, CONFIG}; + +const WITHDRAW_LIQUIDITY_REPLY_ID: u64 = 1; +const BOND_REPLY_ID: u64 = 2; +const PROVIDE_LIQUIDITY_REPLY_ID: u64 = 3; + +// version info for migration info +pub const CONTRACT_NAME: &str = "crates.io:wynd-lp-converter"; +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 { + let hub_contract = deps.api.addr_validate(&msg.hub)?; + + // query hub contract for the liquidity token and bonded denom + let hub_config: HubConfigResponse = deps + .querier + .query_wasm_smart(&hub_contract, &HubQueryMsg::Config {})?; + let hub_supply: SupplyResponse = deps + .querier + .query_wasm_smart(&hub_contract, &HubQueryMsg::Supply {})?; + + // save this for later use in the config + let config = Config { + hub_contract, + token_contract: hub_config.token_contract, + base_denom: hub_supply.supply.bond_denom, + }; + CONFIG.save(deps.storage, &config)?; + + set_contract_version(deps.storage, 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, +) -> Result { + match msg { + ExecuteMsg::Convert { + sender, + amount, + unbonding_period, + pair_contract_from, + pair_contract_to, + } => execute::convert( + deps, + sender, + amount, + unbonding_period, + pair_contract_from, + pair_contract_to, + ), + } +} + +/// The entry point to the contract for processing replies from submessages. +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result { + match msg.id { + WITHDRAW_LIQUIDITY_REPLY_ID => reply::withdraw_liquidity(deps, env), + BOND_REPLY_ID => reply::bond(deps, env), + PROVIDE_LIQUIDITY_REPLY_ID => reply::provide_liquidity(deps, env), + _ => Err(ContractError::UnknownReplyId {}), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { + unimplemented!() +} + +mod execute { + use cosmwasm_std::{to_binary, SubMsg, Uint128, WasmMsg}; + use cw20::Cw20ExecuteMsg; + use wyndex::{ + asset::AssetInfoValidated, + pair::{Cw20HookMsg, PairInfo, QueryMsg as PairQueryMsg}, + }; + + use crate::state::{TmpData, TMP_DATA}; + + use super::*; + + pub fn convert( + deps: DepsMut, + lp_owner: String, + amount: Uint128, + unbonding_period: u64, + pair_contract_from: String, + pair_contract_to: String, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + let lp_owner = deps.api.addr_validate(&lp_owner)?; + let pair_contract_from = deps.api.addr_validate(&pair_contract_from)?; + let pair_contract_to = deps.api.addr_validate(&pair_contract_to)?; + + let pair_info_from: PairInfo = deps + .querier + .query_wasm_smart(&pair_contract_from, &PairQueryMsg::Pair {})?; + + // go through the assets of `pair_contract_from` and replace the base denom with the token contract + let assets: Vec<_> = pair_info_from + .asset_infos + .into_iter() + .map(|asset| match asset { + AssetInfoValidated::Native(denom) if denom == config.base_denom => { + AssetInfoValidated::Token(config.token_contract.clone()) + } + _ => asset, + }) + .collect(); + + // save the data we need for the replies + TMP_DATA.save( + deps.storage, + &TmpData { + lp_owner, + pair_contract_to, + unbonding_period, + assets, + }, + )?; + + // withdraw liquidity from source pair + // to do this, we need to send the LP tokens to the pair contract + let resp = Response::new().add_submessage(SubMsg::reply_on_success( + WasmMsg::Execute { + contract_addr: pair_info_from.liquidity_token.into_string(), + msg: to_binary(&Cw20ExecuteMsg::Send { + contract: pair_contract_from.into_string(), + amount, + msg: to_binary(&Cw20HookMsg::WithdrawLiquidity { assets: vec![] })?, + })?, + funds: vec![], + }, + WITHDRAW_LIQUIDITY_REPLY_ID, + )); + + Ok(resp) + } +} + +mod reply { + use cosmwasm_std::{to_binary, Coin, Decimal, SubMsg, WasmMsg}; + use cw20::Cw20ExecuteMsg; + use wynd_lsd_hub::msg::ExecuteMsg as HubExecuteMsg; + use wyndex::stake::ReceiveMsg; + use wyndex::{ + asset::{AssetInfo, AssetInfoExt}, + pair::{ExecuteMsg as PairExecuteMsg, PairInfo, QueryMsg as PairQueryMsg}, + querier::query_token_balance, + }; + + use crate::state::TMP_DATA; + + use super::*; + + /// Called after the liquidity has been withdrawn from the source pair contract. + /// + /// At this point, we should have the assets from the pair contract. + /// One of them is the `base_denom` (e.g. juno) which needs to be sent to the hub contract. + pub fn withdraw_liquidity(deps: DepsMut, env: Env) -> Result { + let config: Config = CONFIG.load(deps.storage)?; + + // check how much base denom we got + let amount = deps + .querier + .query_balance(env.contract.address, &config.base_denom)? + .amount; + + // send the base denom to the hub contract + let resp = Response::new().add_submessage(SubMsg::reply_on_success( + WasmMsg::Execute { + contract_addr: config.hub_contract.into_string(), + msg: to_binary(&HubExecuteMsg::Bond {})?, + funds: vec![Coin { + denom: config.base_denom, + amount, + }], + }, + BOND_REPLY_ID, + )); + + Ok(resp) + } + + /// Called after the base denom was bonded to the hub contract. + /// + /// At this point, we should have the wyAsset from the hub contract. + /// We need to send this (together with the other assets) to the target pair contract. + pub fn bond(deps: DepsMut, env: Env) -> Result { + let tmp_data = TMP_DATA.load(deps.storage)?; + + // check how much of each asset we got + let assets = tmp_data + .assets + .into_iter() + .map(|asset| { + asset + .query_balance(&deps.querier, &env.contract.address) + .map(|amt| AssetInfo::from(asset).with_balance(amt)) + }) + .collect::>>()?; + + // native assets need to be sent to the target pair contract as funds + let funds: Vec<_> = assets + .iter() + .filter_map(|a| match &a.info { + AssetInfo::Native(denom) => Some(Coin { + denom: denom.clone(), + amount: a.amount, + }), + _ => None, + }) + .collect(); + // cw20 assets need to have their allowance increased + let mut resp = Response::new(); + for asset in &assets { + if let AssetInfo::Token(cw20) = &asset.info { + resp = resp.add_message(WasmMsg::Execute { + contract_addr: cw20.clone(), + msg: to_binary(&Cw20ExecuteMsg::IncreaseAllowance { + spender: tmp_data.pair_contract_to.to_string(), + amount: asset.amount, + expires: None, + })?, + funds: vec![], + }) + } + } + + // send the wyAsset to the target pair contract + let resp = resp.add_submessage(SubMsg::reply_on_success( + WasmMsg::Execute { + contract_addr: tmp_data.pair_contract_to.into_string(), + msg: to_binary(&PairExecuteMsg::ProvideLiquidity { + assets, + slippage_tolerance: Some(Decimal::percent(50)), // this is the max allowed slippage + receiver: None, // we receive the LP tokens back, since we are the sender + })?, + funds, + }, + PROVIDE_LIQUIDITY_REPLY_ID, + )); + + Ok(resp) + } + + /// Called after the liquidity was provided to the target pair. + /// + /// At this point, we should have the target pair LP tokens. + /// We need to stake them to the target pair staking contract. + pub fn provide_liquidity(deps: DepsMut, env: Env) -> Result { + let tmp_data = TMP_DATA.load(deps.storage)?; + + // check how many LP tokens we got + let pair_info_to: PairInfo = deps + .querier + .query_wasm_smart(tmp_data.pair_contract_to, &PairQueryMsg::Pair {})?; + let lp_balance = query_token_balance( + &deps.querier, + &pair_info_to.liquidity_token, + env.contract.address, + )?; + + // send the LP tokens to the staking contract + let resp = Response::new().add_message(WasmMsg::Execute { + contract_addr: pair_info_to.liquidity_token.into_string(), + msg: to_binary(&Cw20ExecuteMsg::Send { + contract: pair_info_to.staking_addr.into_string(), + amount: lp_balance, + msg: to_binary(&ReceiveMsg::Delegate { + unbonding_period: tmp_data.unbonding_period, + delegate_as: Some(tmp_data.lp_owner.into_string()), // this avoids another reply + })?, + })?, + funds: vec![], + }); + + Ok(resp) + } +} diff --git a/contracts/lp-converter/src/error.rs b/contracts/lp-converter/src/error.rs new file mode 100644 index 0000000..8327a7c --- /dev/null +++ b/contracts/lp-converter/src/error.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unknown reply id")] + UnknownReplyId {}, +} diff --git a/contracts/lp-converter/src/lib.rs b/contracts/lp-converter/src/lib.rs new file mode 100644 index 0000000..0a3dd6f --- /dev/null +++ b/contracts/lp-converter/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +mod state; + +#[cfg(test)] +mod multitest; + +pub use crate::error::ContractError; diff --git a/contracts/lp-converter/src/msg.rs b/contracts/lp-converter/src/msg.rs new file mode 100644 index 0000000..395fc12 --- /dev/null +++ b/contracts/lp-converter/src/msg.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of the hub contract that will be used to convert the stake + pub hub: String, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg {} diff --git a/contracts/lp-converter/src/multitest/migrate_stake.rs b/contracts/lp-converter/src/multitest/migrate_stake.rs new file mode 100644 index 0000000..7187f55 --- /dev/null +++ b/contracts/lp-converter/src/multitest/migrate_stake.rs @@ -0,0 +1,245 @@ +use wyndex::{asset::MINIMUM_LIQUIDITY_AMOUNT, stake::ConverterConfig}; +use wyndex_stake::msg::MigrateMsg; + +use super::suite::{juno, uusd, Pair, SuiteBuilder, DAY}; + +#[test] +fn migrate_to_existing_pool() { + let user = "user"; + + let ujuno_amount = 1_000_000u128; + let lsd_amount = 1_000_000u128; + let uusd_amount = 1_000_000u128; + + let unbonding_period = 14 * DAY; + + let mut suite = SuiteBuilder::new() + .with_native_balances("ujuno", vec![(user, lsd_amount + ujuno_amount)]) + .with_native_balances("uusd", vec![(user, 2 * uusd_amount)]) + .build(); + + // get some wyJUNO + suite.bond_juno(user, lsd_amount).unwrap(); + let lsd_balance = suite.query_cw20_balance(user, &suite.lsd_token).unwrap(); + assert_eq!(lsd_balance, lsd_amount); + + // provide some base liquidity to both pools + let native_lp = suite + .provide_liquidity(user, juno(ujuno_amount), uusd(uusd_amount)) + .unwrap(); + let lsd_lp = suite + .provide_liquidity(user, suite.lsd_asset(lsd_amount), uusd(uusd_amount)) + .unwrap(); + + // stake native LP + suite + .stake_lp(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128() + ); + + // migrating from lsd LP to native LP should fail + let err = suite + .migrate_stake(Pair::Lsd, user, lsd_lp, unbonding_period) + .unwrap_err(); + assert_eq!( + wyndex_stake::ContractError::NoConverter {}, + err.downcast().unwrap() + ); + + // migrate it to lsd LP + suite + .migrate_stake(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + + // check that the stake was migrated + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!(stake.stake.u128(), 0); + let stake = suite + .query_stake(Pair::Lsd, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128(), + "all of the stake that was previously in native LP should now be migrated to lsd LP" + ); +} + +#[test] +fn migrate_converter_config() { + let user = "user"; + + let ujuno_amount = 1_000_000u128; + let uusd_amount = 1_000_000u128; + + let unbonding_period = 14 * DAY; + + let mut suite = SuiteBuilder::new() + .with_native_balances("ujuno", vec![(user, ujuno_amount)]) + .with_native_balances("uusd", vec![(user, uusd_amount)]) + .without_converter() + .build(); + + // provide some liquidity to the native pair + let native_lp = suite + .provide_liquidity(user, juno(ujuno_amount), uusd(uusd_amount)) + .unwrap(); + + // stake native LP + suite + .stake_lp(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + + // migrating the liquidity before the converter is set should fail + let err = suite + .migrate_stake(Pair::Native, user, native_lp, unbonding_period) + .unwrap_err(); + assert_eq!( + wyndex_stake::ContractError::NoConverter {}, + err.downcast().unwrap() + ); + + // migrate the staking contract to add the converter + suite + .migrate_staking_contract( + Pair::Native, + MigrateMsg { + unbonder: None, + converter: Some(ConverterConfig { + contract: suite.converter.to_string(), + pair_to: suite.lsd_pair.to_string(), + }), + unbond_all: false, + }, + ) + .unwrap(); + + // migrate liquidity to lsd pair + suite + .migrate_stake(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + + // check that the stake was migrated + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!(stake.stake.u128(), 0); + let stake = suite + .query_stake(Pair::Lsd, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + ujuno_amount - 2 * MINIMUM_LIQUIDITY_AMOUNT.u128(), // 2x because we lp'd twice on empty pools + "all of the stake that was previously in native LP should now be migrated to lsd LP" + ); +} + +#[test] +fn partial_migration() { + let user = "user"; + + let ujuno_amount = 1_000_000u128; + let uusd_amount = 1_000_000u128; + + let unbonding_period = 14 * DAY; + + let mut suite = SuiteBuilder::new() + .with_native_balances("ujuno", vec![(user, ujuno_amount)]) + .with_native_balances("uusd", vec![(user, 2 * uusd_amount)]) + .build(); + + // provide some base liquidity to native pool + let native_lp = suite + .provide_liquidity(user, juno(ujuno_amount), uusd(uusd_amount)) + .unwrap(); + + // stake native LP + suite + .stake_lp(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + + // migrate half of native LP to lsd LP + suite + .migrate_stake(Pair::Native, user, native_lp / 2, unbonding_period) + .unwrap(); + + // check that only half of the stake was migrated + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + (ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128()) / 2, + "half of the stake (minus minimum amount) should remain in native LP" + ); + let stake = suite + .query_stake(Pair::Lsd, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + (ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128()) / 2 - MINIMUM_LIQUIDITY_AMOUNT.u128(), + "half of the stake should be migrated to lsd LP (minus minimum amount)" + ); +} + +#[test] +fn empty_stake_fails() { + let user = "user"; + + let ujuno_amount = 1_000_000u128; + let uusd_amount = 1_000_000u128; + + let unbonding_period = 14 * DAY; + + let mut suite = SuiteBuilder::new() + .with_native_balances("ujuno", vec![(user, ujuno_amount)]) + .with_native_balances("uusd", vec![(user, uusd_amount)]) + .build(); + + // provide some base liquidity to native pool + let native_lp = suite + .provide_liquidity(user, juno(ujuno_amount), uusd(uusd_amount)) + .unwrap(); + + // stake native LP + suite + .stake_lp(Pair::Native, user, native_lp, unbonding_period) + .unwrap(); + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128() + ); + + // migrating zero amount should fail + let err = suite + .migrate_stake(Pair::Native, user, 0, unbonding_period) + .unwrap_err(); + assert_eq!( + cw20_base::ContractError::InvalidZeroAmount {}, + err.downcast().unwrap() + ); + + // migrating more stake than available should fail + suite + .migrate_stake(Pair::Native, user, ujuno_amount, unbonding_period) + .unwrap_err(); + + // check that the stake was not migrated + let stake = suite + .query_stake(Pair::Native, user, unbonding_period) + .unwrap(); + assert_eq!( + stake.stake.u128(), + ujuno_amount - MINIMUM_LIQUIDITY_AMOUNT.u128() + ); +} diff --git a/contracts/lp-converter/src/multitest/mod.rs b/contracts/lp-converter/src/multitest/mod.rs new file mode 100644 index 0000000..eaa4244 --- /dev/null +++ b/contracts/lp-converter/src/multitest/mod.rs @@ -0,0 +1,2 @@ +mod migrate_stake; +mod suite; diff --git a/contracts/lp-converter/src/multitest/suite.rs b/contracts/lp-converter/src/multitest/suite.rs new file mode 100644 index 0000000..16b4486 --- /dev/null +++ b/contracts/lp-converter/src/multitest/suite.rs @@ -0,0 +1,604 @@ +// needed contracts: +// - hub +// - pair + stake + +use std::collections::HashMap; + +use anyhow::Result as AnyResult; + +use cosmwasm_std::{ + testing::mock_env, to_binary, Addr, Coin, Decimal, Empty, StdResult, Uint128, Validator, +}; +use cw20::{BalanceResponse, Cw20ExecuteMsg, Cw20QueryMsg}; +use cw_multi_test::{App, AppResponse, Contract, ContractWrapper, Executor, StakingInfo}; +use wynd_lsd_hub::msg::{ConfigResponse, TokenInitInfo}; +use wyndex::{ + asset::{Asset, AssetInfo, AssetInfoExt}, + factory::{ + DefaultStakeConfig, ExecuteMsg as FactoryExecuteMsg, PairConfig, PairType, + PartialStakeConfig, QueryMsg as FactoryQueryMsg, + }, + fee_config::FeeConfig, + pair::{ExecuteMsg as PairExecuteMsg, PairInfo}, + stake::{ConverterConfig, ReceiveMsg, UnbondingPeriod}, +}; +use wyndex_stake::msg::{ExecuteMsg as StakeExecuteMsg, StakedResponse}; + +pub const DAY: u64 = 24 * HOUR; +pub const HOUR: u64 = 60 * 60; + +fn contract_factory() -> Box> { + let contract = ContractWrapper::new_with_empty( + wyndex_factory::contract::execute, + wyndex_factory::contract::instantiate, + wyndex_factory::contract::query, + ) + .with_reply_empty(wyndex_factory::contract::reply); + + Box::new(contract) +} + +fn contract_pair() -> Box> { + let contract = ContractWrapper::new_with_empty( + wyndex_pair::contract::execute, + wyndex_pair::contract::instantiate, + wyndex_pair::contract::query, + ) + .with_reply_empty(wyndex_pair::contract::reply); + + Box::new(contract) +} + +fn contract_stake() -> Box> { + let contract = ContractWrapper::new_with_empty( + wyndex_stake::contract::execute, + wyndex_stake::contract::instantiate, + wyndex_stake::contract::query, + ) + .with_migrate(wyndex_stake::contract::migrate); + + Box::new(contract) +} + +fn contract_token() -> Box> { + let contract = ContractWrapper::new_with_empty( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + + Box::new(contract) +} + +fn contract_lsd_hub() -> Box> { + let contract = ContractWrapper::new_with_empty( + wynd_lsd_hub::contract::execute, + wynd_lsd_hub::contract::instantiate, + wynd_lsd_hub::contract::query, + ) + .with_reply_empty(wynd_lsd_hub::contract::reply); + + Box::new(contract) +} + +fn contract_converter() -> Box> { + let contract = ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply_empty(crate::contract::reply); + + Box::new(contract) +} + +pub(super) fn juno(amount: u128) -> Asset { + native_asset(amount, "ujuno") +} + +pub(super) fn uusd(amount: u128) -> Asset { + native_asset(amount, "uusd") +} + +pub(super) fn native_asset(amount: u128, denom: &str) -> Asset { + AssetInfo::Native(denom.to_string()).with_balance(amount) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + pub unbonding_periods: Vec, + pub admin: Option, + pub native_balances: Vec<(Addr, Coin)>, + pub no_converter: bool, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + unbonding_periods: vec![7 * DAY, 14 * DAY], + admin: None, + native_balances: vec![], + no_converter: false, + } + } + + pub fn without_converter(mut self) -> Self { + self.no_converter = true; + self + } + + pub fn with_native_balances(mut self, denom: &str, balances: Vec<(&str, u128)>) -> Self { + self.native_balances + .extend(balances.into_iter().map(|(addr, amount)| { + ( + Addr::unchecked(addr), + Coin { + denom: denom.to_owned(), + amount: amount.into(), + }, + ) + })); + self + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked("owner"); + // provide initial native balances + app.init_modules(|router, api, storage| { + // group by address + let mut balances = HashMap::>::new(); + for (addr, coin) in self.native_balances { + let addr_balance = balances.entry(addr).or_default(); + addr_balance.push(coin); + } + + for (addr, coins) in balances { + router + .bank + .init_balance(storage, &addr, coins) + .expect("init balance"); + } + + router + .staking + .setup( + storage, + StakingInfo { + bonded_denom: "ujuno".to_string(), + unbonding_time: 28 * DAY, + apr: Decimal::percent(35), + }, + ) + .unwrap(); + + // register validator for hub + router + .staking + .add_validator( + api, + storage, + &mock_env().block, + Validator { + address: "testvaloper1".to_string(), + commission: Decimal::percent(5), + max_commission: Decimal::one(), + max_change_rate: Decimal::one(), + }, + ) + .unwrap(); + }); + + // create factory contract + let pair_code_id = app.store_code(contract_pair()); + let staking_code_id = app.store_code(contract_stake()); + let token_code_id = app.store_code(contract_token()); + let factory_code_id = app.store_code(contract_factory()); + let factory = app + .instantiate_contract( + factory_code_id, + owner.clone(), + &wyndex::factory::InstantiateMsg { + pair_configs: vec![PairConfig { + code_id: pair_code_id, + pair_type: PairType::Xyk {}, + fee_config: FeeConfig { + total_fee_bps: 100, + protocol_fee_bps: 10, + }, + is_disabled: false, + }], + token_code_id, + fee_address: None, + owner: owner.to_string(), + max_referral_commission: Decimal::one(), + default_stake_config: DefaultStakeConfig { + staking_code_id, + tokens_per_power: Uint128::new(1000), + min_bond: Uint128::new(1000), + unbonding_periods: self.unbonding_periods, + max_distributions: 6, + converter: None, + }, + trading_starts: None, + }, + &[], + String::from("ASTRO"), + None, + ) + .unwrap(); + + // create hub contract + let lsd_hub_id = app.store_code(contract_lsd_hub()); + let lsd_hub = app + .instantiate_contract( + lsd_hub_id, + owner.clone(), + &wynd_lsd_hub::msg::InstantiateMsg { + treasury: "treasury".to_string(), + commission: Decimal::percent(1), + validators: vec![("testvaloper1".to_string(), Decimal::percent(100))], + owner: owner.to_string(), + + epoch_period: 23 * HOUR, + unbond_period: 28 * DAY, + max_concurrent_unbondings: 7, + cw20_init: TokenInitInfo { + label: "label".to_string(), + cw20_code_id: token_code_id, + name: "wyJuno".to_string(), + symbol: "wyJUNO".to_string(), + decimals: 6, + initial_balances: vec![], + marketing: None, + }, + liquidity_discount: Decimal::percent(4), + slashing_safety_margin: 600, + tombstone_treshold: Decimal::percent(30u64), + }, + &[], + "hub", + Some(owner.to_string()), + ) + .unwrap(); + + let lsd_token = app + .wrap() + .query_wasm_smart::(&lsd_hub, &wynd_lsd_hub::msg::QueryMsg::Config {}) + .unwrap() + .token_contract; + + // create converter contract + let converter_code_id = app.store_code(contract_converter()); + let converter = app + .instantiate_contract( + converter_code_id, + owner.clone(), + &crate::msg::InstantiateMsg { + hub: lsd_hub.to_string(), + }, + &[], + String::from("ASTRO"), + None, + ) + .unwrap(); + + // create USD-wyAsset pair + let lsd_pair_assets = vec![ + AssetInfo::Token(lsd_token.to_string()), + AssetInfo::Native("uusd".to_owned()), + ]; + app.execute_contract( + owner.clone(), + factory.clone(), + &FactoryExecuteMsg::CreatePair { + pair_type: PairType::Xyk {}, + asset_infos: lsd_pair_assets.clone(), + staking_config: PartialStakeConfig::default(), + init_params: None, + total_fee_bps: None, + }, + &[], + ) + .unwrap(); + let pair_info = app + .wrap() + .query_wasm_smart::( + Addr::unchecked(&factory), + &FactoryQueryMsg::Pair { + asset_infos: lsd_pair_assets, + }, + ) + .unwrap(); + let lsd_pair = pair_info.contract_addr; + let lsd_staking = pair_info.staking_addr; + + // create USD-Asset pair + let native_pair_assets = vec![ + AssetInfo::Native("ujuno".to_owned()), + AssetInfo::Native("uusd".to_owned()), + ]; + app.execute_contract( + owner, + factory.clone(), + &FactoryExecuteMsg::CreatePair { + pair_type: PairType::Xyk {}, + asset_infos: native_pair_assets.clone(), + staking_config: PartialStakeConfig { + converter: (!self.no_converter).then_some(ConverterConfig { + contract: converter.to_string(), + pair_to: lsd_pair.to_string(), + }), + ..Default::default() + }, + init_params: None, + total_fee_bps: None, + }, + &[], + ) + .unwrap(); + let pair_info = app + .wrap() + .query_wasm_smart::( + Addr::unchecked(&factory), + &FactoryQueryMsg::Pair { + asset_infos: native_pair_assets, + }, + ) + .unwrap(); + let native_pair = pair_info.contract_addr; + let native_staking = pair_info.staking_addr; + + Suite { + app, + + staking_code_id, + + converter, + factory, + native_pair, + native_staking, + lsd_pair, + lsd_staking, + lsd_hub, + lsd_token, + } + } +} + +pub struct Suite { + pub app: App, + + staking_code_id: u64, + pub converter: Addr, + factory: Addr, + pub native_pair: Addr, + native_staking: Addr, + pub lsd_pair: Addr, + lsd_staking: Addr, + lsd_hub: Addr, + pub lsd_token: Addr, +} + +#[derive(Copy, Clone)] +pub enum Pair { + Native, + Lsd, +} + +impl Pair { + pub fn addr(self, suite: &Suite) -> Addr { + match self { + Pair::Native => suite.native_pair.clone(), + Pair::Lsd => suite.lsd_pair.clone(), + } + } + + pub fn staking_addr(self, suite: &Suite) -> Addr { + match self { + Pair::Native => suite.native_staking.clone(), + Pair::Lsd => suite.lsd_staking.clone(), + } + } +} + +impl Suite { + pub fn lsd_asset(&self, amount: u128) -> Asset { + Asset { + info: AssetInfo::Token(self.lsd_token.to_string()), + amount: Uint128::from(amount), + } + } + + pub fn bond_juno(&mut self, addr: &str, amount: u128) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(addr), + self.lsd_hub.clone(), + &wynd_lsd_hub::msg::ExecuteMsg::Bond {}, + &[Coin { + denom: "ujuno".to_string(), + amount: amount.into(), + }], + ) + } + + pub fn stake_lp( + &mut self, + pair: Pair, + sender: &str, + amount: u128, + unbonding_period: u64, + ) -> AnyResult { + let pair_info = self.query_pair_info(pair)?; + // send LP tokens to staking contract + self.app.execute_contract( + Addr::unchecked(sender), + pair_info.liquidity_token, + &Cw20ExecuteMsg::Send { + contract: pair.staking_addr(self).to_string(), + amount: amount.into(), + msg: to_binary(&ReceiveMsg::Delegate { + unbonding_period, + delegate_as: None, + })?, + }, + &[], + ) + } + + /// Provides some liquidity to the given pair. + pub fn provide_liquidity( + &mut self, + provider: &str, + first_asset: Asset, + second_asset: Asset, + ) -> AnyResult { + let pair = self.query_pair(vec![first_asset.info.clone(), second_asset.info.clone()])?; + + let prev_balance = self.query_cw20_balance(provider, pair.liquidity_token.as_str())?; + + let mut native_tokens = vec![]; + + match &first_asset.info { + AssetInfo::Token(addr) => { + // increases allowances for given LP contracts in order to provide liquidity to pool + self.increase_allowance( + provider, + &Addr::unchecked(addr), + pair.contract_addr.as_str(), + first_asset.amount.u128(), + )?; + } + AssetInfo::Native(denom) => { + native_tokens.push(Coin { + amount: first_asset.amount, + denom: denom.to_owned(), + }); + } + }; + match &second_asset.info { + AssetInfo::Token(addr) => { + // increases allowances for given LP contracts in order to provide liquidity to pool + self.increase_allowance( + provider, + &Addr::unchecked(addr), + pair.contract_addr.as_str(), + second_asset.amount.u128(), + )?; + } + AssetInfo::Native(denom) => { + native_tokens.push(Coin { + amount: second_asset.amount, + denom: denom.to_owned(), + }); + } + }; + + self.app.execute_contract( + Addr::unchecked(provider), + pair.contract_addr, + &PairExecuteMsg::ProvideLiquidity { + assets: vec![first_asset, second_asset], + slippage_tolerance: None, + receiver: None, + }, + &native_tokens, + )?; + + let new_balance = self.query_cw20_balance(provider, pair.liquidity_token.as_str())?; + + Ok(new_balance - prev_balance) + } + + pub fn increase_allowance( + &mut self, + owner: &str, + contract: &Addr, + spender: &str, + amount: u128, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(owner), + contract.clone(), + &Cw20ExecuteMsg::IncreaseAllowance { + spender: spender.to_owned(), + amount: amount.into(), + expires: None, + }, + &[], + ) + } + + /// Migrates the staked LP tokens from the native pair's staking contract to + /// the lsd pair's staking contract + pub fn migrate_stake( + &mut self, + pair: Pair, + sender: &str, + amount: u128, + unbonding_period: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + pair.staking_addr(self), + &StakeExecuteMsg::MigrateStake { + amount: Uint128::from(amount), + unbonding_period, + }, + &[], + ) + } + + pub fn migrate_staking_contract( + &mut self, + pair: Pair, + msg: wyndex_stake::msg::MigrateMsg, + ) -> AnyResult { + self.app.migrate_contract( + Addr::unchecked("owner"), + pair.staking_addr(self), + &msg, + self.staking_code_id, // same code id + ) + } + + pub fn query_stake( + &self, + pair: Pair, + addr: &str, + unbonding_period: u64, + ) -> AnyResult { + Ok(self.app.wrap().query_wasm_smart( + pair.staking_addr(self), + &wyndex_stake::msg::QueryMsg::Staked { + address: addr.to_string(), + unbonding_period, + }, + )?) + } + + pub fn query_pair(&self, asset_infos: Vec) -> AnyResult { + Ok(self + .app + .wrap() + .query_wasm_smart(&self.factory, &FactoryQueryMsg::Pair { asset_infos })?) + } + + pub fn query_pair_info(&self, pair: Pair) -> AnyResult { + Ok(self + .app + .wrap() + .query_wasm_smart(pair.addr(self), &wyndex::pair::QueryMsg::Pair {})?) + } + + pub fn query_cw20_balance(&self, address: &str, cw20: impl Into) -> StdResult { + let balance: BalanceResponse = self.app.wrap().query_wasm_smart( + cw20, + &Cw20QueryMsg::Balance { + address: address.to_owned(), + }, + )?; + Ok(balance.balance.u128()) + } +} diff --git a/contracts/lp-converter/src/state.rs b/contracts/lp-converter/src/state.rs new file mode 100644 index 0000000..f7eaca3 --- /dev/null +++ b/contracts/lp-converter/src/state.rs @@ -0,0 +1,31 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::Item; +use wyndex::asset::AssetInfoValidated; + +#[cw_serde] +pub struct Config { + /// The LSD hub contract address to use for the conversion + pub hub_contract: Addr, + /// The address of the wyAsset to convert to + pub token_contract: Addr, + /// The denom of the base asset to convert from + pub base_denom: String, +} + +/// Temporary data used during the conversion process, stored to keep it between submessages +#[cw_serde] +pub struct TmpData { + /// Address that owns all of the source lp and will own all of the converted stake + pub lp_owner: Addr, + /// Address of the pair contract that should receive the converted stake + pub pair_contract_to: Addr, + /// The unbonding period to stake the LP tokens to + pub unbonding_period: u64, + /// The assets of the pair contract we will convert to + pub assets: Vec, +} + +/// Stores the config struct at the given key +pub const CONFIG: Item = Item::new("config"); +pub const TMP_DATA: Item = Item::new("tmp_data"); diff --git a/contracts/multi-hop/src/msg.rs b/contracts/multi-hop/src/msg.rs index f89ff3e..a47129c 100644 --- a/contracts/multi-hop/src/msg.rs +++ b/contracts/multi-hop/src/msg.rs @@ -46,7 +46,7 @@ pub enum ExecuteMsg { ExecuteSwapOperations { /// All swap operations to perform operations: Vec, - /// Guarantee that the ask amount is above a minimum amount + /// Guarantee that the ask amount is above or equal to a minimum amount minimum_receive: Option, /// Recipient of the ask tokens receiver: Option, diff --git a/contracts/multi-hop/src/multitest/suite.rs b/contracts/multi-hop/src/multitest/suite.rs index 58b4758..70422b8 100644 --- a/contracts/multi-hop/src/multitest/suite.rs +++ b/contracts/multi-hop/src/multitest/suite.rs @@ -100,6 +100,7 @@ impl SuiteBuilder { SECONDS_PER_DAY * 21, ], max_distributions: 6, + converter: None, }, } } diff --git a/contracts/nominated-trader/src/multitest/suite.rs b/contracts/nominated-trader/src/multitest/suite.rs index 1b36cff..324af66 100644 --- a/contracts/nominated-trader/src/multitest/suite.rs +++ b/contracts/nominated-trader/src/multitest/suite.rs @@ -135,6 +135,7 @@ impl SuiteBuilder { min_bond: Uint128::new(1000), unbonding_periods: vec![1], max_distributions: 6, + converter: None, }, trading_starts: None, }, diff --git a/contracts/pair/src/contract.rs b/contracts/pair/src/contract.rs index e5eddcb..c811954 100644 --- a/contracts/pair/src/contract.rs +++ b/contracts/pair/src/contract.rs @@ -17,7 +17,6 @@ use wyndex::asset::{ use wyndex::decimal2decimal256; use wyndex::factory::{ConfigResponse as FactoryConfig, PairType}; use wyndex::fee_config::FeeConfig; - use wyndex::pair::{ add_referral, assert_max_spread, check_asset_infos, check_assets, check_cw20_in_pool, create_lp_token, get_share_in_assets, handle_referral, handle_reply, migration_check, @@ -459,6 +458,18 @@ pub fn provide_liquidity( share, )?); + // Calculate new pool amounts + let new_pool0 = pools[0].amount + deposits[0].amount; + let new_pool1 = pools[1].amount + deposits[1].amount; + + let price = Decimal::from_ratio(new_pool0, new_pool1); + if total_share.is_zero() { + // initialize oracle storage + wyndex::oracle::initialize_oracle(deps.storage, &env, price)?; + } else { + wyndex::oracle::store_oracle_price(deps.storage, &env, price)?; + } + // Accumulate prices for the assets in the pool if let Some((price0_cumulative_new, price1_cumulative_new, block_time)) = accumulate_prices(&env, &config, pools[0].amount, pools[1].amount)? @@ -498,6 +509,18 @@ pub fn withdraw_liquidity( let (pools, total_share) = pool_info(deps.querier, &config)?; let refund_assets = get_share_in_assets(&pools, amount, total_share); + // Calculate new pool amounts + let mut new_pools = pools + .iter() + .zip(refund_assets.iter()) + .map(|(p, r)| p.amount - r.amount); + let (new_pool0, new_pool1) = (new_pools.next().unwrap(), new_pools.next().unwrap()); + wyndex::oracle::store_oracle_price( + deps.storage, + &env, + Decimal::from_ratio(new_pool0, new_pool1), + )?; + // Accumulate prices for the pair assets if let Some((price0_cumulative_new, price1_cumulative_new, block_time)) = accumulate_prices(&env, &config, pools[0].amount, pools[1].amount)? @@ -719,6 +742,27 @@ fn do_swap( } } + // Calculate new pool amounts + let (new_pool0, new_pool1) = if pools[0].info.equal(&ask_pool.info) { + // subtract fee and return amount from ask pool + // add offer amount to offer pool + ( + pools[0].amount - protocol_fee_amount - return_amount, + pools[1].amount + offer_amount, + ) + } else { + // same as above, but with inverted indices + ( + pools[0].amount + offer_amount, + pools[1].amount - protocol_fee_amount - return_amount, + ) + }; + wyndex::oracle::store_oracle_price( + deps.storage, + env, + Decimal::from_ratio(new_pool0, new_pool1), + )?; + // Accumulate prices for the assets in the pool if let Some((price0_cumulative_new, price1_cumulative_new, block_time)) = accumulate_prices(env, config, pools[0].amount, pools[1].amount)? @@ -856,6 +900,18 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { referral_commission, )?), QueryMsg::CumulativePrices {} => to_binary(&query_cumulative_prices(deps, env)?), + QueryMsg::Twap { + duration, + start_age, + end_age, + } => to_binary(&wyndex::oracle::query_oracle_range( + deps.storage, + &env, + &CONFIG.load(deps.storage)?.pair_info.asset_infos, + duration, + start_age, + end_age, + )?), QueryMsg::Config {} => to_binary(&query_config(deps)?), _ => Err(StdError::generic_err("Query is not supported")), } diff --git a/contracts/pair/src/testing.rs b/contracts/pair/src/testing.rs index 5417237..f241fc1 100644 --- a/contracts/pair/src/testing.rs +++ b/contracts/pair/src/testing.rs @@ -1,27 +1,31 @@ use cosmwasm_std::testing::{mock_env, mock_info, MOCK_CONTRACT_ADDR}; use cosmwasm_std::{ - attr, to_binary, Addr, BankMsg, BlockInfo, Coin, CosmosMsg, Decimal, DepsMut, Env, ReplyOn, - Response, StdError, SubMsg, Timestamp, Uint128, WasmMsg, + assert_approx_eq, attr, coins, from_binary, to_binary, Addr, BankMsg, BlockInfo, Coin, + CosmosMsg, Decimal, DepsMut, Env, Fraction, ReplyOn, Response, StdError, SubMsg, Timestamp, + Uint128, WasmMsg, }; use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg, MinterResponse}; use cw_utils::MsgInstantiateContractResponse; use proptest::prelude::*; use cw20_base::msg::InstantiateMsg as TokenInstantiateMsg; -use wyndex::asset::{Asset, AssetInfo, AssetInfoValidated, AssetValidated}; +use wyndex::asset::{ + Asset, AssetInfo, AssetInfoValidated, AssetValidated, MINIMUM_LIQUIDITY_AMOUNT, +}; use wyndex::factory::PairType; use wyndex::fee_config::FeeConfig; -use wyndex::pair::MigrateMsg; +use wyndex::oracle::{SamplePeriod, TwapResponse}; use wyndex::pair::{ assert_max_spread, ContractError, Cw20HookMsg, ExecuteMsg, InstantiateMsg, PairInfo, PoolResponse, ReverseSimulationResponse, SimulationResponse, StakeConfig, TWAP_PRECISION, }; +use wyndex::pair::{MigrateMsg, QueryMsg}; -use crate::contract::compute_offer_amount; use crate::contract::{ accumulate_prices, compute_swap, execute, instantiate, migrate, query_pool, query_reverse_simulation, query_share, query_simulation, }; +use crate::contract::{compute_offer_amount, query}; use crate::state::{Config, CONFIG}; // TODO: Copied here just as a temporary measure use crate::mock_querier::mock_dependencies; @@ -50,6 +54,7 @@ fn default_stake_config() -> StakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![60 * 60 * 24 * 7], max_distributions: 6, + converter: None, } } @@ -206,6 +211,35 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { ); // Do one successful action before freezing just for sanity execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // Manually set the correct balances for the pool + deps.querier.with_balance(&[( + &String::from(MOCK_CONTRACT_ADDR), + &[Coin { + denom: "uusd".to_string(), + amount: Uint128::new(100_000000000000000000), + }], + )]); + deps.querier.with_token_balances(&[ + ( + &String::from("liquidity0000"), + &[ + (&String::from(MOCK_CONTRACT_ADDR), &MINIMUM_LIQUIDITY_AMOUNT), + ( + &String::from("addr0000"), + &(Uint128::new(100_000000000000000000) - MINIMUM_LIQUIDITY_AMOUNT), + ), + ], + ), + ( + &String::from("asset0000"), + &[( + &String::from(MOCK_CONTRACT_ADDR), + &Uint128::new(100_000000000000000000), + )], + ), + ]); + // Migrate with the freeze migrate message migrate( deps.as_mut(), @@ -245,7 +279,7 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { ); // Assert an error and that its frozen - let res: ContractError = execute(deps.as_mut(), env, info, msg).unwrap_err(); + let res: ContractError = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err(); assert_eq!(res, ContractError::ContractFrozen {}); // Also do a swap, which should also fail let msg = ExecuteMsg::Swap { @@ -261,7 +295,6 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { referral_commission: None, }; - let env = mock_env(); let info = mock_info( "addr0000", &[Coin { @@ -279,7 +312,7 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { protocol_fee_bps: 5, }, }; - let res = execute(deps.as_mut(), env, info, msg).unwrap_err(); + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err(); assert_eq!(res, ContractError::ContractFrozen {}); // Normal sell but with CW20 @@ -296,10 +329,9 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { }) .unwrap(), }); - let env = mock_env_with_block_time(1000); let info = mock_info("asset0000", &[]); - let res = execute(deps.as_mut(), env, info, msg).unwrap_err(); + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err(); assert_eq!(res, ContractError::ContractFrozen {}); // But we can withdraw liquidity @@ -311,7 +343,6 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { amount: Uint128::new(100u128), }); - let env = mock_env(); let info = mock_info("liquidity0000", &[]); // We just want to ensure it doesn't fail with a ContractFrozen error execute(deps.as_mut(), env.clone(), info, msg).unwrap(); @@ -372,7 +403,6 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { receiver: None, }; - let env = mock_env_with_block_time(env.block.time.seconds() + 1000); let info = mock_info( "addr0001", &[Coin { @@ -380,7 +410,7 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { amount: Uint128::from(99_000000000000000000u128), }], ); - execute(deps.as_mut(), env, info, msg).unwrap(); + execute(deps.as_mut(), env.clone(), info, msg).unwrap(); // Normal sell but with CW20 let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { @@ -396,7 +426,6 @@ fn test_freezing_a_pool_blocking_actions_then_unfreeze() { }) .unwrap(), }); - let env = mock_env_with_block_time(1000); let info = mock_info("asset0000", &[]); execute(deps.as_mut(), env, info, msg).unwrap(); @@ -910,6 +939,14 @@ fn withdraw_liquidity() { // Store liquidity token store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + // need to initialize oracle, because we don't call `provide_liquidity` in this test + wyndex::oracle::initialize_oracle( + &mut deps.storage, + &mock_env_with_block_time(0), + Decimal::one(), + ) + .unwrap(); + // Withdraw liquidity let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { sender: String::from("addr0000"), @@ -986,6 +1023,164 @@ fn withdraw_liquidity() { ); } +#[test] +fn query_twap() { + let mut deps = mock_dependencies(&[]); + let mut env = mock_env(); + + let user = "user"; + + // setup some cw20 tokens, so the queries don't fail + deps.querier.with_token_balances(&[ + ( + &"asset0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ( + &"liquidity0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ]); + + let uusd = AssetInfoValidated::Native("uusd".to_string()); + let token = AssetInfoValidated::Token(Addr::unchecked("asset0000")); + + // instantiate the contract + let msg = InstantiateMsg { + asset_infos: vec![uusd.clone().into(), token.clone().into()], + token_code_id: 10u64, + factory_addr: String::from("factory"), + init_params: None, + staking_config: default_stake_config(), + trading_starts: 0, + fee_config: FeeConfig { + total_fee_bps: 0, + protocol_fee_bps: 0, + }, + circuit_breaker: None, + }; + instantiate(deps.as_mut(), env.clone(), mock_info("owner", &[]), msg).unwrap(); + + // Store the liquidity token + store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + + // provide liquidity to get a first price + let msg = ExecuteMsg::ProvideLiquidity { + assets: vec![ + Asset { + info: uusd.clone().into(), + amount: 1_000_000u128.into(), + }, + Asset { + info: token.into(), + amount: 1_000_000u128.into(), + }, + ], + slippage_tolerance: None, + receiver: None, + }; + // need to set balance manually to simulate funds being sent + deps.querier + .with_balance(&[(&MOCK_CONTRACT_ADDR.into(), &coins(1_000_000u128, "uusd"))]); + execute( + deps.as_mut(), + env.clone(), + mock_info(user, &coins(1_000_000u128, "uusd")), + msg, + ) + .unwrap(); + + // set cw20 balance manually + deps.querier.with_token_balances(&[ + ( + &"asset0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &1_000_000u128.into())], + ), + ( + &"liquidity0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ]); + + // querying TWAP after first price change should fail, because only one price is recorded + let err = query( + deps.as_ref(), + env.clone(), + QueryMsg::Twap { + duration: SamplePeriod::HalfHour, + start_age: 1, + end_age: Some(0), + }, + ) + .unwrap_err(); + + assert_eq!( + StdError::generic_err("start index is earlier than earliest recorded price data"), + err + ); + + // forward time half an hour + const HALF_HOUR: u64 = 30 * 60; + env.block.time = env.block.time.plus_seconds(HALF_HOUR); + + // swap to get a second price + let msg = ExecuteMsg::Swap { + offer_asset: Asset { + info: uusd.into(), + amount: 1_000u128.into(), + }, + to: None, + max_spread: None, + belief_price: None, + ask_asset_info: None, + referral_address: None, + referral_commission: None, + }; + // need to set balance manually to simulate funds being sent + deps.querier + .with_balance(&[(&MOCK_CONTRACT_ADDR.into(), &coins(1_001_000u128, "uusd"))]); + execute( + deps.as_mut(), + env.clone(), + mock_info(user, &coins(1_000u128, "uusd")), + msg, + ) + .unwrap(); + + // forward time half an hour again for the last change to accumulate + env.block.time = env.block.time.plus_seconds(HALF_HOUR); + + // query twap after swap price change + let twap: TwapResponse = from_binary( + &query( + deps.as_ref(), + env, + QueryMsg::Twap { + duration: SamplePeriod::HalfHour, + start_age: 1, + end_age: Some(0), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert!(twap.a_per_b > Decimal::one()); + assert!(twap.b_per_a < Decimal::one()); + assert_approx_eq!( + twap.a_per_b.numerator(), + Decimal::from_ratio(1_001_000u128, 999_000u128).numerator(), + "0.000002", + "twap should be slightly below 1" + ); + assert_approx_eq!( + twap.b_per_a.numerator(), + Decimal::from_ratio(999_000u128, 1_001_000u128).numerator(), + "0.000002", + "twap should be slightly above 1" + ); +} + #[test] fn try_native_to_token() { let total_share = Uint128::new(30000000000u128); @@ -1034,6 +1229,14 @@ fn try_native_to_token() { // Store liquidity token store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + // need to initialize oracle, because we don't call `provide_liquidity` in this test + wyndex::oracle::initialize_oracle( + &mut deps.storage, + &mock_env_with_block_time(0), + Decimal::one(), + ) + .unwrap(); + // Normal swap let msg = ExecuteMsg::Swap { offer_asset: Asset { @@ -1244,6 +1447,14 @@ fn try_token_to_native() { // Store liquidity token store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + // need to initialize oracle, because we don't call `provide_liquidity` in this test + wyndex::oracle::initialize_oracle( + &mut deps.storage, + &mock_env_with_block_time(0), + Decimal::one(), + ) + .unwrap(); + // Unauthorized access; can not execute swap directy for token swap let msg = ExecuteMsg::Swap { offer_asset: Asset { diff --git a/contracts/pair/tests/integration.rs b/contracts/pair/tests/integration.rs index 86f01da..f8517b3 100644 --- a/contracts/pair/tests/integration.rs +++ b/contracts/pair/tests/integration.rs @@ -176,6 +176,7 @@ fn default_stake_config(staking_code_id: u64) -> DefaultStakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![1], max_distributions: 6, + converter: None, } } diff --git a/contracts/pair_lsd/src/contract.rs b/contracts/pair_lsd/src/contract.rs index 553f204..40c6e8f 100644 --- a/contracts/pair_lsd/src/contract.rs +++ b/contracts/pair_lsd/src/contract.rs @@ -42,8 +42,8 @@ use crate::state::{ OWNERSHIP_PROPOSAL, }; use crate::utils::{ - accumulate_prices, adjust_precision, calc_spot_price, compute_current_amp, compute_swap, - find_spot_price, select_pools, SwapResult, + accumulate_prices, adjust_precision, calc_new_price_a_per_b, calc_spot_price, + compute_current_amp, compute_swap, find_spot_price, select_pools, SwapResult, }; use wyndex::pair::ContractError; @@ -593,6 +593,24 @@ pub fn provide_liquidity( }) .collect::>(); + // calculate pools with deposited balances + let new_pools = assets_collection + .into_iter() + .map(|(mut asset, pool)| { + // add deposit amount back to pool amount, so we can calculate the new price + asset.amount += pool; + asset + }) + .collect::>(); + let new_price = calc_new_price_a_per_b(deps.as_ref(), &env, &config, &new_pools)?; + + if total_share.is_zero() { + // initialize oracle storage + wyndex::oracle::initialize_oracle(deps.storage, &env, new_price)?; + } else { + wyndex::oracle::store_oracle_price(deps.storage, &env, new_price)?; + } + if accumulate_prices(deps.as_ref(), &env, &mut config, &old_pools)? || save_config { CONFIG.save(deps.storage, &config)?; } @@ -682,6 +700,20 @@ pub fn withdraw_liquidity( .collect::>>()?; let save_config = update_target_rate(deps.querier, &mut config, &env)?; + + // calculate pools with withdrawn balances + let new_pools = pools + .into_iter() + .zip(refund_assets.iter()) + .map(|(mut pool, refund)| { + pool.amount -= refund.amount; + let precision = get_precision(deps.storage, &pool.info)?; + pool.to_decimal_asset(precision) + }) + .collect::>>()?; + let new_price = calc_new_price_a_per_b(deps.as_ref(), &env, &config, &new_pools)?; + wyndex::oracle::store_oracle_price(deps.storage, &env, new_price)?; + if accumulate_prices(deps.as_ref(), &env, &mut config, &old_pools)? || save_config { CONFIG.save(deps.storage, &config)?; } @@ -965,38 +997,32 @@ pub fn swap( } } - // if accumulate_prices(deps.as_ref(), &env, &mut config, &pools)? { - // // calculate pools with deposited / withdrawn balances - // let new_pools = pools - // .into_iter() - // .map(|mut pool| -> StdResult { - // if pool.info.equal(&offer_asset.info) { - // // add offer amount to pool (it was already subtracted right at the beginning) - // pool.amount = pool.amount.checked_add(Decimal256::with_precision( - // offer_asset.amount, - // offer_precision, - // )?)?; - // } else if pool.info.equal(&ask_pool.info) { - // // subtract fee and return amount from ask pool - // let ask_precision = get_precision(deps.storage, &ask_pool.info)?; - // pool.amount = pool.amount.checked_sub(Decimal256::with_precision( - // return_amount + protocol_fee_amount, - // ask_precision, - // )?)?; - // } - // Ok(pool) - // }) - // .collect::>>()?; - // let new_prices = calc_new_prices(deps.as_ref(), &env, &config, &new_pools)?; - // wyndex::oracle::store_price( - // deps.storage, - // &env, - // &config.pair_info.asset_infos, - // new_prices, - // )?; - // CONFIG.save(deps.storage, &config)?; - // } - if save_config { + // calculate pools with deposited / withdrawn balances + let new_pools = pools + .iter() + .cloned() + .map(|mut pool| -> StdResult { + if pool.info.equal(&offer_asset.info) { + // add offer amount to pool (it was already subtracted right at the beginning) + pool.amount = pool.amount.checked_add(Decimal256::with_precision( + offer_asset.amount, + offer_precision, + )?)?; + } else if pool.info.equal(&ask_pool.info) { + // subtract fee and return amount from ask pool + let ask_precision = get_precision(deps.storage, &ask_pool.info)?; + pool.amount = pool.amount.checked_sub(Decimal256::with_precision( + return_amount + protocol_fee_amount, + ask_precision, + )?)?; + } + Ok(pool) + }) + .collect::>>()?; + let new_price = calc_new_price_a_per_b(deps.as_ref(), &env, &config, &new_pools)?; + wyndex::oracle::store_oracle_price(deps.storage, &env, new_price)?; + + if accumulate_prices(deps.as_ref(), &env, &mut config, &pools)? || save_config { CONFIG.save(deps.storage, &config)?; } @@ -1099,6 +1125,18 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { referral_commission, )?), QueryMsg::CumulativePrices {} => to_binary(&query_cumulative_prices(deps, env)?), + QueryMsg::Twap { + duration, + start_age, + end_age, + } => to_binary(&wyndex::oracle::query_oracle_range( + deps.storage, + &env, + &CONFIG.load(deps.storage)?.pair_info.asset_infos, + duration, + start_age, + end_age, + )?), QueryMsg::Config {} => to_binary(&query_config(deps, env)?), QueryMsg::QueryComputeD {} => to_binary(&query_compute_d(deps, env)?), QueryMsg::SpotPrice { offer, ask } => to_binary(&query_spot_price(deps, env, offer, ask)?), diff --git a/contracts/pair_lsd/src/multitest/suite.rs b/contracts/pair_lsd/src/multitest/suite.rs index a30a9c2..dce1e28 100644 --- a/contracts/pair_lsd/src/multitest/suite.rs +++ b/contracts/pair_lsd/src/multitest/suite.rs @@ -117,6 +117,7 @@ impl SuiteBuilder { SECONDS_PER_DAY * 21, ], max_distributions: 6, + converter: None, }, initial_target_rate: Decimal::one(), } @@ -609,7 +610,7 @@ impl Suite { let amount = self .app .wrap() - .query_balance(&Addr::unchecked(sender), denom)? + .query_balance(Addr::unchecked(sender), denom)? .amount; Ok(amount.into()) } diff --git a/contracts/pair_lsd/src/testing.rs b/contracts/pair_lsd/src/testing.rs index b34086e..c3b83df 100644 --- a/contracts/pair_lsd/src/testing.rs +++ b/contracts/pair_lsd/src/testing.rs @@ -1,20 +1,21 @@ -use crate::contract::{execute, instantiate, migrate}; +use crate::contract::{execute, instantiate, migrate, query}; use crate::state::CONFIG; use wyndex::fee_config::FeeConfig; +use wyndex::oracle::{SamplePeriod, TwapResponse}; // TODO: Copied here just as a temporary measure use crate::mock_querier::mock_dependencies; use cosmwasm_std::testing::{mock_env, mock_info, MOCK_CONTRACT_ADDR}; use cosmwasm_std::{ - attr, to_binary, Addr, BankMsg, BlockInfo, Coin, CosmosMsg, Decimal, DepsMut, Env, ReplyOn, - Response, StdError, SubMsg, Timestamp, Uint128, WasmMsg, + attr, coins, from_binary, to_binary, Addr, BankMsg, BlockInfo, Coin, CosmosMsg, Decimal, + DepsMut, Env, ReplyOn, Response, StdError, SubMsg, Timestamp, Uint128, WasmMsg, }; use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg, MinterResponse}; use cw20_base::msg::InstantiateMsg as TokenInstantiateMsg; use cw_utils::MsgInstantiateContractResponse; -use wyndex::asset::{Asset, AssetInfo, AssetInfoValidated}; +use wyndex::asset::{Asset, AssetInfo, AssetInfoValidated, MINIMUM_LIQUIDITY_AMOUNT}; use wyndex::pair::{ - ContractError, Cw20HookMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, StablePoolParams, + ContractError, Cw20HookMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, StablePoolParams, StakeConfig, }; @@ -52,6 +53,7 @@ fn default_stake_config() -> StakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![60 * 60 * 24 * 7], max_distributions: 6, + converter: None, } } @@ -871,17 +873,23 @@ fn provide_liquidity() { fn withdraw_liquidity() { let mut deps = mock_dependencies(&[Coin { denom: "uusd".to_string(), - amount: Uint128::new(100u128), + amount: Uint128::new(100000u128), }]); deps.querier.with_token_balances(&[ ( &String::from("liquidity0000"), - &[(&String::from("addr0000"), &Uint128::new(100u128))], + &[ + ( + &String::from("addr0000"), + &(Uint128::new(100000u128) - MINIMUM_LIQUIDITY_AMOUNT), + ), + (&String::from(MOCK_CONTRACT_ADDR), &MINIMUM_LIQUIDITY_AMOUNT), + ], ), ( &String::from("asset0000"), - &[(&String::from(MOCK_CONTRACT_ADDR), &Uint128::new(100u128))], + &[(&String::from(MOCK_CONTRACT_ADDR), &Uint128::new(100000u128))], ), ]); @@ -917,11 +925,19 @@ fn withdraw_liquidity() { // Store the liquidity token store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + // need to initialize oracle, because we don't call `provide_liquidity` in this test + wyndex::oracle::initialize_oracle( + &mut deps.storage, + &mock_env_with_block_time(0), + Decimal::one(), + ) + .unwrap(); + // Withdraw liquidity let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { sender: String::from("addr0000"), msg: to_binary(&Cw20HookMsg::WithdrawLiquidity { assets: vec![] }).unwrap(), - amount: Uint128::new(100u128), + amount: Uint128::new(100000u128) - MINIMUM_LIQUIDITY_AMOUNT, }); let env = mock_env(); @@ -939,7 +955,7 @@ fn withdraw_liquidity() { to_address: String::from("addr0000"), amount: vec![Coin { denom: "uusd".to_string(), - amount: Uint128::from(100u128), + amount: Uint128::from(99000u128), }], }), id: 0, @@ -954,7 +970,7 @@ fn withdraw_liquidity() { contract_addr: String::from("asset0000"), msg: to_binary(&Cw20ExecuteMsg::Transfer { recipient: String::from("addr0000"), - amount: Uint128::from(100u128), + amount: Uint128::from(99000u128), }) .unwrap(), funds: vec![], @@ -971,7 +987,7 @@ fn withdraw_liquidity() { msg: WasmMsg::Execute { contract_addr: String::from("liquidity0000"), msg: to_binary(&Cw20ExecuteMsg::Burn { - amount: Uint128::from(100u128), + amount: Uint128::from(100000u128) - MINIMUM_LIQUIDITY_AMOUNT, }) .unwrap(), funds: vec![], @@ -985,14 +1001,174 @@ fn withdraw_liquidity() { assert_eq!( log_withdrawn_share, - &attr("withdrawn_share", 100u128.to_string()) + &attr( + "withdrawn_share", + (100000u128 - MINIMUM_LIQUIDITY_AMOUNT.u128()).to_string() + ) ); assert_eq!( log_refund_assets, - &attr("refund_assets", "100uusd, 100asset0000") + &attr("refund_assets", "99000uusd, 99000asset0000") ); } +#[test] +fn query_twap() { + let mut deps = mock_dependencies(&[]); + let mut env = mock_env(); + + let user = "user"; + + // setup some cw20 tokens, so the queries don't fail + deps.querier.with_token_balances(&[ + ( + &"asset0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ( + &"liquidity0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ]); + + let uusd = AssetInfo::Native("uusd".to_string()); + let token = AssetInfo::Token("asset0000".to_string()); + + // instantiate the contract + let msg = InstantiateMsg { + asset_infos: vec![uusd.clone(), token.clone()], + token_code_id: 10u64, + factory_addr: String::from("factory"), + init_params: Some( + to_binary(&StablePoolParams { + amp: 100, + owner: None, + lsd: None, + }) + .unwrap(), + ), + staking_config: default_stake_config(), + trading_starts: 0, + fee_config: FeeConfig { + total_fee_bps: 0, + protocol_fee_bps: 0, + }, + circuit_breaker: None, + }; + instantiate(deps.as_mut(), env.clone(), mock_info("owner", &[]), msg).unwrap(); + + // Store the liquidity token + store_liquidity_token(deps.as_mut(), "liquidity0000".to_string()); + + // provide liquidity to get a first price + let msg = ExecuteMsg::ProvideLiquidity { + assets: vec![ + Asset { + info: uusd.clone(), + amount: 1_000_000_000_000u128.into(), + }, + Asset { + info: token, + amount: 1_000_000_000_000u128.into(), + }, + ], + slippage_tolerance: None, + receiver: None, + }; + // need to set balance manually to simulate funds being sent + deps.querier.with_balance(&[( + &MOCK_CONTRACT_ADDR.into(), + &coins(1_000_000_000_000u128, "uusd"), + )]); + execute( + deps.as_mut(), + env.clone(), + mock_info(user, &coins(1_000_000_000_000u128, "uusd")), + msg, + ) + .unwrap(); + + // set cw20 balance manually + deps.querier.with_token_balances(&[ + ( + &"asset0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &1_000_000_000_000u128.into())], + ), + ( + &"liquidity0000".into(), + &[(&MOCK_CONTRACT_ADDR.into(), &0u128.into())], + ), + ]); + + // querying TWAP after first price change should fail, because only one price is recorded + let err = query( + deps.as_ref(), + env.clone(), + QueryMsg::Twap { + duration: SamplePeriod::HalfHour, + start_age: 1, + end_age: Some(0), + }, + ) + .unwrap_err(); + + assert_eq!( + StdError::generic_err("start index is earlier than earliest recorded price data"), + err + ); + + // forward time half an hour + const HALF_HOUR: u64 = 30 * 60; + env.block.time = env.block.time.plus_seconds(HALF_HOUR); + + // swap to get a second price + let msg = ExecuteMsg::Swap { + offer_asset: Asset { + info: uusd, + amount: 10_000_000_000u128.into(), + }, + to: None, + max_spread: None, + belief_price: None, + ask_asset_info: None, + referral_address: None, + referral_commission: None, + }; + // need to set balance manually to simulate funds being sent + deps.querier.with_balance(&[( + &MOCK_CONTRACT_ADDR.into(), + &coins(1_010_000_000_000u128, "uusd"), + )]); + execute( + deps.as_mut(), + env.clone(), + mock_info(user, &coins(10_000_000_000u128, "uusd")), + msg, + ) + .unwrap(); + + // forward time half an hour again for the last change to accumulate + env.block.time = env.block.time.plus_seconds(HALF_HOUR); + + // query twap after swap price change + let twap: TwapResponse = from_binary( + &query( + deps.as_ref(), + env, + QueryMsg::Twap { + duration: SamplePeriod::HalfHour, + start_age: 1, + end_age: Some(0), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert!(twap.a_per_b > Decimal::one()); + assert!(twap.b_per_a < Decimal::one()); +} + #[cfg(feature = "requires-python-sim")] mod disabled { use super::*; diff --git a/contracts/pair_lsd/src/utils.rs b/contracts/pair_lsd/src/utils.rs index 0a07e6e..46fd5e5 100644 --- a/contracts/pair_lsd/src/utils.rs +++ b/contracts/pair_lsd/src/utils.rs @@ -1,7 +1,6 @@ use cosmwasm_std::{Decimal, Decimal256, Deps, Env, StdResult, Storage, Uint128, Uint256, Uint64}; use itertools::Itertools; use std::cmp::Ordering; -use wyndex::oracle::PricePoint; use wyndex::asset::{AssetInfoValidated, Decimal256Ext, DecimalAsset}; use wyndex::pair::TWAP_PRECISION; @@ -216,43 +215,22 @@ pub fn accumulate_prices( Ok(true) } -/// Calculates new prices for the assets in the pool. -/// Returns the array of new prices for the different combinations of assets in the pool or -/// an empty vector if one of the pools is empty. -/// -/// * **pools** array with assets available in the pool *after* the latest operation. -// note: will be used for oracles -#[allow(dead_code)] -pub fn calc_new_prices( +/// Calculates the new price of B in terms of A, i.e. how many A you get for 1 B, +/// where A is the first asset in `config.pair_info.asset_infos` and B the second. +pub fn calc_new_price_a_per_b( deps: Deps, env: &Env, config: &Config, pools: &[DecimalAsset], -) -> Result, ContractError> { - if pools.iter().all(|pool| !pool.amount.is_zero()) { - let mut prices = Vec::with_capacity(config.cumulative_prices.len()); - for (from, to, _) in &config.cumulative_prices { - let offer_asset = DecimalAsset { - info: from.clone(), - amount: Decimal256::one(), - }; - - let (offer_pool, ask_pool) = select_pools(Some(from), Some(to), pools)?; - let SwapResult { return_amount, .. } = compute_swap( - deps.storage, - env, - config, - &offer_asset, - &offer_pool, - &ask_pool, - pools, - )?; - prices.push(PricePoint::new(from.clone(), to.clone(), return_amount)); - } - Ok(prices) - } else { - Ok(vec![]) - } +) -> Result { + calc_spot_price( + deps, + env, + config, + &config.pair_info.asset_infos[1], + &config.pair_info.asset_infos[0], + pools, + ) } pub fn calc_spot_price( diff --git a/contracts/pair_lsd/tests/3pool_tests.rs b/contracts/pair_lsd/tests/3pool_tests.rs index e639c15..1120e85 100644 --- a/contracts/pair_lsd/tests/3pool_tests.rs +++ b/contracts/pair_lsd/tests/3pool_tests.rs @@ -220,7 +220,7 @@ fn swap_different_precisions() { // And reverse swap as well let reverse_sim_resp = helper .simulate_reverse_swap( - &helper.assets[&test_coins[2]].with_balance(sim_resp.return_amount.u128()), + helper.assets[&test_coins[2]].with_balance(sim_resp.return_amount.u128()), Some(helper.assets[&test_coins[0]].clone()), ) .unwrap(); diff --git a/contracts/pair_lsd/tests/helper.rs b/contracts/pair_lsd/tests/helper.rs index cc5bf55..9fc21e8 100644 --- a/contracts/pair_lsd/tests/helper.rs +++ b/contracts/pair_lsd/tests/helper.rs @@ -168,6 +168,7 @@ impl Helper { min_bond: Uint128::new(1000), unbonding_periods: vec![60 * 60 * 24 * 7], max_distributions: 6, + converter: None, }, trading_starts: None, }; diff --git a/contracts/pair_lsd/tests/integration.rs b/contracts/pair_lsd/tests/integration.rs index a07ed8a..38702f5 100644 --- a/contracts/pair_lsd/tests/integration.rs +++ b/contracts/pair_lsd/tests/integration.rs @@ -198,6 +198,7 @@ fn default_stake_config(staking_code_id: u64) -> DefaultStakeConfig { min_bond: Uint128::new(1000), unbonding_periods: vec![1], max_distributions: 6, + converter: None, } } diff --git a/contracts/raw-migration/src/contract.rs b/contracts/raw-migration/src/contract.rs index 070897a..e2ea6eb 100644 --- a/contracts/raw-migration/src/contract.rs +++ b/contracts/raw-migration/src/contract.rs @@ -140,7 +140,7 @@ pub fn migrate_tokens( // ensure the requested target pool is valid let w_pool = deps.api.addr_validate(&wynddex_pool)?; if let Some(ref target) = migration.wynddex_pool { - if target != &w_pool { + if target != w_pool { return Err(ContractError::InvalidDestination(wynddex_pool)); } } @@ -242,7 +242,7 @@ pub fn migrate_stakers( let batch_lp: Uint128 = staker_lps.iter().map(|(_, x)| x).sum(); // bonding has full info on who receives the delegation - let bond_msg = wyndex_stake::msg::ReceiveDelegationMsg::MassDelegate { + let bond_msg = wyndex::stake::ReceiveMsg::MassDelegate { unbonding_period: migration.unbonding_period, delegate_to: staker_lps, }; diff --git a/contracts/raw-migration/src/multitest/suite.rs b/contracts/raw-migration/src/multitest/suite.rs index 4118668..337cc27 100644 --- a/contracts/raw-migration/src/multitest/suite.rs +++ b/contracts/raw-migration/src/multitest/suite.rs @@ -266,6 +266,7 @@ impl SuiteBuilder { min_bond: Uint128::new(1000), unbonding_periods: self.unbonding_periods.clone(), max_distributions: 6, + converter: None, }, trading_starts: None, }, @@ -322,6 +323,7 @@ impl SuiteBuilder { min_bond: None, unbonding_periods: None, max_distributions: None, + converter: None, }, }, &[], diff --git a/contracts/stake/Cargo.toml b/contracts/stake/Cargo.toml index cb06f9b..83728b4 100644 --- a/contracts/stake/Cargo.toml +++ b/contracts/stake/Cargo.toml @@ -40,3 +40,7 @@ cosmwasm-schema = { workspace = true } # standard libs anyhow = { workspace = true } test-case = { workspace = true } +lp-converter = { workspace = true, features = ["library"] } +# migration test +wyndex-stake-2_0_0 = { package = "wyndex-stake", git = "https://github.com/wynddao/wynddex", tag = "v2.0.2" } +wyndex-2_0_0 = { package = "wyndex", git = "https://github.com/wynddao/wynddex", tag = "v2.0.2" } diff --git a/contracts/stake/src/contract.rs b/contracts/stake/src/contract.rs index 8ece6c7..cde982e 100644 --- a/contracts/stake/src/contract.rs +++ b/contracts/stake/src/contract.rs @@ -3,34 +3,35 @@ use std::collections::HashMap; #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - from_slice, to_binary, Addr, Binary, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, Order, - Response, StdError, StdResult, Storage, SubMsg, Uint128, WasmMsg, + ensure_eq, from_slice, to_binary, Addr, Binary, Decimal, Deps, DepsMut, Empty, Env, + MessageInfo, Order, Response, StdError, StdResult, Storage, Uint128, WasmMsg, }; use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; use cw_controllers::Claim; use cw_storage_plus::Map; use wyndex::asset::{addr_opt_validate, AssetInfo, AssetInfoValidated}; use wyndex::common::validate_addresses; -use wyndex::stake::{InstantiateMsg, UnbondingPeriod}; +use wyndex::lp_converter::ExecuteMsg as ConverterExecuteMsg; +use wyndex::stake::{FundingInfo, InstantiateMsg, ReceiveMsg, UnbondingPeriod}; use crate::distribution::{ apply_points_correction, execute_delegate_withdrawal, execute_distribute_rewards, execute_withdraw_rewards, query_delegated, query_distributed_rewards, query_distribution_data, query_undistributed_rewards, query_withdraw_adjustment_data, query_withdrawable_rewards, }; -use crate::utils::CurveExt; +use crate::utils::{create_undelegate_msg, CurveExt}; use cw2::set_contract_version; use cw_utils::{ensure_from_older_version, maybe_addr, Expiration}; use crate::error::ContractError; use crate::msg::{ AllStakedResponse, AnnualizedReward, AnnualizedRewardsResponse, BondingInfoResponse, - BondingPeriodInfo, ExecuteMsg, MigrateMsg, QueryMsg, ReceiveDelegationMsg, - RewardsPowerResponse, StakedResponse, TotalStakedResponse, TotalUnbondingResponse, + BondingPeriodInfo, ExecuteMsg, MigrateMsg, QueryMsg, RewardsPowerResponse, StakedResponse, + TotalStakedResponse, TotalUnbondingResponse, UnbondAllResponse, }; use crate::state::{ - Config, Distribution, TokenInfo, TotalStake, ADMIN, CLAIMS, CONFIG, DISTRIBUTION, REWARD_CURVE, - STAKE, TOTAL_PER_PERIOD, TOTAL_STAKED, + Config, ConverterConfig, Distribution, TokenInfo, TotalStake, ADMIN, CLAIMS, CONFIG, + DISTRIBUTION, REWARD_CURVE, STAKE, TOTAL_PER_PERIOD, TOTAL_STAKED, UNBOND_ALL, }; use wynd_curve_utils::Curve; @@ -72,6 +73,9 @@ pub fn instantiate( .collect(), )?; + // Initialize unbond all flag. + UNBOND_ALL.save(deps.storage, &false)?; + let config = Config { instantiator: info.sender, cw20_contract: deps.api.addr_validate(&msg.cw20_contract)?, @@ -80,6 +84,15 @@ pub fn instantiate( unbonding_periods: msg.unbonding_periods, max_distributions: msg.max_distributions, unbonder: addr_opt_validate(deps.api, &msg.unbonder)?, + converter: msg + .converter + .map(|conv| -> StdResult { + Ok(ConverterConfig { + contract: deps.api.addr_validate(&conv.contract)?, + pair_to: deps.api.addr_validate(&conv.pair_to)?, + }) + }) + .transpose()?, }; CONFIG.save(deps.storage, &config)?; @@ -114,8 +127,10 @@ pub fn execute( unbonding_period, } => execute_unbond(deps, env, info, amount, unbonding_period), ExecuteMsg::QuickUnbond { stakers } => execute_quick_unbond(deps, env, info, stakers), + ExecuteMsg::UnbondAll {} => execute_unbond_all(deps, info), + ExecuteMsg::StopUnbondAll {} => execute_stop_unbond_all(deps, info), ExecuteMsg::Claim {} => execute_claim(deps, env, info), - ExecuteMsg::Receive(msg) => execute_receive_delegation(deps, env, info, msg), + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), ExecuteMsg::DistributeRewards { sender } => { execute_distribute_rewards(deps, env, info, sender) } @@ -125,7 +140,13 @@ pub fn execute( ExecuteMsg::DelegateWithdrawal { delegated } => { execute_delegate_withdrawal(deps, info, delegated) } - ExecuteMsg::FundDistribution { curve } => execute_fund_distribution(env, deps, info, curve), + ExecuteMsg::FundDistribution { funding_info } => { + execute_fund_distribution(env, deps, info, funding_info) + } + ExecuteMsg::MigrateStake { + amount, + unbonding_period, + } => execute_migrate_stake(deps, env, info, amount, unbonding_period), } } @@ -135,48 +156,111 @@ pub fn execute_fund_distribution( env: Env, deps: DepsMut, info: MessageInfo, - schedule: Curve, + funding_info: FundingInfo, ) -> Result { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotDistributeIfUnbondAll { + what: "funds".into(), + }); + } + + if funding_info.start_time < env.block.time.seconds() { + return Err(ContractError::PastStartingTime {}); + } + let api = deps.api; let storage = deps.storage; for fund in info.funds { let asset = AssetInfo::Native(fund.denom); let validated_asset = asset.validate(api)?; - update_reward_config( - &env, - storage, - validated_asset, - fund.amount, - schedule.clone(), - )?; + update_reward_config(storage, validated_asset, fund.amount, funding_info.clone())?; } Ok(Response::default()) } +/// Triggers moving the stake from this staking contract to another staking contract +pub fn execute_migrate_stake( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, + unbonding_period: u64, +) -> Result { + let cfg = CONFIG.load(deps.storage)?; + let converter = cfg + .converter + .as_ref() + .ok_or(ContractError::NoConverter {})?; + + remove_stake_without_total( + deps.branch(), + &env, + &cfg, + &info.sender, + unbonding_period, + amount, + )?; + + // update total + TOTAL_STAKED.update::<_, StdError>(deps.storage, |token_info| { + Ok(TokenInfo { + staked: token_info.staked.saturating_sub(amount), + unbonding: token_info.unbonding, + }) + })?; + + // directly send the tokens to the converter instead of providing claim + Ok(Response::new() + // send the tokens to the converter + .add_message(WasmMsg::Execute { + contract_addr: cfg.cw20_contract.into_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: converter.contract.to_string(), + amount, + })?, + funds: vec![], + }) + // once the tokens are transfered to the converter, we convert them + .add_message(WasmMsg::Execute { + contract_addr: converter.contract.to_string(), + msg: to_binary(&ConverterExecuteMsg::Convert { + sender: info.sender.to_string(), + amount, + unbonding_period, + pair_contract_from: cfg.instantiator.into_string(), + pair_contract_to: converter.pair_to.to_string(), + })?, + funds: vec![], + }) + .add_attribute("action", "unbond") + .add_attribute("amount", amount) + .add_attribute("sender", info.sender)) +} + /// Update reward config for the given asset with an additional amount of funding fn update_reward_config( - env: &Env, storage: &mut dyn Storage, validated_asset: AssetInfoValidated, - amount: Uint128, - schedule: Curve, + sent_amount: Uint128, + FundingInfo { + start_time, + distribution_duration, + amount, + }: FundingInfo, ) -> Result<(), ContractError> { // How can we validate the amount and curve? Monotonic decreasing check is below, given this is there still a need to test the amount? let previous_reward_curve = REWARD_CURVE.load(storage, &validated_asset)?; + + let end_time = start_time + distribution_duration; + let schedule = Curve::saturating_linear((start_time, amount.u128()), (end_time, 0)); + let (min, max) = schedule.range(); // Validate the the curve locks at most the amount provided and also fully unlocks all rewards sent - if min != 0 || max > amount.u128() { + if min != 0 || max > sent_amount.u128() { return Err(ContractError::InvalidRewards {}); } - // Move the curve to the right, so as to not overlap with the past (could mess things up with previous withdrawals). - // The idea here is that the person sending the reward can specify how many rewards are locked up until when. - // However, every point on the rewards curve represents the rewards locked up at that point in time, - // so in order to prevent them from influencing the rewards curve at a point in the past, - // we shift it to the right by the current time. - // They can then provide a curve starting at `0`, meaning "right now". - let schedule = schedule.shift(env.block.time.seconds()); // combine the two curves let new_reward_curve = previous_reward_curve.combine(&schedule); new_reward_curve.validate_monotonic_decreasing()?; @@ -204,7 +288,7 @@ pub fn execute_create_distribution_flow( // and we definitely do not want to distribute the staked tokens. let config = CONFIG.load(deps.storage)?; if let AssetInfoValidated::Token(addr) = &asset { - if addr == &config.cw20_contract { + if addr == config.cw20_contract { return Err(ContractError::InvalidAsset {}); } } @@ -264,6 +348,10 @@ pub fn execute_rebond( bond_from: u64, bond_to: u64, ) -> Result { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotRebondIfUnbondAll {}); + } + // Raise if no amount was provided if amount == Uint128::zero() { return Err(ContractError::NoRebondAmount {}); @@ -535,7 +623,7 @@ fn update_total_stake( Ok(()) } -pub fn execute_receive_delegation( +pub fn execute_receive( deps: DepsMut, env: Env, info: MessageInfo, @@ -546,116 +634,107 @@ pub fn execute_receive_delegation( // This cannot be fully trusted (the cw20 contract can fake it), so only use it for actions // in the address's favor (like paying/bonding tokens, not withdrawls) - let msg: ReceiveDelegationMsg = from_slice(&wrapper.msg)?; + let msg: ReceiveMsg = from_slice(&wrapper.msg)?; let api = deps.api; match msg { - ReceiveDelegationMsg::Delegate { + ReceiveMsg::Delegate { unbonding_period, delegate_as, - } => execute_bond( - deps, - env, - info.sender, - wrapper.amount, - unbonding_period, - api.addr_validate(&delegate_as.unwrap_or(wrapper.sender))?, - ), - ReceiveDelegationMsg::MassDelegate { - unbonding_period, - delegate_to, - } => execute_mass_bond( - deps, - env, - info.sender, - wrapper.amount, + } => { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotDelegateIfUnbondAll {}); + } + execute_bond( + deps, + env, + info.sender, + wrapper.amount, + unbonding_period, + api.addr_validate(&delegate_as.unwrap_or(wrapper.sender))?, + ) + } + ReceiveMsg::MassDelegate { unbonding_period, delegate_to, - ), - ReceiveDelegationMsg::Fund { curve } => { + } => { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotDelegateIfUnbondAll {}); + } + execute_mass_bond( + deps, + env, + info.sender, + wrapper.amount, + unbonding_period, + delegate_to, + ) + } + ReceiveMsg::Fund { funding_info } => { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotDistributeIfUnbondAll { + what: "funds".into(), + }); + } + if funding_info.start_time < env.block.time.seconds() { + return Err(ContractError::PastStartingTime {}); + } let validated_asset = AssetInfo::Token(info.sender.to_string()).validate(deps.api)?; - update_reward_config(&env, deps.storage, validated_asset, wrapper.amount, curve)?; + update_reward_config(deps.storage, validated_asset, wrapper.amount, funding_info)?; Ok(Response::default()) } } } pub fn execute_unbond( - deps: DepsMut, + mut deps: DepsMut, env: Env, info: MessageInfo, amount: Uint128, unbonding_period: u64, ) -> Result { let cfg = CONFIG.load(deps.storage)?; + // If unbond all flag has been set to true, no unbonding period is required: !true as u64 == 0 + let unbond_all = UNBOND_ALL.load(deps.storage)?; - if cfg - .unbonding_periods - .binary_search(&unbonding_period) - .is_err() - { - return Err(ContractError::NoUnbondingPeriodFound(unbonding_period)); - } - - let distributions: Vec<_> = DISTRIBUTION - .range(deps.storage, None, None, Order::Ascending) - .collect::>>()?; - // calculate rewards power before updating the stake - let old_rewards = calc_rewards_powers(deps.storage, &cfg, &info.sender, distributions.iter())?; - - // reduce the sender's stake - aborting if insufficient - let mut old_stake = Uint128::zero(); - let new_stake = STAKE - .update( - deps.storage, - (&info.sender, unbonding_period), - |bonding_info| -> StdResult<_> { - let mut bonding_info = bonding_info.unwrap_or_default(); - old_stake = bonding_info.total_stake(); - bonding_info.release_stake(&env, amount)?; - Ok(bonding_info) - }, - )? - .total_stake(); - - update_total_stake(deps.storage, &cfg, unbonding_period, old_stake, new_stake)?; - - // update the adjustment data for all distributions - for ((asset_info, mut distribution), old_reward_power) in - distributions.into_iter().zip(old_rewards.into_iter()) - { - let new_reward_power = distribution.calc_rewards_power(deps.storage, &cfg, &info.sender)?; - update_rewards( - deps.storage, - &asset_info, - &info.sender, - &mut distribution, - old_reward_power, - new_reward_power, - )?; + remove_stake_without_total( + deps.branch(), + &env, + &cfg, + &info.sender, + unbonding_period, + amount, + )?; - // save updated distribution - DISTRIBUTION.save(deps.storage, &asset_info, &distribution)?; - } // update total TOTAL_STAKED.update::<_, StdError>(deps.storage, |token_info| { Ok(TokenInfo { staked: token_info.staked.saturating_sub(amount), - unbonding: token_info.unbonding + amount, + // If unbond all flag set to true the unbonding period is 0. + unbonding: token_info.unbonding + Uint128::new(!unbond_all as u128) * amount, }) })?; - // provide them a claim - CLAIMS.create_claim( - deps.storage, - &info.sender, - amount, - Expiration::AtTime(env.block.time.plus_seconds(unbonding_period)), - )?; - - Ok(Response::new() + let resp = Response::new() .add_attribute("action", "unbond") .add_attribute("amount", amount) - .add_attribute("sender", info.sender)) + .add_attribute("sender", info.sender.clone()); + + // If unbond all flag set to true we don't need to create a claim and send directly. Sending + // directly instead of send a Claim submessage resolves in 2 messages instead of 3. + if unbond_all { + let msg = create_undelegate_msg(info.sender, amount, cfg.cw20_contract)?; + Ok(resp.add_submessage(msg)) + } else { + // provide them a claim + CLAIMS.create_claim( + deps.storage, + &info.sender, + amount, + // If unbond all flag set to true the claim has no delay. + Expiration::AtTime(env.block.time.plus_seconds(unbonding_period)), + )?; + Ok(resp) + } } pub fn execute_quick_unbond( @@ -781,6 +860,48 @@ pub fn execute_quick_unbond( Ok(response) } +pub fn execute_unbond_all(deps: DepsMut, info: MessageInfo) -> Result { + let cfg = CONFIG.load(deps.storage)?; + + // Only unbonder can execute unbond all and set state variable to true. + ensure_eq!( + cfg.unbonder, + Some(info.sender), + ContractError::Unauthorized {} + ); + + UNBOND_ALL.update::<_, ContractError>(deps.storage, |unbond_all| { + if !unbond_all { + Ok(true) + } else { + Err(ContractError::FlagAlreadySet {}) + } + })?; + + Ok(Response::default().add_attribute("action", "unbond all")) +} + +pub fn execute_stop_unbond_all( + deps: DepsMut, + info: MessageInfo, +) -> Result { + let cfg = CONFIG.load(deps.storage)?; + + if cfg.unbonder != Some(info.sender.clone()) && !ADMIN.is_admin(deps.as_ref(), &info.sender)? { + return Err(ContractError::Unauthorized {}); + } + + UNBOND_ALL.update::<_, ContractError>(deps.storage, |unbond_all| { + if unbond_all { + Ok(false) + } else { + Err(ContractError::FlagAlreadySet {}) + } + })?; + + Ok(Response::default().add_attribute("action", "stop unbond all")) +} + /// Calculates rewards power of the user for all given distributions (for all unbonding periods). /// They are returned in the same order as the distributions. fn calc_rewards_powers<'a>( @@ -821,6 +942,67 @@ fn update_rewards( Ok(()) } +/// Removes the stake from the given unbonding period and staker, +/// updating `DISTRIBUTION`, `TOTAL_PER_PERIOD` and `STAKE`, but *not* `TOTAL_STAKED`. +fn remove_stake_without_total( + deps: DepsMut, + env: &Env, + cfg: &Config, + staker: &Addr, + unbonding_period: UnbondingPeriod, + amount: Uint128, +) -> Result<(), ContractError> { + if cfg + .unbonding_periods + .binary_search(&unbonding_period) + .is_err() + { + return Err(ContractError::NoUnbondingPeriodFound(unbonding_period)); + } + + let distributions: Vec<_> = DISTRIBUTION + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + // calculate rewards power before updating the stake + let old_rewards = calc_rewards_powers(deps.storage, cfg, staker, distributions.iter())?; + + // reduce the sender's stake - aborting if insufficient + let mut old_stake = Uint128::zero(); + let new_stake = STAKE + .update( + deps.storage, + (staker, unbonding_period), + |bonding_info| -> StdResult<_> { + let mut bonding_info = bonding_info.unwrap_or_default(); + old_stake = bonding_info.total_stake(); + bonding_info.release_stake(env, amount)?; + Ok(bonding_info) + }, + )? + .total_stake(); + + update_total_stake(deps.storage, cfg, unbonding_period, old_stake, new_stake)?; + + // update the adjustment data for all distributions + for ((asset_info, mut distribution), old_reward_power) in + distributions.into_iter().zip(old_rewards.into_iter()) + { + let new_reward_power = distribution.calc_rewards_power(deps.storage, cfg, staker)?; + update_rewards( + deps.storage, + &asset_info, + staker, + &mut distribution, + old_reward_power, + new_reward_power, + )?; + + // save updated distribution + DISTRIBUTION.save(deps.storage, &asset_info, &distribution)?; + } + Ok(()) +} + pub fn execute_claim( deps: DepsMut, env: Env, @@ -833,15 +1015,7 @@ pub fn execute_claim( let config = CONFIG.load(deps.storage)?; let amount_str = coin_to_string(release, config.cw20_contract.as_str()); - let undelegate = Cw20ExecuteMsg::Transfer { - recipient: info.sender.to_string(), - amount: release, - }; - let undelegate_msg = SubMsg::new(WasmMsg::Execute { - contract_addr: config.cw20_contract.to_string(), - msg: to_binary(&undelegate)?, - funds: vec![], - }); + let undelegate_msg = create_undelegate_msg(info.sender.clone(), release, config.cw20_contract)?; TOTAL_STAKED.update::<_, StdError>(deps.storage, |token_info| { Ok(TokenInfo { @@ -890,6 +1064,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { QueryMsg::WithdrawAdjustmentData { addr, asset } => { to_binary(&query_withdraw_adjustment_data(deps, addr, asset)?) } + QueryMsg::UnbondAll {} => to_binary(&query_unbond_all(deps)?), } } @@ -1122,6 +1297,12 @@ pub fn query_total_unbonding(deps: Deps) -> StdResult { }) } +pub fn query_unbond_all(deps: Deps) -> StdResult { + Ok(UnbondAllResponse { + unbond_all: UNBOND_ALL.load(deps.storage)?, + }) +} + /// Manages the contract migration. #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { @@ -1130,8 +1311,20 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result, ) -> Result { + if UNBOND_ALL.load(deps.storage)? { + return Err(ContractError::CannotDistributeIfUnbondAll { + what: "rewards".into(), + }); + } + let sender = sender .map(|sender| deps.api.addr_validate(&sender)) .transpose()? diff --git a/contracts/stake/src/error.rs b/contracts/stake/src/error.rs index 239c02d..d471677 100644 --- a/contracts/stake/src/error.rs +++ b/contracts/stake/src/error.rs @@ -74,6 +74,24 @@ pub enum ContractError { #[error("No reward duration provided for rewards distribution")] ZeroRewardDuration {}, + + #[error("Cannot migrate stake without a converter contract")] + NoConverter {}, + + #[error("Fund distribution cannot start in the past.")] + PastStartingTime {}, + + #[error("Unbond all flag is already set to true")] + FlagAlreadySet {}, + + #[error("Cannot delegate when unbond all flag is set to true")] + CannotDelegateIfUnbondAll {}, + + #[error("Cannot distribute {what} when unbond all flag is set to true")] + CannotDistributeIfUnbondAll { what: String }, + + #[error("Cannot rebond when unbond all flag is set to true, unbond instead")] + CannotRebondIfUnbondAll {}, } impl From for ContractError { diff --git a/contracts/stake/src/msg.rs b/contracts/stake/src/msg.rs index ef8ab32..2f97c4d 100644 --- a/contracts/stake/src/msg.rs +++ b/contracts/stake/src/msg.rs @@ -2,10 +2,9 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cw20::Cw20ReceiveMsg; use cosmwasm_std::{Addr, Decimal, Uint128}; -use wynd_curve_utils::Curve; use wyndex::asset::{AssetInfo, AssetInfoValidated, AssetValidated}; -use wyndex::stake::UnbondingPeriod; +use wyndex::stake::{ConverterConfig, FundingInfo, UnbondingPeriod}; #[cw_serde] pub enum ExecuteMsg { @@ -31,6 +30,12 @@ pub enum ExecuteMsg { /// The addresses of the stakers that should be unbonded stakers: Vec, }, + /// UnbondAll is used to allow instant unbond of tokens in emergency cases. + /// Can only be called by the `unbonder` account. + UnbondAll {}, + /// Allows to revert the unbond all flag to false. + /// Can only be called by the `unbonder` account or the ADMIN. + StopUnbondAll {}, /// Claim is used to claim your native tokens that you previously "unbonded" /// after the contract-defined waiting period (eg. 1 week) Claim {}, @@ -79,32 +84,15 @@ pub enum ExecuteMsg { delegated: String, }, /// Fund a distribution flow with 1 or more native tokens, updating each provided native token's reward config appropriately. - /// The x-values of the given curve are interpreted as seconds from now (so you probably want to start with `0`) and - /// the y-values as locked rewards that should not be distributed at that point in time. /// Funds to be provided are included in `info.funds` - FundDistribution { curve: Curve }, -} + FundDistribution { funding_info: FundingInfo }, -#[cw_serde] -pub enum ReceiveDelegationMsg { - Delegate { - /// Unbonding period in seconds - unbonding_period: u64, - /// If set, the staked assets will be assigned to the given address instead of the sender - delegate_as: Option, - }, - /// This will delegate a large sum on behalf of many different users. - /// The total amount in delegate_to must be <= the amount of tokens sent. - /// If it is less, any remainder is staked on behalf of the sender - MassDelegate { - /// Unbonding period in seconds + /// Moves the given amount of LP tokens staked to the given unbonding period from the sender's + /// account to a different pool (by converting one or more of the pool tokens). + MigrateStake { + amount: Uint128, unbonding_period: u64, - delegate_to: Vec<(String, Uint128)>, }, - /// Fund a distribution flow with cw20 tokens and update the Reward Config for that cw20 asset. - /// The x-values of the given curve are interpreted as seconds from now (so you probably want to start with `0`) and - /// the y-values as locked rewards that should not be distributed at that point in time. - Fund { curve: Curve }, } #[cw_serde] @@ -165,12 +153,20 @@ pub enum QueryMsg { /// Returns withdraw adjustment data #[returns(WithdrawAdjustmentDataResponse)] WithdrawAdjustmentData { addr: String, asset: AssetInfo }, + /// Returns the value of unbond all flag + #[returns(UnbondAllResponse)] + UnbondAll {}, } #[cw_serde] pub struct MigrateMsg { - /// Address of the account that can call [`ExecuteMsg::QuickUnbond`] + /// Address of the account that can call [`ExecuteMsg::QuickUnbond`], [`ExecuteMsg::UnbondAll`] + /// and [`ExecuteMsg::StopUnbondAll`] pub unbonder: Option, + /// Allows adding a converter to the staking contract after instantiation. + pub converter: Option, + /// Allows to directly set unbond all flag during migrations. + pub unbond_all: bool, } #[cw_serde] @@ -259,3 +255,9 @@ pub struct DistributionDataResponse { pub distributions: Vec<(AssetInfoValidated, crate::state::Distribution)>, } pub type WithdrawAdjustmentDataResponse = crate::state::WithdrawAdjustment; + +#[cw_serde] +pub struct UnbondAllResponse { + /// Value of unbond all flag. + pub unbond_all: bool, +} diff --git a/contracts/stake/src/multitest.rs b/contracts/stake/src/multitest.rs index 358e241..4ea34ac 100644 --- a/contracts/stake/src/multitest.rs +++ b/contracts/stake/src/multitest.rs @@ -1,5 +1,7 @@ mod delegate; mod distribution; +mod migration; mod quick_unbond; mod staking_rewards; mod suite; +mod unbond_all; diff --git a/contracts/stake/src/multitest/distribution.rs b/contracts/stake/src/multitest/distribution.rs index ea077db..e0a3408 100644 --- a/contracts/stake/src/multitest/distribution.rs +++ b/contracts/stake/src/multitest/distribution.rs @@ -2,8 +2,8 @@ use cosmwasm_std::{assert_approx_eq, Addr, Decimal, Uint128}; use cw20::{Cw20Coin, MinterResponse}; use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; use cw_multi_test::Executor; -use wynd_curve_utils::{Curve, PiecewiseLinear}; use wyndex::asset::{AssetInfo, AssetInfoExt, AssetInfoValidated}; +use wyndex::stake::FundingInfo; use super::suite::{contract_token, SuiteBuilder}; use crate::{ @@ -979,11 +979,17 @@ fn apr_cw20() { // fund the distribution flow - 1_000_000 JUNO for a year const YEAR: u64 = 365 * 24 * 60 * 60; + + let curr_block = suite.app.block_info().time; suite .execute_fund_distribution_with_cw20_curve( distributor, cw20_info.with_balance(1_000_000_000_000_000u128), - Curve::saturating_linear((0, 1_000_000_000_000_000), (YEAR, 0)), + FundingInfo { + start_time: curr_block.seconds(), + distribution_duration: YEAR, + amount: Uint128::from(1_000_000_000_000_000u128), + }, ) .unwrap(); @@ -1044,6 +1050,8 @@ fn apr_cw20() { assert_eq!(annual_rewards[1].1[0].amount, Some(Decimal::zero())); assert_eq!(annual_rewards[2].1[0].amount, Some(Decimal::zero())); + // Following code should be removed if the code is not generalized for piecewise linear + /* // fund with a complex piecewise linear curve suite .execute_fund_distribution_with_cw20_curve( @@ -1124,6 +1132,7 @@ fn apr_cw20() { annual_rewards[2].1[0].amount.unwrap() * Uint128::new(1_000_000), Uint128::new(1666666), ); + */ } #[test] diff --git a/contracts/stake/src/multitest/migration.rs b/contracts/stake/src/multitest/migration.rs new file mode 100644 index 0000000..d126af8 --- /dev/null +++ b/contracts/stake/src/multitest/migration.rs @@ -0,0 +1,184 @@ +use cosmwasm_std::{to_binary, Addr, Empty, StdError, Uint128}; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; + +use cw20::{BalanceResponse, Cw20Coin, Cw20ExecuteMsg, Cw20QueryMsg, MinterResponse}; +use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; +use wyndex::stake::ReceiveMsg; +use wyndex_stake_2_0_0::msg::TotalStakedResponse; + +use crate::msg::{ExecuteMsg, MigrateMsg, QueryMsg, UnbondAllResponse}; + +// const UNBONDER: &str = "unbonder"; +const MINTER: &str = "minter"; +const USER: &str = "user"; +const UNBONDER: &str = "unbonder"; +const ADMIN: &str = "admin"; +pub const SEVEN_DAYS: u64 = 604800; + +#[test] +fn stake_old_migrate_with_unbond_all_and_unbond() { + let mut app = App::default(); + + let admin = Addr::unchecked(ADMIN); + + // CW20 token + let cw20_contract: Box> = Box::new(ContractWrapper::new_with_empty( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + )); + + // Instantiate Cw20 token. + let token_id = app.store_code(cw20_contract); + let token_contract = app + .instantiate_contract( + token_id, + admin.clone(), + &Cw20InstantiateMsg { + name: "vesting".to_owned(), + symbol: "VEST".to_owned(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: USER.to_owned(), + amount: 1_000_000u128.into(), + }], + mint: Some(MinterResponse { + minter: MINTER.to_owned(), + cap: None, + }), + marketing: None, + }, + &[], + "vesting", + None, + ) + .unwrap(); + + // Upload old stake contract and create instance + let old_contract: Box> = Box::new(ContractWrapper::new_with_empty( + wyndex_stake_2_0_0::contract::execute, + wyndex_stake_2_0_0::contract::instantiate, + wyndex_stake_2_0_0::contract::query, + )); + let stake_old_id = app.store_code(old_contract); + let stake_old_contract = app + .instantiate_contract( + stake_old_id, + admin.clone(), + &wyndex_2_0_0::stake::InstantiateMsg { + cw20_contract: token_contract.to_string(), + tokens_per_power: Uint128::new(1000), + min_bond: Uint128::new(5000), + unbonding_periods: vec![SEVEN_DAYS], + admin: None, + unbonder: None, + max_distributions: 6, + }, + &[], + "stake", + Some(admin.to_string()), + ) + .unwrap(); + + // Check that UnbondAll is not present. + let err: Result = app + .wrap() + .query_wasm_smart(stake_old_contract.clone(), &QueryMsg::UnbondAll {}); + + assert!(matches!(err.unwrap_err(), StdError::GenericErr { .. })); + + // Delegate tokens into old contract. + app.execute_contract( + Addr::unchecked(USER), + token_contract.clone(), + &Cw20ExecuteMsg::Send { + contract: stake_old_contract.to_string(), + amount: 500_000u128.into(), + msg: to_binary(&ReceiveMsg::Delegate { + unbonding_period: SEVEN_DAYS, + delegate_as: None, + }) + .unwrap(), + }, + &[], + ) + .unwrap(); + + // Check tokens are correctly delegated. + let total_staked_resp: TotalStakedResponse = app + .wrap() + .query_wasm_smart(stake_old_contract.clone(), &QueryMsg::TotalStaked {}) + .unwrap(); + + assert_eq!(Uint128::new(500_000), total_staked_resp.total_staked,); + + // Upload new bytecode. + let new_contract: Box> = Box::new( + ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate), + ); + let stake_new_id = app.store_code(new_contract); + + // Migrate to new contract with unbond all. + app.migrate_contract( + admin, + stake_old_contract.clone(), + &MigrateMsg { + unbonder: Some(UNBONDER.to_owned()), + converter: None, + unbond_all: true, + }, + stake_new_id, + ) + .unwrap(); + + // Check that unbond all has been correctly set. + let resp: UnbondAllResponse = app + .wrap() + .query_wasm_smart(stake_old_contract.clone(), &QueryMsg::UnbondAll {}) + .unwrap(); + + assert!(resp.unbond_all); + + let balance: BalanceResponse = app + .wrap() + .query_wasm_smart( + token_contract.clone(), + &Cw20QueryMsg::Balance { + address: USER.to_owned(), + }, + ) + .unwrap(); + + // Assert that user has initial tokens - staked tokens. + assert_eq!(Uint128::new(500_000), balance.balance,); + + // Unbond tokens staked in old contract + app.execute_contract( + Addr::unchecked(USER), + stake_old_contract, + &ExecuteMsg::Unbond { + tokens: Uint128::new(500_000), + unbonding_period: SEVEN_DAYS, + }, + &[], + ) + .unwrap(); + + let balance: BalanceResponse = app + .wrap() + .query_wasm_smart( + token_contract, + &Cw20QueryMsg::Balance { + address: USER.to_owned(), + }, + ) + .unwrap(); + + // Assert that user has initial tokens. + assert_eq!(Uint128::new(1_000_000), balance.balance,); +} diff --git a/contracts/stake/src/multitest/suite.rs b/contracts/stake/src/multitest/suite.rs index 1fa57f6..17d6184 100644 --- a/contracts/stake/src/multitest/suite.rs +++ b/contracts/stake/src/multitest/suite.rs @@ -7,7 +7,6 @@ use cw20::{BalanceResponse, Cw20Coin, Cw20ExecuteMsg, Cw20QueryMsg, MinterRespon use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; use cw_controllers::{Claim, ClaimsResponse}; use cw_multi_test::{App, AppResponse, Contract, ContractWrapper, Executor}; -use wynd_curve_utils::Curve; use wyndex::{ asset::{AssetInfo, AssetInfoExt, AssetInfoValidated, AssetValidated}, stake::{InstantiateMsg, UnbondingPeriod}, @@ -16,9 +15,10 @@ use wyndex::{ use crate::msg::{ AllStakedResponse, AnnualizedReward, AnnualizedRewardsResponse, BondingInfoResponse, BondingPeriodInfo, DelegatedResponse, DistributedRewardsResponse, ExecuteMsg, QueryMsg, - ReceiveDelegationMsg, RewardsPowerResponse, StakedResponse, TotalStakedResponse, + RewardsPowerResponse, StakedResponse, TotalStakedResponse, UnbondAllResponse, UndistributedRewardsResponse, WithdrawableRewardsResponse, }; +use wyndex::stake::{FundingInfo, ReceiveMsg}; pub const SEVEN_DAYS: u64 = 604800; @@ -190,6 +190,7 @@ impl SuiteBuilder { admin: self.admin, unbonder: self.unbonder, max_distributions: 6, + converter: None, }, &[], "stake", @@ -320,7 +321,7 @@ impl Suite { &Cw20ExecuteMsg::Send { contract: self.stake_contract.to_string(), amount: amount.into(), - msg: to_binary(&ReceiveDelegationMsg::Delegate { + msg: to_binary(&ReceiveMsg::Delegate { unbonding_period: self.unbonding_period_or_default(unbonding_period), delegate_as: delegate_as.map(|s| s.to_string()), })?, @@ -348,7 +349,7 @@ impl Suite { &Cw20ExecuteMsg::Send { contract: self.stake_contract.to_string(), amount: amount.into(), - msg: to_binary(&ReceiveDelegationMsg::MassDelegate { + msg: to_binary(&ReceiveMsg::MassDelegate { unbonding_period: self.unbonding_period_or_default(unbonding_period), delegate_to, })?, @@ -463,11 +464,17 @@ impl Suite { ) -> AnyResult { let _sender = sender.into(); + let curr_block = self.app.block_info().time; + self.app.execute_contract( Addr::unchecked(executor), self.stake_contract.clone(), &ExecuteMsg::FundDistribution { - curve: Curve::saturating_linear((0, funds.amount.u128()), (100, 0)), + funding_info: FundingInfo { + start_time: curr_block.seconds(), + distribution_duration: 100, + amount: funds.amount, + }, }, &[Coin { denom: funds.info.to_string(), @@ -481,13 +488,19 @@ impl Suite { executor: &str, denom: impl Into, amount: u128, - reward_period: u64, + distribution_duration: u64, ) -> AnyResult { + let curr_block = self.app.block_info().time; + self.app.execute_contract( Addr::unchecked(executor), self.stake_contract.clone(), &ExecuteMsg::FundDistribution { - curve: Curve::saturating_linear((0, amount), (reward_period, 0)), + funding_info: FundingInfo { + start_time: curr_block.seconds(), + distribution_duration, + amount: Uint128::from(amount), + }, }, &[Coin { denom: denom.into(), @@ -503,10 +516,16 @@ impl Suite { funds: AssetValidated, ) -> AnyResult { let funds_amount = funds.amount.u128(); + let curr_block = self.app.block_info().time; + self.execute_fund_distribution_with_cw20_curve( executor, funds, - Curve::saturating_linear((0, funds_amount), (100, 0)), + FundingInfo { + start_time: curr_block.seconds(), + distribution_duration: 100, + amount: Uint128::from(funds_amount), + }, ) } @@ -514,7 +533,7 @@ impl Suite { &mut self, executor: &str, funds: AssetValidated, - curve: Curve, + funding_info: FundingInfo, ) -> AnyResult { let token = match funds.info { AssetInfoValidated::Token(contract_addr) => contract_addr, @@ -526,12 +545,30 @@ impl Suite { &Cw20ExecuteMsg::Send { contract: self.stake_contract.to_string(), amount: funds.amount, - msg: to_binary(&ReceiveDelegationMsg::Fund { curve })?, + msg: to_binary(&ReceiveMsg::Fund { funding_info })?, }, &[], ) } + pub fn execute_unbond_all(&mut self, executor: &str) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(executor), + self.stake_contract.clone(), + &ExecuteMsg::UnbondAll {}, + &[], + ) + } + + pub fn execute_stop_unbond_all(&mut self, executor: &str) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(executor), + self.stake_contract.clone(), + &ExecuteMsg::StopUnbondAll {}, + &[], + ) + } + pub fn withdraw_funds<'s>( &mut self, executor: &str, @@ -738,4 +775,13 @@ impl Suite { .filter(|(_, p)| *p > 0) .collect()) } + + pub fn query_unbond_all(&self) -> StdResult { + let resp: UnbondAllResponse = self + .app + .wrap() + .query_wasm_smart(self.stake_contract.clone(), &QueryMsg::UnbondAll {})?; + + Ok(resp.unbond_all) + } } diff --git a/contracts/stake/src/multitest/unbond_all.rs b/contracts/stake/src/multitest/unbond_all.rs new file mode 100644 index 0000000..79f92db --- /dev/null +++ b/contracts/stake/src/multitest/unbond_all.rs @@ -0,0 +1,369 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw20::{Cw20Coin, MinterResponse}; +use cw_multi_test::Executor; + +use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; +use wyndex::asset::{AssetInfo, AssetInfoExt, AssetInfoValidated}; + +use crate::{multitest::suite::SuiteBuilder, ContractError}; + +use super::suite::{contract_token, juno, SEVEN_DAYS}; + +const UNBONDER: &str = "unbonder"; +const ADMIN: &str = "admin"; + +#[test] +fn execute_unbond_all_case() { + let mut suite = SuiteBuilder::new().with_unbonder(UNBONDER).build(); + + // Random account cannot execute unbond all. + let err = suite.execute_unbond_all("fake_unbonder").unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap(),); + + assert!(!suite.query_unbond_all().unwrap()); + + // Unbonder can execute unbond all. + suite.execute_unbond_all(UNBONDER).unwrap(); + + assert!(suite.query_unbond_all().unwrap()); +} + +#[test] +fn execute_stop_unbond_all_case() { + let mut suite = SuiteBuilder::new() + .with_unbonder(UNBONDER) + .with_admin(ADMIN) + .build(); + + // Fails to stop if flag is false both for unbonder and admin. + let mut err = suite.execute_stop_unbond_all(UNBONDER).unwrap_err(); + + assert_eq!(ContractError::FlagAlreadySet {}, err.downcast().unwrap(),); + + err = suite.execute_stop_unbond_all(ADMIN).unwrap_err(); + + assert_eq!(ContractError::FlagAlreadySet {}, err.downcast().unwrap(),); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Fails with unauthorized + err = suite.execute_stop_unbond_all("user").unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap(),); + + // Unbonder can stop unbond all + suite.execute_stop_unbond_all(UNBONDER).unwrap(); + + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Admin can stop unbond all + suite.execute_stop_unbond_all(ADMIN).unwrap(); +} + +#[test] +fn delegate_and_unbond_with_unbond_all() { + let user = "user"; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Delegate half of the tokens for 7 days (default with None). + suite.delegate(user, 50_000u128, None).unwrap(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Unbond with unbond all flag to true. + suite.unbond(user, 50_000u128, None).unwrap(); + + // Staking contract has no token since sent back to user. + assert_eq!(suite.query_balance_staking_contract().unwrap(), 0u128); + + // Total stake is zero. + assert_eq!(suite.query_total_staked().unwrap(), 0u128); + + // No claims + let claims = suite.query_claims(user).unwrap(); + assert_eq!(claims.len(), 0); + + assert_eq!( + suite.query_balance_vesting_contract(user).unwrap(), + 100_000u128 + ); +} + +#[test] +fn single_delegate_unbond_and_claim_with_unbond_all() { + let user = "user"; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Delegate half of the tokens for 7 days (default with None). + suite.delegate(user, 50_000u128, None).unwrap(); + + // Unbond. + suite.unbond(user, 25_000u128, None).unwrap(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Staking contract has all tokens previously deposited + assert_eq!(suite.query_balance_staking_contract().unwrap(), 50_000u128); + + // Staking tokens are half of the delegated + assert_eq!(suite.query_total_staked().unwrap(), 25_000u128); + + // Claim is there since made before unbond all. + let claims = suite.query_claims(user).unwrap(); + assert_eq!(claims.len(), 1); + + // Free locked tokens. + suite.update_time(SEVEN_DAYS * 2); + suite.claim(user).unwrap(); + + // User has not delegated tokens + delegated and then unbonded. + assert_eq!( + suite.query_balance_vesting_contract(user).unwrap(), + 75_000u128 + ); +} + +#[test] +fn multiple_delegate_unbond_and_claim_with_unbond_all() { + let user = "user"; + let mut suite = SuiteBuilder::new() + .with_unbonding_periods(vec![SEVEN_DAYS, SEVEN_DAYS * 3]) + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Delegate half of the tokens for 7 days (default with None). + suite.delegate(user, 50_000u128, SEVEN_DAYS).unwrap(); + + // Delegate half of the tokens for 21 days. + suite.delegate(user, 50_000u128, SEVEN_DAYS * 3).unwrap(); + + // Unbond. + suite.unbond(user, 25_000u128, None).unwrap(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Staking contract has all initial tokens. + assert_eq!(suite.query_balance_staking_contract().unwrap(), 100_000u128); + + // Tokens in stake are 100_000 minus unbonded. + assert_eq!(suite.query_total_staked().unwrap(), 75_000u128); + + // Claim is there since made before unbond all. + let claims = suite.query_claims(user).unwrap(); + assert_eq!(claims.len(), 1); + + suite.update_time(SEVEN_DAYS * 2); + suite.claim(user).unwrap(); + + // User claims only tokens unbonded before unbond all. + assert_eq!( + suite.query_balance_vesting_contract(user).unwrap(), + 25_000u128 + ); + + // Unbond tokens delegated for 21 days. + suite.unbond(user, 25_000u128, SEVEN_DAYS * 3).unwrap(); + + // No claims + let claims = suite.query_claims(user).unwrap(); + assert_eq!(claims.len(), 0); + + // User has previously claimed tokens + unbonded tokens from 21 days. + assert_eq!( + suite.query_balance_vesting_contract(user).unwrap(), + 50_000u128 + ); + + // Staking contract has half available tokens. + assert_eq!(suite.query_balance_staking_contract().unwrap(), 50_000u128); +} + +#[test] +fn delegate_with_unbond_all_flag() { + let user = "user"; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Cannot delegate if unbond all. + let err = suite.delegate(user, 50_000u128, None).unwrap_err(); + assert_eq!( + ContractError::CannotDelegateIfUnbondAll {}, + err.downcast().unwrap() + ); +} + +#[test] +fn delegate_as_with_unbond_all_flag() { + let user = "factory"; + let user2 = "client"; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Cannot delegate through cw20 contract if unbond all. + let err = suite + .delegate_as(user, 50_000u128, None, Some(user2)) + .unwrap_err(); + + assert_eq!( + ContractError::CannotDelegateIfUnbondAll {}, + err.downcast().unwrap() + ); +} + +#[test] +fn mass_delegation_with_unbond_all_flag() { + let user = "factory"; + let user2 = "client"; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Cannot mass delegate if unbond all. + let err = suite + .mass_delegate(user, 50_000u128, None, &[(user2, 50_000u128)]) + .unwrap_err(); + + assert_eq!( + ContractError::CannotDelegateIfUnbondAll {}, + err.downcast().unwrap() + ); +} + +#[test] +fn rebond_with_unbond_all_flag() { + let user = "user"; + let new_unbonding_period = 1000u64; + let old_unbonding_period = 5000u64; + let mut suite = SuiteBuilder::new() + .with_initial_balances(vec![(user, 100_000)]) + .with_unbonder(UNBONDER) + .build(); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Rebond results in error. + let err = suite + .rebond(user, 2_000u128, old_unbonding_period, new_unbonding_period) + .unwrap_err(); + + assert_eq!( + ContractError::CannotRebondIfUnbondAll {}, + err.downcast().unwrap() + ); +} + +#[test] +fn multiple_distribution_flows() { + let user = "user"; + let unbonding_period = 1000u64; + + let mut suite = SuiteBuilder::new() + .with_unbonding_periods(vec![unbonding_period]) + .with_initial_balances(vec![(user, 100_000)]) + .with_admin("admin") + .with_unbonder(UNBONDER) + .with_native_balances("juno", vec![(user, 1200)]) + .build(); + + // Create CW20 token. + let token_id = suite.app.store_code(contract_token()); + let wynd_token = suite + .app + .instantiate_contract( + token_id, + Addr::unchecked("admin"), + &Cw20InstantiateMsg { + name: "wynd-token".to_owned(), + symbol: "WYND".to_owned(), + decimals: 9, + initial_balances: vec![Cw20Coin { + // member4 gets some to distribute + address: "user".to_owned(), + amount: Uint128::from(500u128), + }], + mint: Some(MinterResponse { + minter: "minter".to_owned(), + cap: None, + }), + marketing: None, + }, + &[], + "vesting", + None, + ) + .unwrap(); + + // Distribution flow for native and CW20 tokens. + suite + .create_distribution_flow( + "admin", + user, + AssetInfo::Native("juno".to_string()), + vec![(unbonding_period, Decimal::one())], + ) + .unwrap(); + + suite + .create_distribution_flow( + "admin", + user, + AssetInfo::Token(wynd_token.to_string()), + vec![(unbonding_period, Decimal::one())], + ) + .unwrap(); + + suite.delegate(user, 1_000, unbonding_period).unwrap(); + + // Fund both distribution flows with same amount. + suite + .execute_fund_distribution(user, None, juno(400)) + .unwrap(); + suite + .execute_fund_distribution_with_cw20( + user, + AssetInfoValidated::Token(wynd_token).with_balance(400u128), + ) + .unwrap(); + + suite.update_time(100); + + // Set unbond all flag to true. + suite.execute_unbond_all(UNBONDER).unwrap(); + + // Cannot distribute funds when unbod all. + let err = suite.distribute_funds(user, None, None).unwrap_err(); + + assert_eq!( + ContractError::CannotDistributeIfUnbondAll { + what: "rewards".into() + }, + err.downcast().unwrap() + ); +} diff --git a/contracts/stake/src/state.rs b/contracts/stake/src/state.rs index 827061b..17cb2b8 100644 --- a/contracts/stake/src/state.rs +++ b/contracts/stake/src/state.rs @@ -25,6 +25,18 @@ pub struct Config { pub max_distributions: u32, /// Address of the account that can call [`ExecuteMsg::QuickUnbond`] pub unbonder: Option, + /// Configuration for the [`crate::msg::ExecuteMsg::MigrateStake`] message. + /// Allows converting staked LP tokens to LP tokens of another pool. + /// E.g. LP tokens of the USDC-JUNO pool can be converted to LP tokens of the USDC-wyJUNO pool + pub converter: Option, +} + +#[cw_serde] +pub struct ConverterConfig { + /// Address of the contract that converts the LP tokens + pub contract: Addr, + /// Address of the pair contract the converter should convert to + pub pair_to: Addr, } #[cw_serde] @@ -290,6 +302,9 @@ pub const WITHDRAW_ADJUSTMENT: Map<(&Addr, &AssetInfoValidated), WithdrawAdjustm /// User delegated for funds withdrawal pub const DELEGATED: Map<&Addr, Addr> = Map::new("delegated"); +/// Flag to allow fast unbonding in emergency cases. +pub const UNBOND_ALL: Item = Item::new("unbond_all"); + #[cfg(test)] mod tests { use super::*; diff --git a/contracts/stake/src/utils.rs b/contracts/stake/src/utils.rs index 3f45e3b..2547929 100644 --- a/contracts/stake/src/utils.rs +++ b/contracts/stake/src/utils.rs @@ -1,8 +1,26 @@ -use cosmwasm_std::{Decimal, Uint128}; +use cosmwasm_std::{to_binary, Addr, Decimal, StdResult, SubMsg, Uint128, WasmMsg}; +use cw20::Cw20ExecuteMsg; + use wynd_curve_utils::{Curve, PiecewiseLinear, SaturatingLinear}; use crate::state::Config; +pub fn create_undelegate_msg( + recipient: Addr, + amount: Uint128, + contract: Addr, +) -> StdResult { + let undelegate = Cw20ExecuteMsg::Transfer { + recipient: recipient.to_string(), + amount, + }; + Ok(SubMsg::new(WasmMsg::Execute { + contract_addr: contract.to_string(), + msg: to_binary(&undelegate)?, + funds: vec![], + })) +} + pub fn calc_power(cfg: &Config, stake: Uint128, multiplier: Decimal) -> Uint128 { if stake < cfg.min_bond { Uint128::zero() diff --git a/packages/wyndex/src/factory.rs b/packages/wyndex/src/factory.rs index a8152d8..37ccfca 100644 --- a/packages/wyndex/src/factory.rs +++ b/packages/wyndex/src/factory.rs @@ -2,7 +2,7 @@ use crate::{ asset::AssetInfo, fee_config::FeeConfig, pair::{PairInfo, StakeConfig}, - stake::UnbondingPeriod, + stake::{ConverterConfig, UnbondingPeriod}, }; use cosmwasm_schema::{cw_serde, QueryResponses}; @@ -84,6 +84,8 @@ pub struct DefaultStakeConfig { pub min_bond: Uint128, pub unbonding_periods: Vec, pub max_distributions: u32, + /// Optional converter configuration for the staking contract + pub converter: Option, } impl DefaultStakeConfig { @@ -103,6 +105,9 @@ impl DefaultStakeConfig { if let Some(max_distributions) = partial.max_distributions { self.max_distributions = max_distributions; } + if let Some(converter) = partial.converter { + self.converter = Some(converter); + } self } @@ -132,6 +137,7 @@ impl DefaultStakeConfig { min_bond: self.min_bond, unbonding_periods: self.unbonding_periods, max_distributions: self.max_distributions, + converter: self.converter, } } } @@ -262,6 +268,8 @@ pub struct PartialStakeConfig { pub min_bond: Option, pub unbonding_periods: Option>, pub max_distributions: Option, + /// Optional converter configuration for the staking contract + pub converter: Option, } /// This structure describes the available query messages for the factory contract. @@ -352,6 +360,7 @@ pub enum UpdateAddr { } #[cw_serde] +#[allow(clippy::large_enum_variant)] pub enum MigrateMsg { /// Used to instantiate from cw-placeholder Init(InstantiateMsg), diff --git a/packages/wyndex/src/lib.rs b/packages/wyndex/src/lib.rs index 2b744ae..7d92ada 100644 --- a/packages/wyndex/src/lib.rs +++ b/packages/wyndex/src/lib.rs @@ -2,6 +2,7 @@ pub mod asset; pub mod common; pub mod factory; pub mod fee_config; +pub mod lp_converter; /// Contains some helper functions for storing the price history. pub mod oracle; pub mod pair; diff --git a/packages/wyndex/src/lp_converter.rs b/packages/wyndex/src/lp_converter.rs new file mode 100644 index 0000000..a1ba6b9 --- /dev/null +++ b/packages/wyndex/src/lp_converter.rs @@ -0,0 +1,20 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Uint128; + +#[cw_serde] +pub enum ExecuteMsg { + /// Message sent by the staking contract, along with the freed LP tokens to initiate the conversion + Convert { + /// The address that will own the converted stake. + /// The staking contract will put the sender of the `MigrateStake` message here. + sender: String, + /// How many LP tokens were freed by the staking contract + amount: Uint128, + /// The unbonding period to stake the target LP tokens to + unbonding_period: u64, + /// Address of the pair contract whose LP tokens should be converted + pair_contract_from: String, + /// Address of the pair contract that should receive the converted stake + pair_contract_to: String, + }, +} diff --git a/packages/wyndex/src/oracle.rs b/packages/wyndex/src/oracle.rs index 82a8286..51154d5 100644 --- a/packages/wyndex/src/oracle.rs +++ b/packages/wyndex/src/oracle.rs @@ -1,351 +1,573 @@ -use std::collections::HashMap; +use cosmwasm_schema::cw_serde; -use cosmwasm_schema::{ - cw_serde, - serde::{de::DeserializeOwned, Serialize}, +use cosmwasm_std::{ + Decimal, Decimal256, Env, Fraction, StdError, StdResult, Storage, Timestamp, Uint128, Uint256, }; -use cosmwasm_std::{Deps, Env, Order, StdResult, Storage, Uint128}; -use cw_storage_plus::{Bound, Item, Map}; +use cw_storage_plus::Item; -use crate::{ - asset::AssetInfoValidated, - pair::{ContractError, HistoricalPricesResponse, HistoryDuration}, -}; +use crate::asset::{AssetInfo, AssetInfoValidated}; -const MINUTE: u64 = 60; -const HALF_HOUR: u64 = 30 * MINUTE; -const TWELVE_HOURS: u64 = 60 * MINUTE; +pub const MINUTE: u64 = 60; +pub const HALF_HOUR: u64 = 30 * MINUTE; +pub const SIX_HOURS: u64 = 6 * 60 * MINUTE; -/// For each price history, stores the last timestamp (in seconds) when it was updated #[cw_serde] -#[derive(Default)] -struct LastUpdates { +#[derive(Copy)] +pub enum SamplePeriod { + Minute, + HalfHour, + SixHour, +} + +const LAST_UPDATES: Item = Item::new("oracle_last_updated"); +const LAST_MINUTES_PRICES: Item = Item::new("oracle_by_minute"); +const LAST_HALF_HOUR_PRICES: Item = Item::new("oracle_by_half_hour"); +const LAST_SIX_HOUR_PRICES: Item = Item::new("oracle_by_six_hour"); + +/// For each price history, stores the last timestamp (in seconds) when it was updated, +/// As well as the last measurement (running accumulator). +/// Snapshot times are measured in full seconds +#[cw_serde] +pub struct LastUpdates { + pub accumulator: Accumulator, pub minutes: u64, pub half_hours: u64, - pub twelve_hours: u64, + pub six_hours: u64, } -const LAST_UPDATED: Item = Item::new("oracle_last_updated"); -const LAST_15MINUTES_PRICES: TimeBuffer, MINUTE, 15> = - TimeBuffer::new("oracle_last_15minutes", "oracle_last_15minutes_first"); -const LAST_DAY_PRICES: TimeBuffer, HALF_HOUR, 48> = - TimeBuffer::new("oracle_last_day", "oracle_last_day_first"); -const LAST_WEEK_PRICES: TimeBuffer, TWELVE_HOURS, 14> = - TimeBuffer::new("oracle_last_week", "oracle_last_week_first"); - -pub struct PricePoint { - /// the asset that is being swapped from - pub from: AssetInfoValidated, // TODO: borrow to avoid clone? - /// the asset that is being swapped to - pub to: AssetInfoValidated, - /// the price of the swap - pub price: Uint128, +/// This is the last snapshot of the price accumulator. +/// This only works on 2 pools: +/// A is config.pair_info.asset_infos[0], +/// B is config.pair_info.asset_infos[1], +/// We keep this pattern and limit how much data is written +#[cw_serde] +pub struct Accumulator { + /// Last time we measured the accumulator. + /// Uses nanosecond for subsecond-blocks (eg sei) + pub snapshot: Timestamp, + /// Value of a_per_b at that time + pub last_price: Decimal, + /// Running accumulator values + pub twap_a_per_b: Twap, + pub twap_b_per_a: Twap, + // FIXME: add this later (https://github.com/wynddao/wyndex-priv/issues/7) + // pub geometric_a_to_b: Twgm, } -impl PricePoint { - pub fn new(from: AssetInfoValidated, to: AssetInfoValidated, price: Uint128) -> Self { - Self { from, to, price } +impl Accumulator { + pub fn new(now: Timestamp, price: Decimal) -> Self { + Accumulator { + snapshot: now, + last_price: price, + twap_a_per_b: Default::default(), + twap_b_per_a: Default::default(), + } } -} -/// Stores the price of the asset for TWAP calculations and -pub fn store_price( - storage: &mut dyn Storage, - env: &Env, - asset_infos: &[AssetInfoValidated], - mut prices: Vec, -) -> Result<(), ContractError> { - let mut last_updated = LAST_UPDATED.may_load(storage)?.unwrap_or_default(); - if env.block.time.seconds() == last_updated.minutes { - // if the block time is exactly the minute timestamp, we already updated within this block, - // no need to update - return Ok(()); + /// if env.block.time > self.snapshot, does whole update of twap + /// if equal, then just updates last_price + /// if earlier, panics (should never happen) + pub fn update(&mut self, env: &Env, price: Decimal) { + use std::cmp::Ordering::*; + let now = env.block.time; + match now.cmp(&self.snapshot) { + Less => { + panic!("Cannot update from the past"); + } + Equal => { + // just update the last price + self.last_price = price; + } + Greater => { + // we do proper update + let elapsed = diff_nanos(self.snapshot, now); + self.twap_a_per_b = self.twap_a_per_b.accumulate_nanos(self.last_price, elapsed); + // Note: price must never be 0 + self.twap_b_per_a = self + .twap_b_per_a + .accumulate_nanos(self.last_price.inv().unwrap(), elapsed); + self.last_price = price; + self.snapshot = now; + } + } } +} + +pub const BUFFER_DEPTH: usize = 32; + +/// This is a buffer of at most [`BUFFER_DEPTH`] size, containing snapshots of the accumulator. +/// Accumulator values are stored newest to oldest. Index 0 is the value as of LastUpdates. +/// Every value after that is the (interpolated) value one after that +/// (eg for the HALF_HOUR range, 3 index steps means 90 minutes) +/// The buffer starts out empty, and is filled in as we go, but always has at most [`BUFFER_DEPTH`] values. +#[cw_serde] +#[derive(Default)] +pub struct Prices { + pub twap_a_per_b: Vec, + pub twap_b_per_a: Vec, + // FIXME: add this later (https://github.com/wynddao/wyndex-priv/issues/7) + // pub geometric_a_to_b: [Twgm; BUFFER_DEPTH], +} + +impl Prices { + /// update the whole price buffer, given latest accumulator, last sample time, and current time + pub fn accumulate( + &self, + last_update: u64, + latest_checkpoint: u64, + acc: &Accumulator, + step: u64, + ) -> Prices { + let new_checkpoints = ((latest_checkpoint - last_update) / step) as usize; + + let mut new_prices = Prices::default(); + + if new_prices.twap_a_per_b.len() < BUFFER_DEPTH { + // we have not fully filled the buffer yet, so we extend the size first + // both vectors are the same size, so we only need to calculate this for one of them + let len = BUFFER_DEPTH.min(self.twap_a_per_b.len() + new_checkpoints); + new_prices.twap_a_per_b.resize(len, Default::default()); + new_prices.twap_b_per_a.resize(len, Default::default()); + } + + // we copy any still valid ones to their new offset + // and figure out where we start computing from + let (last_copied, last_timestamp) = if new_checkpoints < BUFFER_DEPTH { + let len = new_prices.twap_a_per_b.len(); + new_prices.twap_a_per_b[new_checkpoints..] + .copy_from_slice(&self.twap_a_per_b[0..len - new_checkpoints]); + new_prices.twap_b_per_a[new_checkpoints..] + .copy_from_slice(&self.twap_b_per_a[0..len - new_checkpoints]); + (new_checkpoints, last_update) + } else { + // all are invalid, need to figure out the time that would be at the first one + let oldest_time = latest_checkpoint - step * ((BUFFER_DEPTH) as u64); + (BUFFER_DEPTH, oldest_time) + }; - // get the correct order of the price entries - let asset_info_index = cartesian_product(asset_infos) - .map(|(a, b)| (a, b)) - .enumerate() - .map(|(i, a)| (a, i)) - .collect::>(); - prices.sort_by(|a, b| { - asset_info_index[&(&a.from, &a.to)].cmp(&asset_info_index[&(&b.from, &b.to)]) - }); - let prices: Vec<_> = prices.into_iter().map(|p| p.price).collect(); - - let mut updated = false; - - if last_updated.minutes + MINUTE <= env.block.time.seconds() { - // update every minute - LAST_15MINUTES_PRICES.save(storage, env.block.time.seconds(), prices.clone())?; - last_updated.minutes = env.block.time.seconds(); - updated = true; + // * last_timestamp from accumulator + // * value at that timestamp + // * last_price from accumulator + // * time at first index we will be writing to + + for i in 0..last_copied { + // how much time passed between the accumulator and this checkpoint + let time = Timestamp::from_seconds(last_timestamp + (last_copied - i) as u64 * step); + let elapsed_nanos = diff_nanos(acc.snapshot, time); + // set the new values + new_prices.twap_a_per_b[i] = acc + .twap_a_per_b + .accumulate_nanos(acc.last_price, elapsed_nanos); + new_prices.twap_b_per_a[i] = acc + .twap_b_per_a + .accumulate_nanos(acc.last_price.inv().unwrap(), elapsed_nanos); + } + + new_prices } +} - if last_updated.half_hours + HALF_HOUR <= env.block.time.seconds() { - // update every half hour - LAST_DAY_PRICES.save(storage, env.block.time.seconds(), prices.clone())?; - last_updated.half_hours = env.block.time.seconds(); - updated = true; +/// We need more precision than Uint128, but will overflow with Decimal +#[cw_serde] +#[derive(Default, Copy, Eq, PartialOrd, Ord)] +pub struct Twap(Decimal256); + +impl Twap { + /// Give it the time since the last measurement and the value at last snapshot. + /// It will add (last_price * elapsed_seconds) to the accumulator. + /// Make sure to be careful with overflow + #[must_use] + pub fn accumulate_nanos(&self, last_price: Decimal, elapsed_nanos: u64) -> Twap { + let numerator = Uint256::from(last_price.numerator()) * Uint256::from(elapsed_nanos); + // 10^18 from Decimal, 10^9 from nanos + let increment = + Decimal256::from_atomics(numerator, Decimal256::DECIMAL_PLACES + 9).unwrap(); + Twap(self.0 + increment) } - if last_updated.twelve_hours + TWELVE_HOURS <= env.block.time.seconds() { - // update every 12 hours - LAST_WEEK_PRICES.save(storage, env.block.time.seconds(), prices)?; - last_updated.twelve_hours = env.block.time.seconds(); - updated = true; + #[must_use] + pub fn accumulate_secs(&self, last_price: Decimal, elapsed_secs: u64) -> Twap { + let numerator = Uint256::from(last_price.numerator()) * Uint256::from(elapsed_secs); + let increment = Decimal256::from_atomics(numerator, Decimal256::DECIMAL_PLACES).unwrap(); + Twap(self.0 + increment) } - if updated { - LAST_UPDATED.save(storage, &last_updated)?; + /// Given two Twap values and the time between them, get the average price in this range + /// (now - earlier) * 10^9 / elapsed_nanos + pub fn average_price(&self, earlier: &Twap, elapsed_nanos: u64) -> Decimal { + let diff = self.0 - earlier.0; + let atomics = diff.numerator() / Uint256::from(elapsed_nanos); + Decimal::from_atomics( + Uint128::try_from(atomics).unwrap(), + Decimal256::DECIMAL_PLACES - 9, + ) + .unwrap() } +} + +/// get the elapsed nanos from older to later +pub fn diff_nanos(older: Timestamp, later: Timestamp) -> u64 { + later.nanos() - older.nanos() +} + +/// This must be called one time when the initial liquidity is added to initialize all the twap counters. +/// It gets the timestamp of the block along with the initial price, and sets up all accumulators +pub fn initialize_oracle(storage: &mut dyn Storage, env: &Env, price: Decimal) -> StdResult<()> { + let now = env.block.time; + + // save the current value + let accumulator = Accumulator::new(now, price); + let last_updates = LastUpdates { + accumulator, + minutes: now.seconds(), + half_hours: now.seconds(), + six_hours: now.seconds(), + }; + LAST_UPDATES.save(storage, &last_updates)?; + + // set empty prices (0 for all accumulators) + let empty_prices = Prices::default(); + LAST_MINUTES_PRICES.save(storage, &empty_prices)?; + LAST_HALF_HOUR_PRICES.save(storage, &empty_prices)?; + LAST_SIX_HOUR_PRICES.save(storage, &empty_prices)?; + Ok(()) } -pub fn query_historical( - deps: Deps, +/// This is called every time the price changes in the pool. +/// If this is the same timestamp as the last update (same block), we just update last_price +/// If it is later timestamp, we update the accumulator, and possibly update historical values +pub fn store_oracle_price( + storage: &mut dyn Storage, env: &Env, - asset_infos: Vec, - duration: HistoryDuration, -) -> StdResult { - match duration { - HistoryDuration::FifteenMinutes => { - query_timebuffer(deps, env, &LAST_15MINUTES_PRICES, asset_infos) - } - HistoryDuration::Day => query_timebuffer(deps, env, &LAST_DAY_PRICES, asset_infos), - HistoryDuration::Week => query_timebuffer(deps, env, &LAST_WEEK_PRICES, asset_infos), + new_price_a_per_b: Decimal, +) -> StdResult<()> { + let mut updates = LAST_UPDATES.load(storage)?; + // if the block time is exactly the minute timestamp, we already updated within this block, just track last_price + if env.block.time == updates.accumulator.snapshot { + updates.accumulator.last_price = new_price_a_per_b; + LAST_UPDATES.save(storage, &updates)?; + return Ok(()); } -} -/// Returns the last day of price history for each asset combination. -/// Make sure the `asset_infos` are ordered correctly. -fn query_timebuffer( - deps: Deps, - env: &Env, - buffer: &TimeBuffer, STEP, CAP>, - asset_infos: Vec, -) -> StdResult { - // buffer.all returns a vec with all prices for each timestamp, - // but we want to return one vec with all prices *per asset combination* - let combinations = combinations(&asset_infos); - - let mut cumulative_prices: Vec<_> = combinations - .into_iter() - .map(|(from, to)| (from, to, vec![])) - .collect(); - - for result in buffer.all(deps.storage, env)? { - let (timestamp, prices) = result?; - for i in 0..cumulative_prices.len() { - cumulative_prices[i].2.push((timestamp, prices[i])); - } + // update if full minute has passed since last time + if let Some(latest_checkpoint) = calc_checkpoint(updates.minutes, env, MINUTE) { + let old_prices = LAST_MINUTES_PRICES.load(storage)?; + let prices = old_prices.accumulate( + updates.minutes, + latest_checkpoint, + &updates.accumulator, + MINUTE, + ); + updates.minutes = latest_checkpoint; + LAST_MINUTES_PRICES.save(storage, &prices)?; } - Ok(HistoricalPricesResponse { - historical_prices: cumulative_prices, - }) -} + // update if full half hour has passed since last time + if let Some(latest_checkpoint) = calc_checkpoint(updates.half_hours, env, HALF_HOUR) { + let old_prices = LAST_HALF_HOUR_PRICES.load(storage)?; + let prices = old_prices.accumulate( + updates.half_hours, + latest_checkpoint, + &updates.accumulator, + HALF_HOUR, + ); + updates.half_hours = latest_checkpoint; + LAST_HALF_HOUR_PRICES.save(storage, &prices)?; + } -fn combinations( - asset_infos: &[AssetInfoValidated], -) -> Vec<(AssetInfoValidated, AssetInfoValidated)> { - let mut combinations = - Vec::with_capacity(asset_infos.len() * asset_infos.len() - asset_infos.len()); - for from in asset_infos { - for to in asset_infos { - if from != to { - combinations.push((from.clone(), to.clone())); - } - } + // update if full six hour has passed since last time + if let Some(latest_checkpoint) = calc_checkpoint(updates.six_hours, env, SIX_HOURS) { + let old_prices = LAST_SIX_HOUR_PRICES.load(storage)?; + let prices = old_prices.accumulate( + updates.six_hours, + latest_checkpoint, + &updates.accumulator, + SIX_HOURS, + ); + updates.six_hours = latest_checkpoint; + LAST_SIX_HOUR_PRICES.save(storage, &prices)?; } - combinations -} -fn cartesian_product( - asset_infos: &[AssetInfoValidated], -) -> impl Iterator { - asset_infos.iter().flat_map(move |from| { - asset_infos - .iter() - .filter(|to| !from.equal(to)) - .map(move |to| (from, to)) - }) + // always update the current accumulator (after calculations are finished to not interfere) + updates.accumulator.update(env, new_price_a_per_b); + LAST_UPDATES.save(storage, &updates) } -/// A ringbuffer, intended for using timestamps as indices. -/// -/// # Layout -/// The buffer is stored in a `Map` with an index (derived from the timestamp) as key and both timestamp and `T` as value. -/// It also contains a metadata item that stores the index of the first element and its timestamp. -/// The map does not give a lot of guarantees, except that all valid entries are ordered by their timestamp. -/// There are, however, possibly invalid entries stored. These are entries that are older than the start time. -/// These are not removed, but filtered out when loading the buffer. -struct TimeBuffer<'a, T: Serialize + DeserializeOwned, const STEP: u64, const CAP: u64> { - /// The actual data. The value contains both the timestamp and the value. - data: Map<'a, u64, (u64, T)>, - /// The start index. This is subtracted from all indices to get the actual index. - /// It will cycle through the `data` index space. - start_idx: Item<'a, StartData>, +/// This finds the most recent checkpoint before the current moment. +/// Returns None if there is nothing more recent than the latest update. +fn calc_checkpoint(last_update: u64, env: &Env, step: u64) -> Option { + let steps = (env.block.time.seconds() - last_update) / step; + if steps == 0 { + None + } else { + Some(last_update + steps * step) + } } #[cw_serde] -struct StartData { - /// The index inside the map that corresponds to the start time. - index: u64, - /// The timestamp when the buffer starts. All indices are calculated relative to this. - time: u64, +pub struct TwapResponse { + pub a: AssetInfo, + pub b: AssetInfo, + pub a_per_b: Decimal, + pub b_per_a: Decimal, } -impl<'a, T: Serialize + DeserializeOwned + std::fmt::Debug, const STEP: u64, const CAP: u64> - TimeBuffer<'a, T, STEP, CAP> -{ - pub const fn new(name: &'a str, start_idx: &'a str) -> Self { - Self { - data: Map::new(name), - start_idx: Item::new(start_idx), +/// This gets the twap for a range, which must be one of our sample frequencies, within the depth we maintain +pub fn query_oracle_range( + storage: &dyn Storage, + env: &Env, + asset_infos: &[AssetInfoValidated], + // This is the resolution of the buffer we wish to read + sample_period: SamplePeriod, + // This is the beginning of the period, measured in how many full samples back we start + // 4 would start 4 full sample periods earlier than the end of the time buffer + start_index: u32, + // This is the end of the period, measured in how many full samples back we end. + // Some(0) takes the last item on the stored buffer. + // None takes the latest accumulator update + end_index: Option, +) -> StdResult { + // TODO: assert start_index > end_index + + let updates = LAST_UPDATES.load(storage)?; + let (step, last_update, stored_prices) = match sample_period { + SamplePeriod::Minute => (MINUTE, updates.minutes, LAST_MINUTES_PRICES.load(storage)?), + SamplePeriod::HalfHour => ( + HALF_HOUR, + updates.half_hours, + LAST_HALF_HOUR_PRICES.load(storage)?, + ), + SamplePeriod::SixHour => ( + SIX_HOURS, + updates.six_hours, + LAST_SIX_HOUR_PRICES.load(storage)?, + ), + }; + + // interpolate prices to the present (if they haven't been updated in a while) + let latest_checkpoint = calc_checkpoint(last_update, env, step); + let (_checkpoint, prices) = match latest_checkpoint { + Some(checkpoint) => ( + checkpoint, + stored_prices.accumulate(last_update, checkpoint, &updates.accumulator, step), + ), + None => (last_update, stored_prices), + }; + + let old_twap_a_per_b = prices + .twap_a_per_b + .get(start_index as usize) + .ok_or_else(|| { + StdError::generic_err("start index is earlier than earliest recorded price data") + })?; + let old_twap_b_per_a = prices.twap_b_per_a[start_index as usize]; + + // handle current accumulator (`end_index == None`) + let (elapsed_nanos, new_twap_a_per_b, new_twap_b_per_a) = match end_index { + Some(end_index) => { + let elapsed_nanos = step * 1_000_000_000u64 * (start_index - end_index) as u64; + + ( + elapsed_nanos, + prices.twap_a_per_b[end_index as usize], + prices.twap_b_per_a[end_index as usize], + ) } - } + None => { + // in this case, we calculate time between start entry and the last buffer entry and add the time since the last update + let elapsed_nanos = step * 1_000_000_000u64 * start_index as u64 + + env.block.time.nanos() + - last_update * 1_000_000_000u64; + + let elapsed_since_acc = env.block.time.nanos() - updates.accumulator.snapshot.nanos(); + ( + elapsed_nanos, + updates + .accumulator + .twap_a_per_b + .accumulate_nanos(updates.accumulator.last_price, elapsed_since_acc), + updates.accumulator.twap_b_per_a.accumulate_nanos( + updates.accumulator.last_price.inv().unwrap(), + elapsed_since_acc, + ), + ) + } + }; - /// Save the given timestamp and value to the buffer. - /// Note: Make sure to only save timestamps in increasing order - /// (or more specifically: Do not store a value chronologically before the oldest entry). - pub fn save(&self, storage: &mut dyn Storage, timestamp: u64, value: T) -> StdResult<()> { - // get start index, defaulting to the given timestamp if not set - let mut save_start = false; - let mut start = match self.start_idx.may_load(storage)? { - Some(start) => start, - None => { - let start = StartData { - index: 0, - time: timestamp, - }; - save_start = true; - start - } - }; + let a_per_b = new_twap_a_per_b.average_price(old_twap_a_per_b, elapsed_nanos); + let b_per_a = new_twap_b_per_a.average_price(&old_twap_b_per_a, elapsed_nanos); - if timestamp < start.time { - panic!( - "Only limited timetravel is supported. Cannot store data before the first entry." - ); - } + Ok(TwapResponse { + a: asset_infos[0].clone().into(), + b: asset_infos[1].clone().into(), + a_per_b, + b_per_a, + }) +} - // calculate the index inside the buffer - let actual_idx = (timestamp - start.time) / STEP + start.index; - if timestamp >= start.time + STEP * CAP { - // we skipped over the start index, so we need to move the start index behind the latest entry - start.index = (actual_idx + 1) % CAP; - // also update the timestamp of the start index - // it should be CAP steps behind the latest entry - start.time += (actual_idx + 1 - CAP) * STEP; - save_start = true; - } - if save_start { - self.start_idx.save(storage, &start)?; - } - // wrap index to the buffer capacity - let actual_idx = actual_idx % CAP; +/// This gets the twap for a range, which must be one of our sample frequencies, within the depth we maintain +pub fn query_oracle_accumulator(storage: &dyn Storage) -> StdResult { + Ok(LAST_UPDATES.load(storage)?.accumulator) +} + +#[cfg(test)] +mod tests { + use crate::oracle::{Accumulator, Twap, BUFFER_DEPTH}; + use cosmwasm_std::testing::mock_env; + use cosmwasm_std::{assert_approx_eq, Decimal, Fraction, Timestamp, Uint128}; + + use super::{calc_checkpoint, Prices, MINUTE}; - self.data.save(storage, actual_idx, &(timestamp, value))?; - Ok(()) + #[test] + fn twap_accumulates() { + // Test 10 s at 3, 10s at 2, 10s at 1... see average + let orig = Twap::default(); + let first = orig.accumulate_nanos(Decimal::percent(300), 10_000_000_000); + let second = first.accumulate_nanos(Decimal::percent(200), 10_000_000_000); + let third = second.accumulate_nanos(Decimal::percent(100), 10_000_000_000); + + // find averages over all time + let total_avg = third.average_price(&orig, 30_000_000_000); + assert_eq!(total_avg, Decimal::percent(200)); + + // this has 10s at 2, 10s at 1 + let partial_avg = third.average_price(&first, 20_000_000_000); + assert_eq!(partial_avg, Decimal::percent(150)); } - pub fn all<'b>( - &self, - storage: &'a dyn Storage, - env: &Env, - ) -> StdResult> + 'b> - where - T: 'b, - 'a: 'b, - { - let start_data = self.start_idx.may_load(storage)?.unwrap_or(StartData { - index: 0, - time: env.block.time.seconds(), - }); - - Ok(self - .data - // range from start index to end of `data` - .range( - storage, - Some(Bound::inclusive(start_data.index)), - None, - Order::Ascending, - ) - // then from start of `data` to start index - .chain(self.data.range( - storage, - None, - Some(Bound::exclusive(start_data.index)), - Order::Ascending, - )) - .filter(move |res| Self::is_valid(start_data.time, res)) - .map(|res| res.map(|(_, (timestamp, value))| (timestamp, value)))) + #[test] + fn updating_accumulator() { + let step = 15u64; + let time = Timestamp::from_seconds(1682155831); + + // this is history that will be ignored + let mut acc = Accumulator::new(time, Decimal::percent(1700)); + + // for the start of our counting era, the price is 3 + let mut env = mock_env(); + let time = time.plus_seconds(500); + env.block.time = time; + acc.update(&env, Decimal::percent(300)); + let orig = acc.clone(); + + // after one "step", drops down to 1.00 + env.block.time = time.plus_seconds(step); + acc.update(&env, Decimal::percent(100)); + + // after another step, comes up to 2.00 + env.block.time = time.plus_seconds(step * 2); + acc.update(&env, Decimal::percent(200)); + + // after another step moves to 5 (doesn't matter as this time is not included) + env.block.time = time.plus_seconds(step * 3); + acc.update(&env, Decimal::percent(500)); + + // ensure other attributes set + assert_eq!(acc.last_price, Decimal::percent(500)); + assert_eq!(acc.snapshot, env.block.time); + + // average a_per_b price should be (3 + 1 + 2) / 3 = 2 + let a_per_b = acc + .twap_a_per_b + .average_price(&orig.twap_a_per_b, step * 3 * 1_000_000_000); + assert_eq!(a_per_b, Decimal::percent(200)); + + // average b_per_a price should be (1/3 + 1 + 1/2) / 3 = 11/18 + let b_per_a = acc + .twap_b_per_a + .average_price(&orig.twap_b_per_a, step * 3 * 1_000_000_000); + let expected = Decimal::from_ratio(11u128, 18u128); + // they should be close to 1 part per 1_000_000 (rounding) + assert_eq!( + b_per_a * Uint128::new(1_000_000), + expected * Uint128::new(1_000_000) + ); } - fn is_valid(start_time: u64, res: &StdResult<(u64, (u64, T))>) -> bool { - // keep only errors and valid entries - match res { - Ok((_, (time, _))) => *time >= start_time, - Err(_) => true, - } + #[test] + fn updating_price_buffer() { + let mut prices = Prices::default(); + + let mut env = mock_env(); + // set price at 2.0 + let accumulator: Accumulator = + Accumulator::new(env.block.time, Decimal::from_atomics(2u128, 0).unwrap()); + let last_update = env.block.time.seconds(); + + // wait two minutes and accumulate + env.block.time = env.block.time.plus_seconds(120); + let checkpoint = calc_checkpoint(last_update, &env, MINUTE).unwrap(); + prices = prices.accumulate(last_update, checkpoint, &accumulator, MINUTE); + + // query the twap price at 1 minute ago vs now (should be 2.0) + let old_twap = prices.twap_a_per_b[1]; + let new_twap = prices.twap_a_per_b[0]; + let a_per_b = new_twap.average_price(&old_twap, MINUTE * 1_000_000_000u64); + + assert_approx_eq!( + a_per_b.numerator(), + Decimal::from_atomics(2u128, 0).unwrap().numerator(), + "0.00002" + ); } -} -#[cfg(test)] -mod tests { - use super::*; - use cosmwasm_std::testing::{mock_env, MockStorage}; + #[test] + fn long_gap_between_prices() { + let mut prices = Prices::default(); + + let mut env = mock_env(); + // set price at 2.0 + let accumulator = Accumulator::new(env.block.time, Decimal::percent(200)); + let last_update = env.block.time.seconds(); + + // wait 10.5 minutes and accumulate + env.block.time = env.block.time.plus_seconds(10 * 60 + 30); + let checkpoint = calc_checkpoint(last_update, &env, MINUTE).unwrap(); + prices = prices.accumulate(last_update, checkpoint, &accumulator, MINUTE); + + let new_twap = prices.twap_a_per_b[0]; + for i in 1..=9 { + // check `i` minutes ago vs latest + let old_twap = prices.twap_a_per_b[i]; + + let a_per_b = new_twap.average_price(&old_twap, i as u64 * MINUTE * 1_000_000_000u64); + assert_eq!(a_per_b.numerator(), Decimal::percent(200).numerator()); + } + assert_eq!(prices.twap_a_per_b.len(), 10); + } #[test] - fn timebuffer() { - let mut storage = MockStorage::default(); - let env = mock_env(); - - // buffer that holds 10 entries, with a 1 second step in between - let buffer = TimeBuffer::::new("test", "test_start"); - - // empty buffer - let entries = buffer - .all(&storage, &env) - .unwrap() - .collect::>>() - .unwrap(); - assert_eq!(entries, vec![]); - - let now = env.block.time.seconds(); - - buffer.save(&mut storage, now, 1).unwrap(); - buffer.save(&mut storage, now + 1, 2).unwrap(); - // leave `now + 2` empty - buffer.save(&mut storage, now + 3, 4).unwrap(); - - let entries = buffer - .all(&storage, &env) - .unwrap() - .collect::>>() - .unwrap(); - assert_eq!(entries, vec![(now, 1), (now + 1, 2), (now + 3, 4)]); - - // wrap around, overwriting `now` - buffer.save(&mut storage, now + 10, 5).unwrap(); - - let entries = buffer - .all(&storage, &env) - .unwrap() - .collect::>>() - .unwrap(); - assert_eq!(entries, vec![(now + 1, 2), (now + 3, 4), (now + 10, 5)]); - - // wrap around multiple times, overwriting all other entries - buffer.save(&mut storage, now + 35, 6).unwrap(); - - let entries = buffer - .all(&storage, &env) - .unwrap() - .collect::>>() - .unwrap(); - assert_eq!(entries, vec![(now + 35, 6)]); + fn buffer_len() { + let mut prices = Prices::default(); + + let mut env = mock_env(); + let mut accumulator = Accumulator::new(env.block.time, Decimal::one()); + let last_update = env.block.time.seconds(); + + // wait 1 second and accumulate + env.block.time = env.block.time.plus_seconds(1); + let checkpoint = calc_checkpoint(last_update, &env, 1).unwrap(); + prices = prices.accumulate(last_update, checkpoint, &accumulator, 1); + let last_update = env.block.time.seconds(); + // change accumulator price + accumulator.update(&env, Decimal::percent(200)); + + // wait `BUFFER_DEPTH` seconds and accumulate (this should overwrite the first entry) + env.block.time = env.block.time.plus_seconds(BUFFER_DEPTH as u64); + let checkpoint = calc_checkpoint(last_update, &env, 1).unwrap(); + assert_eq!(checkpoint, last_update + BUFFER_DEPTH as u64); + prices = prices.accumulate(last_update, checkpoint, &accumulator, 1); + + // all TWAPs should come out to the new price, since the first entry was overwritten + let latest = prices.twap_a_per_b[0]; + for i in 1..BUFFER_DEPTH { + assert_eq!( + latest.average_price(&prices.twap_a_per_b[i], i as u64 * 1_000_000_000u64), + Decimal::percent(200) + ); + } + + assert_eq!(prices.twap_a_per_b.len(), BUFFER_DEPTH); } } diff --git a/packages/wyndex/src/oracle_old.rs b/packages/wyndex/src/oracle_old.rs new file mode 100644 index 0000000..82a8286 --- /dev/null +++ b/packages/wyndex/src/oracle_old.rs @@ -0,0 +1,351 @@ +use std::collections::HashMap; + +use cosmwasm_schema::{ + cw_serde, + serde::{de::DeserializeOwned, Serialize}, +}; +use cosmwasm_std::{Deps, Env, Order, StdResult, Storage, Uint128}; +use cw_storage_plus::{Bound, Item, Map}; + +use crate::{ + asset::AssetInfoValidated, + pair::{ContractError, HistoricalPricesResponse, HistoryDuration}, +}; + +const MINUTE: u64 = 60; +const HALF_HOUR: u64 = 30 * MINUTE; +const TWELVE_HOURS: u64 = 60 * MINUTE; + +/// For each price history, stores the last timestamp (in seconds) when it was updated +#[cw_serde] +#[derive(Default)] +struct LastUpdates { + pub minutes: u64, + pub half_hours: u64, + pub twelve_hours: u64, +} + +const LAST_UPDATED: Item = Item::new("oracle_last_updated"); +const LAST_15MINUTES_PRICES: TimeBuffer, MINUTE, 15> = + TimeBuffer::new("oracle_last_15minutes", "oracle_last_15minutes_first"); +const LAST_DAY_PRICES: TimeBuffer, HALF_HOUR, 48> = + TimeBuffer::new("oracle_last_day", "oracle_last_day_first"); +const LAST_WEEK_PRICES: TimeBuffer, TWELVE_HOURS, 14> = + TimeBuffer::new("oracle_last_week", "oracle_last_week_first"); + +pub struct PricePoint { + /// the asset that is being swapped from + pub from: AssetInfoValidated, // TODO: borrow to avoid clone? + /// the asset that is being swapped to + pub to: AssetInfoValidated, + /// the price of the swap + pub price: Uint128, +} + +impl PricePoint { + pub fn new(from: AssetInfoValidated, to: AssetInfoValidated, price: Uint128) -> Self { + Self { from, to, price } + } +} + +/// Stores the price of the asset for TWAP calculations and +pub fn store_price( + storage: &mut dyn Storage, + env: &Env, + asset_infos: &[AssetInfoValidated], + mut prices: Vec, +) -> Result<(), ContractError> { + let mut last_updated = LAST_UPDATED.may_load(storage)?.unwrap_or_default(); + if env.block.time.seconds() == last_updated.minutes { + // if the block time is exactly the minute timestamp, we already updated within this block, + // no need to update + return Ok(()); + } + + // get the correct order of the price entries + let asset_info_index = cartesian_product(asset_infos) + .map(|(a, b)| (a, b)) + .enumerate() + .map(|(i, a)| (a, i)) + .collect::>(); + prices.sort_by(|a, b| { + asset_info_index[&(&a.from, &a.to)].cmp(&asset_info_index[&(&b.from, &b.to)]) + }); + let prices: Vec<_> = prices.into_iter().map(|p| p.price).collect(); + + let mut updated = false; + + if last_updated.minutes + MINUTE <= env.block.time.seconds() { + // update every minute + LAST_15MINUTES_PRICES.save(storage, env.block.time.seconds(), prices.clone())?; + last_updated.minutes = env.block.time.seconds(); + updated = true; + } + + if last_updated.half_hours + HALF_HOUR <= env.block.time.seconds() { + // update every half hour + LAST_DAY_PRICES.save(storage, env.block.time.seconds(), prices.clone())?; + last_updated.half_hours = env.block.time.seconds(); + updated = true; + } + + if last_updated.twelve_hours + TWELVE_HOURS <= env.block.time.seconds() { + // update every 12 hours + LAST_WEEK_PRICES.save(storage, env.block.time.seconds(), prices)?; + last_updated.twelve_hours = env.block.time.seconds(); + updated = true; + } + + if updated { + LAST_UPDATED.save(storage, &last_updated)?; + } + Ok(()) +} + +pub fn query_historical( + deps: Deps, + env: &Env, + asset_infos: Vec, + duration: HistoryDuration, +) -> StdResult { + match duration { + HistoryDuration::FifteenMinutes => { + query_timebuffer(deps, env, &LAST_15MINUTES_PRICES, asset_infos) + } + HistoryDuration::Day => query_timebuffer(deps, env, &LAST_DAY_PRICES, asset_infos), + HistoryDuration::Week => query_timebuffer(deps, env, &LAST_WEEK_PRICES, asset_infos), + } +} + +/// Returns the last day of price history for each asset combination. +/// Make sure the `asset_infos` are ordered correctly. +fn query_timebuffer( + deps: Deps, + env: &Env, + buffer: &TimeBuffer, STEP, CAP>, + asset_infos: Vec, +) -> StdResult { + // buffer.all returns a vec with all prices for each timestamp, + // but we want to return one vec with all prices *per asset combination* + let combinations = combinations(&asset_infos); + + let mut cumulative_prices: Vec<_> = combinations + .into_iter() + .map(|(from, to)| (from, to, vec![])) + .collect(); + + for result in buffer.all(deps.storage, env)? { + let (timestamp, prices) = result?; + for i in 0..cumulative_prices.len() { + cumulative_prices[i].2.push((timestamp, prices[i])); + } + } + + Ok(HistoricalPricesResponse { + historical_prices: cumulative_prices, + }) +} + +fn combinations( + asset_infos: &[AssetInfoValidated], +) -> Vec<(AssetInfoValidated, AssetInfoValidated)> { + let mut combinations = + Vec::with_capacity(asset_infos.len() * asset_infos.len() - asset_infos.len()); + for from in asset_infos { + for to in asset_infos { + if from != to { + combinations.push((from.clone(), to.clone())); + } + } + } + combinations +} + +fn cartesian_product( + asset_infos: &[AssetInfoValidated], +) -> impl Iterator { + asset_infos.iter().flat_map(move |from| { + asset_infos + .iter() + .filter(|to| !from.equal(to)) + .map(move |to| (from, to)) + }) +} + +/// A ringbuffer, intended for using timestamps as indices. +/// +/// # Layout +/// The buffer is stored in a `Map` with an index (derived from the timestamp) as key and both timestamp and `T` as value. +/// It also contains a metadata item that stores the index of the first element and its timestamp. +/// The map does not give a lot of guarantees, except that all valid entries are ordered by their timestamp. +/// There are, however, possibly invalid entries stored. These are entries that are older than the start time. +/// These are not removed, but filtered out when loading the buffer. +struct TimeBuffer<'a, T: Serialize + DeserializeOwned, const STEP: u64, const CAP: u64> { + /// The actual data. The value contains both the timestamp and the value. + data: Map<'a, u64, (u64, T)>, + /// The start index. This is subtracted from all indices to get the actual index. + /// It will cycle through the `data` index space. + start_idx: Item<'a, StartData>, +} + +#[cw_serde] +struct StartData { + /// The index inside the map that corresponds to the start time. + index: u64, + /// The timestamp when the buffer starts. All indices are calculated relative to this. + time: u64, +} + +impl<'a, T: Serialize + DeserializeOwned + std::fmt::Debug, const STEP: u64, const CAP: u64> + TimeBuffer<'a, T, STEP, CAP> +{ + pub const fn new(name: &'a str, start_idx: &'a str) -> Self { + Self { + data: Map::new(name), + start_idx: Item::new(start_idx), + } + } + + /// Save the given timestamp and value to the buffer. + /// Note: Make sure to only save timestamps in increasing order + /// (or more specifically: Do not store a value chronologically before the oldest entry). + pub fn save(&self, storage: &mut dyn Storage, timestamp: u64, value: T) -> StdResult<()> { + // get start index, defaulting to the given timestamp if not set + let mut save_start = false; + let mut start = match self.start_idx.may_load(storage)? { + Some(start) => start, + None => { + let start = StartData { + index: 0, + time: timestamp, + }; + save_start = true; + start + } + }; + + if timestamp < start.time { + panic!( + "Only limited timetravel is supported. Cannot store data before the first entry." + ); + } + + // calculate the index inside the buffer + let actual_idx = (timestamp - start.time) / STEP + start.index; + if timestamp >= start.time + STEP * CAP { + // we skipped over the start index, so we need to move the start index behind the latest entry + start.index = (actual_idx + 1) % CAP; + // also update the timestamp of the start index + // it should be CAP steps behind the latest entry + start.time += (actual_idx + 1 - CAP) * STEP; + save_start = true; + } + if save_start { + self.start_idx.save(storage, &start)?; + } + // wrap index to the buffer capacity + let actual_idx = actual_idx % CAP; + + self.data.save(storage, actual_idx, &(timestamp, value))?; + Ok(()) + } + + pub fn all<'b>( + &self, + storage: &'a dyn Storage, + env: &Env, + ) -> StdResult> + 'b> + where + T: 'b, + 'a: 'b, + { + let start_data = self.start_idx.may_load(storage)?.unwrap_or(StartData { + index: 0, + time: env.block.time.seconds(), + }); + + Ok(self + .data + // range from start index to end of `data` + .range( + storage, + Some(Bound::inclusive(start_data.index)), + None, + Order::Ascending, + ) + // then from start of `data` to start index + .chain(self.data.range( + storage, + None, + Some(Bound::exclusive(start_data.index)), + Order::Ascending, + )) + .filter(move |res| Self::is_valid(start_data.time, res)) + .map(|res| res.map(|(_, (timestamp, value))| (timestamp, value)))) + } + + fn is_valid(start_time: u64, res: &StdResult<(u64, (u64, T))>) -> bool { + // keep only errors and valid entries + match res { + Ok((_, (time, _))) => *time >= start_time, + Err(_) => true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_env, MockStorage}; + + #[test] + fn timebuffer() { + let mut storage = MockStorage::default(); + let env = mock_env(); + + // buffer that holds 10 entries, with a 1 second step in between + let buffer = TimeBuffer::::new("test", "test_start"); + + // empty buffer + let entries = buffer + .all(&storage, &env) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(entries, vec![]); + + let now = env.block.time.seconds(); + + buffer.save(&mut storage, now, 1).unwrap(); + buffer.save(&mut storage, now + 1, 2).unwrap(); + // leave `now + 2` empty + buffer.save(&mut storage, now + 3, 4).unwrap(); + + let entries = buffer + .all(&storage, &env) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(entries, vec![(now, 1), (now + 1, 2), (now + 3, 4)]); + + // wrap around, overwriting `now` + buffer.save(&mut storage, now + 10, 5).unwrap(); + + let entries = buffer + .all(&storage, &env) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(entries, vec![(now + 1, 2), (now + 3, 4), (now + 10, 5)]); + + // wrap around multiple times, overwriting all other entries + buffer.save(&mut storage, now + 35, 6).unwrap(); + + let entries = buffer + .all(&storage, &env) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(entries, vec![(now + 35, 6)]); + } +} diff --git a/packages/wyndex/src/pair.rs b/packages/wyndex/src/pair.rs index 34ed726..ebae855 100644 --- a/packages/wyndex/src/pair.rs +++ b/packages/wyndex/src/pair.rs @@ -4,6 +4,8 @@ use crate::{ asset::{Asset, AssetInfo, AssetInfoValidated, AssetValidated, DecimalAsset}, factory::{ConfigResponse as FactoryConfigResponse, QueryMsg as FactoryQueryMsg}, fee_config::FeeConfig, + oracle::{SamplePeriod, TwapResponse}, + stake::ConverterConfig, }; use cosmwasm_std::{ @@ -130,6 +132,8 @@ pub struct StakeConfig { pub min_bond: Uint128, pub unbonding_periods: Vec, pub max_distributions: u32, + /// Optional converter configuration for the staking contract + pub converter: Option, } impl StakeConfig { @@ -155,6 +159,7 @@ impl StakeConfig { max_distributions: self.max_distributions, admin: Some(factory_addr), unbonder: None, // TODO: allow specifying unbonder + converter: self.converter, })?, funds: vec![], admin: Some(factory_owner), @@ -279,6 +284,15 @@ pub enum QueryMsg { /// Returns information about the cumulative prices in a [`CumulativePricesResponse`] object #[returns(CumulativePricesResponse)] CumulativePrices {}, + /// Returns a price history of the given duration + #[returns(TwapResponse)] + Twap { + duration: SamplePeriod, + /// duration: Day and start_age: 3 means to start from first checkpoint 3 days ago + start_age: u32, + /// end_age: None means count until the current time, end_age: Some(0) means til the last checkpoint, which would be more regular + end_age: Option, + }, /// Returns current D invariant in as a [`u128`] value #[returns(Uint128)] QueryComputeD {}, @@ -302,13 +316,6 @@ pub enum QueryMsg { }, } -#[cw_serde] -pub enum HistoryDuration { - FifteenMinutes, - Day, - Week, -} - /// This struct is used to return a query result with the total amount of LP tokens and assets in a specific pool. #[cw_serde] pub struct PoolResponse { @@ -366,16 +373,6 @@ pub struct CumulativePricesResponse { pub cumulative_prices: Vec<(AssetInfoValidated, AssetInfoValidated, Uint128)>, } -pub type TimeSeries = Vec<(u64, Uint128)>; - -/// This structure is used to return a historical prices query response. -#[cw_serde] -pub struct HistoricalPricesResponse { - /// The vector contains historical prices for each pair of assets in the pool - /// The first element of the tuple is the offer asset, the second element is the ask asset - pub historical_prices: Vec<(AssetInfoValidated, AssetInfoValidated, TimeSeries)>, -} - /// This structure holds stableswap pool parameters. #[cw_serde] pub struct StablePoolParams { diff --git a/packages/wyndex/src/pair/error.rs b/packages/wyndex/src/pair/error.rs index 39fe8ec..69a229a 100644 --- a/packages/wyndex/src/pair/error.rs +++ b/packages/wyndex/src/pair/error.rs @@ -8,6 +8,9 @@ pub enum ContractError { #[error("{0}")] Std(#[from] StdError), + #[error("{0}")] + Decimal(#[from] cosmwasm_std::DecimalRangeExceeded), + #[error("Unknown reply id '{0}'")] UnknownReply(u64), diff --git a/packages/wyndex/src/stake.rs b/packages/wyndex/src/stake.rs index 75cf4ea..da33a25 100644 --- a/packages/wyndex/src/stake.rs +++ b/packages/wyndex/src/stake.rs @@ -18,4 +18,46 @@ pub struct InstantiateMsg { pub admin: Option, /// Address of the account that can call [`ExecuteMsg::QuickUnbond`] pub unbonder: Option, + /// Configuration for the [`crate::msg::ExecuteMsg::MigrateStake`] message. + /// Allows converting staked LP tokens to LP tokens of another pool. + /// E.g. LP tokens of the USDC-JUNO pool can be converted to LP tokens of the USDC-wyJUNO pool + pub converter: Option, +} + +#[cw_serde] +pub struct ConverterConfig { + /// Address of the contract that converts the LP tokens + pub contract: String, + /// Address of the pair contract the converter should convert to + pub pair_to: String, +} + +#[cw_serde] +pub enum ReceiveMsg { + Delegate { + /// Unbonding period in seconds + unbonding_period: u64, + /// If set, the staked assets will be assigned to the given address instead of the sender + delegate_as: Option, + }, + /// This will delegate a large sum on behalf of many different users. + /// The total amount in delegate_to must be <= the amount of tokens sent. + /// If it is less, any remainder is staked on behalf of the sender + MassDelegate { + /// Unbonding period in seconds + unbonding_period: u64, + delegate_to: Vec<(String, Uint128)>, + }, + /// Fund a distribution flow with cw20 tokens and update the Reward Config for that cw20 asset. + Fund { funding_info: FundingInfo }, +} + +#[cw_serde] +pub struct FundingInfo { + /// Epoch in seconds when distribution should start. + pub start_time: u64, + /// Duration of distribution in seconds. + pub distribution_duration: u64, + /// Amount to distribute. + pub amount: Uint128, } diff --git a/tests/src/suite.rs b/tests/src/suite.rs index 293dbd9..a9cbfa5 100644 --- a/tests/src/suite.rs +++ b/tests/src/suite.rs @@ -101,6 +101,7 @@ impl SuiteBuilder { min_bond: Uint128::new(1000), unbonding_periods: vec![60 * 60 * 24 * 7, 60 * 60 * 24 * 14, 60 * 60 * 24 * 21], max_distributions: 6, + converter: None, }, trading_starts: None, } diff --git a/tests/tests/staking.rs b/tests/tests/staking.rs index 0618884..66576a2 100644 --- a/tests/tests/staking.rs +++ b/tests/tests/staking.rs @@ -5,8 +5,9 @@ use cosmwasm_std::{coin, from_slice, Addr, Decimal, Uint128}; use wyndex::{ asset::{AssetInfo, AssetInfoExt}, factory::PartialStakeConfig, + stake::ReceiveMsg, }; -use wyndex_stake::msg::{QueryMsg as StakeQueryMsg, ReceiveDelegationMsg, StakedResponse}; +use wyndex_stake::msg::{QueryMsg as StakeQueryMsg, StakedResponse}; use wyndex_stake::state::Config as WyndexStakeConfig; mod staking { @@ -35,6 +36,7 @@ mod staking { min_bond: Uint128::new(1), unbonding_periods: vec![1, 2], max_distributions: 1, + converter: None, }) .build(); @@ -87,7 +89,7 @@ mod staking { &pair_info.liquidity_token, 1000, pair_info.staking_addr.as_str(), - ReceiveDelegationMsg::Delegate { + ReceiveMsg::Delegate { unbonding_period: 1, delegate_as: None, }, @@ -181,6 +183,7 @@ mod staking { min_bond: Uint128::new(1), unbonding_periods: vec![1], max_distributions: 3, + converter: None, }) .build();