diff --git a/Cargo.lock b/Cargo.lock index 5dbb85aad..10e2c2ef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2419,6 +2419,25 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "native-stake" +version = "2.0.3" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 0.16.0", + "cw-multi-test", + "cw-paginate 2.0.3", + "cw-storage-plus 0.16.0", + "cw-utils 0.16.0", + "cw2 0.16.0", + "cw20 0.16.0", + "cw20-base 0.16.0", + "thiserror", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/contracts/staking/native-stake/.cargo/config b/contracts/staking/native-stake/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/staking/native-stake/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/staking/native-stake/Cargo.toml b/contracts/staking/native-stake/Cargo.toml new file mode 100644 index 000000000..005d29981 --- /dev/null +++ b/contracts/staking/native-stake/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "native-stake" +version = "2.0.3" +authors = ["mccallofthewild "] +edition = "2018" +license = "Apache-2.0" +repository = "https://github.com/DA0-DA0/dao-contracts/contracts/staking/native-stake" +description = "Staking contract for native Cosmos SDK tokens. Staked balances can be queried at any height." + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw2 = { workspace = true } +thiserror = { workspace = true } +cw-paginate = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/staking/native-stake/LICENSE b/contracts/staking/native-stake/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/contracts/staking/native-stake/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/contracts/staking/native-stake/NOTICE b/contracts/staking/native-stake/NOTICE new file mode 100644 index 000000000..cb0bbd1be --- /dev/null +++ b/contracts/staking/native-stake/NOTICE @@ -0,0 +1,13 @@ +Copyright 2021 Ben + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/staking/native-stake/README.md b/contracts/staking/native-stake/README.md new file mode 100644 index 000000000..9b2625066 --- /dev/null +++ b/contracts/staking/native-stake/README.md @@ -0,0 +1,22 @@ +# Stake CW20 + +This is a basic implementation of a cw20 staking contract. Staked tokens can be unbonded with a configurable unbonding period. Staked balances can be queried at any arbitrary height by external contracts. + +## Running this contract + +You will need Rust 1.58.1+ with `wasm32-unknown-unknown` target installed. + +You can run unit tests on this via: + +`cargo test` + +Once you are happy with the content, you can compile it to wasm via: + +``` +RUSTFLAGS='-C link-arg=-s' cargo wasm +cp ../../target/wasm32-unknown-unknown/release/stake_cw20.wasm . +ls -l stake_cw20.wasm +sha256sum stake_cw20.wasm +``` + +Or for a production-ready (optimized) build, run a build command in the the repository root: https://github.com/CosmWasm/cw-plus#compiling. diff --git a/contracts/staking/native-stake/examples/schema.rs b/contracts/staking/native-stake/examples/schema.rs new file mode 100644 index 000000000..3f8975b29 --- /dev/null +++ b/contracts/staking/native-stake/examples/schema.rs @@ -0,0 +1,41 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; + +use cw20::{ + AllAccountsResponse, AllAllowancesResponse, AllowanceResponse, BalanceResponse, + TokenInfoResponse, +}; +use native_stake::msg::{ + ClaimsResponse, ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, QueryMsg, + StakedBalanceAtHeightResponse, StakedValueResponse, TotalStakedAtHeightResponse, + TotalValueResponse, +}; +use native_stake::state::Config; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); + export_schema(&schema_for!(StakedBalanceAtHeightResponse), &out_dir); + export_schema(&schema_for!(TotalStakedAtHeightResponse), &out_dir); + export_schema(&schema_for!(StakedValueResponse), &out_dir); + export_schema(&schema_for!(TotalValueResponse), &out_dir); + export_schema(&schema_for!(GetHooksResponse), &out_dir); + export_schema(&schema_for!(ClaimsResponse), &out_dir); + export_schema(&schema_for!(AllowanceResponse), &out_dir); + export_schema(&schema_for!(BalanceResponse), &out_dir); + export_schema(&schema_for!(TokenInfoResponse), &out_dir); + export_schema(&schema_for!(AllAllowancesResponse), &out_dir); + export_schema(&schema_for!(AllAccountsResponse), &out_dir); + export_schema(&schema_for!(ListStakersResponse), &out_dir); + + // Need to rename so it matches the TS pattern + export_schema_with_title(&schema_for!(Config), &out_dir, "GetConfigResponse"); +} diff --git a/contracts/staking/native-stake/schema/all_accounts_response.json b/contracts/staking/native-stake/schema/all_accounts_response.json new file mode 100644 index 000000000..cea50fba4 --- /dev/null +++ b/contracts/staking/native-stake/schema/all_accounts_response.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllAccountsResponse", + "type": "object", + "required": [ + "accounts" + ], + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/contracts/staking/native-stake/schema/all_allowances_response.json b/contracts/staking/native-stake/schema/all_allowances_response.json new file mode 100644 index 000000000..6bb2291ce --- /dev/null +++ b/contracts/staking/native-stake/schema/all_allowances_response.json @@ -0,0 +1,99 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllAllowancesResponse", + "type": "object", + "required": [ + "allowances" + ], + "properties": { + "allowances": { + "type": "array", + "items": { + "$ref": "#/definitions/AllowanceInfo" + } + } + }, + "definitions": { + "AllowanceInfo": { + "type": "object", + "required": [ + "allowance", + "expires", + "spender" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "$ref": "#/definitions/Expiration" + }, + "spender": { + "type": "string" + } + } + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/allowance_response.json b/contracts/staking/native-stake/schema/allowance_response.json new file mode 100644 index 000000000..c4f98d6fc --- /dev/null +++ b/contracts/staking/native-stake/schema/allowance_response.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllowanceResponse", + "type": "object", + "required": [ + "allowance", + "expires" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "$ref": "#/definitions/Expiration" + } + }, + "definitions": { + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/balance_response.json b/contracts/staking/native-stake/schema/balance_response.json new file mode 100644 index 000000000..4e1a0be2b --- /dev/null +++ b/contracts/staking/native-stake/schema/balance_response.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BalanceResponse", + "type": "object", + "required": [ + "balance" + ], + "properties": { + "balance": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/claims_response.json b/contracts/staking/native-stake/schema/claims_response.json new file mode 100644 index 000000000..08211a3c8 --- /dev/null +++ b/contracts/staking/native-stake/schema/claims_response.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ClaimsResponse", + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "array", + "items": { + "$ref": "#/definitions/Claim" + } + } + }, + "definitions": { + "Claim": { + "type": "object", + "required": [ + "amount", + "release_at" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "release_at": { + "$ref": "#/definitions/Expiration" + } + } + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/execute_msg.json b/contracts/staking/native-stake/schema/execute_msg.json new file mode 100644 index 000000000..171c0f2e1 --- /dev/null +++ b/contracts/staking/native-stake/schema/execute_msg.json @@ -0,0 +1,178 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/get_config_response.json b/contracts/staking/native-stake/schema/get_config_response.json new file mode 100644 index 000000000..20488cf98 --- /dev/null +++ b/contracts/staking/native-stake/schema/get_config_response.json @@ -0,0 +1,83 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetConfigResponse", + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + }, + "manager": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/staking/native-stake/schema/get_hooks_response.json b/contracts/staking/native-stake/schema/get_hooks_response.json new file mode 100644 index 000000000..96bf4c560 --- /dev/null +++ b/contracts/staking/native-stake/schema/get_hooks_response.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetHooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/contracts/staking/native-stake/schema/instantiate_msg.json b/contracts/staking/native-stake/schema/instantiate_msg.json new file mode 100644 index 000000000..a7849de96 --- /dev/null +++ b/contracts/staking/native-stake/schema/instantiate_msg.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "definitions": { + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/staking/native-stake/schema/list_stakers_response.json b/contracts/staking/native-stake/schema/list_stakers_response.json new file mode 100644 index 000000000..e48d91d9b --- /dev/null +++ b/contracts/staking/native-stake/schema/list_stakers_response.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListStakersResponse", + "type": "object", + "required": [ + "stakers" + ], + "properties": { + "stakers": { + "type": "array", + "items": { + "$ref": "#/definitions/StakerBalanceResponse" + } + } + }, + "definitions": { + "StakerBalanceResponse": { + "type": "object", + "required": [ + "address", + "balance" + ], + "properties": { + "address": { + "type": "string" + }, + "balance": { + "$ref": "#/definitions/Uint128" + } + } + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/query_msg.json b/contracts/staking/native-stake/schema/query_msg.json new file mode 100644 index 000000000..f4d9193ba --- /dev/null +++ b/contracts/staking/native-stake/schema/query_msg.json @@ -0,0 +1,160 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "staked_balance_at_height" + ], + "properties": { + "staked_balance_at_height": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "total_staked_at_height" + ], + "properties": { + "total_staked_at_height": { + "type": "object", + "properties": { + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staked_value" + ], + "properties": { + "staked_value": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "total_value" + ], + "properties": { + "total_value": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_config" + ], + "properties": { + "get_config": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_hooks" + ], + "properties": { + "get_hooks": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "list_stakers" + ], + "properties": { + "list_stakers": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/staking/native-stake/schema/staked_balance_at_height_response.json b/contracts/staking/native-stake/schema/staked_balance_at_height_response.json new file mode 100644 index 000000000..1d1b2a412 --- /dev/null +++ b/contracts/staking/native-stake/schema/staked_balance_at_height_response.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StakedBalanceAtHeightResponse", + "type": "object", + "required": [ + "balance", + "height" + ], + "properties": { + "balance": { + "$ref": "#/definitions/Uint128" + }, + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/staked_value_response.json b/contracts/staking/native-stake/schema/staked_value_response.json new file mode 100644 index 000000000..8a4815241 --- /dev/null +++ b/contracts/staking/native-stake/schema/staked_value_response.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StakedValueResponse", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/token_info_response.json b/contracts/staking/native-stake/schema/token_info_response.json new file mode 100644 index 000000000..9920c841f --- /dev/null +++ b/contracts/staking/native-stake/schema/token_info_response.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TokenInfoResponse", + "type": "object", + "required": [ + "decimals", + "name", + "symbol", + "total_supply" + ], + "properties": { + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "total_supply": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/total_staked_at_height_response.json b/contracts/staking/native-stake/schema/total_staked_at_height_response.json new file mode 100644 index 000000000..c410e5fdf --- /dev/null +++ b/contracts/staking/native-stake/schema/total_staked_at_height_response.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalStakedAtHeightResponse", + "type": "object", + "required": [ + "height", + "total" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "total": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/schema/total_value_response.json b/contracts/staking/native-stake/schema/total_value_response.json new file mode 100644 index 000000000..b3054f033 --- /dev/null +++ b/contracts/staking/native-stake/schema/total_value_response.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalValueResponse", + "type": "object", + "required": [ + "total" + ], + "properties": { + "total": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/staking/native-stake/src/contract.rs b/contracts/staking/native-stake/src/contract.rs new file mode 100644 index 000000000..8462eaa56 --- /dev/null +++ b/contracts/staking/native-stake/src/contract.rs @@ -0,0 +1,472 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; + +use cosmwasm_std::{ + coins, to_binary, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response, + StdError, StdResult, Uint128, +}; + +use crate::hooks::{stake_hook_msgs, unstake_hook_msgs}; +use crate::msg::{ + ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, QueryMsg, + StakedBalanceAtHeightResponse, StakedValueResponse, StakerBalanceResponse, + TotalStakedAtHeightResponse, TotalValueResponse, +}; +use crate::state::{ + Config, BALANCE, CLAIMS, CONFIG, HOOKS, MAX_CLAIMS, STAKED_BALANCES, STAKED_TOTAL, +}; +use crate::ContractError; +use cw2::set_contract_version; +pub use cw20_base::allowances::{ + execute_burn_from, execute_decrease_allowance, execute_increase_allowance, execute_send_from, + execute_transfer_from, query_allowance, +}; +pub use cw20_base::contract::{ + execute_burn, execute_mint, execute_send, execute_transfer, execute_update_marketing, + execute_upload_logo, query_balance, query_download_logo, query_marketing_info, query_minter, + query_token_info, +}; +pub use cw20_base::enumerable::query_all_accounts; +use cw_controllers::ClaimsResponse; +use cw_utils::{must_pay, Duration}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:native-stake"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn validate_duration(duration: Option) -> Result<(), ContractError> { + if let Some(unstaking_duration) = duration { + match unstaking_duration { + Duration::Height(height) => { + if height == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + Duration::Time(time) => { + if time == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + } + } + Ok(()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + let owner = match msg.owner { + Some(owner) => Some(deps.api.addr_validate(owner.as_str())?), + None => None, + }; + + let manager = match msg.manager { + Some(manager) => Some(deps.api.addr_validate(manager.as_str())?), + None => None, + }; + + validate_duration(msg.unstaking_duration)?; + let config = Config { + owner, + manager, + denom: msg.denom, + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + // Initialize state to zero. We do this instead of using + // `unwrap_or_default` where this is used as it protects us + // against a scenerio where state is cleared by a bad actor and + // `unwrap_or_default` carries on. + STAKED_TOTAL.save(deps.storage, &Uint128::zero(), env.block.height)?; + BALANCE.save(deps.storage, &Uint128::zero())?; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::Fund {} => execute_fund(deps, env, info), + ExecuteMsg::Stake {} => execute_stake(deps, env, info), + ExecuteMsg::Unstake { amount } => execute_unstake(deps, env, info, amount), + ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + } => execute_update_config(info, deps, owner, manager, duration), + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr), + } +} + +pub fn execute_update_config( + info: MessageInfo, + deps: DepsMut, + new_owner: Option, + new_manager: Option, + duration: Option, +) -> Result { + let new_owner = new_owner + .map(|new_owner| deps.api.addr_validate(&new_owner)) + .transpose()?; + let new_manager = new_manager + .map(|new_manager| deps.api.addr_validate(&new_manager)) + .transpose()?; + let mut config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender.clone()) != config.manager { + return Err(ContractError::Unauthorized {}); + }; + if Some(info.sender) != config.owner && new_owner != config.owner { + return Err(ContractError::OnlyOwnerCanChangeOwner {}); + }; + + validate_duration(duration)?; + + config.owner = new_owner; + config.manager = new_manager; + + config.unstaking_duration = duration; + + CONFIG.save(deps.storage, &config)?; + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + ) + .add_attribute( + "manager", + config + .manager + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let amount = must_pay(&info, &config.denom)?; + let balance = BALANCE.load(deps.storage)?; + let staked_total = STAKED_TOTAL.load(deps.storage)?; + let amount_to_stake = if staked_total == Uint128::zero() || balance == Uint128::zero() { + amount + } else { + staked_total + .checked_mul(amount) + .map_err(StdError::overflow)? + .checked_div(balance) + .map_err(StdError::divide_by_zero)? + }; + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> StdResult { + Ok(balance.unwrap_or_default().checked_add(amount_to_stake)?) + }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> StdResult { + Ok(total.unwrap_or_default().checked_add(amount_to_stake)?) + }, + )?; + BALANCE.save( + deps.storage, + &balance.checked_add(amount).map_err(StdError::overflow)?, + )?; + let hook_msgs = stake_hook_msgs(deps.storage, info.sender.clone(), amount_to_stake)?; + Ok(Response::new() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount)) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let balance = BALANCE.load(deps.storage)?; + let staked_total = STAKED_TOTAL.load(deps.storage)?; + let amount_to_claim = amount + .checked_mul(balance) + .map_err(StdError::overflow)? + .checked_div(staked_total) + .map_err(|_e| ContractError::InvalidUnstakeAmount {})?; + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> Result { + balance + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> Result { + total + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + BALANCE.update(deps.storage, |bal| -> Result { + bal.checked_sub(amount_to_claim) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + })?; + let hook_msgs = unstake_hook_msgs(deps.storage, info.sender.clone(), amount)?; + match config.unstaking_duration { + None => { + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(amount_to_claim.u128(), config.denom), + }); + Ok(Response::new() + .add_message(msg) + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", "None")) + } + Some(duration) => { + let outstanding_claims = CLAIMS.query_claims(deps.as_ref(), &info.sender)?.claims; + if outstanding_claims.len() >= MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + CLAIMS.create_claim( + deps.storage, + &info.sender, + amount_to_claim, + duration.after(&env.block), + )?; + Ok(Response::new() + .add_attribute("action", "unstake") + .add_submessages(hook_msgs) + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", format!("{}", duration))) + } + } +} + +pub fn execute_claim( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let release = CLAIMS.claim_tokens(deps.storage, &info.sender, &env.block, None)?; + if release.is_zero() { + return Err(ContractError::NothingToClaim {}); + } + + let config = CONFIG.load(deps.storage)?; + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(release.u128(), config.denom), + }); + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "claim") + .add_attribute("from", info.sender) + .add_attribute("amount", release)) +} + +pub fn execute_fund( + deps: DepsMut, + _env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let amount = must_pay(&info, &config.denom)?; + BALANCE.update(deps.storage, |balance| -> StdResult<_> { + balance.checked_add(amount).map_err(StdError::overflow) + })?; + Ok(Response::new() + .add_attribute("action", "fund") + .add_attribute("from", info.sender) + .add_attribute("amount", amount)) +} + +pub fn execute_add_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + let addr = deps.api.addr_validate(&addr)?; + let config: Config = CONFIG.load(deps.storage)?; + if config.owner != Some(info.sender.clone()) && config.manager != Some(info.sender) { + return Err(ContractError::Unauthorized {}); + }; + HOOKS.add_hook(deps.storage, addr.clone())?; + Ok(Response::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + let addr = deps.api.addr_validate(&addr)?; + let config: Config = CONFIG.load(deps.storage)?; + if config.owner != Some(info.sender.clone()) && config.manager != Some(info.sender) { + return Err(ContractError::Unauthorized {}); + }; + HOOKS.remove_hook(deps.storage, addr.clone())?; + Ok(Response::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetConfig {} => to_binary(&query_config(deps)?), + QueryMsg::StakedBalanceAtHeight { address, height } => { + to_binary(&query_staked_balance_at_height(deps, env, address, height)?) + } + QueryMsg::TotalStakedAtHeight { height } => { + to_binary(&query_total_staked_at_height(deps, env, height)?) + } + QueryMsg::StakedValue { address } => to_binary(&query_staked_value(deps, env, address)?), + QueryMsg::TotalValue {} => to_binary(&query_total_value(deps, env)?), + QueryMsg::Claims { address } => to_binary(&query_claims(deps, address)?), + QueryMsg::GetHooks {} => to_binary(&query_hooks(deps)?), + QueryMsg::ListStakers { start_after, limit } => { + query_list_stakers(deps, start_after, limit) + } + } +} + +pub fn query_staked_balance_at_height( + deps: Deps, + _env: Env, + address: String, + height: Option, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let height = height.unwrap_or(_env.block.height); + let balance = STAKED_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + Ok(StakedBalanceAtHeightResponse { balance, height }) +} + +pub fn query_total_staked_at_height( + deps: Deps, + _env: Env, + height: Option, +) -> StdResult { + let height = height.unwrap_or(_env.block.height); + let total = STAKED_TOTAL + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + Ok(TotalStakedAtHeightResponse { total, height }) +} + +pub fn query_staked_value( + deps: Deps, + _env: Env, + address: String, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let balance = BALANCE.load(deps.storage).unwrap_or_default(); + let staked = STAKED_BALANCES + .load(deps.storage, &address) + .unwrap_or_default(); + let total = STAKED_TOTAL.load(deps.storage)?; + if balance == Uint128::zero() || staked == Uint128::zero() || total == Uint128::zero() { + Ok(StakedValueResponse { + value: Uint128::zero(), + }) + } else { + let value = staked + .checked_mul(balance) + .map_err(StdError::overflow)? + .checked_div(total) + .map_err(StdError::divide_by_zero)?; + Ok(StakedValueResponse { value }) + } +} + +pub fn query_total_value(deps: Deps, _env: Env) -> StdResult { + let balance = BALANCE.load(deps.storage)?; + Ok(TotalValueResponse { total: balance }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(config) +} + +pub fn query_claims(deps: Deps, address: String) -> StdResult { + CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + Ok(GetHooksResponse { + hooks: HOOKS.query_hooks(deps)?.hooks, + }) +} + +pub fn query_list_stakers( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let start_at = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + + let stakers = cw_paginate::paginate_snapshot_map( + deps, + &STAKED_BALANCES, + start_at.as_ref(), + limit, + cosmwasm_std::Order::Ascending, + )?; + + let stakers = stakers + .into_iter() + .map(|(address, balance)| StakerBalanceResponse { + address: address.into_string(), + balance, + }) + .collect(); + + to_binary(&ListStakersResponse { stakers }) +} diff --git a/contracts/staking/native-stake/src/error.rs b/contracts/staking/native-stake/src/error.rs new file mode 100644 index 000000000..239885449 --- /dev/null +++ b/contracts/staking/native-stake/src/error.rs @@ -0,0 +1,29 @@ +use cosmwasm_std::{Addr, StdError}; +use cw_utils::PaymentError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + #[error("{0}")] + PaymentError(#[from] PaymentError), + #[error("Nothing to claim")] + NothingToClaim {}, + #[error("Invalid token")] + InvalidToken { received: Addr, expected: Addr }, + #[error("Unauthorized")] + Unauthorized {}, + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + #[error("No admin configured")] + NoAdminConfigured {}, + #[error("{0}")] + HookError(#[from] cw_controllers::HookError), + #[error("Only owner can change owner")] + OnlyOwnerCanChangeOwner {}, + #[error("Invalid unstaking duration, unstaking duration cannot be 0")] + InvalidUnstakingDuration {}, + #[error("Can only unstake less than or equal to the amount you have staked")] + InvalidUnstakeAmount {}, +} diff --git a/contracts/staking/native-stake/src/hooks.rs b/contracts/staking/native-stake/src/hooks.rs new file mode 100644 index 000000000..5867d8ff4 --- /dev/null +++ b/contracts/staking/native-stake/src/hooks.rs @@ -0,0 +1,52 @@ +use crate::state::HOOKS; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, StdResult, Storage, SubMsg, Uint128, WasmMsg}; + +// This is just a helper to properly serialize the above message +#[cw_serde] +pub enum StakeChangedHookMsg { + Stake { addr: Addr, amount: Uint128 }, + Unstake { addr: Addr, amount: Uint128 }, +} + +pub fn stake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Stake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +pub fn unstake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Unstake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum StakeChangedExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), +} diff --git a/contracts/staking/native-stake/src/lib.rs b/contracts/staking/native-stake/src/lib.rs new file mode 100644 index 000000000..efc1a5eca --- /dev/null +++ b/contracts/staking/native-stake/src/lib.rs @@ -0,0 +1,10 @@ +pub mod contract; +mod error; +pub mod hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/staking/native-stake/src/msg.rs b/contracts/staking/native-stake/src/msg.rs new file mode 100644 index 000000000..10f5cec7b --- /dev/null +++ b/contracts/staking/native-stake/src/msg.rs @@ -0,0 +1,111 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Uint128; + +use cw_utils::Duration; + +pub use cw_controllers::ClaimsResponse; + +#[cw_serde] +pub struct InstantiateMsg { + // Owner can update all configs including changing the owner. This will generally be a DAO. + pub owner: Option, + // Manager can update all configs except changing the owner. This will generally be an operations multisig for a DAO. + pub manager: Option, + pub denom: String, + pub unstaking_duration: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + Fund {}, + Stake {}, + Unstake { + amount: Uint128, + }, + Claim {}, + UpdateConfig { + owner: Option, + manager: Option, + duration: Option, + }, + AddHook { + addr: String, + }, + RemoveHook { + addr: String, + }, +} + +#[cw_serde] +pub enum ReceiveMsg { + Stake {}, + Fund {}, +} + +#[cw_serde] +pub enum QueryMsg { + StakedBalanceAtHeight { + address: String, + height: Option, + }, + TotalStakedAtHeight { + height: Option, + }, + StakedValue { + address: String, + }, + TotalValue {}, + GetConfig {}, + Claims { + address: String, + }, + GetHooks {}, + ListStakers { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub enum MigrateMsg { + FromBeta { manager: Option }, + FromCompatible {}, +} + +#[cw_serde] +pub struct StakedBalanceAtHeightResponse { + pub balance: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct TotalStakedAtHeightResponse { + pub total: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct StakedValueResponse { + pub value: Uint128, +} + +#[cw_serde] +pub struct TotalValueResponse { + pub total: Uint128, +} + +#[cw_serde] +pub struct GetHooksResponse { + pub hooks: Vec, +} + +#[cw_serde] +pub struct ListStakersResponse { + pub stakers: Vec, +} + +#[cw_serde] +pub struct StakerBalanceResponse { + pub address: String, + pub balance: Uint128, +} diff --git a/contracts/staking/native-stake/src/state.rs b/contracts/staking/native-stake/src/state.rs new file mode 100644 index 000000000..bc11c8d22 --- /dev/null +++ b/contracts/staking/native-stake/src/state.rs @@ -0,0 +1,40 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_controllers::Claims; +use cw_controllers::Hooks; +use cw_storage_plus::{Item, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; + +#[cw_serde] +pub struct Config { + pub owner: Option, + pub manager: Option, + pub denom: String, + pub unstaking_duration: Option, +} + +pub const CONFIG: Item = Item::new("config"); + +pub const STAKED_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "staked_balances", + "staked_balance__checkpoints", + "staked_balance__changelog", + Strategy::EveryBlock, +); + +pub const STAKED_TOTAL: SnapshotItem = SnapshotItem::new( + "total_staked", + "total_staked__checkpoints", + "total_staked__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 100; + +pub const CLAIMS: Claims = Claims::new("claims"); + +pub const BALANCE: Item = Item::new("balance"); + +// Hooks to contracts that will receive staking and unstaking messages +pub const HOOKS: Hooks = Hooks::new("hooks"); diff --git a/contracts/staking/native-stake/src/tests.rs b/contracts/staking/native-stake/src/tests.rs new file mode 100644 index 000000000..a2676f862 --- /dev/null +++ b/contracts/staking/native-stake/src/tests.rs @@ -0,0 +1,1105 @@ +use std::borrow::BorrowMut; + +use crate::msg::{ + ExecuteMsg, InstantiateMsg, ListStakersResponse, QueryMsg, StakedBalanceAtHeightResponse, + StakedValueResponse, StakerBalanceResponse, TotalStakedAtHeightResponse, TotalValueResponse, +}; +use crate::state::Config; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{coins, Addr, Coin, Empty, Uint128}; +use cw_controllers::ClaimsResponse; +use cw_multi_test::{ + custom_app, next_block, App, AppResponse, Contract, ContractWrapper, Executor, +}; +use cw_utils::Duration; + +const DAO_ADDR: &str = "dao"; +const ADDR1: &str = "addr1"; +const ADDR2: &str = "addr2"; +const DENOM: &str = "ujuno"; +const INVALID_DENOM: &str = "uinvalid"; + +fn query_staked_balance, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = QueryMsg::StakedBalanceAtHeight { + address: address.into(), + height: None, + }; + let result: StakedBalanceAtHeightResponse = + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn query_staked_value, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = QueryMsg::StakedValue { + address: address.into(), + }; + let result: StakedValueResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.value +} + +fn query_total_value>(app: &App, contract_addr: T) -> Uint128 { + let msg = QueryMsg::TotalValue {}; + let result: TotalValueResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.total +} + +fn query_total_staked>(app: &App, contract_addr: T) -> Uint128 { + let msg = QueryMsg::TotalStakedAtHeight { height: None }; + let result: TotalStakedAtHeightResponse = + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.total +} + +fn staking_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn mock_app() -> App { + custom_app(|r, _a, s| { + r.bank + .init_balance( + s, + &Addr::unchecked(DAO_ADDR), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR1), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR2), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + }) +} + +fn instantiate_staking(app: &mut App, staking_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + staking_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "Staking", + None, + ) + .unwrap() +} + +fn stake_tokens( + app: &mut App, + staking_addr: &Addr, + sender: &str, + amount: u128, + denom: &str, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr.clone(), + &ExecuteMsg::Stake {}, + &coins(amount, denom), + ) +} + +fn unstake_tokens( + app: &mut App, + staking_addr: &Addr, + sender: &str, + amount: u128, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr.clone(), + &ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }, + &[], + ) +} + +fn claim(app: &mut App, staking_addr: Addr, sender: &str) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Claim {}, + &[], + ) +} + +fn update_config( + app: &mut App, + staking_addr: Addr, + sender: &str, + owner: Option, + manager: Option, + duration: Option, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + }, + &[], + ) +} + +fn get_config(app: &mut App, staking_addr: Addr) -> Config { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::GetConfig {}) + .unwrap() +} + +fn get_claims(app: &mut App, staking_addr: Addr, address: String) -> ClaimsResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::Claims { address }) + .unwrap() +} + +fn get_balance(app: &App, address: &str, denom: &str) -> Uint128 { + app.wrap().query_balance(address, denom).unwrap().amount +} + +#[test] +fn test_instantiate() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + denom: DENOM.to_string(), + unstaking_duration: None, + }, + ); +} + +#[test] +fn test_instantiate_dao_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + let config = get_config(&mut app, addr); + + assert_eq!(config.owner, Some(Addr::unchecked(DAO_ADDR))) +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_instantiate_invalid_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(0)), + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + denom: DENOM.to_string(), + unstaking_duration: None, + }, + ); +} + +#[test] +#[should_panic(expected = "Must send reserve token 'ujuno'")] +fn test_stake_invalid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Try and stake an invalid denom + stake_tokens(&mut app, &addr, ADDR1, 100, INVALID_DENOM).unwrap(); +} + +#[test] +fn test_stake_valid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Try and stake an valid denom + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_none_staked() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + unstake_tokens(&mut app, &addr, ADDR1, 100).unwrap(); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_invalid_balance() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Try and unstake too many + unstake_tokens(&mut app, &addr, ADDR1, 200).unwrap(); +} + +#[test] +fn test_unstake() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some + unstake_tokens(&mut app, &addr, ADDR1, 75).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + app.update_block(next_block); + + // Unstake the rest + unstake_tokens(&mut app, &addr, ADDR1, 25).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_unstake_no_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, &addr, ADDR1, 75).unwrap(); + + app.update_block(next_block); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, &addr, ADDR1, 25).unwrap(); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)) +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_no_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_claim_not_reached() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake them to create the claims + unstake_tokens(&mut app, &addr, ADDR1, 100).unwrap(); + app.update_block(next_block); + + // We have a claim but it isnt reached yet so this will still fail + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +fn test_claim() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some to create the claims + unstake_tokens(&mut app, &addr, ADDR1, 75).unwrap(); + app.update_block(|b| { + b.height += 5; + b.time = b.time.plus_seconds(25); + }); + + // Claim + claim(&mut app, addr.clone(), ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, &addr, ADDR1, 25).unwrap(); + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(50); + }); + + // Claim + claim(&mut app, addr, ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_update_config_invalid_sender() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // From ADDR2, so not owner or manager + update_config( + &mut app, + addr, + ADDR2, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Only owner can change owner")] +fn test_update_config_non_owner_changes_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // ADDR1 is the manager so cannot change the owner + update_config(&mut app, addr, ADDR1, Some(ADDR2.to_string()), None, None).unwrap(); +} + +#[test] +fn test_update_config_as_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Swap owner and manager, change duration + update_config( + &mut app, + addr.clone(), + DAO_ADDR, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(ADDR1)), + manager: Some(Addr::unchecked(DAO_ADDR)), + unstaking_duration: Some(Duration::Height(10)), + denom: DENOM.to_string(), + }, + config + ); +} + +#[test] +fn test_update_config_as_manager() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr.clone(), + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR2)), + unstaking_duration: Some(Duration::Height(10)), + denom: DENOM.to_string(), + }, + config + ); +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_update_config_invalid_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr, + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(0)), + ) + .unwrap(); +} + +#[test] +fn test_query_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 0); + + // Stake some tokens + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, &addr, ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + + unstake_tokens(&mut app, &addr, ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_query_get_config() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + let config = get_config(&mut app, addr); + assert_eq!( + config, + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR1)), + unstaking_duration: Some(Duration::Height(5)), + denom: DENOM.to_string(), + } + ) +} + +#[test] +fn test_query_list_stakers() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + }, + ); + + // ADDR1 stakes + stake_tokens(&mut app, &addr, ADDR1, 100, DENOM).unwrap(); + + // ADDR2 stakes + stake_tokens(&mut app, &addr, ADDR2, 50, DENOM).unwrap(); + + // check entire result set + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![ + StakerBalanceResponse { + address: ADDR1.to_string(), + balance: Uint128::new(100), + }, + StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }, + ], + }; + + assert_eq!(stakers, test_res); + + // skipped 1, check result + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: Some(ADDR1.to_string()), + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }], + }; + + assert_eq!(stakers, test_res); + + // skipped 2, check result. should be nothing + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr, + &QueryMsg::ListStakers { + start_after: Some(ADDR2.to_string()), + limit: None, + }, + ) + .unwrap(); + + assert_eq!(stakers, ListStakersResponse { stakers: vec![] }); +} + +fn mock_compounding_app() -> App { + custom_app(|r, _a, s| { + r.bank + .init_balance( + s, + &Addr::unchecked(DAO_ADDR), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR1), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(1000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR2), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(0), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + }) +} + +#[test] +fn test_auto_compounding_staking() { + let _deps = mock_dependencies(); + let mut app = mock_compounding_app(); + + let _env = mock_env(); + let staking_id = app.store_code(staking_contract()); + app.update_block(next_block); + let staking_addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(DAO_ADDR.to_string()), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: None, + }, + ); + app.update_block(next_block); + // Successful bond + stake_tokens(&mut app, &staking_addr, &ADDR1, 100_u128, DENOM).unwrap(); + app.update_block(next_block); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128), + "Staked balance should be 100" + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(100u128), + "Total staked balance should be 100" + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128), + "Staked value should be 100" + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(100u128), + "Total value should be 100" + ); + assert_eq!(get_balance(&mut app, &ADDR1, DENOM), Uint128::from(900u128)); + + // Add compounding rewards + let _res = app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + staking_addr.clone(), + &ExecuteMsg::Fund {}, + &coins(100_u128, DENOM), + ) + .unwrap(); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128), + "Staked balance should be 100 after compounding" + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(100u128), + "Total staked balance should be 100 after compounding" + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(200u128), + "Staked value should be 200 after compounding" + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(200u128), + "Total value should be 200 after compounding" + ); + assert_eq!( + get_balance(&mut app, &ADDR1, DENOM), + Uint128::from(800u128), + "Balance should be 800 after compounding" + ); + + // Sucessful transfer of unbonded amount + let _res = app + .borrow_mut() + .execute( + Addr::unchecked(ADDR1), + cosmwasm_std::CosmosMsg::Bank(cosmwasm_std::BankMsg::Send { + amount: coins(100_u128, DENOM), + to_address: ADDR2.to_string(), + }), + ) + .unwrap(); + + assert_eq!( + get_balance(&mut app, ADDR1, DENOM), + Uint128::from(700u128), + "Balance should be 700 after transfer" + ); + assert_eq!( + get_balance(&mut app, ADDR2, DENOM), + Uint128::from(100u128), + "Balance should be 100 after transfer" + ); + + // Addr 2 successful bond + stake_tokens( + &mut app, + &staking_addr, + &ADDR2, + Uint128::new(100).u128(), + DENOM, + ) + .unwrap(); + + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(50u128), + "Staked balance should be 50" + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(150u128), + "Total staked balance should be 150" + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR2.to_string()), + Uint128::from(100u128), + "Staked value should be 100" + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(300u128), + "Total value should be 300" + ); + assert_eq!( + get_balance(&mut app, ADDR2, DENOM), + Uint128::zero(), + "Balance should be 0 after staking" + ); + + // Can't unstake more than you have staked + let _info = mock_info(ADDR2, &[]); + let _err = unstake_tokens(&mut app, &staking_addr, ADDR2, Uint128::new(51).u128()).unwrap_err(); + + // Add compounding rewards + let _res = app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + staking_addr.clone(), + &ExecuteMsg::Fund {}, + &coins(90_u128, DENOM), + ) + .unwrap(); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128), + "Staked balance should be 100 after compounding the second time" + ); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(50u128), + "Staked balance should be 50 after compounding the second time" + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(150u128), + "Total staked balance should be 150 after compounding the second time" + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(260u128), + "Staked value should be 260 after compounding the second time" + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR2.to_string()), + Uint128::from(130u128), + "Staked value should be 130 after compounding the second time" + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(390u128), + "Total value should be 390 after compounding the second time" + ); + assert_eq!( + get_balance(&app, ADDR1, DENOM), + Uint128::from(610u128), + "Balance should be 610 after compounding the second time" + ); + + // Successful unstake + let _res = unstake_tokens(&mut app, &staking_addr, ADDR2, Uint128::new(25).u128()).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(25u128), + "Staked balance should be 25 after unstaking" + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(125u128), + "Total staked balance should be 125 after unstaking" + ); + assert_eq!( + get_balance(&app, ADDR2, DENOM), + Uint128::from(65u128), + "Balance should be 65 after unstaking" + ); +}