diff --git a/Cargo.lock b/Cargo.lock index 25d1c957..b8f0d1f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -835,13 +835,26 @@ dependencies = [ "lido-staking-base", "prost", "prost-types", - "protobuf", "schemars", "serde", "serde-json-wasm 1.0.1", "tendermint-proto", ] +[[package]] +name = "lido-auto-withdrawer" +version = "1.0.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw2 1.1.2", + "lido-helpers", + "lido-staking-base", + "neutron-sdk", + "thiserror", +] + [[package]] name = "lido-core" version = "1.0.0" @@ -1041,7 +1054,6 @@ dependencies = [ "lido-staking-base", "prost", "prost-types", - "protobuf", "schemars", "serde", "serde-json-wasm 1.0.1", diff --git a/Cargo.toml b/Cargo.toml index 5b625a87..6e39e490 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "contracts/hook-tester", "contracts/core", "packages/puppeteer-base", + "contracts/auto-withdrawer", "packages/base", "packages/helpers", ] diff --git a/contracts/astroport-exchange-handler/Cargo.toml b/contracts/astroport-exchange-handler/Cargo.toml index e7de00f0..89cc98a4 100644 --- a/contracts/astroport-exchange-handler/Cargo.toml +++ b/contracts/astroport-exchange-handler/Cargo.toml @@ -28,7 +28,6 @@ library = [] cosmos-sdk-proto = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } -protobuf = { workspace = true } tendermint-proto = { workspace = true } cosmwasm-schema = { workspace = true } diff --git a/contracts/auto-withdrawer/.cargo/config b/contracts/auto-withdrawer/.cargo/config new file mode 100644 index 00000000..b7dcbb8b --- /dev/null +++ b/contracts/auto-withdrawer/.cargo/config @@ -0,0 +1,2 @@ +[alias] +schema = "run --bin lido-auto-withdrawer-schema" diff --git a/contracts/auto-withdrawer/Cargo.toml b/contracts/auto-withdrawer/Cargo.toml new file mode 100644 index 00000000..81000e0d --- /dev/null +++ b/contracts/auto-withdrawer/Cargo.toml @@ -0,0 +1,23 @@ +[package] +authors = ["Murad Karammaev "] +description = "Bond ldASSETs and let anyone to withdraw them for you permissionlessly" +edition = "2021" +name = "lido-auto-withdrawer" +version = "1.0.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +neutron-sdk = { workspace = true } +thiserror = { workspace = true } +cw2 = { workspace = true } +cw-storage-plus = { workspace = true } +lido-staking-base = { workspace = true } +lido-helpers = { workspace = true } diff --git a/contracts/auto-withdrawer/README.md b/contracts/auto-withdrawer/README.md new file mode 100644 index 00000000..229d2ea3 --- /dev/null +++ b/contracts/auto-withdrawer/README.md @@ -0,0 +1 @@ +# Lido Auto Withdrawer diff --git a/contracts/auto-withdrawer/src/bin/lido-auto-withdrawer-schema.rs b/contracts/auto-withdrawer/src/bin/lido-auto-withdrawer-schema.rs new file mode 100644 index 00000000..a120f113 --- /dev/null +++ b/contracts/auto-withdrawer/src/bin/lido-auto-withdrawer-schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use lido_auto_withdrawer::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg + } +} diff --git a/contracts/auto-withdrawer/src/contract.rs b/contracts/auto-withdrawer/src/contract.rs new file mode 100644 index 00000000..5a0b944f --- /dev/null +++ b/contracts/auto-withdrawer/src/contract.rs @@ -0,0 +1,330 @@ +use crate::{ + error::{ContractError, ContractResult}, + msg::{ + BondMsg, BondingResponse, BondingsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, + QueryMsg, + }, + store::{ + bondings_map, + reply::{CoreUnbond, CORE_UNBOND}, + BondingRecord, CORE_ADDRESS, LD_TOKEN, WITHDRAWAL_MANAGER_ADDRESS, + WITHDRAWAL_VOUCHER_ADDRESS, + }, +}; +use cosmwasm_std::{ + attr, ensure, ensure_eq, to_json_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, + Env, Event, MessageInfo, Order, Reply, Response, SubMsg, WasmMsg, +}; +use cw_storage_plus::Bound; +use lido_helpers::answer::response; +use neutron_sdk::bindings::{msg::NeutronMsg, query::NeutronQuery}; + +pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const CORE_UNBOND_REPLY_ID: u64 = 1; +pub const PAGINATION_DEFAULT_LIMIT: usize = 100; + +#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> ContractResult> { + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + CORE_ADDRESS.save(deps.storage, &deps.api.addr_validate(&msg.core_address)?)?; + WITHDRAWAL_VOUCHER_ADDRESS.save( + deps.storage, + &deps.api.addr_validate(&msg.withdrawal_voucher_address)?, + )?; + WITHDRAWAL_MANAGER_ADDRESS.save( + deps.storage, + &deps.api.addr_validate(&msg.withdrawal_manager_address)?, + )?; + LD_TOKEN.save(deps.storage, &msg.ld_token)?; + + Ok(response( + "instantiate", + CONTRACT_NAME, + [ + attr("core_address", msg.core_address), + attr("withdrawal_voucher", msg.withdrawal_voucher_address), + attr("withdrawal_manager", msg.withdrawal_manager_address), + attr("ld_token", msg.ld_token), + ], + )) +} + +#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> ContractResult> { + match msg { + ExecuteMsg::Bond(bond_msg) => match bond_msg { + BondMsg::WithLdAssets {} => execute_bond_with_ld_assets(deps, info), + BondMsg::WithNFT { token_id } => execute_bond_with_nft(deps, env, info, token_id), + }, + ExecuteMsg::Unbond { token_id } => execute_unbond(deps, info, token_id), + ExecuteMsg::Withdraw { token_id } => execute_withdraw(deps, info, token_id), + } +} + +fn execute_bond_with_ld_assets( + deps: DepsMut, + mut info: MessageInfo, +) -> ContractResult> { + let ld_token = LD_TOKEN.load(deps.storage)?; + + let ld_asset = info.funds.swap_remove( + info.funds + .iter() + .position(|coin| coin.denom == ld_token) + .ok_or(ContractError::LdTokenExpected {})?, + ); + let deposit = info.funds; + ensure!(!deposit.is_empty(), ContractError::DepositExpected {}); + + CORE_UNBOND.save( + deps.storage, + &CoreUnbond { + sender: info.sender, + deposit, + }, + )?; + + let msg = WasmMsg::Execute { + contract_addr: CORE_ADDRESS.load(deps.storage)?.into_string(), + msg: to_json_binary(&lido_staking_base::msg::core::ExecuteMsg::Unbond {})?, + funds: vec![ld_asset], + }; + + // TODO: attributes + Ok(Response::new().add_submessage(SubMsg::reply_on_success(msg, CORE_UNBOND_REPLY_ID))) +} + +fn execute_bond_with_nft( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_id: String, +) -> ContractResult> { + let deposit = info.funds; + ensure!(!deposit.is_empty(), ContractError::DepositExpected {}); + + // XXX: this code allows user to pass ld_token as a deposit. This sounds strange, but it might actually make + // sense to do so. Should we introduce a check that forbids it? + + bondings_map().save( + deps.storage, + &token_id, + &BondingRecord { + bonder: info.sender, + deposit, + }, + )?; + + let withdrawal_voucher = WITHDRAWAL_VOUCHER_ADDRESS.load(deps.storage)?; + let msg = WasmMsg::Execute { + contract_addr: withdrawal_voucher.into_string(), + msg: to_json_binary( + &lido_staking_base::msg::withdrawal_voucher::ExecuteMsg::TransferNft { + recipient: env.contract.address.into_string(), + token_id, + }, + )?, + funds: vec![], + }; + + // TODO: attributes + Ok(Response::new().add_message(msg)) +} + +fn execute_unbond( + deps: DepsMut, + info: MessageInfo, + token_id: String, +) -> ContractResult> { + let bonding = bondings_map().load(deps.storage, &token_id)?; + ensure_eq!(info.sender, bonding.bonder, ContractError::Unauthorized {}); + bondings_map().remove(deps.storage, &token_id)?; + + let withdrawal_voucher = WITHDRAWAL_VOUCHER_ADDRESS.load(deps.storage)?; + + let nft_msg: CosmosMsg = WasmMsg::Execute { + contract_addr: withdrawal_voucher.into_string(), + msg: to_json_binary( + &lido_staking_base::msg::withdrawal_voucher::ExecuteMsg::TransferNft { + recipient: info.sender.to_string(), + token_id, + }, + )?, + funds: vec![], + } + .into(); + + let deposit_msg = BankMsg::Send { + to_address: info.sender.into_string(), + amount: bonding.deposit, + } + .into(); + + // TODO: attributes + Ok(Response::new().add_messages([nft_msg, deposit_msg])) +} + +fn execute_withdraw( + deps: DepsMut, + info: MessageInfo, + token_id: String, +) -> ContractResult> { + let bonding = bondings_map().load(deps.storage, &token_id)?; + bondings_map().remove(deps.storage, &token_id)?; + + let withdrawal_voucher = WITHDRAWAL_VOUCHER_ADDRESS.load(deps.storage)?; + let withdrawal_manager = WITHDRAWAL_MANAGER_ADDRESS.load(deps.storage)?; + + let withdraw_msg: CosmosMsg = WasmMsg::Execute { + contract_addr: withdrawal_voucher.into_string(), + msg: to_json_binary( + &lido_staking_base::msg::withdrawal_voucher::ExecuteMsg::SendNft { + contract: withdrawal_manager.into_string(), + token_id, + msg: to_json_binary( + &lido_staking_base::msg::withdrawal_manager::ReceiveNftMsg::Withdraw { + receiver: Some(bonding.bonder.into_string()), + }, + )?, + }, + )?, + funds: vec![], + } + .into(); + + let deposit_msg = BankMsg::Send { + to_address: info.sender.into_string(), + amount: bonding.deposit, + } + .into(); + + // TODO: attributes + Ok(Response::new().add_messages([withdraw_msg, deposit_msg])) +} + +#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] +pub fn reply( + deps: DepsMut, + _env: Env, + reply: Reply, +) -> ContractResult> { + match reply.id { + CORE_UNBOND_REPLY_ID => { + let CoreUnbond { sender, deposit } = CORE_UNBOND.load(deps.storage)?; + CORE_UNBOND.remove(deps.storage); + // it is safe to use unwrap() here since this reply is only called on success + let events = reply.result.unwrap().events; + reply_core_unbond(deps, sender, deposit, events) + } + _ => unreachable!(), + } +} + +fn reply_core_unbond( + deps: DepsMut, + sender: Addr, + deposit: Vec, + events: Vec, +) -> ContractResult> { + let token_id = events + .into_iter() + .filter(|event| event.ty == "wasm") + .flat_map(|event| event.attributes) + .find(|attribute| attribute.key == "token_id") + // it is safe to use unwrap here because cw-721 always generates valid events on success + .unwrap() + .value; + + bondings_map().save( + deps.storage, + &token_id, + &BondingRecord { + bonder: sender, + deposit, + }, + )?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> ContractResult { + match msg { + QueryMsg::Bondings { + user, + limit, + page_key, + } => query_all_bondings(deps, user, limit, page_key), + QueryMsg::Config {} => query_config(deps), + } +} + +fn query_all_bondings( + deps: Deps, + user: Option, + limit: Option, + page_key: Option, +) -> ContractResult { + let user = user.map(|addr| deps.api.addr_validate(&addr)).transpose()?; + let limit = limit.unwrap_or(PAGINATION_DEFAULT_LIMIT); + let page_key = page_key.as_deref().map(Bound::inclusive); + let mut iter = match user { + None => bondings_map().range(deps.storage, page_key, None, Order::Ascending), + Some(addr) => bondings_map().idx.bonder.prefix(addr).range( + deps.storage, + page_key, + None, + Order::Ascending, + ), + }; + + let mut bondings = vec![]; + for i in (&mut iter).take(limit) { + let (token_id, bonding) = i?; + bondings.push(BondingResponse { + token_id, + bonder: bonding.bonder.into_string(), + deposit: bonding.deposit, + }) + } + + let next_page_key = iter + .next() + .transpose()? + .map(|(token_id, _bonding)| token_id); + + Ok(to_json_binary(&BondingsResponse { + bondings, + next_page_key, + })?) +} + +fn query_config(deps: Deps) -> ContractResult { + Ok(to_json_binary(&InstantiateMsg { + core_address: CORE_ADDRESS.load(deps.storage)?.into_string(), + withdrawal_voucher_address: WITHDRAWAL_VOUCHER_ADDRESS.load(deps.storage)?.into_string(), + withdrawal_manager_address: WITHDRAWAL_MANAGER_ADDRESS.load(deps.storage)?.into_string(), + ld_token: LD_TOKEN.load(deps.storage)?, + })?) +} + +#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] +pub fn migrate( + _deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> ContractResult> { + Ok(Response::new()) +} diff --git a/contracts/auto-withdrawer/src/error.rs b/contracts/auto-withdrawer/src/error.rs new file mode 100644 index 00000000..a5bc7cad --- /dev/null +++ b/contracts/auto-withdrawer/src/error.rs @@ -0,0 +1,16 @@ +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] cosmwasm_std::StdError), + + #[error("unauthorized")] + Unauthorized {}, + + #[error("no ldTOKENs were provided")] + LdTokenExpected {}, + + #[error("no deposit was provided")] + DepositExpected {}, +} + +pub type ContractResult = Result; diff --git a/contracts/auto-withdrawer/src/lib.rs b/contracts/auto-withdrawer/src/lib.rs new file mode 100644 index 00000000..46a15bb0 --- /dev/null +++ b/contracts/auto-withdrawer/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod store; + +#[cfg(test)] +mod tests; diff --git a/contracts/auto-withdrawer/src/msg.rs b/contracts/auto-withdrawer/src/msg.rs new file mode 100644 index 00000000..b6fcdb84 --- /dev/null +++ b/contracts/auto-withdrawer/src/msg.rs @@ -0,0 +1,56 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Coin; + +#[cw_serde] +pub struct InstantiateMsg { + pub core_address: String, + pub withdrawal_voucher_address: String, + pub withdrawal_manager_address: String, + pub ld_token: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + Bond(BondMsg), + Unbond { token_id: String }, + Withdraw { token_id: String }, +} + +#[cw_serde] +pub enum BondMsg { + WithLdAssets {}, + WithNFT { token_id: String }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// List all bondings + #[returns(BondingsResponse)] + Bondings { + /// Optionally filter bondings by user address + user: Option, + /// Pagination limit. Default is 100 + limit: Option, + /// Pagination offset + page_key: Option, + }, + #[returns(InstantiateMsg)] // config is static and is 100% similar to InstantiateMsg + Config {}, +} + +#[cw_serde] +pub struct BondingsResponse { + pub bondings: Vec, + pub next_page_key: Option, +} + +#[cw_serde] +pub struct BondingResponse { + pub token_id: String, + pub bonder: String, + pub deposit: Vec, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/auto-withdrawer/src/store.rs b/contracts/auto-withdrawer/src/store.rs new file mode 100644 index 00000000..30066f67 --- /dev/null +++ b/contracts/auto-withdrawer/src/store.rs @@ -0,0 +1,50 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex}; + +pub const CORE_ADDRESS: Item = Item::new("core"); +pub const WITHDRAWAL_VOUCHER_ADDRESS: Item = Item::new("withdrawal_voucher"); +pub const WITHDRAWAL_MANAGER_ADDRESS: Item = Item::new("withdrawal_manager"); +pub const LD_TOKEN: Item = Item::new("ld_token"); + +pub use bondings::{map as bondings_map, BondingRecord}; +mod bondings { + use super::*; + + #[cw_serde] + pub struct BondingRecord { + pub bonder: Addr, + pub deposit: Vec, + } + + pub struct BondingRecordIndexes<'a> { + pub bonder: MultiIndex<'a, Addr, BondingRecord, &'a str>, + } + + impl<'a> IndexList for BondingRecordIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.bonder]; + Box::new(v.into_iter()) + } + } + + pub fn map<'a>() -> IndexedMap<'a, &'a str, BondingRecord, BondingRecordIndexes<'a>> { + IndexedMap::new( + "bondings", + BondingRecordIndexes { + bonder: MultiIndex::new(|_pk, b| b.bonder.clone(), "bondings", "bondings__bonder"), + }, + ) + } +} + +pub mod reply { + use super::*; + + #[cw_serde] + pub struct CoreUnbond { + pub sender: Addr, + pub deposit: Vec, + } + pub const CORE_UNBOND: Item = Item::new("reply_core_unbond"); +} diff --git a/contracts/auto-withdrawer/src/tests.rs b/contracts/auto-withdrawer/src/tests.rs new file mode 100644 index 00000000..73947905 --- /dev/null +++ b/contracts/auto-withdrawer/src/tests.rs @@ -0,0 +1,120 @@ +use crate::{ + contract, + error::ContractError, + msg::{BondMsg, ExecuteMsg, InstantiateMsg}, + store::{CORE_ADDRESS, LD_TOKEN, WITHDRAWAL_MANAGER_ADDRESS, WITHDRAWAL_VOUCHER_ADDRESS}, +}; +use cosmwasm_std::{ + attr, coin, + testing::{mock_env, mock_info, MockApi, MockQuerier, MockStorage}, + Event, OwnedDeps, Querier, +}; +use neutron_sdk::bindings::query::NeutronQuery; +use std::marker::PhantomData; + +fn mock_dependencies() -> OwnedDeps { + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: Q::default(), + custom_query_type: PhantomData, + } +} + +#[test] +fn instantiate() { + let mut deps = mock_dependencies::(); + let response = contract::instantiate( + deps.as_mut(), + mock_env(), + mock_info("admin", &[]), + InstantiateMsg { + core_address: "core".to_string(), + withdrawal_voucher_address: "withdrawal_voucher".to_string(), + withdrawal_manager_address: "withdrawal_manager".to_string(), + ld_token: "ld_token".to_string(), + }, + ) + .unwrap(); + + let core = CORE_ADDRESS.load(deps.as_ref().storage).unwrap(); + assert_eq!(core, "core"); + let ld_token = LD_TOKEN.load(deps.as_ref().storage).unwrap(); + assert_eq!(ld_token, "ld_token"); + let withdrawal_voucher = WITHDRAWAL_VOUCHER_ADDRESS + .load(deps.as_ref().storage) + .unwrap(); + assert_eq!(withdrawal_voucher, "withdrawal_voucher"); + let withdrawal_manager = WITHDRAWAL_MANAGER_ADDRESS + .load(deps.as_ref().storage) + .unwrap(); + assert_eq!(withdrawal_manager, "withdrawal_manager"); + + assert_eq!(response.messages.len(), 0); + assert_eq!( + response.events, + vec![ + Event::new("lido-auto-withdrawer-instantiate").add_attributes([ + attr("core_address", "core"), + attr("withdrawal_voucher", "withdrawal_voucher"), + attr("withdrawal_manager", "withdrawal_manager"), + attr("ld_token", "ld_token") + ]) + ] + ); + assert!(response.attributes.is_empty()); +} + +#[test] +fn bond_missing_ld_assets() { + let mut deps = mock_dependencies::(); + LD_TOKEN + .save(deps.as_mut().storage, &"ld_token".into()) + .unwrap(); + let err = contract::execute( + deps.as_mut(), + mock_env(), + mock_info("sender", &[coin(10, "uatom"), coin(20, "untrn")]), + ExecuteMsg::Bond(BondMsg::WithLdAssets {}), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::LdTokenExpected {}); +} + +mod bond_missing_deposit { + use super::*; + + #[test] + fn with_ld_assets() { + let mut deps = mock_dependencies::(); + LD_TOKEN + .save(deps.as_mut().storage, &"ld_token".into()) + .unwrap(); + let err = contract::execute( + deps.as_mut(), + mock_env(), + mock_info("sender", &[coin(10, "ld_token")]), + ExecuteMsg::Bond(BondMsg::WithLdAssets {}), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::DepositExpected {}); + } + + #[test] + fn with_nft() { + let mut deps = mock_dependencies::(); + let err = contract::execute( + deps.as_mut(), + mock_env(), + mock_info("sender", &[]), + ExecuteMsg::Bond(BondMsg::WithNFT { + token_id: "token_id".into(), + }), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::DepositExpected {}); + } +} diff --git a/contracts/rewards-manager/Cargo.toml b/contracts/rewards-manager/Cargo.toml index 2537a285..51eb5dcf 100644 --- a/contracts/rewards-manager/Cargo.toml +++ b/contracts/rewards-manager/Cargo.toml @@ -28,7 +28,6 @@ library = [] cosmos-sdk-proto = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } -protobuf = { workspace = true } tendermint-proto = { workspace = true } cosmwasm-schema = { workspace = true } diff --git a/contracts/withdrawal-manager/src/contract.rs b/contracts/withdrawal-manager/src/contract.rs index b314cd2e..632455c9 100644 --- a/contracts/withdrawal-manager/src/contract.rs +++ b/contracts/withdrawal-manager/src/contract.rs @@ -1,13 +1,13 @@ use cosmwasm_std::{ - attr, ensure_eq, entry_point, to_json_binary, Attribute, BankMsg, Binary, Coin, CosmosMsg, - Deps, DepsMut, Env, MessageInfo, Response, StdResult, + attr, ensure_eq, entry_point, from_json, to_json_binary, Attribute, BankMsg, Binary, Coin, + CosmosMsg, Deps, DepsMut, Env, MessageInfo, Response, StdResult, }; use cw2::set_contract_version; use cw721::NftInfoResponse; use lido_helpers::answer::response; use lido_staking_base::{ msg::{ - withdrawal_manager::{ExecuteMsg, InstantiateMsg, QueryMsg}, + withdrawal_manager::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveNftMsg}, withdrawal_voucher::Extension, }, state::{ @@ -58,7 +58,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult, - env: Env, + _env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> ContractResult> { @@ -68,7 +68,18 @@ pub fn execute( core_contract, voucher_contract, } => execute_update_config(deps, info, owner, core_contract, voucher_contract), - ExecuteMsg::ReceiveNft(msg) => execute_receive_nft(deps, env, info, msg), + ExecuteMsg::ReceiveNft(Cw721ReceiveMsg { + sender, + token_id, + msg: raw_msg, + }) => { + let msg: ReceiveNftMsg = from_json(raw_msg)?; + match msg { + ReceiveNftMsg::Withdraw { receiver } => { + execute_receive_nft_withdraw(deps, info, sender, token_id, receiver) + } + } + } } } @@ -100,11 +111,12 @@ fn execute_update_config( Ok(response("update_config", CONTRACT_NAME, attrs)) } -fn execute_receive_nft( +fn execute_receive_nft_withdraw( deps: DepsMut, - _env: Env, info: MessageInfo, - msg: Cw721ReceiveMsg, + sender: String, + token_id: String, + receiver: Option, ) -> ContractResult> { let mut attrs = vec![attr("action", "receive_nft")]; let config = CONFIG.load(deps.storage)?; @@ -115,9 +127,7 @@ fn execute_receive_nft( ); let voucher: NftInfoResponse = deps.querier.query_wasm_smart( config.withdrawal_voucher_contract, - &lido_staking_base::msg::withdrawal_voucher::QueryMsg::NftInfo { - token_id: msg.token_id.clone(), - }, + &lido_staking_base::msg::withdrawal_voucher::QueryMsg::NftInfo { token_id }, )?; let voucher_extention = voucher.extension.ok_or_else(|| ContractError::InvalidNFT { reason: "extension is not set".to_string(), @@ -147,14 +157,17 @@ fn execute_receive_nft( .ok_or(ContractError::BatchSlashingEffectIsEmpty {})? * voucher_extention.expected_amount; + let to_address = receiver.unwrap_or(sender); + attrs.push(attr("batch_id", batch_id.to_string())); + attrs.push(attr("payout_amount", payout_amount.to_string())); + attrs.push(attr("to_address", &to_address)); + let msg = CosmosMsg::Bank(BankMsg::Send { - to_address: msg.sender, + to_address, amount: vec![Coin { denom: config.base_denom, amount: payout_amount, }], }); - attrs.push(attr("batch_id", batch_id.to_string())); - attrs.push(attr("payout_amount", payout_amount.to_string())); Ok(response("execute-receive_nft", CONTRACT_NAME, attrs).add_message(msg)) } diff --git a/integration_tests/package.json b/integration_tests/package.json index cd4e5105..5cba0026 100644 --- a/integration_tests/package.json +++ b/integration_tests/package.json @@ -15,6 +15,7 @@ "test:validators-stats": "vitest --run validators-stats.test --bail 1", "test:validator-set": "vitest --run validator-set.test --bail 1", "test:distribution": "vitest --run distribution.test --bail 1", + "test:auto-withdrawer": "vitest --run auto-withdrawer.test --bail 1", "watch": "vitest", "build-ts-client": "ts-node ./src/rebuild-client.ts", "build-lsm-image": "./dockerfiles/lsm/build.sh", diff --git a/integration_tests/src/generated/contractLib/index.ts b/integration_tests/src/generated/contractLib/index.ts index 61e2564b..87f14d60 100644 --- a/integration_tests/src/generated/contractLib/index.ts +++ b/integration_tests/src/generated/contractLib/index.ts @@ -1,47 +1,50 @@ import * as _0 from './lidoAstroportExchangeHandler'; export const LidoAstroportExchangeHandler = _0; -import * as _1 from './lidoCore'; -export const LidoCore = _1; +import * as _1 from './lidoAutoWithdrawer'; +export const LidoAutoWithdrawer = _1; -import * as _2 from './lidoDistribution'; -export const LidoDistribution = _2; +import * as _2 from './lidoCore'; +export const LidoCore = _2; -import * as _3 from './lidoFactory'; -export const LidoFactory = _3; +import * as _3 from './lidoDistribution'; +export const LidoDistribution = _3; -import * as _4 from './lidoHookTester'; -export const LidoHookTester = _4; +import * as _4 from './lidoFactory'; +export const LidoFactory = _4; -import * as _5 from './lidoPump'; -export const LidoPump = _5; +import * as _5 from './lidoHookTester'; +export const LidoHookTester = _5; -import * as _6 from './lidoPuppeteerAuthz'; -export const LidoPuppeteerAuthz = _6; +import * as _6 from './lidoPump'; +export const LidoPump = _6; -import * as _7 from './lidoPuppeteer'; -export const LidoPuppeteer = _7; +import * as _7 from './lidoPuppeteerAuthz'; +export const LidoPuppeteerAuthz = _7; -import * as _8 from './lidoRewardsManager'; -export const LidoRewardsManager = _8; +import * as _8 from './lidoPuppeteer'; +export const LidoPuppeteer = _8; -import * as _9 from './lidoStargatePoc'; -export const LidoStargatePoc = _9; +import * as _9 from './lidoRewardsManager'; +export const LidoRewardsManager = _9; -import * as _10 from './lidoStrategy'; -export const LidoStrategy = _10; +import * as _10 from './lidoStargatePoc'; +export const LidoStargatePoc = _10; -import * as _11 from './lidoToken'; -export const LidoToken = _11; +import * as _11 from './lidoStrategy'; +export const LidoStrategy = _11; -import * as _12 from './lidoValidatorsSet'; -export const LidoValidatorsSet = _12; +import * as _12 from './lidoToken'; +export const LidoToken = _12; -import * as _13 from './lidoValidatorsStats'; -export const LidoValidatorsStats = _13; +import * as _13 from './lidoValidatorsSet'; +export const LidoValidatorsSet = _13; -import * as _14 from './lidoWithdrawalManager'; -export const LidoWithdrawalManager = _14; +import * as _14 from './lidoValidatorsStats'; +export const LidoValidatorsStats = _14; -import * as _15 from './lidoWithdrawalVoucher'; -export const LidoWithdrawalVoucher = _15; +import * as _15 from './lidoWithdrawalManager'; +export const LidoWithdrawalManager = _15; + +import * as _16 from './lidoWithdrawalVoucher'; +export const LidoWithdrawalVoucher = _16; diff --git a/integration_tests/src/generated/contractLib/lidoAutoWithdrawer.ts b/integration_tests/src/generated/contractLib/lidoAutoWithdrawer.ts new file mode 100644 index 00000000..dfb8cfd5 --- /dev/null +++ b/integration_tests/src/generated/contractLib/lidoAutoWithdrawer.ts @@ -0,0 +1,129 @@ +import { CosmWasmClient, SigningCosmWasmClient, ExecuteResult, InstantiateResult } from "@cosmjs/cosmwasm-stargate"; +import { StdFee } from "@cosmjs/amino"; +export interface InstantiateMsg { + core_address: string; + ld_token: string; + withdrawal_manager_address: string; + withdrawal_voucher_address: string; +} +/** + * 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. + * + * # Examples + * + * Use `from` to create instances of this and `u128` to get the value out: + * + * ``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123); + * + * let b = Uint128::from(42u64); assert_eq!(b.u128(), 42); + * + * let c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ``` + */ +export type Uint128 = string; +export type BondArgs = + | { + with_ld_assets: {}; + } + | { + with_n_f_t: { + token_id: string; + }; + }; + +export interface LidoAutoWithdrawerSchema { + responses: BondingsResponse | InstantiateMsg; + query: BondingsArgs; + execute: BondArgs | UnbondArgs | WithdrawArgs; + [k: string]: unknown; +} +export interface BondingsResponse { + bondings: BondingResponse[]; + next_page_key?: string | null; +} +export interface BondingResponse { + bonder: string; + deposit: Coin[]; + token_id: string; +} +export interface Coin { + amount: Uint128; + denom: string; + [k: string]: unknown; +} +export interface InstantiateMsg { + core_address: string; + ld_token: string; + withdrawal_manager_address: string; + withdrawal_voucher_address: string; +} +export interface BondingsArgs { + /** + * Pagination limit. Default is 100 + */ + limit?: number | null; + /** + * Pagination offset + */ + page_key?: string | null; + /** + * Optionally filter bondings by user address + */ + user?: string | null; +} +export interface UnbondArgs { + token_id: string; +} +export interface WithdrawArgs { + token_id: string; +} + + +function isSigningCosmWasmClient( + client: CosmWasmClient | SigningCosmWasmClient +): client is SigningCosmWasmClient { + return 'execute' in client; +} + +export class Client { + private readonly client: CosmWasmClient | SigningCosmWasmClient; + contractAddress: string; + constructor(client: CosmWasmClient | SigningCosmWasmClient, contractAddress: string) { + this.client = client; + this.contractAddress = contractAddress; + } + mustBeSigningClient() { + return new Error("This client is not a SigningCosmWasmClient"); + } + static async instantiate( + client: SigningCosmWasmClient, + sender: string, + codeId: number, + initMsg: InstantiateMsg, + label: string, + initCoins?: readonly Coin[], + fees?: StdFee | 'auto' | number, + ): Promise { + const res = await client.instantiate(sender, codeId, initMsg, label, fees, { + ...(initCoins && initCoins.length && { funds: initCoins }), + }); + return res; + } + queryBondings = async(args: BondingsArgs): Promise => { + return this.client.queryContractSmart(this.contractAddress, { bondings: args }); + } + queryConfig = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { config: {} }); + } + bond = async(sender:string, args: BondArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { bond: args }, fee || "auto", memo, funds); + } + unbond = async(sender:string, args: UnbondArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { unbond: args }, fee || "auto", memo, funds); + } + withdraw = async(sender:string, args: WithdrawArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { + if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } + return this.client.execute(sender, this.contractAddress, { withdraw: args }, fee || "auto", memo, funds); + } +} diff --git a/integration_tests/src/generated/contractLib/lidoRewardsManager.ts b/integration_tests/src/generated/contractLib/lidoRewardsManager.ts index 55231e31..ca4c858d 100644 --- a/integration_tests/src/generated/contractLib/lidoRewardsManager.ts +++ b/integration_tests/src/generated/contractLib/lidoRewardsManager.ts @@ -18,15 +18,15 @@ export interface InstantiateMsg { * let c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ``` */ export type Uint128 = string; +export type ArrayOfHandlerConfig = HandlerConfig[]; export interface LidoRewardsManagerSchema { - responses: ConfigResponse; + responses: ConfigResponse | ArrayOfHandlerConfig; execute: UpdateConfigArgs | AddHandlerArgs | RemoveHandlerArgs; [k: string]: unknown; } export interface ConfigResponse { core_address: string; - handlers: HandlerConfig[]; } export interface HandlerConfig { address: string; @@ -77,6 +77,9 @@ export class Client { queryConfig = async(): Promise => { return this.client.queryContractSmart(this.contractAddress, { config: {} }); } + queryHandlers = async(): Promise => { + return this.client.queryContractSmart(this.contractAddress, { handlers: {} }); + } updateConfig = async(sender:string, args: UpdateConfigArgs, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]): Promise => { if (!isSigningCosmWasmClient(this.client)) { throw this.mustBeSigningClient(); } return this.client.execute(sender, this.contractAddress, { update_config: args }, fee || "auto", memo, funds); diff --git a/integration_tests/src/testcases/auto-withdrawer.test.ts b/integration_tests/src/testcases/auto-withdrawer.test.ts new file mode 100644 index 00000000..fcf7f815 --- /dev/null +++ b/integration_tests/src/testcases/auto-withdrawer.test.ts @@ -0,0 +1,634 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { + LidoAutoWithdrawer, + LidoCore, + LidoFactory, + LidoWithdrawalManager, + LidoWithdrawalVoucher, +} from '../generated/contractLib'; +import { + QueryClient, + StakingExtension, + BankExtension, + setupStakingExtension, + setupBankExtension, + SigningStargateClient, +} from '@cosmjs/stargate'; +import { MsgTransfer } from 'cosmjs-types/ibc/applications/transfer/v1/tx'; +import { join } from 'path'; +import { Tendermint34Client } from '@cosmjs/tendermint-rpc'; +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'; +import { Client as NeutronClient } from '@neutron-org/client-ts'; +import { AccountData, DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { GasPrice } from '@cosmjs/stargate'; +import { setupPark } from '../testSuite'; +import fs from 'fs'; +import Cosmopark from '@neutron-org/cosmopark'; +import { waitFor } from '../helpers/waitFor'; + +const LidoFactoryClass = LidoFactory.Client; +const LidoCoreClass = LidoCore.Client; +const LidoWithdrawalVoucherClass = LidoWithdrawalVoucher.Client; +const LidoWithdrawalManagerClass = LidoWithdrawalManager.Client; +const LidoAutoWithdrawerClass = LidoAutoWithdrawer.Client; + +describe('Auto withdrawer', () => { + const context: { + park?: Cosmopark; + contractAddress?: string; + wallet?: DirectSecp256k1HdWallet; + gaiaWallet?: DirectSecp256k1HdWallet; + contractClient?: InstanceType; + coreContractClient?: InstanceType; + withdrawalVoucherContractClient?: InstanceType< + typeof LidoWithdrawalVoucherClass + >; + withdrawalManagerContractClient?: InstanceType< + typeof LidoWithdrawalManagerClass + >; + autoWithdrawerContractClient?: InstanceType; + account?: AccountData; + icaAddress?: string; + client?: SigningCosmWasmClient; + gaiaClient?: SigningStargateClient; + gaiaUserAddress?: string; + gaiaQueryClient?: QueryClient & StakingExtension & BankExtension; + neutronClient?: InstanceType; + neutronUserAddress?: string; + neutronSecondUserAddress?: string; + validatorAddress?: string; + secondValidatorAddress?: string; + tokenizedDenomOnNeutron?: string; + codeIds: { + core?: number; + token?: number; + withdrawalVoucher?: number; + withdrawalManager?: number; + strategy?: number; + puppeteer?: number; + validatorsSet?: number; + distribution?: number; + rewardsManager?: number; + }; + exchangeRate?: number; + tokenContractAddress?: string; + neutronIBCDenom?: string; + ldDenom?: string; + } = { + codeIds: {}, + }; + + beforeAll(async () => { + context.park = await setupPark('autowithdrawer', ['neutron', 'gaia'], true); + context.wallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'neutron', + }, + ); + context.gaiaWallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demowallet1.mnemonic, + { + prefix: 'cosmos', + }, + ); + context.account = (await context.wallet.getAccounts())[0]; + context.neutronClient = new NeutronClient({ + apiURL: `http://127.0.0.1:${context.park.ports.neutron.rest}`, + rpcURL: `127.0.0.1:${context.park.ports.neutron.rpc}`, + prefix: 'neutron', + }); + context.client = await SigningCosmWasmClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.neutron.rpc}`, + context.wallet, + { + gasPrice: GasPrice.fromString('0.025untrn'), + }, + ); + context.gaiaClient = await SigningStargateClient.connectWithSigner( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + context.gaiaWallet, + { + gasPrice: GasPrice.fromString('0.025stake'), + }, + ); + const tmClient = await Tendermint34Client.connect( + `http://127.0.0.1:${context.park.ports.gaia.rpc}`, + ); + context.gaiaQueryClient = QueryClient.withExtensions( + tmClient, + setupStakingExtension, + setupBankExtension, + ); + + const secondWallet = await DirectSecp256k1HdWallet.fromMnemonic( + context.park.config.wallets.demo2.mnemonic, + { + prefix: 'neutron', + }, + ); + context.neutronSecondUserAddress = ( + await secondWallet.getAccounts() + )[0].address; + }); + + afterAll(async () => { + await context.park.stop(); + }); + + it('instantiate', async () => { + const { client, account } = context; + + { + const res = await client.upload( + account.address, + fs.readFileSync(join(__dirname, '../../../artifacts/lido_core.wasm')), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.core = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync(join(__dirname, '../../../artifacts/lido_token.wasm')), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.token = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_withdrawal_voucher.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.withdrawalVoucher = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_withdrawal_manager.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.withdrawalManager = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_strategy.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.strategy = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_distribution.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.distribution = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_validators_set.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.validatorsSet = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_puppeteer.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.puppeteer = res.codeId; + } + { + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_rewards_manager.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + context.codeIds.rewardsManager = res.codeId; + } + const res = await client.upload( + account.address, + fs.readFileSync(join(__dirname, '../../../artifacts/lido_factory.wasm')), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + const instantiateRes = await LidoFactory.Client.instantiate( + client, + account.address, + res.codeId, + { + code_ids: { + core_code_id: context.codeIds.core, + token_code_id: context.codeIds.token, + withdrawal_voucher_code_id: context.codeIds.withdrawalVoucher, + withdrawal_manager_code_id: context.codeIds.withdrawalManager, + strategy_code_id: context.codeIds.strategy, + distribution_code_id: context.codeIds.distribution, + validators_set_code_id: context.codeIds.validatorsSet, + puppeteer_code_id: context.codeIds.puppeteer, + rewards_manager_code_id: context.codeIds.rewardsManager, + }, + remote_opts: { + connection_id: 'connection-0', + transfer_channel_id: 'channel-0', + port_id: 'transfer', + denom: 'stake', + update_period: 2, + }, + salt: 'salt', + subdenom: 'lido', + token_metadata: { + description: 'Lido token', + display: 'lido', + exponent: 6, + name: 'Lido liquid staking token', + symbol: 'LIDO', + uri: null, + uri_hash: null, + }, + }, + 'Lido-staking-factory', + [], + 'auto', + ); + expect(instantiateRes.contractAddress).toHaveLength(66); + context.contractAddress = instantiateRes.contractAddress; + context.contractClient = new LidoFactory.Client( + client, + context.contractAddress, + ); + context.gaiaUserAddress = ( + await context.gaiaWallet.getAccounts() + )[0].address; + context.neutronUserAddress = ( + await context.wallet.getAccounts() + )[0].address; + }); + + it('transfer tokens to neutron', async () => { + const { gaiaClient, gaiaUserAddress, neutronUserAddress, neutronClient } = + context; + const res = await gaiaClient.signAndBroadcast( + gaiaUserAddress, + [ + { + typeUrl: '/ibc.applications.transfer.v1.MsgTransfer', + value: MsgTransfer.fromPartial({ + sender: gaiaUserAddress, + sourceChannel: 'channel-0', + sourcePort: 'transfer', + receiver: neutronUserAddress, + token: { denom: 'stake', amount: '2000000' }, + timeoutTimestamp: BigInt((Date.now() + 10 * 60 * 1000) * 1e6), + timeoutHeight: { + revisionHeight: BigInt(0), + revisionNumber: BigInt(0), + }, + }), + }, + ], + 1.5, + ); + expect(res.transactionHash).toHaveLength(64); + await waitFor(async () => { + const balances = + await neutronClient.CosmosBankV1Beta1.query.queryAllBalances( + neutronUserAddress, + ); + context.neutronIBCDenom = balances.data.balances.find((b) => + b.denom.startsWith('ibc/'), + )?.denom; + return balances.data.balances.length > 1; + }); + expect(context.neutronIBCDenom).toBeTruthy(); + }); + it('init', async () => { + const { contractClient, neutronUserAddress, neutronIBCDenom } = context; + { + const res = await contractClient.init(context.neutronUserAddress, { + base_denom: context.neutronIBCDenom, + core_params: { + idle_min_interval: 1, + puppeteer_timeout: 60, + unbond_batch_switch_time: 6000, + unbonding_safe_period: 10, + unbonding_period: 60, + }, + }); + expect(res.transactionHash).toHaveLength(64); + } + const res = await contractClient.queryState(); + context.coreContractClient = new LidoCore.Client( + context.client, + res.core_contract, + ); + context.withdrawalVoucherContractClient = new LidoWithdrawalVoucher.Client( + context.client, + res.withdrawal_voucher_contract, + ); + context.withdrawalManagerContractClient = new LidoWithdrawalManager.Client( + context.client, + res.withdrawal_manager_contract, + ); + context.tokenContractAddress = res.token_contract; + context.exchangeRate = parseFloat( + await context.coreContractClient.queryExchangeRate(), + ); + context.ldDenom = `factory/${context.tokenContractAddress}/lido`; + + { + const res = await context.coreContractClient.bond( + neutronUserAddress, + {}, + 1.6, + undefined, + [ + { + amount: '500000', + denom: neutronIBCDenom, + }, + ], + ); + expect(res.transactionHash).toHaveLength(64); + } + { + const res = await context.coreContractClient.unbond( + neutronUserAddress, + 1.6, + undefined, + [ + { + amount: Math.floor(400_000 / context.exchangeRate).toString(), + denom: context.ldDenom, + }, + ], + ); + expect(res.transactionHash).toHaveLength(64); + } + }); + it('setup auto withdrawer', async () => { + const { client, account, ldDenom } = context; + const res = await client.upload( + account.address, + fs.readFileSync( + join(__dirname, '../../../artifacts/lido_auto_withdrawer.wasm'), + ), + 1.5, + ); + expect(res.codeId).toBeGreaterThan(0); + const instantiateRes = await LidoAutoWithdrawer.Client.instantiate( + client, + account.address, + res.codeId, + { + core_address: context.coreContractClient.contractAddress, + withdrawal_voucher_address: + context.withdrawalVoucherContractClient.contractAddress, + withdrawal_manager_address: + context.withdrawalManagerContractClient.contractAddress, + ld_token: ldDenom, + }, + 'Lido-auto-withdrawer', + [], + 'auto', + ); + expect(instantiateRes.contractAddress).toHaveLength(66); + context.autoWithdrawerContractClient = new LidoAutoWithdrawer.Client( + client, + instantiateRes.contractAddress, + ); + }); + + // TODO: test deposit + it('bond with ld assets', async () => { + const { neutronUserAddress, ldDenom, autoWithdrawerContractClient } = + context; + const res = await autoWithdrawerContractClient.bond( + neutronUserAddress, + { + with_ld_assets: {}, + }, + 1.6, + undefined, + [ + { + amount: String(2000), + denom: ldDenom, + }, + { + amount: String(50000), + denom: 'untrn', + }, + ], + ); + expect(res.transactionHash).toHaveLength(64); + + const bondings = await autoWithdrawerContractClient.queryBondings({ + user: neutronUserAddress, + }); + expect(bondings).toEqual({ + bondings: [ + { + bonder: neutronUserAddress, + deposit: [ + { + amount: '50000', + denom: 'untrn', + }, + ], + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + ], + next_page_key: null, + }); + }); + + it('unbond', async () => { + const { + neutronUserAddress, + autoWithdrawerContractClient, + withdrawalVoucherContractClient, + } = context; + const res = await autoWithdrawerContractClient.unbond( + neutronUserAddress, + { + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + 1.6, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + + const owner = await withdrawalVoucherContractClient.queryOwnerOf({ + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }); + expect(owner.owner).toEqual(neutronUserAddress); + + const bondings = await autoWithdrawerContractClient.queryBondings({ + user: neutronUserAddress, + }); + expect(bondings).toEqual({ + bondings: [], + next_page_key: null, + }); + }); + + it('bond with NFT', async () => { + const { + neutronUserAddress, + autoWithdrawerContractClient, + withdrawalVoucherContractClient, + } = context; + + { + const res = await withdrawalVoucherContractClient.approve( + neutronUserAddress, + { + spender: autoWithdrawerContractClient.contractAddress, + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + 1.6, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + } + { + const res = await autoWithdrawerContractClient.bond( + neutronUserAddress, + { + with_n_f_t: { + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + }, + 1.6, + undefined, + [{ amount: '40000', denom: 'untrn' }], + ); + expect(res.transactionHash).toHaveLength(64); + } + + const bondings = await autoWithdrawerContractClient.queryBondings({ + user: neutronUserAddress, + }); + expect(bondings).toEqual({ + bondings: [ + { + bonder: neutronUserAddress, + deposit: [ + { + amount: '40000', + denom: 'untrn', + }, + ], + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + ], + next_page_key: null, + }); + }); + + // TODO: figure out how to sign this tx from neutronSecondUserAccount + it('try to withdraw before unbonding period is over', async () => { + const { neutronUserAddress, autoWithdrawerContractClient } = context; + + await expect( + autoWithdrawerContractClient.withdraw( + neutronUserAddress, + { + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + 1.6, + undefined, + [], + ), + ).rejects.toThrowError(/is not unbonded yet/); + }); + + it('fake unbonding period', async () => { + const { neutronUserAddress, neutronIBCDenom } = context; + await context.coreContractClient.fakeProcessBatch(neutronUserAddress, { + batch_id: '0', + unbonded_amount: '499999', + }); + await context.client.sendTokens( + neutronUserAddress, + context.withdrawalManagerContractClient.contractAddress, + [{ amount: '500000', denom: neutronIBCDenom }], + 1.6, + undefined, + ); + }); + + it('withdraw', async () => { + const { + neutronUserAddress, + neutronClient, + neutronIBCDenom, + autoWithdrawerContractClient, + } = context; + + const balanceBefore = parseInt( + ( + await neutronClient.CosmosBankV1Beta1.query.queryBalance( + neutronUserAddress, + { denom: neutronIBCDenom }, + ) + ).data.balance.amount, + ); + + const res = await autoWithdrawerContractClient.withdraw( + neutronUserAddress, + { + token_id: `0_${autoWithdrawerContractClient.contractAddress}_2`, + }, + 1.6, + undefined, + [], + ); + expect(res.transactionHash).toHaveLength(64); + + const balance = await neutronClient.CosmosBankV1Beta1.query.queryBalance( + neutronUserAddress, + { denom: neutronIBCDenom }, + ); + expect(parseInt(balance.data.balance.amount) - balanceBefore).toBe(2512); + + const bondings = await autoWithdrawerContractClient.queryBondings({ + user: neutronUserAddress, + }); + expect(bondings).toEqual({ + bondings: [], + next_page_key: null, + }); + }); +}); diff --git a/integration_tests/src/testcases/core.test.ts b/integration_tests/src/testcases/core.test.ts index e449f6c3..cc50faeb 100644 --- a/integration_tests/src/testcases/core.test.ts +++ b/integration_tests/src/testcases/core.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import { + LidoAutoWithdrawer, LidoCore, LidoFactory, LidoPump, @@ -37,6 +38,7 @@ const LidoPuppeteerClass = LidoPuppeteer.Client; const LidoStrategyClass = LidoStrategy.Client; const LidoWithdrawalVoucherClass = LidoWithdrawalVoucher.Client; const LidoWithdrawalManagerClass = LidoWithdrawalManager.Client; +const LidoAutoWithdrawerClass = LidoAutoWithdrawer.Client; describe('Core', () => { const context: { @@ -55,6 +57,7 @@ describe('Core', () => { withdrawalManagerContractClient?: InstanceType< typeof LidoWithdrawalManagerClass >; + autoWithdrawerContractClient?: InstanceType; account?: AccountData; icaAddress?: string; client?: SigningCosmWasmClient; @@ -144,7 +147,6 @@ describe('Core', () => { it('instantiate', async () => { const { client, account } = context; - context.codeIds = {}; { const res = await client.upload( @@ -560,18 +562,26 @@ describe('Core', () => { it('unbond', async () => { const { coreContractClient, neutronUserAddress, ldDenom } = context; - const res = await coreContractClient.unbond( + let res = await coreContractClient.unbond( neutronUserAddress, 1.6, undefined, [ { - amount: Math.floor(500_000 / context.exchangeRate).toString(), + amount: Math.floor(300_000 / context.exchangeRate).toString(), denom: ldDenom, }, ], ); expect(res.transactionHash).toHaveLength(64); + + res = await coreContractClient.unbond(neutronUserAddress, 1.6, undefined, [ + { + amount: Math.floor(100_000 / context.exchangeRate).toString(), + denom: ldDenom, + }, + ]); + expect(res.transactionHash).toHaveLength(64); }); it('validate unbonding batch', async () => { @@ -585,12 +595,17 @@ describe('Core', () => { created: expect.any(Number), expected_release: 0, status: 'new', - total_amount: '495049', - expected_amount: '499999', + total_amount: '396038', + expected_amount: '399998', unbond_items: [ { - amount: '495049', - expected_amount: '499999', + amount: '297029', + expected_amount: '299999', + sender: neutronUserAddress, + }, + { + amount: '99009', + expected_amount: '99999', sender: neutronUserAddress, }, ], @@ -604,16 +619,56 @@ describe('Core', () => { const vouchers = await withdrawalVoucherContractClient.queryTokens({ owner: context.neutronUserAddress, }); - expect(vouchers.tokens.length).toBe(1); + expect(vouchers.tokens.length).toBe(2); + expect(vouchers.tokens[0]).toBe(`0_${neutronUserAddress}_1`); - const tokenId = vouchers.tokens[0]; - const voucher = await withdrawalVoucherContractClient.queryNftInfo({ + let tokenId = vouchers.tokens[0]; + let voucher = await withdrawalVoucherContractClient.queryNftInfo({ + token_id: tokenId, + }); + expect(voucher).toBeTruthy(); + expect(voucher).toMatchObject({ + extension: { + amount: '297029', + attributes: [ + { + display_type: null, + trait_type: 'unbond_batch_id', + value: '0', + }, + { + display_type: null, + trait_type: 'received_amount', + value: '297029', + }, + { + display_type: null, + trait_type: 'expected_amount', + value: '299999', + }, + { + display_type: null, + trait_type: 'exchange_rate', + value: '1.01', + }, + ], + batch_id: '0', + description: 'Withdrawal voucher', + expected_amount: '299999', + name: 'LDV voucher', + }, + token_uri: null, + }); + + expect(vouchers.tokens[1]).toBe(`0_${neutronUserAddress}_2`); + tokenId = vouchers.tokens[1]; + voucher = await withdrawalVoucherContractClient.queryNftInfo({ token_id: tokenId, }); expect(voucher).toBeTruthy(); expect(voucher).toMatchObject({ extension: { - amount: '495049', + amount: '99009', attributes: [ { display_type: null, @@ -623,12 +678,12 @@ describe('Core', () => { { display_type: null, trait_type: 'received_amount', - value: '495049', + value: '99009', }, { display_type: null, trait_type: 'expected_amount', - value: '499999', + value: '99999', }, { display_type: null, @@ -638,7 +693,7 @@ describe('Core', () => { ], batch_id: '0', description: 'Withdrawal voucher', - expected_amount: '499999', + expected_amount: '99999', name: 'LDV voucher', }, token_uri: null, @@ -652,7 +707,11 @@ describe('Core', () => { withdrawalVoucherContractClient.sendNft(neutronUserAddress, { token_id: tokenId, contract: context.withdrawalManagerContractClient.contractAddress, - msg: Buffer.from('{}').toString('base64'), + msg: Buffer.from( + JSON.stringify({ + withdraw: {}, + }), + ).toString('base64'), }), ).rejects.toThrowError(/is not unbonded yet/); }); @@ -672,16 +731,21 @@ describe('Core', () => { }); expect(batch).toBeTruthy(); expect(batch).toEqual({ - slashing_effect: '1', + slashing_effect: '1.250003750018750093', status: 'unbonded', created: expect.any(Number), expected_release: 0, - total_amount: '495049', - expected_amount: '499999', + total_amount: '396038', + expected_amount: '399998', unbond_items: [ { - amount: '495049', - expected_amount: '499999', + amount: '297029', + expected_amount: '299999', + sender: neutronUserAddress, + }, + { + amount: '99009', + expected_amount: '99999', sender: neutronUserAddress, }, ], @@ -700,7 +764,11 @@ describe('Core', () => { voucherContractClient.sendNft(neutronUserAddress, { token_id: tokenId, contract: context.withdrawalManagerContractClient.contractAddress, - msg: Buffer.from('{}').toString('base64'), + msg: Buffer.from( + JSON.stringify({ + withdraw: {}, + }), + ).toString('base64'), }), ).rejects.toThrowError(/spendable balance {2}is smaller/); }); @@ -740,13 +808,53 @@ describe('Core', () => { const res = await voucherContractClient.sendNft(neutronUserAddress, { token_id: tokenId, contract: context.withdrawalManagerContractClient.contractAddress, - msg: Buffer.from('{}').toString('base64'), + msg: Buffer.from( + JSON.stringify({ + withdraw: {}, + }), + ).toString('base64'), }); expect(res.transactionHash).toHaveLength(64); const balance = await neutronClient.CosmosBankV1Beta1.query.queryBalance( neutronUserAddress, { denom: neutronIBCDenom }, ); - expect(parseInt(balance.data.balance.amount) - balanceBefore).toBe(499999); + expect(parseInt(balance.data.balance.amount) - balanceBefore).toBe(374999); + }); + it('withdraw to custom receiver', async () => { + const { + withdrawalVoucherContractClient: voucherContractClient, + neutronUserAddress, + neutronSecondUserAddress, + neutronClient, + neutronIBCDenom, + } = context; + const balanceBefore = parseInt( + ( + await neutronClient.CosmosBankV1Beta1.query.queryBalance( + neutronSecondUserAddress, + { denom: neutronIBCDenom }, + ) + ).data.balance.amount, + ); + expect(balanceBefore).toEqual(0); + const tokenId = `0_${neutronUserAddress}_2`; + const res = await voucherContractClient.sendNft(neutronUserAddress, { + token_id: tokenId, + contract: context.withdrawalManagerContractClient.contractAddress, + msg: Buffer.from( + JSON.stringify({ + withdraw: { + receiver: neutronSecondUserAddress, + }, + }), + ).toString('base64'), + }); + expect(res.transactionHash).toHaveLength(64); + const balance = await neutronClient.CosmosBankV1Beta1.query.queryBalance( + neutronSecondUserAddress, + { denom: neutronIBCDenom }, + ); + expect(parseInt(balance.data.balance.amount)).toBe(124999); }); }); diff --git a/packages/base/src/msg/withdrawal_manager.rs b/packages/base/src/msg/withdrawal_manager.rs index 518d37c9..9adced69 100644 --- a/packages/base/src/msg/withdrawal_manager.rs +++ b/packages/base/src/msg/withdrawal_manager.rs @@ -26,5 +26,10 @@ pub enum ExecuteMsg { ReceiveNft(Cw721ReceiveMsg), } +#[cw_serde] +pub enum ReceiveNftMsg { + Withdraw { receiver: Option }, +} + #[cw_serde] pub enum MigrateMsg {} diff --git a/packages/base/src/state/core.rs b/packages/base/src/state/core.rs index 11d1f8c8..fae522bc 100644 --- a/packages/base/src/state/core.rs +++ b/packages/base/src/state/core.rs @@ -50,6 +50,9 @@ pub struct UnbondBatch { pub total_amount: Uint128, pub expected_amount: Uint128, pub expected_release: u64, + // TODO: this always growing array should definitely be refactored into some kind of a map, + // because each successfull unbond call will consume more and more gas on (de)serialization + // until it eventually doesn't fit in a block anymore pub unbond_items: Vec, pub status: UnbondBatchStatus, pub slashing_effect: Option,