diff --git a/contracts/core/src/contract.rs b/contracts/core/src/contract.rs index 19424eba..98ab3d97 100644 --- a/contracts/core/src/contract.rs +++ b/contracts/core/src/contract.rs @@ -1,19 +1,20 @@ use crate::error::{ContractError, ContractResult}; use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - attr, ensure, ensure_eq, ensure_ne, entry_point, to_json_binary, Attribute, BankQuery, Binary, - Coin, CosmosMsg, CustomQuery, Decimal, Deps, DepsMut, Env, MessageInfo, Order, QueryRequest, - Response, StdError, StdResult, Timestamp, Uint128, WasmMsg, + attr, ensure, ensure_eq, ensure_ne, entry_point, to_json_binary, Addr, Attribute, BankQuery, + Binary, Coin, CosmosMsg, CustomQuery, Decimal, Deps, DepsMut, Env, MessageInfo, Order, + QueryRequest, Response, StdError, StdResult, Timestamp, Uint128, WasmMsg, }; use cw2::set_contract_version; use lido_helpers::answer::response; use lido_puppeteer_base::msg::TransferReadyBatchMsg; +use lido_puppeteer_base::state::RedeemShareItem; use lido_staking_base::state::core::{ unbond_batches_map, Config, ConfigOptional, ContractState, FeeItem, NonNativeRewardsItem, UnbondBatch, UnbondBatchStatus, UnbondItem, BONDED_AMOUNT, COLLECTED_FEES, CONFIG, FAILED_BATCH_ID, FSM, LAST_ICA_BALANCE_CHANGE_HEIGHT, LAST_PUPPETEER_RESPONSE, - NON_NATIVE_REWARDS_CONFIG, PENDING_TRANSFER, PRE_UNBONDING_BALANCE, TOTAL_LSM_SHARES, - UNBOND_BATCH_ID, + LSM_SHARES_TO_REDEEM, NON_NATIVE_REWARDS_CONFIG, PENDING_LSM_SHARES, PENDING_TRANSFER, + PRE_UNBONDING_BALANCE, TOTAL_LSM_SHARES, UNBOND_BATCH_ID, }; use lido_staking_base::state::validatorset::ValidatorInfo; use lido_staking_base::state::withdrawal_voucher::{Metadata, Trait}; @@ -66,6 +67,14 @@ pub fn instantiate( pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> ContractResult { Ok(match msg { QueryMsg::Config {} => to_json_binary(&CONFIG.load(deps.storage)?)?, + QueryMsg::Owner {} => to_json_binary( + &cw_ownable::get_ownership(deps.storage)? + .owner + .unwrap_or(Addr::unchecked("")) + .to_string(), + )?, + QueryMsg::PendingLSMShares {} => query_pending_lsm_shares(deps)?, + QueryMsg::LSMSharesToRedeem {} => query_lsm_shares_to_redeem(deps)?, QueryMsg::TotalBonded {} => to_json_binary(&BONDED_AMOUNT.load(deps.storage)?)?, QueryMsg::ExchangeRate {} => to_json_binary(&query_exchange_rate(deps, env, None)?)?, QueryMsg::UnbondBatch { batch_id } => query_unbond_batch(deps, batch_id)?, @@ -79,6 +88,20 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> ContractResul }) } +fn query_pending_lsm_shares(deps: Deps) -> ContractResult { + let shares: Vec<(String, (String, Uint128))> = PENDING_LSM_SHARES + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .collect::>>()?; + to_json_binary(&shares).map_err(From::from) +} + +fn query_lsm_shares_to_redeem(deps: Deps) -> ContractResult { + let shares: Vec<(String, (String, Uint128))> = LSM_SHARES_TO_REDEEM + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .collect::>>()?; + to_json_binary(&shares).map_err(From::from) +} + fn query_exchange_rate( deps: Deps, env: Env, @@ -228,6 +251,49 @@ fn execute_puppeteer_hook( ); if let lido_puppeteer_base::msg::ResponseHookMsg::Success(_) = msg { LAST_ICA_BALANCE_CHANGE_HEIGHT.save(deps.storage, &env.block.height)?; + if let lido_puppeteer_base::msg::ResponseHookMsg::Success(success_msg) = &msg { + match &success_msg.transaction { + lido_puppeteer_base::msg::Transaction::IBCTransfer { + denom, + amount, + recipient: _, + } => { + let current_pending = + PENDING_LSM_SHARES.may_load(deps.storage, denom.to_string())?; + if let Some((remote_denom, current_amount)) = current_pending { + let sent_amount = Uint128::from(*amount); + LSM_SHARES_TO_REDEEM.update(deps.storage, denom.to_string(), |one| { + let mut new = one.unwrap_or((remote_denom, Uint128::zero())); + new.1 += sent_amount; + StdResult::Ok(new) + })?; + if current_amount == sent_amount { + PENDING_LSM_SHARES.remove(deps.storage, denom.to_string()); + } else { + PENDING_LSM_SHARES.update(deps.storage, denom.to_string(), |one| { + match one { + Some(one) => { + let mut new = one; + new.1 -= Uint128::from(*amount); + StdResult::Ok(new) + } + None => unreachable!("denom should be in the map"), + } + })?; + } + } + } + lido_puppeteer_base::msg::Transaction::RedeemShares { items, .. } => { + let mut sum = 0u128; + for item in items { + sum += item.amount.u128(); + LSM_SHARES_TO_REDEEM.remove(deps.storage, item.local_denom.to_string()); + } + TOTAL_LSM_SHARES.update(deps.storage, |one| StdResult::Ok(one - sum))?; + } + _ => {} + } + } } // if it's error we don't need to save the height because balance wasn't changed LAST_PUPPETEER_RESPONSE.save(deps.storage, &msg)?; @@ -267,9 +333,17 @@ fn execute_tick_idle( if env.block.time.seconds() - last_idle_call < config.idle_min_interval { //process non-native rewards if let Some(transfer_msg) = - get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info, env)? + get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info.clone(), &env)? { messages.push(transfer_msg); + } else if let Some(lsm_msg) = + get_pending_redeem_msg(deps.as_ref(), config, &env, info.funds.clone())? + { + messages.push(lsm_msg); + } else if let Some(lsm_msg) = + get_pending_lsm_share_msg(deps, config, &env, info.funds.clone())? + { + messages.push(lsm_msg); } else { //return error if none return Err(ContractError::IdleMinIntervalIsNotReached {}); @@ -375,7 +449,7 @@ fn execute_tick_idle( if validators_to_claim.is_empty() { attrs.push(attr("validators_to_claim", "empty")); if let Some((transfer_msg, pending_amount)) = - get_transfer_pending_balance(deps.as_ref(), &env, config, info.funds.clone())? + get_transfer_pending_balance_msg(deps.as_ref(), &env, config, info.funds.clone())? { FSM.go_to(deps.storage, ContractState::Transfering)?; PENDING_TRANSFER.save(deps.storage, &pending_amount)?; @@ -440,7 +514,7 @@ fn execute_tick_claiming( } } if let Some((transfer_msg, pending_amount)) = - get_transfer_pending_balance(deps.as_ref(), &env, config, info.funds.clone())? + get_transfer_pending_balance_msg(deps.as_ref(), &env, config, info.funds.clone())? { FSM.go_to(deps.storage, ContractState::Transfering)?; PENDING_TRANSFER.save(deps.storage, &pending_amount)?; @@ -592,8 +666,13 @@ fn execute_bond( BONDED_AMOUNT.update(deps.storage, |total| StdResult::Ok(total + amount))?; let denom_type = check_denom::check_denom(&deps, &denom, &config)?; - if denom_type == check_denom::DenomType::LsmShare { + if let check_denom::DenomType::LsmShare(remote_denom) = denom_type { TOTAL_LSM_SHARES.update(deps.storage, |total| StdResult::Ok(total + amount.u128()))?; + PENDING_LSM_SHARES.update(deps.storage, denom, |one| { + let mut new = one.unwrap_or((remote_denom, Uint128::zero())); + new.1 += amount; + StdResult::Ok(new) + })?; } let mut attrs = vec![attr("action", "bond")]; @@ -824,7 +903,7 @@ fn get_unbonded_batch(deps: Deps) -> ContractResult( +fn get_transfer_pending_balance_msg( deps: Deps, env: &Env, config: &Config, @@ -995,7 +1074,7 @@ fn new_unbond(now: u64) -> UnbondBatch { pub fn get_non_native_rewards_and_fee_transfer_msg( deps: Deps, info: MessageInfo, - env: Env, + env: &Env, ) -> ContractResult>> { let config = CONFIG.load(deps.storage)?; let non_native_rewards_receivers = NON_NATIVE_REWARDS_CONFIG.load(deps.storage)?; @@ -1071,13 +1150,76 @@ pub fn get_non_native_rewards_and_fee_transfer_msg( }))) } +fn get_pending_redeem_msg( + deps: Deps, + config: &Config, + env: &Env, + funds: Vec, +) -> ContractResult>> { + if LSM_SHARES_TO_REDEEM + .keys(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .count() + < config.lsm_redeem_threshold as usize + { + return Ok(None); + } + let shares_to_redeeem = LSM_SHARES_TO_REDEEM + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .collect::>>()?; + let items = shares_to_redeeem + .iter() + .map(|(local_denom, (denom, amount))| RedeemShareItem { + amount: *amount, + local_denom: local_denom.to_string(), + remote_denom: denom.to_string(), + }) + .collect(); + Ok(Some(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: config.puppeteer_contract.to_string(), + msg: to_json_binary( + &lido_staking_base::msg::puppeteer::ExecuteMsg::RedeemShares { + items, + timeout: Some(config.puppeteer_timeout), + reply_to: env.contract.address.to_string(), + }, + )?, + funds, + }))) +} + +fn get_pending_lsm_share_msg( + deps: DepsMut, + config: &Config, + env: &Env, + funds: Vec, +) -> ContractResult>> { + let lsm_share: Option<(String, (String, Uint128))> = PENDING_LSM_SHARES.first(deps.storage)?; + match lsm_share { + Some((denom, (_remote_denom, amount))) => Ok(Some(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: config.puppeteer_contract.to_string(), + msg: to_json_binary( + &lido_staking_base::msg::puppeteer::ExecuteMsg::IBCTransfer { + timeout: config.puppeteer_timeout, + reply_to: env.contract.address.to_string(), + }, + )?, + funds: { + let mut all_funds = vec![cosmwasm_std::Coin { denom, amount }]; + all_funds.extend(funds); + all_funds + }, + }))), + None => Ok(None), + } +} + mod check_denom { use super::*; #[derive(PartialEq)] pub enum DenomType { Base, - LsmShare, + LsmShare(String), } // XXX: cosmos_sdk_proto defines these structures for me, @@ -1125,7 +1267,6 @@ mod check_denom { if denom == config.base_denom { return Ok(DenomType::Base); } - let trace = query_denom_trace(deps, denom)?.denom_trace; let (port, channel) = trace .path @@ -1151,7 +1292,6 @@ mod check_denom { if validator_info.is_none() { return Err(ContractError::InvalidDenom {}); } - - Ok(DenomType::LsmShare) + Ok(DenomType::LsmShare(trace.base_denom)) } } diff --git a/contracts/core/src/tests.rs b/contracts/core/src/tests.rs index a091bec5..98e7b740 100644 --- a/contracts/core/src/tests.rs +++ b/contracts/core/src/tests.rs @@ -144,11 +144,11 @@ fn get_default_config(fee: Option) -> Config { unbonding_safe_period: 10, unbond_batch_switch_time: 6000, pump_address: None, - owner: "owner".to_string(), ld_denom: None, channel: "channel".to_string(), fee, fee_address: Some("fee_address".to_string()), + lsm_redeem_threshold: 10u64, bond_limit: None, } } @@ -184,7 +184,7 @@ fn get_non_native_rewards_and_fee_transfer_msg_success() { let info = mock_info("addr0000", &[Coin::new(1000, "untrn")]); let result: CosmosMsg = - get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info, mock_env()) + get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info, &mock_env()) .unwrap() .unwrap(); @@ -240,7 +240,7 @@ fn get_non_native_rewards_and_fee_transfer_msg_zero_fee() { let info = mock_info("addr0000", &[Coin::new(1000, "untrn")]); let result: CosmosMsg = - get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info, mock_env()) + get_non_native_rewards_and_fee_transfer_msg(deps.as_ref(), info, &mock_env()) .unwrap() .unwrap(); diff --git a/contracts/factory/src/contract.rs b/contracts/factory/src/contract.rs index 3650b1cf..9144d329 100644 --- a/contracts/factory/src/contract.rs +++ b/contracts/factory/src/contract.rs @@ -427,6 +427,7 @@ fn execute_init( idle_min_interval: core_params.idle_min_interval, bond_limit: core_params.bond_limit, channel: core_params.channel, + lsm_redeem_threshold: core_params.lsm_redeem_threshold, owner: env.contract.address.to_string(), fee: None, fee_address: None, diff --git a/contracts/factory/src/msg.rs b/contracts/factory/src/msg.rs index 4d1e1e13..2be6fa33 100644 --- a/contracts/factory/src/msg.rs +++ b/contracts/factory/src/msg.rs @@ -24,6 +24,7 @@ pub struct CoreParams { pub unbonding_period: u64, pub unbonding_safe_period: u64, pub unbond_batch_switch_time: u64, + pub lsm_redeem_threshold: u64, pub channel: String, pub bond_limit: Option, } diff --git a/contracts/hook-tester/src/contract.rs b/contracts/hook-tester/src/contract.rs index ca8f217c..47b9877e 100644 --- a/contracts/hook-tester/src/contract.rs +++ b/contracts/hook-tester/src/contract.rs @@ -3,7 +3,10 @@ use cosmwasm_std::{ Response, StdResult, Uint128, WasmMsg, }; use lido_helpers::answer::response; -use lido_puppeteer_base::msg::{ResponseHookErrorMsg, ResponseHookMsg, ResponseHookSuccessMsg}; +use lido_puppeteer_base::{ + msg::{ResponseHookErrorMsg, ResponseHookMsg, ResponseHookSuccessMsg}, + state::RedeemShareItem, +}; use lido_staking_base::{ msg::hook_tester::{ExecuteMsg, InstantiateMsg, QueryMsg}, state::hook_tester::{Config, ANSWERS, CONFIG, ERRORS}, @@ -252,10 +255,12 @@ fn execute_redeem_share( let msg = CosmosMsg::Wasm(WasmMsg::Execute { contract_addr: config.puppeteer_addr, msg: to_json_binary( - &lido_staking_base::msg::puppeteer::ExecuteMsg::RedeemShare { - validator, - amount, - denom, + &lido_staking_base::msg::puppeteer::ExecuteMsg::RedeemShares { + items: vec![RedeemShareItem { + remote_denom: denom, + amount, + local_denom: "some".to_string(), + }], timeout, reply_to: env.contract.address.to_string(), }, diff --git a/contracts/puppeteer/src/contract.rs b/contracts/puppeteer/src/contract.rs index cbd54d0d..1e5c46f1 100644 --- a/contracts/puppeteer/src/contract.rs +++ b/contracts/puppeteer/src/contract.rs @@ -35,7 +35,8 @@ use lido_puppeteer_base::{ }, proto::MsgIBCTransfer, state::{ - PuppeteerBase, ReplyMsg, TxState, TxStateStatus, UnbondingDelegation, ICA_ID, LOCAL_DENOM, + PuppeteerBase, RedeemShareItem, ReplyMsg, TxState, TxStateStatus, UnbondingDelegation, + ICA_ID, LOCAL_DENOM, }, }; use lido_staking_base::{ @@ -195,13 +196,11 @@ pub fn execute( timeout, reply_to, } => execute_tokenize_share(deps, info, validator, amount, timeout, reply_to), - ExecuteMsg::RedeemShare { - validator, - amount, - denom, + ExecuteMsg::RedeemShares { + items, timeout, reply_to, - } => execute_redeem_share(deps, info, validator, amount, denom, timeout, reply_to), + } => execute_redeem_shares(deps, info, items, timeout, reply_to), ExecuteMsg::ClaimRewardsAndOptionalyTransfer { validators, transfer, @@ -713,20 +712,16 @@ fn execute_tokenize_share( Ok(Response::default().add_submessages(vec![submsg])) } -fn execute_redeem_share( +fn execute_redeem_shares( mut deps: DepsMut, info: MessageInfo, - validator: String, - amount: Uint128, - denom: String, + items: Vec, timeout: Option, reply_to: String, ) -> ContractResult> { let attrs = vec![ attr("action", "redeem_share"), - attr("validator", validator.clone()), - attr("amount", amount.to_string()), - attr("denom", denom.clone()), + attr("items", format!("{:?}", items)), ]; let puppeteer_base = Puppeteer::default(); deps.api.addr_validate(&reply_to)?; @@ -734,25 +729,24 @@ fn execute_redeem_share( let config: Config = puppeteer_base.config.load(deps.storage)?; validate_sender(&config, &info.sender)?; let delegator = puppeteer_base.ica.get_address(deps.storage)?; - let redeem_msg = MsgRedeemTokensforShares { - delegator_address: delegator, - amount: Some(ProtoCoin { - denom: denom.to_string(), - amount: amount.to_string(), - }), - }; + let any_msgs = items + .iter() + .map(|one| MsgRedeemTokensforShares { + delegator_address: delegator.to_string(), + amount: Some(ProtoCoin { + denom: one.remote_denom.to_string(), + amount: one.amount.to_string(), + }), + }) + .map(|msg| prepare_any_msg(msg, "/cosmos.staking.v1beta1.MsgRedeemTokensForShares")) + .collect::>>()?; let submsg = compose_submsg( deps.branch(), config, - vec![prepare_any_msg( - redeem_msg, - "/cosmos.staking.v1beta1.MsgRedeemTokensForShares", - )?], - Transaction::RedeemShare { + any_msgs, + Transaction::RedeemShares { interchain_account_id: ICA_ID.to_string(), - validator, - denom, - amount: amount.into(), + items, }, timeout, reply_to, diff --git a/integration_tests/src/generated/contractLib/lidoCore.ts b/integration_tests/src/generated/contractLib/lidoCore.ts index 96ad760f..cdb03e8e 100644 --- a/integration_tests/src/generated/contractLib/lidoCore.ts +++ b/integration_tests/src/generated/contractLib/lidoCore.ts @@ -28,6 +28,7 @@ export interface InstantiateMsg { fee?: Decimal | null; fee_address?: string | null; idle_min_interval: number; + lsm_redeem_threshold: number; owner: string; pump_address?: string | null; puppeteer_contract: string; @@ -69,6 +70,7 @@ export type ContractState = "idle" | "claiming" | "unbonding" | "staking" | "tra * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) */ export type Decimal1 = string; +export type ArrayOfTupleOf_StringAnd_TupleOf_StringAnd_Uint128 = [string, [string, Uint128]][]; export type ResponseHookMsg = | { success: ResponseHookSuccessMsg; @@ -150,11 +152,9 @@ export type Transaction = }; } | { - redeem_share: { - amount: number; - denom: string; + redeem_shares: { interchain_account_id: string; - validator: string; + items: RedeemShareItem[]; }; } | { @@ -179,6 +179,8 @@ export type Transaction = }; }; export type ArrayOfNonNativeRewardsItem = NonNativeRewardsItem[]; +export type String = string; +export type ArrayOfTupleOf_StringAnd_TupleOf_StringAnd_Uint1281 = [string, [string, Uint128]][]; /** * A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq. * @@ -259,7 +261,17 @@ export type Timestamp2 = Uint64; export type Uint64 = string; export interface LidoCoreSchema { - responses: Config | ContractState | Decimal1 | ResponseHookMsg | ArrayOfNonNativeRewardsItem | Uint1281 | UnbondBatch; + responses: + | Config + | ContractState + | Decimal1 + | ArrayOfTupleOf_StringAnd_TupleOf_StringAnd_Uint128 + | ResponseHookMsg + | ArrayOfNonNativeRewardsItem + | String + | ArrayOfTupleOf_StringAnd_TupleOf_StringAnd_Uint1281 + | Uint1281 + | UnbondBatch; query: UnbondBatchArgs; execute: BondArgs | UpdateConfigArgs | UpdateNonNativeRewardsReceiversArgs | PuppeteerHookArgs | UpdateOwnershipArgs; [k: string]: unknown; @@ -272,7 +284,7 @@ export interface Config { fee_address?: string | null; idle_min_interval: number; ld_denom?: string | null; - owner: string; + lsm_redeem_threshold: number; pump_address?: string | null; puppeteer_contract: string; puppeteer_timeout: number; @@ -335,6 +347,11 @@ export interface RequestPacketTimeoutHeight { revision_number?: number | null; [k: string]: unknown; } +export interface RedeemShareItem { + amount: Uint128; + local_denom: string; + remote_denom: string; +} export interface TransferReadyBatchMsg { amount: Uint128; batch_id: number; @@ -386,7 +403,7 @@ export interface ConfigOptional { fee_address?: string | null; idle_min_interval?: number | null; ld_denom?: string | null; - owner?: string | null; + lsm_redeem_threshold?: number | null; pump_address?: string | null; puppeteer_contract?: string | null; puppeteer_timeout?: number | null; @@ -438,6 +455,9 @@ export class Client { queryConfig = async(): Promise => { return this.client.queryContractSmart(this.contractAddress, { config: {} }); } + queryOwner = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { owner: {} }); + } queryExchangeRate = async(): Promise => { return this.client.queryContractSmart(this.contractAddress, { exchange_rate: {} }); } @@ -453,6 +473,12 @@ export class Client { queryNonNativeRewardsReceivers = async(): Promise => { return this.client.queryContractSmart(this.contractAddress, { non_native_rewards_receivers: {} }); } + queryPendingLSMShares = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { pending_l_s_m_shares: {} }); + } + queryLSMSharesToRedeem = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { l_s_m_shares_to_redeem: {} }); + } queryTotalBonded = async(): Promise => { return this.client.queryContractSmart(this.contractAddress, { total_bonded: {} }); } diff --git a/integration_tests/src/generated/contractLib/lidoFactory.ts b/integration_tests/src/generated/contractLib/lidoFactory.ts index 4820be1a..7e59cf15 100644 --- a/integration_tests/src/generated/contractLib/lidoFactory.ts +++ b/integration_tests/src/generated/contractLib/lidoFactory.ts @@ -153,6 +153,7 @@ export interface CoreParams { bond_limit?: Uint128 | null; channel: string; idle_min_interval: number; + lsm_redeem_threshold: number; puppeteer_timeout: number; unbond_batch_switch_time: number; unbonding_period: number; @@ -166,7 +167,7 @@ export interface ConfigOptional { fee_address?: string | null; idle_min_interval?: number | null; ld_denom?: string | null; - owner?: string | null; + lsm_redeem_threshold?: number | null; pump_address?: string | null; puppeteer_contract?: string | null; puppeteer_timeout?: number | null; diff --git a/integration_tests/src/generated/contractLib/lidoHookTester.ts b/integration_tests/src/generated/contractLib/lidoHookTester.ts index 3500cd9d..f6b2e0cf 100644 --- a/integration_tests/src/generated/contractLib/lidoHookTester.ts +++ b/integration_tests/src/generated/contractLib/lidoHookTester.ts @@ -89,11 +89,9 @@ export type Transaction = }; } | { - redeem_share: { - amount: number; - denom: string; + redeem_shares: { interchain_account_id: string; - validator: string; + items: RedeemShareItem[]; }; } | { @@ -188,6 +186,11 @@ export interface RequestPacketTimeoutHeight { revision_number?: number | null; [k: string]: unknown; } +export interface RedeemShareItem { + amount: Uint128; + local_denom: string; + remote_denom: string; +} export interface TransferReadyBatchMsg { amount: Uint128; batch_id: number; diff --git a/integration_tests/src/generated/contractLib/lidoPuppeteer.ts b/integration_tests/src/generated/contractLib/lidoPuppeteer.ts index 9abb320e..bafd845f 100644 --- a/integration_tests/src/generated/contractLib/lidoPuppeteer.ts +++ b/integration_tests/src/generated/contractLib/lidoPuppeteer.ts @@ -62,11 +62,9 @@ export type Transaction = }; } | { - redeem_share: { - amount: number; - denom: string; + redeem_shares: { interchain_account_id: string; - validator: string; + items: RedeemShareItem[]; }; } | { @@ -134,7 +132,7 @@ export interface LidoPuppeteerSchema { | UndelegateArgs | RedelegateArgs | TokenizeShareArgs - | RedeemShareArgs + | RedeemSharesArgs | IBCTransferArgs | TransferArgs | ClaimRewardsAndOptionalyTransferArgs; @@ -145,6 +143,11 @@ export interface ConfigResponse { owner: string; update_period: number; } +export interface RedeemShareItem { + amount: Uint128; + local_denom: string; + remote_denom: string; +} export interface TransferReadyBatchMsg { amount: Uint128; batch_id: number; @@ -197,12 +200,10 @@ export interface TokenizeShareArgs { timeout?: number | null; validator: string; } -export interface RedeemShareArgs { - amount: Uint128; - denom: string; +export interface RedeemSharesArgs { + items: RedeemShareItem[]; reply_to: string; timeout?: number | null; - validator: string; } export interface IBCTransferArgs { reply_to: string; @@ -303,9 +304,9 @@ export class Client { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } return this.client.execute(sender, this.contractAddress, { tokenize_share: args }, fee || "auto", memo, funds); } - redeemShare = async(sender:string, args: RedeemShareArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + redeemShares = async(sender:string, args: RedeemSharesArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } - return this.client.execute(sender, this.contractAddress, { redeem_share: args }, fee || "auto", memo, funds); + return this.client.execute(sender, this.contractAddress, { redeem_shares: args }, fee || "auto", memo, funds); } iBCTransfer = async(sender:string, args: IBCTransferArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } diff --git a/integration_tests/src/helpers/waitForTx.ts b/integration_tests/src/helpers/waitForTx.ts new file mode 100644 index 00000000..1213e38d --- /dev/null +++ b/integration_tests/src/helpers/waitForTx.ts @@ -0,0 +1,23 @@ +import { SigningStargateClient, StargateClient } from '@cosmjs/stargate'; +import { waitFor } from './waitFor'; + +export const waitForTx = async ( + client: SigningStargateClient | StargateClient, + hash: string, + timeout: number = 10000, + interval: number = 600, +): Promise => + await waitFor( + async () => { + const tx = await client.getTx(hash); + if (tx === null) { + return false; + } + if (tx.code !== 0) { + throw new Error(`Transaction failed with code: ${tx.code}`); + } + return tx.code === 0 && tx.height > 0; + }, + timeout, + interval, + ); diff --git a/integration_tests/src/testcases/auto-withdrawer.test.ts b/integration_tests/src/testcases/auto-withdrawer.test.ts index b6089336..bb542cc3 100644 --- a/integration_tests/src/testcases/auto-withdrawer.test.ts +++ b/integration_tests/src/testcases/auto-withdrawer.test.ts @@ -397,6 +397,7 @@ describe('Auto withdrawer', () => { unbonding_safe_period: 10, unbonding_period: 360, channel: 'channel-0', + lsm_redeem_threshold: 10, }, }); expect(res.transactionHash).toHaveLength(64); diff --git a/integration_tests/src/testcases/core.test.ts b/integration_tests/src/testcases/core.test.ts index 90382ddc..5598e0fc 100644 --- a/integration_tests/src/testcases/core.test.ts +++ b/integration_tests/src/testcases/core.test.ts @@ -15,7 +15,6 @@ import { setupStakingExtension, setupBankExtension, SigningStargateClient, - IndexedTx, } from '@cosmjs/stargate'; import { MsgTransfer } from 'cosmjs-types/ibc/applications/transfer/v1/tx'; import { join } from 'path'; @@ -39,6 +38,7 @@ import { } from '../generated/contractLib/lidoCore'; import { stringToPath } from '@cosmjs/crypto'; import { sleep } from '../helpers/sleep'; +import { waitForTx } from '../helpers/waitForTx'; const LidoFactoryClass = LidoFactory.Client; const LidoCoreClass = LidoCore.Client; @@ -396,6 +396,7 @@ describe('Core', () => { unbonding_safe_period: 10, unbonding_period: 360, channel: 'channel-0', + lsm_redeem_threshold: 2, bond_limit: '100000', }, }); @@ -679,13 +680,7 @@ describe('Core', () => { const out = JSON.parse(res.out); expect(out.code).toBe(0); expect(out.txhash).toHaveLength(64); - let tx: IndexedTx | null = null; - await waitFor(async () => { - tx = await context.gaiaClient.getTx(out.txhash); - return tx !== null; - }); - expect(tx.height).toBeGreaterThan(0); - expect(tx.code).toBe(0); + await waitForTx(context.gaiaClient, out.txhash); }); it('tokenize share on gaia side', async () => { const res = await context.park.executeInNetwork( @@ -696,13 +691,7 @@ describe('Core', () => { const out = JSON.parse(res.out); expect(out.code).toBe(0); expect(out.txhash).toHaveLength(64); - let tx: IndexedTx | null = null; - await waitFor(async () => { - tx = await context.gaiaClient.getTx(out.txhash); - return tx !== null; - }); - expect(tx.height).toBeGreaterThan(0); - expect(tx.code).toBe(0); + await waitForTx(context.gaiaClient, out.txhash); const balances = await context.gaiaQueryClient.bank.allBalances( context.gaiaUserAddress, ); @@ -722,13 +711,7 @@ describe('Core', () => { const out = JSON.parse(res.out); expect(out.code).toBe(0); expect(out.txhash).toHaveLength(64); - let tx: IndexedTx | null = null; - await waitFor(async () => { - tx = await context.gaiaClient.getTx(out.txhash); - return tx !== null; - }); - expect(tx.height).toBeGreaterThan(0); - expect(tx.code).toBe(0); + await waitForTx(context.gaiaClient, out.txhash); }); it('wait for neutron to receive tokenized share', async () => { const { neutronClient, neutronUserAddress } = context; @@ -1266,7 +1249,7 @@ describe('Core', () => { expect(state).toEqual('idle'); }); }); - describe('third cycle', () => { + describe('third cycle (non-native rewards)', () => { let remoteNonNativeDenoms: string[] = []; it('generate two new tokenfactory tokens and send them to the remote zone', async () => { const { neutronUserAddress } = context; @@ -1452,7 +1435,337 @@ describe('Core', () => { }); }); - describe('fourth cycle', () => { + describe('fourth cycle (LSM-shares)', () => { + let lsmDenoms: string[] = []; + let oldBalanceDenoms: string[] = []; + let exchangeRate = ''; + describe('prepare', () => { + it('get exchange rate', async () => { + exchangeRate = await context.coreContractClient.queryExchangeRate(); + }); + describe('create LSM shares and send them to neutron', () => { + it('get balances', async () => { + const oldBalances = + await context.neutronClient.CosmosBankV1Beta1.query.queryAllBalances( + context.neutronUserAddress, + ); + oldBalanceDenoms = oldBalances.data.balances.map((b) => b.denom); + }); + it('delegate', async () => { + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking delegate ${context.validatorAddress} 100000stake --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + } + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking delegate ${context.secondValidatorAddress} 100000stake --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + } + }); + it('tokenize shares', async () => { + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking tokenize-share ${context.validatorAddress} 60000stake ${context.gaiaUserAddress} --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --gas auto --gas-adjustment 2 --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + const balances = await context.gaiaQueryClient.bank.allBalances( + context.gaiaUserAddress, + ); + expect( + balances.find( + (a) => a.denom == `${context.validatorAddress}/2`, + ), + ).toEqual({ + denom: `${context.validatorAddress}/2`, + amount: '60000', + }); + } + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx staking tokenize-share ${context.secondValidatorAddress} 60000stake ${context.gaiaUserAddress} --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --gas auto --gas-adjustment 2 --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + const balances = await context.gaiaQueryClient.bank.allBalances( + context.gaiaUserAddress, + ); + expect( + balances.find( + (a) => a.denom == `${context.secondValidatorAddress}/3`, + ), + ).toEqual({ + denom: `${context.secondValidatorAddress}/3`, + amount: '60000', + }); + } + }); + it('transfer shares to neutron', async () => { + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx ibc-transfer transfer transfer channel-0 ${context.neutronUserAddress} 60000${context.validatorAddress}/2 --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --gas auto --gas-adjustment 2 --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + } + await sleep(10_000); + { + const res = await context.park.executeInNetwork( + 'gaia', + `gaiad tx ibc-transfer transfer transfer channel-0 ${context.neutronUserAddress} 60000${context.secondValidatorAddress}/3 --from ${context.gaiaUserAddress} --yes --chain-id testgaia --home=/opt --keyring-backend=test --gas auto --gas-adjustment 2 --output json`, + ); + expect(res.exitCode).toBe(0); + const out = JSON.parse(res.out); + expect(out.code).toBe(0); + expect(out.txhash).toHaveLength(64); + await waitForTx(context.gaiaClient, out.txhash); + } + }); + it('wait for balances to come', async () => { + await waitFor(async () => { + const newbalances = + await context.neutronClient.CosmosBankV1Beta1.query.queryAllBalances( + context.neutronUserAddress, + ); + const newDenoms = newbalances.data.balances.map((b) => b.denom); + const diff = newDenoms.filter( + (d) => !oldBalanceDenoms.includes(d), + ); + lsmDenoms = diff; + return diff.length === 2; + }, 30_000); + }); + }); + + it('bond LSM shares', async () => { + { + const { coreContractClient, neutronUserAddress } = context; + const res = await coreContractClient.bond( + neutronUserAddress, + {}, + 1.6, + undefined, + [ + { + amount: '60000', + denom: lsmDenoms[0], + }, + ], + ); + expect(res.transactionHash).toHaveLength(64); + } + { + const { coreContractClient, neutronUserAddress } = context; + const res = await coreContractClient.bond( + neutronUserAddress, + {}, + 1.6, + undefined, + [ + { + amount: '60000', + denom: lsmDenoms[1], + }, + ], + ); + expect(res.transactionHash).toHaveLength(64); + } + }); + it('verify pending lsm shares', async () => { + const pending = + await context.coreContractClient.queryPendingLSMShares(); + expect(pending).toHaveLength(2); + }); + }); + describe('transfering', () => { + it('tick', async () => { + const { neutronUserAddress } = context; + const res = await context.coreContractClient.tick( + neutronUserAddress, + 1.5, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + const state = await context.coreContractClient.queryContractState(); + expect(state).toEqual('idle'); + }); + it('one lsm share is gone from the contract balance', async () => { + const balances = + await context.neutronClient.CosmosBankV1Beta1.query.queryAllBalances( + context.coreContractClient.contractAddress, + ); + expect( + balances.data.balances.find((one) => one.denom === lsmDenoms[0]), + ).toBeFalsy(); + }); + it('await for pending length decrease', async () => { + let pending: any; + await waitFor(async () => { + try { + const res = + await context.coreContractClient.queryPendingLSMShares(); + pending = res; + } catch (e) { + // + } + return !!pending && pending.length === 1; + }, 60_000); + }); + it('tick', async () => { + const { neutronUserAddress } = context; + const res = await context.coreContractClient.tick( + neutronUserAddress, + 1.5, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + const state = await context.coreContractClient.queryContractState(); + expect(state).toEqual('idle'); + }); + it('second lsm share is gone from the contract balance', async () => { + const balances = + await context.neutronClient.CosmosBankV1Beta1.query.queryAllBalances( + context.coreContractClient.contractAddress, + ); + expect( + balances.data.balances.find((one) => one.denom === lsmDenoms[1]), + ).toBeFalsy(); + }); + it('await for pending length decrease', async () => { + let pending: any; + await waitFor(async () => { + try { + const res = + await context.coreContractClient.queryPendingLSMShares(); + pending = res; + } catch (e) { + // + } + return !!pending && pending.length === 0; + }, 60_000); + expect(pending).toEqual([]); + }); + }); + describe('redeem', () => { + let delegationsSum = 0; + it('query delegations', async () => { + const res: any = await context.puppeteerContractClient.queryExtention( + { + msg: { + delegations: {}, + }, + }, + ); + for (const d of res[0].delegations) { + delegationsSum += parseInt(d.amount.amount); + } + }); + it('verify pending lsm shares to unbond', async () => { + const pending = + await context.coreContractClient.queryLSMSharesToRedeem(); + expect(pending).toHaveLength(2); + }); + it('tick', async () => { + const { neutronUserAddress } = context; + const res = await context.coreContractClient.tick( + neutronUserAddress, + 1.5, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + const state = await context.coreContractClient.queryContractState(); + expect(state).toEqual('idle'); + }); + it('imeediately tick again fails', async () => { + const { neutronUserAddress } = context; + await expect( + context.coreContractClient.tick( + neutronUserAddress, + 1.5, + undefined, + [], + ), + ).rejects.toThrowError( + /Transaction txState is not equal to expected: Idle/, + ); + }); + it('await for pending length decrease', async () => { + await waitFor(async () => { + const pending = + await context.coreContractClient.queryLSMSharesToRedeem(); + return pending.length === 0; + }, 30_000); + }); + it('wait for delegations to come', async () => { + const [, currentHeight] = + await context.puppeteerContractClient.queryExtention({ + msg: { + delegations: {}, + }, + }); + await waitFor(async () => { + const [, nowHeight] = + await context.puppeteerContractClient.queryExtention({ + msg: { + delegations: {}, + }, + }); + return nowHeight !== currentHeight; + }); + }); + it('query delegations', async () => { + const res: any = await context.puppeteerContractClient.queryExtention( + { + msg: { + delegations: {}, + }, + }, + ); + let newDelegationsSum = 0; + for (const d of res[0].delegations) { + newDelegationsSum += parseInt(d.amount.amount); + } + expect(newDelegationsSum - delegationsSum).toEqual(120_000); + }); + it('verify exchange rate', async () => { + const newExchangeRate = + await context.coreContractClient.queryExchangeRate(); + expect(parseFloat(newExchangeRate)).toBeGreaterThan( + parseFloat(exchangeRate), + ); + }); + }); + }); + + describe('fifth cycle', () => { let previousResponse: ResponseHookSuccessMsg; it('validate NFT', async () => { diff --git a/packages/base/src/msg/core.rs b/packages/base/src/msg/core.rs index 477c5abb..50f20c93 100644 --- a/packages/base/src/msg/core.rs +++ b/packages/base/src/msg/core.rs @@ -15,6 +15,7 @@ pub struct InstantiateMsg { pub validators_set_contract: String, pub base_denom: String, pub remote_denom: String, + pub lsm_redeem_threshold: u64, pub idle_min_interval: u64, //seconds pub unbonding_period: u64, //seconds pub unbonding_safe_period: u64, //seconds @@ -32,6 +33,8 @@ pub struct InstantiateMsg { pub enum QueryMsg { #[returns(Config)] Config {}, + #[returns(String)] + Owner {}, #[returns(cosmwasm_std::Decimal)] ExchangeRate {}, #[returns(crate::state::core::UnbondBatch)] @@ -42,6 +45,10 @@ pub enum QueryMsg { LastPuppeteerResponse {}, #[returns(Vec)] NonNativeRewardsReceivers {}, + #[returns(Vec<(String,(String, Uint128))>)] + PendingLSMShares {}, + #[returns(Vec<(String,(String, Uint128))>)] + LSMSharesToRedeem {}, #[returns(Uint128)] TotalBonded {}, } @@ -74,12 +81,12 @@ impl From for Config { base_denom: val.base_denom, remote_denom: val.remote_denom, channel: val.channel, - owner: val.owner, ld_denom: None, idle_min_interval: val.idle_min_interval, unbonding_safe_period: val.unbonding_safe_period, unbonding_period: val.unbonding_period, pump_address: val.pump_address, + lsm_redeem_threshold: val.lsm_redeem_threshold, validators_set_contract: val.validators_set_contract, unbond_batch_switch_time: val.unbond_batch_switch_time, bond_limit: val.bond_limit, diff --git a/packages/base/src/msg/puppeteer.rs b/packages/base/src/msg/puppeteer.rs index 70579612..563557be 100644 --- a/packages/base/src/msg/puppeteer.rs +++ b/packages/base/src/msg/puppeteer.rs @@ -7,7 +7,10 @@ use cosmos_sdk_proto::cosmos::{ base::v1beta1::Coin as CosmosCoin, staking::v1beta1::{Delegation, Validator as CosmosValidator}, }; -use lido_puppeteer_base::msg::{ExecuteMsg as BaseExecuteMsg, TransferReadyBatchMsg}; +use lido_puppeteer_base::{ + msg::{ExecuteMsg as BaseExecuteMsg, TransferReadyBatchMsg}, + state::RedeemShareItem, +}; use neutron_sdk::{ bindings::types::StorageValue, interchain_queries::v045::types::{Balances, Delegations}, @@ -71,10 +74,8 @@ pub enum ExecuteMsg { timeout: Option, reply_to: String, }, - RedeemShare { - validator: String, - amount: Uint128, - denom: String, + RedeemShares { + items: Vec, timeout: Option, reply_to: String, }, diff --git a/packages/base/src/state/core.rs b/packages/base/src/state/core.rs index 8ad663e8..fa202c54 100644 --- a/packages/base/src/state/core.rs +++ b/packages/base/src/state/core.rs @@ -22,9 +22,9 @@ pub struct Config { pub unbonding_safe_period: u64, //seconds pub unbond_batch_switch_time: u64, //seconds pub pump_address: Option, - pub owner: String, pub channel: String, pub ld_denom: Option, + pub lsm_redeem_threshold: u64, pub bond_limit: Option, pub fee: Option, pub fee_address: Option, @@ -89,6 +89,8 @@ pub fn unbond_batches_map<'a>() -> IndexedMap<'a, u128, UnbondBatch, UnbondBatch pub const UNBOND_BATCH_ID: Item = Item::new("batches_ids"); pub const TOTAL_LSM_SHARES: Item = Item::new("total_lsm_shares"); +pub const PENDING_LSM_SHARES: Map = Map::new("pending_lsm_shares"); +pub const LSM_SHARES_TO_REDEEM: Map = Map::new("lsm_shares_to_redeem"); #[cw_serde] pub enum ContractState { diff --git a/packages/puppeteer-base/src/msg.rs b/packages/puppeteer-base/src/msg.rs index b802304f..7487cf15 100644 --- a/packages/puppeteer-base/src/msg.rs +++ b/packages/puppeteer-base/src/msg.rs @@ -1,7 +1,10 @@ -use crate::proto::{ - MsgBeginRedelegateResponse, MsgDelegateResponse, MsgExecResponse, MsgIBCTransfer, - MsgRedeemTokensforSharesResponse, MsgSendResponse, MsgTokenizeSharesResponse, - MsgUndelegateResponse, +use crate::{ + proto::{ + MsgBeginRedelegateResponse, MsgDelegateResponse, MsgExecResponse, MsgIBCTransfer, + MsgRedeemTokensforSharesResponse, MsgSendResponse, MsgTokenizeSharesResponse, + MsgUndelegateResponse, + }, + state::RedeemShareItem, }; use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{Empty, Uint128}; @@ -126,11 +129,9 @@ pub enum Transaction { denom: String, amount: u128, }, - RedeemShare { + RedeemShares { interchain_account_id: String, - validator: String, - denom: String, - amount: u128, + items: Vec, }, ClaimRewardsAndOptionalyTransfer { interchain_account_id: String, diff --git a/packages/puppeteer-base/src/state.rs b/packages/puppeteer-base/src/state.rs index 43fbf857..5ad88c5e 100644 --- a/packages/puppeteer-base/src/state.rs +++ b/packages/puppeteer-base/src/state.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::Coin; +use cosmwasm_std::{Coin, Uint128}; use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, UniqueIndex}; use lido_helpers::ica::Ica; use neutron_sdk::{bindings::msg::IbcFee, interchain_queries::v045::types::UnbondingEntry}; @@ -123,6 +123,13 @@ pub struct UnbondingDelegation { pub last_updated_height: u64, } +#[cw_serde] +pub struct RedeemShareItem { + pub amount: Uint128, + pub remote_denom: String, + pub local_denom: String, +} + pub struct UnbondingDelegationIndexes<'a> { pub query_id: UniqueIndex<'a, u64, UnbondingDelegation, String>, }