diff --git a/.gitignore b/.gitignore index aad66ca..bab7ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist node_modules/ yarn.lock .vscode/ +.idea/ contracts/*/.editorconfig packages/*/.editorconfig diff --git a/Cargo.lock b/Cargo.lock index 49b5e3e..01c444e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,19 +38,18 @@ dependencies = [ ] [[package]] -name = "airdrop" -version = "0.0.1" +name = "airdrop-token-vesting" +version = "0.2.0" dependencies = [ "anyhow", "cosmwasm-schema", "cosmwasm-std", - "cw-ownable", "cw-storage-plus", - "cw2", - "nibiru-std", + "cw-utils", + "cw20", "schemars", - "semver", "serde", + "serde_json", "thiserror", ] @@ -1050,21 +1049,6 @@ dependencies = [ "memmap2 0.5.10", ] -[[package]] -name = "e2e-tests" -version = "0.1.0" -dependencies = [ - "anyhow", - "bash-rs", - "cosmwasm-schema", - "cosmwasm-std", - "home", - "serde", - "serde_json", - "thiserror", - "toml", -] - [[package]] name = "ecdsa" version = "0.16.9" @@ -2493,15 +2477,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2810,40 +2785,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "toml" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" -dependencies = [ - "indexmap 2.1.0", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", -] - [[package]] name = "tower-service" version = "0.3.2" @@ -3459,15 +3400,6 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" -[[package]] -name = "winnow" -version = "0.5.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a4882e6b134d6c28953a387571f1acdd3496830d5e36c5e3a1075580ea641c" -dependencies = [ - "memchr", -] - [[package]] name = "winreg" version = "0.50.0" diff --git a/Cookbook.md b/Cookbook.md index d1a5d7f..046c704 100644 --- a/Cookbook.md +++ b/Cookbook.md @@ -260,12 +260,13 @@ This contract implements vesting accounts for the CW20 and native tokens. There's no instantiation message. ```javascript -{} +{ +} ``` ### 3.2 Execute -- **Receive** +- **Receive** ```javascript { @@ -340,7 +341,8 @@ This smart contract showcases usage examples for certain Nibiru-specific and Cos There's no instantiation message. ```javascript -{} +{ +} ``` ### 4.2 Execute @@ -356,33 +358,33 @@ There's no instantiation message. - **Mint** mints tokens ```javascript -{ - "mint": { - "coin": { "amount": "[amount]", "denom": "tf/[contract-addr]/[subdenom]" }, - "mint_to": "[mint-to-addr]" - } +{ + "mint": { + "coin": { "amount": "[amount]", "denom": "tf/[contract-addr]/[subdenom]" }, + "mint_to": "[mint-to-addr]" + } } ``` - **Burn** burns tokens ```javascript -{ - "burn": { - "coin": { "amount": "[amount]", "denom": "tf/[contract-addr]/[subdenom]" }, - "burn_from": "[burn-from-addr]" - } +{ + "burn": { + "coin": { "amount": "[amount]", "denom": "tf/[contract-addr]/[subdenom]" }, + "burn_from": "[burn-from-addr]" + } } ``` - **ChangeAdmin** changes the admin of a denom ```javascript -{ - "change_admin": { - "denom": "tf/[contract-addr]/[subdenom]", - "new_admin": "[ADDR]" - } +{ + "change_admin": { + "denom": "tf/[contract-addr]/[subdenom]", + "new_admin": "[ADDR]" + } } ``` @@ -399,6 +401,7 @@ The instantiation defines the owner of the contract, who will be able to add and "admin": "cosmos1...", } ``` + ### 5.2 Execute - **MarketOrder** places a market order for a specified trading pair. `pair` indicates the trading pair, `is_long` determines if it's a long or short order, `quote_amount` is the amount in the quote currency, `leverage` specifies the leverage to apply, and `base_amount_limit` sets a limit for the amount in the base currency. @@ -540,7 +543,6 @@ The owner is the only one who can execute messages in the contract ### 6.3 Query - - **Mintable** queries the amount of μNUSD that can be minted in exchange for the specified set of `from_coins`. ```javascript @@ -579,3 +581,77 @@ The owner is the only one who can execute messages in the contract } } ``` + +## 7. Airdrop token vesting + +This contract implements vesting accounts for the native tokens. + +### 7.1 Instantiate + +We need to specify admin and managers + +```javascript +{ + "admin": "cosmos1...", + "managers": ["cosmos1...", "cosmos1..."] +} +``` + +### 7.2 Execute + +- **RewardUsers** registers several vesting contracts + +```javascript +{ + "reward_users": { + "rewards": [ + { + "user_address": "cosmos1...", + "vesting_amount": "1000000", + "cliff_amount": "100000", // Only needed if vesting schedule is linear with cliff + } + ], + "vesting_schedule": { + "linear_vesting": { + "start_time": "1703772805", + "end_time": "1703872805", + "vesting_amount": "0" // This amount does not matter + } + } + } +} +``` + +- **DeregisterVestingAccount** deregisters a vesting account + +```javascript +{ + "deregister_vesting_account": { + "address": "cosmos1...", + "vested_token_recipient": "cosmos1...", // address that will receive the vested tokens after deregistration. If None, tokens are received by the owner address. + "left_vested_token_recipient": "cosmos1...", // address that will receive the left vesting tokens after deregistration. + } +} +``` + +- **Claim** allows to claim vested tokens + +```javascript +{ + "claim": { + "recipient": "cosmos1...", + } +} +``` + +### 7.3 Query + +- **VestingAccount** returns the vesting account details for a given address. + +```javascript +{ + "vesting_account": { + "address": "cosmos1...", + } +} +``` diff --git a/README.md b/README.md index c46c9d2..5c2bbd1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Smart contract sandbox for Nibiru Chain. └── 📂 core-shifter # Calls peg shift and depth shift in x/perp. └── 📂 core-controller # Calls other admin calls from Nibiru foundation. └── 📂 core-token-vesting # Token linear vesting contracts with optional cliffs. + └── 📂 airdrop-token-vesting # Token linear vesting contracts with optional cliffs but handled for airdrop. ├── 📂 nibiru-std # Nibiru Chain standard library for smart contracts └── 📦 proto # Types and traits for QueryRequest::Stargate and CosmosMsg::Stargate └── # Includes constructors for Cosmos, IBC, and Nibiru. diff --git a/artifacts/airdrop_token_vesting.wasm b/artifacts/airdrop_token_vesting.wasm new file mode 100644 index 0000000..ebfaefc Binary files /dev/null and b/artifacts/airdrop_token_vesting.wasm differ diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt index ef18516..2e37847 100644 --- a/artifacts/checksums.txt +++ b/artifacts/checksums.txt @@ -1,4 +1,5 @@ 6e43956a6ae41e032b611529bbbd0112699f96a8126fc32b879cfe14521ad85f airdrop.wasm +79f5056138d9d0e4b25131ffb02aa5679d5242fe5ef2d9ae64299280645034cd airdrop_token_vesting.wasm 382c05baf544f2886de849933ecf59e8bc3bcdcdd552d5a63537bd6d63f2ecf1 controller.wasm 0ee6293c7ab257139d6b10abb31cafe7a6c00f3fbf2c8be126363f3c1e4e6d80 cw3_flex_multisig.wasm 515a13e891e6bf6a95ab985f653a45668c24991931fc664b64d5a0e803e4ab33 incentives.wasm diff --git a/contracts/README.md b/contracts/README.md index 21abb82..61e1fe3 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -27,4 +27,6 @@ - [**pricefeed**](./pricefeed): Legacy implementation of the Nibiru Oracle Module in pure - CosmWasm rather than the Cosmos-SDK. \ No newline at end of file + CosmWasm rather than the Cosmos-SDK. + +- [**airdrop-token-vesting**](./airdrop-token-vesting/README.md) \ No newline at end of file diff --git a/contracts/airdrop/.cargo/config b/contracts/airdrop-token-vesting/.cargo/config similarity index 67% rename from contracts/airdrop/.cargo/config rename to contracts/airdrop-token-vesting/.cargo/config index b613a59..8a76ed5 100644 --- a/contracts/airdrop/.cargo/config +++ b/contracts/airdrop-token-vesting/.cargo/config @@ -1,4 +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/airdrop-token-vesting/.gitignore b/contracts/airdrop-token-vesting/.gitignore new file mode 100644 index 0000000..10fe5d6 --- /dev/null +++ b/contracts/airdrop-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/airdrop-token-vesting/Cargo.lock b/contracts/airdrop-token-vesting/Cargo.lock new file mode 100644 index 0000000..96fc701 --- /dev/null +++ b/contracts/airdrop-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/airdrop-token-vesting/Cargo.toml b/contracts/airdrop-token-vesting/Cargo.toml new file mode 100644 index 0000000..367f68a --- /dev/null +++ b/contracts/airdrop-token-vesting/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "airdrop-token-vesting" +version = "0.2.0" +edition = "2021" +description = "Airdrop token vesting contract" + +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.4.0" +cosmwasm-std = "1.4.0" +cw20 = "1.1.1" +cw-utils = { version = "1.0.2" } +thiserror = { version = "1.0.49" } +cw-storage-plus = "1.1.0" +schemars = "0.8.15" +serde = { version = "1.0.188", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } + +[dev-dependencies] +anyhow = { workspace = true } diff --git a/contracts/airdrop-token-vesting/README.md b/contracts/airdrop-token-vesting/README.md new file mode 100644 index 0000000..2a8030f --- /dev/null +++ b/contracts/airdrop-token-vesting/README.md @@ -0,0 +1,192 @@ +## Token Vesting + +This contract implements vesting accounts for the native tokens. + +Admin and managers are defined at the instantiation of the contracts. Both can +reward users and de-register vesting accounts, but only the admin can withdraw +the unallocated amount from the contract. + +- [Master Operations](#master-operations) + - [By admin and managers](#by-admin-and-managers) + - [By admin only](#by-admin-only) +- [Vesting Account Operations](#vesting-account-operations) +- [Deployed Contract Info](#deployed-contract-info) + +### Master Operations + +#### By admin and managers + +```rust + RewardUsers { + rewards: Vec, + vesting_schedule: VestingSchedule, + }, +``` + +This creates a set of vesting accounts for the given users. + +```rust + DeregisterVestingAccount { +address: String, +vested_token_recipient: Option, +left_vesting_token_recipient: Option, +}, +``` + +- DeregisterVestingAccount - deregister 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`). + +#### By admin only + +```rust + Withdraw { + amount: Uint128, + recipient: String, + }, +``` + +This allows to get part or all of the unallocated amount from the contract and sends it to the `recipient`. Unallocated is equal to the +amount sent on instantiation minus the already rewarded to users. + +### Vesting Account Operations + +```rust +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + //////////////////////// + /// VestingAccount Operations /// + //////////////////////// + Claim { + recipient: Option, + }, +} +``` + +- Sends 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`. + + If everything is claimed, the vesting account is removed from the contract. + +### Deployed Contract Info + +TODO for mainnet/testnet + +| Field | Value | +| ------------- | ----- | +| code_id | ... | +| contract_addr | ... | +| rpc_url | ... | +| chain_id | ... | + +### Testing Against a Live Chain + +You can test this smart contract on a live chain with the following script. It +requires `nibid` version 1 or 2. + +```bash +WALLET=devnet_wallet +MANAGER_WALLET=validator +REWARDEE_WALLET=rewardee + +CONTRACT_PATH=airdrop_token_vesting.wasm + +TX_FLAG=(--keyring-backend test --output json -y --gas 100000000) + +ADMIN=nibi1ds5zr8pv3dqnj4glmr73yef5j4wxq4p3wfxuhv +MANAGER=nibi1zaavvzxez0elundtn32qnk9lkm8kmcsz44g7xl +REWARDEE=nibi1qad9nvdzha9ugl5y28fw3h3ujjg5mrpydrsmeh + +# Send funds to managers +nibid tx bank send $WALLET $MANAGER 100000unibi -y $TX_FLAG + +# Deploy the contract +nibid tx wasm store "$CONTRACT_PATH" --from $WALLET $TX_FLAG + +# you have the code_id from the output of the store tx +CODE_ID=2 + +# Instantiate the contract +cat << EOF | jq '.' | tee instantiate.json +{ + "admin": "$ADMIN", + "managers": ["$MANAGER"] +} +EOF +JSON_DATA="$(: 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.", + "type": "object", + "required": [ + "deregister_vesting_account" + ], + "properties": { + "deregister_vesting_account": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "left_vesting_token_recipient": { + "type": [ + "string", + "null" + ] + }, + "vested_token_recipient": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Claim is an operation that allows one to claim vested tokens.", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "properties": { + "recipient": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "RewardUserRequest": { + "type": "object", + "required": [ + "user_address", + "vesting_amount" + ], + "properties": { + "cliff_amount": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "user_address": { + "type": "string" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "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/airdrop-token-vesting/schema/instantiate_msg.json b/contracts/airdrop-token-vesting/schema/instantiate_msg.json new file mode 100644 index 0000000..86caf63 --- /dev/null +++ b/contracts/airdrop-token-vesting/schema/instantiate_msg.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "description": "Structure for the message that instantiates the smart contract.", + "type": "object", + "required": [ + "admin", + "managers" + ], + "properties": { + "admin": { + "type": "string" + }, + "managers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/contracts/airdrop-token-vesting/schema/query_msg.json b/contracts/airdrop-token-vesting/schema/query_msg.json new file mode 100644 index 0000000..10a7347 --- /dev/null +++ b/contracts/airdrop-token-vesting/schema/query_msg.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "description": "Enum representing the message types for the query entry point.", + "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/airdrop-token-vesting/schema/vesting_account_response.json b/contracts/airdrop-token-vesting/schema/vesting_account_response.json new file mode 100644 index 0000000..026467e --- /dev/null +++ b/contracts/airdrop-token-vesting/schema/vesting_account_response.json @@ -0,0 +1,143 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VestingAccountResponse", + "type": "object", + "required": [ + "address", + "vesting" + ], + "properties": { + "address": { + "type": "string" + }, + "vesting": { + "$ref": "#/definitions/VestingData" + } + }, + "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" + }, + "VestingAccount": { + "type": "object", + "required": [ + "address", + "claimed_amount", + "vesting_amount", + "vesting_schedule" + ], + "properties": { + "address": { + "type": "string" + }, + "claimed_amount": { + "$ref": "#/definitions/Uint128" + }, + "vesting_amount": { + "$ref": "#/definitions/Uint128" + }, + "vesting_schedule": { + "$ref": "#/definitions/VestingSchedule" + } + }, + "additionalProperties": false + }, + "VestingData": { + "type": "object", + "required": [ + "claimable_amount", + "vested_amount", + "vesting_account" + ], + "properties": { + "claimable_amount": { + "$ref": "#/definitions/Uint128" + }, + "vested_amount": { + "$ref": "#/definitions/Uint128" + }, + "vesting_account": { + "$ref": "#/definitions/VestingAccount" + } + }, + "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/airdrop-token-vesting/src/contract.rs b/contracts/airdrop-token-vesting/src/contract.rs new file mode 100644 index 0000000..e0e4eb9 --- /dev/null +++ b/contracts/airdrop-token-vesting/src/contract.rs @@ -0,0 +1,612 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Attribute, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, + Env, MessageInfo, Response, StdError, StdResult, Storage, Timestamp, + Uint128, +}; +use std::cmp::min; + +use serde_json::to_string; + +use crate::errors::ContractError; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, QueryMsg, RewardUserRequest, RewardUserResponse, + VestingAccountResponse, VestingData, VestingSchedule, +}; +use crate::state::{ + VestingAccount, Whitelist, DENOM, UNALLOCATED_AMOUNT, VESTING_ACCOUNTS, + WHITELIST, +}; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + // Funds validation + if info.funds.len() != 1 { + return Err(StdError::generic_err( + "must deposit exactly one type of token", + ) + .into()); + } + if info.funds[0].amount.is_zero() { + return Err(StdError::generic_err("must deposit some token").into()); + } + // Managers validation + if msg.managers.is_empty() { + return Err(StdError::generic_err("managers cannot be empty").into()); + } + + deps.api.addr_validate(&msg.admin)?; + for manager in msg.managers.iter() { + let _ = deps.api.addr_validate(manager)?; + } + + let unallocated_amount = info.funds[0].amount; + let denom = info.funds[0].denom.clone(); + + UNALLOCATED_AMOUNT.save(deps.storage, &unallocated_amount)?; + DENOM.save(deps.storage, &denom)?; + WHITELIST.save( + deps.storage, + &Whitelist { + members: msg.managers.into_iter().collect(), + admin: msg.admin, + }, + )?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::RewardUsers { + rewards, + vesting_schedule, + } => reward_users(deps, env, info, rewards, vesting_schedule), + ExecuteMsg::DeregisterVestingAccount { + address, + vested_token_recipient, + left_vesting_token_recipient, + } => deregister_vesting_account( + deps, + env, + info, + address, + vested_token_recipient, + left_vesting_token_recipient, + ), + ExecuteMsg::Claim { recipient } => claim(deps, env, info, recipient), + ExecuteMsg::Withdraw { amount, recipient } => { + withdraw(deps, env, info, amount, recipient) + } + } +} + +/// Allow the contract owner to withdraw the funds of the campaign +/// +/// Ensures the requested amount is less than or equal to the unallocated amount +pub fn withdraw( + deps: DepsMut, + _env: Env, + info: MessageInfo, + amount: Uint128, + recipient: String, +) -> Result { + let whitelist = WHITELIST.load(deps.storage)?; + let mut unallocated_amount = UNALLOCATED_AMOUNT.load(deps.storage)?; + let denom = DENOM.load(deps.storage)?; + + if !whitelist.is_admin(info.sender.clone()) { + return Err(StdError::generic_err("Unauthorized").into()); + } + + let amount_max = min(amount, unallocated_amount); + if amount_max.is_zero() { + return Err(StdError::generic_err("Nothing to withdraw").into()); + } + + unallocated_amount = unallocated_amount - amount_max; + UNALLOCATED_AMOUNT.save(deps.storage, &unallocated_amount)?; + + // validate recipient address + deps.api.addr_validate(&recipient.clone())?; + + Ok(Response::new() + .add_messages(vec![build_send_msg( + denom, + amount_max, + recipient.clone(), + )?]) + .add_attribute("action", "withdraw") + .add_attribute("recipient", &recipient) + .add_attribute("amount", &amount_max.to_string()) + .add_attribute("unallocated_amount", &unallocated_amount.to_string())) +} + +fn reward_users( + deps: DepsMut, + env: Env, + info: MessageInfo, + rewards: Vec, + mut vesting_schedule: VestingSchedule, +) -> Result { + let mut res = vec![]; + + let whitelist = WHITELIST.load(deps.storage)?; + + if !(whitelist.is_member(info.sender.clone()) + || whitelist.is_admin(info.sender.clone())) + { + return Err(StdError::generic_err("Unauthorized").into()); + } + + let unallocated_amount = UNALLOCATED_AMOUNT.load(deps.storage)?; + + let total_requested: Uint128 = + rewards.iter().map(|req| req.vesting_amount).sum(); + if total_requested > unallocated_amount { + return Err( + StdError::generic_err("Insufficient funds for all rewards").into() + ); + } + vesting_schedule.validate_time(env.block.time)?; + + let mut attrs: Vec = vec![]; + for req in rewards { + // validate amounts and cliff details if there's one + req.validate(vesting_schedule.clone())?; + + // update the vesting schedule to match with the request + match &mut vesting_schedule { + VestingSchedule::LinearVesting { vesting_amount, .. } => { + *vesting_amount = req.vesting_amount; + } + VestingSchedule::LinearVestingWithCliff { + vesting_amount, + cliff_amount, + .. + } => { + *vesting_amount = req.vesting_amount; + *cliff_amount = req.cliff_amount.unwrap(); + } + } + + let result = register_vesting_account( + deps.storage, + env.block.time, + req.user_address.clone(), + req.vesting_amount, + vesting_schedule.clone(), + ); + + if let Ok(response) = result { + attrs.extend(response.attributes); + res.push(RewardUserResponse { + user_address: req.user_address.clone(), + success: true, + error_msg: "".to_string(), + }); + } else { + let error = result.err().unwrap(); + res.push(RewardUserResponse { + user_address: req.user_address.clone(), + success: false, + error_msg: "Failed to register vesting account: ".to_string() + + &error.to_string(), + }); + } + } + + UNALLOCATED_AMOUNT + .save(deps.storage, &(unallocated_amount - total_requested))?; + + Ok(Response::new() + .add_attributes(attrs) + .add_attribute("method", "reward_users") + .set_data(to_json_binary(&res).unwrap())) +} + +fn register_vesting_account( + storage: &mut dyn Storage, + block_time: Timestamp, + address: String, + deposit_amount: Uint128, + vesting_schedule: VestingSchedule, +) -> Result { + // vesting_account existence check + if VESTING_ACCOUNTS.has(storage, address.as_str()) { + return Err(StdError::generic_err("already exists").into()); + } + vesting_schedule.validate(block_time)?; + + VESTING_ACCOUNTS.save( + storage, + address.as_str(), + &VestingAccount { + address: address.to_string(), + vesting_amount: deposit_amount, + vesting_schedule, + claimed_amount: Uint128::zero(), + }, + )?; + + Ok(Response::new().add_attributes(vec![ + ("action", "register_vesting_account"), + ("address", address.as_str()), + ("vesting_amount", &deposit_amount.to_string()), + ])) +} + +fn deregister_vesting_account( + deps: DepsMut, + env: Env, + info: MessageInfo, + address: String, + vested_token_recipient: Option, + left_vesting_token_recipient: Option, +) -> Result { + let sender = info.sender; + + let whitelist = WHITELIST.load(deps.storage)?; + + let mut messages: Vec = vec![]; + + // vesting_account existence check + let account = VESTING_ACCOUNTS.may_load(deps.storage, address.as_str())?; + let denom = DENOM.load(deps.storage)?; + + if account.is_none() { + return Err(ContractError::Std(StdError::generic_err(format!( + "vesting entry is not found for address {:?}", + to_string(&address).unwrap(), + )))); + } + let account = account.unwrap(); + + if !(whitelist.is_admin(sender.clone()) || whitelist.is_member(sender)) { + return Err(StdError::generic_err("unauthorized").into()); + } + + // remove vesting account + VESTING_ACCOUNTS.remove(deps.storage, address.as_str()); + + let vested_amount = account + .vesting_schedule + .vested_amount(env.block.time.seconds())?; + let claimed_amount = account.claimed_amount; + + // transfer already vested amount to vested_token_recipient and if + // it is not provided, transfer it to the address that is the owner of the vesting account + let claimable_amount = vested_amount.checked_sub(claimed_amount)?; + send_if_amount_is_not_zero( + &mut messages, + claimable_amount, + denom.clone(), + vested_token_recipient, + address.clone(), + )?; + + // transfer left vesting amount to left_vesting_token_recipient and if + // it is not provided, transfer it to the master_address + let left_vesting_amount = + account.vesting_amount.checked_sub(vested_amount)?; + send_if_amount_is_not_zero( + &mut messages, + left_vesting_amount, + denom, + left_vesting_token_recipient, + whitelist.admin.clone(), + )?; + + Ok(Response::new().add_messages(messages).add_attributes(vec![ + ("action", "deregister_vesting_account"), + ("address", address.as_str()), + ("vesting_amount", &account.vesting_amount.to_string()), + ("vested_amount", &vested_amount.to_string()), + ("left_vesting_amount", &left_vesting_amount.to_string()), + ])) +} + +/// +/// creates a send message if the amount to send is not zero +/// +/// If we provide a recipient, we use it. Otherwise, we use the default_recipient +fn send_if_amount_is_not_zero( + messages: &mut Vec, + amount: Uint128, + denom: String, + recipient_option: Option, + default_recipient: String, +) -> Result<(), ContractError> { + if !amount.is_zero() { + let recipient = recipient_option.unwrap_or_else(|| default_recipient); + let msg_send: CosmosMsg = build_send_msg(denom, amount, recipient)?; + messages.push(msg_send); + } + + Ok(()) +} + +fn claim( + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: Option, +) -> Result { + let sender = info.sender; + let recipient = recipient.unwrap_or_else(|| sender.to_string()); + let denom = DENOM.load(deps.storage)?; + + let mut messages: Vec = vec![]; + let mut attrs: Vec = vec![]; + + // vesting_account existence check + let account = VESTING_ACCOUNTS.may_load(deps.storage, sender.as_str())?; + if account.is_none() { + return Err(StdError::generic_err(format!( + "vesting entry is not found for denom {}", + to_string(&denom).unwrap(), + )) + .into()); + } + + 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() { + return Err(StdError::generic_err("nothing left to claim").into()); + } + + account.claimed_amount = vested_amount; + if account.claimed_amount == account.vesting_amount { + VESTING_ACCOUNTS.remove(deps.storage, sender.as_str()); + } else { + VESTING_ACCOUNTS.save(deps.storage, sender.as_str(), &account)?; + } + + let msg_send: CosmosMsg = + build_send_msg(denom, claimable_amount, recipient.clone())?; + + messages.push(msg_send); + attrs.extend( + vec![ + ("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: String, + amount: Uint128, + to: String, +) -> StdResult { + Ok(BankMsg::Send { + to_address: to, + amount: vec![Coin { + denom: denom, + amount, + }], + } + .into()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VestingAccount { + address, + start_after: _start_after, + limit: _limit, + } => to_json_binary(&vesting_account(deps, env, address)?), + } +} + +/// 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, +) -> StdResult { + let account = VESTING_ACCOUNTS.may_load(deps.storage, address.as_str())?; + + match account { + None => Err(StdError::not_found("Vesting account not found")), + Some(account) => { + let vested_amount = account + .vesting_schedule + .vested_amount(env.block.time.seconds())?; + + let vesting = VestingData { + vesting_account: account.clone(), + vested_amount: vested_amount, + claimable_amount: vested_amount + .checked_sub(account.claimed_amount)?, + }; + + Ok(VestingAccountResponse { address, vesting }) + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + use anyhow::anyhow; + use cosmwasm_std::{ + coin, + testing::{self, MockApi, MockQuerier, MockStorage}, + Empty, OwnedDeps, Uint64, + }; + + pub type TestResult = Result<(), anyhow::Error>; + + pub fn mock_env_with_time(block_time: u64) -> Env { + let mut env = testing::mock_env(); + env.block.time = Timestamp::from_seconds(block_time); + env + } + + /// Convenience function for instantiating the contract at and setting up + /// the env to have the given block time. + pub fn setup_with_block_time( + block_time: u64, + ) -> anyhow::Result<(OwnedDeps, Env)> + { + let mut deps = testing::mock_dependencies(); + let env = mock_env_with_time(block_time); + instantiate( + deps.as_mut(), + env.clone(), + testing::mock_info("admin-sender", &[coin(5000, "token")]), + InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["manager-sender".to_string()], + }, + )?; + Ok((deps, env)) + } + + #[test] + fn deregister_err_nonexistent_vesting_account() -> TestResult { + let (mut deps, _env) = setup_with_block_time(0)?; + + let msg = ExecuteMsg::DeregisterVestingAccount { + address: "nonexistent".to_string(), + vested_token_recipient: None, + left_vesting_token_recipient: None, + }; + + let res = execute( + deps.as_mut(), + testing::mock_env(), + testing::mock_info("admin-sender", &[]), + msg, + ); + + match res { + Ok(_) => Err(anyhow!("Unexpected result: {:#?}", res)), + Err(ContractError::Std(StdError::GenericErr { msg, .. })) => { + assert!(msg.contains("vesting entry is not found for address")); + Ok(()) + } + Err(err) => Err(anyhow!("Unexpected error: {:#?}", err)), + } + } + + #[test] + fn deregister_err_unauthorized_vesting_account() -> TestResult { + // Set up the environment with a block time before the vesting start time + let (mut deps, env) = setup_with_block_time(50)?; + + let register_msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(5000u128), + cliff_amount: None, + }], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + + execute( + deps.as_mut(), + env.clone(), // Use the custom environment with the adjusted block time + testing::mock_info("admin-sender", &[coin(1000000, "token")]), + register_msg, + )?; + + // Try to deregister with unauthorized sender + let msg = ExecuteMsg::DeregisterVestingAccount { + address: "addr0001".to_string(), + vested_token_recipient: None, + left_vesting_token_recipient: None, + }; + + let res = execute( + deps.as_mut(), + env, // Use the custom environment with the adjusted block time + testing::mock_info("addr0003", &[]), + msg, + ); + match res { + Err(ContractError::Std(StdError::GenericErr { msg, .. })) + if msg == "unauthorized" => {} + _ => return Err(anyhow!("Unexpected result: {:?}", res)), + } + + Ok(()) + } + + #[test] + fn deregister_successful() -> TestResult { + // Set up the environment with a block time before the vesting start time + let (mut deps, env) = setup_with_block_time(50)?; + + let register_msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(5000u128), + cliff_amount: None, + }], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + + execute( + deps.as_mut(), + env.clone(), // Use the custom environment with the adjusted block time + testing::mock_info("admin-sender", &[]), + register_msg, + )?; + + // Deregister with the manager address + let msg = ExecuteMsg::DeregisterVestingAccount { + address: "addr0001".to_string(), + vested_token_recipient: None, + left_vesting_token_recipient: None, + }; + + let _res = execute( + deps.as_mut(), + env, // Use the custom environment with the adjusted block time + testing::mock_info("manager-sender", &[]), + msg, + )?; + + Ok(()) + } +} diff --git a/contracts/airdrop-token-vesting/src/errors.rs b/contracts/airdrop-token-vesting/src/errors.rs new file mode 100644 index 0000000..be9b992 --- /dev/null +++ b/contracts/airdrop-token-vesting/src/errors.rs @@ -0,0 +1,51 @@ +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] cosmwasm_std::StdError), + + #[error(transparent)] + Vesting(#[from] VestingError), + + #[error(transparent)] + Cliff(#[from] CliffError), + + #[error(transparent)] + Overflow(#[from] cosmwasm_std::OverflowError), +} + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum CliffError { + #[error("cliff_amount is zero but should be greater than 0")] + ZeroAmount, + + #[error("cliff_time ({cliff_time}) should be greater than block_time ({block_time})")] + InvalidTime { cliff_time: u64, block_time: u64 }, + + #[error("cliff_amount ({cliff_amount}) should be less than or equal to vesting_amount ({vesting_amount})")] + ExcessiveAmount { + cliff_amount: u128, + vesting_amount: u128, + }, +} + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum VestingError { + #[error("vesting_amount is zero but should be greater than 0")] + ZeroVestingAmount, + + #[error( + "end_time ({end_time}) should be greater than start_time ({start_time})" + )] + InvalidTimeRange { start_time: u64, end_time: u64 }, + + #[error(transparent)] + Cliff(#[from] CliffError), + + #[error("vesting_amount ({vesting_amount}) should be equal to deposit_amount ({deposit_amount})")] + MismatchedVestingAndDepositAmount { + vesting_amount: u128, + deposit_amount: u128, + }, +} diff --git a/contracts/airdrop/src/lib.rs b/contracts/airdrop-token-vesting/src/lib.rs similarity index 67% rename from contracts/airdrop/src/lib.rs rename to contracts/airdrop-token-vesting/src/lib.rs index b88f588..4368eaf 100644 --- a/contracts/airdrop/src/lib.rs +++ b/contracts/airdrop-token-vesting/src/lib.rs @@ -1,6 +1,7 @@ pub mod contract; +pub mod errors; pub mod msg; pub mod state; #[cfg(test)] -mod tests; +mod testing; diff --git a/contracts/airdrop-token-vesting/src/msg.rs b/contracts/airdrop-token-vesting/src/msg.rs new file mode 100644 index 0000000..6b019da --- /dev/null +++ b/contracts/airdrop-token-vesting/src/msg.rs @@ -0,0 +1,362 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{StdResult, Timestamp, Uint128, Uint64}; +use cw20::Denom; + +use crate::{ + errors::{CliffError, ContractError, VestingError}, + state::VestingAccount, +}; + +/// Structure for the message that instantiates the smart contract. +#[cw_serde] +pub struct InstantiateMsg { + pub admin: String, + pub managers: Vec, +} + +/// 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 { + /// A creator operation that registers a vesting account + /// address: String: Bech 32 address of the owner of the vesting account. + /// vesting_schedule: VestingSchedule: The vesting schedule of the account. + RewardUsers { + rewards: Vec, + vesting_schedule: VestingSchedule, + }, + + /// A creator operation that unregisters a vesting account. + /// 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, + vested_token_recipient: Option, + left_vesting_token_recipient: Option, + }, + + /// Claim is an operation that allows one to claim vested tokens. + Claim { + recipient: Option, + }, + + // Withdraw allows the admin to withdraw the funds from the contract + Withdraw { + amount: Uint128, + recipient: String, + }, +} + +#[cw_serde] +pub struct RewardUserRequest { + pub user_address: String, + pub vesting_amount: Uint128, + pub cliff_amount: Option, +} + +impl RewardUserRequest { + pub fn validate( + &self, + vesting_schedule: VestingSchedule, + ) -> Result<(), ContractError> { + if self.vesting_amount.is_zero() { + return Err(ContractError::Vesting(VestingError::ZeroVestingAmount)); + } + + if let VestingSchedule::LinearVestingWithCliff { .. } = vesting_schedule + { + if self.cliff_amount.is_none() + || self.cliff_amount.unwrap().is_zero() + { + return Err(ContractError::Vesting(VestingError::Cliff( + CliffError::ZeroAmount, + ))); + } + + if self.cliff_amount.unwrap() > self.vesting_amount { + return Err(ContractError::Vesting(VestingError::Cliff( + CliffError::ExcessiveAmount { + cliff_amount: self.cliff_amount.unwrap().into(), + vesting_amount: self.vesting_amount.into(), + }, + ))); + } + } + + Ok(()) + } +} + +#[cw_serde] +pub struct RewardUserResponse { + pub user_address: String, + pub success: bool, + pub error_msg: String, +} + +/// 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 vesting: VestingData, +} + +#[cw_serde] +pub struct VestingData { + pub vesting_account: VestingAccount, + pub vested_amount: Uint128, + 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_time(&self, block_time: Timestamp) -> Result<(), CliffError> { + let cliff_time_seconds = self.time.u64(); + if cliff_time_seconds < block_time.seconds() { + return Err(CliffError::InvalidTime { + cliff_time: cliff_time_seconds, + block_time: block_time.seconds(), + }); + } + Ok(()) + } + + pub fn ok_amount(&self, vesting_amount: Uint128) -> Result<(), CliffError> { + if self.amount.is_zero() { + return Err(CliffError::ZeroAmount); + } + + let cliff_amount = self.amount.u128(); + if cliff_amount > vesting_amount.u128() { + return Err(CliffError::ExcessiveAmount { + cliff_amount, + vesting_amount: vesting_amount.u128(), + }); + } + 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) + } + } + } + + /// + /// Validates the vesting schedule. + /// + /// - If the VestingSchedule is LinearVesting, it checks that the vesting amount is not zero. + /// - If the VestingSchedule is LinearVestingWithCliff, it checks: + /// - that the vesting amount is not zero. + /// - that the cliff amount is not zero. + /// - that the cliff amount is less than or equal to the vesting amount. + /// + /// Also it calls to validate_time + /// + pub fn validate(&self, block_time: Timestamp) -> Result<(), VestingError> { + self.validate_time(block_time)?; + match &self { + VestingSchedule::LinearVesting { + start_time: _, + end_time: _, + vesting_amount, + } => { + if vesting_amount.is_zero() { + return Err(VestingError::ZeroVestingAmount); + } + Ok(()) + } + + VestingSchedule::LinearVestingWithCliff { + start_time: _, + end_time: _, + vesting_amount, + cliff_time, + cliff_amount, + } => { + if vesting_amount.is_zero() { + return Err(VestingError::ZeroVestingAmount); + } + let cliff = Cliff { + amount: *cliff_amount, + time: *cliff_time, + }; + cliff.ok_amount(*vesting_amount)?; + Ok(()) + } + } + } + + /// + /// validate_time checks that the start_time is less than the end_time. + /// additionally, if the vesting schedule is LinearVestingWithCliff, it checks that the cliff_time + /// is less than the end_time. + /// + /// Additionally, it the vesting schedule is LinearVestingWithCliff, it checks that the cliff_time + /// is bigger or equal to the block_time. + /// + pub fn validate_time( + &self, + block_time: Timestamp, + ) -> Result<(), VestingError> { + match self { + VestingSchedule::LinearVesting { + start_time, + end_time, + .. + } => { + if end_time <= start_time { + return Err(VestingError::InvalidTimeRange { + start_time: start_time.u64(), + end_time: end_time.u64(), + }); + } + Ok(()) + } + VestingSchedule::LinearVestingWithCliff { + start_time, + end_time, + cliff_time, + .. + } => { + if end_time <= start_time { + return Err(VestingError::InvalidTimeRange { + start_time: start_time.u64(), + end_time: end_time.u64(), + }); + } + let cliff = Cliff { + amount: Uint128::zero(), + time: *cliff_time, + }; + cliff.ok_time(block_time)?; + Ok(()) + } + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::contract::tests::TestResult; + + #[test] + fn linear_vesting_vested_amount() -> TestResult { + 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)?, Uint128::zero()); + assert_eq!(schedule.vested_amount(105)?, Uint128::new(500000u128)); + assert_eq!(schedule.vested_amount(110)?, Uint128::new(1000000u128)); + assert_eq!(schedule.vested_amount(115)?, Uint128::new(1000000u128)); + + Ok(()) + } + + #[test] + fn linear_vesting_with_cliff_vested_amount() -> TestResult { + 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)?, Uint128::zero()); + assert_eq!(schedule.vested_amount(105)?, Uint128::new(100000u128)); // cliff time then the cliff amount + assert_eq!(schedule.vested_amount(120)?, Uint128::new(1000000u128)); // complete vesting + assert_eq!(schedule.vested_amount(104)?, Uint128::zero()); // before cliff time + assert_eq!(schedule.vested_amount(109)?, Uint128::new(820_000)); // after cliff time but before end time + + Ok(()) + } +} diff --git a/contracts/airdrop-token-vesting/src/state.rs b/contracts/airdrop-token-vesting/src/state.rs new file mode 100644 index 0000000..536f58c --- /dev/null +++ b/contracts/airdrop-token-vesting/src/state.rs @@ -0,0 +1,39 @@ +use std::collections::HashSet; + +use cosmwasm_schema::cw_serde; + +use crate::msg::VestingSchedule; +use cosmwasm_std::Uint128; +use cw_storage_plus::{Item, Map}; + +pub const VESTING_ACCOUNTS: Map<&str, VestingAccount> = + Map::new("vesting_accounts"); +pub const UNALLOCATED_AMOUNT: Item = Item::new("unallocated_amount"); +pub const DENOM: Item = Item::new("denom"); +pub const WHITELIST: Item = Item::new("whitelist"); + +#[cw_serde] +pub struct Whitelist { + pub members: HashSet, + pub admin: String, +} + +impl Whitelist { + pub fn is_admin(&self, addr: impl AsRef) -> bool { + let addr = addr.as_ref(); + self.admin == addr + } + + pub fn is_member(&self, addr: impl AsRef) -> bool { + let addr = addr.as_ref(); + self.members.contains(addr) + } +} + +#[cw_serde] +pub struct VestingAccount { + pub address: String, + pub vesting_amount: Uint128, + pub vesting_schedule: VestingSchedule, + pub claimed_amount: Uint128, +} diff --git a/contracts/airdrop-token-vesting/src/testing.rs b/contracts/airdrop-token-vesting/src/testing.rs new file mode 100644 index 0000000..586c8f1 --- /dev/null +++ b/contracts/airdrop-token-vesting/src/testing.rs @@ -0,0 +1,865 @@ +use crate::contract::tests::TestResult; +use crate::contract::{execute, instantiate, query}; +use crate::errors::{CliffError, ContractError, VestingError}; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, QueryMsg, RewardUserRequest, + VestingAccountResponse, VestingData, VestingSchedule, +}; + +use cosmwasm_std::testing::{MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{coin, MessageInfo}; +use cosmwasm_std::{ + from_json, + testing::{mock_dependencies, mock_env, mock_info}, + Attribute, BankMsg, Coin, Env, OwnedDeps, Response, StdError, SubMsg, + Timestamp, Uint128, Uint64, +}; + +#[test] +fn proper_initialization() -> TestResult { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["admin-sender".to_string()], + }; + + let info = mock_info("addr0000", &[coin(1000, "nibi")]); + + let _res = instantiate(deps.as_mut(), mock_env(), info, msg)?; + Ok(()) +} + +#[test] +fn invalid_coin_sent_instantiation() -> TestResult { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["admin-sender".to_string()], + }; + + // No coins sent + let res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + msg.clone(), + ); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "must deposit exactly one type of token".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + // 2 coins sent + let res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(1000, "nibi"), coin(1000, "usd")]), + msg.clone(), + ); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "must deposit exactly one type of token".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + // 0 amount coins sent + let res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(0, "nibi")]), + msg, + ); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "must deposit some token".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + Ok(()) +} + +#[test] +fn invalid_manangers_initialization() -> TestResult { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec![], + }; + + let info = mock_info("addr0000", &[coin(1000, "nibi")]); + + let res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg.clone()); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "managers cannot be empty".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["".to_string()], + }; + let res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg.clone()); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "Invalid input: human address too short for this mock implementation (must be >= 3).".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["admin-sender".to_string(), "".to_string()], + }; + let res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg.clone()); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "Invalid input: human address too short for this mock implementation (must be >= 3).".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + let msg = InstantiateMsg { + admin: "".to_string(), + managers: vec!["admin-sender".to_string()], + }; + let res = instantiate(deps.as_mut(), mock_env(), info.clone(), msg.clone()); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "Invalid input: human address too short for this mock implementation (must be >= 3).".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + Ok(()) +} + +#[test] +fn invalid_managers() -> TestResult { + let mut deps = mock_dependencies(); + + let msg = InstantiateMsg { + admin: "admin-sender".to_string(), + managers: vec!["admin-manager".to_string()], + }; + + // No coins sent + let res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[]), + msg.clone(), + ); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "must deposit exactly one type of token".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + // 2 coins sent + let res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(1000, "nibi"), coin(1000, "usd")]), + msg, + ); + match res { + Err(err) => { + assert_eq!( + err, + StdError::GenericErr { + msg: "must deposit exactly one type of token".to_string(), + } + ) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } + + Ok(()) +} + +#[test] +fn register_cliff_vesting_account_with_native_token() -> TestResult { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(2000, "uusd")]), + InstantiateMsg { + admin: "addr0000".to_string(), + managers: vec!["admin-sender".to_string()], + }, + )?; + + 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: Option, + cliff_time: u64| + -> ExecuteMsg { + ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(vesting_amount), + cliff_amount: cliff_amount.map(Uint128::new), + }], + vesting_schedule: VestingSchedule::LinearVestingWithCliff { + start_time: Uint64::new(start_time), + end_time: Uint64::new(end_time), + vesting_amount: Uint128::zero(), + cliff_amount: Uint128::zero(), + cliff_time: Uint64::new(cliff_time), + }, + } + }; + + // unauthorized sender + let msg = create_msg(100, 110, 0, Some(1000), 105); + require_error( + &mut deps, + &env, + mock_info("addr0042", &[]), + msg, + StdError::generic_err("Unauthorized").into(), + ); + + // zero amount vesting token + let msg = create_msg(100, 110, 0, Some(1000), 105); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[]), + msg, + ContractError::Vesting(VestingError::ZeroVestingAmount), + ); + + // zero amount cliff token + let msg = create_msg(100, 110, 1000, Some(0), 105); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[]), + msg, + ContractError::Vesting(VestingError::Cliff(CliffError::ZeroAmount)), + ); + + // none amount cliff token + let msg = create_msg(100, 110, 1000, None, 105); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[]), + msg, + ContractError::Vesting(VestingError::Cliff(CliffError::ZeroAmount)), + ); + + // cliff time less than block time + let msg = create_msg(100, 110, 1000, Some(1000), 99); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[Coin::new(1000u128, "uusd")]), + msg, + ContractError::Vesting(VestingError::Cliff(CliffError::InvalidTime { + cliff_time: 99, + block_time: 100, + })), + ); + + // end time less than start time + let msg = create_msg(110, 100, 1000, Some(1000), 105); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[Coin::new(1000u128, "uusd")]), + msg, + ContractError::Vesting(VestingError::InvalidTimeRange { + start_time: 110, + end_time: 100, + }), + ); + + // cliff amount greater than vesting amount + let (vesting_amount, cliff_amount, cliff_time) = (1000, 1001, 105); + let msg = + create_msg(100, 110, vesting_amount, Some(cliff_amount), cliff_time); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[Coin::new(1000u128, "uusd")]), + msg, + ContractError::Vesting( + CliffError::ExcessiveAmount { + cliff_amount, + vesting_amount, + } + .into(), + ), + ); + + // deposit amount higher than unallocated + let (vesting_amount, cliff_amount, cliff_time) = (10000, 250, 105); + let msg = + create_msg(100, 110, vesting_amount, Some(cliff_amount), cliff_time); + require_error( + &mut deps, + &env, + mock_info("addr0000", &[Coin::new(999u128, "uusd")]), + msg, + StdError::generic_err("Insufficient funds for all rewards").into(), + ); + + // valid amount + let (vesting_amount, cliff_amount, cliff_time) = (1000, 250, 105); + let msg = + create_msg(100, 110, vesting_amount, Some(cliff_amount), cliff_time); + + let res = + execute(deps.as_mut(), env.clone(), mock_info("addr0000", &[]), msg)?; + + assert_eq!( + res.attributes, + vec![ + Attribute { + key: "action".to_string(), + value: "register_vesting_account".to_string() + }, + Attribute { + key: "address".to_string(), + value: "addr0001".to_string() + }, + Attribute { + key: "vesting_amount".to_string(), + value: "1000".to_string() + }, + Attribute { + key: "method".to_string(), + value: "reward_users".to_string() + } + ] + ); + + // valid amount - one failed because duplicate + let vesting_amount = 500u128; + let cliff_amount = 250u128; + let cliff_time = 105u64; + + let msg = ExecuteMsg::RewardUsers { + rewards: vec![ + RewardUserRequest { + user_address: "addr0002".to_string(), + vesting_amount: Uint128::new(vesting_amount), + cliff_amount: Some(Uint128::new(cliff_amount)), + }, + RewardUserRequest { + user_address: "addr0002".to_string(), + vesting_amount: Uint128::new(vesting_amount), + cliff_amount: Some(Uint128::new(cliff_amount)), + }, + ], + vesting_schedule: VestingSchedule::LinearVestingWithCliff { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::zero(), + cliff_amount: Uint128::zero(), + cliff_time: Uint64::new(cliff_time), + }, + }; + + let res = + execute(deps.as_mut(), env.clone(), mock_info("addr0000", &[]), msg)?; + + assert_eq!( + res.attributes, + vec![ + Attribute { + key: "action".to_string(), + value: "register_vesting_account".to_string() + }, + Attribute { + key: "address".to_string(), + value: "addr0002".to_string() + }, + Attribute { + key: "vesting_amount".to_string(), + value: "500".to_string() + }, + Attribute { + key: "method".to_string(), + value: "reward_users".to_string() + } + ] + ); + + Ok(()) +} + +#[test] +fn test_withdraw() -> TestResult { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(2000, "uusd")]), + InstantiateMsg { + admin: "addr0000".to_string(), + managers: vec!["admin-sender".to_string()], + }, + )?; + + 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: Option, + cliff_time: u64| + -> ExecuteMsg { + ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(vesting_amount), + cliff_amount: cliff_amount.map(Uint128::new), + }], + vesting_schedule: VestingSchedule::LinearVestingWithCliff { + start_time: Uint64::new(start_time), + end_time: Uint64::new(end_time), + vesting_amount: Uint128::zero(), + cliff_amount: Uint128::zero(), + cliff_time: Uint64::new(cliff_time), + }, + } + }; + + // valid amount + let (vesting_amount, cliff_amount, cliff_time) = (1000, 250, 105); + let msg = + create_msg(100, 110, vesting_amount, Some(cliff_amount), cliff_time); + + let _res = + execute(deps.as_mut(), env.clone(), mock_info("addr0000", &[]), msg)?; + + // try to withdraw + + // unauthorized sender + let msg = ExecuteMsg::Withdraw { + recipient: "addr0000".to_string(), + amount: Uint128::new(1000), + }; + require_error( + &mut deps, + &env, + mock_info("addr0042", &[]), + msg, + StdError::generic_err("Unauthorized").into(), + ); + + // withdraw more than unallocated + let msg = ExecuteMsg::Withdraw { + recipient: "addr0000".to_string(), + amount: Uint128::new(1001), + }; + let res = + execute(deps.as_mut(), env.clone(), mock_info("addr0000", &[]), msg)?; + + assert_eq!( + res.attributes, + vec![ + Attribute { + key: "action".to_string(), + value: "withdraw".to_string() + }, + Attribute { + key: "recipient".to_string(), + value: "addr0000".to_string() + }, + Attribute { + key: "amount".to_string(), + value: "1000".to_string() + }, + Attribute { + key: "unallocated_amount".to_string(), + value: "0".to_string() + }, + ] + ); + + // withdraw but there's no more unallocated + let msg = ExecuteMsg::Withdraw { + recipient: "addr0000".to_string(), + amount: Uint128::new(1), + }; + require_error( + &mut deps, + &env, + mock_info("addr0000", &[]), + msg, + StdError::generic_err("Nothing to withdraw").into(), + ); + + Ok(()) +} + +fn require_error( + deps: &mut OwnedDeps, + env: &Env, + info: MessageInfo, + msg: ExecuteMsg, + expected_error: ContractError, +) { + let res = execute(deps.as_mut(), env.clone(), info, msg); + match res { + Err(err) => { + assert_eq!(err, expected_error) + } + Ok(_) => panic!("Expected error but got success: {res:?}"), + } +} + +#[test] +fn register_vesting_account_with_native_token() -> TestResult { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(1000, "uusd")]), + InstantiateMsg { + admin: "addr0000".to_string(), + managers: vec!["admin-sender".to_string()], + }, + )?; + + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // zero amount vesting token + let msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::zero(), + cliff_amount: None, + }], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::zero(), + }, + }; + + require_error( + &mut deps, + &env, + mock_info("addr0000", &[Coin::new(0u128, "uusd")]), + msg, + ContractError::Vesting(VestingError::ZeroVestingAmount), + ); + + // too much vesting amount + let msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(1000001u128), + cliff_amount: None, + }], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + let info = mock_info("addr0000", &[]); + let res = execute(deps.as_mut(), env.clone(), info, msg.clone()); + match res { + Err(ContractError::Std(StdError::GenericErr { msg, .. })) => { + assert_eq!(msg, "Insufficient funds for all rewards") + } + _ => panic!("should not enter. got result: {res:?}"), + } + + // too much vesting amount in 2 rewards + let msg = ExecuteMsg::RewardUsers { + rewards: vec![ + RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(1000u128), + cliff_amount: None, + }, + RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(1u128), + cliff_amount: None, + }, + ], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + }; + let info = mock_info("addr0000", &[]); + let res = execute(deps.as_mut(), env.clone(), info, msg.clone()); + match res { + Err(ContractError::Std(StdError::GenericErr { msg, .. })) => { + assert_eq!(msg, "Insufficient funds for all rewards") + } + _ => panic!("should not enter. got result: {res:?}"), + } + + // valid amount + let msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(100u128), + cliff_amount: None, + }], + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(100u128), + }, + }; + let info = mock_info("addr0000", &[Coin::new(1000u128, "uusd")]); + let res: Response = execute(deps.as_mut(), env.clone(), info, msg)?; + assert_eq!( + res.attributes, + vec![ + Attribute { + key: "action".to_string(), + value: "register_vesting_account".to_string() + }, + Attribute { + key: "address".to_string(), + value: "addr0001".to_string() + }, + Attribute { + key: "vesting_amount".to_string(), + value: "100".to_string() + }, + Attribute { + key: "method".to_string(), + value: "reward_users".to_string() + } + ] + ); + + // query vesting account + assert_eq!( + from_json::(&query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + )?)?, + VestingAccountResponse { + address: "addr0001".to_string(), + vesting: VestingData { + vesting_account: crate::state::VestingAccount { + address: "addr0001".to_string(), + vesting_amount: Uint128::new(100u128), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(100u128), + }, + claimed_amount: Uint128::zero(), + }, + vested_amount: Uint128::zero(), + claimable_amount: Uint128::zero(), + }, + } + ); + Ok(()) +} + +#[test] +fn claim_native() -> TestResult { + let mut deps = mock_dependencies(); + let _res = instantiate( + deps.as_mut(), + mock_env(), + mock_info("addr0000", &[coin(1000000u128, "uusd")]), + InstantiateMsg { + admin: "addr0000".to_string(), + managers: vec!["admin-sender".to_string()], + }, + )?; + + // init env to time 100 + let mut env = mock_env(); + env.block.time = Timestamp::from_seconds(100); + + // valid amount + let msg = ExecuteMsg::RewardUsers { + rewards: vec![RewardUserRequest { + user_address: "addr0001".to_string(), + vesting_amount: Uint128::new(1000000u128), + cliff_amount: None, + }], + 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)?; + + // make time to half claimable + env.block.time = Timestamp::from_seconds(105); + + // valid claim + let info = mock_info("addr0001", &[]); + let msg = ExecuteMsg::Claim { recipient: None }; + + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone())?; + 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_amount", "1000000"), + Attribute::new("vested_amount", "500000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + assert_eq!( + from_json::(&query( + deps.as_ref(), + env.clone(), + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + )?)?, + VestingAccountResponse { + address: "addr0001".to_string(), + vesting: VestingData { + vesting_account: crate::state::VestingAccount { + address: "addr0001".to_string(), + vesting_amount: Uint128::new(1000000), + vesting_schedule: VestingSchedule::LinearVesting { + start_time: Uint64::new(100), + end_time: Uint64::new(110), + vesting_amount: Uint128::new(1000000u128), + }, + claimed_amount: Uint128::new(500000), + }, + vested_amount: Uint128::new(500000), + 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)?; + 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_amount", "1000000"), + Attribute::new("vested_amount", "1000000"), + Attribute::new("claim_amount", "500000"), + ], + ); + + // query vesting account + let res = &query( + deps.as_ref(), + env, + QueryMsg::VestingAccount { + address: "addr0001".to_string(), + start_after: None, + limit: None, + }, + ); + //expect res to be an errro + match res { + Err(StdError::NotFound { .. }) => {} + _ => panic!("should not enter. got result: {res:?}"), + } + + Ok(()) +} diff --git a/contracts/airdrop/Cargo.toml b/contracts/airdrop/Cargo.toml deleted file mode 100644 index 39ac9e9..0000000 --- a/contracts/airdrop/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "airdrop" -version = "0.0.1" -edition = "2021" -homepage = "https://nibiru.fi" -repository = "https://github.com/NibiruChain/cw-nibiru" -exclude = [ - # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. - "contract.wasm", - "hash.txt", -] - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -# for more explicit tests, cargo test --features=backtraces -backtraces = ["cosmwasm-std/backtraces"] -# use library feature to disable all instantiate/execute/query exports -library = [] - -[dependencies] -cosmwasm-std = { workspace = true } -cosmwasm-schema = { workspace = true } -cw-storage-plus = { workspace = true } -nibiru-std = { workspace = true } -thiserror = { workspace = true } -schemars = "0.8.15" -serde = { version = "1.0.188", default-features = false, features = ["derive"] } -cw-ownable = { workspace = true } -cw2 = { workspace = true } -semver = "1" - -[dev-dependencies] -anyhow = { workspace = true } \ No newline at end of file diff --git a/contracts/airdrop/README.md b/contracts/airdrop/README.md deleted file mode 100644 index fae09cc..0000000 --- a/contracts/airdrop/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Airdrop Contract - -## Overview - -The airdrop contract is used to distribute tokens to a list of addresses. An instance of a contract represents a campaign. The contract is initialized with a campaign id, a campaign name, a campaign description, an owner (the deployer of the contract), a list of managers, and funds which become the unallocated amount. - -The token allocation amount starts unallocated and eventually gets allocated to users by the owner and managers. - -The contract owner and managers can allocate/reward users with tokens by calling the `reward_users` function. The `reward_users` function takes a list of addresses and amounts. The total reward amount must be less than the unallocated token amount of the contract. - -## Withdraw - -Only the contract owner can withdraw from the contract (not the managers). The `withdraw` exists to withdraw any leftover tokens after the campaign has ended. There is no check for if the total outstanding reward amount is greater than the amount of funds left in the contract. `withdraw` should only be called after the campaign ends because it could leave the contract in a state where it cannot fulfill a user's outstanding reward amount. Additional funds can be sent to the contract to reverse the withdrawal. diff --git a/contracts/airdrop/src/contract.rs b/contracts/airdrop/src/contract.rs deleted file mode 100644 index 09fafab..0000000 --- a/contracts/airdrop/src/contract.rs +++ /dev/null @@ -1,256 +0,0 @@ -use crate::{ - msg::{ - ExecuteMsg, InstantiateMsg, QueryMsg, RewardUserRequest, - RewardUserResponse, - }, - state::{Campaign, CAMPAIGN, USER_REWARDS}, -}; -#[cfg(not(feature = "library"))] -use cosmwasm_std::entry_point; -use cosmwasm_std::{ - to_json_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, - Empty, Env, MessageInfo, Response, StdError, StdResult, Uint128, -}; -use cw2::{get_contract_version, set_contract_version}; -use semver::Version; - -pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); -pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn instantiate( - deps: DepsMut, - _env: Env, - info: MessageInfo, - msg: InstantiateMsg, -) -> Result { - set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - - if info.funds.len() != 1 { - return Err(StdError::generic_err("Only one coin is allowed")); - } - - let bond_denom = deps.querier.query_bonded_denom()?; - let coin = info.funds.get(0).unwrap(); - if coin.denom != bond_denom { - return Err(StdError::generic_err("Only native tokens are allowed")); - } - - let campaign = Campaign { - campaign_id: msg.campaign_id, - campaign_name: msg.campaign_name, - campaign_description: msg.campaign_description, - owner: info.sender.clone(), - managers: msg.managers, - unallocated_amount: coin.amount, - }; - CAMPAIGN.save(deps.storage, &campaign)?; - - Ok(Response::new() - .add_attribute("method", "instantiate") - .add_attribute("owner", info.sender)) -} - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn migrate( - deps: DepsMut, - _env: Env, - _msg: Empty, -) -> Result { - let new_version: Version = CONTRACT_VERSION - .parse() - .map_err(|_| StdError::generic_err("Invalid contract version format"))?; - let current_version = get_contract_version(deps.storage)?; - - if current_version.contract != CONTRACT_NAME { - return Err(StdError::generic_err( - "Can only upgrade from same contract type", - )); - } - - if current_version.version.parse::().unwrap() >= new_version { - return Err(StdError::generic_err( - "Cannot upgrade from a newer contract version", - )); - } - - set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - - Ok(Response::new().add_attribute("method", "migrate")) -} - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn execute( - deps: DepsMut, - env: Env, - info: MessageInfo, - msg: ExecuteMsg, -) -> Result { - match msg { - ExecuteMsg::RewardUsers { requests } => { - reward_users(deps, env, info, requests) - } - ExecuteMsg::Claim {} => claim(deps, env, info), - ExecuteMsg::Withdraw { amount } => withdraw(deps, env, info, amount), - } -} - -/// Reward a set of users with native tokens from the campaign pool -/// -/// - Requires sender to be the contract owner or a campaign manager. -/// - Ensures there are enough unallocated funds in the campaign -/// - Saves/updates user reward pool balances. -/// - Reduces the available campaign balance -pub fn reward_users( - deps: DepsMut, - _env: Env, - info: MessageInfo, - requests: Vec, -) -> Result { - let mut res = vec![]; - - let mut campaign = CAMPAIGN - .load(deps.storage) - .map_err(|_| StdError::generic_err("Failed to load campaign data"))?; - - if campaign.owner != info.sender && !campaign.managers.contains(&info.sender) - { - return Err(StdError::generic_err("Unauthorized")); - } - - for req in requests { - if campaign.unallocated_amount < req.amount { - return Err(StdError::generic_err( - "Not enough funds in the campaign", - )); - } - - match USER_REWARDS.may_load(deps.storage, req.user_address.clone())? { - Some(mut user_reward) => { - user_reward += req.amount; - USER_REWARDS.save( - deps.storage, - req.user_address.clone(), - &user_reward, - )?; - } - None => { - USER_REWARDS.save( - deps.storage, - req.user_address.clone(), - &req.amount, - )?; - } - }; - campaign.unallocated_amount -= req.amount; - CAMPAIGN.save(deps.storage, &campaign)?; - - res.push(RewardUserResponse { - user_address: req.user_address.clone(), - success: true, - error_msg: "".to_string(), - }); - } - - Ok(Response::new() - .add_attribute("method", "reward_users") - .set_data(to_json_binary(&res).unwrap())) -} - -/// Allow a user to claim any rewards allocated to them -/// -/// Transfers the user's full reward balance to their account. Resets their -/// reward balance to 0. -pub fn claim( - deps: DepsMut, - _env: Env, - info: MessageInfo, -) -> Result { - let bond_denom = deps.querier.query_bonded_denom()?; - - match USER_REWARDS.may_load(deps.storage, info.sender.clone())? { - Some(user_reward) => { - USER_REWARDS.remove(deps.storage, info.sender.clone()); - - Ok(Response::new() - .add_attribute("method", "claim") - .add_message(CosmosMsg::Bank(BankMsg::Send { - to_address: info.sender.to_string(), - amount: vec![Coin { - denom: bond_denom.clone(), - amount: user_reward, - }], - }))) - } - None => Err(StdError::generic_err("User pool does not exist")), - } -} - -/// Allow the contract owner to withdraw native tokens -/// -/// Ensures the requested amount is available in the contract balance. Transfers -/// tokens to the contract owner's account. -pub fn withdraw( - deps: DepsMut, - env: Env, - info: MessageInfo, - amount: Uint128, -) -> Result { - let campaign = CAMPAIGN.load(deps.storage)?; - - if info.sender != campaign.owner { - return Err(StdError::generic_err("Only contract owner can withdraw")); - } - - let bond_denom = deps.querier.query_bonded_denom()?; - - let own_balance: Uint128 = deps - .querier - .query_balance(env.contract.address, bond_denom.clone()) - .map_err(|_| StdError::generic_err("Failed to query contract balance"))? - .amount; - - if amount > own_balance { - return Err(StdError::generic_err("Not enough funds in the contract")); - } - - let res = Response::new() - .add_attribute("method", "withdraw") - .add_message(CosmosMsg::Bank(BankMsg::Send { - to_address: info.sender.to_string(), - amount: vec![Coin { - denom: bond_denom.clone(), - amount, - }], - })); - - Ok(res) -} - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { - match msg { - QueryMsg::Campaign {} => query_campaign(deps, env), - QueryMsg::GetUserReward { user_address } => { - query_user_reward(deps, env, user_address) - } - } -} - -pub fn query_campaign(deps: Deps, _env: Env) -> StdResult { - match CAMPAIGN.load(deps.storage) { - Ok(campaign) => to_json_binary(&campaign), - Err(_) => Err(StdError::generic_err("Failed to load campaign data")), - } -} - -pub fn query_user_reward( - deps: Deps, - _env: Env, - user_address: Addr, -) -> StdResult { - match USER_REWARDS.load(deps.storage, user_address) { - Ok(user_reward) => to_json_binary(&user_reward), - Err(_) => Err(StdError::generic_err("User reward does not exist")), - } -} diff --git a/contracts/airdrop/src/msg.rs b/contracts/airdrop/src/msg.rs deleted file mode 100644 index 6ab5696..0000000 --- a/contracts/airdrop/src/msg.rs +++ /dev/null @@ -1,38 +0,0 @@ -use cosmwasm_std::{Addr, Uint128}; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct InstantiateMsg { - pub campaign_id: String, - pub campaign_name: String, - pub campaign_description: String, - pub managers: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct RewardUserRequest { - pub user_address: Addr, - pub amount: Uint128, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub struct RewardUserResponse { - pub user_address: Addr, - pub success: bool, - pub error_msg: String, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ExecuteMsg { - RewardUsers { requests: Vec }, - Claim {}, - Withdraw { amount: Uint128 }, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum QueryMsg { - Campaign {}, - GetUserReward { user_address: Addr }, -} diff --git a/contracts/airdrop/src/state.rs b/contracts/airdrop/src/state.rs deleted file mode 100644 index b486153..0000000 --- a/contracts/airdrop/src/state.rs +++ /dev/null @@ -1,18 +0,0 @@ -use cosmwasm_std::{Addr, Uint128}; -use cw_storage_plus::{Item, Map}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -pub struct Campaign { - pub campaign_id: String, - pub campaign_name: String, - pub campaign_description: String, - - pub unallocated_amount: Uint128, - pub owner: Addr, - pub managers: Vec, -} - -pub const CAMPAIGN: Item = Item::new("campaign"); -pub const USER_REWARDS: Map = Map::new("user_rewards"); diff --git a/contracts/airdrop/src/tests/execute/claim.rs b/contracts/airdrop/src/tests/execute/claim.rs deleted file mode 100644 index 22db64c..0000000 --- a/contracts/airdrop/src/tests/execute/claim.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::contract::{claim, instantiate, reward_users}; -use crate::msg::{InstantiateMsg, RewardUserRequest}; -use crate::state::USER_REWARDS; -use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{coins, Addr, BankMsg, CosmosMsg, StdError, SubMsg, Uint128}; -use std::vec; - -#[test] -fn test_claim() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - reward_users( - deps.as_mut(), - env.clone(), - mock_info("owner", &[]), - vec![ - RewardUserRequest { - user_address: Addr::unchecked("user1"), - amount: Uint128::new(750), - }, - RewardUserRequest { - user_address: Addr::unchecked("user2"), - amount: Uint128::new(250), - }, - ], - ) - .unwrap(); - - // try to claim from user1 - let resp = - claim(deps.as_mut(), env.clone(), mock_info("user1", &[])).unwrap(); - - assert_eq!( - resp.messages, - vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { - to_address: "user1".to_string(), - amount: coins(750, ""), - }))] - ); - assert!(!USER_REWARDS.has(deps.as_ref().storage, Addr::unchecked("user1"))); - - // try to claim from user2 - let resp = - claim(deps.as_mut(), env.clone(), mock_info("user2", &[])).unwrap(); - - assert_eq!( - resp.messages, - vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { - to_address: "user2".to_string(), - amount: coins(250, ""), - }))] - ); - assert!(!USER_REWARDS.has(deps.as_ref().storage, Addr::unchecked("user2"))); - - // try to claim from user3 who doesn't exist - let resp = claim(deps.as_mut(), env.clone(), mock_info("user3", &[])); - - assert_eq!(resp, Err(StdError::generic_err("User pool does not exist"))); -} diff --git a/contracts/airdrop/src/tests/execute/mod.rs b/contracts/airdrop/src/tests/execute/mod.rs deleted file mode 100644 index 62de483..0000000 --- a/contracts/airdrop/src/tests/execute/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod claim; -mod reward_users; -mod withdraw; diff --git a/contracts/airdrop/src/tests/execute/reward_users.rs b/contracts/airdrop/src/tests/execute/reward_users.rs deleted file mode 100644 index 2ab8ee6..0000000 --- a/contracts/airdrop/src/tests/execute/reward_users.rs +++ /dev/null @@ -1,231 +0,0 @@ -use crate::contract::{instantiate, reward_users}; -use crate::msg::{InstantiateMsg, RewardUserRequest, RewardUserResponse}; -use crate::state::{Campaign, CAMPAIGN, USER_REWARDS}; -use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{coins, from_json, Addr, StdError, Uint128}; -use std::vec; - -#[test] -fn test_reward_users_fully_allocated() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - let resp = reward_users( - deps.as_mut(), - env.clone(), - mock_info("owner", &[]), - vec![ - RewardUserRequest { - user_address: Addr::unchecked("user1"), - amount: Uint128::new(750), - }, - RewardUserRequest { - user_address: Addr::unchecked("user2"), - amount: Uint128::new(250), - }, - ], - ) - .unwrap(); - - // assert response - let user_responses: Vec = - from_json(resp.data.unwrap()).unwrap(); - assert_eq!( - user_responses, - vec![ - RewardUserResponse { - user_address: Addr::unchecked("user1"), - success: true, - error_msg: "".to_string(), - }, - RewardUserResponse { - user_address: Addr::unchecked("user2"), - success: true, - error_msg: "".to_string(), - }, - ] - ); - - // assert inner state of the contract - let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); - assert_eq!( - campaign, - Campaign { - owner: Addr::unchecked("owner"), - unallocated_amount: Uint128::zero(), - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2") - ], - } - ); - - assert_eq!( - USER_REWARDS - .load(deps.as_ref().storage, Addr::unchecked("user1")) - .unwrap(), - Uint128::new(750) - ); - - assert_eq!( - USER_REWARDS - .load(deps.as_ref().storage, Addr::unchecked("user2")) - .unwrap(), - Uint128::new(250) - ); -} - -#[test] -fn test_reward_users_as_manager() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - let resp = reward_users( - deps.as_mut(), - env.clone(), - mock_info("manager1", &[]), - vec![ - RewardUserRequest { - user_address: Addr::unchecked("user1"), - amount: Uint128::new(750), - }, - RewardUserRequest { - user_address: Addr::unchecked("user2"), - amount: Uint128::new(250), - }, - ], - ) - .unwrap(); - - // assert response - let user_responses: Vec = - from_json(resp.data.unwrap()).unwrap(); - assert_eq!( - user_responses, - vec![ - RewardUserResponse { - user_address: Addr::unchecked("user1"), - success: true, - error_msg: "".to_string(), - }, - RewardUserResponse { - user_address: Addr::unchecked("user2"), - success: true, - error_msg: "".to_string(), - }, - ] - ); - - // assert inner state of the contract - let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); - assert_eq!( - campaign, - Campaign { - owner: Addr::unchecked("owner"), - unallocated_amount: Uint128::zero(), - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2") - ], - } - ); - - assert_eq!( - USER_REWARDS - .load(deps.as_ref().storage, Addr::unchecked("user1")) - .unwrap(), - Uint128::new(750) - ); - - assert_eq!( - USER_REWARDS - .load(deps.as_ref().storage, Addr::unchecked("user2")) - .unwrap(), - Uint128::new(250) - ); -} - -#[test] -fn test_fails_when_we_try_to_allocate_more_than_available() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - let resp = reward_users( - deps.as_mut(), - env.clone(), - mock_info("manager1", &[]), - vec![ - RewardUserRequest { - user_address: Addr::unchecked("user1"), - amount: Uint128::new(750), - }, - RewardUserRequest { - user_address: Addr::unchecked("user2"), - amount: Uint128::new(250), - }, - RewardUserRequest { - user_address: Addr::unchecked("user3"), - amount: Uint128::new(251), - }, - ], - ); - - assert_eq!( - resp, - Err(StdError::generic_err("Not enough funds in the campaign",)) - ); -} diff --git a/contracts/airdrop/src/tests/execute/withdraw.rs b/contracts/airdrop/src/tests/execute/withdraw.rs deleted file mode 100644 index f64d519..0000000 --- a/contracts/airdrop/src/tests/execute/withdraw.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::contract::{instantiate, withdraw}; -use crate::msg::InstantiateMsg; -use cosmwasm_std::testing::{ - mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, -}; -use cosmwasm_std::{coins, Addr, BankMsg, CosmosMsg, StdError, SubMsg, Uint128}; -use std::vec; - -#[test] -fn test_withdraw_ok() { - let mut deps = mock_dependencies_with_balance(&coins(1000, "")); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - // try to withdraw - let resp = withdraw( - deps.as_mut(), - env.clone(), - mock_info("owner", &[]), - Uint128::new(1000), - ) - .unwrap(); - - assert_eq!( - resp.messages, - vec![SubMsg::new(CosmosMsg::Bank(BankMsg::Send { - to_address: "owner".to_string(), - amount: coins(1000, ""), - }))] - ); -} - -#[test] -fn test_withdraw_too_much() { - let mut deps = mock_dependencies_with_balance(&coins(1000, "")); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - // try to withdraw - let resp = withdraw( - deps.as_mut(), - env.clone(), - mock_info("owner", &[]), - Uint128::new(1001), - ); - - assert_eq!( - resp, - Err(StdError::generic_err("Not enough funds in the contract")) - ); -} - -#[test] -fn test_withdraw_unauthorized() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - // try to withdraw - let res = withdraw( - deps.as_mut(), - env.clone(), - mock_info("not_owner", &[]), - Uint128::new(1000), - ); - assert_eq!( - res, - Err(StdError::generic_err("Only contract owner can withdraw")) - ); -} diff --git a/contracts/airdrop/src/tests/instantiate.rs b/contracts/airdrop/src/tests/instantiate.rs deleted file mode 100644 index cf0348d..0000000 --- a/contracts/airdrop/src/tests/instantiate.rs +++ /dev/null @@ -1,74 +0,0 @@ -use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{coins, Addr, StdError, Uint128}; - -use crate::contract::instantiate; -use crate::msg::InstantiateMsg; -use crate::state::{Campaign, CAMPAIGN}; - -#[test] -fn test_instantiate() { - let mut deps = mock_dependencies(); - let info = mock_info("sender", &coins(1000, "")); - let env = mock_env(); - let msg = InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], - }; - - instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()).unwrap(); - - let campaign = CAMPAIGN.load(deps.as_ref().storage).unwrap(); - assert_eq!( - campaign, - Campaign { - owner: Addr::unchecked("sender"), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2") - ], - unallocated_amount: Uint128::new(1000), - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - } - ); -} - -#[test] -fn test_instantiate_with_no_funds() { - let mut deps = mock_dependencies(); - let info = mock_info("sender", &[]); - let env = mock_env(); - let msg = InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], - }; - - let resp = - instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); - assert_eq!(resp, Err(StdError::generic_err("Only one coin is allowed"))); -} - -#[test] -fn test_instantiate_with_invalid_denom() { - let mut deps = mock_dependencies(); - let info = mock_info("sender", &coins(1000, "foo")); - let env = mock_env(); - let msg = InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![Addr::unchecked("manager1"), Addr::unchecked("manager2")], - }; - - let resp = - instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); - assert_eq!( - resp, - Err(StdError::generic_err("Only native tokens are allowed")) - ); -} diff --git a/contracts/airdrop/src/tests/mod.rs b/contracts/airdrop/src/tests/mod.rs deleted file mode 100644 index 577ea74..0000000 --- a/contracts/airdrop/src/tests/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod execute; -mod instantiate; -mod query; diff --git a/contracts/airdrop/src/tests/query/campaign.rs b/contracts/airdrop/src/tests/query/campaign.rs deleted file mode 100644 index 36b80bc..0000000 --- a/contracts/airdrop/src/tests/query/campaign.rs +++ /dev/null @@ -1,45 +0,0 @@ -use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{coins, from_json, Addr, Uint128}; - -use crate::contract::{instantiate, query_campaign}; -use crate::msg::InstantiateMsg; -use crate::state::Campaign; - -#[test] -fn test_query_campaign() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - let res = query_campaign(deps.as_ref(), env.clone()).unwrap(); - let campaign: Campaign = from_json(res).unwrap(); - assert_eq!( - campaign, - Campaign { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - owner: Addr::unchecked("owner"), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2") - ], - unallocated_amount: Uint128::new(1000), - } - ); -} diff --git a/contracts/airdrop/src/tests/query/mod.rs b/contracts/airdrop/src/tests/query/mod.rs deleted file mode 100644 index 5abd2f9..0000000 --- a/contracts/airdrop/src/tests/query/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod campaign; -mod user_pool; diff --git a/contracts/airdrop/src/tests/query/user_pool.rs b/contracts/airdrop/src/tests/query/user_pool.rs deleted file mode 100644 index 55a54c1..0000000 --- a/contracts/airdrop/src/tests/query/user_pool.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::contract::{instantiate, query_user_reward, reward_users}; -use crate::msg::{InstantiateMsg, RewardUserRequest}; -use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; -use cosmwasm_std::{coins, from_json, Addr, StdError, Uint128}; -use std::vec; - -#[test] -fn test_query_user_pool() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - reward_users( - deps.as_mut(), - env.clone(), - mock_info("owner", &[]), - vec![RewardUserRequest { - user_address: Addr::unchecked("user1"), - amount: Uint128::new(999), - }], - ) - .unwrap(); - - let res = - query_user_reward(deps.as_ref(), env.clone(), Addr::unchecked("user1")) - .unwrap(); - let user_pool: Uint128 = from_json(res).unwrap(); - assert_eq!(user_pool, Uint128::new(999)); -} - -#[test] -fn test_query_user_pool_empty() { - let mut deps = mock_dependencies(); - let env = mock_env(); - - instantiate( - deps.as_mut(), - env.clone(), - mock_info("owner", &coins(1000, "")), - InstantiateMsg { - campaign_id: "campaign_id".to_string(), - campaign_name: "campaign_name".to_string(), - campaign_description: "campaign_description".to_string(), - managers: vec![ - Addr::unchecked("manager1"), - Addr::unchecked("manager2"), - ], - }, - ) - .unwrap(); - - let res = - query_user_reward(deps.as_ref(), env.clone(), Addr::unchecked("user1")); - assert_eq!( - res, - Err(StdError::generic_err("User reward does not exist")) - ); -}