From 2deef67a8f99bdc4d43a8afa62c91e4554caff14 Mon Sep 17 00:00:00 2001 From: playX18 <158266309+playX18@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:55:46 +0700 Subject: [PATCH] feat(gear-programs): vft-treasury program implementation (#127) --- Cargo.lock | 37 ++ Cargo.toml | 4 + gear-programs/vft-treasury/Cargo.toml | 22 + gear-programs/vft-treasury/build.rs | 24 + gear-programs/vft-treasury/src/lib.rs | 19 + .../src/services/bridge_builtin_operations.rs | 64 +++ .../vft-treasury/src/services/error.rs | 23 + .../vft-treasury/src/services/mod.rs | 419 ++++++++++++++++++ .../vft-treasury/src/services/msg_tracker.rs | 118 +++++ .../src/services/token_operations.rs | 92 ++++ .../vft-treasury/src/services/utils.rs | 156 +++++++ .../vft-treasury/src/services/vft.rs | 6 + .../vft-treasury/src/wasm/Cargo.toml | 27 ++ gear-programs/vft-treasury/src/wasm/build.rs | 20 + .../vft-treasury/src/wasm/src/lib.rs | 6 + .../vft-treasury/src/wasm/tests/end2end.rs | 93 ++++ .../vft-treasury/src/wasm/tests/utils.rs | 247 +++++++++++ .../src/wasm/tests/utils_gclient.rs | 322 ++++++++++++++ .../src/wasm/tests/vft_treasury.rs | 176 ++++++++ .../vft-treasury/src/wasm/vft-treasury.idl | 82 ++++ gear-programs/vft-treasury/vft.idl | 33 ++ 21 files changed, 1990 insertions(+) create mode 100644 gear-programs/vft-treasury/Cargo.toml create mode 100644 gear-programs/vft-treasury/build.rs create mode 100644 gear-programs/vft-treasury/src/lib.rs create mode 100644 gear-programs/vft-treasury/src/services/bridge_builtin_operations.rs create mode 100644 gear-programs/vft-treasury/src/services/error.rs create mode 100644 gear-programs/vft-treasury/src/services/mod.rs create mode 100644 gear-programs/vft-treasury/src/services/msg_tracker.rs create mode 100644 gear-programs/vft-treasury/src/services/token_operations.rs create mode 100644 gear-programs/vft-treasury/src/services/utils.rs create mode 100644 gear-programs/vft-treasury/src/services/vft.rs create mode 100644 gear-programs/vft-treasury/src/wasm/Cargo.toml create mode 100644 gear-programs/vft-treasury/src/wasm/build.rs create mode 100644 gear-programs/vft-treasury/src/wasm/src/lib.rs create mode 100644 gear-programs/vft-treasury/src/wasm/tests/end2end.rs create mode 100644 gear-programs/vft-treasury/src/wasm/tests/utils.rs create mode 100644 gear-programs/vft-treasury/src/wasm/tests/utils_gclient.rs create mode 100644 gear-programs/vft-treasury/src/wasm/tests/vft_treasury.rs create mode 100644 gear-programs/vft-treasury/src/wasm/vft-treasury.idl create mode 100644 gear-programs/vft-treasury/vft.idl diff --git a/Cargo.lock b/Cargo.lock index 117daa9b..8e1568bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13937,6 +13937,24 @@ dependencies = [ "scale-info", ] +[[package]] +name = "vft-treasury-app" +version = "0.1.0" +dependencies = [ + "blake2", + "gbuiltin-eth-bridge", + "gclient 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gear-core 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "git-download", + "gstd 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "parity-scale-codec", + "primitive-types 0.12.2", + "sails-client-gen 0.3.0", + "sails-rs 0.3.0", + "scale-info", + "tokio", +] + [[package]] name = "vft_gateway_wasm" version = "0.1.0" @@ -13955,6 +13973,25 @@ dependencies = [ "vft-gateway-app", ] +[[package]] +name = "vft_treasury_wasm" +version = "0.1.0" +dependencies = [ + "blake2", + "extended_vft_wasm", + "gclient 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gear-core 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gear-wasm-builder 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "gtest 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "parity-scale-codec", + "sails-client-gen 0.3.0", + "sails-idl-gen 0.3.0", + "sails-rs 0.3.0", + "scale-info", + "tokio", + "vft-treasury-app", +] + [[package]] name = "void" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 3b19ef6e..f32707d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "gear-programs/bridging-payment/src/wasm", "gear-programs/vft-gateway", "gear-programs/vft-gateway/src/wasm", + "gear-programs/vft-treasury", + "gear-programs/vft-treasury/src/wasm", "gear-programs/*", "gear-programs/checkpoint-light-client/io", "utils-prometheus", @@ -37,6 +39,8 @@ bridging_payment = { path = "gear-programs/bridging-payment" } bridging_payment_wasm = { path = "gear-programs/bridging_payment/src/wasm" } vft-gateway-app = { path = "gear-programs/vft-gateway" } vft_gateway_wasm = { path = "gear-programs/vft-gateway/src/wasm" } +vft-treasury-app = { path = "gear-programs/vft-treasury" } +vft_treasury_wasm = { path = "gear-programs/vft-treasury/src/wasm" } gear_proof_storage = { path = "gear-programs/proof-storage" } checkpoint_light_client-io = { path = "gear-programs/checkpoint-light-client/io", default-features = false } utils-prometheus = { path = "utils-prometheus" } diff --git a/gear-programs/vft-treasury/Cargo.toml b/gear-programs/vft-treasury/Cargo.toml new file mode 100644 index 00000000..2bad51ef --- /dev/null +++ b/gear-programs/vft-treasury/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vft-treasury-app" +version.workspace = true +edition.workspace = true + +[dependencies] +sails-rs.workspace = true +parity-scale-codec.workspace = true +scale-info.workspace = true +gstd = { workspace = true, features = ["debug"] } +gbuiltin-eth-bridge.workspace = true + +[build-dependencies] +git-download.workspace = true +sails-client-gen.workspace = true + +[dev-dependencies] +gclient.workspace = true +tokio.workspace = true +blake2.workspace = true +gear-core.workspace = true +primitive-types.workspace = true diff --git a/gear-programs/vft-treasury/build.rs b/gear-programs/vft-treasury/build.rs new file mode 100644 index 00000000..e84458d9 --- /dev/null +++ b/gear-programs/vft-treasury/build.rs @@ -0,0 +1,24 @@ +use sails_client_gen::ClientGenerator; +use std::{env, path::PathBuf}; + +fn main() { + let out_dir_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + let client_rs_file_path = out_dir_path.join("vft.rs"); + + #[cfg(not(target_family = "windows"))] + let idl_file_path = out_dir_path.join("vft.idl"); + #[cfg(not(target_family = "windows"))] + git_download::repo("https://github.com/gear-foundation/standards") + .branch_name("master") + .add_file("extended-vft/wasm/extended_vft.idl", &idl_file_path) + .exec() + .unwrap(); + + // use local copy of `vft.idl` to build on windows + #[cfg(target_family = "windows")] + let idl_file_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("vft.idl"); + + ClientGenerator::from_idl_path(&idl_file_path) + .generate_to(client_rs_file_path) + .unwrap(); +} diff --git a/gear-programs/vft-treasury/src/lib.rs b/gear-programs/vft-treasury/src/lib.rs new file mode 100644 index 00000000..e68ed404 --- /dev/null +++ b/gear-programs/vft-treasury/src/lib.rs @@ -0,0 +1,19 @@ +#![no_std] + +use sails_rs::{gstd::GStdExecContext, prelude::*}; +pub mod services; +use services::{InitConfig, VftTreasury}; +#[derive(Default)] +pub struct Program; + +#[program] +impl Program { + pub fn new(init_config: InitConfig) -> Self { + VftTreasury::::seed(init_config, GStdExecContext::new()); + Self + } + + pub fn vft_treasury(&self) -> VftTreasury { + VftTreasury::new(GStdExecContext::new()) + } +} diff --git a/gear-programs/vft-treasury/src/services/bridge_builtin_operations.rs b/gear-programs/vft-treasury/src/services/bridge_builtin_operations.rs new file mode 100644 index 00000000..f3fcb4fc --- /dev/null +++ b/gear-programs/vft-treasury/src/services/bridge_builtin_operations.rs @@ -0,0 +1,64 @@ +use super::{msg_tracker_mut, utils, Config, Error, MessageStatus}; +use gstd::MessageId; +use sails_rs::prelude::*; + +pub async fn send_message_to_bridge_builtin( + gear_bridge_builtin: ActorId, + receiver_contract_address: H160, + receiver: H160, + token_id: H160, + amount: U256, + config: &Config, + msg_id: MessageId, +) -> Result { + msg_tracker_mut() + .update_message_status(msg_id, MessageStatus::SendingMessageToBridgeBuiltin) + .expect("no message found"); + + let payload_bytes = Payload { + receiver, + token_id, + amount, + } + .pack(); + + let bytes = gbuiltin_eth_bridge::Request::SendEthMessage { + destination: receiver_contract_address, + payload: payload_bytes, + } + .encode(); + + utils::set_critical_hook(msg_id); + utils::send_message_with_gas_for_reply( + gear_bridge_builtin, + bytes, + config.gas_to_send_request_to_builtin, + config.gas_for_reply_deposit, + config.reply_timeout, + msg_id, + ) + .await?; + msg_tracker_mut().check_bridge_reply(&msg_id) +} + +#[derive(Debug, Decode, Encode, TypeInfo)] +pub struct Payload { + pub receiver: H160, + pub token_id: H160, + pub amount: U256, +} + +impl Payload { + pub fn pack(self) -> Vec { + let mut packed = Vec::with_capacity(20 + 20 + 32); // H160 is 20 bytes, U256 is 32 bytes + + packed.extend_from_slice(self.receiver.as_bytes()); + packed.extend_from_slice(self.token_id.as_bytes()); + + let mut amount_bytes = [0u8; 32]; + self.amount.to_big_endian(&mut amount_bytes); + packed.extend_from_slice(&amount_bytes); + + packed + } +} diff --git a/gear-programs/vft-treasury/src/services/error.rs b/gear-programs/vft-treasury/src/services/error.rs new file mode 100644 index 00000000..7ab345a8 --- /dev/null +++ b/gear-programs/vft-treasury/src/services/error.rs @@ -0,0 +1,23 @@ +use sails_rs::prelude::*; + +#[derive(Debug, Encode, Decode, TypeInfo, Clone, PartialEq, Eq)] +pub enum Error { + SendFailure(String), + ReplyFailure(String), + BuiltinDecode, + ReplyTimeout, + DuplicateAddressMapping, + NoCorrespondingEthAddress, + ReplyHook(String), + MessageNotFound, + InvalidMessageStatus, + MessageFailed, + BridgeBuiltinMessageFailed, + TokensRefunded, + NotEthClient, + NotAdmin, + NotBridgingClient, + NotEnoughGas, + TransferFailed, + TransferTokensDecode, +} diff --git a/gear-programs/vft-treasury/src/services/mod.rs b/gear-programs/vft-treasury/src/services/mod.rs new file mode 100644 index 00000000..1d18ba4e --- /dev/null +++ b/gear-programs/vft-treasury/src/services/mod.rs @@ -0,0 +1,419 @@ +pub(super) use error::Error; +use msg_tracker::{MessageInfo, MessageStatus, MessageTracker, TxDetails}; +use sails_rs::{gstd::debug, gstd::ExecContext, prelude::*}; +mod bridge_builtin_operations; +pub mod error; +mod msg_tracker; +mod token_operations; +mod utils; +mod vft; + +pub struct VftTreasury { + exec_context: ExecContext, +} + +static mut DATA: Option = None; +static mut CONFIG: Option = None; +static mut MSG_TRACKER: Option = None; + +#[derive(Debug, Default)] +struct VftTreasuryData { + admin: ActorId, + receiver_contract_address: H160, + gear_bridge_builtin: ActorId, + ethereum_event_client: ActorId, + vara_eth_mapping: Vec<(ActorId, H160)>, +} + +#[derive(Debug, Decode, Encode, TypeInfo, Clone)] +pub struct Config { + pub gas_for_transfer_tokens: u64, + pub gas_for_reply_deposit: u64, + pub gas_to_send_request_to_builtin: u64, + pub reply_timeout: u32, + pub gas_for_transfer_to_eth_msg: u64, +} + +impl Config { + pub fn new( + gas_for_transfer_tokens: u64, + gas_for_reply_deposit: u64, + gas_to_send_request_to_builtin: u64, + reply_timeout: u32, + gas_for_transfer_to_eth_msg: u64, + ) -> Self { + Self { + gas_for_transfer_tokens, + gas_for_reply_deposit, + gas_to_send_request_to_builtin, + reply_timeout, + gas_for_transfer_to_eth_msg, + } + } +} + +#[derive(Debug, Decode, Encode, TypeInfo)] +pub struct InitConfig { + pub receiver_contract_address: H160, + pub gear_bridge_builtin: ActorId, + pub ethereum_event_client: ActorId, + pub config: Config, +} + +impl InitConfig { + pub fn new( + receiver_contract_address: H160, + gear_bridge_builtin: ActorId, + ethereum_event_client: ActorId, + config: Config, + ) -> Self { + Self { + receiver_contract_address, + gear_bridge_builtin, + ethereum_event_client, + config, + } + } +} +impl VftTreasury +where + T: ExecContext, +{ + pub fn seed(config: InitConfig, exec_context: T) { + unsafe { + DATA = Some(VftTreasuryData { + receiver_contract_address: config.receiver_contract_address, + gear_bridge_builtin: config.gear_bridge_builtin, + ethereum_event_client: config.ethereum_event_client, + admin: exec_context.actor_id(), + vara_eth_mapping: Vec::new(), + }); + CONFIG = Some(config.config); + MSG_TRACKER = Some(MessageTracker::default()); + } + } + + pub fn new(exec_context: T) -> Self { + Self { exec_context } + } + + fn data(&self) -> &VftTreasuryData { + unsafe { DATA.as_ref().expect("VftTreasury::seed() must be called") } + } + + fn data_mut(&mut self) -> &mut VftTreasuryData { + unsafe { DATA.as_mut().expect("VftTreasury::seed() must be called") } + } + + fn config(&self) -> &Config { + unsafe { + CONFIG + .as_ref() + .expect("VftTreasury::seed() must be invoked") + } + } + + fn get_eth_token_id(&self, vara_token_id: &ActorId) -> Result { + self.data() + .vara_eth_mapping + .iter() + .find(|(vara, _)| vara_token_id == vara) + .map(|(_, eth)| *eth) + .ok_or(Error::NoCorrespondingEthAddress) + } + + fn get_vara_token_id(&self, eth_token_id: &H160) -> Result { + self.data() + .vara_eth_mapping + .iter() + .find(|(_, eth)| eth_token_id == eth) + .map(|(vara, _)| *vara) + .ok_or(Error::NoCorrespondingEthAddress) + } +} + +#[derive(Encode, Decode, TypeInfo)] +pub enum VftTreasuryEvents { + Deposit { + from: ActorId, + to: H160, + token: ActorId, + amount: U256, + }, + Withdraw { + receiver: ActorId, + + token: ActorId, + amount: U256, + }, +} + +#[service(events = VftTreasuryEvents)] +impl VftTreasury +where + T: ExecContext, +{ + pub fn ensure_admin(&self) -> Result<(), Error> { + if self.data().admin != self.exec_context.actor_id() { + return Err(Error::NotAdmin); + } + + Ok(()) + } + + pub fn update_config(&mut self, config: Config) -> Result<(), Error> { + self.ensure_admin()?; + + unsafe { + CONFIG = Some(config); + } + + Ok(()) + } + + pub fn map_vara_to_eth_address( + &mut self, + ethereum_token: H160, + vara_token: ActorId, + ) -> Result<(), Error> { + self.ensure_admin()?; + + for (vara, eth) in self.data().vara_eth_mapping.iter() { + if vara == &vara_token || eth == ðereum_token { + return Err(Error::DuplicateAddressMapping); + } + } + + self.data_mut() + .vara_eth_mapping + .push((vara_token, ethereum_token)); + + Ok(()) + } + + pub fn unmap_vara_to_eth_address( + &mut self, + ethereum_token: H160, + vara_token: ActorId, + ) -> Result<(), Error> { + self.ensure_admin()?; + + let ix = self + .data() + .vara_eth_mapping + .iter() + .enumerate() + .find(|(_, map)| *map == &(vara_token, ethereum_token)) + .map(|(ix, _)| ix) + .ok_or(Error::NoCorrespondingEthAddress)?; + + self.data_mut().vara_eth_mapping.swap_remove(ix); + + Ok(()) + } + + pub fn update_ethereum_event_client_address( + &mut self, + new_address: ActorId, + ) -> Result<(), Error> { + self.ensure_admin()?; + self.data_mut().ethereum_event_client = new_address; + Ok(()) + } + + pub fn admin(&self) -> ActorId { + self.data().admin + } + + pub fn get_config(&self) -> Config { + self.config().clone() + } + + pub fn gear_bridge_builtin(&self) -> ActorId { + self.data().gear_bridge_builtin + } + + pub fn msg_tracker_state(&self) -> Vec<(MessageId, MessageInfo)> { + msg_tracker().message_info.clone().into_iter().collect() + } + + pub fn vara_to_eth_addresses(&self) -> Vec<(ActorId, H160)> { + self.data().vara_eth_mapping.clone() + } + + pub async fn deposit_tokens( + &mut self, + vara_token_id: ActorId, + from: ActorId, + amount: U256, + to: H160, + ) -> Result<(U256, H160), Error> { + let data = self.data(); + let config = self.config(); + + if gstd::exec::gas_available() + < config.gas_for_transfer_tokens + + config.gas_for_reply_deposit * 3 + + config.gas_to_send_request_to_builtin + { + return Err(Error::NotEnoughGas); + } + + let msg_id = gstd::msg::id(); + let eth_token_id = self.get_eth_token_id(&vara_token_id)?; + + token_operations::deposit_to_treasury( + vara_token_id, + eth_token_id, + from, + amount, + to, + config, + msg_id, + ) + .await?; + debug!("Deposit tokens {}", amount); + + let nonce = match bridge_builtin_operations::send_message_to_bridge_builtin( + data.gear_bridge_builtin, + data.receiver_contract_address, + to, + eth_token_id, + amount, + config, + msg_id, + ) + .await + { + Ok(nonce) => nonce, + Err(e) => { + // In case of failure, take tokens from program address and send them back to the sender + token_operations::withdraw_from_treasury( + vara_token_id, + eth_token_id, + from, + amount, + config, + msg_id, + ) + .await?; + return Err(e); + } + }; + + Ok((nonce, eth_token_id)) + } + + pub async fn withdraw_tokens( + &mut self, + eth_token_id: H160, + recepient: ActorId, + amount: U256, + ) -> Result<(), Error> { + let data = self.data(); + let sender = self.exec_context.actor_id(); + let vara_token_id = self.get_vara_token_id(ð_token_id)?; + + if sender != data.ethereum_event_client { + return Err(Error::NotEthClient); + } + + let config = self.config(); + + if gstd::exec::gas_available() + < config.gas_for_transfer_tokens + config.gas_for_reply_deposit + { + panic!("Please attach more gas"); + } + + let msg_id = gstd::msg::id(); + + token_operations::withdraw_from_treasury( + vara_token_id, + eth_token_id, + recepient, + amount, + config, + msg_id, + ) + .await + } + + pub async fn handle_interrupted_transfer( + &mut self, + msg_id: MessageId, + ) -> Result<(U256, H160), Error> { + let data = self.data(); + + let config = self.config(); + let msg_tracker = msg_tracker_mut(); + + let msg_info = msg_tracker + .get_message_info(&msg_id) + .expect("Unexpected: msg status does not exist"); + + let TxDetails::DepositToTreasury { + vara_token_id, + eth_token_id, + sender, + amount, + receiver, + } = msg_info.details + else { + panic!("Wrong message type") + }; + + match msg_info.status { + MessageStatus::TokenTransferCompleted(true) | MessageStatus::BridgeBuiltinStep => { + match bridge_builtin_operations::send_message_to_bridge_builtin( + data.gear_bridge_builtin, + data.receiver_contract_address, + receiver, + eth_token_id, + amount, + config, + msg_id, + ) + .await + { + Ok(nonce) => Ok((nonce, eth_token_id)), + Err(_) => { + token_operations::withdraw_from_treasury( + vara_token_id, + eth_token_id, + sender, + amount, + config, + msg_id, + ) + .await?; + Err(Error::TokensRefunded) + } + } + } + + MessageStatus::BridgeResponseReceived(Some(nonce)) => { + msg_tracker_mut().remove_message_info(&msg_id); + Ok((nonce, eth_token_id)) + } + + _ => panic!("Unexpected status or transaction completed"), + } + } +} + +fn msg_tracker() -> &'static MessageTracker { + unsafe { + MSG_TRACKER + .as_ref() + .expect("VftGateway::seed() should be called") + } +} + +fn msg_tracker_mut() -> &'static mut MessageTracker { + unsafe { + MSG_TRACKER + .as_mut() + .expect("VftGateway::seed() should be called") + } +} diff --git a/gear-programs/vft-treasury/src/services/msg_tracker.rs b/gear-programs/vft-treasury/src/services/msg_tracker.rs new file mode 100644 index 00000000..c29d9983 --- /dev/null +++ b/gear-programs/vft-treasury/src/services/msg_tracker.rs @@ -0,0 +1,118 @@ +//! # Message Tracker +//! +//! This module tracks lifetime of a message: handle reply, +//! execute next step, handles an error appropriatly. + +use super::Error; +use gstd::{prelude::collections::HashMap, MessageId}; +use sails_rs::prelude::*; + +#[derive(Default, Debug)] +pub struct MessageTracker { + pub message_info: HashMap, +} + +#[derive(Debug, Clone, Encode, Decode, TypeInfo)] +pub struct MessageInfo { + pub status: MessageStatus, + pub details: TxDetails, +} + +#[derive(Debug, Clone, Encode, Decode, TypeInfo)] +pub enum TxDetails { + DepositToTreasury { + vara_token_id: ActorId, + eth_token_id: H160, + sender: ActorId, + amount: U256, + receiver: H160, + }, + + WithdrawFromTreasury { + vara_token_id: ActorId, + eth_token_id: H160, + recepient: ActorId, + amount: U256, + }, +} + +impl MessageTracker { + pub fn insert_message_info( + &mut self, + msg_id: MessageId, + status: MessageStatus, + details: TxDetails, + ) { + self.message_info + .insert(msg_id, MessageInfo { status, details }); + } + + pub fn update_message_status( + &mut self, + msg_id: MessageId, + status: MessageStatus, + ) -> Result<(), Error> { + self.message_info + .get_mut(&msg_id) + .ok_or(Error::MessageNotFound)? + .status = status; + Ok(()) + } + + pub fn get_message_info(&self, msg_id: &MessageId) -> Option<&MessageInfo> { + self.message_info.get(msg_id) + } + + pub fn remove_message_info(&mut self, msg_id: &MessageId) -> Option { + self.message_info.remove(msg_id) + } + + pub fn check_bridge_reply(&mut self, msg_id: &MessageId) -> Result { + if let Some(info) = self.message_info.get(msg_id) { + match info.status { + MessageStatus::BridgeResponseReceived(Some(nonce)) => { + self.remove_message_info(msg_id); + Ok(nonce) + } + MessageStatus::BridgeResponseReceived(None) => { + Err(Error::BridgeBuiltinMessageFailed) + } + _ => Err(Error::InvalidMessageStatus), + } + } else { + Err(Error::MessageNotFound) + } + } + + pub fn check_transfer_result(&mut self, msg_id: &MessageId) -> Result<(), Error> { + if let Some(info) = self.message_info.get(msg_id) { + match info.status { + MessageStatus::TokenTransferCompleted(true) => Ok(()), + MessageStatus::TokenTransferCompleted(false) => { + self.message_info.remove(msg_id); + Err(Error::TransferFailed) + } + + _ => Err(Error::InvalidMessageStatus), + } + } else { + Err(Error::MessageNotFound) + } + } +} + +#[derive(Debug, Clone, PartialEq, Encode, Decode, TypeInfo)] +pub enum MessageStatus { + // Transfer tokens statuses + SendingMessageToTransferTokens, + TokenTransferCompleted(bool), + WaitingReplyFromTransfer, + + // Send message to bridge builtin + SendingMessageToBridgeBuiltin, + BridgeResponseReceived(Option), + WaitingReplyFromBuiltin, + BridgeBuiltinStep, + + MessageProcessedWithSuccess(U256), +} diff --git a/gear-programs/vft-treasury/src/services/token_operations.rs b/gear-programs/vft-treasury/src/services/token_operations.rs new file mode 100644 index 00000000..39d27839 --- /dev/null +++ b/gear-programs/vft-treasury/src/services/token_operations.rs @@ -0,0 +1,92 @@ +use super::msg_tracker::MessageStatus; +use super::msg_tracker::TxDetails; +use super::msg_tracker_mut; +use super::utils; + +use super::vft::vft::io as vft_io; +use super::Config; +use super::Error; +use gstd::{ActorId, MessageId}; +use sails_rs::prelude::*; + +/// Deposit VFT of `vara_token_id` to treasury from `sender` with `amount` of tokens +/// expecting it to arrive to `receiver` on Ethereum, `eth_token_id` is a contract which +/// implements the token on ETH network. +pub async fn deposit_to_treasury( + vara_token_id: ActorId, + eth_token_id: H160, + sender: ActorId, + amount: U256, + eth_receiver: H160, + config: &Config, + msg_id: MessageId, +) -> Result<(), Error> { + let receiver = gstd::exec::program_id(); + let bytes: Vec = vft_io::TransferFrom::encode_call(sender, receiver, amount); + + let transaction_detail = TxDetails::DepositToTreasury { + vara_token_id, + eth_token_id, + sender, + amount, + receiver: eth_receiver, + }; + + msg_tracker_mut().insert_message_info( + msg_id, + MessageStatus::SendingMessageToTransferTokens, + transaction_detail, + ); + + utils::set_critical_hook(msg_id); + utils::send_message_with_gas_for_reply( + vara_token_id, + bytes, + config.gas_for_transfer_tokens, + config.gas_for_reply_deposit, + config.reply_timeout, + msg_id, + ) + .await?; + msg_tracker_mut().check_transfer_result(&msg_id) +} + +/// Withdraw `vara_token_id` of `amount` from treasury to `recepient` account. It is expected that someone +/// burned the necessary `amount` of tokens on Ethereum network and then ethereum event client send the +/// event to us to perfrorm a withdraw transaction. +pub async fn withdraw_from_treasury( + vara_token_id: ActorId, + eth_token_id: H160, + recepient: ActorId, + amount: U256, + config: &Config, + msg_id: MessageId, +) -> Result<(), Error> { + let sender = gstd::exec::program_id(); + let bytes: Vec = vft_io::TransferFrom::encode_call(sender, recepient, amount); + + let transaction_detail = TxDetails::WithdrawFromTreasury { + vara_token_id, + eth_token_id, + recepient, + amount, + }; + + msg_tracker_mut().insert_message_info( + msg_id, + MessageStatus::SendingMessageToTransferTokens, + transaction_detail, + ); + + utils::set_critical_hook(msg_id); + utils::send_message_with_gas_for_reply( + vara_token_id, + bytes, + config.gas_for_transfer_tokens, + config.gas_for_reply_deposit, + config.reply_timeout, + msg_id, + ) + .await?; + msg_tracker_mut().check_transfer_result(&msg_id) +} diff --git a/gear-programs/vft-treasury/src/services/utils.rs b/gear-programs/vft-treasury/src/services/utils.rs new file mode 100644 index 00000000..a82123dd --- /dev/null +++ b/gear-programs/vft-treasury/src/services/utils.rs @@ -0,0 +1,156 @@ +//! # utils +//! +//! Various utility functions necessary to run VFT Treasury service. + +use super::Error; +use super::{msg_tracker::MessageStatus, msg_tracker_mut, vft::vft::io as vft_io}; +use sails_rs::{calls::ActionIo, prelude::*}; + +/// Set a critical hook that guarantees code execution for `msg_id` in case of any unexpected +/// code failure be it not enough gas, panic, unexpected error etc. +/// +/// The hook is executed inside `handle_signal`, refer to [`gstd::critical`] for more information. +pub fn set_critical_hook(msg_id: MessageId) { + gstd::critical::set_hook(move || { + let msg_tracker = msg_tracker_mut(); + let msg_info = msg_tracker + .get_message_info(&msg_id) + .expect("Unexpected: msg info does not exist"); + + match msg_info.status { + MessageStatus::SendingMessageToBridgeBuiltin => { + msg_tracker + .update_message_status(msg_id, MessageStatus::WaitingReplyFromBuiltin) + .expect("message not found"); + } + + MessageStatus::SendingMessageToTransferTokens => { + msg_tracker + .update_message_status(msg_id, MessageStatus::WaitingReplyFromTransfer) + .expect("message not found"); + } + + MessageStatus::TokenTransferCompleted(true) => { + msg_tracker + .update_message_status(msg_id, MessageStatus::BridgeBuiltinStep) + .expect("message not found"); + } + + MessageStatus::TokenTransferCompleted(false) => { + msg_tracker.remove_message_info(&msg_id); + } + + MessageStatus::BridgeResponseReceived(None) => {} + _ => {} + } + }); +} + +fn decode_transfer_reply(bytes: &[u8]) -> Result { + vft_io::TransferFrom::decode_reply(bytes).map_err(|_| Error::TransferTokensDecode) +} + +fn decode_bridge_reply(mut bytes: &[u8]) -> Result, Error> { + let reply = + gbuiltin_eth_bridge::Response::decode(&mut bytes).map_err(|_| Error::BuiltinDecode)?; + + match reply { + gbuiltin_eth_bridge::Response::EthMessageQueued { nonce, .. } => Ok(Some(nonce)), + } +} + +fn handle_reply_hook(msg_id: MessageId) { + let msg_tracker = msg_tracker_mut(); + + let msg_info = msg_tracker + .get_message_info(&msg_id) + .expect("Unexpected: msg info does not exist"); + let reply_bytes = gstd::msg::load_bytes().expect("Unable to load bytes"); + + match msg_info.status { + MessageStatus::SendingMessageToTransferTokens => { + match decode_transfer_reply(&reply_bytes) { + Ok(reply) => { + msg_tracker + .update_message_status(msg_id, MessageStatus::TokenTransferCompleted(reply)) + .expect("message not found"); + } + + Err(_) => { + msg_tracker.remove_message_info(&msg_id); + } + } + } + + MessageStatus::WaitingReplyFromTransfer => { + let reply = decode_transfer_reply(&reply_bytes).unwrap_or(false); + + if reply { + msg_tracker + .update_message_status(msg_id, MessageStatus::TokenTransferCompleted(reply)) + .expect("message not found"); + } else { + msg_tracker.remove_message_info(&msg_id); + } + } + + MessageStatus::SendingMessageToBridgeBuiltin => { + let reply = decode_bridge_reply(&reply_bytes); + + let result = match reply { + Ok(Some(nonce)) => Some(nonce), + _ => None, + }; + + msg_tracker + .update_message_status(msg_id, MessageStatus::BridgeResponseReceived(result)) + .expect("message not found"); + } + + MessageStatus::WaitingReplyFromBuiltin => { + let reply = decode_bridge_reply(&reply_bytes); + + match reply { + Ok(Some(nonce)) => { + msg_tracker + .update_message_status( + msg_id, + MessageStatus::MessageProcessedWithSuccess(nonce), + ) + .expect("message not found"); + } + + _ => { + msg_tracker.remove_message_info(&msg_id); + } + } + } + + _ => {} + } +} + +/// Send message to `destination` with `message` bytes as payload and include +/// gas to send, deposit for reply and reply timeout. +/// +/// `msg_id` is an message ID that we wait to get reply from. This function sets +/// reply hook which will decode reply and perform necessary actions for message depending +/// on [MessageInfo](super::msg_tracker::MessageInfo) of the `msg_id`. +pub async fn send_message_with_gas_for_reply( + destination: ActorId, + message: Vec, + gas_to_send: u64, + gas_deposit: u64, + reply_timeout: u32, + msg_id: MessageId, +) -> Result<(), Error> { + gstd::msg::send_bytes_with_gas_for_reply(destination, message, gas_to_send, 0, gas_deposit) + .map_err(|err| Error::SendFailure(err.to_string()))? + .up_to(Some(reply_timeout)) + .map_err(|_| Error::ReplyTimeout)? + .handle_reply(move || handle_reply_hook(msg_id)) + .map_err(|err| Error::ReplyHook(err.to_string()))? + .await + .map_err(|err| Error::ReplyFailure(err.to_string()))?; + Ok(()) +} diff --git a/gear-programs/vft-treasury/src/services/vft.rs b/gear-programs/vft-treasury/src/services/vft.rs new file mode 100644 index 00000000..3887cc79 --- /dev/null +++ b/gear-programs/vft-treasury/src/services/vft.rs @@ -0,0 +1,6 @@ +mod vft_module { + #![allow(dead_code)] + include!(concat!(env!("OUT_DIR"), "/vft.rs")); +} + +pub use vft_module::*; diff --git a/gear-programs/vft-treasury/src/wasm/Cargo.toml b/gear-programs/vft-treasury/src/wasm/Cargo.toml new file mode 100644 index 00000000..e850d39b --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "vft_treasury_wasm" +version.workspace = true +edition.workspace = true + +[dependencies] +vft-treasury-app.workspace = true + +sails-rs.workspace = true + +[build-dependencies] +gwasm-builder.workspace = true +sails-idl-gen.workspace = true +vft-treasury-app.workspace = true +sails-client-gen.workspace = true + +[dev-dependencies] +gtest.workspace = true +vft-treasury-app.workspace = true +parity-scale-codec.workspace = true +scale-info.workspace = true +sails-rs.workspace = true +tokio.workspace = true +blake2.workspace = true +gear-core.workspace = true +extended_vft_wasm.workspace = true +gclient.workspace = true diff --git a/gear-programs/vft-treasury/src/wasm/build.rs b/gear-programs/vft-treasury/src/wasm/build.rs new file mode 100644 index 00000000..c415ef0a --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/build.rs @@ -0,0 +1,20 @@ +use sails_client_gen::ClientGenerator; +use sails_idl_gen::program; +use std::{env, fs::File, path::PathBuf}; +use vft_treasury_app::Program; + +fn main() { + gwasm_builder::build(); + + let manifest_dir_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + let idl_file_path = manifest_dir_path.join("vft-treasury.idl"); + + let idl_file = File::create(idl_file_path.clone()).unwrap(); + + program::generate_idl::(idl_file).unwrap(); + + ClientGenerator::from_idl_path(&idl_file_path) + .generate_to(PathBuf::from(env::var("OUT_DIR").unwrap()).join("vft_treasury_client.rs")) + .unwrap(); +} diff --git a/gear-programs/vft-treasury/src/wasm/src/lib.rs b/gear-programs/vft-treasury/src/wasm/src/lib.rs new file mode 100644 index 00000000..b964f11a --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/src/lib.rs @@ -0,0 +1,6 @@ +#![no_std] +include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs")); +include!(concat!(env!("OUT_DIR"), "/vft_treasury_client.rs")); + +#[cfg(target_arch = "wasm32")] +pub use vft_treasury_app::wasm::*; diff --git a/gear-programs/vft-treasury/src/wasm/tests/end2end.rs b/gear-programs/vft-treasury/src/wasm/tests/end2end.rs new file mode 100644 index 00000000..b6bf6451 --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/tests/end2end.rs @@ -0,0 +1,93 @@ +use gclient::{GearApi, Result}; +mod utils_gclient; +use sails_rs::{ActorId, H160, U256}; +use utils_gclient::*; + +#[tokio::test] +#[ignore] +async fn test_treasury() -> Result<()> { + // It will not work on local node as vft-treasury logic relies on pallet-gear-eth-bridge + // which will be initialized only in ~12 hrs from start on local node. + let mut client = GearApi::vara_testnet().await?; + + let actor: ActorId = client.get_actor_id(); + + // Subscribe to events + let mut listener = client.subscribe().await?; + + // Check that blocks are still running + assert!(listener.blocks_running().await?); + + let vft = Vft::new(&client, &mut listener).await?; + + let amount = U256::from(10_000_000_000_u64); + + let result = vft + .mint(&client, &mut listener, actor, 10_000_000_000u64.into()) + .await?; + assert!(result, "failed to mint to {}", actor); + let balance = vft.balance_of(&client, &mut listener, actor).await?; + assert_eq!(balance, amount); + let treasury = VftTreasury::new(&client, &mut listener).await?; + + let success = vft + .approve(&client, &mut listener, treasury.program_id(), amount) + .await?; + assert!( + success, + "failed to approve {:?} spending {} tokens from {:?}", + treasury.program_id(), + amount, + actor + ); + let allowance = vft + .allowance(&client, &mut listener, actor, treasury.program_id()) + .await?; + assert_eq!(allowance, amount); + + treasury + .map_vara_to_eth_address(&client, &mut listener, [3; 20].into(), vft.program_id()) + .await? + .expect("failed to map address"); + + let reply = treasury + .deposit_tokens( + &client, + &mut listener, + 100_000_000_000, + vft.program_id(), + actor, + amount, + [3; 20].into(), + ) + .await + .unwrap_or_else(|e| { + eprintln!("error: {:?}", e); + client.print_node_logs(); + panic!() + }); + + let expected = H160::from([3; 20]); + assert_eq!(reply.expect("failed to deposit").1, expected); + treasury + .update_ethereum_event_client_address(&client, &mut listener, actor) + .await? + .expect("failed to update ETH event client address"); + + treasury + .withdraw_tokens( + &client, + &mut listener, + 100_000_000_000, + [3; 20].into(), + actor, + amount, + ) + .await? + .expect("failed to withdraw tokens"); + + let balance = vft.balance_of(&client, &mut listener, actor).await?; + assert_eq!(balance, amount); + + Ok(()) +} diff --git a/gear-programs/vft-treasury/src/wasm/tests/utils.rs b/gear-programs/vft-treasury/src/wasm/tests/utils.rs new file mode 100644 index 00000000..6c437d99 --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/tests/utils.rs @@ -0,0 +1,247 @@ +use extended_vft_wasm::WASM_BINARY as TOKEN_WASM_BINARY; +use gtest::{Program, System, WasmProgram}; +use sails_rs::prelude::*; +use vft_treasury_app::services::error::Error; +use vft_treasury_app::services::{Config, InitConfig}; + +pub const ADMIN_ID: u64 = 1000; +pub const TOKEN_ID: u64 = 200; +pub const ETH_CLIENT_ID: u64 = 500; +pub const BRIDGE_BUILTIN_ID: u64 = 300; + +// Mocks for programs +macro_rules! create_mock { + ($name:ident, $handle_result:expr) => { + #[derive(Debug)] + pub struct $name; + + impl WasmProgram for $name { + fn init(&mut self, _payload: Vec) -> Result>, &'static str> { + Ok(None) + } + + fn handle(&mut self, _payload: Vec) -> Result>, &'static str> { + $handle_result + } + + fn handle_reply(&mut self, _payload: Vec) -> Result<(), &'static str> { + unimplemented!() + } + + fn handle_signal(&mut self, _payload: Vec) -> Result<(), &'static str> { + unimplemented!() + } + + fn state(&mut self) -> Result, &'static str> { + unimplemented!() + } + } + }; +} + +create_mock!(FTMockError, Err("Error")); +create_mock!(FTMockWrongReply, Ok(None)); +create_mock!( + FTMockReturnsFalse, + Ok(Some( + ["Vft".encode(), "Burn".encode(), false.encode()].concat() + )) +); +create_mock!( + FTMockReturnsTrue, + Ok(Some( + ["Vft".encode(), "Burn".encode(), true.encode()].concat() + )) +); + +#[derive(Encode, Decode, Clone, Debug, PartialEq, Eq)] +pub enum Response { + MessageSent { nonce: U256, hash: H256 }, +} + +create_mock!( + GearBridgeBuiltinMock, + Ok(Some( + Response::MessageSent { + nonce: U256::from(1), + hash: [1; 32].into(), + } + .encode(), + )) +); +create_mock!(GearBridgeBuiltinMockPanic, Err("Error")); + +pub trait Token { + fn token(system: &System, id: u64) -> Program<'_>; + fn approve(&self, from: u64, spender: ActorId, value: U256); + fn mint(&self, from: u64, to: ActorId, value: U256); + fn balance_of(&self, account: ActorId) -> U256; +} + +impl Token for Program<'_> { + fn token(system: &System, id: u64) -> Program<'_> { + let token = Program::from_binary_with_id(system, id, TOKEN_WASM_BINARY); + let payload = ["New".encode(), ("Token", "Token", 18).encode()].concat(); + let result = token.send_bytes(ADMIN_ID, payload); + assert!(!result.main_failed()); + token + } + + fn mint(&self, from: u64, to: ActorId, value: U256) { + let payload = ["Vft".encode(), "Mint".encode(), (to, value).encode()].concat(); + assert!(!self.send_bytes(from, payload).main_failed()); + } + + fn approve(&self, from: u64, spender: ActorId, value: U256) { + let payload = [ + "Vft".encode(), + "Approve".encode(), + (spender, value).encode(), + ] + .concat(); + + assert!(!self.send_bytes(from, payload).main_failed()); + } + + fn balance_of(&self, account: ActorId) -> U256 { + let query = ["Vft".encode(), "BalanceOf".encode(), account.encode()].concat(); + let result = self.send_bytes(ADMIN_ID, query.clone()); + + let log_entry = result + .log() + .iter() + .find(|log_entry| log_entry.destination() == ADMIN_ID.into()) + .expect("Unable to get query reply"); + + let query_reply = <(String, String, U256)>::decode(&mut log_entry.payload()) + .expect("Unable to decode reply"); + query_reply.2 + } +} + +pub trait VftTreasury { + fn vft_treasury(system: &System) -> Program<'_>; + fn deposit_tokens( + &self, + from: u64, + vara_token_id: ActorId, + sender: ActorId, + amount: U256, + to: H160, + with_gas: u64, + ) -> Result<(U256, H160), Error>; + + fn withdraw_tokens( + &self, + from: u64, + eth_token_id: H160, + recepient: ActorId, + amount: U256, + with_gas: u64, + panic: bool, + ) -> Result<(), Error>; + + fn map_vara_to_eth_address(&self, from: u64, ethereum_token: H160, vara_token: ActorId); +} + +impl VftTreasury for Program<'_> { + fn vft_treasury(system: &System) -> Program<'_> { + let program = Program::current(system); + let init_config = InitConfig::new( + [1; 20].into(), + BRIDGE_BUILTIN_ID.into(), + ETH_CLIENT_ID.into(), + Config::new( + 15_000_000_000, + 15_000_000_000, + 15_000_000_000, + 100, + 15_000_000_000, + ), + ); + + let payload = ["New".encode(), init_config.encode()].concat(); + let result = program.send_bytes(ADMIN_ID, payload); + assert!(!result.main_failed()); + program + } + + fn deposit_tokens( + &self, + from: u64, + vara_token_id: ActorId, + sender: ActorId, + amount: U256, + to: H160, + with_gas: u64, + ) -> Result<(U256, H160), Error> { + let payload = [ + "VftTreasury".encode(), + "DepositTokens".encode(), + (vara_token_id, sender, amount, to).encode(), + ] + .concat(); + + let result = self.send_bytes_with_gas(from, payload, with_gas, 0); + + let log_entry = result + .log() + .iter() + .find(|log_entry| log_entry.destination() == from.into()) + .expect("Unable to get reply"); + + let reply = + <(String, String, Result<(U256, H160), Error>)>::decode(&mut log_entry.payload()) + .expect("Unable to decode reply"); // Panic if decoding fails + + reply.2 + } + + fn withdraw_tokens( + &self, + from: u64, + eth_token_id: H160, + recepient: ActorId, + amount: U256, + with_gas: u64, + panic: bool, + ) -> Result<(), Error> { + let payload = [ + "VftTreasury".encode(), + "WithdrawTokens".encode(), + (eth_token_id, recepient, amount).encode(), + ] + .concat(); + + let result = self.send_bytes_with_gas(from, payload, with_gas, 0); + + if panic { + assert!(result.main_failed()); + Ok(()) + } else { + let log_entry = result + .log() + .iter() + .find(|log_entry| log_entry.destination() == from.into()) + .expect("Unable to get reply"); + + let reply = <(String, String, Result<(), Error>)>::decode(&mut log_entry.payload()) + .expect("Unable to decode reply"); + + reply.2 + } + } + + fn map_vara_to_eth_address(&self, from: u64, ethereum_token: H160, vara_token: ActorId) { + let payload = [ + "VftTreasury".encode(), + "MapVaraToEthAddress".encode(), + (ethereum_token, vara_token).encode(), + ] + .concat(); + + let result = self.send_bytes(from, payload); + + assert!(!result.main_failed()); + } +} diff --git a/gear-programs/vft-treasury/src/wasm/tests/utils_gclient.rs b/gear-programs/vft-treasury/src/wasm/tests/utils_gclient.rs new file mode 100644 index 00000000..07c5c833 --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/tests/utils_gclient.rs @@ -0,0 +1,322 @@ +/* gclient utils */ +use gclient::EventProcessor; +use gclient::{EventListener, GearApi, Result}; +use gear_core::ids::*; +use sails_rs::{Decode, Encode}; +use sails_rs::{H160, U256}; +use vft_treasury_app::services::{Config, InitConfig}; + +pub async fn upload_program( + client: &gclient::GearApi, + listener: &mut gclient::EventListener, + code: &[u8], + payload: Vec, +) -> gclient::Result { + let gas_limit = client + .calculate_upload_gas(None, code.to_vec(), payload.clone(), 0, true) + .await? + .min_limit; + println!("init gas {gas_limit:?}"); + let (message_id, program_id, _) = client + .upload_program_bytes( + code, + gclient::now_micros().to_le_bytes(), + payload, + gas_limit, + 0, + ) + .await?; + assert!(listener.message_processed(message_id).await?.succeed()); + + Ok(program_id) +} + +pub fn decode(payload: Vec) -> gclient::Result { + Ok(T::decode(&mut payload.as_slice())?) +} + +async fn send_request_with_reply( + client: &GearApi, + listener: &mut gclient::EventListener, + destination: ActorId, + service: &str, + method: &str, + arguments: impl Encode, +) -> Result +where + R: Decode, +{ + let payload = [service.encode(), method.encode(), arguments.encode()].concat(); + let gas_info = client + .calculate_handle_gas(None, destination, payload.clone(), 0, true) + .await?; + + let (message_id, _) = client + .send_message_bytes(destination, payload, gas_info.min_limit, 0) + .await?; + + let (_, raw_reply, _) = listener.reply_bytes_on(message_id).await?; + + let decoded_reply: (String, String, R) = match raw_reply { + Ok(raw_reply) => decode(raw_reply)?, + Err(e) => panic!("no reply: {:?}", e), + }; + + Ok(decoded_reply.2) +} + +async fn send_request_with_reply_gas( + client: &GearApi, + listener: &mut gclient::EventListener, + destination: ActorId, + service: &str, + method: &str, + arguments: impl Encode, + gas: u64, +) -> Result +where + R: Decode, +{ + let payload = [service.encode(), method.encode(), arguments.encode()].concat(); + + let (message_id, _) = client + .send_message_bytes(destination, payload, gas, 0) + .await?; + + let (_, raw_reply, _) = listener.reply_bytes_on(message_id).await?; + + let decoded_reply: (String, String, R) = match raw_reply { + Ok(raw_reply) => decode(raw_reply)?, + Err(e) => panic!("no reply: {:?}", e), + }; + + Ok(decoded_reply.2) +} +pub struct Vft(ProgramId); + +impl Vft { + pub async fn new(client: &GearApi, listener: &mut EventListener) -> Result { + let payload = ["New".encode(), ("Token", "Token", 18).encode()].concat(); + + let program_id = upload_program( + client, + listener, + extended_vft_wasm::WASM_BINARY_OPT, + payload, + ) + .await?; + println!("vft ID = {:?}", ProgramId::from(program_id)); + Ok(Self(program_id)) + } + + pub fn program_id(&self) -> ActorId { + self.0 + } + + pub async fn balance_of( + &self, + client: &GearApi, + listener: &mut EventListener, + account: ActorId, + ) -> Result { + send_request_with_reply(client, listener, self.0, "Vft", "BalanceOf", account).await + } + + pub async fn mint( + &self, + client: &GearApi, + listener: &mut EventListener, + account: ActorId, + amount: U256, + ) -> Result { + send_request_with_reply(client, listener, self.0, "Vft", "Mint", (account, amount)).await + } + + pub async fn approve( + &self, + client: &GearApi, + listener: &mut EventListener, + spender: ActorId, + allowance: U256, + ) -> Result { + send_request_with_reply( + client, + listener, + self.0, + "Vft", + "Approve", + (spender, allowance), + ) + .await + } + + pub async fn allowance( + &self, + client: &GearApi, + listener: &mut EventListener, + owner: ActorId, + spender: ActorId, + ) -> Result { + send_request_with_reply( + client, + listener, + self.0, + "Vft", + "Allowance", + (owner, spender), + ) + .await + } +} + +pub struct VftTreasury(ProgramId); + +impl VftTreasury { + pub async fn new(client: &GearApi, listener: &mut EventListener) -> Result { + let seed = *b"built/in"; + // a code based on what is in runtime/vara and gear-builtin pallete. Update + // if the pallete or runtime are changed. + // ActorWithId<3> is bridge builtin while `seed` comes from pallet-gear-builtin. + let bridge_builtin_id: ProgramId = + gear_core::ids::hash((seed, 3u64).encode().as_slice()).into(); + println!("bridge builtin id={:?}", bridge_builtin_id); + let init_config = InitConfig::new( + [2; 20].into(), + bridge_builtin_id, + 44.into(), + Config { + gas_for_reply_deposit: 15_000_000_000, + gas_for_transfer_to_eth_msg: 15_000_000_000, + gas_for_transfer_tokens: 15_000_000_000, + gas_to_send_request_to_builtin: 15_000_000_000, + reply_timeout: 100, + }, + ); + + let payload = ["New".encode(), init_config.encode()].concat(); + + let program_id = upload_program( + client, + listener, + vft_treasury_wasm::WASM_BINARY_OPT, + payload, + ) + .await?; + println!("treasury ID = {:?}", ::from(program_id)); + Ok(Self(program_id)) + } + + pub fn program_id(&self) -> ActorId { + self.0 + } + + #[allow(clippy::too_many_arguments)] + pub async fn deposit_tokens( + &self, + client: &GearApi, + listener: &mut EventListener, + gas: u64, + vara_token_id: ActorId, + from: ActorId, + amount: U256, + to: H160, + ) -> Result> { + send_request_with_reply_gas( + client, + listener, + self.0, + "VftTreasury", + "DepositTokens", + (vara_token_id, from, amount, to), + gas, + ) + .await + } + + pub async fn withdraw_tokens( + &self, + client: &GearApi, + listener: &mut EventListener, + gas: u64, + ethereum_token_id: H160, + recepient: ActorId, + amount: U256, + ) -> Result> { + send_request_with_reply_gas( + client, + listener, + self.0, + "VftTreasury", + "WithdrawTokens", + (ethereum_token_id, recepient, amount), + gas, + ) + .await + } + + pub async fn map_vara_to_eth_address( + &self, + client: &GearApi, + listener: &mut EventListener, + ethereum_token_id: H160, + vara_token_id: ActorId, + ) -> Result> { + send_request_with_reply( + client, + listener, + self.0, + "VftTreasury", + "MapVaraToEthAddress", + (ethereum_token_id, vara_token_id), + ) + .await + } + + pub async fn vara_to_eth_addresses( + &self, + client: &GearApi, + listener: &mut EventListener, + ) -> Result> { + send_request_with_reply( + client, + listener, + self.0, + "VftTreasury", + "MapVaraToEthAddress", + (), + ) + .await + } + + pub async fn update_ethereum_event_client_address( + &self, + client: &GearApi, + listener: &mut EventListener, + new_address: ActorId, + ) -> Result> { + send_request_with_reply( + client, + listener, + self.0, + "VftTreasury", + "UpdateEthereumEventClientAddress", + new_address, + ) + .await + } +} + +pub trait ApiUtils { + fn get_actor_id(&self) -> ActorId; +} + +impl ApiUtils for GearApi { + fn get_actor_id(&self) -> ActorId { + ActorId::new( + self.account_id() + .encode() + .try_into() + .expect("Unexpected invalid account id length."), + ) + } +} diff --git a/gear-programs/vft-treasury/src/wasm/tests/vft_treasury.rs b/gear-programs/vft-treasury/src/wasm/tests/vft_treasury.rs new file mode 100644 index 00000000..9ce74a09 --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/tests/vft_treasury.rs @@ -0,0 +1,176 @@ +use gtest::{Program, System}; +use sails_rs::prelude::*; +use utils::{VftTreasury, *}; +use vft_treasury_app::services::error::Error; + +mod utils; + +struct TestState<'a> { + #[allow(dead_code)] + gear_bridge_builtin: Program<'a>, + vft: Program<'a>, + vft_treasury: Program<'a>, +} + +fn setup_for_test(system: &System) -> TestState<'_> { + system.init_logger(); + + let vft = Program::token(system, TOKEN_ID); + let gear_bridge_builtin = + Program::mock_with_id(system, BRIDGE_BUILTIN_ID, GearBridgeBuiltinMock); + let _ = gear_bridge_builtin.send_bytes(ADMIN_ID, b"INIT"); + + let vft_treasury = Program::vft_treasury(system); + + TestState { + gear_bridge_builtin, + vft, + vft_treasury, + } +} + +#[test] +fn test_treasury() { + let system = System::new(); + let TestState { + vft, vft_treasury, .. + } = setup_for_test(&system); + + vft_treasury.map_vara_to_eth_address(ADMIN_ID, [2; 20].into(), vft.id()); + + let account_id: u64 = 100000; + let amount = U256::from(10_000_000_000_u64); + let gas = 100_000_000_000; + + vft.mint(ADMIN_ID, account_id.into(), amount); + + vft.approve(account_id, vft_treasury.id(), amount); + + let reply = vft_treasury.deposit_tokens( + ADMIN_ID, + vft.id(), + account_id.into(), + amount, + [3; 20].into(), + gas, + ); + + let expected = Ok((U256::from(1), H160::from([2; 20]))); + + assert_eq!(reply, expected); + assert!(vft.balance_of(account_id.into()).is_zero()); + assert_eq!(vft.balance_of(vft_treasury.id()), amount); + + vft_treasury + .withdraw_tokens( + ETH_CLIENT_ID, + [2; 20].into(), + account_id.into(), + amount, + gas, + false, + ) + .unwrap(); + + assert_eq!(vft.balance_of(account_id.into()), amount); + assert!(vft.balance_of(vft_treasury.id()).is_zero()); +} + +#[test] +fn test_mapping_does_not_exists() { + let system = System::new(); + let TestState { + vft, vft_treasury, .. + } = setup_for_test(&system); + + let account_id: u64 = 100000; + let amount = U256::from(10_000_000_000_u64); + let gas = 100_000_000_000; + + vft.mint(ADMIN_ID, account_id.into(), amount); + vft.approve(account_id, vft_treasury.id(), amount); + + let reply = vft_treasury.deposit_tokens( + ADMIN_ID, + vft.id(), + account_id.into(), + amount, + [3; 20].into(), + gas, + ); + + assert!(reply.is_err()); + assert_eq!(reply.unwrap_err(), Error::NoCorrespondingEthAddress); +} + +#[test] +fn test_withdraw_fails_with_bad_origin() { + let system = System::new(); + let TestState { + vft, vft_treasury, .. + } = setup_for_test(&system); + + vft_treasury.map_vara_to_eth_address(ADMIN_ID, [2; 20].into(), vft.id()); + + let account_id: u64 = 100000; + + let result = vft_treasury.withdraw_tokens( + ADMIN_ID, + [2; 20].into(), + account_id.into(), + U256::from(42), + 100_000_000_000, + false, + ); + + assert!(matches!(result, Err(Error::NotEthClient))); +} + +#[test] +fn test_anyone_can_deposit() { + let system = System::new(); + + let TestState { + vft, vft_treasury, .. + } = setup_for_test(&system); + + vft_treasury.map_vara_to_eth_address(ADMIN_ID, [2; 20].into(), vft.id()); + + let account0_id: u64 = 100000; + let account1_id: u64 = 100001; + let amount = U256::from(10_000_000_000_u64); + let gas = 100_000_000_000; + + vft.mint(ADMIN_ID, account0_id.into(), amount); + vft.mint(ADMIN_ID, account1_id.into(), amount); + + vft.approve(account0_id, vft_treasury.id(), amount); + vft.approve(account1_id, vft_treasury.id(), amount); + + let reply = vft_treasury.deposit_tokens( + account1_id, + vft.id(), + account0_id.into(), + amount, + [3; 20].into(), + gas, + ); + + let expected = Ok((U256::from(1), H160::from([2; 20]))); + + assert_eq!(reply, expected); + assert!(vft.balance_of(account0_id.into()).is_zero()); + assert_eq!(vft.balance_of(vft_treasury.id()), amount); + + let reply = vft_treasury.deposit_tokens( + account0_id, + vft.id(), + account1_id.into(), + amount, + [3; 20].into(), + gas, + ); + assert_eq!(reply, expected); + assert!(vft.balance_of(account1_id.into()).is_zero()); + assert_eq!(vft.balance_of(vft_treasury.id()), amount * 2); +} diff --git a/gear-programs/vft-treasury/src/wasm/vft-treasury.idl b/gear-programs/vft-treasury/src/wasm/vft-treasury.idl new file mode 100644 index 00000000..fd9da849 --- /dev/null +++ b/gear-programs/vft-treasury/src/wasm/vft-treasury.idl @@ -0,0 +1,82 @@ +type InitConfig = struct { + receiver_contract_address: h160, + gear_bridge_builtin: actor_id, + ethereum_event_client: actor_id, + config: Config, +}; + +type Config = struct { + gas_for_transfer_tokens: u64, + gas_for_reply_deposit: u64, + gas_to_send_request_to_builtin: u64, + reply_timeout: u32, + gas_for_transfer_to_eth_msg: u64, +}; + +type Error = enum { + SendFailure: str, + ReplyFailure: str, + BuiltinDecode, + ReplyTimeout, + DuplicateAddressMapping, + NoCorrespondingEthAddress, + ReplyHook: str, + MessageNotFound, + InvalidMessageStatus, + MessageFailed, + BridgeBuiltinMessageFailed, + TokensRefunded, + NotEthClient, + NotAdmin, + NotBridgingClient, + NotEnoughGas, + TransferFailed, + TransferTokensDecode, +}; + +type MessageInfo = struct { + status: MessageStatus, + details: TxDetails, +}; + +type MessageStatus = enum { + SendingMessageToTransferTokens, + TokenTransferCompleted: bool, + WaitingReplyFromTransfer, + SendingMessageToBridgeBuiltin, + BridgeResponseReceived: opt u256, + WaitingReplyFromBuiltin, + BridgeBuiltinStep, + MessageProcessedWithSuccess: u256, +}; + +type TxDetails = enum { + DepositToTreasury: struct { vara_token_id: actor_id, eth_token_id: h160, sender: actor_id, amount: u256, receiver: h160 }, + WithdrawFromTreasury: struct { vara_token_id: actor_id, eth_token_id: h160, recepient: actor_id, amount: u256 }, +}; + +constructor { + New : (init_config: InitConfig); +}; + +service VftTreasury { + DepositTokens : (vara_token_id: actor_id, from: actor_id, amount: u256, to: h160) -> result (struct { u256, h160 }, Error); + HandleInterruptedTransfer : (msg_id: message_id) -> result (struct { u256, h160 }, Error); + MapVaraToEthAddress : (ethereum_token: h160, vara_token: actor_id) -> result (null, Error); + UnmapVaraToEthAddress : (ethereum_token: h160, vara_token: actor_id) -> result (null, Error); + UpdateConfig : (config: Config) -> result (null, Error); + UpdateEthereumEventClientAddress : (new_address: actor_id) -> result (null, Error); + WithdrawTokens : (eth_token_id: h160, recepient: actor_id, amount: u256) -> result (null, Error); + query Admin : () -> actor_id; + query EnsureAdmin : () -> result (null, Error); + query GearBridgeBuiltin : () -> actor_id; + query GetConfig : () -> Config; + query MsgTrackerState : () -> vec struct { message_id, MessageInfo }; + query VaraToEthAddresses : () -> vec struct { actor_id, h160 }; + + events { + Deposit: struct { from: actor_id, to: h160, token: actor_id, amount: u256 }; + Withdraw: struct { receiver: actor_id, token: actor_id, amount: u256 }; + } +}; + diff --git a/gear-programs/vft-treasury/vft.idl b/gear-programs/vft-treasury/vft.idl new file mode 100644 index 00000000..01379404 --- /dev/null +++ b/gear-programs/vft-treasury/vft.idl @@ -0,0 +1,33 @@ +constructor { + New : (name: str, symbol: str, decimals: u8); +}; + +service Vft { + Burn : (from: actor_id, value: u256) -> bool; + GrantAdminRole : (to: actor_id) -> null; + GrantBurnerRole : (to: actor_id) -> null; + GrantMinterRole : (to: actor_id) -> null; + Mint : (to: actor_id, value: u256) -> bool; + RevokeAdminRole : (from: actor_id) -> null; + RevokeBurnerRole : (from: actor_id) -> null; + RevokeMinterRole : (from: actor_id) -> null; + Approve : (spender: actor_id, value: u256) -> bool; + Transfer : (to: actor_id, value: u256) -> bool; + TransferFrom : (from: actor_id, to: actor_id, value: u256) -> bool; + query Admins : () -> vec actor_id; + query Burners : () -> vec actor_id; + query Minters : () -> vec actor_id; + query Allowance : (owner: actor_id, spender: actor_id) -> u256; + query BalanceOf : (account: actor_id) -> u256; + query Decimals : () -> u8; + query Name : () -> str; + query Symbol : () -> str; + query TotalSupply : () -> u256; + + events { + Minted: struct { to: actor_id, value: u256 }; + Burned: struct { from: actor_id, value: u256 }; + Approval: struct { owner: actor_id, spender: actor_id, value: u256 }; + Transfer: struct { from: actor_id, to: actor_id, value: u256 }; + } +};