From 2c058bcbca5585c6f841d90ee43ace0622a6c502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=99drzej=20Stuczy=C5=84ski?= Date: Thu, 29 Apr 2021 19:22:14 +0100 Subject: [PATCH] Feature/simple payments (#571) * Calculating reward per epoch * Initial uptime-based rewards * Setting monitor address on init + minor cleanup * Ability to reward nodes through validator client * Preemptively updated client version * Using hardcoded monitor address * Removed unnecessary let binding * Comment typo * Changed validator-client package.json version * Checking for broadcast error * Added epoch_length to typescript client's stateparams * Setting default state on migration * Removed reward mixnode/gateway methods from the client api * api for executing custom contract methods * Removed whitespace leftover from the merge * Extra comments * Cleanup Co-authored-by: Dave Hrycyszyn --- clients/validator/src/index.ts | 20 +- contracts/mixnet/src/contract.rs | 71 +++++-- contracts/mixnet/src/error.rs | 12 ++ contracts/mixnet/src/helpers.rs | 146 ++++++++++++++ contracts/mixnet/src/lib.rs | 2 + contracts/mixnet/src/msg.rs | 20 +- contracts/mixnet/src/queries.rs | 39 ++-- contracts/mixnet/src/state.rs | 89 +-------- contracts/mixnet/src/storage.rs | 282 +++++++++++++++++++++++++++ contracts/mixnet/src/transactions.rs | 185 +++++++++++++++++- 10 files changed, 731 insertions(+), 135 deletions(-) create mode 100644 contracts/mixnet/src/helpers.rs create mode 100644 contracts/mixnet/src/storage.rs diff --git a/clients/validator/src/index.ts b/clients/validator/src/index.ts index f0ea3ee2c9..a9e45bcdc4 100644 --- a/clients/validator/src/index.ts +++ b/clients/validator/src/index.ts @@ -1,7 +1,7 @@ import NetClient, { INetClient } from "./net-client"; import { Gateway, GatewayBond, MixNode, MixNodeBond, SendRequest } from "./types"; import { Bip39, Random } from "@cosmjs/crypto"; -import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { DirectSecp256k1HdWallet, EncodeObject } from "@cosmjs/proto-signing"; import MixnodesCache from "./caches/mixnodes"; import { buildFeeTable, coin, Coin, coins, StdFee } from "@cosmjs/launchpad"; import { @@ -23,16 +23,18 @@ import { } from "./currency"; import GatewaysCache from "./caches/gateways"; import QueryClient, { IQueryClient } from "./query-client"; +import { nymGasLimits, nymGasPrice } from "./stargate-helper"; import { BroadcastTxSuccess, isBroadcastTxFailure } from "@cosmjs/stargate"; import { makeBankMsgSend } from "./utils"; -import { nymGasLimits, nymGasPrice } from "./stargate-helper"; export { coins, coin }; export { Coin }; export { displayAmountToNative, nativeCoinToDisplay, printableCoin, printableBalance, nativeToPrintable, MappedCoin, CoinMap } +export { nymGasLimits, nymGasPrice } export default class ValidatorClient { private readonly stakeDenom: string; + // TODO: do those even still make sense since they can vary? private readonly defaultGatewayBondingStake: number = 100_000000 private readonly defaultMixnodeBondingStake: number = 100_000000 @@ -364,7 +366,6 @@ export default class ValidatorClient { } else { throw new Error("Tried to update state params with a query client") } - } // TODO: if we just keep a reference to the SigningCosmWasmClient somewhere we can probably go direct @@ -409,6 +410,18 @@ export default class ValidatorClient { } } + public async executeCustom(signerAddress: string, messages: readonly EncodeObject[], customFee: StdFee, memo?: string): Promise { + if (this.client instanceof NetClient) { + const result = await this.client.signAndBroadcast(signerAddress, messages, customFee, memo); + if (isBroadcastTxFailure(result)) { + throw new Error(`Error when broadcasting tx ${result.transactionHash} at height ${result.height}. Code: ${result.code}; Raw log: ${result.rawLog}`) + } + return result + } else { + throw new Error("Tried to use executeCustom with a query client"); + } + } + async upload(senderAddress: string, wasmCode: Uint8Array, meta?: UploadMeta, memo?: string): Promise { if (this.client instanceof NetClient) { return this.client.upload(senderAddress, wasmCode, meta, memo).catch((err) => this.handleRequestFailure(err)); @@ -477,6 +490,7 @@ export type GatewayOwnershipResponse = { } export type StateParams = { + epoch_length: number, // ideally I'd want to define those as `number` rather than `string`, but // rust-side they are defined as Uint128 and Decimal that don't have // native javascript representations and therefore are interpreted as strings after deserialization diff --git a/contracts/mixnet/src/contract.rs b/contracts/mixnet/src/contract.rs index 7801c1d46f..add75555f5 100644 --- a/contracts/mixnet/src/contract.rs +++ b/contracts/mixnet/src/contract.rs @@ -1,26 +1,58 @@ +use crate::helpers::calculate_epoch_reward_rate; use crate::msg::{HandleMsg, InitMsg, MigrateMsg, QueryMsg}; -use crate::state::{config, State, StateParams}; +use crate::state::{State, StateParams}; +use crate::storage::config; use crate::{error::ContractError, queries, transactions}; use cosmwasm_std::{ - to_binary, Decimal, Deps, DepsMut, Env, HandleResponse, InitResponse, MessageInfo, + to_binary, Decimal, Deps, DepsMut, Env, HandleResponse, HumanAddr, InitResponse, MessageInfo, MigrateResponse, QueryResponse, Uint128, }; +pub const INITIAL_DEFAULT_EPOCH_LENGTH: u32 = 2; + /// Constant specifying minimum of coin required to bond a gateway pub const INITIAL_GATEWAY_BOND: Uint128 = Uint128(100_000000); /// Constant specifying minimum of coin required to bond a mixnode pub const INITIAL_MIXNODE_BOND: Uint128 = Uint128(100_000000); -pub const INITIAL_MIXNODE_BOND_REWARD_RATE: Decimal = Decimal::one(); - -pub const INITIAL_GATEWAY_BOND_REWARD_RATE: Decimal = Decimal::one(); +// percentage annual increase. Given starting value of x, we expect to have 1.1x at the end of the year +pub const INITIAL_MIXNODE_BOND_REWARD_RATE: u64 = 110; +pub const INITIAL_GATEWAY_BOND_REWARD_RATE: u64 = 110; pub const INITIAL_MIXNODE_ACTIVE_SET_SIZE: u32 = 100; +const NETWORK_MONITOR_ADDRESS: &str = "hal1v9qauwdq5terag6uvfsdytcs2d0sdmfdq6e83g"; + /// Constant specifying denomination of the coin used for bonding pub const DENOM: &str = "uhal"; +fn default_initial_state(owner: HumanAddr) -> State { + let mixnode_bond_reward_rate = Decimal::percent(INITIAL_MIXNODE_BOND_REWARD_RATE); + let gateway_bond_reward_rate = Decimal::percent(INITIAL_GATEWAY_BOND_REWARD_RATE); + + State { + owner, + network_monitor_address: NETWORK_MONITOR_ADDRESS.into(), + params: StateParams { + epoch_length: INITIAL_DEFAULT_EPOCH_LENGTH, + minimum_mixnode_bond: INITIAL_MIXNODE_BOND, + minimum_gateway_bond: INITIAL_GATEWAY_BOND, + mixnode_bond_reward_rate, + gateway_bond_reward_rate, + mixnode_active_set_size: INITIAL_MIXNODE_ACTIVE_SET_SIZE, + }, + mixnode_epoch_bond_reward: calculate_epoch_reward_rate( + INITIAL_DEFAULT_EPOCH_LENGTH, + mixnode_bond_reward_rate, + ), + gateway_epoch_bond_reward: calculate_epoch_reward_rate( + INITIAL_DEFAULT_EPOCH_LENGTH, + gateway_bond_reward_rate, + ), + } +} + /// Instantiate the contract. /// /// `deps` contains Storage, API and Querier @@ -32,19 +64,8 @@ pub fn init( info: MessageInfo, _msg: InitMsg, ) -> Result { - // TODO: to discuss with DH, should the initial state be set as it is right now, i.e. - // using the defined constants, or should it rather be all based on whatever is sent - // in `InitMsg`? - let state = State { - owner: info.sender, - params: StateParams { - minimum_mixnode_bond: INITIAL_MIXNODE_BOND, - minimum_gateway_bond: INITIAL_GATEWAY_BOND, - mixnode_bond_reward_rate: INITIAL_MIXNODE_BOND_REWARD_RATE, - gateway_bond_reward_rate: INITIAL_GATEWAY_BOND_REWARD_RATE, - mixnode_active_set_size: INITIAL_MIXNODE_ACTIVE_SET_SIZE, - }, - }; + let state = default_initial_state(info.sender); + config(deps.storage).save(&state)?; Ok(InitResponse::default()) } @@ -64,6 +85,12 @@ pub fn handle( HandleMsg::UpdateStateParams(params) => { transactions::try_update_state_params(deps, info, params) } + HandleMsg::RewardMixnode { owner, uptime } => { + transactions::try_reward_mixnode(deps, info, owner, uptime) + } + HandleMsg::RewardGateway { owner, uptime } => { + transactions::try_reward_gateway(deps, info, owner, uptime) + } } } @@ -88,11 +115,15 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> Result Result { + // set the state to the default + let state = default_initial_state(info.sender); + + config(deps.storage).save(&state)?; Ok(Default::default()) } diff --git a/contracts/mixnet/src/error.rs b/contracts/mixnet/src/error.rs index d665e48e3c..cbc6b47fb8 100644 --- a/contracts/mixnet/src/error.rs +++ b/contracts/mixnet/src/error.rs @@ -33,9 +33,21 @@ pub enum ContractError { #[error("Wrong coin denomination, you must send {}", DENOM)] WrongDenom {}, + #[error("Received multiple coin types during bond")] + MultipleDenoms, + #[error("No coin was sent for the bonding, you must send {}", DENOM)] NoBondFound, + #[error("The bond reward rate for mixnode was set to be lower than 1")] + DecreasingMixnodeBondReward, + + #[error("The bond reward rate for gateway was set to be lower than 1")] + DecreasingGatewayBondReward, + + #[error("The node had uptime larger than 100%")] + UnexpectedUptime, + #[error("This address has already bonded a mixnode")] AlreadyOwnsMixnode, diff --git a/contracts/mixnet/src/helpers.rs b/contracts/mixnet/src/helpers.rs new file mode 100644 index 0000000000..5fbc245e53 --- /dev/null +++ b/contracts/mixnet/src/helpers.rs @@ -0,0 +1,146 @@ +// Copyright 2021 Nym Technologies SA +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::error::ContractError; +use cosmwasm_std::{Decimal, Uint128}; + +// for time being completely ignore concept of a leap year and assume each year is exactly 365 days +// i.e. 8760 hours +const HOURS_IN_YEAR: u128 = 8760; + +const DECIMAL_FRACTIONAL: Uint128 = Uint128(1_000_000_000_000_000_000u128); + +// calculates value - 1 +fn decimal_sub_one(value: Decimal) -> Decimal { + assert!(value >= Decimal::one()); + + let value_uint128 = value * DECIMAL_FRACTIONAL; + let uint128_sub_one = (value_uint128 - DECIMAL_FRACTIONAL).unwrap(); + Decimal::from_ratio(uint128_sub_one, DECIMAL_FRACTIONAL) +} + +fn decimal_to_uint128(value: Decimal) -> Uint128 { + value * DECIMAL_FRACTIONAL +} + +fn uint128_to_decimal(value: Uint128) -> Decimal { + Decimal::from_ratio(value, DECIMAL_FRACTIONAL) +} + +pub(crate) fn calculate_epoch_reward_rate( + epoch_length: u32, + annual_reward_rate: Decimal, +) -> Decimal { + // this is more of a sanity check as the contract does not allow setting annual reward rates + // to be lower than 1. + debug_assert!(annual_reward_rate >= Decimal::one()); + + // converts reward rate, like 1.25 into the expected gain, like 0.25 + let annual_reward = decimal_sub_one(annual_reward_rate); + // do a simple cross-multiplication: + // `annual_reward` - `HOURS_IN_YEAR` + // x - `epoch_length` + // + // x = `annual_reward` * `epoch_length` / `HOURS_IN_YEAR` + + // converts reward, like 0.25 into 250000000000000000 + let annual_reward_uint128 = decimal_to_uint128(annual_reward); + + // calculates `annual_reward_uint128` * `epoch_length` / `HOURS_IN_YEAR` + let epoch_reward_uint128 = annual_reward_uint128.multiply_ratio(epoch_length, HOURS_IN_YEAR); + + // note: this returns a % reward, like 0.05 rather than reward rate (like 1.05) + uint128_to_decimal(epoch_reward_uint128) +} + +pub(crate) fn scale_reward_by_uptime( + reward: Decimal, + uptime: u32, +) -> Result { + if uptime > 100 { + return Err(ContractError::UnexpectedUptime); + } + let uptime_ratio = Decimal::from_ratio(uptime, 100u128); + // if we do not convert into a more precise representation, we might end up with, for example, + // reward 0.05 and uptime of 50% which would produce 0.50 * 0.05 = 0 (because of u128 representation) + // and also the above would be impossible to compute as Mul for Decimal is not implemented + // + // but with the intermediate conversion, we would have + // 0.50 * 50_000_000_000_000_000 = 25_000_000_000_000_000 + // which converted back would give us the proper 0.025 + let uptime_ratio_u128 = decimal_to_uint128(uptime_ratio); + let scaled = reward * uptime_ratio_u128; + Ok(uint128_to_decimal(scaled)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn calculating_epoch_reward_rate() { + // 1.10 + let annual_reward_rate = Decimal::from_ratio(110u128, 100u128); + + // if the epoch is (for some reason) exactly one year, + // the reward rate should be unchanged + let per_epoch_rate = calculate_epoch_reward_rate(HOURS_IN_YEAR as u32, annual_reward_rate); + // 0.10 + let expected = decimal_sub_one(annual_reward_rate); + assert_eq!(expected, per_epoch_rate); + + // 24 hours + let per_epoch_rate = calculate_epoch_reward_rate(24, annual_reward_rate); + // 0.1 / 365 + let expected = Decimal::from_ratio(1u128, 3650u128); + assert_eq!(expected, per_epoch_rate); + + let expected_per_epoch_rate_excel = Decimal::from_str("0.000273972602739726").unwrap(); + assert_eq!(expected_per_epoch_rate_excel, per_epoch_rate); + + // 1 hour + let per_epoch_rate = calculate_epoch_reward_rate(1, annual_reward_rate); + // 0.1 / 8760 + let expected = Decimal::from_ratio(1u128, 87600u128); + assert_eq!(expected, per_epoch_rate); + } + + #[test] + fn scaling_reward_by_uptime() { + // 0.05 + let epoch_reward = Decimal::from_ratio(5u128, 100u128); + + // scaling by 100 does nothing + let scaled = scale_reward_by_uptime(epoch_reward, 100).unwrap(); + assert_eq!(epoch_reward, scaled); + + // scaling by 0 makes the reward 0 + let scaled = scale_reward_by_uptime(epoch_reward, 0).unwrap(); + assert_eq!(Decimal::zero(), scaled); + + // 50 halves it + let scaled = scale_reward_by_uptime(epoch_reward, 50).unwrap(); + let expected = Decimal::from_ratio(25u128, 1000u128); + assert_eq!(expected, scaled); + + // 10 takes 1/10th + let scaled = scale_reward_by_uptime(epoch_reward, 10).unwrap(); + let expected = Decimal::from_ratio(5u128, 1000u128); + assert_eq!(expected, scaled); + + // anything larger than 100 returns an error + assert!(scale_reward_by_uptime(epoch_reward, 101).is_err()) + } +} diff --git a/contracts/mixnet/src/lib.rs b/contracts/mixnet/src/lib.rs index cca2400889..92c4113eeb 100644 --- a/contracts/mixnet/src/lib.rs +++ b/contracts/mixnet/src/lib.rs @@ -1,8 +1,10 @@ pub mod contract; pub mod error; +pub(crate) mod helpers; pub mod msg; pub mod queries; pub mod state; +pub(crate) mod storage; pub mod support; pub mod transactions; diff --git a/contracts/mixnet/src/msg.rs b/contracts/mixnet/src/msg.rs index 0ab0fa1a62..51bfb18690 100644 --- a/contracts/mixnet/src/msg.rs +++ b/contracts/mixnet/src/msg.rs @@ -10,11 +10,27 @@ pub struct InitMsg {} #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum HandleMsg { - BondMixnode { mix_node: MixNode }, + BondMixnode { + mix_node: MixNode, + }, UnbondMixnode {}, - BondGateway { gateway: Gateway }, + BondGateway { + gateway: Gateway, + }, UnbondGateway {}, UpdateStateParams(StateParams), + + RewardMixnode { + owner: HumanAddr, + // percentage value in range 0-100 + uptime: u32, + }, + + RewardGateway { + owner: HumanAddr, + // percentage value in range 0-100 + uptime: u32, + }, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] diff --git a/contracts/mixnet/src/queries.rs b/contracts/mixnet/src/queries.rs index a7ba61611f..996c59cb8c 100644 --- a/contracts/mixnet/src/queries.rs +++ b/contracts/mixnet/src/queries.rs @@ -1,10 +1,9 @@ -// settings for pagination -use crate::state::{config_read, gateways_read, mixnodes_read, StateParams, PREFIX_MIXNODES}; +use crate::state::StateParams; +use crate::storage::{gateways_read, mixnodes_read, read_state_params}; use cosmwasm_std::Deps; use cosmwasm_std::HumanAddr; use cosmwasm_std::Order; use cosmwasm_std::StdResult; -use cosmwasm_storage::bucket_read; use mixnet_contract::{ GatewayBond, GatewayOwnershipResponse, MixNodeBond, MixOwnershipResponse, PagedGatewayResponse, PagedResponse, @@ -21,19 +20,15 @@ pub fn query_mixnodes_paged( let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; let start = calculate_start_value(start_after); - let bucket = bucket_read::(deps.storage, PREFIX_MIXNODES); - let res = bucket + let nodes = mixnodes_read(deps.storage) .range(start.as_deref(), None, Order::Ascending) - .take(limit); - let node_tuples = res.collect::, MixNodeBond)>>>()?; - let nodes = node_tuples - .into_iter() - .map(|item| item.1) - .collect::>(); + .take(limit) + .map(|res| res.map(|item| item.1)) + .collect::>>()?; + let start_next_after = nodes.last().map(|node| node.owner().clone()); - let response = PagedResponse::new(nodes, limit, start_next_after); - Ok(response) + Ok(PagedResponse::new(nodes, limit, start_next_after)) } pub(crate) fn query_gateways_paged( @@ -78,6 +73,10 @@ pub(crate) fn query_owns_gateway( }) } +pub(crate) fn query_state_params(deps: Deps) -> StateParams { + read_state_params(deps.storage) +} + /// Adds a 0 byte to terminate the `start_after` value given. This allows CosmWasm /// to get the succeeding key as the start of the next page. fn calculate_start_value( @@ -90,17 +89,11 @@ fn calculate_start_value( }) } -pub(crate) fn query_state_params(deps: Deps) -> StateParams { - // note: In any other case, I wouldn't have attempted to unwrap this result, but in here - // if we fail to load the stored state we would already be in the undefined behaviour land, - // so we better just blow up immediately. - config_read(deps.storage).load().unwrap().params -} - #[cfg(test)] mod tests { use super::*; - use crate::state::{config, gateways, mixnodes, State}; + use crate::state::State; + use crate::storage::{config, gateways, mixnodes}; use crate::support::tests::helpers; use cosmwasm_std::Storage; @@ -403,13 +396,17 @@ mod tests { let dummy_state = State { owner: "someowner".into(), + network_monitor_address: "monitor".into(), params: StateParams { + epoch_length: 1, minimum_mixnode_bond: 123u128.into(), minimum_gateway_bond: 456u128.into(), mixnode_bond_reward_rate: "1.23".parse().unwrap(), gateway_bond_reward_rate: "4.56".parse().unwrap(), mixnode_active_set_size: 1000, }, + mixnode_epoch_bond_reward: "1.23".parse().unwrap(), + gateway_epoch_bond_reward: "4.56".parse().unwrap(), }; config(deps.as_mut().storage).save(&dummy_state).unwrap(); diff --git a/contracts/mixnet/src/state.rs b/contracts/mixnet/src/state.rs index 64682a46a7..ece82842d0 100644 --- a/contracts/mixnet/src/state.rs +++ b/contracts/mixnet/src/state.rs @@ -1,94 +1,25 @@ -use cosmwasm_std::{Decimal, HumanAddr, Storage, Uint128}; -use cosmwasm_storage::{ - bucket, bucket_read, singleton, singleton_read, Bucket, ReadonlyBucket, ReadonlySingleton, - Singleton, -}; -use mixnet_contract::{GatewayBond, MixNodeBond}; +use cosmwasm_std::{Decimal, HumanAddr, Uint128}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -// Contract-level stuff - -pub static CONFIG_KEY: &[u8] = b"config"; - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] pub struct State { pub owner: HumanAddr, // only the owner account can update state - + pub network_monitor_address: HumanAddr, pub params: StateParams, + + // helper values to avoid having to recalculate them on every single payment operation + pub mixnode_epoch_bond_reward: Decimal, // reward per epoch expressed as a decimal like 0.05 + pub gateway_epoch_bond_reward: Decimal, // reward per epoch expressed as a decimal like 0.05 } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] pub struct StateParams { + pub epoch_length: u32, // length of an epoch, expressed in hours + pub minimum_mixnode_bond: Uint128, // minimum amount a mixnode must bond to get into the system pub minimum_gateway_bond: Uint128, // minimum amount a gateway must bond to get into the system - pub mixnode_bond_reward_rate: Decimal, // epoch reward rate, expressed as a decimal like 1.25 - pub gateway_bond_reward_rate: Decimal, // epoch reward rate, expressed as a decimal like 1.25 + pub mixnode_bond_reward_rate: Decimal, // annual reward rate, expressed as a decimal like 1.25 + pub gateway_bond_reward_rate: Decimal, // annual reward rate, expressed as a decimal like 1.25 pub mixnode_active_set_size: u32, } - -pub fn config(storage: &mut dyn Storage) -> Singleton { - singleton(storage, CONFIG_KEY) -} - -pub fn config_read(storage: &dyn Storage) -> ReadonlySingleton { - singleton_read(storage, CONFIG_KEY) -} - -// Mixnode-related stuff -pub const PREFIX_MIXNODES: &[u8] = b"mixnodes"; - -pub fn mixnodes(storage: &mut dyn Storage) -> Bucket { - bucket(storage, PREFIX_MIXNODES) -} - -pub fn mixnodes_read(storage: &dyn Storage) -> ReadonlyBucket { - bucket_read(storage, PREFIX_MIXNODES) -} - -// Gateway-related stuff - -pub const PREFIX_GATEWAYS: &[u8] = b"gateways"; - -pub fn gateways(storage: &mut dyn Storage) -> Bucket { - bucket(storage, PREFIX_GATEWAYS) -} - -pub fn gateways_read(storage: &dyn Storage) -> ReadonlyBucket { - bucket_read(storage, PREFIX_GATEWAYS) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::support::tests::helpers::{gateway_bond_fixture, mixnode_bond_fixture}; - use cosmwasm_std::testing::MockStorage; - - #[test] - fn mixnode_single_read_retrieval() { - let mut storage = MockStorage::new(); - let bond1 = mixnode_bond_fixture(); - let bond2 = mixnode_bond_fixture(); - mixnodes(&mut storage).save(b"bond1", &bond1).unwrap(); - mixnodes(&mut storage).save(b"bond2", &bond2).unwrap(); - - let res1 = mixnodes_read(&storage).load(b"bond1").unwrap(); - let res2 = mixnodes_read(&storage).load(b"bond2").unwrap(); - assert_eq!(bond1, res1); - assert_eq!(bond2, res2); - } - - #[test] - fn gateway_single_read_retrieval() { - let mut storage = MockStorage::new(); - let bond1 = gateway_bond_fixture(); - let bond2 = gateway_bond_fixture(); - gateways(&mut storage).save(b"bond1", &bond1).unwrap(); - gateways(&mut storage).save(b"bond2", &bond2).unwrap(); - - let res1 = gateways_read(&storage).load(b"bond1").unwrap(); - let res2 = gateways_read(&storage).load(b"bond2").unwrap(); - assert_eq!(bond1, res1); - assert_eq!(bond2, res2); - } -} diff --git a/contracts/mixnet/src/storage.rs b/contracts/mixnet/src/storage.rs new file mode 100644 index 0000000000..bdfea3e32a --- /dev/null +++ b/contracts/mixnet/src/storage.rs @@ -0,0 +1,282 @@ +use crate::state::{State, StateParams}; +use cosmwasm_std::{Decimal, StdError, StdResult, Storage}; +use cosmwasm_storage::{ + bucket, bucket_read, singleton, singleton_read, Bucket, ReadonlyBucket, ReadonlySingleton, + Singleton, +}; +use mixnet_contract::{GatewayBond, MixNodeBond}; + +// Contract-level stuff +const CONFIG_KEY: &[u8] = b"config"; + +pub fn config(storage: &mut dyn Storage) -> Singleton { + singleton(storage, CONFIG_KEY) +} + +pub fn config_read(storage: &dyn Storage) -> ReadonlySingleton { + singleton_read(storage, CONFIG_KEY) +} + +pub(crate) fn read_state_params(storage: &dyn Storage) -> StateParams { + // note: In any other case, I wouldn't have attempted to unwrap this result, but in here + // if we fail to load the stored state we would already be in the undefined behaviour land, + // so we better just blow up immediately. + config_read(storage).load().unwrap().params +} + +pub(crate) fn read_mixnode_epoch_reward_rate(storage: &dyn Storage) -> Decimal { + // same justification as in `read_state_params` for the unwrap + config_read(storage) + .load() + .unwrap() + .mixnode_epoch_bond_reward +} + +pub(crate) fn read_gateway_epoch_reward_rate(storage: &dyn Storage) -> Decimal { + // same justification as in `read_state_params` for the unwrap + config_read(storage) + .load() + .unwrap() + .gateway_epoch_bond_reward +} + +// Mixnode-related stuff +const PREFIX_MIXNODES: &[u8] = b"mixnodes"; + +pub fn mixnodes(storage: &mut dyn Storage) -> Bucket { + bucket(storage, PREFIX_MIXNODES) +} + +pub fn mixnodes_read(storage: &dyn Storage) -> ReadonlyBucket { + bucket_read(storage, PREFIX_MIXNODES) +} + +// helpers +pub(crate) fn increase_mixnode_bond( + storage: &mut dyn Storage, + owner: &[u8], + scaled_reward_rate: Decimal, +) -> StdResult<()> { + let mut bucket = mixnodes(storage); + let mut node = bucket.load(owner)?; + if node.amount.len() != 1 { + return Err(StdError::generic_err( + "mixnode seems to have been bonded with multiple coin types", + )); + } + + let reward = node.amount[0].amount * scaled_reward_rate; + node.amount[0].amount += reward; + bucket.save(owner, &node) +} + +// currently not used outside tests +#[cfg(test)] +pub(crate) fn read_mixnode_bond( + storage: &dyn Storage, + owner: &[u8], +) -> StdResult { + let bucket = mixnodes_read(storage); + let node = bucket.load(owner)?; + if node.amount.len() != 1 { + return Err(StdError::generic_err( + "mixnode seems to have been bonded with multiple coin types", + )); + } + Ok(node.amount[0].amount) +} + +// Gateway-related stuff + +const PREFIX_GATEWAYS: &[u8] = b"gateways"; + +pub fn gateways(storage: &mut dyn Storage) -> Bucket { + bucket(storage, PREFIX_GATEWAYS) +} + +pub fn gateways_read(storage: &dyn Storage) -> ReadonlyBucket { + bucket_read(storage, PREFIX_GATEWAYS) +} + +// helpers +pub(crate) fn increase_gateway_bond( + storage: &mut dyn Storage, + owner: &[u8], + scaled_reward_rate: Decimal, +) -> StdResult<()> { + let mut bucket = gateways(storage); + let mut node = bucket.load(owner)?; + if node.amount.len() != 1 { + return Err(StdError::generic_err( + "gateway seems to have been bonded with multiple coin types", + )); + } + let reward = node.amount[0].amount * scaled_reward_rate; + node.amount[0].amount += reward; + bucket.save(owner, &node) +} + +// currently not used outside tests +#[cfg(test)] +pub(crate) fn read_gateway_bond( + storage: &dyn Storage, + owner: &[u8], +) -> StdResult { + let bucket = gateways_read(storage); + let node = bucket.load(owner)?; + if node.amount.len() != 1 { + return Err(StdError::generic_err( + "gateway seems to have been bonded with multiple coin types", + )); + } + Ok(node.amount[0].amount) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract::DENOM; + use crate::support::tests::helpers::{ + gateway_bond_fixture, gateway_fixture, mix_node_fixture, mixnode_bond_fixture, + }; + use cosmwasm_std::testing::MockStorage; + use cosmwasm_std::{coins, Uint128}; + + #[test] + fn mixnode_single_read_retrieval() { + let mut storage = MockStorage::new(); + let bond1 = mixnode_bond_fixture(); + let bond2 = mixnode_bond_fixture(); + mixnodes(&mut storage).save(b"bond1", &bond1).unwrap(); + mixnodes(&mut storage).save(b"bond2", &bond2).unwrap(); + + let res1 = mixnodes_read(&storage).load(b"bond1").unwrap(); + let res2 = mixnodes_read(&storage).load(b"bond2").unwrap(); + assert_eq!(bond1, res1); + assert_eq!(bond2, res2); + } + + #[test] + fn gateway_single_read_retrieval() { + let mut storage = MockStorage::new(); + let bond1 = gateway_bond_fixture(); + let bond2 = gateway_bond_fixture(); + gateways(&mut storage).save(b"bond1", &bond1).unwrap(); + gateways(&mut storage).save(b"bond2", &bond2).unwrap(); + + let res1 = gateways_read(&storage).load(b"bond1").unwrap(); + let res2 = gateways_read(&storage).load(b"bond2").unwrap(); + assert_eq!(bond1, res1); + assert_eq!(bond2, res2); + } + + #[test] + fn increasing_mixnode_bond() { + let mut storage = MockStorage::new(); + let node_owner = b"owner"; + // 0.001 + let reward = Decimal::from_ratio(1u128, 1000u128); + + // produces an error if target mixnode doesn't exist + let res = increase_mixnode_bond(&mut storage, node_owner, reward); + assert!(res.is_err()); + + // increases the reward appropriately if node exists + let mixnode_bond = MixNodeBond { + amount: coins(1000, DENOM), + owner: std::str::from_utf8(node_owner).unwrap().into(), + mix_node: mix_node_fixture(), + }; + + mixnodes(&mut storage) + .save(node_owner, &mixnode_bond) + .unwrap(); + + increase_mixnode_bond(&mut storage, node_owner, reward).unwrap(); + let new_bond = read_mixnode_bond(&storage, node_owner).unwrap(); + assert_eq!(Uint128(1001), new_bond); + } + + #[test] + fn reading_mixnode_bond() { + let mut storage = MockStorage::new(); + let node_owner = b"owner"; + + // produces an error if target mixnode doesn't exist + let res = read_mixnode_bond(&storage, node_owner); + assert!(res.is_err()); + + // returns appropriate value otherwise + let bond_value = 1000; + + let mixnode_bond = MixNodeBond { + amount: coins(bond_value, DENOM), + owner: std::str::from_utf8(node_owner).unwrap().into(), + mix_node: mix_node_fixture(), + }; + + mixnodes(&mut storage) + .save(node_owner, &mixnode_bond) + .unwrap(); + + assert_eq!( + Uint128(bond_value), + read_mixnode_bond(&storage, node_owner).unwrap() + ); + } + + #[test] + fn increasing_gateway_bond() { + let mut storage = MockStorage::new(); + let node_owner = b"owner"; + // 0.001 + let reward = Decimal::from_ratio(1u128, 1000u128); + + // produces an error if target gateway doesn't exist + let res = increase_gateway_bond(&mut storage, node_owner, reward); + assert!(res.is_err()); + + // increases the reward appropriately if node exists + let gateway_bond = GatewayBond { + amount: coins(1000, DENOM), + owner: std::str::from_utf8(node_owner).unwrap().into(), + gateway: gateway_fixture(), + }; + + gateways(&mut storage) + .save(node_owner, &gateway_bond) + .unwrap(); + + increase_gateway_bond(&mut storage, node_owner, reward).unwrap(); + let new_bond = read_gateway_bond(&storage, node_owner).unwrap(); + assert_eq!(Uint128(1001), new_bond); + } + + #[test] + fn reading_gateway_bond() { + let mut storage = MockStorage::new(); + let node_owner = b"owner"; + + // produces an error if target mixnode doesn't exist + let res = read_gateway_bond(&storage, node_owner); + assert!(res.is_err()); + + // returns appropriate value otherwise + let bond_value = 1000; + + let gateway_bond = GatewayBond { + amount: coins(1000, DENOM), + owner: std::str::from_utf8(node_owner).unwrap().into(), + gateway: gateway_fixture(), + }; + + gateways(&mut storage) + .save(node_owner, &gateway_bond) + .unwrap(); + + assert_eq!( + Uint128(bond_value), + read_gateway_bond(&storage, node_owner).unwrap() + ); + } +} diff --git a/contracts/mixnet/src/transactions.rs b/contracts/mixnet/src/transactions.rs index a2b76ece36..0d968c85fc 100644 --- a/contracts/mixnet/src/transactions.rs +++ b/contracts/mixnet/src/transactions.rs @@ -1,10 +1,15 @@ use crate::contract::DENOM; use crate::error::ContractError; -use crate::queries::query_state_params; -use crate::state::{ - config, config_read, gateways, gateways_read, mixnodes, mixnodes_read, StateParams, +use crate::helpers::scale_reward_by_uptime; +use crate::state::StateParams; +use crate::storage::{ + config, config_read, gateways, gateways_read, increase_gateway_bond, increase_mixnode_bond, + mixnodes, mixnodes_read, read_gateway_epoch_reward_rate, read_mixnode_epoch_reward_rate, + read_state_params, +}; +use cosmwasm_std::{ + attr, BankMsg, Coin, Decimal, DepsMut, Env, HandleResponse, HumanAddr, MessageInfo, Uint128, }; -use cosmwasm_std::{attr, BankMsg, Coin, DepsMut, Env, HandleResponse, MessageInfo, Uint128}; use mixnet_contract::{Gateway, GatewayBond, MixNode, MixNodeBond}; fn validate_mixnode_bond(bond: &[Coin], minimum_bond: Uint128) -> Result<(), ContractError> { @@ -14,7 +19,7 @@ fn validate_mixnode_bond(bond: &[Coin], minimum_bond: Uint128) -> Result<(), Con } if bond.len() > 1 { - // TODO: ask DH what would be an appropriate action here + return Err(ContractError::MultipleDenoms); } // check that the denomination is correct @@ -46,7 +51,7 @@ pub(crate) fn try_add_mixnode( return Err(ContractError::AlreadyOwnsGateway); } - let minimum_bond = query_state_params(deps.as_ref()).minimum_mixnode_bond; + let minimum_bond = read_state_params(deps.storage).minimum_mixnode_bond; validate_mixnode_bond(&info.sent_funds, minimum_bond)?; let bond = MixNodeBond::new(info.sent_funds, info.sender.clone(), mix_node); @@ -56,7 +61,6 @@ pub(crate) fn try_add_mixnode( .may_load(sender_bytes)? .is_some(); - // TODO: do attributes also go back to the client or does this need to be put into `data`? let attributes = vec![attr("overwritten", was_present)]; mixnodes(deps.storage).save(sender_bytes, &bond)?; @@ -106,7 +110,7 @@ fn validate_gateway_bond(bond: &[Coin], minimum_bond: Uint128) -> Result<(), Con } if bond.len() > 1 { - // TODO: ask DH what would be an appropriate action here + return Err(ContractError::MultipleDenoms); } // check that the denomination is correct @@ -138,7 +142,7 @@ pub(crate) fn try_add_gateway( return Err(ContractError::AlreadyOwnsMixnode); } - let minimum_bond = query_state_params(deps.as_ref()).minimum_gateway_bond; + let minimum_bond = read_state_params(deps.storage).minimum_gateway_bond; validate_gateway_bond(&info.sent_funds, minimum_bond)?; let bond = GatewayBond::new(info.sent_funds, info.sender.clone(), gateway); @@ -148,7 +152,6 @@ pub(crate) fn try_add_gateway( .may_load(sender_bytes)? .is_some(); - // TODO: do attributes also go back to the client or does this need to be put into `data`? let attributes = vec![attr("overwritten", was_present)]; gateways(deps.storage).save(sender_bytes, &bond)?; @@ -216,18 +219,71 @@ pub(crate) fn try_update_state_params( return Err(ContractError::Unauthorized); } + if params.mixnode_bond_reward_rate < Decimal::one() { + return Err(ContractError::DecreasingMixnodeBondReward); + } + + if params.gateway_bond_reward_rate < Decimal::one() { + return Err(ContractError::DecreasingGatewayBondReward); + } + state.params = params; config(deps.storage).save(&state)?; Ok(HandleResponse::default()) } +pub(crate) fn try_reward_mixnode( + deps: DepsMut, + info: MessageInfo, + node_owner: HumanAddr, + uptime: u32, +) -> Result { + let state = config_read(deps.storage).load().unwrap(); + + // check if this is executed by the monitor, if not reject the transaction + if info.sender != state.network_monitor_address { + return Err(ContractError::Unauthorized); + } + + let reward = read_mixnode_epoch_reward_rate(deps.storage); + let scaled_reward = scale_reward_by_uptime(reward, uptime)?; + + increase_mixnode_bond(deps.storage, node_owner.as_bytes(), scaled_reward)?; + + Ok(HandleResponse::default()) +} + +pub(crate) fn try_reward_gateway( + deps: DepsMut, + info: MessageInfo, + gateway_owner: HumanAddr, + uptime: u32, +) -> Result { + let state = config_read(deps.storage).load().unwrap(); + + // check if this is executed by the owner, if not reject the transaction + if info.sender != state.network_monitor_address { + return Err(ContractError::Unauthorized); + } + + let reward = read_gateway_epoch_reward_rate(deps.storage); + let scaled_reward = scale_reward_by_uptime(reward, uptime)?; + + increase_gateway_bond(deps.storage, gateway_owner.as_bytes(), scaled_reward)?; + + Ok(HandleResponse::default()) +} + #[cfg(test)] pub mod tests { use super::*; use crate::contract::{handle, init, query, INITIAL_GATEWAY_BOND, INITIAL_MIXNODE_BOND}; use crate::msg::{HandleMsg, InitMsg, QueryMsg}; + use crate::state::StateParams; + use crate::storage::{read_gateway_bond, read_gateway_epoch_reward_rate, read_mixnode_bond}; use crate::support::tests::helpers; + use crate::support::tests::helpers::{gateway_fixture, mix_node_fixture}; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; use cosmwasm_std::{coins, from_binary, Uint128}; use mixnet_contract::{PagedGatewayResponse, PagedResponse}; @@ -704,6 +760,7 @@ pub mod tests { let mut deps = helpers::init_contract(); let new_params = StateParams { + epoch_length: 1, minimum_mixnode_bond: 123u128.into(), minimum_gateway_bond: 456u128.into(), mixnode_bond_reward_rate: "1.23".parse().unwrap(), @@ -725,4 +782,112 @@ pub mod tests { let current_state = config_read(deps.as_ref().storage).load().unwrap(); assert_eq!(current_state.params, new_params) } + + #[test] + fn rewarding_mixnode() { + let mut deps = helpers::init_contract(); + let current_state = config(deps.as_mut().storage).load().unwrap(); + let network_monitor_address = current_state.network_monitor_address; + + // errors out if executed by somebody else than network monitor + let info = mock_info("not-the-monitor", &[]); + let res = try_reward_mixnode(deps.as_mut(), info, "node-owner".into(), 100); + assert_eq!(res, Err(ContractError::Unauthorized)); + + // errors out if the target owner hasn't bound any mixnodes + let info = mock_info(network_monitor_address.clone(), &[]); + let res = try_reward_mixnode(deps.as_mut(), info, "node-owner".into(), 100); + assert!(res.is_err()); + + let initial_bond = 100_000000; + let mixnode_bond = MixNodeBond { + amount: coins(initial_bond, DENOM), + owner: "node-owner".into(), + mix_node: mix_node_fixture(), + }; + + mixnodes(deps.as_mut().storage) + .save(b"node-owner", &mixnode_bond) + .unwrap(); + + let reward = read_mixnode_epoch_reward_rate(deps.as_ref().storage); + + // the node's bond is correctly increased and scaled by uptime + // if node was 100% up, it will get full epoch reward + let expected_bond = Uint128(initial_bond) * reward + Uint128(initial_bond); + + let info = mock_info(network_monitor_address.clone(), &[]); + try_reward_mixnode(deps.as_mut(), info, "node-owner".into(), 100).unwrap(); + + assert_eq!( + expected_bond, + read_mixnode_bond(deps.as_ref().storage, b"node-owner").unwrap() + ); + + // if node was 20% up, it will get 1/5th of epoch reward + let scaled_reward = scale_reward_by_uptime(reward, 20).unwrap(); + let expected_bond = expected_bond * scaled_reward + expected_bond; + + let info = mock_info(network_monitor_address, &[]); + try_reward_mixnode(deps.as_mut(), info, "node-owner".into(), 20).unwrap(); + + assert_eq!( + expected_bond, + read_mixnode_bond(deps.as_ref().storage, b"node-owner").unwrap() + ); + } + + #[test] + fn rewarding_gateway() { + let mut deps = helpers::init_contract(); + let current_state = config(deps.as_mut().storage).load().unwrap(); + let network_monitor_address = current_state.network_monitor_address; + + // errors out if executed by somebody else than network monitor + let info = mock_info("not-the-monitor", &[]); + let res = try_reward_gateway(deps.as_mut(), info, "node-owner".into(), 100); + assert_eq!(res, Err(ContractError::Unauthorized)); + + // errors out if the target owner hasn't bound any mixnodes + let info = mock_info(network_monitor_address.clone(), &[]); + let res = try_reward_gateway(deps.as_mut(), info, "node-owner".into(), 100); + assert!(res.is_err()); + + let initial_bond = 100_000000; + let gateway_bond = GatewayBond { + amount: coins(initial_bond, DENOM), + owner: "node-owner".into(), + gateway: gateway_fixture(), + }; + + gateways(deps.as_mut().storage) + .save(b"node-owner", &gateway_bond) + .unwrap(); + + let reward = read_gateway_epoch_reward_rate(deps.as_ref().storage); + + // the node's bond is correctly increased and scaled by uptime + // if node was 100% up, it will get full epoch reward + let expected_bond = Uint128(initial_bond) * reward + Uint128(initial_bond); + + let info = mock_info(network_monitor_address.clone(), &[]); + try_reward_gateway(deps.as_mut(), info, "node-owner".into(), 100).unwrap(); + + assert_eq!( + expected_bond, + read_gateway_bond(deps.as_ref().storage, b"node-owner").unwrap() + ); + + // if node was 20% up, it will get 1/5th of epoch reward + let scaled_reward = scale_reward_by_uptime(reward, 20).unwrap(); + let expected_bond = expected_bond * scaled_reward + expected_bond; + + let info = mock_info(network_monitor_address, &[]); + try_reward_gateway(deps.as_mut(), info, "node-owner".into(), 20).unwrap(); + + assert_eq!( + expected_bond, + read_gateway_bond(deps.as_ref().storage, b"node-owner").unwrap() + ); + } }