diff --git a/Cargo.lock b/Cargo.lock index 43870e1..88da905 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -542,6 +542,7 @@ dependencies = [ "cw-storage-plus", "cw20", "cw20-base", + "schemars", "serde", "steak", "terra-cosmwasm", diff --git a/contracts/hub/Cargo.toml b/contracts/hub/Cargo.toml index e4ed1fc..6069ba1 100644 --- a/contracts/hub/Cargo.toml +++ b/contracts/hub/Cargo.toml @@ -20,5 +20,9 @@ cw-storage-plus = "0.9" steak = { path = "../../packages/steak" } terra-cosmwasm = "2.2" +# These two are only used in legacy code; to be removed later +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } + [dev-dependencies] serde = { version = "1.0.103", default-features = false, features = ["derive"] } diff --git a/contracts/hub/src/contract.rs b/contracts/hub/src/contract.rs index f8351f0..dffd89f 100644 --- a/contracts/hub/src/contract.rs +++ b/contracts/hub/src/contract.rs @@ -8,6 +8,7 @@ use terra_cosmwasm::TerraMsgWrapper; use steak::hub::{CallbackMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, ReceiveMsg}; use crate::helpers::{parse_received_fund, unwrap_reply}; +use crate::legacy::migrate_batches; use crate::state::State; use crate::{execute, queries}; @@ -59,6 +60,7 @@ pub fn execute( ExecuteMsg::AcceptOwnership {} => execute::accept_ownership(deps, info.sender), ExecuteMsg::Harvest {} => execute::harvest(deps, env), ExecuteMsg::Rebalance {} => execute::rebalance(deps, env), + ExecuteMsg::Reconcile {} => execute::reconcile(deps, env), ExecuteMsg::SubmitBatch {} => execute::submit_batch(deps, env), ExecuteMsg::Callback(callback_msg) => callback(deps, env, info, callback_msg), } @@ -159,6 +161,10 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { } #[entry_point] -pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> StdResult { - Ok(Response::new()) +pub fn migrate(deps: DepsMut, env: Env, _msg: MigrateMsg) -> StdResult> { + let event = migrate_batches(deps.storage)?; + + let res = execute::reconcile(deps, env)?; + + Ok(res.add_event(event)) } diff --git a/contracts/hub/src/execute.rs b/contracts/hub/src/execute.rs index eb695bd..f50cc93 100644 --- a/contracts/hub/src/execute.rs +++ b/contracts/hub/src/execute.rs @@ -14,7 +14,7 @@ use steak::hub::{Batch, CallbackMsg, ExecuteMsg, InstantiateMsg, PendingBatch, U use crate::helpers::{query_cw20_total_supply, query_delegations, query_delegation}; use crate::math::{ compute_mint_amount, compute_redelegations_for_rebalancing, compute_redelegations_for_removal, - compute_unbond_amount, compute_undelegations, + compute_unbond_amount, compute_undelegations, reconcile_batches, }; use crate::state::State; use crate::types::{Coins, Delegation}; @@ -410,6 +410,7 @@ pub fn submit_batch(deps: DepsMut, env: Env) -> StdResult StdResult StdResult> { + let state = State::default(); + let current_time = env.block.time.seconds(); + + // Load batches that have not been reconciled + let all_batches = state + .previous_batches + .idx + .reconciled + .prefix(false.into()) + .range(deps.storage, None, None, Order::Ascending) + .map(|item| { + let (_, v) = item?; + Ok(v) + }) + .collect::>>()?; + + let mut batches = all_batches + .into_iter() + .filter(|b| current_time > b.est_unbond_end_time) + .collect::>(); + + let uluna_expected_received: Uint128 = batches + .iter() + .map(|b| b.uluna_unclaimed) + .sum(); + + if uluna_expected_received.is_zero() { + return Ok(Response::new()); + } + + let unlocked_coins = state.unlocked_coins.load(deps.storage)?; + let uluna_expected_unlocked = Coins(unlocked_coins).find("uluna").amount; + + let uluna_expected = uluna_expected_received + uluna_expected_unlocked; + let uluna_actual = deps.querier.query_balance(&env.contract.address, "uluna")?.amount; + + if uluna_actual >= uluna_expected { + return Ok(Response::new()); + } + + let uluna_to_deduct = uluna_expected - uluna_actual; + + reconcile_batches(&mut batches, uluna_to_deduct); + + for batch in &batches { + state.previous_batches.save(deps.storage, batch.id.into(), batch)?; + } + + let ids = batches + .iter() + .map(|b| b.id.to_string()) + .collect::>() + .join(","); + + let event = Event::new("steakhub/reconciled") + .add_attribute("ids", ids) + .add_attribute("uluna_deducted", uluna_to_deduct.to_string()); + + Ok(Response::new() + .add_event(event) + .add_attribute("action", "steakhub/reconcile")) +} + pub fn withdraw_unbonded( deps: DepsMut, env: Env, @@ -477,11 +542,17 @@ pub fn withdraw_unbonded( }) .collect::>>()?; + // NOTE: Luna in the following batches are withdrawn it the batch: + // - is a _previous_ batch, not a _pending_ batch + // - is reconciled + // - has finished unbonding + // If not sure whether the batches have been reconciled, the user should first invoke `ExecuteMsg::Reconcile` + // before withdrawing. let mut total_uluna_to_refund = Uint128::zero(); let mut ids: Vec = vec![]; for request in &requests { if let Ok(mut batch) = state.previous_batches.load(deps.storage, request.id.into()) { - if batch.est_unbond_end_time < current_time { + if batch.reconciled && batch.est_unbond_end_time < current_time { let uluna_to_refund = batch .uluna_unclaimed .multiply_ratio(request.shares, batch.total_shares); @@ -493,7 +564,7 @@ pub fn withdraw_unbonded( batch.uluna_unclaimed -= uluna_to_refund; if batch.total_shares.is_zero() { - state.previous_batches.remove(deps.storage, request.id.into()); + state.previous_batches.remove(deps.storage, request.id.into())?; } else { state.previous_batches.save(deps.storage, batch.id.into(), &batch)?; } @@ -543,8 +614,14 @@ pub fn rebalance(deps: DepsMut, env: Env) -> StdResult .map(|rd| SubMsg::reply_on_success(rd.to_cosmos_msg(), 2)) .collect::>(); + let amount: u128 = new_redelegations.iter().map(|rd| rd.amount).sum(); + + let event = Event::new("steakhub/rebalanced") + .add_attribute("uluna_moved", amount.to_string()); + Ok(Response::new() .add_submessages(redelegate_submsgs) + .add_event(event) .add_attribute("action", "steakhub/rebalance")) } diff --git a/contracts/hub/src/legacy.rs b/contracts/hub/src/legacy.rs new file mode 100644 index 0000000..ba4b8e5 --- /dev/null +++ b/contracts/hub/src/legacy.rs @@ -0,0 +1,129 @@ +use cosmwasm_std::{Storage, Order, StdResult, Uint128, Event}; +use cw_storage_plus::{Map, U64Key}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use steak::hub::Batch; + +use crate::state::State; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct LegacyBatch { + pub id: u64, + pub total_shares: Uint128, + pub uluna_unclaimed: Uint128, + pub est_unbond_end_time: u64, +} + +const LEGACY_BATCHES: Map = Map::new("previous_batches"); + +pub(crate) fn migrate_batches(storage: &mut dyn Storage) -> StdResult { + let state = State::default(); + + // Find all previous batches + let legacy_batches = LEGACY_BATCHES + .range(storage, None, None, Order::Ascending) + .map(|item| { + let (_, v) = item?; + Ok(v) + }) + .collect::>>()?; + + // Cast legacy data to the new type + let batches = legacy_batches + .iter() + .map(|lb| Batch { + id: lb.id, + reconciled: false, + total_shares: lb.total_shares, + uluna_unclaimed: lb.uluna_unclaimed, + est_unbond_end_time: lb.est_unbond_end_time, + }) + .collect::>(); + + // Delete the legacy data + legacy_batches + .iter() + .for_each(|lb| { + LEGACY_BATCHES.remove(storage, lb.id.into()) + }); + + // Save the new type data + // We use unwrap here, which is undesired, but it's ok with me since this code will only be in + // the contract temporarily + batches + .iter() + .for_each(|b| { + state.previous_batches.save(storage, b.id.into(), b).unwrap() + }); + + let ids = batches.iter().map(|b| b.id.to_string()).collect::>(); + + Ok(Event::new("steakhub/batches_migrated") + .add_attribute("ids", ids.join(","))) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::testing::mock_dependencies; + + use super::*; + use crate::queries; + + #[test] + fn migrating_batches() { + let mut deps = mock_dependencies(&[]); + + let legacy_batches = vec![ + LegacyBatch { + id: 1, + total_shares: Uint128::new(123), + uluna_unclaimed: Uint128::new(678), + est_unbond_end_time: 10000, + }, + LegacyBatch { + id: 2, + total_shares: Uint128::new(234), + uluna_unclaimed: Uint128::new(789), + est_unbond_end_time: 15000, + }, + LegacyBatch { + id: 3, + total_shares: Uint128::new(345), + uluna_unclaimed: Uint128::new(890), + est_unbond_end_time: 20000, + }, + LegacyBatch { + id: 4, + total_shares: Uint128::new(456), + uluna_unclaimed: Uint128::new(999), + est_unbond_end_time: 25000, + }, + ]; + + for legacy_batch in &legacy_batches { + LEGACY_BATCHES.save(deps.as_mut().storage, legacy_batch.id.into(), legacy_batch).unwrap(); + } + + let event = migrate_batches(deps.as_mut().storage).unwrap(); + assert_eq!( + event, + Event::new("steakhub/batches_migrated").add_attribute("ids", "1,2,3,4") + ); + + let batches = queries::previous_batches(deps.as_ref(), None, None).unwrap(); + + let expected = legacy_batches + .iter() + .map(|lb| Batch { + id: lb.id, + reconciled: false, + total_shares: lb.total_shares, + uluna_unclaimed: lb.uluna_unclaimed, + est_unbond_end_time: lb.est_unbond_end_time, + }) + .collect::>(); + + assert_eq!(batches, expected); + } +} \ No newline at end of file diff --git a/contracts/hub/src/lib.rs b/contracts/hub/src/lib.rs index fa16077..0b1bdc4 100644 --- a/contracts/hub/src/lib.rs +++ b/contracts/hub/src/lib.rs @@ -10,3 +10,6 @@ pub mod types; #[cfg(test)] mod testing; + +// Legacy code; only used in migrations +mod legacy; diff --git a/contracts/hub/src/math.rs b/contracts/hub/src/math.rs index 80d6b88..83c29a5 100644 --- a/contracts/hub/src/math.rs +++ b/contracts/hub/src/math.rs @@ -2,6 +2,8 @@ use std::{cmp, cmp::Ordering}; use cosmwasm_std::Uint128; +use steak::hub::Batch; + use crate::types::{Delegation, Redelegation, Undelegation}; //-------------------------------------------------------------------------------------------------- @@ -192,3 +194,27 @@ pub(crate) fn compute_redelegations_for_rebalancing( new_redelegations } + +//-------------------------------------------------------------------------------------------------- +// Batch logics +//-------------------------------------------------------------------------------------------------- + +/// If the received uluna amount after the unbonding period is less than expected, e.g. due to rounding +/// error or the validator(s) being slashed, then deduct the difference in amount evenly from each +/// unreconciled batch. +/// +/// The idea of "reconciling" is based on Stader's implementation: +/// https://github.com/stader-labs/stader-liquid-token/blob/v0.2.1/contracts/staking/src/contract.rs#L968-L1048 +pub(crate) fn reconcile_batches(batches: &mut [Batch], uluna_to_deduct: Uint128) { + let batch_count = batches.len() as u128; + let uluna_per_batch = uluna_to_deduct.u128() / batch_count; + let remainder = uluna_to_deduct.u128() % batch_count; + + for (i, batch) in batches.iter_mut().enumerate() { + let remainder_for_batch: u128 = if (i + 1) as u128 <= remainder { 1 } else { 0 }; + let uluna_for_batch = uluna_per_batch + remainder_for_batch; + + batch.uluna_unclaimed -= Uint128::new(uluna_for_batch); + batch.reconciled = true; + } +} diff --git a/contracts/hub/src/state.rs b/contracts/hub/src/state.rs index 9bec62a..a7bfc66 100644 --- a/contracts/hub/src/state.rs +++ b/contracts/hub/src/state.rs @@ -1,8 +1,10 @@ use cosmwasm_std::{Addr, Coin, Storage, StdError, StdResult}; -use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, MultiIndex, U64Key}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex, U64Key}; use steak::hub::{Batch, PendingBatch, UnbondRequest}; +use crate::types::BooleanKey; + pub(crate) struct State<'a> { /// Account who can call certain privileged functions pub owner: Item<'a, Addr>, @@ -21,14 +23,21 @@ pub(crate) struct State<'a> { /// The current batch of unbonding requests queded to be executed pub pending_batch: Item<'a, PendingBatch>, /// Previous batches that have started unbonding but not yet finished - pub previous_batches: Map<'a, U64Key, Batch>, + pub previous_batches: IndexedMap<'a, U64Key, Batch, PreviousBatchesIndexes<'a>>, /// Users' shares in unbonding batches pub unbond_requests: IndexedMap<'a, (U64Key, &'a Addr), UnbondRequest, UnbondRequestsIndexes<'a>>, } impl Default for State<'static> { fn default() -> Self { - let indexes = UnbondRequestsIndexes { + let pb_indexes = PreviousBatchesIndexes { + reconciled: MultiIndex::new( + |d: &Batch, k: Vec| (d.reconciled.into(), k), + "previous_batches", + "previous_batches__reconciled", + ), + }; + let ubr_indexes = UnbondRequestsIndexes { user: MultiIndex::new( |d: &UnbondRequest, k: Vec| (d.user.clone().into(), k), "unbond_requests", @@ -44,8 +53,8 @@ impl Default for State<'static> { validators: Item::new("validators"), unlocked_coins: Item::new("unlocked_coins"), pending_batch: Item::new("pending_batch"), - previous_batches: Map::new("previous_batches"), - unbond_requests: IndexedMap::new("unbond_requests", indexes), + previous_batches: IndexedMap::new("previous_batches", pb_indexes), + unbond_requests: IndexedMap::new("unbond_requests", ubr_indexes), } } } @@ -61,7 +70,20 @@ impl<'a> State<'a> { } } +pub(crate) struct PreviousBatchesIndexes<'a> { + // pk goes to second tuple element + pub reconciled: MultiIndex<'a, (BooleanKey, Vec), Batch>, +} + +impl<'a> IndexList for PreviousBatchesIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.reconciled]; + Box::new(v.into_iter()) + } +} + pub(crate) struct UnbondRequestsIndexes<'a> { + // pk goes to second tuple element pub user: MultiIndex<'a, (String, Vec), UnbondRequest>, } diff --git a/contracts/hub/src/testing/custom_querier.rs b/contracts/hub/src/testing/custom_querier.rs index 5688cb9..821b2b8 100644 --- a/contracts/hub/src/testing/custom_querier.rs +++ b/contracts/hub/src/testing/custom_querier.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use cosmwasm_std::testing::{StakingQuerier, MOCK_CONTRACT_ADDR}; +use cosmwasm_std::testing::{MOCK_CONTRACT_ADDR, BankQuerier, StakingQuerier}; use cosmwasm_std::{ from_binary, from_slice, Addr, Coin, Decimal, FullDelegation, Querier, QuerierResult, QueryRequest, SystemError, WasmQuery, @@ -12,12 +12,13 @@ use crate::types::Delegation; use super::cw20_querier::Cw20Querier; use super::helpers::err_unsupported_query; -use super::native_querier::NativeQuerier; +use super::terra_querier::TerraQuerier; #[derive(Default)] pub(super) struct CustomQuerier { pub cw20_querier: Cw20Querier, - pub native_querier: NativeQuerier, + pub terra_querier: TerraQuerier, + pub bank_querier: BankQuerier, pub staking_querier: StakingQuerier, } @@ -58,19 +59,23 @@ impl CustomQuerier { .insert(token.to_string(), total_supply); } - pub fn set_native_exchange_rate( + pub fn set_terra_exchange_rate( &mut self, base_denom: &str, quote_denom: &str, exchange_rate: Decimal, ) { - self.native_querier + self.terra_querier .exchange_rates .insert((base_denom.to_string(), quote_denom.to_string()), exchange_rate); } + pub fn set_bank_balances(&mut self, balances: &[Coin]) { + self.bank_querier = BankQuerier::new(&[(MOCK_CONTRACT_ADDR, balances)]) + } + pub fn set_staking_delegations(&mut self, delegations: &[Delegation]) { - let fds: Vec = delegations + let fds = delegations .iter() .map(|d| FullDelegation { delegator: Addr::unchecked(MOCK_CONTRACT_ADDR), @@ -79,7 +84,7 @@ impl CustomQuerier { can_redelegate: Coin::new(0, "uluna"), accumulated_rewards: vec![], }) - .collect(); + .collect::>(); self.staking_querier = StakingQuerier::new("uluna", &[], &fds); } @@ -100,7 +105,9 @@ impl CustomQuerier { QueryRequest::Custom(TerraQueryWrapper { route: _, query_data, - }) => self.native_querier.handle_query(query_data), + }) => self.terra_querier.handle_query(query_data), + + QueryRequest::Bank(query) => self.bank_querier.query(query), QueryRequest::Staking(query) => self.staking_querier.query(query), diff --git a/contracts/hub/src/testing/helpers.rs b/contracts/hub/src/testing/helpers.rs index a95d38b..1d88733 100644 --- a/contracts/hub/src/testing/helpers.rs +++ b/contracts/hub/src/testing/helpers.rs @@ -26,7 +26,7 @@ pub(super) fn mock_dependencies() -> OwnedDeps Env { +pub(super) fn mock_env_at_timestamp(timestamp: u64) -> Env { Env { block: BlockInfo { height: 12_345, diff --git a/contracts/hub/src/testing/mod.rs b/contracts/hub/src/testing/mod.rs index b821898..084c097 100644 --- a/contracts/hub/src/testing/mod.rs +++ b/contracts/hub/src/testing/mod.rs @@ -1,5 +1,5 @@ mod custom_querier; mod cw20_querier; mod helpers; -mod native_querier; +mod terra_querier; mod tests; diff --git a/contracts/hub/src/testing/native_querier.rs b/contracts/hub/src/testing/terra_querier.rs similarity index 98% rename from contracts/hub/src/testing/native_querier.rs rename to contracts/hub/src/testing/terra_querier.rs index 3a81018..47c79b5 100644 --- a/contracts/hub/src/testing/native_querier.rs +++ b/contracts/hub/src/testing/terra_querier.rs @@ -6,12 +6,12 @@ use terra_cosmwasm::{ExchangeRateItem, ExchangeRatesResponse, TerraQuery}; use super::helpers::err_unsupported_query; #[derive(Default)] -pub struct NativeQuerier { +pub struct TerraQuerier { /// Maps (base_denom, quote_denom) pair to exchange rate pub exchange_rates: HashMap<(String, String), Decimal>, } -impl NativeQuerier { +impl TerraQuerier { /// We only implement the `exchange_rates` query as that is the only one we need in the unit tests /// /// NOTE: When querying exchange rates, Terra's oracle module behaves in the following way: diff --git a/contracts/hub/src/testing/tests.rs b/contracts/hub/src/testing/tests.rs index d106055..99d3b6b 100644 --- a/contracts/hub/src/testing/tests.rs +++ b/contracts/hub/src/testing/tests.rs @@ -2,8 +2,8 @@ use std::str::FromStr; use cosmwasm_std::testing::{mock_env, mock_info, MockApi, MockStorage, MOCK_CONTRACT_ADDR}; use cosmwasm_std::{ - to_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, DistributionMsg, Event, OwnedDeps, Reply, - ReplyOn, StdError, SubMsg, SubMsgExecutionResponse, Uint128, WasmMsg, + to_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, DistributionMsg, Event, Order, OwnedDeps, + Reply, ReplyOn, StdError, SubMsg, SubMsgExecutionResponse, Uint128, WasmMsg, }; use cw20::{Cw20ExecuteMsg, MinterResponse}; use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; @@ -22,7 +22,7 @@ use crate::state::State; use crate::types::{Coins, Delegation, Redelegation, Undelegation}; use super::custom_querier::CustomQuerier; -use super::helpers::{mock_dependencies, mock_env_with_timestamp, query_helper}; +use super::helpers::{mock_dependencies, mock_env_at_timestamp, query_helper}; //-------------------------------------------------------------------------------------------------- // Test setup @@ -33,7 +33,7 @@ fn setup_test() -> OwnedDeps { let res = instantiate( deps.as_mut(), - mock_env_with_timestamp(10000), + mock_env_at_timestamp(10000), mock_info("deployer", &[]), InstantiateMsg { cw20_code_id: 69420, @@ -82,7 +82,7 @@ fn setup_test() -> OwnedDeps { let res = reply( deps.as_mut(), - mock_env_with_timestamp(10000), + mock_env_at_timestamp(10000), Reply { id: 1, result: cosmwasm_std::ContractResult::Ok(SubMsgExecutionResponse { @@ -394,17 +394,17 @@ fn swapping() { let state = State::default(); // Only denoms that has exchange rates defined in the oracle module can be swapped to Luna - deps.querier.set_native_exchange_rate( + deps.querier.set_terra_exchange_rate( "uluna", "ukrw", Decimal::from_str("129108.193653786399948012").unwrap(), ); - deps.querier.set_native_exchange_rate( + deps.querier.set_terra_exchange_rate( "uluna", "usdr", Decimal::from_str("77.056327779353129245").unwrap(), ); - deps.querier.set_native_exchange_rate( + deps.querier.set_terra_exchange_rate( "uluna", "uusd", Decimal::from_str("105.476484668836552061").unwrap(), @@ -544,7 +544,7 @@ fn queuing_unbond() { // request is saved, but not the pending batch is not submitted for unbonding let res = execute( deps.as_mut(), - mock_env_with_timestamp(12345), // est_unbond_start_time = 269200 + mock_env_at_timestamp(12345), // est_unbond_start_time = 269200 mock_info("steak_token", &[]), ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { sender: "user_1".to_string(), @@ -563,7 +563,7 @@ fn queuing_unbond() { // request is saved, and the pending is automatically submitted for unbonding let res = execute( deps.as_mut(), - mock_env_with_timestamp(269201), // est_unbond_start_time = 269200 + mock_env_at_timestamp(269201), // est_unbond_start_time = 269200 mock_info("steak_token", &[]), ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { sender: "user_2".to_string(), @@ -695,7 +695,7 @@ fn submitting_batch() { // Charlie: 345,781 - (314,049 + 0) = 31,732 let res = execute( deps.as_mut(), - mock_env_with_timestamp(269201), + mock_env_at_timestamp(269201), mock_info(MOCK_CONTRACT_ADDR, &[]), ExecuteMsg::SubmitBatch {}, ) @@ -748,6 +748,7 @@ fn submitting_batch() { previous_batch, Batch { id: 1, + reconciled: false, total_shares: Uint128::new(92876), uluna_unclaimed: Uint128::new(95197), est_unbond_end_time: 2083601 // 269,201 + 1,814,400 @@ -755,6 +756,113 @@ fn submitting_batch() { ); } +#[test] +fn reconciling() { + let mut deps = setup_test(); + let state = State::default(); + + let previous_batches = vec![ + Batch { + id: 1, + reconciled: true, + total_shares: Uint128::new(92876), + uluna_unclaimed: Uint128::new(95197), // 1.025 Luna per Steak + est_unbond_end_time: 10000, + }, + Batch { + id: 2, + reconciled: false, + total_shares: Uint128::new(1345), + uluna_unclaimed: Uint128::new(1385), // 1.030 Luna per Steak + est_unbond_end_time: 20000, + }, + Batch { + id: 3, + reconciled: false, + total_shares: Uint128::new(1456), + uluna_unclaimed: Uint128::new(1506), // 1.035 Luna per Steak + est_unbond_end_time: 30000, + }, + Batch { + id: 4, + reconciled: false, + total_shares: Uint128::new(1567), + uluna_unclaimed: Uint128::new(1629), // 1.040 Luna per Steak + est_unbond_end_time: 40000, // not yet finished unbonding, ignored + } + ]; + + for previous_batch in &previous_batches { + state + .previous_batches + .save(deps.as_mut().storage, previous_batch.id.into(), previous_batch) + .unwrap(); + } + + state.unlocked_coins.save(deps.as_mut().storage, &vec![ + Coin::new(10000, "uluna"), + Coin::new(234, "ukrw"), + Coin::new(345, "uusd"), + Coin::new(69420, "ibc/0471F1C4E7AFD3F07702BEF6DC365268D64570F7C1FDC98EA6098DD6DE59817B"), + ]) + .unwrap(); + + deps.querier.set_bank_balances(&[ + Coin::new(12345, "uluna"), + Coin::new(234, "ukrw"), + Coin::new(345, "uusd"), + Coin::new(69420, "ibc/0471F1C4E7AFD3F07702BEF6DC365268D64570F7C1FDC98EA6098DD6DE59817B"), + ]); + + execute( + deps.as_mut(), + mock_env_at_timestamp(35000), + mock_info("worker", &[]), + ExecuteMsg::Reconcile {} + ).unwrap(); + + // Expected received: batch 2 + batch 3 = 1385 + 1506 = 2891 + // Expected unlocked: 10000 + // Expected: 12891 + // Actual: 12345 + // Shortfall: 12891 - 12345 = 456 + // + // uluna per batch: 546 / 2 = 273 + // remainder: 0 + // batch 2: 1385 - 273 = 1112 + // batch 3: 1506 - 273 = 1233 + let batch = state.previous_batches.load(deps.as_ref().storage, 2u64.into()).unwrap(); + assert_eq!( + batch, + Batch { + id: 2, + reconciled: true, + total_shares: Uint128::new(1345), + uluna_unclaimed: Uint128::new(1112), // 1385 - 273 + est_unbond_end_time: 20000, + } + ); + + let batch = state.previous_batches.load(deps.as_ref().storage, 3u64.into()).unwrap(); + assert_eq!( + batch, + Batch { + id: 3, + reconciled: true, + total_shares: Uint128::new(1456), + uluna_unclaimed: Uint128::new(1233), // 1506 - 273 + est_unbond_end_time: 30000, + } + ); + + // Batches 1 and 4 should not have changed + let batch = state.previous_batches.load(deps.as_ref().storage, 1u64.into()).unwrap(); + assert_eq!(batch, previous_batches[0]); + + let batch = state.previous_batches.load(deps.as_ref().storage, 4u64.into()).unwrap(); + assert_eq!(batch, previous_batches[3]); +} + #[test] fn withdrawing_unbonded() { let mut deps = setup_test(); @@ -806,22 +914,32 @@ fn withdrawing_unbonded() { let previous_batches = vec![ Batch { id: 1, + reconciled: true, total_shares: Uint128::new(92876), uluna_unclaimed: Uint128::new(95197), // 1.025 Luna per Steak est_unbond_end_time: 10000, }, Batch { id: 2, + reconciled: true, total_shares: Uint128::new(34567), uluna_unclaimed: Uint128::new(35604), // 1.030 Luna per Steak est_unbond_end_time: 20000, }, Batch { id: 3, + reconciled: false, // finished unbonding, but not reconciled; ignored total_shares: Uint128::new(45678), uluna_unclaimed: Uint128::new(47276), // 1.035 Luna per Steak - est_unbond_end_time: 30000, + est_unbond_end_time: 20000, }, + Batch { + id: 4, + reconciled: true, + total_shares: Uint128::new(56789), + uluna_unclaimed: Uint128::new(59060), // 1.040 Luna per Steak + est_unbond_end_time: 30000, // reconciled, but not yet finished unbonding; ignored + } ]; for previous_batch in &previous_batches { @@ -846,7 +964,7 @@ fn withdrawing_unbonded() { // Attempt to withdraw before any batch has completed unbonding. Should error let err = execute( deps.as_mut(), - mock_env_with_timestamp(5000), + mock_env_at_timestamp(5000), mock_info("user_1", &[]), ExecuteMsg::WithdrawUnbonded { receiver: None, @@ -869,7 +987,7 @@ fn withdrawing_unbonded() { // Batch 2 is completely withdrawn, should be purged from storage let res = execute( deps.as_mut(), - mock_env_with_timestamp(25000), + mock_env_at_timestamp(25000), mock_info("user_1", &[]), ExecuteMsg::WithdrawUnbonded { receiver: None, @@ -891,12 +1009,13 @@ fn withdrawing_unbonded() { } ); - // Pending batches should have been updated + // Previous batches should have been updated let batch = state.previous_batches.load(deps.as_ref().storage, 1u64.into()).unwrap(); assert_eq!( batch, Batch { id: 1, + reconciled: true, total_shares: Uint128::new(69420), uluna_unclaimed: Uint128::new(71155), est_unbond_end_time: 10000, @@ -937,7 +1056,7 @@ fn withdrawing_unbonded() { // User 3 attempt to withdraw; also specifying a receiver let res = execute( deps.as_mut(), - mock_env_with_timestamp(25000), + mock_env_at_timestamp(25000), mock_info("user_3", &[]), ExecuteMsg::WithdrawUnbonded { receiver: Some("user_2".to_string()), @@ -1152,16 +1271,32 @@ fn querying_previous_batches() { let batches = vec![ Batch { id: 1, + reconciled: false, total_shares: Uint128::new(123), - uluna_unclaimed: Uint128::new(456), + uluna_unclaimed: Uint128::new(678), est_unbond_end_time: 10000, }, Batch { id: 2, - total_shares: Uint128::new(345), - uluna_unclaimed: Uint128::new(456), + reconciled: true, + total_shares: Uint128::new(234), + uluna_unclaimed: Uint128::new(789), est_unbond_end_time: 15000, }, + Batch { + id: 3, + reconciled: false, + total_shares: Uint128::new(345), + uluna_unclaimed: Uint128::new(890), + est_unbond_end_time: 20000, + }, + Batch { + id: 4, + reconciled: true, + total_shares: Uint128::new(456), + uluna_unclaimed: Uint128::new(999), + est_unbond_end_time: 25000, + }, ]; let state = State::default(); @@ -1169,12 +1304,14 @@ fn querying_previous_batches() { state.previous_batches.save(deps.as_mut().storage, batch.id.into(), batch).unwrap(); } + // Querying a single batch let res: Batch = query_helper(deps.as_ref(), QueryMsg::PreviousBatch(1)); assert_eq!(res, batches[0].clone()); let res: Batch = query_helper(deps.as_ref(), QueryMsg::PreviousBatch(2)); assert_eq!(res, batches[1].clone()); + // Query multiple batches let res: Vec = query_helper( deps.as_ref(), QueryMsg::PreviousBatches { @@ -1191,16 +1328,45 @@ fn querying_previous_batches() { limit: None, }, ); - assert_eq!(res, vec![batches[1].clone()]); + assert_eq!(res, vec![batches[1].clone(), batches[2].clone(), batches[3].clone()]); let res: Vec = query_helper( deps.as_ref(), QueryMsg::PreviousBatches { - start_after: Some(2), + start_after: Some(4), limit: None, }, ); assert_eq!(res, vec![]); + + // Query multiple batches, indexed by whether it has been reconciled + let res = state + .previous_batches + .idx + .reconciled + .prefix(true.into()) + .range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| { + let (_, v) = item.unwrap(); + v + }) + .collect::>(); + + assert_eq!(res, vec![batches[1].clone(), batches[3].clone()]); + + let res = state + .previous_batches + .idx + .reconciled + .prefix(false.into()) + .range(deps.as_ref().storage, None, None, Order::Ascending) + .map(|item| { + let (_, v) = item.unwrap(); + v + }) + .collect::>(); + + assert_eq!(res, vec![batches[0].clone(), batches[2].clone()]); } #[test] diff --git a/contracts/hub/src/types.rs b/contracts/hub/src/types.rs deleted file mode 100644 index 4ba2bab..0000000 --- a/contracts/hub/src/types.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::str::FromStr; - -use cosmwasm_std::{Coin, CosmosMsg, StakingMsg, StdError, StdResult}; -use terra_cosmwasm::TerraMsgWrapper; - -use crate::helpers::parse_coin; - -//-------------------------------------------------------------------------------------------------- -// Coins -//-------------------------------------------------------------------------------------------------- - -pub(crate) struct Coins(pub Vec); - -impl FromStr for Coins { - type Err = StdError; - - fn from_str(s: &str) -> Result { - if s.is_empty() { - return Ok(Self(vec![])); - } - - Ok(Self( - s.split(',') - .filter(|coin_str| !coin_str.is_empty()) // coin with zero amount may appeat as an empty string in the event log - .collect::>() - .iter() - .map(|s| parse_coin(s)) - .collect::>>()?, - )) - } -} - -impl Coins { - pub fn add(&mut self, coin_to_add: &Coin) -> StdResult<()> { - match self.0.iter_mut().find(|coin| coin.denom == coin_to_add.denom) { - Some(coin) => { - coin.amount = coin.amount.checked_add(coin_to_add.amount)?; - }, - None => { - self.0.push(coin_to_add.clone()); - }, - } - Ok(()) - } - - pub fn add_many(&mut self, coins_to_add: &Coins) -> StdResult<()> { - for coin_to_add in &coins_to_add.0 { - self.add(coin_to_add)?; - } - Ok(()) - } -} - -//-------------------------------------------------------------------------------------------------- -// Delegation -//-------------------------------------------------------------------------------------------------- - -#[derive(Clone)] -#[cfg_attr(test, derive(Debug, PartialEq))] -pub(crate) struct Delegation { - pub validator: String, - pub amount: u128, -} - -impl Delegation { - pub fn new(validator: &str, amount: u128) -> Self { - Self { - validator: validator.to_string(), - amount, - } - } - - pub fn to_cosmos_msg(&self) -> CosmosMsg { - CosmosMsg::Staking(StakingMsg::Delegate { - validator: self.validator.clone(), - amount: Coin::new(self.amount, "uluna"), - }) - } -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -pub(crate) struct Undelegation { - pub validator: String, - pub amount: u128, -} - -impl Undelegation { - pub fn new(validator: &str, amount: u128) -> Self { - Self { - validator: validator.to_string(), - amount, - } - } - - pub fn to_cosmos_msg(&self) -> CosmosMsg { - CosmosMsg::Staking(StakingMsg::Undelegate { - validator: self.validator.clone(), - amount: Coin::new(self.amount, "uluna"), - }) - } -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -pub(crate) struct Redelegation { - pub src: String, - pub dst: String, - pub amount: u128, -} - -impl Redelegation { - pub fn new(src: &str, dst: &str, amount: u128) -> Self { - Self { - src: src.to_string(), - dst: dst.to_string(), - amount, - } - } - - pub fn to_cosmos_msg(&self) -> CosmosMsg { - CosmosMsg::Staking(StakingMsg::Redelegate { - src_validator: self.src.clone(), - dst_validator: self.dst.clone(), - amount: Coin::new(self.amount, "uluna"), - }) - } -} diff --git a/contracts/hub/src/types/coins.rs b/contracts/hub/src/types/coins.rs new file mode 100644 index 0000000..073d86f --- /dev/null +++ b/contracts/hub/src/types/coins.rs @@ -0,0 +1,55 @@ +use std::str::FromStr; + +use cosmwasm_std::{Coin, StdError, StdResult}; + +use crate::helpers::parse_coin; + +pub struct Coins(pub Vec); + +impl FromStr for Coins { + type Err = StdError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Ok(Self(vec![])); + } + + Ok(Self( + s.split(',') + .filter(|coin_str| !coin_str.is_empty()) // coin with zero amount may appeat as an empty string in the event log + .collect::>() + .iter() + .map(|s| parse_coin(s)) + .collect::>>()?, + )) + } +} + +impl Coins { + pub fn add(&mut self, coin_to_add: &Coin) -> StdResult<()> { + match self.0.iter_mut().find(|coin| coin.denom == coin_to_add.denom) { + Some(coin) => { + coin.amount = coin.amount.checked_add(coin_to_add.amount)?; + }, + None => { + self.0.push(coin_to_add.clone()); + }, + } + Ok(()) + } + + pub fn add_many(&mut self, coins_to_add: &Coins) -> StdResult<()> { + for coin_to_add in &coins_to_add.0 { + self.add(coin_to_add)?; + } + Ok(()) + } + + pub fn find(&self, denom: &str) -> Coin { + self.0 + .iter() + .cloned() + .find(|coin| coin.denom == denom) + .unwrap_or_else(|| Coin::new(0, denom)) + } +} diff --git a/contracts/hub/src/types/keys.rs b/contracts/hub/src/types/keys.rs new file mode 100644 index 0000000..a99080c --- /dev/null +++ b/contracts/hub/src/types/keys.rs @@ -0,0 +1,43 @@ +use std::marker::PhantomData; + +use cw_storage_plus::{Prefixer, PrimaryKey}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BooleanKey { + pub wrapped: Vec, + pub data: PhantomData, +} + +impl BooleanKey { + pub fn new(val: bool) -> Self { + BooleanKey { + wrapped: if val { + vec![1] + } else { + vec![0] + }, + data: PhantomData, + } + } +} + +impl From for BooleanKey { + fn from(val: bool) -> Self { + Self::new(val) + } +} + +impl<'a> PrimaryKey<'a> for BooleanKey { + type Prefix = (); + type SubPrefix = (); + + fn key(&self) -> Vec<&[u8]> { + self.wrapped.key() + } +} + +impl<'a> Prefixer<'a> for BooleanKey { + fn prefix(&self) -> Vec<&[u8]> { + self.wrapped.prefix() + } +} diff --git a/contracts/hub/src/types/mod.rs b/contracts/hub/src/types/mod.rs new file mode 100644 index 0000000..238b36a --- /dev/null +++ b/contracts/hub/src/types/mod.rs @@ -0,0 +1,7 @@ +mod coins; +mod keys; +mod staking; + +pub use coins::Coins; +pub use keys::BooleanKey; +pub use staking::{Delegation, Redelegation, Undelegation}; diff --git a/contracts/hub/src/types/staking.rs b/contracts/hub/src/types/staking.rs new file mode 100644 index 0000000..cad39c9 --- /dev/null +++ b/contracts/hub/src/types/staking.rs @@ -0,0 +1,72 @@ +use cosmwasm_std::{Coin, CosmosMsg, StakingMsg}; +use terra_cosmwasm::TerraMsgWrapper; + +#[derive(Clone)] +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct Delegation { + pub validator: String, + pub amount: u128, +} + +impl Delegation { + pub fn new(validator: &str, amount: u128) -> Self { + Self { + validator: validator.to_string(), + amount, + } + } + + pub fn to_cosmos_msg(&self) -> CosmosMsg { + CosmosMsg::Staking(StakingMsg::Delegate { + validator: self.validator.clone(), + amount: Coin::new(self.amount, "uluna"), + }) + } +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct Undelegation { + pub validator: String, + pub amount: u128, +} + +impl Undelegation { + pub fn new(validator: &str, amount: u128) -> Self { + Self { + validator: validator.to_string(), + amount, + } + } + + pub fn to_cosmos_msg(&self) -> CosmosMsg { + CosmosMsg::Staking(StakingMsg::Undelegate { + validator: self.validator.clone(), + amount: Coin::new(self.amount, "uluna"), + }) + } +} + +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct Redelegation { + pub src: String, + pub dst: String, + pub amount: u128, +} + +impl Redelegation { + pub fn new(src: &str, dst: &str, amount: u128) -> Self { + Self { + src: src.to_string(), + dst: dst.to_string(), + amount, + } + } + + pub fn to_cosmos_msg(&self) -> CosmosMsg { + CosmosMsg::Staking(StakingMsg::Redelegate { + src_validator: self.src.clone(), + dst_validator: self.dst.clone(), + amount: Coin::new(self.amount, "uluna"), + }) + } +} diff --git a/packages/steak/src/hub.rs b/packages/steak/src/hub.rs index 9775864..91a84c7 100644 --- a/packages/steak/src/hub.rs +++ b/packages/steak/src/hub.rs @@ -55,6 +55,8 @@ pub enum ExecuteMsg { Harvest {}, /// Use redelegations to balance the amounts of Luna delegated to validators Rebalance {}, + /// Update Luna amounts in unbonding batches to reflect any slashing or rounding errors + Reconcile {}, /// Submit the current pending batch of unbonding requests to be unbonded SubmitBatch {}, /// Callbacks; can only be invoked by the contract itself @@ -164,6 +166,8 @@ pub struct PendingBatch { pub struct Batch { /// ID of this batch pub id: u64, + /// Whether this batch has already been reconciled + pub reconciled: bool, /// Total amount of shares remaining this batch. Each `usteak` burned = 1 share pub total_shares: Uint128, /// Amount of `uluna` in this batch that have not been claimed