diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ff1cb89 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = false +indent_style = space +indent_size = 4 +max_line_length = 81 + +[*.md] +# double whitespace at end of line +# denotes a line break in Markdown +trim_trailing_whitespace = false +indent_size = 2 + +[*.yml] +indent_size = 2 diff --git a/.gitignore b/.gitignore index 93f65d3..b6b2595 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ node_modules/ yarn.lock .vscode/ -lcov.info +contracts/*/.editorconfig +packages/*/.editorconfig +lcov.info \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2b017f0..68f2c66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1764,6 +1764,21 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "token-vesting" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw20", + "schemars", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "tracing" version = "0.1.37" diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt index e0394a5..822db27 100644 --- a/artifacts/checksums.txt +++ b/artifacts/checksums.txt @@ -4,3 +4,4 @@ b73b0a03fe8a4bbb547b1219c8d16db72978567261e17a8891995357b4c8de51 bindings_perp. e9834b1d66fbd389e1c1f77f6c5ae7e9151ce6adc2d56e5aa818d2171a637c83 lockup.wasm 87c8b35253b4593bd70769685a2f5a36ed25f6c73f17fbeea9be4736227a78fd pricefeed.wasm 0456beb3865a56e45cca30199243b5c00df5e5d8704bb02250451d97c31aa08c shifter.wasm +ee6c3581365d7d0040bc95ecfb42c1b733569536a32b446b7898502aafcf8c1b token_vesting.wasm diff --git a/artifacts/checksums_intermediate.txt b/artifacts/checksums_intermediate.txt index f067a1a..13605f2 100644 --- a/artifacts/checksums_intermediate.txt +++ b/artifacts/checksums_intermediate.txt @@ -4,3 +4,4 @@ c3ebe629a7fb6cd41e88c6a77fb2076001c992d823240415f9cda2744023dda6 target/wasm32- f09ca83af90096f8773f48bbde73dcf8f509dc1da84a9243d1c7dba125c5c9f9 target/wasm32-unknown-unknown/release/pricefeed.wasm ee8c29604ce82d0c3046c445998f3b6862f09e7eae0afe922bbf6b624e971b14 target/wasm32-unknown-unknown/release/shifter.wasm e74d712db4d582678edf6d04d700d85c2444087ca9a03b0a90c49a78304ad039 target/wasm32-unknown-unknown/release/bindings_perp.wasm +8d435cd6cb82e2a27981f468977fdb4aab003f31d01ba3aa47485251a4aadba5 target/wasm32-unknown-unknown/release/token_vesting.wasm diff --git a/artifacts/token_vesting.wasm b/artifacts/token_vesting.wasm new file mode 100644 index 0000000..2f56ae8 Binary files /dev/null and b/artifacts/token_vesting.wasm differ diff --git a/contracts/token-vesting/.cargo/config b/contracts/token-vesting/.cargo/config new file mode 100644 index 0000000..8a76ed5 --- /dev/null +++ b/contracts/token-vesting/.cargo/config @@ -0,0 +1,7 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +integration-test = "test --test integration" +schema = "run --example schema" + diff --git a/contracts/token-vesting/.gitignore b/contracts/token-vesting/.gitignore new file mode 100644 index 0000000..10fe5d6 --- /dev/null +++ b/contracts/token-vesting/.gitignore @@ -0,0 +1,12 @@ +# Build results +/target + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/contracts/token-vesting/Cargo.lock b/contracts/token-vesting/Cargo.lock new file mode 100644 index 0000000..96fc701 --- /dev/null +++ b/contracts/token-vesting/Cargo.lock @@ -0,0 +1,264 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "addr2line" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a49806b9dadc843c61e7c97e72490ad7f7220ae249012fbda9ad0609457c0543" +dependencies = [ + "gimli", +] + +[[package]] +name = "backtrace" +version = "0.3.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df2f85c8a2abbe3b7d7e748052fdd9b76a0458fdeb16ad4223f5eca78c7c130" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cosmwasm-schema" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2196586ea197eaa21129d09c84a19e2eb80bdce239eec8e6a4f108cb644c295f" +dependencies = [ + "schemars", + "serde_json", +] + +[[package]] +name = "cosmwasm-std" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f85908a2696117c8f2c1b3ce201d34a1aa9a6b3c1583a65cfb794ec66e1cfde4" +dependencies = [ + "base64", + "schemars", + "serde", + "serde-json-wasm", + "snafu", +] + +[[package]] +name = "cosmwasm-storage" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e103531a2ce636e86b7639cec25d348c4d360832ab8e0e7f9a6e00f08aac1379" +dependencies = [ + "cosmwasm-std", + "serde", +] + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "gimli" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc8e0c9bce37868955864dbecd2b1ab2bdf967e6f28066d65aaac620444b65c" + +[[package]] +name = "itoa" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b7a7c0c47db5545ed3fef7468ee7bb5b74691498139e4b3f6a20685dc6dd8e" + +[[package]] +name = "libc" +version = "0.2.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3baa92041a6fec78c687fa0cc2b3fae8884f743d672cf551bed1d6dac6988d0f" + +[[package]] +name = "limit-order" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "schemars", + "serde", + "terra-cosmwasm", +] + +[[package]] +name = "object" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cbca9424c482ee628fa549d9c812e2cd22f1180b9222c9200fdfa6eb31aecb2" + +[[package]] +name = "proc-macro2" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1502d12e458c49a4c9cbff560d0fe0060c252bc29799ed94ca2ed4bb665a0101" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a21852a652ad6f610c9510194f398ff6f8692e334fd1145fed931f7fbe44ea" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" + +[[package]] +name = "ryu" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3d612bc64430efeb3f7ee6ef26d590dce0c43249217bddc62112540c7941e1" + +[[package]] +name = "schemars" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be77ed66abed6954aabf6a3e31a84706bedbf93750d267e92ef4a6d90bbd6a61" +dependencies = [ + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11af7a475c9ee266cfaa9e303a47c830ebe072bf3101ab907a7b7b9d816fa01d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e7b308464d16b56eba9964e4972a3eee817760ab60d88c3f86e1fecb08204c" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7294d94d390f1d2334697c065ea591d7074c676e2d20aa6f1df752fced29823f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818fbf6bfa9a42d3bfcaca148547aa00c7b915bec71d1757aa2d44ca68771984" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dbab34ca63057a1f15280bdf3c39f2b1eb1b54c17e98360e511637aef7418c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993948e75b189211a9b31a7528f950c6adc21f9720b6438ff80a7fa2f864cea2" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "snafu" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5aed652511f5c9123cf2afbe9c244c29db6effa2abb05c866e965c82405ce" +dependencies = [ + "backtrace", + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf8f7d5720104a9df0f7076a8682024e958bba0fe9848767bb44f251f3648e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f14a640819f79b72a710c0be059dce779f9339ae046c8bef12c361d56702146f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "terra-cosmwasm" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293b020a968fdd2df1099fb99392ce348201b8416bbc92d6b4de291e3ca0b744" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "unicode-xid" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" diff --git a/contracts/token-vesting/Cargo.toml b/contracts/token-vesting/Cargo.toml new file mode 100644 index 0000000..5515295 --- /dev/null +++ b/contracts/token-vesting/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "token-vesting" +version = "0.1.0" +edition = "2021" +description = "Provide various token vesting feature" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for quicker tests, cargo test --lib +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] + +[dependencies] +cosmwasm-schema = "1.2.7" +cosmwasm-std = "1.2.7" +cw20 = "1.0.1" +cw-utils = { version = "1.0.1" } +thiserror = { version = "1.0.23" } +cw-storage-plus = "1.0.1" +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } diff --git a/contracts/token-vesting/README.md b/contracts/token-vesting/README.md new file mode 100644 index 0000000..82668a7 --- /dev/null +++ b/contracts/token-vesting/README.md @@ -0,0 +1,70 @@ +## Token Vesting + +This contract implements vesting accounts for the CW20 and native tokens. + +### Master Operations + +```rust + RegisterVestingAccount { + master_address: Option, // if given, the vesting account can be unregistered + address: String, + vesting_schedule: VestingSchedule, + }, +``` +* RegisterVestingAccount - register vesting account + * When creating vesting account, the one can specify the `master_address` to enable deregister feature. + +```rust + DeregisterVestingAccount { + address: String, + denom: Denom, + vested_token_recipient: Option, + left_vesting_token_recipient: Option, + }, +``` +* DeregisterVestingAccount - deregister vesting account + * This interface only executable from the `master_address` of a vesting account. + * It will compute `claimable_amount` and `left_vesting_amount`. Each amount respectively sent to (`vested_token_recipient` or `vesting_account`) and (`left_vesting_token_recipient` or `master_address`). + +```rust +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Cw20HookMsg { + /// Register vesting account with token transfer + RegisterVestingAccount { + master_address: Option, // if given, the vesting account can be unregistered + address: String, + vesting_schedule: VestingSchedule, + }, +} +``` + +### Vesting Account Operations + +* Claim - send newly vested token to the (`recipient` or `vesting_account`). The `claim_amount` is computed as (`vested_amount` - `claimed_amount`) and `claimed_amount` is updated to `vested_amount`. + +```rust +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + //////////////////////// + /// VestingAccount Operations /// + //////////////////////// + Claim { + denoms: Vec, + recipient: Option, + }, +} +``` + +### Deployed Contract Info + +TODO for mainnet/testnet + + +| Field | Value | +| ------------- | ------ | +| code_id | ... | +| contract_addr | ... | +| rpc_url | ... | +| chain_id | ... | diff --git a/contracts/token-vesting/examples/schema.rs b/contracts/token-vesting/examples/schema.rs new file mode 100644 index 0000000..e0521d1 --- /dev/null +++ b/contracts/token-vesting/examples/schema.rs @@ -0,0 +1,20 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; +use token_vesting::msg::{ + Cw20HookMsg, ExecuteMsg, InstantiateMsg, QueryMsg, VestingAccountResponse, +}; + +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!(Cw20HookMsg), &out_dir); + export_schema(&schema_for!(VestingAccountResponse), &out_dir); +} diff --git a/contracts/token-vesting/schema/cw20_hook_msg.json b/contracts/token-vesting/schema/cw20_hook_msg.json new file mode 100644 index 0000000..e0af96a --- /dev/null +++ b/contracts/token-vesting/schema/cw20_hook_msg.json @@ -0,0 +1,119 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Cw20HookMsg", + "oneOf": [ + { + "description": "Register vesting account with token transfer", + "type": "object", + "required": [ + "register_vesting_account" + ], + "properties": { + "register_vesting_account": { + "type": "object", + "required": [ + "address", + "vesting_schedule" + ], + "properties": { + "address": { + "type": "string" + }, + "master_address": { + "type": [ + "string", + "null" + ] + }, + "vesting_schedule": { + "$ref": "#/definitions/VestingSchedule" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "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" + }, + "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" + }, + "VestingSchedule": { + "oneOf": [ + { + "description": "LinearVesting is used to vest tokens linearly during a time period. The total_amount will be vested during this period.", + "type": "object", + "required": [ + "linear_vesting" + ], + "properties": { + "linear_vesting": { + "type": "object", + "required": [ + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "linear_vesting_with_cliff" + ], + "properties": { + "linear_vesting_with_cliff": { + "type": "object", + "required": [ + "cliff_amount", + "cliff_time", + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "cliff_amount": { + "$ref": "#/definitions/Uint128" + }, + "cliff_time": { + "$ref": "#/definitions/Uint64" + }, + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/token-vesting/schema/execute_msg.json b/contracts/token-vesting/schema/execute_msg.json new file mode 100644 index 0000000..34ae237 --- /dev/null +++ b/contracts/token-vesting/schema/execute_msg.json @@ -0,0 +1,257 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Creator Operations ///", + "type": "object", + "required": [ + "register_vesting_account" + ], + "properties": { + "register_vesting_account": { + "type": "object", + "required": [ + "address", + "vesting_schedule" + ], + "properties": { + "address": { + "type": "string" + }, + "master_address": { + "type": [ + "string", + "null" + ] + }, + "vesting_schedule": { + "$ref": "#/definitions/VestingSchedule" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "only available when master_address was set", + "type": "object", + "required": [ + "deregister_vesting_account" + ], + "properties": { + "deregister_vesting_account": { + "type": "object", + "required": [ + "address", + "denom" + ], + "properties": { + "address": { + "type": "string" + }, + "denom": { + "$ref": "#/definitions/Denom" + }, + "left_vesting_token_recipient": { + "type": [ + "string", + "null" + ] + }, + "vested_token_recipient": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "VestingAccount Operations ///", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "required": [ + "denoms" + ], + "properties": { + "denoms": { + "type": "array", + "items": { + "$ref": "#/definitions/Denom" + } + }, + "recipient": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "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" + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cw20ReceiveMsg": { + "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "amount", + "msg", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "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" + }, + "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" + }, + "VestingSchedule": { + "oneOf": [ + { + "description": "LinearVesting is used to vest tokens linearly during a time period. The total_amount will be vested during this period.", + "type": "object", + "required": [ + "linear_vesting" + ], + "properties": { + "linear_vesting": { + "type": "object", + "required": [ + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "linear_vesting_with_cliff" + ], + "properties": { + "linear_vesting_with_cliff": { + "type": "object", + "required": [ + "cliff_amount", + "cliff_time", + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "cliff_amount": { + "$ref": "#/definitions/Uint128" + }, + "cliff_time": { + "$ref": "#/definitions/Uint64" + }, + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/token-vesting/schema/instantiate_msg.json b/contracts/token-vesting/schema/instantiate_msg.json new file mode 100644 index 0000000..1352613 --- /dev/null +++ b/contracts/token-vesting/schema/instantiate_msg.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "additionalProperties": false +} diff --git a/contracts/token-vesting/schema/query_msg.json b/contracts/token-vesting/schema/query_msg.json new file mode 100644 index 0000000..5802c73 --- /dev/null +++ b/contracts/token-vesting/schema/query_msg.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "vesting_account" + ], + "properties": { + "vesting_account": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "anyOf": [ + { + "$ref": "#/definitions/Denom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/token-vesting/schema/vesting_account_response.json b/contracts/token-vesting/schema/vesting_account_response.json new file mode 100644 index 0000000..3c73c67 --- /dev/null +++ b/contracts/token-vesting/schema/vesting_account_response.json @@ -0,0 +1,168 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VestingAccountResponse", + "type": "object", + "required": [ + "address", + "vestings" + ], + "properties": { + "address": { + "type": "string" + }, + "vestings": { + "type": "array", + "items": { + "$ref": "#/definitions/VestingData" + } + } + }, + "additionalProperties": false, + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "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" + }, + "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" + }, + "VestingData": { + "type": "object", + "required": [ + "claimable_amount", + "vested_amount", + "vesting_amount", + "vesting_denom", + "vesting_schedule" + ], + "properties": { + "claimable_amount": { + "$ref": "#/definitions/Uint128" + }, + "master_address": { + "type": [ + "string", + "null" + ] + }, + "vested_amount": { + "$ref": "#/definitions/Uint128" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + }, + "vesting_denom": { + "$ref": "#/definitions/Denom" + }, + "vesting_schedule": { + "$ref": "#/definitions/VestingSchedule" + } + }, + "additionalProperties": false + }, + "VestingSchedule": { + "oneOf": [ + { + "description": "LinearVesting is used to vest tokens linearly during a time period. The total_amount will be vested during this period.", + "type": "object", + "required": [ + "linear_vesting" + ], + "properties": { + "linear_vesting": { + "type": "object", + "required": [ + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "linear_vesting_with_cliff" + ], + "properties": { + "linear_vesting_with_cliff": { + "type": "object", + "required": [ + "cliff_amount", + "cliff_time", + "end_time", + "start_time", + "vesting_amount" + ], + "properties": { + "cliff_amount": { + "$ref": "#/definitions/Uint128" + }, + "cliff_time": { + "$ref": "#/definitions/Uint64" + }, + "end_time": { + "$ref": "#/definitions/Uint64" + }, + "start_time": { + "$ref": "#/definitions/Uint64" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/contracts/token-vesting/src/contract.rs b/contracts/token-vesting/src/contract.rs new file mode 100644 index 0000000..90705a8 --- /dev/null +++ b/contracts/token-vesting/src/contract.rs @@ -0,0 +1,389 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_binary, to_binary, Attribute, BankMsg, Binary, Coin, CosmosMsg, Deps, + DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Storage, + Timestamp, Uint128, WasmMsg, +}; + +use serde_json::to_string; + +use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg, Denom}; +use cw_storage_plus::Bound; + +use crate::msg::{ + Cw20HookMsg, ExecuteMsg, InstantiateMsg, QueryMsg, VestingAccountResponse, + VestingData, VestingSchedule, +}; +use crate::state::{denom_to_key, VestingAccount, VESTING_ACCOUNTS}; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, +) -> StdResult { + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + match msg { + ExecuteMsg::Receive(msg) => receive_cw20(deps, env, info, msg), + ExecuteMsg::RegisterVestingAccount { + master_address, + address, + vesting_schedule, + } => { + // deposit validation + if info.funds.len() != 1 { + return Err(StdError::generic_err( + "must deposit only one type of token", + )); + } + + let deposit_coin = info.funds[0].clone(); + register_vesting_account( + deps.storage, + env.block.time, + master_address, + address, + Denom::Native(deposit_coin.denom), + deposit_coin.amount, + vesting_schedule, + ) + } + ExecuteMsg::DeregisterVestingAccount { + address, + denom, + vested_token_recipient, + left_vesting_token_recipient, + } => deregister_vesting_account( + deps, + env, + info, + address, + denom, + vested_token_recipient, + left_vesting_token_recipient, + ), + ExecuteMsg::Claim { denoms, recipient } => { + claim(deps, env, info, denoms, recipient) + } + } +} + +fn register_vesting_account( + storage: &mut dyn Storage, + block_time: Timestamp, + master_address: Option, + address: String, + deposit_denom: Denom, + deposit_amount: Uint128, + vesting_schedule: VestingSchedule, +) -> StdResult { + let denom_key = denom_to_key(deposit_denom.clone()); + + // vesting_account existence check + if VESTING_ACCOUNTS.has(storage, (address.as_str(), &denom_key)) { + return Err(StdError::generic_err("already exists")); + } + + // validate vesting schedule + vesting_schedule.validate(block_time, deposit_amount)?; + + VESTING_ACCOUNTS.save( + storage, + (address.as_str(), &denom_key), + &VestingAccount { + master_address: master_address.clone(), + address: address.to_string(), + vesting_denom: deposit_denom.clone(), + vesting_amount: deposit_amount, + vesting_schedule, + claimed_amount: Uint128::zero(), + }, + )?; + + Ok(Response::new().add_attributes(vec![ + ("action", "register_vesting_account"), + ( + "master_address", + master_address.unwrap_or_default().as_str(), + ), + ("address", address.as_str()), + ("vesting_denom", &to_string(&deposit_denom).unwrap()), + ("vesting_amount", &deposit_amount.to_string()), + ])) +} + +fn deregister_vesting_account( + deps: DepsMut, + env: Env, + info: MessageInfo, + address: String, + denom: Denom, + vested_token_recipient: Option, + left_vesting_token_recipient: Option, +) -> StdResult { + let denom_key = denom_to_key(denom.clone()); + let sender = info.sender; + + let mut messages: Vec = vec![]; + + // vesting_account existence check + let account = VESTING_ACCOUNTS + .may_load(deps.storage, (address.as_str(), &denom_key))?; + if account.is_none() { + return Err(StdError::generic_err(format!( + "vesting entry is not found for denom {:?}", + to_string(&denom).unwrap(), + ))); + } + + let account = account.unwrap(); + if account.master_address.is_none() + || account.master_address.unwrap() != sender + { + return Err(StdError::generic_err("unauthorized")); + } + + // remove vesting account + VESTING_ACCOUNTS.remove(deps.storage, (address.as_str(), &denom_key)); + + let vested_amount = account + .vesting_schedule + .vested_amount(env.block.time.seconds())?; + let claimed_amount = account.claimed_amount; + + // transfer already vested but not claimed amount to + // a account address or the given `vested_token_recipient` address + let claimable_amount = vested_amount.checked_sub(claimed_amount)?; + if !claimable_amount.is_zero() { + let recipient = + vested_token_recipient.unwrap_or_else(|| address.to_string()); + let msg_send: CosmosMsg = build_send_msg( + account.vesting_denom.clone(), + claimable_amount, + recipient, + )?; + messages.push(msg_send); + } + + // transfer left vesting amount to owner or + // the given `left_vesting_token_recipient` address + let left_vesting_amount = + account.vesting_amount.checked_sub(vested_amount)?; + if !left_vesting_amount.is_zero() { + let recipient = + left_vesting_token_recipient.unwrap_or_else(|| sender.to_string()); + let msg_send: CosmosMsg = build_send_msg( + account.vesting_denom.clone(), + left_vesting_amount, + recipient, + )?; + messages.push(msg_send); + } + + Ok(Response::new().add_messages(messages).add_attributes(vec![ + ("action", "deregister_vesting_account"), + ("address", address.as_str()), + ("vesting_denom", &to_string(&account.vesting_denom).unwrap()), + ("vesting_amount", &account.vesting_amount.to_string()), + ("vested_amount", &vested_amount.to_string()), + ("left_vesting_amount", &left_vesting_amount.to_string()), + ])) +} + +fn claim( + deps: DepsMut, + env: Env, + info: MessageInfo, + denoms: Vec, + recipient: Option, +) -> StdResult { + let sender = info.sender; + let recipient = recipient.unwrap_or_else(|| sender.to_string()); + + let mut messages: Vec = vec![]; + let mut attrs: Vec = vec![]; + for denom in denoms.iter() { + let denom_key = denom_to_key(denom.clone()); + + // vesting_account existence check + let account = VESTING_ACCOUNTS + .may_load(deps.storage, (sender.as_str(), &denom_key))?; + if account.is_none() { + return Err(StdError::generic_err(format!( + "vesting entry is not found for denom {}", + to_string(&denom).unwrap(), + ))); + } + + let mut account = account.unwrap(); + let vested_amount = account + .vesting_schedule + .vested_amount(env.block.time.seconds())?; + let claimed_amount = account.claimed_amount; + + let claimable_amount = vested_amount.checked_sub(claimed_amount)?; + if claimable_amount.is_zero() { + continue; + } + + account.claimed_amount = vested_amount; + if account.claimed_amount == account.vesting_amount { + VESTING_ACCOUNTS.remove(deps.storage, (sender.as_str(), &denom_key)); + } else { + VESTING_ACCOUNTS.save( + deps.storage, + (sender.as_str(), &denom_key), + &account, + )?; + } + + let msg_send: CosmosMsg = build_send_msg( + account.vesting_denom.clone(), + claimable_amount, + recipient.clone(), + )?; + + messages.push(msg_send); + attrs.extend( + vec![ + ("vesting_denom", &to_string(&account.vesting_denom).unwrap()), + ("vesting_amount", &account.vesting_amount.to_string()), + ("vested_amount", &vested_amount.to_string()), + ("claim_amount", &claimable_amount.to_string()), + ] + .into_iter() + .map(|(key, val)| Attribute::new(key, val)), + ); + } + + Ok(Response::new() + .add_messages(messages) + .add_attributes(vec![("action", "claim"), ("address", sender.as_str())]) + .add_attributes(attrs)) +} + +fn build_send_msg( + denom: Denom, + amount: Uint128, + to: String, +) -> StdResult { + Ok(match denom { + Denom::Native(denom) => BankMsg::Send { + to_address: to, + amount: vec![Coin { denom, amount }], + } + .into(), + Denom::Cw20(contract_addr) => WasmMsg::Execute { + contract_addr: contract_addr.to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: to, + amount, + })?, + funds: vec![], + } + .into(), + }) +} + +pub fn receive_cw20( + deps: DepsMut, + env: Env, + info: MessageInfo, + cw20_msg: Cw20ReceiveMsg, +) -> StdResult { + let amount = cw20_msg.amount; + let _sender = cw20_msg.sender; + let contract = info.sender; + + match from_binary(&cw20_msg.msg) { + Ok(Cw20HookMsg::RegisterVestingAccount { + master_address, + address, + vesting_schedule, + }) => register_vesting_account( + deps.storage, + env.block.time, + master_address, + address, + Denom::Cw20(contract), + amount, + vesting_schedule, + ), + Err(_) => Err(StdError::generic_err("invalid cw20 hook message")), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VestingAccount { + address, + start_after, + limit, + } => { + to_binary(&vesting_account(deps, env, address, start_after, limit)?) + } + } +} + +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +/// address: Bech 32 address for the owner of the vesting accounts. This will be +/// the prefix we filter by in state. +/// limit: Maximum number of vesting accounts to retrieve when reading the +/// VESTING_ACCOUNTs store. +fn vesting_account( + deps: Deps, + env: Env, + address: String, + min_denom: Option, + limit: Option, +) -> StdResult { + let mut vestings: Vec = vec![]; + // Ensure the value of 'limit' does not exceed MAX_LIMIT + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + + for item in VESTING_ACCOUNTS + .prefix(address.as_str()) + .range( + deps.storage, + min_denom + .map(denom_to_key) + .map(|s| s.as_bytes().to_vec()) + .map(Bound::ExclusiveRaw), + None, + Order::Ascending, + ) + // limits the number of vesting accounts retrieved + .take(limit) + { + let (_, account) = item?; + let vested_amount = account + .vesting_schedule + .vested_amount(env.block.time.seconds())?; + + vestings.push(VestingData { + master_address: account.master_address, + vesting_denom: account.vesting_denom, + vesting_amount: account.vesting_amount, + vested_amount, + vesting_schedule: account.vesting_schedule, + claimable_amount: vested_amount + .checked_sub(account.claimed_amount)?, + }) + } + + Ok(VestingAccountResponse { address, vestings }) +} diff --git a/contracts/token-vesting/src/lib.rs b/contracts/token-vesting/src/lib.rs new file mode 100644 index 0000000..ffa4f02 --- /dev/null +++ b/contracts/token-vesting/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; diff --git a/contracts/token-vesting/src/msg.rs b/contracts/token-vesting/src/msg.rs new file mode 100644 index 0000000..0474643 --- /dev/null +++ b/contracts/token-vesting/src/msg.rs @@ -0,0 +1,305 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{StdError, StdResult, Timestamp, Uint128, Uint64}; +use cw20::{Cw20ReceiveMsg, Denom}; + +/// Structure for the message that instantiates the smart contract. +#[cw_serde] +pub struct InstantiateMsg {} + +/// Enum respresenting message types for the execute entry point. +/// These express the different ways in which one can invoke the contract +/// and broadcast tx messages against it. +#[cw_serde] +pub enum ExecuteMsg { + Receive(Cw20ReceiveMsg), + + /// A creator operation that registers a vesting account + /// address: String: Bech 32 address of the owner of the vesting account. + /// master_address: Option: Bech 32 address that can unregister the vesting account. + /// vesting_schedule: VestingSchedule: The vesting schedule of the account. + RegisterVestingAccount { + address: String, + master_address: Option, // if given, the vesting account can be unregistered + vesting_schedule: VestingSchedule, + }, + + /// A creator operation that unregisters a vesting account. This method is only available only + /// available when 'master_address' was set during vesting account registration. + /// Args: + /// - address: String: Bech 32 address of the owner of vesting account. + /// - denom: Denom: The denomination of the tokens vested. + /// - vested_token_recipient: Option: Bech 32 address that will receive the vested + /// tokens after deregistration. If None, tokens are received by the owner address. + /// - left_vesting_token_recipient: Option: Bech 32 address that will receive the left + /// vesting tokens after deregistration. + DeregisterVestingAccount { + address: String, + denom: Denom, + vested_token_recipient: Option, + left_vesting_token_recipient: Option, + }, + + /// Claim is an operation that allows one to claim vested tokens. + Claim { + denoms: Vec, + recipient: Option, + }, +} + +#[cw_serde] +pub enum Cw20HookMsg { + /// Register vesting account with token transfer + RegisterVestingAccount { + master_address: Option, // if given, the vesting account can be unregistered + address: String, + vesting_schedule: VestingSchedule, + }, +} + +/// Enum representing the message types for the query entry point. +#[cw_serde] +pub enum QueryMsg { + VestingAccount { + address: String, + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct VestingAccountResponse { + pub address: String, + pub vestings: Vec, +} + +#[cw_serde] +pub struct VestingData { + pub master_address: Option, + pub vesting_denom: Denom, + pub vesting_amount: Uint128, + pub vested_amount: Uint128, + pub vesting_schedule: VestingSchedule, + pub claimable_amount: Uint128, +} + +#[cw_serde] +pub enum VestingSchedule { + /// LinearVesting is used to vest tokens linearly during a time period. + /// The total_amount will be vested during this period. + LinearVesting { + start_time: Uint64, // vesting start time in second unit + end_time: Uint64, // vesting end time in second unit + vesting_amount: Uint128, // total vesting amount + }, + LinearVestingWithCliff { + start_time: Uint64, // vesting start time in second unit + end_time: Uint64, // vesting end time in second unit + vesting_amount: Uint128, // total vesting amount + cliff_amount: Uint128, // amount that will be unvested at cliff_time + cliff_time: Uint64, // cliff time in second unit + }, +} + +pub struct Cliff { + pub amount: Uint128, + pub time: Uint64, +} + +impl Cliff { + pub fn ok( + &self, + block_time: Timestamp, + vesting_amount: Uint128, + ) -> StdResult<()> { + if self.amount.is_zero() { + return Err(StdError::generic_err("assert(cliff_amount > 0)")); + } + + if self.time.u64() < block_time.seconds() { + return Err(StdError::generic_err( + "assert(cliff_time > block_time)", + )); + } + + if self.amount.u128() > vesting_amount.u128() { + return Err(StdError::generic_err( + "assert(cliff_amount <= vesting_amount)", + )); + } + Ok(()) + } +} + +impl VestingSchedule { + pub fn vested_amount(&self, block_time: u64) -> StdResult { + match self { + VestingSchedule::LinearVesting { + start_time, + end_time, + vesting_amount, + } => { + if block_time <= start_time.u64() { + return Ok(Uint128::zero()); + } + + if block_time >= end_time.u64() { + return Ok(*vesting_amount); + } + + let vested_token = vesting_amount + .checked_mul(Uint128::from(block_time - start_time.u64()))? + .checked_div(Uint128::from(end_time - start_time))?; + + Ok(vested_token) + } + VestingSchedule::LinearVestingWithCliff { + start_time: _start_time, + end_time, + vesting_amount, + cliff_amount, + cliff_time, + } => { + if block_time < cliff_time.u64() { + return Ok(Uint128::zero()); + } + + if block_time == cliff_time.u64() { + return Ok(*cliff_amount); + } + + if block_time >= end_time.u64() { + return Ok(*vesting_amount); + } + + let remaining_token = + vesting_amount.checked_sub(*cliff_amount)?; + let vested_token = remaining_token + .checked_mul(Uint128::from(block_time - cliff_time.u64()))? + .checked_div(Uint128::from(end_time - cliff_time))?; + + Ok(vested_token + cliff_amount) + } + } + } + + pub fn validate( + &self, + block_time: Timestamp, + deposit_amount: Uint128, + ) -> StdResult<()> { + match &self { + VestingSchedule::LinearVesting { + start_time, + end_time, + vesting_amount, + } => { + if vesting_amount.is_zero() { + return Err(StdError::generic_err( + "assert(vesting_amount > 0)", + )); + } + + if start_time.u64() < block_time.seconds() { + return Err(StdError::generic_err( + "assert(start_time < block_time)", + )); + } + + if end_time <= start_time { + return Err(StdError::generic_err( + "assert(end_time <= start_time)", + )); + } + + if vesting_amount != deposit_amount { + return Err(StdError::generic_err( + "assert(deposit_amount == vesting_amount)", + )); + } + Ok(()) + } + + VestingSchedule::LinearVestingWithCliff { + start_time, + end_time, + vesting_amount, + cliff_time, + cliff_amount, + } => { + if vesting_amount.is_zero() { + return Err(StdError::generic_err( + "assert(vesting_amount > 0)", + )); + } + + if end_time <= start_time { + return Err(StdError::generic_err( + "assert(end_time > start_time)", + )); + } + + if start_time.u64() < block_time.seconds() { + return Err(StdError::generic_err( + "assert(start_time > block_time)", + )); + } + + let cliff = Cliff { + amount: *cliff_amount, + time: *cliff_time, + }; + cliff.ok(block_time, *vesting_amount)?; + Ok(()) + } + } + } +} + +#[test] +fn linear_vesting_vested_amount() { + let schedule = VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }; + + assert_eq!(schedule.vested_amount(100).unwrap(), Uint128::zero()); + assert_eq!( + schedule.vested_amount(105).unwrap(), + Uint128::new(500000u128) + ); + assert_eq!( + schedule.vested_amount(110).unwrap(), + Uint128::new(1000000u128) + ); + assert_eq!( + schedule.vested_amount(115).unwrap(), + Uint128::new(1000000u128) + ); +} + +#[test] +fn linear_vesting_with_cliff_vested_amount() { + let schedule = VestingSchedule::LinearVestingWithCliff { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1_000_000_u128), + cliff_amount: Uint128::new(100_000_u128), + cliff_time: Uint64::new(105), + }; + + assert_eq!(schedule.vested_amount(100).unwrap(), Uint128::zero()); + assert_eq!( + schedule.vested_amount(105).unwrap(), + Uint128::new(100000u128) + ); // cliff time then the cliff amount + assert_eq!( + // complete vesting + schedule.vested_amount(120).unwrap(), + Uint128::new(1000000u128) + ); + + // other permutations + assert_eq!(schedule.vested_amount(104).unwrap(), Uint128::zero()); // before cliff time + assert_eq!(schedule.vested_amount(109).unwrap(), Uint128::new(820_000)); // after cliff time but before end time +} diff --git a/contracts/token-vesting/src/state.rs b/contracts/token-vesting/src/state.rs new file mode 100644 index 0000000..002b5f2 --- /dev/null +++ b/contracts/token-vesting/src/state.rs @@ -0,0 +1,48 @@ +use cosmwasm_schema::cw_serde; + +use crate::msg::VestingSchedule; +use cosmwasm_std::Uint128; +use cw20::Denom; +use cw_storage_plus::Map; + +pub const VESTING_ACCOUNTS: Map<(&str, &str), VestingAccount> = + Map::new("vesting_accounts"); + +#[cw_serde] +pub struct VestingAccount { + pub master_address: Option, + pub address: String, + pub vesting_denom: Denom, + pub vesting_amount: Uint128, + pub vesting_schedule: VestingSchedule, + pub claimed_amount: Uint128, +} + +pub fn denom_to_key(denom: Denom) -> String { + match denom { + Denom::Cw20(addr) => format!("cw20-{}", addr.to_string()), + Denom::Native(denom) => format!("native-{}", denom), + } +} + +#[test] +fn test_denom_to_key() { + use cosmwasm_std::Uint64; + + let schedule = VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(120), + vesting_amount: Uint128::new(1000), + }; + + let vesting_account = VestingAccount { + master_address: None, + address: String::from("address"), + vesting_denom: Denom::Native(String::from("nibi")), + vesting_amount: Uint128::zero(), + vesting_schedule: schedule, + claimed_amount: Uint128::zero(), + }; + + assert_eq!(denom_to_key(vesting_account.vesting_denom), "native-nibi"); +} diff --git a/contracts/token-vesting/src/testing.rs b/contracts/token-vesting/src/testing.rs new file mode 100644 index 0000000..b8d8c98 --- /dev/null +++ b/contracts/token-vesting/src/testing.rs @@ -0,0 +1,842 @@ +use crate::contract::{execute, instantiate, query}; +use crate::msg::{ + Cw20HookMsg, ExecuteMsg, InstantiateMsg, QueryMsg, VestingAccountResponse, + VestingData, VestingSchedule, +}; + +use cosmwasm_std::testing::{MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{ + from_binary, + testing::{mock_dependencies, mock_env, mock_info}, + to_binary, Addr, Attribute, BankMsg, Coin, Env, OwnedDeps, Response, + StdError, SubMsg, Timestamp, Uint128, Uint64, WasmMsg, +}; +use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg, Denom}; + +#[test] +fn proper_initialization() { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg {}; + + let info = mock_info("addr0000", &[]); + + // we can just call .unwrap() to assert this was a success + let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); +} + +#[test] +fn register_cliff_vesting_account_with_native_token() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + let create_msg = |start_time: u64, + end_time: u64, + vesting_amount: u128, + cliff_amount: u128, + cliff_time: u64| + -> ExecuteMsg { + ExecuteMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVestingWithCliff { + start_time: Uint64::new(start_time), + end_time: Uint64::new(end_time), + vesting_amount: Uint128::new(vesting_amount), + cliff_amount: Uint128::new(cliff_amount), + cliff_time: Uint64::new(cliff_time), + }, + } + }; + + // zero amount vesting token + let msg = create_msg(100, 110, 0, 1000, 105); + require_error(&mut deps, &env, msg, "assert(vesting_amount > 0)"); + + // zero amount cliff token + let msg = create_msg(100, 110, 1000, 0, 105); + require_error(&mut deps, &env, msg, "assert(cliff_amount > 0)"); + + // cliff time less than block time + let msg = create_msg(100, 110, 1000, 1000, 99); + require_error(&mut deps, &env, msg, "assert(cliff_time > block_time)"); + + // end time less than start time + let msg = create_msg(110, 100, 1000, 1000, 105); + require_error(&mut deps, &env, msg, "assert(end_time > start_time)"); + + // start time less than block time + let msg = create_msg(99, 110, 1000, 1000, 105); + require_error(&mut deps, &env, msg, "assert(start_time > block_time)"); + + // cliff amount greater than vesting amount + let msg = create_msg(100, 110, 1000, 1001, 105); + require_error( + &mut deps, + &env, + msg, + "assert(cliff_amount <= vesting_amount)", + ); +} + +fn require_error( + deps: &mut OwnedDeps, + env: &Env, + msg: ExecuteMsg, + error_message: &str, +) { + let info = mock_info("addr0000", &[Coin::new(0u128, "uusd")]); + let res = execute(deps.as_mut(), env.clone(), info, msg); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, error_message) + } + _ => panic!("should not enter"), + } +} + +#[test] +fn register_vesting_account_with_native_token() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // zero amount vesting token + let msg = ExecuteMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::zero(), + }, + }; + require_error(&mut deps, &env, msg, "assert(vesting_amount > 0)"); + + // normal amount vesting token + let msg = ExecuteMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + + // invalid amount + let info = mock_info("addr0000", &[]); + let res = execute(deps.as_mut(), env.clone(), info, msg.clone()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, "must deposit only one type of token") + } + _ => panic!("should not enter"), + } + + // invalid amount + let info = mock_info( + "addr0000", + &[Coin::new(100u128, "uusd"), Coin::new(10u128, "ukrw")], + ); + let res = execute(deps.as_mut(), env.clone(), info, msg.clone()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, "must deposit only one type of token") + } + _ => panic!("should not enter"), + } + + // invalid amount + let info = mock_info("addr0000", &[Coin::new(10u128, "uusd")]); + let res = execute(deps.as_mut(), env.clone(), info, msg.clone()); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, "assert(deposit_amount == vesting_amount)") + } + _ => panic!("should not enter"), + } + + // valid amount + let info = mock_info("addr0000", &[Coin::new(1000000u128, "uusd")]); + let res: Response = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!( + res.attributes, + vec![ + ("action", "register_vesting_account"), + ("master_address", "",), + ("address", "addr0001"), + ("vesting_denom", "{\"native\":\"uusd\"}"), + ("vesting_amount", "1000000"), + ] + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Native("uusd".to_string()), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::zero(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::zero(), + }], + } + ); +} + +#[test] +fn register_vesting_account_with_cw20_token() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + let info = mock_info("token0000", &[]); + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // zero amount vesting token + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr0000".to_string(), + amount: Uint128::new(1000000u128), + msg: to_binary(&Cw20HookMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::zero(), + }, + }) + .unwrap(), + }); + + // invalid zero amount + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, "assert(vesting_amount > 0)") + } + _ => panic!("should not enter"), + } + + // invariant amount + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr0000".to_string(), + amount: Uint128::new(1000000u128), + msg: to_binary(&Cw20HookMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(999000u128), + }, + }) + .unwrap(), + }); + + // invalid amount + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => { + assert_eq!(msg, "assert(deposit_amount == vesting_amount)") + } + _ => panic!("should not enter"), + } + + // valid amount + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr0000".to_string(), + amount: Uint128::new(1000000u128), + msg: to_binary(&Cw20HookMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }) + .unwrap(), + }); + + // valid amount + let res: Response = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!( + res.attributes, + vec![ + ("action", "register_vesting_account"), + ("master_address", "",), + ("address", "addr0001"), + ("vesting_denom", "{\"cw20\":\"token0000\"}"), + ("vesting_amount", "1000000"), + ] + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Cw20(Addr::unchecked("token0000")), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::zero(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::zero(), + }], + } + ); +} + +#[test] +fn claim_native() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + // init env to time 100 + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // valid amount + let msg = ExecuteMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + + let info = mock_info("addr0000", &[Coin::new(1000000u128, "uusd")]); + let _ = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // make time to half claimable + env.block.time = Timestamp::from_seconds(105); + + // claim not found denom + let msg = ExecuteMsg::Claim { + denoms: vec![ + Denom::Native("ukrw".to_string()), + Denom::Native("uusd".to_string()), + ], + recipient: None, + }; + + let info = mock_info("addr0001", &[]); + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!( + msg, + "vesting entry is not found for denom {\"native\":\"ukrw\"}" + ), + _ => panic!("should not enter"), + } + + // valid claim + let msg = ExecuteMsg::Claim { + denoms: vec![Denom::Native("uusd".to_string())], + recipient: None, + }; + + let res = + execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()).unwrap(); + assert_eq!( + res.messages, + vec![SubMsg::new(BankMsg::Send { + to_address: "addr0001".to_string(), + amount: vec![Coin { + denom: "uusd".to_string(), + amount: Uint128::new(500000u128), + }], + }),] + ); + assert_eq!( + res.attributes, + vec![ + Attribute::new("action", "claim"), + Attribute::new("address", "addr0001"), + Attribute::new("vesting_denom", "{\"native\":\"uusd\"}"), + Attribute::new("vesting_amount", "1000000"), + Attribute::new("vested_amount", "500000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env.clone(), + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Native("uusd".to_string()), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::zero(), + }], + } + ); + + // make time to half claimable + env.block.time = Timestamp::from_seconds(110); + + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!( + res.messages, + vec![SubMsg::new(BankMsg::Send { + to_address: "addr0001".to_string(), + amount: vec![Coin { + denom: "uusd".to_string(), + amount: Uint128::new(500000u128), + }], + }),] + ); + assert_eq!( + res.attributes, + vec![ + Attribute::new("action", "claim"), + Attribute::new("address", "addr0001"), + Attribute::new("vesting_denom", "{\"native\":\"uusd\"}"), + Attribute::new("vesting_amount", "1000000"), + Attribute::new("vested_amount", "1000000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![], + } + ); +} + +#[test] +fn claim_cw20() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + // init env to time 100 + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // valid amount + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr0000".to_string(), + amount: Uint128::new(1000000u128), + msg: to_binary(&Cw20HookMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }) + .unwrap(), + }); + + // valid amount + let info = mock_info("token0001", &[]); + let _ = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // make time to half claimable + env.block.time = Timestamp::from_seconds(105); + + // claim not found denom + let msg = ExecuteMsg::Claim { + denoms: vec![ + Denom::Cw20(Addr::unchecked("token0002")), + Denom::Cw20(Addr::unchecked("token0001")), + ], + recipient: None, + }; + + let info = mock_info("addr0001", &[]); + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + match res.unwrap_err() { + StdError::GenericErr { msg, .. } => assert_eq!( + msg, + "vesting entry is not found for denom {\"cw20\":\"token0002\"}" + ), + _ => panic!("should not enter"), + } + + // valid claim + let msg = ExecuteMsg::Claim { + denoms: vec![Denom::Cw20(Addr::unchecked("token0001"))], + recipient: None, + }; + + let res = + execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()).unwrap(); + assert_eq!( + res.messages, + vec![SubMsg::new(WasmMsg::Execute { + contract_addr: "token0001".to_string(), + funds: vec![], + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "addr0001".to_string(), + amount: Uint128::new(500000u128), + }) + .unwrap(), + }),] + ); + assert_eq!( + res.attributes, + vec![ + Attribute::new("action", "claim"), + Attribute::new("address", "addr0001"), + Attribute::new("vesting_denom", "{\"cw20\":\"token0001\"}"), + Attribute::new("vesting_amount", "1000000"), + Attribute::new("vested_amount", "500000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env.clone(), + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Cw20(Addr::unchecked("token0001")), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::zero(), + }], + } + ); + + // make time to half claimable + env.block.time = Timestamp::from_seconds(110); + + let res = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!( + res.messages, + vec![SubMsg::new(WasmMsg::Execute { + contract_addr: "token0001".to_string(), + funds: vec![], + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "addr0001".to_string(), + amount: Uint128::new(500000u128), + }) + .unwrap(), + }),] + ); + assert_eq!( + res.attributes, + vec![ + Attribute::new("action", "claim"), + Attribute::new("address", "addr0001"), + Attribute::new("vesting_denom", "{\"cw20\":\"token0001\"}"), + Attribute::new("vesting_amount", "1000000"), + Attribute::new("vested_amount", "1000000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![], + } + ); +} + +#[test] +fn query_vesting_account() { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + InstantiateMsg {}, + ) + .unwrap(); + + // init env to time 100 + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // native vesting + let msg = ExecuteMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + + let info = mock_info("addr0000", &[Coin::new(1000000u128, "uusd")]); + let _ = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { + sender: "addr0000".to_string(), + amount: Uint128::new(1000000u128), + msg: to_binary(&Cw20HookMsg::RegisterVestingAccount { + master_address: None, + address: "addr0001".to_string(), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }) + .unwrap(), + }); + + // valid amount + let info = mock_info("token0001", &[]); + let _ = execute(deps.as_mut(), env.clone(), info, msg).unwrap(); + + // half claimable + env.block.time = Timestamp::from_seconds(105); + + // query all entry + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env.clone(), + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![ + VestingData { + master_address: None, + vesting_denom: Denom::Cw20(Addr::unchecked("token0001")), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::new(500000), + }, + VestingData { + master_address: None, + vesting_denom: Denom::Native("uusd".to_string()), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::new(500000), + } + ], + } + ); + + // query one entry + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env.clone(), + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: Some(1), + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Cw20(Addr::unchecked("token0001")), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::new(500000), + },], + } + ); + + // query one entry after first one + assert_eq!( + from_binary::( + &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: Some(Denom::Cw20(Addr::unchecked("token0001"))), + limit: Some(1), + }, + ) + .unwrap() + ) + .unwrap(), + VestingAccountResponse { + address: "addr0001".to_string(), + vestings: vec![VestingData { + master_address: None, + vesting_denom: Denom::Native("uusd".to_string()), + vesting_amount: Uint128::new(1000000), + vested_amount: Uint128::new(500000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimable_amount: Uint128::new(500000), + }], + } + ); +} diff --git a/packages/dummy/query_resp.json b/packages/dummy/query_resp.json index 848b35c..eb2eb2c 100644 --- a/packages/dummy/query_resp.json +++ b/packages/dummy/query_resp.json @@ -50,18 +50,18 @@ }, "positions": { "positions": { - "BTC:USD": { + "ETH:USD": { "trader_addr": "nibi1zaavvzxez0elundtn32qnk9lkm8kmcsz44g7xl", - "pair": "BTC:USD", + "pair": "ETH:USD", "size": "420", "margin": "420", "open_notional": "420", "latest_cpf": "0", "block_number": "1" }, - "ETH:USD": { + "BTC:USD": { "trader_addr": "nibi1zaavvzxez0elundtn32qnk9lkm8kmcsz44g7xl", - "pair": "ETH:USD", + "pair": "BTC:USD", "size": "420", "margin": "420", "open_notional": "420", @@ -114,7 +114,7 @@ } }, "oracle_prices": { - "NIBI:USD": "69", - "ETH:USD": "420" + "ETH:USD": "420", + "NIBI:USD": "69" } } \ No newline at end of file