diff --git a/Cargo.lock b/Cargo.lock index b2f0dab3..d7b714d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,6 +610,8 @@ dependencies = [ "mesh-bindings", "mesh-burn", "mesh-simple-price-feed", + "mesh-sync", + "mesh-virtual-staking", "schemars", "serde", "sylvia", diff --git a/contracts/consumer/converter/Cargo.toml b/contracts/consumer/converter/Cargo.toml index a152fbeb..aefd9b14 100644 --- a/contracts/consumer/converter/Cargo.toml +++ b/contracts/consumer/converter/Cargo.toml @@ -23,6 +23,8 @@ fake-custom = [ "mesh-simple-price-feed/fake-custom" ] [dependencies] mesh-apis = { workspace = true } mesh-bindings = { workspace = true } +mesh-sync = { workspace = true } +mesh-virtual-staking = { workspace = true } sylvia = { workspace = true } cosmwasm-schema = { workspace = true } @@ -38,7 +40,7 @@ thiserror = { workspace = true } [dev-dependencies] mesh-burn = { workspace = true } mesh-simple-price-feed = { workspace = true, features = ["mt"] } - +mesh-virtual-staking = { workspace = true, features = ["mt"] } cw-multi-test = { workspace = true } test-case = { workspace = true } derivative = { workspace = true } diff --git a/contracts/consumer/converter/src/contract.rs b/contracts/consumer/converter/src/contract.rs index caedb92d..98eb878c 100644 --- a/contracts/consumer/converter/src/contract.rs +++ b/contracts/consumer/converter/src/contract.rs @@ -1,7 +1,7 @@ use cosmwasm_std::{ ensure_eq, to_json_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, Deps, DepsMut, Event, - Fraction, MessageInfo, Reply, Response, StdError, SubMsg, SubMsgResponse, Uint128, Validator, - WasmMsg, + Fraction, IbcMsg, MessageInfo, Reply, Response, StdError, SubMsg, SubMsgResponse, Uint128, + Validator, WasmMsg, }; use cw2::set_contract_version; use cw_storage_plus::Item; @@ -15,7 +15,9 @@ use mesh_apis::price_feed_api; use mesh_apis::virtual_staking_api; use crate::error::ContractError; -use crate::ibc::{make_ibc_packet, valset_update_msg, IBC_CHANNEL}; +use crate::ibc::{ + make_ibc_packet, packet_timeout_internal_unstake, valset_update_msg, IBC_CHANNEL, +}; use crate::msg::ConfigResponse; use crate::state::Config; @@ -72,6 +74,7 @@ impl ConverterContract<'_> { remote_denom: String, virtual_staking_code_id: u64, admin: Option, + max_retrieve: u16, ) -> Result { nonpayable(&ctx.info)?; // validate args @@ -95,11 +98,13 @@ impl ConverterContract<'_> { ctx.deps.api.addr_validate(admin)?; } + let msg = + to_json_binary(&mesh_virtual_staking::contract::sv::InstantiateMsg { max_retrieve })?; // Instantiate virtual staking contract let init_msg = WasmMsg::Instantiate { admin, code_id: virtual_staking_code_id, - msg: b"{}".into(), + msg, funds: vec![], label: format!("Virtual Staking: {}", &config.remote_denom), }; @@ -138,17 +143,18 @@ impl ConverterContract<'_> { fn test_stake( &self, ctx: ExecCtx, + delegator: String, validator: String, stake: Coin, ) -> Result { #[cfg(any(test, feature = "mt"))] { // This can only ever be called in tests - self.stake(ctx.deps, validator, stake) + self.stake(ctx.deps, delegator, validator, stake) } #[cfg(not(any(test, feature = "mt")))] { - let _ = (ctx, validator, stake); + let _ = (ctx, delegator, validator, stake); Err(ContractError::Unauthorized) } } @@ -159,17 +165,18 @@ impl ConverterContract<'_> { fn test_unstake( &self, ctx: ExecCtx, + delegator: String, validator: String, unstake: Coin, ) -> Result { #[cfg(any(test, feature = "mt"))] { // This can only ever be called in tests - self.unstake(ctx.deps, validator, unstake) + self.unstake(ctx.deps, delegator, validator, unstake) } #[cfg(not(any(test, feature = "mt")))] { - let _ = (ctx, validator, unstake); + let _ = (ctx, delegator, validator, unstake); Err(ContractError::Unauthorized) } } @@ -214,6 +221,7 @@ impl ConverterContract<'_> { pub(crate) fn stake( &self, deps: DepsMut, + delegator: String, validator: String, stake: Coin, ) -> Result { @@ -223,7 +231,11 @@ impl ConverterContract<'_> { .add_attribute("validator", &validator) .add_attribute("amount", amount.amount.to_string()); - let msg = virtual_staking_api::sv::ExecMsg::Bond { validator, amount }; + let msg = virtual_staking_api::sv::ExecMsg::Bond { + delegator, + validator, + amount, + }; let msg = WasmMsg::Execute { contract_addr: self.virtual_stake.load(deps.storage)?.into(), msg: to_json_binary(&msg)?, @@ -238,6 +250,7 @@ impl ConverterContract<'_> { pub(crate) fn unstake( &self, deps: DepsMut, + delegator: String, validator: String, unstake: Coin, ) -> Result { @@ -247,7 +260,11 @@ impl ConverterContract<'_> { .add_attribute("validator", &validator) .add_attribute("amount", amount.amount.to_string()); - let msg = virtual_staking_api::sv::ExecMsg::Unbond { validator, amount }; + let msg = virtual_staking_api::sv::ExecMsg::Unbond { + delegator, + validator, + amount, + }; let msg = WasmMsg::Execute { contract_addr: self.virtual_stake.load(deps.storage)?.into(), msg: to_json_binary(&msg)?, @@ -603,4 +620,40 @@ impl ConverterApi for ConverterContract<'_> { resp = resp.add_event(event); Ok(resp) } + + fn internal_unstake( + &self, + ctx: ExecCtx, + delegator: String, + validator: String, + amount: Coin, + ) -> Result { + let virtual_stake = self.virtual_stake.load(ctx.deps.storage)?; + ensure_eq!(ctx.info.sender, virtual_stake, ContractError::Unauthorized); + + #[allow(unused_mut)] + let mut resp = Response::new() + .add_attribute("action", "internal_unstake") + .add_attribute("amount", amount.amount.to_string()) + .add_attribute("owner", delegator.clone()); + + let channel = IBC_CHANNEL.load(ctx.deps.storage)?; + + // Recalculate the price when unbond + let inverted_amount = self.invert_price(ctx.deps.as_ref(), amount.clone())?; + let packet = ConsumerPacket::InternalUnstake { + delegator, + validator, + normalize_amount: amount, + inverted_amount, + }; + let msg = IbcMsg::SendPacket { + channel_id: channel.endpoint.channel_id, + data: to_json_binary(&packet)?, + timeout: packet_timeout_internal_unstake(&ctx.env), + }; + // send packet if we are ibc enabled + resp = resp.add_message(msg); + Ok(resp) + } } diff --git a/contracts/consumer/converter/src/ibc.rs b/contracts/consumer/converter/src/ibc.rs index c54a19dd..90865843 100644 --- a/contracts/consumer/converter/src/ibc.rs +++ b/contracts/consumer/converter/src/ibc.rs @@ -5,7 +5,7 @@ use cosmwasm_std::{ from_json, to_json_binary, DepsMut, Env, Event, Ibc3ChannelOpenResponse, IbcBasicResponse, IbcChannel, IbcChannelCloseMsg, IbcChannelConnectMsg, IbcChannelOpenMsg, IbcChannelOpenResponse, IbcMsg, IbcPacketAckMsg, IbcPacketReceiveMsg, IbcPacketTimeoutMsg, - IbcReceiveResponse, IbcTimeout, Validator, + IbcReceiveResponse, IbcTimeout, Validator, WasmMsg, }; use cw_storage_plus::Item; @@ -14,6 +14,7 @@ use mesh_apis::ibc::{ ack_success, validate_channel_order, AckWrapper, AddValidator, ConsumerPacket, ProtocolVersion, ProviderPacket, StakeAck, TransferRewardsAck, UnstakeAck, PROTOCOL_NAME, }; +use mesh_apis::virtual_staking_api; use sylvia::types::ExecCtx; use crate::{ @@ -34,6 +35,8 @@ const DEFAULT_VALIDATOR_TIMEOUT: u64 = 24 * 60 * 60; // But reward messages should go faster or timeout const DEFAULT_REWARD_TIMEOUT: u64 = 60 * 60; +const DEFAULT_INTERNAL_UNSTAKE_TIMEOUT: u64 = 60 * 60; + pub fn packet_timeout_validator(env: &Env) -> IbcTimeout { // No idea about their block time, but 24 hours ahead of our view of the clock // should be decently in the future. @@ -48,6 +51,16 @@ pub fn packet_timeout_rewards(env: &Env) -> IbcTimeout { IbcTimeout::with_timestamp(timeout) } +pub fn packet_timeout_internal_unstake(env: &Env) -> IbcTimeout { + // No idea about their block time, but 24 hours ahead of our view of the clock + // should be decently in the future. + let timeout = env + .block + .time + .plus_seconds(DEFAULT_INTERNAL_UNSTAKE_TIMEOUT); + IbcTimeout::with_timestamp(timeout) +} + #[cfg_attr(not(feature = "library"), entry_point)] /// enforces ordering and versioning constraints pub fn ibc_channel_open( @@ -195,11 +208,12 @@ pub fn ibc_packet_receive( let contract = ConverterContract::new(); let res = match packet { ProviderPacket::Stake { + delegator, validator, stake, tx_id: _, } => { - let response = contract.stake(deps, validator, stake)?; + let response = contract.stake(deps, delegator, validator, stake)?; let ack = ack_success(&StakeAck {})?; IbcReceiveResponse::new() .set_ack(ack) @@ -208,11 +222,12 @@ pub fn ibc_packet_receive( .add_attributes(response.attributes) } ProviderPacket::Unstake { + delegator, validator, unstake, tx_id: _, } => { - let response = contract.unstake(deps, validator, unstake)?; + let response = contract.unstake(deps, delegator, validator, unstake)?; let ack = ack_success(&UnstakeAck {})?; IbcReceiveResponse::new() .set_ack(ack) @@ -245,14 +260,37 @@ pub fn ibc_packet_receive( /// If it succeeded, take no action. If it errored, we can't do anything else and let it go. /// We just log the error cases so they can be detected. pub fn ibc_packet_ack( - _deps: DepsMut, + deps: DepsMut, _env: Env, msg: IbcPacketAckMsg, ) -> Result { let ack: AckWrapper = from_json(&msg.acknowledgement.data)?; + let contract = ConverterContract::new(); let mut res = IbcBasicResponse::new(); match ack { - AckWrapper::Result(_) => {} + AckWrapper::Result(_) => { + let packet: ConsumerPacket = from_json(&msg.original_packet.data)?; + if let ConsumerPacket::InternalUnstake { + delegator, + validator, + normalize_amount, + inverted_amount: _, + } = packet + { + // execute virtual contract's internal unbond + let msg = virtual_staking_api::sv::ExecMsg::InternalUnbond { + delegator, + validator, + amount: normalize_amount, + }; + let msg = WasmMsg::Execute { + contract_addr: contract.virtual_stake.load(deps.storage)?.into(), + msg: to_json_binary(&msg)?, + funds: vec![], + }; + res = res.add_message(msg); + } + } AckWrapper::Error(e) => { // The wasmd framework will label this with the contract_addr, which helps us find the port and issue. // Provide info to find the actual packet. diff --git a/contracts/consumer/converter/src/multitest.rs b/contracts/consumer/converter/src/multitest.rs index 22169d99..6d3a8ab7 100644 --- a/contracts/consumer/converter/src/multitest.rs +++ b/contracts/consumer/converter/src/multitest.rs @@ -63,6 +63,7 @@ fn setup<'a>(app: &'a App, args: SetupArgs<'a>) -> SetupResponse<'a> { JUNO.to_owned(), virtual_staking_code.code_id(), Some(admin.to_owned()), + 50, ) .with_label("Juno Converter") .with_admin(admin) @@ -172,17 +173,17 @@ fn ibc_stake_and_unstake() { // let's stake some converter - .test_stake(val1.to_string(), coin(1000, JUNO)) + .test_stake(owner.to_string(), val1.to_string(), coin(1000, JUNO)) .call(owner) .unwrap(); converter - .test_stake(val2.to_string(), coin(4000, JUNO)) + .test_stake(owner.to_string(), val2.to_string(), coin(4000, JUNO)) .call(owner) .unwrap(); // and unstake some converter - .test_unstake(val2.to_string(), coin(2000, JUNO)) + .test_unstake(owner.to_string(), val2.to_string(), coin(2000, JUNO)) .call(owner) .unwrap(); @@ -258,11 +259,11 @@ fn ibc_stake_and_burn() { // let's stake some converter - .test_stake(val1.to_string(), coin(1000, JUNO)) + .test_stake(owner.to_string(), val1.to_string(), coin(1000, JUNO)) .call(owner) .unwrap(); converter - .test_stake(val2.to_string(), coin(4000, JUNO)) + .test_stake(owner.to_string(), val2.to_string(), coin(4000, JUNO)) .call(owner) .unwrap(); diff --git a/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs b/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs index 3faaedf8..96048e17 100644 --- a/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs +++ b/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs @@ -133,6 +133,7 @@ impl VirtualStakingApi for VirtualStakingMock<'_> { fn bond( &self, ctx: ExecCtx, + _delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error> { @@ -160,6 +161,7 @@ impl VirtualStakingApi for VirtualStakingMock<'_> { fn unbond( &self, ctx: ExecCtx, + _delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error> { @@ -241,6 +243,16 @@ impl VirtualStakingApi for VirtualStakingMock<'_> { Ok(Response::new()) } + fn internal_unbond( + &self, + _ctx: ExecCtx, + _delegator: String, + _validator: String, + _amount: Coin, + ) -> Result, Self::Error> { + unimplemented!() + } + /// SudoMsg::HandleEpoch{} should be called once per epoch by the sdk (in EndBlock). /// It allows the virtual staking contract to bond or unbond any pending requests, as well /// as to perform a rebalance if needed (over the max cap). diff --git a/contracts/consumer/virtual-staking/Cargo.toml b/contracts/consumer/virtual-staking/Cargo.toml index 2952a33b..000abb3c 100644 --- a/contracts/consumer/virtual-staking/Cargo.toml +++ b/contracts/consumer/virtual-staking/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +sylvia = { workspace = true, features = ["mt"] } mesh-simple-price-feed = { workspace = true, features = ["mt", "fake-custom"] } mesh-converter = { workspace = true, features = ["mt", "fake-custom"] } cw-multi-test = { workspace = true } diff --git a/contracts/consumer/virtual-staking/src/contract.rs b/contracts/consumer/virtual-staking/src/contract.rs index 52af7d8b..037e28d4 100644 --- a/contracts/consumer/virtual-staking/src/contract.rs +++ b/contracts/consumer/virtual-staking/src/contract.rs @@ -74,12 +74,14 @@ impl VirtualStakingContract<'_> { pub fn instantiate( &self, ctx: InstantiateCtx, + max_retrieve: u16, ) -> Result, ContractError> { nonpayable(&ctx.info)?; let denom = ctx.deps.querier.query_bonded_denom()?; let config = Config { denom, converter: ctx.info.sender, + max_retrieve, }; self.config.save(ctx.deps.storage, &config)?; // initialize these to no one, so no issue when reading for the first time @@ -360,6 +362,7 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { fn bond( &self, ctx: ExecCtx, + delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error> { @@ -381,7 +384,13 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { self.bond_requests .save(ctx.deps.storage, &validator, &bonded)?; - Ok(Response::new()) + let msg = VirtualStakeMsg::UpdateDelegation { + amount, + is_deduct: false, + delegator, + validator, + }; + Ok(Response::new().add_message(msg)) } /// Requests to unbond tokens from a validator. This will be actually handled at the next epoch. @@ -390,6 +399,7 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { fn unbond( &self, ctx: ExecCtx, + delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error> { @@ -410,7 +420,13 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { self.bond_requests .save(ctx.deps.storage, &validator, &bonded)?; - Ok(Response::new()) + let msg = VirtualStakeMsg::UpdateDelegation { + amount, + is_deduct: true, + delegator, + validator, + }; + Ok(Response::new().add_message(msg)) } /// Requests to unbond and burn tokens from a list of validators. Unbonding will be actually handled at the next epoch. @@ -475,6 +491,54 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { Ok(Response::new()) } + /// Immediately unbond the given amount due to zero max cap + fn internal_unbond( + &self, + ctx: ExecCtx, + delegator: String, + validator: String, + amount: Coin, + ) -> Result, Self::Error> { + nonpayable(&ctx.info)?; + let cfg = self.config.load(ctx.deps.storage)?; + ensure_eq!(ctx.info.sender, cfg.converter, ContractError::Unauthorized); // only the converter can call this + ensure_eq!( + amount.denom, + cfg.denom, + ContractError::WrongDenom(cfg.denom) + ); + + // Immediately unbond + let bonded = self.bond_requests.load(ctx.deps.storage, &validator)?; + let bonded = bonded + .checked_sub(amount.amount) + .map_err(|_| ContractError::InsufficientBond(validator.clone(), amount.amount))?; + self.bond_requests + .save(ctx.deps.storage, &validator, &bonded)?; + + let requests: Vec<(String, Uint128)> = self + .bond_requests + .range( + ctx.deps.as_ref().storage, + None, + None, + cosmwasm_std::Order::Ascending, + ) + .collect::>()?; + self.bonded.save(ctx.deps.storage, &requests)?; + + let msgs = vec![ + VirtualStakeMsg::UpdateDelegation { + amount: amount.clone(), + is_deduct: true, + delegator, + validator: validator.clone(), + }, + VirtualStakeMsg::Unbond { amount, validator }, + ]; + Ok(Response::new().add_messages(msgs)) + } + // FIXME: need to handle custom message types and queries /** * This is called once per epoch to withdraw all rewards and rebalance the bonded tokens. @@ -506,15 +570,39 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { let bond = TokenQuerier::new(&deps.querier).bond_status(env.contract.address.to_string())?; let max_cap = bond.cap.amount; + + let config = self.config.load(deps.storage)?; // If 0 max cap, then we assume all tokens were force unbonded already, and just return the withdraw rewards // call and set bonded to empty // TODO: verify this behavior with SDK module (otherwise we send unbond message) if max_cap.is_zero() { - self.bonded.save(deps.storage, &vec![])?; - return Ok(resp); + let all_delegations = TokenQuerier::new(&deps.querier) + .all_delegations(env.contract.address.to_string(), config.max_retrieve)?; + if all_delegations.delegations.len() == 0 { + return Ok(resp.add_message(VirtualStakeMsg::DeleteAllScheduledTasks {})); + } + let mut msgs = vec![]; + for delegation in all_delegations.delegations { + let validator = delegation.validator.clone(); + // Send unstake request to converter contract + let msg = converter_api::sv::ExecMsg::InternalUnstake { + delegator: delegation.delegator, + validator, + amount: Coin { + denom: config.denom.clone(), + amount: delegation.amount, + }, + }; + let msg = WasmMsg::Execute { + contract_addr: config.converter.to_string(), + msg: to_json_binary(&msg)?, + funds: vec![], + }; + msgs.push(msg); + } + return Ok(resp.add_messages(msgs)); } - let config = self.config.load(deps.storage)?; // Make current bonded mutable let mut current = bonded; // Process slashes due to tombstoning (unbonded) or jailing, over bond_requests and current @@ -647,7 +735,7 @@ mod tests { use cosmwasm_std::{ coins, from_json, testing::{mock_env, mock_info, MockApi, MockQuerier, MockStorage}, - Decimal, + AllDelegationsResponse, Decimal, }; use mesh_bindings::{BondStatusResponse, SlashRatioResponse}; @@ -665,7 +753,7 @@ mod tests { contract.quick_inst(deps.as_mut()); knobs.bond_status.update_cap(0u128); - contract.quick_bond(deps.as_mut(), "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_no_bonding() @@ -681,7 +769,7 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(10u128); - contract.quick_bond(deps.as_mut(), "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom))]) @@ -697,8 +785,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(10u128); - contract.quick_bond(deps.as_mut(), "val1", 6); - contract.quick_bond(deps.as_mut(), "val2", 4); + contract.quick_bond(deps.as_mut(), "owner", "val1", 6); + contract.quick_bond(deps.as_mut(), "owner", "val2", 4); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (6u128, &denom)), ("val2", (4u128, &denom))]) @@ -716,8 +804,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(5u128); - contract.quick_bond(deps.as_mut(), "val1", 10); - contract.quick_bond(deps.as_mut(), "val2", 40); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val2", 40); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (1u128, &denom)), ("val2", (4u128, &denom))]) @@ -733,13 +821,13 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(10u128); - contract.quick_bond(deps.as_mut(), "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom))]) .assert_rewards(&[]); - contract.quick_unbond(deps.as_mut(), "val1", 5); + contract.quick_unbond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_unbond(&[("val1", (5u128, &denom))]) @@ -755,7 +843,7 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(10u128); - contract.quick_bond(deps.as_mut(), "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom))]) @@ -777,8 +865,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 5); - contract.quick_bond(deps.as_mut(), "val2", 20); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val2", 20); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom)), ("val2", (20u128, &denom))]) @@ -802,8 +890,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 5); - contract.quick_bond(deps.as_mut(), "val2", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val2", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom)), ("val2", (10u128, &denom))]) @@ -827,8 +915,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 5); - contract.quick_bond(deps.as_mut(), "val2", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val2", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom)), ("val2", (10u128, &denom))]) @@ -850,8 +938,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 10); - contract.quick_bond(deps.as_mut(), "val2", 20); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val2", 20); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (10u128, &denom)), ("val2", (20u128, &denom))]) @@ -914,14 +1002,14 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (10u128, &denom))]) .assert_rewards(&[]); // Val1 is bonding some more - contract.quick_bond(deps.as_mut(), "val1", 20); + contract.quick_bond(deps.as_mut(), "owner", "val1", 20); // And it's being jailed at the same time contract.jail(deps.as_mut(), "val1", Decimal::percent(10), Uint128::one()); @@ -946,14 +1034,14 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (10u128, &denom))]) .assert_rewards(&[]); // Val1 is unbonding - contract.quick_unbond(deps.as_mut(), "val1", 10); + contract.quick_unbond(deps.as_mut(), "owner", "val1", 10); // And it's being jailed at the same time contract.jail(deps.as_mut(), "val1", Decimal::percent(10), Uint128::one()); @@ -993,7 +1081,7 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(10u128); - contract.quick_bond(deps.as_mut(), "val1", 5); + contract.quick_bond(deps.as_mut(), "owner", "val1", 5); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (5u128, &denom))]) @@ -1021,8 +1109,8 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 20); - contract.quick_bond(deps.as_mut(), "val2", 20); + contract.quick_bond(deps.as_mut(), "owner", "val1", 20); + contract.quick_bond(deps.as_mut(), "owner", "val2", 20); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (20u128, &denom)), ("val2", (20u128, &denom))]) @@ -1061,14 +1149,14 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (10u128, &denom))]) .assert_rewards(&[]); // Val1 is bonding some more - contract.quick_bond(deps.as_mut(), "val1", 20); + contract.quick_bond(deps.as_mut(), "owner", "val1", 20); // And it's being tombstoned at the same time contract.tombstone(deps.as_mut(), "val1", Decimal::percent(25), Uint128::new(2)); @@ -1101,14 +1189,14 @@ mod tests { let denom = contract.config.load(&deps.storage).unwrap().denom; knobs.bond_status.update_cap(100u128); - contract.quick_bond(deps.as_mut(), "val1", 10); + contract.quick_bond(deps.as_mut(), "owner", "val1", 10); contract .hit_epoch(deps.as_mut()) .assert_bond(&[("val1", (10u128, &denom))]) .assert_rewards(&[]); // Val1 is unbonding - contract.quick_unbond(deps.as_mut(), "val1", 10); + contract.quick_unbond(deps.as_mut(), "owner", "val1", 10); // And it's being tombstoned at the same time contract.tombstone(deps.as_mut(), "val1", Decimal::percent(25), Uint128::new(2)); @@ -1213,6 +1301,9 @@ mod tests { slash_fraction_downtime: "0.1".to_string(), slash_fraction_double_sign: "0.25".to_string(), }); + let all_delegations = MockAllDelegations::new(AllDelegationsResponse { + delegations: vec![], + }); let handler = { let bs_copy = bond_status.clone(); @@ -1229,6 +1320,11 @@ mod tests { to_json_binary(&*slash_ratio.borrow()).unwrap(), )) } + mesh_bindings::VirtualStakeQuery::AllDelegations { .. } => { + cosmwasm_std::SystemResult::Ok(cosmwasm_std::ContractResult::Ok( + to_json_binary(&*all_delegations.borrow()).unwrap(), + )) + } } } }; @@ -1279,6 +1375,19 @@ mod tests { } } + #[derive(Clone)] + struct MockAllDelegations(Rc>); + + impl MockAllDelegations { + fn new(res: AllDelegationsResponse) -> Self { + Self(Rc::new(RefCell::new(res))) + } + + fn borrow(&self) -> Ref<'_, AllDelegationsResponse> { + self.0.borrow() + } + } + fn set_reward_targets(storage: &mut dyn Storage, targets: &[&str]) { REWARD_TARGETS .save( @@ -1292,8 +1401,8 @@ mod tests { fn quick_inst(&self, deps: DepsMut); fn push_rewards(&self, deps: &mut OwnedDeps, amount: u128) -> PushRewardsResult; fn hit_epoch(&self, deps: DepsMut) -> HitEpochResult; - fn quick_bond(&self, deps: DepsMut, validator: &str, amount: u128); - fn quick_unbond(&self, deps: DepsMut, validator: &str, amount: u128); + fn quick_bond(&self, deps: DepsMut, delegator: &str, validator: &str, amount: u128); + fn quick_unbond(&self, deps: DepsMut, delegator: &str, validator: &str, amount: u128); fn quick_burn( &self, deps: DepsMut, @@ -1321,11 +1430,14 @@ mod tests { impl VirtualStakingExt for VirtualStakingContract<'_> { fn quick_inst(&self, deps: DepsMut) { - self.instantiate(InstantiateCtx { - deps, - env: mock_env(), - info: mock_info("me", &[]), - }) + self.instantiate( + InstantiateCtx { + deps, + env: mock_env(), + info: mock_info("me", &[]), + }, + 50, + ) .unwrap(); } @@ -1366,7 +1478,7 @@ mod tests { HitEpochResult::new(self.handle_epoch(deps).unwrap()) } - fn quick_bond(&self, deps: DepsMut, validator: &str, amount: u128) { + fn quick_bond(&self, deps: DepsMut, delegator: &str, validator: &str, amount: u128) { let denom = self.config.load(deps.storage).unwrap().denom; self.bond( @@ -1375,13 +1487,14 @@ mod tests { env: mock_env(), info: mock_info("me", &[]), }, + delegator.to_string(), validator.to_string(), coin(amount, denom), ) .unwrap(); } - fn quick_unbond(&self, deps: DepsMut, validator: &str, amount: u128) { + fn quick_unbond(&self, deps: DepsMut, delegator: &str, validator: &str, amount: u128) { let denom = self.config.load(deps.storage).unwrap().denom; self.unbond( @@ -1390,6 +1503,7 @@ mod tests { env: mock_env(), info: mock_info("me", &[]), }, + delegator.to_string(), validator.to_string(), coin(amount, denom), ) @@ -1623,9 +1737,9 @@ mod tests { #[track_caller] fn assert_no_bonding(&self) -> &Self { - if !self.virtual_stake_msgs.is_empty() { + if self.virtual_stake_msgs.len() > 1 { panic!( - "hit_epoch result was expected to be a noop, but has these: {:?}", + "hit_epoch result was expected to only contain DeleteAllScheduledTasks, but has these: {:?}", self.virtual_stake_msgs ); } diff --git a/contracts/consumer/virtual-staking/src/multitest.rs b/contracts/consumer/virtual-staking/src/multitest.rs index 54be3760..76f6ff1e 100644 --- a/contracts/consumer/virtual-staking/src/multitest.rs +++ b/contracts/consumer/virtual-staking/src/multitest.rs @@ -60,6 +60,7 @@ fn setup<'a>(app: &'a App, args: SetupArgs<'a>) -> SetupResponse<'a> { JUNO.to_owned(), virtual_staking_code.code_id(), Some(admin.to_owned()), + 50, ) .with_label("Juno Converter") .with_admin(admin) diff --git a/contracts/consumer/virtual-staking/src/state.rs b/contracts/consumer/virtual-staking/src/state.rs index 705e61a7..76a6cb25 100644 --- a/contracts/consumer/virtual-staking/src/state.rs +++ b/contracts/consumer/virtual-staking/src/state.rs @@ -8,4 +8,8 @@ pub struct Config { /// The address of the converter contract (that is authorized to bond/unbond and will receive rewards) pub converter: Addr, + + /// Maximum + /// + pub max_retrieve: u16, } diff --git a/contracts/provider/external-staking/src/contract.rs b/contracts/provider/external-staking/src/contract.rs index 25293bc3..115d1bef 100644 --- a/contracts/provider/external-staking/src/contract.rs +++ b/contracts/provider/external-staking/src/contract.rs @@ -26,7 +26,7 @@ use crate::msg::{ StakeInfo, StakesResponse, TxResponse, ValidatorPendingRewards, }; use crate::stakes::Stakes; -use crate::state::{Config, Distribution, SlashRatio, Stake}; +use crate::state::{Config, Distribution, PendingUnbond, SlashRatio, Stake}; pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -296,10 +296,11 @@ impl ExternalStakingContract<'_> { let mut resp = Response::new() .add_attribute("action", "unstake") .add_attribute("amount", amount.amount.to_string()) - .add_attribute("owner", info.sender); + .add_attribute("owner", info.sender.clone()); let channel = IBC_CHANNEL.load(deps.storage)?; let packet = ProviderPacket::Unstake { + delegator: info.sender.to_string(), validator, unstake: amount, tx_id, @@ -445,6 +446,57 @@ impl ExternalStakingContract<'_> { Ok(()) } + // immediate unstake assets + pub(crate) fn internal_unstake( + &self, + deps: DepsMut, + env: Env, + delegator: String, + validator: String, + amount: Coin, + ) -> Result { + let user = deps.api.addr_validate(&delegator)?; + // Load stake + let mut stake = self.stakes.stake.load(deps.storage, (&user, &validator))?; + + // Load distribution + let mut distribution = self + .distribution + .may_load(deps.storage, &validator)? + .unwrap_or_default(); + + // Commit sub amount, saturating if slashed + let amount = min(amount.amount, stake.stake.high()); + stake.stake.sub(amount, Uint128::zero())?; + + let unbond = PendingUnbond { + amount, + release_at: env.block.time, + }; + stake.pending_unbonds.push(unbond); + + // Distribution alignment + stake + .points_alignment + .stake_decreased(amount, distribution.points_per_stake); + distribution.total_stake -= amount; + + // Save stake + self.stakes + .stake + .save(deps.storage, (&user, &validator), &stake)?; + + // Save distribution + self.distribution + .save(deps.storage, &validator, &distribution)?; + let event = Event::new("internal_unstake") + .add_attribute("delegator", delegator) + .add_attribute("validator", validator) + .add_attribute("amount", amount.to_string()); + + Ok(event) + } + /// In non-test code, this is called from `ibc_packet_ack` #[allow(clippy::too_many_arguments)] pub(crate) fn valset_update( @@ -1271,6 +1323,7 @@ pub mod cross_staking { let channel = IBC_CHANNEL.load(ctx.deps.storage)?; let packet = ProviderPacket::Stake { + delegator: owner.to_string(), validator: msg.validator, stake: amount.clone(), tx_id, diff --git a/contracts/provider/external-staking/src/ibc.rs b/contracts/provider/external-staking/src/ibc.rs index 69127f2d..c8080f70 100644 --- a/contracts/provider/external-staking/src/ibc.rs +++ b/contracts/provider/external-staking/src/ibc.rs @@ -155,6 +155,17 @@ pub fn ibc_packet_receive( .add_event(evt) .add_messages(msgs) } + ConsumerPacket::InternalUnstake { + delegator, + validator, + normalize_amount: _, + inverted_amount, + } => { + let evt = + contract.internal_unstake(deps, env, delegator, validator, inverted_amount)?; + let ack = ack_success(&DistributeAck {})?; + IbcReceiveResponse::new().set_ack(ack).add_event(evt) + } ConsumerPacket::Distribute { validator, rewards } => { let evt = contract.distribute_rewards(deps, &validator, rewards)?; let ack = ack_success(&DistributeAck {})?; diff --git a/packages/apis/src/converter_api.rs b/packages/apis/src/converter_api.rs index 8fc26412..d9d900e9 100644 --- a/packages/apis/src/converter_api.rs +++ b/packages/apis/src/converter_api.rs @@ -51,6 +51,16 @@ pub trait ConverterApi { tombstoned: Vec, slashed: Vec, ) -> Result, Self::Error>; + + /// Send ibc packet, request the external staking contract to unstake + #[sv::msg(exec)] + fn internal_unstake( + &self, + ctx: ExecCtx, + delegator: String, + validator: String, + amount: Coin, + ) -> Result, Self::Error>; } #[cw_serde] diff --git a/packages/apis/src/ibc/packet.rs b/packages/apis/src/ibc/packet.rs index 0978b9e8..86bdac64 100644 --- a/packages/apis/src/ibc/packet.rs +++ b/packages/apis/src/ibc/packet.rs @@ -12,6 +12,7 @@ use crate::converter_api::{RewardInfo, ValidatorSlashInfo}; pub enum ProviderPacket { /// This should be called when we lock more tokens to virtually stake on a given validator Stake { + delegator: String, validator: String, /// This is the local (provider-side) denom that is held in the vault. /// It will be converted to the consumer-side staking token in the converter with help @@ -22,6 +23,7 @@ pub enum ProviderPacket { }, /// This should be called when we begin the unbonding period of some more tokens previously virtually staked Unstake { + delegator: String, validator: String, /// This is the local (provider-side) denom that is held in the vault. /// It will be converted to the consumer-side staking token in the converter with help @@ -110,6 +112,17 @@ pub enum ConsumerPacket { /// This has precedence over all other events in the same packet. slashed: Vec, }, + /// This is a part of zero max cap process + /// The consumer chain will send this packet to provider, force user to unbond token + InternalUnstake { + delegator: String, + validator: String, + /// This is the local (provider-side) denom that is held in the vault. + /// It will be converted to the consumer-side staking token in the converter with help + /// of the price feed. + normalize_amount: Coin, + inverted_amount: Coin, + }, /// This is part of the rewards protocol Distribute { /// The validator whose stakers should receive these rewards diff --git a/packages/apis/src/virtual_staking_api.rs b/packages/apis/src/virtual_staking_api.rs index 982855ce..9377eee1 100644 --- a/packages/apis/src/virtual_staking_api.rs +++ b/packages/apis/src/virtual_staking_api.rs @@ -25,6 +25,7 @@ pub trait VirtualStakingApi { fn bond( &self, ctx: ExecCtx, + delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error>; @@ -36,6 +37,7 @@ pub trait VirtualStakingApi { fn unbond( &self, ctx: ExecCtx, + delegator: String, validator: String, amount: Coin, ) -> Result, Self::Error>; @@ -51,6 +53,18 @@ pub trait VirtualStakingApi { amount: Coin, ) -> Result, Self::Error>; + /// Immediately unbond the given amount due to zero max cap + /// When the consumer chain receives ack packet from provider - which means the unbond process from provider is success, + /// consumer chain will trigger this function to unbond this contract actor staking base on the delegate amount. + #[sv::msg(exec)] + fn internal_unbond( + &self, + ctx: ExecCtx, + delegator: String, + validator: String, + amount: Coin, + ) -> Result, Self::Error>; + /// SudoMsg::HandleEpoch{} should be called once per epoch by the sdk (in EndBlock). /// It allows the virtual staking contract to bond or unbond any pending requests, as well /// as to perform a rebalance if needed (over the max cap). diff --git a/packages/bindings/src/msg.rs b/packages/bindings/src/msg.rs index 70e94ffe..b897c850 100644 --- a/packages/bindings/src/msg.rs +++ b/packages/bindings/src/msg.rs @@ -30,6 +30,16 @@ pub enum VirtualStakeMsg { /// It will then burn those tokens from the caller's account, /// and update the currently minted amount. Unbond { amount: Coin, validator: String }, + /// After each bonding or unbond process, a msg will be sent to the chain + /// Consumer chain will save the data - represent each delegator's stake amount + UpdateDelegation { + amount: Coin, + is_deduct: bool, + delegator: String, + validator: String, + }, + /// Delete all scheduled tasks after zero max cap and unbond all delegations + DeleteAllScheduledTasks {}, } impl VirtualStakeMsg { @@ -54,6 +64,29 @@ impl VirtualStakeMsg { validator: validator.to_string(), } } + + pub fn update_delegation( + denom: &str, + is_deduct: bool, + amount: impl Into, + delgator: &str, + validator: &str, + ) -> VirtualStakeMsg { + let coin = Coin { + amount: amount.into(), + denom: denom.into(), + }; + VirtualStakeMsg::UpdateDelegation { + amount: coin, + is_deduct, + delegator: delgator.to_string(), + validator: validator.to_string(), + } + } + + pub fn delete_all_scheduled_tasks() -> VirtualStakeMsg { + VirtualStakeMsg::DeleteAllScheduledTasks {} + } } impl From for CosmosMsg { diff --git a/packages/bindings/src/query.rs b/packages/bindings/src/query.rs index 07af5ac5..72b3bf14 100644 --- a/packages/bindings/src/query.rs +++ b/packages/bindings/src/query.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Coin, CustomQuery, QuerierWrapper, QueryRequest, StdResult}; +use cosmwasm_std::{Coin, CustomQuery, QuerierWrapper, QueryRequest, StdResult, Uint128}; #[cw_serde] #[derive(QueryResponses)] @@ -21,6 +21,10 @@ pub enum VirtualStakeQuery { /// Returns the blockchain's slashing ratios. #[returns(SlashRatioResponse)] SlashRatio {}, + + /// Returns a max retrieve amount of delegations for the given contract + #[returns(AllDelegationsResponse)] + AllDelegations { contract: String, max_retrieve: u16 }, } /// Bookkeeping info in the virtual staking sdk module @@ -42,6 +46,18 @@ pub struct SlashRatioResponse { pub slash_fraction_double_sign: String, } +#[cw_serde] +pub struct AllDelegationsResponse { + pub delegations: Vec, +} + +#[cw_serde] +pub struct Delegation { + pub delegator: String, + pub validator: String, + pub amount: Uint128, +} + impl CustomQuery for VirtualStakeCustomQuery {} impl From for QueryRequest { @@ -69,4 +85,16 @@ impl<'a> TokenQuerier<'a> { let slash_ratio_query = VirtualStakeQuery::SlashRatio {}; self.querier.query(&slash_ratio_query.into()) } + + pub fn all_delegations( + &self, + contract: String, + max_retrieve: u16, + ) -> StdResult { + let all_delegations_query = VirtualStakeQuery::AllDelegations { + contract, + max_retrieve, + }; + self.querier.query(&all_delegations_query.into()) + } } diff --git a/packages/virtual-staking-mock/src/lib.rs b/packages/virtual-staking-mock/src/lib.rs index 09abfeb6..0796f5e5 100644 --- a/packages/virtual-staking-mock/src/lib.rs +++ b/packages/virtual-staking-mock/src/lib.rs @@ -2,8 +2,8 @@ use anyhow::Result as AnyResult; use cosmwasm_std::{ coin, testing::{MockApi, MockStorage}, - to_json_binary, Addr, Api, Binary, BlockInfo, CustomQuery, Empty, Querier, QuerierWrapper, - Storage, Uint128, + to_json_binary, Addr, AllDelegationsResponse, Api, Binary, BlockInfo, CustomQuery, Empty, + Querier, QuerierWrapper, Storage, Uint128, }; use cw_multi_test::{AppResponse, BankKeeper, Module, WasmKeeper}; use cw_storage_plus::{Item, Map}; @@ -135,6 +135,10 @@ impl Module for VirtualStakingModule { Err(anyhow::anyhow!("bonded amount exceeded")) } } + mesh_bindings::VirtualStakeMsg::UpdateDelegation { .. } => Ok(AppResponse::default()), + mesh_bindings::VirtualStakeMsg::DeleteAllScheduledTasks { .. } => { + Ok(AppResponse::default()) + } } } @@ -181,6 +185,11 @@ impl Module for VirtualStakingModule { mesh_bindings::VirtualStakeQuery::SlashRatio {} => { to_json_binary(&self.slash_ratio.load(storage)?)? } + mesh_bindings::VirtualStakeQuery::AllDelegations { .. } => { + to_json_binary(&AllDelegationsResponse { + delegations: vec![], + })? + } }; Ok(to_json_binary(&result)?)