diff --git a/Cargo.lock b/Cargo.lock index 81589192..0b98a9e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3569,8 +3569,10 @@ dependencies = [ "gclient 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "getrandom 0.2.15", "gstd 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "hex", "hex-literal", "lazy_static", + "sails-client-gen 0.4.0", "sails-rs 0.4.0", "tokio", ] diff --git a/gear-programs/erc20-relay/Cargo.toml b/gear-programs/erc20-relay/Cargo.toml index 8c1e9733..3ecca93c 100644 --- a/gear-programs/erc20-relay/Cargo.toml +++ b/gear-programs/erc20-relay/Cargo.toml @@ -13,3 +13,4 @@ sails-idl-gen = { git = "https://github.com/gear-tech/sails.git", rev = "aab0873 [features] wasm-binary = [] +gas_calculation = ["erc20-relay-app/gas_calculation"] diff --git a/gear-programs/erc20-relay/app/Cargo.toml b/gear-programs/erc20-relay/app/Cargo.toml index 266dd250..10b38b98 100644 --- a/gear-programs/erc20-relay/app/Cargo.toml +++ b/gear-programs/erc20-relay/app/Cargo.toml @@ -22,3 +22,11 @@ gstd.workspace = true sails-rs = { git = "https://github.com/gear-tech/sails.git", rev = "aab08733ee9ccdb809dc9b29c57dd411b2b917b1", features = ["gclient"] } tokio = { workspace = true, features = ["rt", "macros"] } hex-literal.workspace = true +hex.workspace = true + +[build-dependencies] +sails-client-gen = { git = "https://github.com/gear-tech/sails.git", rev = "aab08733ee9ccdb809dc9b29c57dd411b2b917b1" } + +[features] +gas_calculation = [] +mocks = [] diff --git a/gear-programs/erc20-relay/app/build.rs b/gear-programs/erc20-relay/app/build.rs new file mode 100644 index 00000000..7185aecb --- /dev/null +++ b/gear-programs/erc20-relay/app/build.rs @@ -0,0 +1,16 @@ +use sails_client_gen::ClientGenerator; +use std::{env, path::PathBuf}; + +fn main() { + let mut path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + path.pop(); + path.pop(); + + let idl_file_path = path.join("vft-gateway/src/wasm/vft-gateway.idl"); + + // Generate client code from IDL file + ClientGenerator::from_idl_path(&idl_file_path) + .with_mocks("mocks") + .generate_to(PathBuf::from(env::var("OUT_DIR").unwrap()).join("vft-gateway.rs")) + .unwrap(); +} diff --git a/gear-programs/erc20-relay/app/src/error.rs b/gear-programs/erc20-relay/app/src/error.rs index 6c30c485..91af09e3 100644 --- a/gear-programs/erc20-relay/app/src/error.rs +++ b/gear-programs/erc20-relay/app/src/error.rs @@ -16,5 +16,6 @@ pub enum Error { InvalidBlockProof, TrieDbFailure, InvalidReceiptProof, - InvalidAmount, + ReplyTimeout, + ReplyHook, } diff --git a/gear-programs/erc20-relay/app/src/lib.rs b/gear-programs/erc20-relay/app/src/lib.rs index 64d1ab94..5261e56f 100644 --- a/gear-programs/erc20-relay/app/src/lib.rs +++ b/gear-programs/erc20-relay/app/src/lib.rs @@ -8,7 +8,7 @@ use abi::ERC20_TREASURY; use alloy_sol_types::SolEvent; use cell::RefCell; use checkpoint_light_client_io::{Handle, HandleResult}; -use cmp::Ordering; +use collections::BTreeSet; use error::Error; use ethereum_common::{ beacon::{light::Block as LightBeaconBlock, BlockHeader as BeaconBlockHeader}, @@ -18,10 +18,10 @@ use ethereum_common::{ trie_db::{HashDB, Trie}, utils as eth_utils, utils::ReceiptEnvelope, - H160, H256, + H160, H256, U256, }; use sails_rs::{ - gstd::{debug, msg, ExecContext, GStdExecContext}, + gstd::{msg, ExecContext, GStdExecContext}, prelude::*, }; use services::{Erc20Relay as Erc20RelayService, FTManage as FTManageService}; @@ -29,6 +29,19 @@ use services::{Erc20Relay as Erc20RelayService, FTManage as FTManageService}; const CAPACITY: usize = 500_000; const CAPACITY_MAP: usize = 100; +#[cfg(feature = "gas_calculation")] +const CAPACITY_STEP_SIZE: usize = 50_000; + +static mut TRANSACTIONS: Option> = None; + +fn transactions_mut() -> &'static mut BTreeSet<(u64, u64)> { + unsafe { + TRANSACTIONS + .as_mut() + .expect("Program should be constructed") + } +} + #[derive(Clone, Debug, Decode, TypeInfo)] #[codec(crate = sails_rs::scale_codec)] #[scale_info(crate = sails_rs::scale_info)] @@ -51,28 +64,55 @@ pub struct State { admin: ActorId, map: Vec<(H160, ActorId)>, checkpoints: ActorId, - // vft: ActorId, - // (slot, transaction_index) - transactions: Vec<(u64, u64)>, + vft: ActorId, + reply_timeout: u32, + reply_deposit: u64, } pub struct Erc20RelayProgram(RefCell); #[sails_rs::program] impl Erc20RelayProgram { - pub fn new(checkpoints: ActorId, _vft: ActorId) -> Self { + pub fn new(checkpoints: ActorId, vft: ActorId, reply_timeout: u32, reply_deposit: u64) -> Self { + unsafe { + TRANSACTIONS = Some(BTreeSet::new()); + } + let exec_context = GStdExecContext::new(); Self(RefCell::new(State { admin: exec_context.actor_id(), map: Vec::with_capacity(CAPACITY_MAP), checkpoints, - // vft, - transactions: Vec::with_capacity(CAPACITY), + vft, + reply_timeout, + reply_deposit, })) } - pub fn erc20_relay(&self) -> Erc20RelayService { - Erc20RelayService::new(&self.0) + pub fn gas_calculation(_reply_timeout: u32, _reply_deposit: u64) -> Self { + #[cfg(feature = "gas_calculation")] + { + let self_ = Self::new( + Default::default(), + Default::default(), + _reply_timeout, + _reply_deposit, + ); + + let transactions = transactions_mut(); + for i in 0..CAPACITY_STEP_SIZE { + transactions.insert((0, i as u64)); + } + + self_ + } + + #[cfg(not(feature = "gas_calculation"))] + panic!("Please rebuild with enabled `gas_calculation` feature") + } + + pub fn erc20_relay(&self) -> Erc20RelayService { + Erc20RelayService::new(&self.0, GStdExecContext::new()) } pub fn ft_manage(&self) -> FTManageService { diff --git a/gear-programs/erc20-relay/app/src/services/erc20_relay.rs b/gear-programs/erc20-relay/app/src/services/erc20_relay.rs index f42a7c28..57cf7a29 100644 --- a/gear-programs/erc20-relay/app/src/services/erc20_relay.rs +++ b/gear-programs/erc20-relay/app/src/services/erc20_relay.rs @@ -1,5 +1,13 @@ +// Incorporate code generated based on the IDL file +#[allow(dead_code)] +mod vft { + include!(concat!(env!("OUT_DIR"), "/vft-gateway.rs")); +} + use super::*; use ops::ControlFlow::*; +use sails_rs::{calls::ActionIo, gstd}; +use vft::vft_gateway; #[derive(Encode, TypeInfo)] #[codec(crate = sails_rs::scale_codec)] @@ -8,21 +16,29 @@ enum Event { Relayed { fungible_token: ActorId, to: ActorId, - amount: u128, + amount: U256, }, } -pub struct Erc20Relay<'a>(&'a RefCell); +pub struct Erc20Relay<'a, ExecContext> { + state: &'a RefCell, + exec_context: ExecContext, +} #[sails_rs::service(events = Event)] -impl<'a> Erc20Relay<'a> { - pub fn new(state: &'a RefCell) -> Self { - Self(state) +impl<'a, T> Erc20Relay<'a, T> +where + T: ExecContext, +{ + pub fn new(state: &'a RefCell, exec_context: T) -> Self { + Self { + state, + exec_context, + } } pub async fn relay(&mut self, message: EthToVaraEvent) -> Result<(), Error> { let (fungible_token, receipt, event) = self.prepare(&message)?; - let amount = u128::try_from(event.amount).map_err(|_| Error::InvalidAmount)?; let EthToVaraEvent { proof_block: BlockInclusionProof { block, mut headers }, @@ -32,7 +48,7 @@ impl<'a> Erc20Relay<'a> { } = message; // verify the proof of block inclusion - let checkpoints = self.0.borrow().checkpoints; + let checkpoints = self.state.borrow().checkpoints; let slot = block.slot; let checkpoint = Self::request_checkpoint(checkpoints, slot).await?; @@ -69,23 +85,36 @@ impl<'a> Erc20Relay<'a> { let (key_db, value_db) = eth_utils::rlp_encode_index_and_receipt(&transaction_index, &receipt); match trie.get(&key_db) { - Ok(Some(found_value)) if found_value == value_db => { - // TODO - debug!("Proofs are valid. Mint the tokens"); - // TODO: save slot and index of the processed transaction - - self.notify_on(Event::Relayed { - fungible_token, - to: ActorId::from(event.to.0), - amount, - }) - .unwrap(); + Ok(Some(found_value)) if found_value == value_db => (), + _ => return Err(Error::InvalidReceiptProof), + } - Ok(()) - } + let amount = U256::from_little_endian(event.amount.as_le_slice()); + let receiver = ActorId::from(event.to.0); + let call_payload = + vft_gateway::io::MintTokens::encode_call(fungible_token, receiver, amount); + let (vft, reply_timeout, reply_deposit) = { + let state = self.state.borrow(); - _ => Err(Error::InvalidReceiptProof), - } + (state.vft, state.reply_timeout, state.reply_deposit) + }; + gstd::msg::send_bytes_for_reply(vft, call_payload, 0, reply_deposit) + .map_err(|_| Error::SendFailure)? + .up_to(Some(reply_timeout)) + .map_err(|_| Error::ReplyTimeout)? + .handle_reply(move || handle_reply(slot, transaction_index)) + .map_err(|_| Error::ReplyHook)? + .await + .map_err(|_| Error::ReplyFailure)?; + + self.notify_on(Event::Relayed { + fungible_token, + to: ActorId::from(event.to.0), + amount, + }) + .expect("Unable to notify about relaying tokens"); + + Ok(()) } fn prepare( @@ -102,7 +131,7 @@ impl<'a> Erc20Relay<'a> { } let slot = message.proof_block.block.slot; - let mut state = self.0.borrow_mut(); + let state = self.state.borrow_mut(); // decode log and pick the corresponding fungible token address if any let (fungible_token, event) = receipt .logs() @@ -123,23 +152,19 @@ impl<'a> Erc20Relay<'a> { .ok_or(Error::NotSupportedEvent)?; // check for double spending - let index = state - .transactions - .binary_search_by( - |(slot_old, transaction_index_old)| match slot.cmp(slot_old) { - Ordering::Equal => message.transaction_index.cmp(transaction_index_old), - ordering => ordering, - }, - ) - .err() - .ok_or(Error::AlreadyProcessed)?; - - if state.transactions.capacity() <= state.transactions.len() { - if index == state.transactions.len() - 1 { - return Err(Error::TooOldTransaction); - } + let transactions = transactions_mut(); + let key = (slot, message.transaction_index); + if transactions.contains(&key) { + return Err(Error::AlreadyProcessed); + } - state.transactions.pop(); + if CAPACITY <= transactions.len() + && transactions + .first() + .map(|first| &key < first) + .unwrap_or(false) + { + return Err(Error::TooOldTransaction); } Ok((fungible_token, receipt, event)) @@ -160,4 +185,75 @@ impl<'a> Erc20Relay<'a> { _ => panic!("Unexpected result to `GetCheckpointFor` request"), } } + + pub fn fill_transactions(&mut self) -> bool { + #[cfg(feature = "gas_calculation")] + { + let transactions = transactions_mut(); + if CAPACITY == transactions.len() { + return false; + } + + let count = cmp::min(CAPACITY - transactions.len(), CAPACITY_STEP_SIZE); + let (last, _) = transactions.last().copied().unwrap(); + for i in 0..count { + transactions.insert((last + 1, i as u64)); + } + + true + } + + #[cfg(not(feature = "gas_calculation"))] + panic!("Please rebuild with enabled `gas_calculation` feature") + } + + pub async fn calculate_gas_for_reply( + &mut self, + _slot: u64, + _transaction_index: u64, + ) -> Result<(), Error> { + #[cfg(feature = "gas_calculation")] + { + let call_payload = vft_gateway::io::MintTokens::encode_call( + Default::default(), + Default::default(), + Default::default(), + ); + let (reply_timeout, reply_deposit) = { + let state = self.state.borrow(); + + (state.reply_timeout, state.reply_deposit) + }; + let source = self.exec_context.actor_id(); + gstd::msg::send_bytes_for_reply(source, call_payload, 0, reply_deposit) + .map_err(|_| Error::SendFailure)? + .up_to(Some(reply_timeout)) + .map_err(|_| Error::ReplyTimeout)? + .handle_reply(move || handle_reply(_slot, _transaction_index)) + .map_err(|_| Error::ReplyHook)? + .await + .map_err(|_| Error::ReplyFailure)?; + + Ok(()) + } + + #[cfg(not(feature = "gas_calculation"))] + panic!("Please rebuild with enabled `gas_calculation` feature") + } +} + +fn handle_reply(slot: u64, transaction_index: u64) { + let reply_bytes = msg::load_bytes().expect("Unable to load bytes"); + let reply = vft_gateway::io::MintTokens::decode_reply(&reply_bytes) + .expect("Unable to decode MintTokens reply"); + if let Err(e) = reply { + panic!("Request to mint tokens failed: {e:?}"); + } + + let transactions = transactions_mut(); + if CAPACITY <= transactions.len() { + transactions.pop_first(); + } + + transactions.insert((slot, transaction_index)); } diff --git a/gear-programs/erc20-relay/app/tests/gclient.rs b/gear-programs/erc20-relay/app/tests/gclient.rs index f0d4f904..123a47be 100644 --- a/gear-programs/erc20-relay/app/tests/gclient.rs +++ b/gear-programs/erc20-relay/app/tests/gclient.rs @@ -1,11 +1,18 @@ +// Incorporate code generated based on the IDL file +#[allow(dead_code)] +mod vft { + include!(concat!(env!("OUT_DIR"), "/vft-gateway.rs")); +} + use erc20_relay_client::{ ft_manage::events::FtManageEvents, traits::{Erc20RelayFactory, FtManage}, }; use futures::StreamExt; -use gclient::GearApi; +use gclient::{Event, EventProcessor, GearApi, GearEvent}; use hex_literal::hex; use sails_rs::{calls::*, events::*, gclient::calls::*, prelude::*}; +use vft::vft_gateway; const PATH_WASM: &str = match cfg!(debug_assertions) { true => "../../../target/wasm32-unknown-unknown/debug/erc20_relay.opt.wasm", @@ -28,7 +35,12 @@ async fn tokens_map() { let factory = erc20_relay_client::Erc20RelayFactory::new(remoting.clone()); let program_id = factory - .new(Default::default(), Default::default()) + .new( + Default::default(), + Default::default(), + 10_000, + 1_000_000_000, + ) .with_gas_limit(gas_limit) .send_recv(code_id, "") .await @@ -155,3 +167,73 @@ async fn tokens_map() { .await; assert!(result.is_err()); } + +#[tokio::test] +async fn gas_for_reply() { + use erc20_relay_client::{traits::Erc20Relay as _, Erc20Relay, Erc20RelayFactory}; + + let route = ::ROUTE; + + let (remoting, code_id, gas_limit) = spin_up_node().await; + let account_id: ActorId = <[u8; 32]>::from(remoting.api().account_id().clone()).into(); + + let factory = Erc20RelayFactory::new(remoting.clone()); + + let program_id = factory + .gas_calculation(1_000, 5_500_000_000) + .with_gas_limit(gas_limit) + .send_recv(code_id, []) + .await + .unwrap(); + + let mut client = Erc20Relay::new(remoting.clone()); + while client + .fill_transactions() + .send_recv(program_id) + .await + .unwrap() + {} + + println!("prepared"); + + for i in 5..10 { + let mut listener = remoting.api().subscribe().await.unwrap(); + + client + .calculate_gas_for_reply(i, i) + .with_gas_limit(10_000_000_000) + .send(program_id) + .await + .unwrap(); + + let message_id = listener + .proc(|e| match e { + Event::Gear(GearEvent::UserMessageSent { message, .. }) + if message.destination == account_id.into() && message.details.is_none() => + { + message.payload.0.starts_with(route).then_some(message.id) + } + _ => None, + }) + .await + .unwrap(); + + println!("message_id = {}", hex::encode(message_id.0.as_ref())); + + let reply: ::Reply = Ok(()); + let payload = { + let mut result = Vec::with_capacity(route.len() + reply.encoded_size()); + result.extend_from_slice(route); + reply.encode_to(&mut result); + + result + }; + let gas_info = remoting + .api() + .calculate_reply_gas(None, message_id.into(), payload, 0, true) + .await + .unwrap(); + + println!("gas_info = {gas_info:?}"); + } +}