diff --git a/Cargo.lock b/Cargo.lock index c96e1b1c..3eb51171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,6 +569,10 @@ dependencies = [ "cosmwasm-std", ] +[[package]] +name = "mesh-burn" +version = "0.8.0-alpha.1" + [[package]] name = "mesh-converter" version = "0.8.0-alpha.1" @@ -583,6 +587,7 @@ dependencies = [ "cw2", "derivative", "mesh-apis", + "mesh-burn", "mesh-simple-price-feed", "schemars", "serde", @@ -604,6 +609,7 @@ dependencies = [ "cw-utils", "cw2", "mesh-apis", + "mesh-burn", "mesh-native-staking", "mesh-native-staking-proxy", "mesh-sync", @@ -652,6 +658,7 @@ dependencies = [ "cw2", "derivative", "mesh-apis", + "mesh-burn", "mesh-native-staking", "mesh-vault", "schemars", @@ -736,6 +743,7 @@ dependencies = [ "itertools 0.11.0", "mesh-apis", "mesh-bindings", + "mesh-burn", "mesh-converter", "mesh-simple-price-feed", "schemars", diff --git a/Cargo.toml b/Cargo.toml index 48829f19..939b2382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,8 @@ repository = "https://github.com/osmosis-labs/mesh-security" [workspace.dependencies] mesh-apis = { path = "./packages/apis" } -mesh-bindings = { path = "./packages/bindings" } +mesh-bindings = { path = "./packages/bindings" } +mesh-burn = { path = "./packages/burn" } mesh-sync = { path = "./packages/sync" } mesh-virtual-staking-mock = { path = "./packages/virtual-staking-mock" } diff --git a/contracts/consumer/converter/Cargo.toml b/contracts/consumer/converter/Cargo.toml index 61147731..668c4509 100644 --- a/contracts/consumer/converter/Cargo.toml +++ b/contracts/consumer/converter/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +mesh-burn = { workspace = true } mesh-simple-price-feed = { workspace = true, features = ["mt"] } cw-multi-test = { workspace = true } diff --git a/contracts/consumer/converter/src/contract.rs b/contracts/consumer/converter/src/contract.rs index de5e9a1f..ac64b671 100644 --- a/contracts/consumer/converter/src/contract.rs +++ b/contracts/consumer/converter/src/contract.rs @@ -154,6 +154,27 @@ impl ConverterContract<'_> { } } + /// This is only used for tests. + /// Ideally we want conditional compilation of these whole methods and the enum variants + #[msg(exec)] + fn test_burn( + &self, + ctx: ExecCtx, + validators: Vec, + burn: Coin, + ) -> Result { + #[cfg(any(test, feature = "mt"))] + { + // This can only ever be called in tests + self.burn(ctx.deps, &validators, burn) + } + #[cfg(not(any(test, feature = "mt")))] + { + let _ = (ctx, validators, burn); + Err(ContractError::Unauthorized) + } + } + #[msg(query)] fn config(&self, ctx: QueryCtx) -> Result { let config = self.config.load(ctx.deps.storage)?; @@ -213,6 +234,33 @@ impl ConverterContract<'_> { Ok(Response::new().add_message(msg).add_event(event)) } + /// This is called by ibc_packet_receive. + /// It is pulled out into a method, so it can also be called by test_burn for testing + pub(crate) fn burn( + &self, + deps: DepsMut, + validators: &[String], + burn: Coin, + ) -> Result { + let amount = self.normalize_price(deps.as_ref(), burn)?; + + let event = Event::new("mesh-burn") + .add_attribute("validators", validators.join(",")) + .add_attribute("amount", amount.amount.to_string()); + + let msg = virtual_staking_api::ExecMsg::Burn { + validators: validators.to_vec(), + amount, + }; + let msg = WasmMsg::Execute { + contract_addr: self.virtual_stake.load(deps.storage)?.into(), + msg: to_binary(&msg)?, + funds: vec![], + }; + + Ok(Response::new().add_message(msg).add_event(event)) + } + fn normalize_price(&self, deps: Deps, amount: Coin) -> Result { let config = self.config.load(deps.storage)?; ensure_eq!( diff --git a/contracts/consumer/converter/src/ibc.rs b/contracts/consumer/converter/src/ibc.rs index b239c813..d2f8186a 100644 --- a/contracts/consumer/converter/src/ibc.rs +++ b/contracts/consumer/converter/src/ibc.rs @@ -214,6 +214,15 @@ pub fn ibc_packet_receive( .add_events(response.events) .add_attributes(response.attributes) } + ProviderPacket::Burn { validators, burn } => { + let response = contract.burn(deps, &validators, burn)?; + let ack = ack_success(&UnstakeAck {})?; + IbcReceiveResponse::new() + .set_ack(ack) + .add_submessages(response.messages) + .add_events(response.events) + .add_attributes(response.attributes) + } ProviderPacket::TransferRewards { rewards, recipient, .. } => { diff --git a/contracts/consumer/converter/src/multitest.rs b/contracts/consumer/converter/src/multitest.rs index 32c5e7eb..95c44330 100644 --- a/contracts/consumer/converter/src/multitest.rs +++ b/contracts/consumer/converter/src/multitest.rs @@ -200,6 +200,92 @@ fn ibc_stake_and_unstake() { ); } +#[test] +fn ibc_stake_and_burn() { + let app = App::default(); + + let owner = "sunny"; // Owner of the staking contract (i. e. the vault contract) + let admin = "theman"; + let discount = Decimal::percent(40); // 1 OSMO worth of JUNO should give 0.6 OSMO of stake + let native_per_foreign = Decimal::percent(50); // 1 JUNO is worth 0.5 OSMO + + let SetupResponse { + price_feed: _, + converter, + virtual_staking, + } = setup( + &app, + SetupArgs { + owner, + admin, + discount, + native_per_foreign, + }, + ); + + // no one is staked + let val1 = "Val Kilmer"; + let val2 = "Valley Girl"; + assert!(virtual_staking.all_stake().unwrap().stakes.is_empty()); + assert_eq!( + virtual_staking + .stake(val1.to_string()) + .unwrap() + .stake + .u128(), + 0 + ); + assert_eq!( + virtual_staking + .stake(val2.to_string()) + .unwrap() + .stake + .u128(), + 0 + ); + + // let's stake some + converter + .test_stake(val1.to_string(), coin(1000, JUNO)) + .call(owner) + .unwrap(); + converter + .test_stake(val2.to_string(), coin(4000, JUNO)) + .call(owner) + .unwrap(); + + // and burn some + converter + .test_burn(vec![val2.to_string()], coin(2000, JUNO)) + .call(owner) + .unwrap(); + + // and check the stakes (1000 * 0.6 * 0.5 = 300) (2000 * 0.6 * 0.5 = 600) + assert_eq!( + virtual_staking + .stake(val1.to_string()) + .unwrap() + .stake + .u128(), + 300 + ); + assert_eq!( + virtual_staking + .stake(val2.to_string()) + .unwrap() + .stake + .u128(), + 600 + ); + assert_eq!( + virtual_staking.all_stake().unwrap().stakes, + vec![ + (val1.to_string(), Uint128::new(300)), + (val2.to_string(), Uint128::new(600)), + ] + ); +} + #[test] fn valset_update_works() { let app = App::default(); diff --git a/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs b/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs index d5ad147e..13552e60 100644 --- a/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs +++ b/contracts/consumer/converter/src/multitest/virtual_staking_mock.rs @@ -28,6 +28,9 @@ pub enum ContractError { #[error("Wrong denom. Cannot stake {0}")] WrongDenom(String), + + #[error("Virtual staking {0} has not enough delegated funds: {1}")] + InsufficientDelegations(String, Uint128), } /// This is a stub implementation of the virtual staking contract, for test purposes only. @@ -133,7 +136,7 @@ impl VirtualStakingApi for VirtualStakingMock<'_> { /// Requests to unbond tokens from a validator. This will be actually handled at the next epoch. /// If the virtual staking module is over the max cap, it will trigger a rebalance in addition to unbond. - /// If the virtual staking contract doesn't have at least amont tokens staked to the given validator, this will return an error. + /// If the virtual staking contract doesn't have at least amount tokens staked to the given validator, this will return an error. #[msg(exec)] fn unbond( &self, @@ -158,4 +161,65 @@ impl VirtualStakingApi for VirtualStakingMock<'_> { Ok(Response::new()) } + + /// Requests to unbond and burn tokens from a lists of validators (one or more). This will be actually handled at the next epoch. + /// If the virtual staking module is over the max cap, it will trigger a rebalance in addition to unbond. + /// If the virtual staking contract doesn't have at least amount tokens staked over the given validators, this will return an error. + #[msg(exec)] + fn burn( + &self, + ctx: ExecCtx, + validators: Vec, + amount: Coin, + ) -> Result { + nonpayable(&ctx.info)?; + let cfg = self.config.load(ctx.deps.storage)?; + // only the converter can call this + ensure_eq!(ctx.info.sender, cfg.converter, ContractError::Unauthorized); + ensure_eq!( + amount.denom, + cfg.denom, + ContractError::WrongDenom(cfg.denom) + ); + + let mut stakes = vec![]; + for validator in validators { + let stake = self + .stake + .may_load(ctx.deps.storage, &validator)? + .unwrap_or_default() + .u128(); + if stake != 0 { + stakes.push((validator, stake)); + } + } + + // Error if no delegations + if stakes.is_empty() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + let (burned, burns) = mesh_burn::distribute_burn(stakes.as_slice(), amount.amount.u128()); + + // Bail if we still don't have enough stake + if burned < amount.amount.u128() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + // Update stake + for (validator, burn_amount) in burns { + self.stake + .update::<_, ContractError>(ctx.deps.storage, validator, |old| { + Ok(old.unwrap_or_default() - Uint128::new(burn_amount)) + })?; + } + + Ok(Response::new()) + } } diff --git a/contracts/consumer/virtual-staking/Cargo.toml b/contracts/consumer/virtual-staking/Cargo.toml index d68cf427..588ec4f3 100644 --- a/contracts/consumer/virtual-staking/Cargo.toml +++ b/contracts/consumer/virtual-staking/Cargo.toml @@ -20,6 +20,7 @@ mt = ["library", "sylvia/mt"] [dependencies] mesh-apis = { workspace = true } +mesh-burn = { workspace = true } mesh-bindings = { workspace = true } sylvia = { workspace = true } diff --git a/contracts/consumer/virtual-staking/src/contract.rs b/contracts/consumer/virtual-staking/src/contract.rs index 6bc1874e..36503c64 100644 --- a/contracts/consumer/virtual-staking/src/contract.rs +++ b/contracts/consumer/virtual-staking/src/contract.rs @@ -49,6 +49,10 @@ pub struct VirtualStakingContract<'a> { // `inactive` could be a Map like `bond_requests`, but the only time we use it is to read / write the entire list in bulk (in handle_epoch), // never accessing one element. Reading 100 elements in an Item is much cheaper than ranging over a Map with 100 entries. pub inactive: Item<'a, Vec>, + /// Amount of tokens that have been burned from a validator. + /// This is just for accounting / tracking reasons, as token "burning" is being implemented as unbonding, + /// and there's no real need to discount the burned amount in this contract. + burned: Map<'a, &'a str, u128>, } #[cfg_attr(not(feature = "library"), sylvia::entry_points)] @@ -65,6 +69,7 @@ impl VirtualStakingContract<'_> { tombstone_requests: Item::new("tombstoned"), jail_requests: Item::new("jailed"), inactive: Item::new("inactive"), + burned: Map::new("burned"), } } @@ -555,6 +560,69 @@ impl VirtualStakingApi for VirtualStakingContract<'_> { Ok(Response::new()) } + + /// Requests to unbond and burn tokens from a list of validators. Unbonding will be actually handled at the next epoch. + /// If the virtual staking module is over the max cap, it will trigger a rebalance in addition to unbond. + /// If the virtual staking contract doesn't have at least amount tokens staked over the given validators, this will return an error. + #[msg(exec)] + fn burn( + &self, + ctx: ExecCtx, + validators: Vec, + amount: Coin, + ) -> Result { + 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) + ); + let mut bonds = vec![]; + for validator in validators { + let stake = self + .bond_requests + .may_load(ctx.deps.storage, &validator)? + .unwrap_or_default() + .u128(); + if stake != 0 { + bonds.push((validator, stake)); + } + } + + // Error if no delegations + if bonds.is_empty() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + let (burned, burns) = mesh_burn::distribute_burn(bonds.as_slice(), amount.amount.u128()); + + for (validator, burn_amount) in burns { + // Update bond requests + self.bond_requests + .update::<_, ContractError>(ctx.deps.storage, validator, |old| { + Ok(old.unwrap_or_default() - Uint128::new(burn_amount)) + })?; + // Accounting trick to avoid burning stake + self.burned.update(ctx.deps.storage, validator, |old| { + Ok::<_, ContractError>(old.unwrap_or_default() + burn_amount) + })?; + } + + // Bail if we still don't have enough stake + if burned < amount.amount.u128() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + Ok(Response::new()) + } } #[cfg_attr(not(feature = "library"), entry_point)] @@ -694,6 +762,101 @@ mod tests { .assert_rewards(&["val1"]); } + #[test] + fn burn() { + let (mut deps, knobs) = mock_dependencies(); + + let contract = VirtualStakingContract::new(); + contract.quick_inst(deps.as_mut()); + let denom = contract.config.load(&deps.storage).unwrap().denom; + + knobs.bond_status.update_cap(10u128); + contract.quick_bond(deps.as_mut(), "val1", 5); + contract + .hit_epoch(deps.as_mut()) + .assert_bond(&[("val1", (5u128, &denom))]) + .assert_rewards(&[]); + + contract.quick_burn(deps.as_mut(), &["val1"], 5).unwrap(); + contract + .hit_epoch(deps.as_mut()) + .assert_unbond(&[("val1", (5u128, &denom))]) + .assert_rewards(&["val1"]); + } + + #[test] + fn multiple_validators_burn() { + let (mut deps, knobs) = mock_dependencies(); + + let contract = VirtualStakingContract::new(); + contract.quick_inst(deps.as_mut()); + 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 + .hit_epoch(deps.as_mut()) + .assert_bond(&[("val1", (5u128, &denom)), ("val2", (20u128, &denom))]) + .assert_rewards(&[]); + + contract + .quick_burn(deps.as_mut(), &["val1", "val2"], 5) + .unwrap(); + contract + .hit_epoch(deps.as_mut()) + .assert_unbond(&[("val1", (3u128, &denom)), ("val2", (2u128, &denom))]) + .assert_rewards(&["val1", "val2"]); + } + + #[test] + fn some_unbonded_validators_burn() { + let (mut deps, knobs) = mock_dependencies(); + + let contract = VirtualStakingContract::new(); + contract.quick_inst(deps.as_mut()); + 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 + .hit_epoch(deps.as_mut()) + .assert_bond(&[("val1", (5u128, &denom)), ("val2", (10u128, &denom))]) + .assert_rewards(&[]); + + contract + .quick_burn(deps.as_mut(), &["val1", "val2"], 15) + .unwrap(); + contract + .hit_epoch(deps.as_mut()) + .assert_unbond(&[("val1", (5u128, &denom)), ("val2", (10u128, &denom))]) + .assert_rewards(&["val1", "val2"]); + } + + #[test] + fn unbonded_validators_burn() { + let (mut deps, knobs) = mock_dependencies(); + + let contract = VirtualStakingContract::new(); + contract.quick_inst(deps.as_mut()); + 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 + .hit_epoch(deps.as_mut()) + .assert_bond(&[("val1", (5u128, &denom)), ("val2", (10u128, &denom))]) + .assert_rewards(&[]); + + let res = contract.quick_burn(deps.as_mut(), &["val1", "val2"], 20); + assert!(matches!( + res.unwrap_err(), + ContractError::InsufficientDelegations { .. } + )); + } + #[test] fn validator_jail_unjail() { let (mut deps, knobs) = mock_dependencies(); @@ -1151,6 +1314,12 @@ mod tests { 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_burn( + &self, + deps: DepsMut, + validator: &[&str], + amount: u128, + ) -> Result; fn jail(&self, deps: DepsMut, val: &str); fn unjail(&self, deps: DepsMut, val: &str); fn tombstone(&self, deps: DepsMut, val: &str); @@ -1235,6 +1404,25 @@ mod tests { .unwrap(); } + fn quick_burn( + &self, + deps: DepsMut, + validators: &[&str], + amount: u128, + ) -> Result { + let denom = self.config.load(deps.storage).unwrap().denom; + + self.burn( + ExecCtx { + deps: deps.into_empty(), + env: mock_env(), + info: mock_info("me", &[]), + }, + validators.iter().map(<&str>::to_string).collect(), + coin(amount, denom), + ) + } + fn jail(&self, deps: DepsMut, val: &str) { self.handle_valset_update(deps, &[], &[], &[], &[val.to_string()], &[], &[]) .unwrap(); diff --git a/contracts/consumer/virtual-staking/src/error.rs b/contracts/consumer/virtual-staking/src/error.rs index 6541e5b9..f8d7ef31 100644 --- a/contracts/consumer/virtual-staking/src/error.rs +++ b/contracts/consumer/virtual-staking/src/error.rs @@ -21,4 +21,10 @@ pub enum ContractError { #[error("Invalid Reply ID. Don't recognize {0}")] InvalidReplyId(u64), + + #[error("Empty validators list")] + NoValidators {}, + + #[error("Virtual staking {0} has not enough delegated funds: {1}")] + InsufficientDelegations(String, Uint128), } diff --git a/contracts/provider/external-staking/Cargo.toml b/contracts/provider/external-staking/Cargo.toml index afb19f76..df3d6b27 100644 --- a/contracts/provider/external-staking/Cargo.toml +++ b/contracts/provider/external-staking/Cargo.toml @@ -19,6 +19,7 @@ mt = ["library", "sylvia/mt"] [dependencies] mesh-apis = { workspace = true } +mesh-burn = { workspace = true } mesh-sync = { workspace = true } sylvia = { workspace = true } diff --git a/contracts/provider/external-staking/src/contract.rs b/contracts/provider/external-staking/src/contract.rs index 78909927..b03098d5 100644 --- a/contracts/provider/external-staking/src/contract.rs +++ b/contracts/provider/external-staking/src/contract.rs @@ -902,7 +902,9 @@ impl ExternalStakingContract<'_> { } // Route associated users to vault for slashing of their collateral - let msg = config.vault.process_cross_slashing(slash_infos)?; + let msg = config + .vault + .process_cross_slashing(slash_infos, validator)?; Ok(Some(msg)) } @@ -1250,6 +1252,140 @@ pub mod cross_staking { Ok(resp) } + #[msg(exec)] + fn burn_virtual_stake( + &self, + ctx: ExecCtx, + owner: String, + amount: Coin, + validator: Option, + ) -> Result { + let config = self.config.load(ctx.deps.storage)?; + ensure_eq!(ctx.info.sender, config.vault.0, ContractError::Unauthorized); + + // sending proper denom + ensure_eq!( + amount.denom, + config.denom, + ContractError::InvalidDenom(config.denom) + ); + + let owner = ctx.deps.api.addr_validate(&owner)?; + + let stakes: Vec<_> = match validator { + Some(validator) => { + // Burn from validator + // TODO: Preferentially, i.e. burn remaining amount, if any, from other validators + let stake = self + .stakes + .stake + .may_load(ctx.deps.storage, (&owner, &validator))? + .unwrap_or_default(); + vec![(validator, stake.stake.high().u128())] // Burn takes precedence over any pending txs + } + None => { + // Burn proportionally from all validators associated to the user + self.stakes + .stake + .prefix(&owner) + .range(ctx.deps.storage, None, None, Order::Ascending) + .map(|item| { + let (validator, stake) = item?; + Ok::<_, Self::Error>((validator, stake.stake.high().u128())) + // Burn takes precedence over any pending txs + }) + .collect::>()? + } + }; + + // Error if no stakes + if stakes.is_empty() { + return Err(ContractError::InsufficientDelegations( + owner.to_string(), + amount.amount, + )); + } + + let (burned, burns) = mesh_burn::distribute_burn(&stakes, amount.amount.u128()); + + // Bail if we don't have enough stake + if burned < amount.amount.u128() { + return Err(ContractError::InsufficientDelegations( + owner.to_string(), + amount.amount, + )); + } + + for (validator, burn_amount) in &burns { + let burn_amount = Uint128::new(*burn_amount); + // Perform stake subtraction + let mut stake = self + .stakes + .stake + .load(ctx.deps.storage, (&owner, validator))?; + // We don't check for min here, as this call can only come from the `vault` contract, which already + // performed the proper check. + stake.stake.sub(burn_amount, None)?; + + // Load distribution + let mut distribution = self + .distribution + .may_load(ctx.deps.storage, validator)? + .unwrap_or_default(); + + // Distribution alignment + stake + .points_alignment + .stake_decreased(burn_amount, distribution.points_per_stake); + distribution.total_stake -= burn_amount; + + // Save stake + self.stakes + .stake + .save(ctx.deps.storage, (&owner, validator), &stake)?; + + // Save distribution + self.distribution + .save(ctx.deps.storage, validator, &distribution)?; + } + + let channel = IBC_CHANNEL.load(ctx.deps.storage)?; + let packet = ProviderPacket::Burn { + validators: burns.iter().map(|v| v.0.to_string()).collect(), + burn: amount.clone(), + }; + let msg = IbcMsg::SendPacket { + channel_id: channel.endpoint.channel_id, + data: to_binary(&packet)?, + timeout: packet_timeout(&ctx.env), + }; + let mut resp = Response::new(); + // add ibc packet if we are ibc enabled (skip in tests) + #[cfg(not(any(feature = "mt", test)))] + { + resp = resp.add_message(msg); + } + #[cfg(any(feature = "mt", test))] + { + let _ = msg; + } + + resp = resp + .add_attribute("action", "burn_virtual_stake") + .add_attribute("owner", owner) + .add_attribute( + "validators", + stakes + .into_iter() + .map(|s| s.0) + .collect::>() + .join(", "), + ) + .add_attribute("amount", amount.to_string()); + + Ok(resp) + } + #[msg(query)] fn max_slash(&self, ctx: QueryCtx) -> Result { let Config { max_slashing, .. } = self.config.load(ctx.deps.storage)?; @@ -1473,6 +1609,7 @@ mod tests { user: OWNER.to_string(), slash: Uint128::new(10), }], + validator: "bob".to_string(), }) .unwrap(), funds: vec![], @@ -1587,6 +1724,7 @@ mod tests { user: OWNER.to_string(), slash: Uint128::new(10), }], + validator: "bob".to_string(), }) .unwrap(), funds: vec![], @@ -1715,6 +1853,7 @@ mod tests { user: OWNER.to_string(), slash: Uint128::new(10), // Owner is slashed over the full stake, including pending }], + validator: "bob".to_string(), }) .unwrap(), funds: vec![], @@ -2093,6 +2232,7 @@ mod tests { user: OWNER.to_string(), slash: Uint128::new(10), }], + validator: "bob".to_string(), }) .unwrap(), funds: vec![], diff --git a/contracts/provider/external-staking/src/error.rs b/contracts/provider/external-staking/src/error.rs index 5b26d027..84e316b8 100644 --- a/contracts/provider/external-staking/src/error.rs +++ b/contracts/provider/external-staking/src/error.rs @@ -59,4 +59,7 @@ pub enum ContractError { #[error("{0}")] Range(#[from] RangeError), + + #[error("User {0} has not enough delegated funds: {1}")] + InsufficientDelegations(String, Uint128), } diff --git a/contracts/provider/external-staking/src/ibc.rs b/contracts/provider/external-staking/src/ibc.rs index c2708979..892cb033 100644 --- a/contracts/provider/external-staking/src/ibc.rs +++ b/contracts/provider/external-staking/src/ibc.rs @@ -187,43 +187,63 @@ pub fn ibc_packet_ack( resp = resp .add_message(msg) .add_attribute("success", "true") - .add_attribute("tx_id", tx_id.to_string()); + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "stake"); } (ProviderPacket::Stake { tx_id, .. }, AckWrapper::Error(e)) => { let msg = contract.rollback_stake(deps, tx_id)?; resp = resp .add_message(msg) .add_attribute("error", e) - .add_attribute("tx_id", tx_id.to_string()); + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "stake"); } (ProviderPacket::Unstake { tx_id, .. }, AckWrapper::Result(_)) => { contract.commit_unstake(deps, env, tx_id)?; resp = resp .add_attribute("success", "true") - .add_attribute("tx_id", tx_id.to_string()); + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "unstake"); } (ProviderPacket::Unstake { tx_id, .. }, AckWrapper::Error(e)) => { contract.rollback_unstake(deps, tx_id)?; resp = resp .add_attribute("error", e) - .add_attribute("tx_id", tx_id.to_string()); + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "unstake"); + } + (ProviderPacket::Burn { .. }, AckWrapper::Result(_)) => { + resp = resp + .add_attribute("success", "true") + .add_attribute("packet_type", "burn"); + } + (ProviderPacket::Burn { validators, burn }, AckWrapper::Error(e)) => { + resp = resp + .add_attribute("error", e) + .add_attribute("packet_type", "burn") + .add_attribute("validators", validators.join(",")) + .add_attribute("amount", burn.amount.to_string()); } (ProviderPacket::TransferRewards { tx_id, .. }, AckWrapper::Result(_)) => { - // TODO: Any events to add? contract.commit_withdraw_rewards(deps, tx_id)?; + resp = resp + .add_attribute("success", "true") + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "transfer_rewards"); } (ProviderPacket::TransferRewards { tx_id, .. }, AckWrapper::Error(e)) => { contract.rollback_withdraw_rewards(deps, tx_id)?; resp = resp .add_attribute("error", e) - .add_attribute("packet", msg.original_packet.sequence.to_string()); + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "transfer_rewards"); } } Ok(resp) } #[cfg_attr(not(feature = "library"), entry_point)] -/// This should trigger a rollback of staking/unstaking +/// This should trigger a rollback of staking/unstaking/burning pub fn ibc_packet_timeout( deps: DepsMut, _env: Env, @@ -237,15 +257,30 @@ pub fn ibc_packet_timeout( let msg = contract.rollback_stake(deps, tx_id)?; resp = resp .add_message(msg) - .add_attribute("tx_id", tx_id.to_string()); + .add_attribute("error", "timeout") + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "stake"); } ProviderPacket::Unstake { tx_id, .. } => { contract.rollback_unstake(deps, tx_id)?; - resp = resp.add_attribute("tx_id", tx_id.to_string()); + resp = resp + .add_attribute("error", "timeout") + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "unstake"); + } + ProviderPacket::Burn { validators, burn } => { + resp = resp + .add_attribute("error", "timeout") + .add_attribute("packet_type", "burn") + .add_attribute("validators", validators.join(",")) + .add_attribute("amount", burn.amount.to_string()); } ProviderPacket::TransferRewards { tx_id, .. } => { contract.rollback_withdraw_rewards(deps, tx_id)?; - resp = resp.add_attribute("tx_id", tx_id.to_string()); + resp = resp + .add_attribute("error", "timeout") + .add_attribute("tx_id", tx_id.to_string()) + .add_attribute("packet_type", "transfer_rewards"); } }; Ok(resp) diff --git a/contracts/provider/external-staking/src/msg.rs b/contracts/provider/external-staking/src/msg.rs index cfe6707a..4872871e 100644 --- a/contracts/provider/external-staking/src/msg.rs +++ b/contracts/provider/external-staking/src/msg.rs @@ -94,7 +94,7 @@ pub struct StakesResponse { pub stakes: Vec, } -/// Message to be sent as `msg` field on `receive_virtual_staking` +/// Message to be sent as `msg` field on `receive_virtual_stake` #[cw_serde] pub struct ReceiveVirtualStake { pub validator: String, diff --git a/contracts/provider/native-staking-proxy/Cargo.toml b/contracts/provider/native-staking-proxy/Cargo.toml index ea147acc..25e6f542 100644 --- a/contracts/provider/native-staking-proxy/Cargo.toml +++ b/contracts/provider/native-staking-proxy/Cargo.toml @@ -20,6 +20,7 @@ mt = ["library", "sylvia/mt"] [dependencies] mesh-apis = { workspace = true } +mesh-burn = { workspace = true } sylvia = { workspace = true } cosmwasm-schema = { workspace = true } diff --git a/contracts/provider/native-staking-proxy/src/contract.rs b/contracts/provider/native-staking-proxy/src/contract.rs index ed780578..9646a883 100644 --- a/contracts/provider/native-staking-proxy/src/contract.rs +++ b/contracts/provider/native-staking-proxy/src/contract.rs @@ -20,6 +20,7 @@ pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); pub struct NativeStakingProxyContract<'a> { config: Item<'a, Config>, + burned: Item<'a, u128>, } #[cfg_attr(not(feature = "library"), sylvia::entry_points)] @@ -29,6 +30,7 @@ impl NativeStakingProxyContract<'_> { pub const fn new() -> Self { Self { config: Item::new("config"), + burned: Item::new("burned"), } } @@ -50,6 +52,9 @@ impl NativeStakingProxyContract<'_> { self.config.save(ctx.deps.storage, &config)?; set_contract_version(ctx.deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + // Set burned stake to zero + self.burned.save(ctx.deps.storage, &0)?; + // Stake info.funds on validator let exec_ctx = ExecCtx { deps: ctx.deps, @@ -83,6 +88,96 @@ impl NativeStakingProxyContract<'_> { Ok(Response::new().add_message(msg)) } + /// Burn `amount` tokens from the given validator, if set. + /// If `validator` is not set, undelegate evenly from all validators the user has stake. + /// Can only be called by the parent contract + #[msg(exec)] + fn burn( + &self, + ctx: ExecCtx, + validator: Option, + amount: Coin, + ) -> Result { + let cfg = self.config.load(ctx.deps.storage)?; + ensure_eq!(cfg.parent, ctx.info.sender, ContractError::Unauthorized {}); + + nonpayable(&ctx.info)?; + + // Check denom + ensure_eq!( + amount.denom, + cfg.denom, + ContractError::InvalidDenom(amount.denom) + ); + + let delegations = match validator { + Some(validator) => { + let delegation = ctx + .deps + .querier + .query_delegation(ctx.env.contract.address.clone(), validator)? + .map(|full_delegation| { + ( + full_delegation.validator, + full_delegation.amount.amount.u128(), + ) + }) + .unwrap(); + vec![delegation] + } + None => { + let delegations = ctx + .deps + .querier + .query_all_delegations(ctx.env.contract.address.clone())? + .iter() + .map(|delegation| { + ( + delegation.validator.clone(), + delegation.amount.amount.u128(), + ) + }) + .collect::>(); + delegations + } + }; + + // Error if no validators + if delegations.is_empty() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + let (burned, burns) = mesh_burn::distribute_burn(&delegations, amount.amount.u128()); + + // Bail if we don't have enough delegations + if burned < amount.amount.u128() { + return Err(ContractError::InsufficientDelegations( + ctx.env.contract.address.to_string(), + amount.amount, + )); + } + + // Build undelegate messages + let mut undelegate_msgs = vec![]; + for (validator, burn_amount) in burns { + let undelegate_msg = StakingMsg::Undelegate { + validator: validator.to_string(), + amount: coin(burn_amount, &cfg.denom), + }; + undelegate_msgs.push(undelegate_msg); + } + + // Accounting trick to avoid burning stake + self.burned.update(ctx.deps.storage, |old| { + Ok::<_, ContractError>(old + amount.amount.u128()) + })?; + + Ok(Response::new().add_messages(undelegate_msgs)) + } + /// Re-stakes the given amount from the one validator to another on behalf of the calling user. /// Returns an error if the user doesn't have such stake #[msg(exec)] @@ -208,11 +303,20 @@ impl NativeStakingProxyContract<'_> { nonpayable(&ctx.info)?; - // Simply assume all of our liquid assets are from unbondings + // Simply assumes all of our liquid assets are from unbondings let balance = ctx .deps .querier .query_balance(ctx.env.contract.address, cfg.denom)?; + // But discount burned stake + // FIXME: this is not accurate, as it doesn't take into account the unbonding period + let burned = self.burned.load(ctx.deps.storage)?; + let balance = coin(balance.amount.u128().saturating_sub(burned), &balance.denom); + + // Short circuit if there are no funds to send + if balance.amount.is_zero() { + return Ok(Response::new()); + } // Send them to the parent contract via `release_proxy_stake` let msg = to_binary(&native_staking_callback::ExecMsg::ReleaseProxyStake {})?; diff --git a/contracts/provider/native-staking-proxy/src/error.rs b/contracts/provider/native-staking-proxy/src/error.rs index d2c416a7..094fa45e 100644 --- a/contracts/provider/native-staking-proxy/src/error.rs +++ b/contracts/provider/native-staking-proxy/src/error.rs @@ -18,4 +18,7 @@ pub enum ContractError { #[error("Validator {0} has not enough delegated funds: {1}")] InsufficientDelegation(String, Uint128), + + #[error("Native proxy {0} has not enough delegated funds: {1}")] + InsufficientDelegations(String, Uint128), } diff --git a/contracts/provider/native-staking-proxy/src/multitest.rs b/contracts/provider/native-staking-proxy/src/multitest.rs index 6de9b395..bfab5124 100644 --- a/contracts/provider/native-staking-proxy/src/multitest.rs +++ b/contracts/provider/native-staking-proxy/src/multitest.rs @@ -55,7 +55,7 @@ fn setup<'app>( app: &'app App, owner: &str, user: &str, - validator: &str, + validators: &[&str], ) -> AnyResult> { let vault_code = mesh_vault::contract::multitest_utils::CodeId::store_code(app); let staking_code = mesh_native_staking::contract::multitest_utils::CodeId::store_code(app); @@ -89,16 +89,18 @@ fn setup<'app>( .unwrap(); // Stakes some of it locally. This instantiates the staking proxy contract for user - vault - .stake_local( - coin(100, OSMO), - to_binary(&mesh_native_staking::msg::StakeMsg { - validator: validator.to_owned(), - }) - .unwrap(), - ) - .call(user) - .unwrap(); + for &validator in validators { + vault + .stake_local( + coin(100, OSMO), + to_binary(&mesh_native_staking::msg::StakeMsg { + validator: validator.to_owned(), + }) + .unwrap(), + ) + .call(user) + .unwrap(); + } Ok(vault) } @@ -114,7 +116,7 @@ fn instantiation() { let validator = "validator1"; // Where to stake / unstake let app = init_app(user, &[validator]); // Fund user, create validator - setup(&app, owner, user, validator).unwrap(); + setup(&app, owner, user, &[validator]).unwrap(); // Access staking proxy instance let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( @@ -160,7 +162,7 @@ fn staking() { let validator = "validator1"; // Where to stake / unstake let app = init_app(user, &[validator]); // Fund user, create validator - let vault = setup(&app, owner, user, validator).unwrap(); + let vault = setup(&app, owner, user, &[validator]).unwrap(); // Access staking proxy instance let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( @@ -208,7 +210,7 @@ fn restaking() { let validator2 = "validator2"; // Where to re-stake let app = init_app(user, &[validator, validator2]); // Fund user, create validator - setup(&app, owner, user, validator).unwrap(); + setup(&app, owner, user, &[validator]).unwrap(); // Access staking proxy instance let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( @@ -249,7 +251,7 @@ fn unstaking() { let validator = "validator1"; // Where to stake / unstake let app = init_app(user, &[validator]); // Fund user, create validator - setup(&app, owner, user, validator).unwrap(); + setup(&app, owner, user, &[validator]).unwrap(); // Access staking proxy instance let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( @@ -274,6 +276,10 @@ fn unstaking() { // And that they are now held, until the unbonding period // First, check that the contract has no funds + // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt + app.app_mut() + .sudo(SudoMsg::Staking(StakingSudo::ProcessQueue {})) + .unwrap(); assert_eq!( app.app() .wrap() @@ -285,7 +291,7 @@ fn unstaking() { // Advance time until the unbonding period is over app.update_block(|block| { block.height += 1234; - block.time = block.time.plus_seconds(UNBONDING_PERIOD + 1); + block.time = block.time.plus_seconds(UNBONDING_PERIOD); }); // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt app.app_mut() @@ -302,6 +308,181 @@ fn unstaking() { ); } +#[test] +fn burning() { + let owner = "vault_admin"; + + let staking_addr = "contract1"; // Second contract (instantiated by vault on instantiation) + let proxy_addr = "contract2"; // Third contract (instantiated by staking contract on stake) + + let user = "user1"; // One who wants to local stake (uses the proxy) + let validator = "validator1"; // Where to stake / unstake + + let app = init_app(user, &[validator]); // Fund user, create validator + setup(&app, owner, user, &[validator]).unwrap(); + + // Access staking proxy instance + let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( + Addr::unchecked(proxy_addr), + &app, + ); + + // Burn 10%, from validator + staking_proxy + .burn(Some(validator.to_owned()), coin(10, OSMO)) + .call(staking_addr) + .unwrap(); + + // Check that funds have been unstaked + let delegation = app + .app() + .wrap() + .query_delegation(staking_proxy.contract_addr.clone(), validator.to_owned()) + .unwrap() + .unwrap(); + assert_eq!(delegation.amount, coin(90, OSMO)); + + // And that they are now held, until the unbonding period + // First, check that the contract has no funds + // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt + app.app_mut() + .sudo(SudoMsg::Staking(StakingSudo::ProcessQueue {})) + .unwrap(); + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr.clone(), OSMO) + .unwrap(), + coin(0, OSMO) + ); + + // Advance time until the unbonding period is over + app.update_block(|block| { + block.height += 1234; + block.time = block.time.plus_seconds(UNBONDING_PERIOD); + }); + // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt + app.app_mut() + .sudo(SudoMsg::Staking(StakingSudo::ProcessQueue {})) + .unwrap(); + + // Check that the contract now has the funds + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr.clone(), OSMO) + .unwrap(), + coin(10, OSMO) + ); + + // But they cannot be released + staking_proxy.release_unbonded().call(user).unwrap(); + + // Check that the contract still has the funds (they are being effectively "burned") + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr, OSMO) + .unwrap(), + coin(10, OSMO) + ); +} + +#[test] +fn burning_multiple_delegations() { + let owner = "vault_admin"; + + let staking_addr = "contract1"; // Second contract (instantiated by vault on instantiation) + let proxy_addr = "contract2"; // Third contract (instantiated by staking contract on stake) + + let user = "user1"; // One who wants to local stake (uses the proxy) + let validators = ["validator1", "validator2"]; // Where to stake / unstake + + let app = init_app(user, &validators); // Fund user, create validator + setup(&app, owner, user, &validators).unwrap(); + + // Access staking proxy instance + let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( + Addr::unchecked(proxy_addr), + &app, + ); + + // Burn 15%, no validator specified + let burn_amount = 15; + staking_proxy + .burn(None, coin(burn_amount, OSMO)) + .call(staking_addr) + .unwrap(); + + // Check that funds have been unstaked (15 / 2 = 7.5, rounded down to 7, rounded up to 8) + // First validator gets the round up + let delegation1 = app + .app() + .wrap() + .query_delegation( + staking_proxy.contract_addr.clone(), + validators[0].to_owned(), + ) + .unwrap() + .unwrap(); + assert_eq!(delegation1.amount, coin(100 - (burn_amount / 2 + 1), OSMO)); + let delegation2 = app + .app() + .wrap() + .query_delegation( + staking_proxy.contract_addr.clone(), + validators[1].to_owned(), + ) + .unwrap() + .unwrap(); + assert_eq!(delegation2.amount, coin(100 - burn_amount / 2, OSMO)); + + // And that they are now held, until the unbonding period + // First, check that the contract has no funds + // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt + app.app_mut() + .sudo(SudoMsg::Staking(StakingSudo::ProcessQueue {})) + .unwrap(); + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr.clone(), OSMO) + .unwrap(), + coin(0, OSMO) + ); + + // Advance time until the unbonding period is over + app.update_block(|block| { + block.height += 1234; + block.time = block.time.plus_seconds(UNBONDING_PERIOD); + }); + // Manually cause queue to get processed. TODO: Handle automatically in sylvia mt or cw-mt + app.app_mut() + .sudo(SudoMsg::Staking(StakingSudo::ProcessQueue {})) + .unwrap(); + + // Check that the contract now has the funds + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr.clone(), OSMO) + .unwrap(), + coin(15, OSMO) + ); + + // But they cannot be released + staking_proxy.release_unbonded().call(user).unwrap(); + + // Check that the contract still has the funds (they are being effectively "burned") + assert_eq!( + app.app() + .wrap() + .query_balance(staking_proxy.contract_addr, OSMO) + .unwrap(), + coin(15, OSMO) + ); +} + #[test] fn releasing_unbonded() { let owner = "vault_admin"; @@ -312,7 +493,7 @@ fn releasing_unbonded() { let validator = "validator1"; // Where to stake / unstake let app = init_app(user, &[validator]); // Fund user, create validator - let vault = setup(&app, owner, user, validator).unwrap(); + let vault = setup(&app, owner, user, &[validator]).unwrap(); // Access staking proxy instance let staking_proxy = contract::multitest_utils::NativeStakingProxyContractProxy::new( @@ -368,7 +549,7 @@ fn withdrawing_rewards() { let validator = "validator1"; // Where to stake / unstake let app = init_app(user, &[validator]); // Fund user, create validator - let vault = setup(&app, owner, user, validator).unwrap(); + let vault = setup(&app, owner, user, &[validator]).unwrap(); // Record current vault, staking and user funds let original_vault_funds = app diff --git a/contracts/provider/native-staking/src/error.rs b/contracts/provider/native-staking/src/error.rs index ffb4a4e7..30e9a7d2 100644 --- a/contracts/provider/native-staking/src/error.rs +++ b/contracts/provider/native-staking/src/error.rs @@ -22,6 +22,9 @@ pub enum ContractError { #[error("Missing instantiate reply data")] NoInstantiateData {}, + #[error("Missing proxy contract for {0}")] + NoProxy(String), + #[error("You cannot use a max slashing rate over 1.0 (100%)")] InvalidMaxSlashing, } diff --git a/contracts/provider/native-staking/src/local_staking_api.rs b/contracts/provider/native-staking/src/local_staking_api.rs index 6cf056e8..69a8355a 100644 --- a/contracts/provider/native-staking/src/local_staking_api.rs +++ b/contracts/provider/native-staking/src/local_staking_api.rs @@ -1,5 +1,5 @@ -use cosmwasm_std::{ensure_eq, from_slice, to_binary, Binary, Response, SubMsg, WasmMsg}; -use cw_utils::must_pay; +use cosmwasm_std::{ensure_eq, from_slice, to_binary, Binary, Coin, Response, SubMsg, WasmMsg}; +use cw_utils::{must_pay, nonpayable}; use sylvia::types::QueryCtx; use sylvia::{contract, types::ExecCtx}; @@ -77,6 +77,48 @@ impl LocalStakingApi for NativeStakingContract<'_> { } } + /// Burns stake. This is called when the user's collateral is slashed and, as part of slashing + /// propagation, the native staking contract needs to burn / discount the indicated slashing amount. + /// If `validator` is set, undelegate preferentially from it first. + /// If it is not set, undelegate evenly from all validators the user has stake in. + #[msg(exec)] + fn burn_stake( + &self, + ctx: ExecCtx, + owner: String, + amount: Coin, + validator: Option, + ) -> Result { + // Can only be called by the vault + let cfg = self.config.load(ctx.deps.storage)?; + ensure_eq!(cfg.vault, ctx.info.sender, ContractError::Unauthorized {}); + // Assert no funds are passed in + nonpayable(&ctx.info)?; + + let owner_addr = ctx.deps.api.addr_validate(&owner)?; + + // Look up if there is a proxy to match. Fail or call burn on existing + match self + .proxy_by_owner + .may_load(ctx.deps.storage, &owner_addr)? + { + None => Err(ContractError::NoProxy(owner)), + Some(proxy_addr) => { + // Send burn message to the proxy contract + let msg = to_binary(&mesh_native_staking_proxy::contract::ExecMsg::Burn { + validator, + amount, + })?; + let wasm_msg = WasmMsg::Execute { + contract_addr: proxy_addr.into(), + msg, + funds: ctx.info.funds, + }; + Ok(Response::new().add_message(wasm_msg)) + } + } + } + /// Returns the maximum percentage that can be slashed #[msg(query)] fn max_slash(&self, ctx: QueryCtx) -> Result { diff --git a/contracts/provider/vault/src/contract.rs b/contracts/provider/vault/src/contract.rs index 88a4a7f0..062dcbcb 100644 --- a/contracts/provider/vault/src/contract.rs +++ b/contracts/provider/vault/src/contract.rs @@ -725,15 +725,17 @@ impl VaultContract<'_> { /// /// This slashes the users that have funds delegated to the validator involved in the /// misbehaviour. - /// - /// In case of remote slashing, it makes sure to unbond native user funds from the native - /// staking contract, if they are needed for slashing. - /// /// It also checks that the mesh security invariants are not violated after slashing, /// i.e. performs slashing propagation across lien holders, for all of the slashed users. - fn slash(&self, ctx: &mut ExecCtx, slashes: &[SlashInfo]) -> Result<(), ContractError> { + fn slash( + &self, + ctx: &mut ExecCtx, + slashes: &[SlashInfo], + validator: &str, + ) -> Result, ContractError> { // Process users that belong to lien_holder let lien_holder = ctx.info.sender.clone(); + let mut msgs = vec![]; for slash in slashes { let slash_user = Addr::unchecked(slash.user.clone()); // User must have a lien with this lien holder @@ -758,13 +760,16 @@ impl VaultContract<'_> { let free_collateral = user_info.free_collateral().low(); // For simplicity if free_collateral < slash_amount { // Check / adjust mesh security invariants according to the new collateral - self.propagate_slash( + let burn_msgs = self.propagate_slash( ctx.deps.storage, &slash_user, &mut user_info, new_collateral, slash_amount - free_collateral, + &lien_holder, + validator, )?; + msgs.extend_from_slice(&burn_msgs); } // Adjust collateral user_info.collateral = new_collateral; @@ -773,17 +778,23 @@ impl VaultContract<'_> { // Save user info self.users.save(ctx.deps.storage, &slash_user, &user_info)?; } - Ok(()) + Ok(msgs) } + #[allow(clippy::too_many_arguments)] fn propagate_slash( &self, storage: &mut dyn Storage, user: &Addr, user_info: &mut UserInfo, new_collateral: Uint128, - required_collateral: Uint128, - ) -> Result<(), ContractError> { + claimed_collateral: Uint128, + slashed_lien_holder: &Addr, + slashed_validator: &str, + ) -> Result, ContractError> { + let denom = self.config.load(storage)?.denom; + let native_staking = self.local_staking.load(storage)?; + let mut msgs = vec![]; if user_info.max_lien.high() >= user_info.total_slashable.high() { // Liens adjustment let broken_liens = self @@ -800,16 +811,30 @@ impl VaultContract<'_> { let new_low_amount = min(lien.amount.low(), new_collateral); let new_high_amount = min(lien.amount.high(), new_collateral); // Adjust the user's total slashable amount + let adjust_amount_low = lien.amount.low() - new_low_amount; + let adjust_amount_high = lien.amount.high() - new_high_amount; user_info.total_slashable = ValueRange::new( - user_info.total_slashable.low() - - (lien.amount.low() - new_low_amount) * lien.slashable, - user_info.total_slashable.high() - - (lien.amount.high() - new_high_amount) * lien.slashable, + user_info.total_slashable.low() - adjust_amount_low * lien.slashable, + user_info.total_slashable.high() - adjust_amount_high * lien.slashable, ); // Keep the invariant over the lien lien.amount = ValueRange::new(new_low_amount, new_high_amount); self.liens.save(storage, (user, &lien_holder), &lien)?; - // TODO: Remove required amount from the user's stake (needs rebalance msg) + // Remove the required amount from the user's stake + let validator = if lien_holder == slashed_lien_holder { + Some(slashed_validator.to_string()) + } else { + None + }; + let burn_msg = self.burn_stake( + user, + &denom, + &native_staking, + &lien_holder, + adjust_amount_high, // High amount for simplicity + validator, + )?; + msgs.push(burn_msg); } } else { // Total slashable adjustment @@ -821,15 +846,15 @@ impl VaultContract<'_> { let (_, lien) = item?; Ok::<_, ContractError>(sum + lien.slashable) })?; - let round_up = if (required_collateral * slash_ratio_sum.inv().unwrap()) + let round_up = if (claimed_collateral * slash_ratio_sum.inv().unwrap()) * slash_ratio_sum - != required_collateral + != claimed_collateral { Uint128::one() } else { Uint128::zero() }; - let sub_amount = required_collateral * slash_ratio_sum.inv().unwrap() + round_up; + let sub_amount = claimed_collateral * slash_ratio_sum.inv().unwrap() + round_up; let all_liens = self .liens .prefix(user) @@ -843,10 +868,47 @@ impl VaultContract<'_> { // Keep the invariant over the lien lien.amount.sub(sub_amount, Uint128::zero())?; self.liens.save(storage, (user, &lien_holder), &lien)?; - // TODO: Remove required amount from the user's stake (needs rebalance msg) + // Remove the required amount from the user's stake + let validator = if lien_holder == slashed_lien_holder { + Some(slashed_validator.to_string()) + } else { + None + }; + let burn_msg = self.burn_stake( + user, + &denom, + &native_staking, + &lien_holder, + sub_amount, + validator, + )?; + msgs.push(burn_msg); } } - Ok(()) + Ok(msgs) + } + + fn burn_stake( + &self, + user: &Addr, + denom: &String, + native_staking: &Option, + lien_holder: &Addr, + amount: Uint128, + validator: Option, + ) -> Result { + // Native vs cross staking + let msg = match &native_staking { + Some(local_staking) if local_staking.contract.0 == lien_holder => { + let contract = local_staking.contract.clone(); + contract.burn_stake(user, coin(amount.u128(), denom), validator)? + } + _ => { + let contract = CrossStakingApiHelper(lien_holder.clone()); + contract.burn_virtual_stake(user, coin(amount.u128(), denom), validator)? + } + }; + Ok(msg) } } @@ -913,14 +975,17 @@ impl VaultApi for VaultContract<'_> { &self, mut ctx: ExecCtx, slashes: Vec, + validator: String, ) -> Result { nonpayable(&ctx.info)?; - self.slash(&mut ctx, &slashes)?; + let msgs = self.slash(&mut ctx, &slashes, &validator)?; let resp = Response::new() + .add_messages(msgs) .add_attribute("action", "process_cross_slashing") .add_attribute("lien_holder", ctx.info.sender) + .add_attribute("validator", validator.to_string()) .add_attribute( "users", slashes diff --git a/contracts/provider/vault/src/multitest.rs b/contracts/provider/vault/src/multitest.rs index 019172ed..76533ceb 100644 --- a/contracts/provider/vault/src/multitest.rs +++ b/contracts/provider/vault/src/multitest.rs @@ -2152,15 +2152,15 @@ fn cross_slash_scenario_4() { ] ); - // TODO: external-staking slashing propagation + // Considering external-staking slashing propagation let cross_stake2 = cross_staking_2 .stakes(user.to_string(), None, None) .unwrap(); assert_eq!( cross_stake2.stakes, [ - StakeInfo::new(user, validators_2[0], &Stake::from_amount(100u128.into())), - StakeInfo::new(user, validators_2[1], &Stake::from_amount(88u128.into())) + StakeInfo::new(user, validators_2[0], &Stake::from_amount(99u128.into())), + StakeInfo::new(user, validators_2[1], &Stake::from_amount(87u128.into())) ] ); } @@ -2322,7 +2322,7 @@ fn cross_slash_scenario_5() { assert_eq!(acc_details.free, ValueRange::new_val(Uint128::zero())); // Cross stake - // TODO: external-staking slashing propagation + // Considering external-staking slashing propagation let cross_stake1 = cross_staking_1 .stakes(user.to_string(), None, None) .unwrap(); @@ -2331,7 +2331,7 @@ fn cross_slash_scenario_5() { [StakeInfo::new( user, validators[0], - &Stake::from_amount(90u128.into()) + &Stake::from_amount(68u128.into()) ),] ); @@ -2343,7 +2343,7 @@ fn cross_slash_scenario_5() { [StakeInfo::new( user, validators[1], - &Stake::from_amount(80u128.into()) + &Stake::from_amount(58u128.into()) ),] ); @@ -2355,7 +2355,7 @@ fn cross_slash_scenario_5() { [StakeInfo::new( user, validators[2], - &Stake::from_amount(100u128.into()) + &Stake::from_amount(78u128.into()) ),] ); } diff --git a/packages/apis/src/cross_staking_api.rs b/packages/apis/src/cross_staking_api.rs index cb3129f7..040cb917 100644 --- a/packages/apis/src/cross_staking_api.rs +++ b/packages/apis/src/cross_staking_api.rs @@ -28,6 +28,21 @@ pub trait CrossStakingApi { msg: Binary, ) -> Result; + /// Burns stake. This is called when the user's collateral is slashed and, as part of slashing + /// propagation, the virtual staking contract needs to burn / discount the indicated slashing amount. + /// If `validator` is set, undelegate preferentially from it first. + /// If it is not set, undelegate evenly from all validators the user has stake in. + /// This should be transactional, but it's not. If the transaction fails there isn't much we can + /// do, besides logging the failure. + #[msg(exec)] + fn burn_virtual_stake( + &self, + ctx: ExecCtx, + owner: String, + amount: Coin, + validator: Option, + ) -> Result; + /// Returns the maximum percentage that can be slashed #[msg(query)] fn max_slash(&self, ctx: QueryCtx) -> Result; @@ -63,6 +78,25 @@ impl CrossStakingApiHelper { Ok(wasm) } + pub fn burn_virtual_stake( + &self, + owner: &Addr, + amount: Coin, + validator: Option, + ) -> Result { + let msg = CrossStakingApiExecMsg::BurnVirtualStake { + owner: owner.to_string(), + validator, + amount, + }; + let wasm = WasmMsg::Execute { + contract_addr: self.0.to_string(), + msg: to_binary(&msg)?, + funds: vec![], + }; + Ok(wasm) + } + pub fn max_slash(&self, deps: Deps) -> Result { let query = CrossStakingApiQueryMsg::MaxSlash {}; deps.querier.query_wasm_smart(&self.0, &query) diff --git a/packages/apis/src/ibc/packet.rs b/packages/apis/src/ibc/packet.rs index 4a0aa4aa..4e557ab7 100644 --- a/packages/apis/src/ibc/packet.rs +++ b/packages/apis/src/ibc/packet.rs @@ -30,6 +30,17 @@ pub enum ProviderPacket { /// This is local to the sending side to track the transaction, should be passed through opaquely on the consumer tx_id: u64, }, + /// This should be called when we burn tokens from the given validators, because of slashing + /// propagation / vault invariants keeping. + /// If there is more than one validator, the burn amount will be split evenly between them. + /// This is non-transactional, as if it fails we cannot do much about it, besides logging the failure. + Burn { + validators: Vec, + /// This is the local (provider-side) denom that is being burned in the vault. + /// It will be converted to the consumer-side staking token in the converter with help + /// of the price feed. + burn: Coin, + }, /// This is part of the rewards protocol TransferRewards { /// Amount previously received by ConsumerPacket::Distribute diff --git a/packages/apis/src/local_staking_api.rs b/packages/apis/src/local_staking_api.rs index dc35e6f0..0ec9180a 100644 --- a/packages/apis/src/local_staking_api.rs +++ b/packages/apis/src/local_staking_api.rs @@ -30,6 +30,19 @@ pub trait LocalStakingApi { msg: Binary, ) -> Result; + /// Burns stake. This is called when the user's collateral is slashed and, as part of slashing + /// propagation, the native staking contract needs to burn / discount the indicated slashing amount. + /// If `validator` is set, undelegate preferentially from it first. + /// If it is not set, undelegate evenly from all validators the user has stake in. + #[msg(exec)] + fn burn_stake( + &self, + ctx: ExecCtx, + owner: String, + amount: Coin, + validator: Option, + ) -> Result; + /// Returns the maximum percentage that can be slashed #[msg(query)] fn max_slash(&self, ctx: QueryCtx) -> Result; @@ -61,6 +74,25 @@ impl LocalStakingApiHelper { Ok(wasm) } + pub fn burn_stake( + &self, + owner: &Addr, + amount: Coin, + validator: Option, + ) -> Result { + let msg = LocalStakingApiExecMsg::BurnStake { + owner: owner.to_string(), + validator, + amount, + }; + let wasm = WasmMsg::Execute { + contract_addr: self.0.to_string(), + msg: to_binary(&msg)?, + funds: vec![], + }; + Ok(wasm) + } + pub fn max_slash(&self, deps: Deps) -> Result { let query = LocalStakingApiQueryMsg::MaxSlash {}; deps.querier.query_wasm_smart(&self.0, &query) diff --git a/packages/apis/src/vault_api.rs b/packages/apis/src/vault_api.rs index a3e853fa..fa906670 100644 --- a/packages/apis/src/vault_api.rs +++ b/packages/apis/src/vault_api.rs @@ -41,9 +41,16 @@ pub trait VaultApi { fn rollback_tx(&self, ctx: ExecCtx, tx_id: u64) -> Result; /// This must be called by the external staking contract to process a slashing event - /// because of a misbehaviour on the Consumer chain + /// because of a misbehaviour on the Consumer chain. + /// `validator` is the misbehaving validator address. Used during slashing propagation to + /// preferentially burn stakes from this validator. #[msg(exec)] - fn cross_slash(&self, ctx: ExecCtx, slashes: Vec) -> Result; + fn cross_slash( + &self, + ctx: ExecCtx, + slashes: Vec, + validator: String, + ) -> Result; } #[cw_serde] @@ -93,8 +100,15 @@ impl VaultApiHelper { Ok(wasm) } - pub fn process_cross_slashing(&self, slashes: Vec) -> Result { - let msg = VaultApiExecMsg::CrossSlash { slashes }; + pub fn process_cross_slashing( + &self, + slashes: Vec, + slashed_validator: &str, + ) -> Result { + let msg = VaultApiExecMsg::CrossSlash { + slashes, + validator: slashed_validator.to_string(), + }; let wasm = WasmMsg::Execute { contract_addr: self.0.to_string(), msg: to_binary(&msg)?, diff --git a/packages/apis/src/virtual_staking_api.rs b/packages/apis/src/virtual_staking_api.rs index ffb03857..5296444d 100644 --- a/packages/apis/src/virtual_staking_api.rs +++ b/packages/apis/src/virtual_staking_api.rs @@ -26,6 +26,17 @@ pub trait VirtualStakingApi { validator: String, amount: Coin, ) -> Result; + + /// Burns stake. This is called when the user's collateral is slashed and, as part of slashing + /// propagation, the native staking contract needs to burn / discount the indicated slashing amount. + /// Undelegates evenly from all `validators`. + #[msg(exec)] + fn burn( + &self, + ctx: ExecCtx, + validators: Vec, + amount: Coin, + ) -> Result; } #[cw_serde] diff --git a/packages/burn/Cargo.toml b/packages/burn/Cargo.toml new file mode 100644 index 00000000..a748f3c2 --- /dev/null +++ b/packages/burn/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "mesh-burn" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } diff --git a/packages/burn/src/burn.rs b/packages/burn/src/burn.rs new file mode 100644 index 00000000..14d59e3e --- /dev/null +++ b/packages/burn/src/burn.rs @@ -0,0 +1,149 @@ +use std::cmp::min; +use std::collections::HashMap; + +/// Tries to burn `amount` evenly from `delegations`. +/// Assigns the remainder to the first validator that has enough stake. +/// `delegations` must not be empty, or this will panic. +/// +/// Returns the total amount burned, and the list of validators and amounts. +/// The total burned amount can be used to check if the user has enough stake in `delegations`. +/// +/// N.B..: This can be improved by distributing the remainder evenly across validators. +pub fn distribute_burn( + delegations: &[(String, u128)], + amount: u128, +) -> (u128, Vec<(&String, u128)>) { + let mut burns = HashMap::new(); + let mut burned = 0; + let proportional_amount = amount / delegations.len() as u128; + for (validator, delegated_amount) in delegations { + // Check validator has `proportional_amount` delegated. Adjust accordingly if not. + let burn_amount = min(*delegated_amount, proportional_amount); + if burn_amount == 0 { + continue; + } + burns + .entry(validator) + .and_modify(|amount| *amount += burn_amount) + .or_insert(burn_amount); + burned += burn_amount; + } + // Adjust possible rounding issues / unfunded validators + if burned < amount { + // Look for the first validator that has enough stake, and burn it from there + let burn_amount = amount - burned; + for (validator, delegated_amount) in delegations { + if burn_amount + burns.get(&validator).unwrap_or(&0) <= *delegated_amount { + burns + .entry(validator) + .and_modify(|amount| *amount += burn_amount) + .or_insert(burn_amount); + burned += burn_amount; + break; + } + } + } + (burned, burns.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[track_caller] + fn assert_burns(burns: &[(&String, u128)], expected: &[(&str, u128)]) { + let mut burns = burns + .iter() + .map(|(validator, amount)| (validator.to_string(), *amount)) + .collect::>(); + burns.sort_by(|(v1, _), (v2, _)| v1.cmp(v2)); + let expected = expected + .iter() + .map(|(validator, amount)| (validator.to_string(), *amount)) + .collect::>(); + assert_eq!(burns, expected); + } + + #[test] + fn distribute_burn_works() { + let delegations = vec![ + ("validator1".to_string(), 100), + ("validator2".to_string(), 200), + ("validator3".to_string(), 300), + ]; + let (burned, burns) = distribute_burn(&delegations, 100); + assert_eq!(burned, 100); + assert_burns( + &burns, + &[("validator1", 34), ("validator2", 33), ("validator3", 33)], + ); + } + + /// Panics on empty delegations + #[test] + #[should_panic] + fn distribute_burn_empty_distributions() { + let delegations = vec![]; + distribute_burn(&delegations, 100); + } + + #[test] + fn distribute_burn_one_validator() { + let delegations = vec![("validator1".to_string(), 100)]; + let (burned, burns) = distribute_burn(&delegations, 100); + assert_eq!(burned, 100); + assert_burns(&burns, &[("validator1", 100)]); + } + + /// Some validators do not have enough funds, so the remainder is burned from the first validator + /// that has enough funds + #[test] + fn distribute_burn_unfunded_validator() { + let delegations = vec![ + ("validator1".to_string(), 100), + ("validator2".to_string(), 1), + ]; + let (burned, burns) = distribute_burn(&delegations, 101); + assert_eq!(burned, 101); + assert_burns(&burns, &[("validator1", 100), ("validator2", 1)]); + } + + /// There are not enough funds to burn, so the returned burned amount is less that the requested amount + #[test] + fn distribute_burn_insufficient_delegations() { + let delegations = vec![ + ("validator1".to_string(), 100), + ("validator2".to_string(), 1), + ]; + let (burned, burns) = distribute_burn(&delegations, 102); + assert_eq!(burned, 52); + assert_burns(&burns, &[("validator1", 51), ("validator2", 1)]); + } + + /// There are enough funds to burn, but they are not consolidated enough in a single delegation. + // FIXME? This is a limitation of the current impl. + #[test] + fn distribute_burn_insufficient_whole_delegation() { + let delegations = vec![ + ("validator1".to_string(), 29), + ("validator2".to_string(), 30), + ("validator3".to_string(), 31), + ("validator4".to_string(), 1), + ]; + assert_eq!( + delegations.iter().map(|(_, amount)| amount).sum::(), + 91 + ); + let (burned, burns) = distribute_burn(&delegations, 91); + assert_eq!(burned, 67); + assert_burns( + &burns, + &[ + ("validator1", 22), + ("validator2", 22), + ("validator3", 22), + ("validator4", 1), + ], + ); + } +} diff --git a/packages/burn/src/lib.rs b/packages/burn/src/lib.rs new file mode 100644 index 00000000..ca4d28e5 --- /dev/null +++ b/packages/burn/src/lib.rs @@ -0,0 +1,3 @@ +mod burn; + +pub use burn::distribute_burn;