diff --git a/Cargo.lock b/Cargo.lock index 00b7fa4f98..6c9fa26c01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,9 +181,9 @@ dependencies = [ [[package]] name = "arrayref" -version = "0.3.6" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" @@ -193,9 +193,9 @@ checksum = "cff77d8686867eceff3105329d4698d96c2391c176d5d03adc90c7389162b5b8" [[package]] name = "arrayvec" -version = "0.7.1" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4dc07131ffa69b8072d35f5007352af944213cde02545e2103680baed38fcd" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "asn1_der" @@ -946,6 +946,7 @@ dependencies = [ "async-std", "async-trait", "base64 0.21.7", + "bincode", "bip32", "bitcoin", "bitcoin_hashes", @@ -1036,12 +1037,16 @@ dependencies = [ "sha2 0.10.9", "sha3 0.9.1", "sia-rust", + "solana-bincode", "solana-keypair", "solana-pubkey", "solana-rpc-client", "solana-rpc-client-types", "solana-signer", + "solana-system-transaction", + "solana-transaction", "spl-associated-token-account-client", + "spl-token", "spv_validation", "tendermint-rpc", "time", @@ -2001,7 +2006,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha3 0.10.4", + "sha3 0.10.8", "thiserror 1.0.40", "uint", ] @@ -3326,9 +3331,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.0" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c21572b4949434e4fc1e1978b99c5f77064153c59d998bf13ecd96fb5ecba7" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] [[package]] name = "keccak-hash" @@ -4795,11 +4803,10 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "autocfg 1.1.0", "num-integer", "num-traits", "serde", @@ -4824,11 +4831,10 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.43" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d59457e662d541ba17869cf51cf177c0b5f0cbf476c66bdc90bf1edac4f875b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg 1.1.0", "num-traits", ] @@ -4864,6 +4870,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote 1.0.37", + "syn 2.0.87", +] + [[package]] name = "object" version = "0.29.0" @@ -4937,7 +4965,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b44461635bbb1a0300f100a841e571e7d919c81c73075ef5d152ffdb521066" dependencies = [ - "arrayvec 0.7.1", + "arrayvec 0.7.6", "bitvec 1.0.0", "byte-slice-cast", "impl-trait-for-tuples", @@ -4951,7 +4979,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c45ed1f39709f5a89338fab50e59816b2e8815f5bb58276e7ddf9afd495f73f8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.1.3", "proc-macro2", "quote 1.0.37", "syn 1.0.95", @@ -5252,6 +5280,15 @@ dependencies = [ "toml 0.5.7", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-warning" version = "0.4.1" @@ -6401,7 +6438,7 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50e334bb10a245e28e5fd755cabcafd96cfcd167c99ae63a46924ca8d8703a3c" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.1.3", "proc-macro2", "quote 1.0.37", "syn 1.0.95", @@ -6795,9 +6832,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.4" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaedf34ed289ea47c2b741bb72e5357a209512d67bcd4bda44359e5bf0470f56" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ "digest 0.10.7", "keccak", @@ -7319,6 +7356,21 @@ dependencies = [ "solana-define-syscall", ] +[[package]] +name = "solana-program-option" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc677a2e9bc616eda6dbdab834d463372b92848b2bfe4a1ed4e4b4adba3397d0" + +[[package]] +name = "solana-program-pack" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319f0ef15e6e12dc37c597faccb7d62525a509fec5f6975ecb9419efddeb277b" +dependencies = [ + "solana-program-error", +] + [[package]] name = "solana-pubkey" version = "2.4.0" @@ -7633,6 +7685,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "solana-system-transaction" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd98a25e5bcba8b6be8bcbb7b84b24c2a6a8178d7fb0e3077a916855ceba91a" +dependencies = [ + "solana-hash", + "solana-keypair", + "solana-message", + "solana-pubkey", + "solana-signer", + "solana-system-interface", + "solana-transaction", +] + [[package]] name = "solana-sysvar" version = "2.3.0" @@ -7848,7 +7915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ecb916b9664ed9f90abef0ff5a3e61454c1efea5861b2997e03f39b59b955f" dependencies = [ "Inflector", - "proc-macro-crate", + "proc-macro-crate 1.1.3", "proc-macro2", "quote 1.0.37", "syn 1.0.95", @@ -7953,6 +8020,34 @@ dependencies = [ "solana-pubkey", ] +[[package]] +name = "spl-token" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053067c6a82c705004f91dae058b11b4780407e9ccd6799dc9e7d0fab5f242da" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-cpi", + "solana-decode-error", + "solana-instruction", + "solana-msg", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-program-option", + "solana-program-pack", + "solana-pubkey", + "solana-rent", + "solana-sdk-ids", + "solana-sysvar", + "thiserror 2.0.15", +] + [[package]] name = "spv_validation" version = "0.1.0" @@ -9261,7 +9356,7 @@ name = "web3" version = "0.19.0" source = "git+https://github.com/komodoplatform/rust-web3?tag=v0.20.0#01de1d732e61c920cfb2fb1533db7d7110c8a457" dependencies = [ - "arrayvec 0.7.1", + "arrayvec 0.7.6", "base64 0.13.0", "bytes", "derive_more", diff --git a/mm2src/coins/Cargo.toml b/mm2src/coins/Cargo.toml index e84ed0e373..bb55483c48 100644 --- a/mm2src/coins/Cargo.toml +++ b/mm2src/coins/Cargo.toml @@ -10,12 +10,17 @@ enable-sia = [ "dep:sia-rust" ] enable-solana = [ + "dep:bincode", + "dep:solana-bincode", "dep:solana-keypair", "dep:solana-pubkey", "dep:solana-rpc-client-types", "dep:solana-rpc-client", "dep:solana-signer", + "dep:solana-system-transaction", + "dep:solana-transaction", "dep:spl-associated-token-account-client", + "dep:spl-token", ] default = [] run-docker-tests = [] @@ -125,12 +130,17 @@ zcash_extras.workspace = true zcash_primitives.workspace = true # Solana +bincode = { version = "1.3", default-features = false, optional = true } +solana-bincode = { version = "2.2", default-features = false, optional = true } solana-keypair = { version = "2.2", default-features = false, optional = true } solana-pubkey = { version = "2.4", default-features = false, optional = true } solana-rpc-client = { version = "2.3", default-features = false, optional = true } solana-rpc-client-types= { version = "2.3", default-features = false, optional = true } solana-signer = { version = "2.2", default-features = false, optional = true } +solana-system-transaction = { version = "2.2", default-features = false, optional = true } +solana-transaction = { version = "2.2", default-features = false, optional = true } spl-associated-token-account-client = { version = "2.0", default-features = false, optional = true } +spl-token = { version = "8", default-features = false, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] blake2b_simd.workspace = true diff --git a/mm2src/coins/lp_coins.rs b/mm2src/coins/lp_coins.rs index 9c53c21648..83b5193156 100644 --- a/mm2src/coins/lp_coins.rs +++ b/mm2src/coins/lp_coins.rs @@ -303,6 +303,8 @@ use z_coin::{ZCoin, ZcoinProtocolInfo}; #[cfg(feature = "enable-solana")] pub mod solana; +#[cfg(feature = "enable-solana")] +use crate::solana::SolanaFeeDetails; pub type TransactionFut = Box + Send>; pub type TransactionResult = Result; @@ -2440,6 +2442,8 @@ pub enum TxFeeDetails { Qrc20(Qrc20FeeDetails), Slp(SlpFeeDetails), Tendermint(TendermintFeeDetails), + #[cfg(feature = "enable-solana")] + Solana(SolanaFeeDetails), } /// Deserialize the TxFeeDetails as an untagged enum. @@ -2454,14 +2458,20 @@ impl<'de> Deserialize<'de> for TxFeeDetails { Utxo(UtxoFeeDetails), Eth(EthTxFeeDetails), Qrc20(Qrc20FeeDetails), + Slp(SlpFeeDetails), Tendermint(TendermintFeeDetails), + #[cfg(feature = "enable-solana")] + Solana(SolanaFeeDetails), } match Deserialize::deserialize(deserializer)? { TxFeeDetailsUnTagged::Utxo(f) => Ok(TxFeeDetails::Utxo(f)), TxFeeDetailsUnTagged::Eth(f) => Ok(TxFeeDetails::Eth(f)), TxFeeDetailsUnTagged::Qrc20(f) => Ok(TxFeeDetails::Qrc20(f)), + TxFeeDetailsUnTagged::Slp(f) => Ok(TxFeeDetails::Slp(f)), TxFeeDetailsUnTagged::Tendermint(f) => Ok(TxFeeDetails::Tendermint(f)), + #[cfg(feature = "enable-solana")] + TxFeeDetailsUnTagged::Solana(f) => Ok(TxFeeDetails::Solana(f)), } } } diff --git a/mm2src/coins/solana/mod.rs b/mm2src/coins/solana/mod.rs index 4aa90e1631..a4796f8754 100644 --- a/mm2src/coins/solana/mod.rs +++ b/mm2src/coins/solana/mod.rs @@ -2,6 +2,7 @@ mod solana_coin; mod solana_token; pub use solana_coin::RpcNode; +pub use solana_coin::SolanaFeeDetails; pub use solana_coin::{SolanaCoin, SolanaProtocolInfo}; pub use solana_coin::{SolanaInitError, SolanaInitErrorKind}; pub use solana_token::{SolanaToken, SolanaTokenProtocolInfo}; diff --git a/mm2src/coins/solana/solana_coin.rs b/mm2src/coins/solana/solana_coin.rs index bb99d0ff87..bf74fcddbb 100644 --- a/mm2src/coins/solana/solana_coin.rs +++ b/mm2src/coins/solana/solana_coin.rs @@ -10,6 +10,7 @@ use common::executor::{ abortable_queue::{AbortableQueue, WeakSpawner}, AbortableSystem, AbortedError, }; +use common::now_sec; use derive_more::Display; use futures::lock::Mutex as AsyncMutex; use futures::{FutureExt, TryFutureExt}; @@ -18,16 +19,21 @@ use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use mm2_number::{BigDecimal, MmNumber}; use nom::AsBytes; +use num_traits::ToPrimitive; use num_traits::Zero; use parking_lot::Mutex as PaMutex; +use rpc::v1::types::Bytes as BytesJson; use rpc::v1::types::{Bytes as RpcBytes, H264 as RpcH264}; -use solana_keypair::keypair_from_seed; +use solana_bincode::limited_deserialize; +use solana_keypair::{keypair_from_seed, Keypair}; use solana_pubkey::Pubkey as SolanaAddress; -use solana_rpc_client::rpc_client::RpcClient; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; use solana_rpc_client_types::request::TokenAccountsFilter; use solana_signer::Signer; +use solana_transaction::Transaction; use url::Url; +use crate::TxFeeDetails; use crate::{ coin_errors::{AddressFromPubkeyError, MyAddressError, ValidatePaymentResult}, hd_wallet::HDAddressSelector, @@ -35,13 +41,22 @@ use crate::{ FoundSwapTxSpend, HistorySyncState, MarketCoinOps, MmCoin, NegotiateSwapContractAddrErr, PrivKeyBuildPolicy, RawTransactionFut, RawTransactionRequest, RawTransactionResult, RefundPaymentArgs, SearchForSwapTxSpendInput, SendPaymentArgs, SignRawTransactionRequest, SignatureResult, SpendPaymentArgs, SwapOps, TradeFee, TradePreimageFut, - TradePreimageResult, TradePreimageValue, TransactionEnum, TransactionResult, TxMarshalingErr, - UnexpectedDerivationMethod, ValidateAddressResult, ValidateFeeArgs, ValidateOtherPubKeyErr, ValidatePaymentInput, - VerificationResult, WaitForHTLCTxSpendArgs, WatcherOps, WithdrawFut, WithdrawRequest, + TradePreimageResult, TradePreimageValue, TransactionData, TransactionDetails, TransactionEnum, TransactionResult, + TransactionType, TxMarshalingErr, UnexpectedDerivationMethod, ValidateAddressResult, ValidateFeeArgs, + ValidateOtherPubKeyErr, ValidatePaymentInput, VerificationResult, WaitForHTLCTxSpendArgs, WatcherOps, + WithdrawError, WithdrawFut, WithdrawRequest, }; pub const SOLANA_DECIMALS: u8 = 9; +/// Maximum over-the-wire size of a Transaction +/// 1280 is IPv6 minimum MTU +/// 40 bytes is the size of the IPv6 header +/// 8 bytes is the size of the fragment header +/// +/// Ported from: https://github.com/anza-xyz/solana-sdk/blob/ac902c4bdb8b0a1/packet/src/lib.rs#L28-L32 +pub const PACKET_DATA_SIZE: usize = 1280 - 40 - 8; + #[derive(Clone, Deserialize)] pub struct RpcNode { url: Url, @@ -53,6 +68,7 @@ pub struct SolanaCoin(Arc); pub struct SolanaCoinFields { ticker: String, pub(crate) address: SolanaAddress, + pub(crate) keypair: Keypair, pub(crate) abortable_system: AbortableQueue, rpc_clients: AsyncMutex>>, protocol_info: SolanaProtocolInfo, @@ -94,6 +110,19 @@ pub enum SolanaInitErrorKind { }, } +/// Fees associated with a Solana transaction. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SolanaFeeDetails { + /// Network fee in SOL. + pub fee_amount: BigDecimal, + /// Rent in SOL when an associated token account (ATA) is created. + /// + /// This is 0 if no ATA creation is needed. + pub rent_amount: BigDecimal, + /// Sum of the network fee and rent. + pub total_amount: BigDecimal, +} + impl SolanaCoin { pub async fn init( ctx: &MmArc, @@ -147,7 +176,10 @@ impl SolanaCoin { kind: SolanaInitErrorKind::Internal { reason: e.to_string() }, })?; - let rpc_clients: Vec> = nodes.iter().map(|n| Arc::new(RpcClient::new(&n.url))).collect(); + let rpc_clients: Vec> = nodes + .iter() + .map(|n| Arc::new(RpcClient::new(n.url.to_string()))) + .collect(); let abortable_system = ctx.abortable_system.create_subsystem().map_to_mm(|e| SolanaInitError { ticker: ticker.clone(), @@ -157,6 +189,7 @@ impl SolanaCoin { let fields = SolanaCoinFields { ticker, address, + keypair, abortable_system, rpc_clients: AsyncMutex::new(rpc_clients), protocol_info, @@ -169,11 +202,11 @@ impl SolanaCoin { pub(crate) async fn rpc_client(&self) -> MmResult, String> { let mut rpcs = self.rpc_clients.lock().await; - if let Some(index) = rpcs.iter().position(|rpc| rpc.get_health().is_ok()) { - // Put healthy one to the front. - rpcs.rotate_left(index); - - return Ok(rpcs[0].clone()); + for (index, rpc) in rpcs.iter().enumerate() { + if rpc.get_health().await.is_ok() { + rpcs.rotate_left(index); + return Ok(rpcs[0].clone()); + } } MmError::err("No healthy RPC client found.".to_owned()) @@ -189,7 +222,10 @@ impl SolanaCoin { .map_err(|e| BalanceError::Transport(e.into_inner())) .await?; - if let Err(e) = rpc.get_token_accounts_by_owner(&self.address, TokenAccountsFilter::Mint(*mint_address)) { + if let Err(e) = rpc + .get_token_accounts_by_owner(&self.address, TokenAccountsFilter::Mint(*mint_address)) + .await + { if e.kind.to_string().contains("could not find mint") { return Ok(CoinBalance { spendable: BigDecimal::zero(), @@ -205,6 +241,7 @@ impl SolanaCoin { let balance_string = rpc .get_token_account_balance(&token_account) + .await .map_err(|e| BalanceError::Transport(e.to_string()))? .ui_amount_string; @@ -215,6 +252,77 @@ impl SolanaCoin { unspendable: Default::default(), }) } + + /// Calculates the withdraw amount (in lamports) that can be withdrawn based on the + /// user's request along with the network fee (in lamports). + /// + /// Returns the amount to withdraw and network fee in lamports on success or + /// [`WithdrawError`] if the request is invalid or cannot be processed. + async fn calculate_withdraw_and_fee_amount(&self, req: &WithdrawRequest) -> MmResult<(u64, u64), WithdrawError> { + let rpc = self + .rpc_client() + .await + .map_err(|e| WithdrawError::Transport(e.into_inner()))?; + + let recent_blockhash = rpc + .get_latest_blockhash() + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + // Dummy TX to estimate the fee. + let tx = solana_system_transaction::transfer(&self.keypair, &self.address, 0, recent_blockhash); + let fee_u64 = rpc + .get_fee_for_message(tx.message()) + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + let balance_u64 = rpc + .get_balance(&self.address) + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + if req.max { + let amount = balance_u64.saturating_sub(fee_u64); + let amount_big_decimal = u64_lamports_to_big_decimal(amount, SOLANA_DECIMALS); + + // Amount must be bigger than min_tx_amount. + if amount_big_decimal < self.min_tx_amount() { + return MmError::err(WithdrawError::AmountTooLow { + amount: amount_big_decimal, + threshold: self.min_tx_amount(), + }); + } + + return Ok((amount, fee_u64)); + } + + let requested_amount = include_lamports_to_big_decimal(&req.amount, SOLANA_DECIMALS); + + // Amount must be bigger than min_tx_amount. + if requested_amount < self.min_tx_amount() { + return MmError::err(WithdrawError::AmountTooLow { + amount: requested_amount, + threshold: self.min_tx_amount(), + }); + } + + let requested_amount_u64 = requested_amount.to_u64().ok_or_else(|| { + MmError::new(WithdrawError::InternalError(format!( + "Couldn't convert {requested_amount} to u64." + ))) + })?; + + // User must have enough balance to cover both the send and fee amounts. + if requested_amount_u64 + fee_u64 > balance_u64 { + return MmError::err(WithdrawError::NotSufficientBalance { + coin: self.ticker.to_owned(), + available: u64_lamports_to_big_decimal(balance_u64, SOLANA_DECIMALS), + required: u64_lamports_to_big_decimal(requested_amount_u64 + fee_u64, SOLANA_DECIMALS), + }); + }; + + Ok((requested_amount_u64, fee_u64)) + } } #[async_trait] @@ -228,11 +336,84 @@ impl MmCoin for SolanaCoin { } fn spawner(&self) -> WeakSpawner { - todo!() + self.abortable_system.weak_spawner() } fn withdraw(&self, req: WithdrawRequest) -> WithdrawFut { - todo!() + let coin = self.clone(); + let fut = async move { + let to = SolanaAddress::from_str(&req.to).map_err(|e| WithdrawError::InvalidAddress(e.to_string()))?; + + let rpc = coin + .rpc_client() + .await + .map_err(|e| WithdrawError::Transport(e.into_inner()))?; + + let (withdraw_lamports, fee_lamports) = coin.calculate_withdraw_and_fee_amount(&req).await?; + + if withdraw_lamports == 0 { + return MmError::err(WithdrawError::AmountTooLow { + amount: req.amount, + threshold: coin.min_tx_amount(), + }); + } + + let recent_blockhash = rpc + .get_latest_blockhash() + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + // Actual TX + let tx = solana_system_transaction::transfer(&coin.keypair, &to, withdraw_lamports, recent_blockhash); + + let tx_hash = tx + .signatures + .first() + .map(|s| s.to_string()) + .ok_or_else(|| WithdrawError::InternalError("Couldn't find the TX signature.".to_owned()))?; + + let tx_bytes = + bincode::serialize(&tx).map_err(|e| MmError::new(WithdrawError::InternalError(e.to_string())))?; + + let tx_data = TransactionData::new_signed(BytesJson(tx_bytes), tx_hash.clone()); + + let amount_dec = u64_lamports_to_big_decimal(withdraw_lamports, SOLANA_DECIMALS); + + let fee = u64_lamports_to_big_decimal(fee_lamports, SOLANA_DECIMALS); + + let received_by_me = if to == coin.address { + amount_dec.clone() + } else { + BigDecimal::zero() + }; + + let spent_by_me = &amount_dec + &fee; + + Ok(TransactionDetails { + tx: tx_data, + from: vec![coin.address.to_string()], + to: vec![to.to_string()], + my_balance_change: &received_by_me - &spent_by_me, + spent_by_me, + total_amount: amount_dec, + received_by_me, + block_height: 0, + timestamp: now_sec(), + fee_details: Some(TxFeeDetails::Solana(SolanaFeeDetails { + fee_amount: fee.clone(), + rent_amount: 0.into(), + total_amount: fee, + })), + coin: req.coin, + internal_id: BytesJson(tx_hash.into_bytes()), + kmd_rewards: None, + transaction_type: TransactionType::StandardTransfer, + // TODO: Add memo instruction to the TX. + memo: None, + }) + }; + + Box::new(fut.boxed().compat()) } fn get_raw_transaction(&self, req: RawTransactionRequest) -> RawTransactionFut<'_> { @@ -379,10 +560,10 @@ impl MarketCoinOps for SolanaCoin { let balance_u64 = rpc_client .get_balance(&coin.address) + .await .map_err(|e| BalanceError::Transport(e.to_string()))?; - let scale = BigDecimal::from(10u64.pow(SOLANA_DECIMALS as u32)); - let balance_decimal = BigDecimal::from(balance_u64) / scale; + let balance_decimal = u64_lamports_to_big_decimal(balance_u64, SOLANA_DECIMALS); Ok(CoinBalance { spendable: balance_decimal, @@ -402,11 +583,24 @@ impl MarketCoinOps for SolanaCoin { } fn send_raw_tx(&self, tx: &str) -> Box + Send> { - todo!() + let bytes = try_fus!(hex::decode(tx)); + self.send_raw_tx_bytes(&bytes) } fn send_raw_tx_bytes(&self, tx: &[u8]) -> Box + Send> { - todo!() + let coin = self.clone(); + let bytes = tx.to_vec(); + let fut = async move { + let rpc = coin.rpc_client().await.map_err(|e| e.into_inner())?; + + let tx: Transaction = limited_deserialize(&bytes, PACKET_DATA_SIZE as u64).map_err(|e| e.to_string())?; + let signature = rpc.send_transaction(&tx).await.map_err(|e| e.to_string())?; + + // TX hash is just the base58 `String` form of the `Signature`. + // ref: https://solana.com/docs/references/terminology#transaction-id + Ok(signature.to_string()) + }; + Box::new(fut.boxed().compat()) } #[inline(always)] @@ -432,7 +626,7 @@ impl MarketCoinOps for SolanaCoin { let fut = async move { let rpc_client = try_s!(coin.rpc_client().await); - rpc_client.get_block_height().map_err(|e| e.to_string()) + rpc_client.get_block_height().await.map_err(|e| e.to_string()) }; Box::new(fut.boxed().compat()) @@ -444,7 +638,7 @@ impl MarketCoinOps for SolanaCoin { #[inline] fn min_tx_amount(&self) -> BigDecimal { - todo!() + u64_lamports_to_big_decimal(1, SOLANA_DECIMALS) } #[inline] @@ -564,3 +758,11 @@ impl SwapOps for SolanaCoin { #[async_trait] impl WatcherOps for SolanaCoin {} + +pub(crate) fn u64_lamports_to_big_decimal>(lamports: u64, decimals: T) -> BigDecimal { + BigDecimal::from(lamports) / BigDecimal::from(10u64.pow(decimals.into())) +} + +pub(crate) fn include_lamports_to_big_decimal>(amount: &BigDecimal, decimals: T) -> BigDecimal { + amount * &BigDecimal::from(10u64.pow(decimals.into())) +} diff --git a/mm2src/coins/solana/solana_token.rs b/mm2src/coins/solana/solana_token.rs index 92939b7dda..aa2d55182c 100644 --- a/mm2src/coins/solana/solana_token.rs +++ b/mm2src/coins/solana/solana_token.rs @@ -6,21 +6,28 @@ use std::str::FromStr; use std::sync::Arc; use async_trait::async_trait; +use bitcrypto::sha256; use common::executor::abortable_queue::{AbortableQueue, WeakSpawner}; use common::executor::{AbortableSystem, AbortedError}; +use common::{now_sec, Future01CompatExt}; use derive_more::Display; use futures::{FutureExt, TryFutureExt}; use futures01::Future; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use mm2_number::{BigDecimal, MmNumber}; +use num_traits::ToPrimitive; +use num_traits::Zero; use rpc::v1::types::{Bytes as RpcBytes, H264 as RpcH264}; use serde::Deserialize; use crate::coin_errors::{AddressFromPubkeyError, MyAddressError, ValidatePaymentResult}; use crate::hd_wallet::HDAddressSelector; +use crate::solana::solana_coin::{include_lamports_to_big_decimal, u64_lamports_to_big_decimal}; +use crate::solana::SolanaFeeDetails; use crate::{ - solana::SolanaCoin, BalanceFut, CoinBalance, RawTransactionFut, RawTransactionRequest, WithdrawFut, WithdrawRequest, + solana::SolanaCoin, BalanceFut, CoinBalance, RawTransactionFut, RawTransactionRequest, TxFeeDetails, WithdrawFut, + WithdrawRequest, }; use crate::{ CheckIfMyPaymentSentArgs, ConfirmPaymentInput, DexFee, FeeApproxStage, FoundSwapTxSpend, HistorySyncState, @@ -28,12 +35,17 @@ use crate::{ SearchForSwapTxSpendInput, SendPaymentArgs, SignRawTransactionRequest, SignatureResult, SpendPaymentArgs, SwapOps, TradeFee, TradePreimageFut, TradePreimageResult, TradePreimageValue, TransactionEnum, TransactionResult, TxMarshalingErr, UnexpectedDerivationMethod, ValidateAddressResult, ValidateFeeArgs, ValidateOtherPubKeyErr, - ValidatePaymentInput, VerificationResult, WaitForHTLCTxSpendArgs, WatcherOps, + ValidatePaymentInput, VerificationResult, WaitForHTLCTxSpendArgs, WatcherOps, WithdrawError, }; use solana_pubkey::Pubkey as SolanaAddress; +use solana_transaction::Transaction; +use spl_associated_token_account_client::address::get_associated_token_address_with_program_id; +use spl_associated_token_account_client::instruction::create_associated_token_account_idempotent; +use spl_token::solana_program::program_pack::Pack; pub struct SolanaTokenFields { pub ticker: String, + address: SolanaAddress, pub platform_coin: SolanaCoin, pub protocol_info: SolanaTokenProtocolInfo, abortable_system: AbortableQueue, @@ -56,6 +68,8 @@ pub struct SolanaTokenProtocolInfo { pub decimals: u8, #[serde(serialize_with = "serialize_pubkey", deserialize_with = "deserialize_pubkey")] pub mint_address: SolanaAddress, + #[serde(serialize_with = "serialize_pubkey", deserialize_with = "deserialize_pubkey")] + program_id: SolanaAddress, } pub fn serialize_pubkey(public_key: &SolanaAddress, serializer: S) -> Result @@ -87,6 +101,8 @@ pub enum SolanaTokenInitErrorKind { Internal { reason: String, }, + #[display(fmt = "None of the RPC servers are healthy.")] + UnhealthyRPCs, #[display( fmt = "Expected platform coin is '{expected_platform_coin}' but requested one is '{actual_platform_coin}'." )] @@ -97,7 +113,7 @@ pub enum SolanaTokenInitErrorKind { } impl SolanaToken { - pub fn init( + pub async fn init( ticker: String, platform_coin: SolanaCoin, protocol_info: SolanaTokenProtocolInfo, @@ -110,8 +126,45 @@ impl SolanaToken { kind: SolanaTokenInitErrorKind::Internal { reason: e.to_string() }, })?; + let address = get_associated_token_address_with_program_id( + &platform_coin.address, + &protocol_info.mint_address, + &protocol_info.program_id, + ); + + let rpc = platform_coin.rpc_client().await.map_err(|e| SolanaTokenInitError { + ticker: ticker.clone(), + kind: SolanaTokenInitErrorKind::UnhealthyRPCs, + })?; + + match rpc.get_account(&protocol_info.mint_address).await { + Ok(mint_account) => { + if mint_account.owner != protocol_info.program_id { + return MmError::err(SolanaTokenInitError { + ticker: ticker.clone(), + kind: SolanaTokenInitErrorKind::QueryError { + reason: format!( + "Unsupported SPL program. Expected Program ID: '{}', Got: '{}'.", + protocol_info.program_id, mint_account.owner + ), + }, + }); + } + }, + Err(e) if e.kind.to_string().contains("AccountNotFound") => { + // Nothing to do here. + }, + Err(e) => { + return MmError::err(SolanaTokenInitError { + ticker: ticker.clone(), + kind: SolanaTokenInitErrorKind::QueryError { reason: e.to_string() }, + }) + }, + }; + let token_fields = SolanaTokenFields { ticker, + address, platform_coin, protocol_info, abortable_system, @@ -119,6 +172,10 @@ impl SolanaToken { Ok(SolanaToken(Arc::new(token_fields))) } + + fn token_id(&self) -> RpcBytes { + sha256(&self.protocol_info.mint_address.to_bytes()).to_vec().into() + } } #[async_trait] @@ -132,11 +189,185 @@ impl MmCoin for SolanaToken { } fn spawner(&self) -> WeakSpawner { - todo!() + self.abortable_system.weak_spawner() } fn withdraw(&self, req: WithdrawRequest) -> WithdrawFut { - todo!() + let token = self.clone(); + let coin = self.platform_coin.clone(); + + let fut = async move { + let rpc = coin + .rpc_client() + .await + .map_err(|e| WithdrawError::Transport(e.into_inner()))?; + + // `to` can be either a Solana address, or a token address. We create + // `to_token_account` regardless to support the both cases. + let to = SolanaAddress::from_str(&req.to).map_err(|e| WithdrawError::InvalidAddress(e.to_string()))?; + let to_token_account = get_associated_token_address_with_program_id( + &to, + &token.protocol_info.mint_address, + &token.protocol_info.program_id, + ); + + let balance = token + .my_balance() + .compat() + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + let amount_u64 = if req.max { + balance.spendable.to_u64().ok_or_else(|| { + MmError::new(WithdrawError::InternalError(format!( + "Couldn't convert {} to u64.", + balance.spendable + ))) + })? + } else { + let big_decimal = include_lamports_to_big_decimal(&req.amount, token.protocol_info.decimals); + + big_decimal.to_u64().ok_or_else(|| { + MmError::new(WithdrawError::InternalError(format!( + "Couldn't convert {big_decimal} to u64." + ))) + })? + }; + + if amount_u64 == 0 { + return MmError::err(WithdrawError::AmountTooLow { + amount: req.amount, + threshold: token.min_tx_amount(), + }); + } + + let amount_decimal = u64_lamports_to_big_decimal(amount_u64, token.protocol_info.decimals); + if balance.spendable < amount_decimal { + return MmError::err(WithdrawError::NotSufficientBalance { + coin: token.ticker.to_owned(), + available: balance.spendable, + required: amount_decimal, + }); + } + + // Instructions: + // - Create recipient address if missing. + // - Transfer. + let mut instructions = Vec::new(); + let mut rent_lamports = 0; + + if let Err(e) = rpc.get_account(&to_token_account).await { + if !e.kind.to_string().contains("AccountNotFound") { + return MmError::err(WithdrawError::Transport(e.to_string())); + } + + rent_lamports = rpc + // TODO: use dynamic account length + .get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN) + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + instructions.push(create_associated_token_account_idempotent( + &coin.address, + &to, + &token.protocol_info.mint_address, + &token.protocol_info.program_id, + )); + }; + + let transfer_ix = spl_token::instruction::transfer_checked( + &token.protocol_info.program_id, + &token.address, + &token.protocol_info.mint_address, + &to_token_account, + &coin.address, + &[], + amount_u64, + token.protocol_info.decimals, + ) + .map_err(|e| WithdrawError::InternalError(e.to_string()))?; + instructions.push(transfer_ix); + + let recent_blockhash = rpc + .get_latest_blockhash() + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&coin.address), + &[&coin.keypair], + recent_blockhash, + ); + + // TX hash is the first signature (base58 String). + let tx_hash = tx + .signatures + .first() + .map(|s| s.to_string()) + .ok_or_else(|| WithdrawError::InternalError("Couldn't find the TX signature.".to_owned()))?; + + let tx_bytes = + bincode::serialize(&tx).map_err(|e| MmError::new(WithdrawError::InternalError(e.to_string())))?; + + let tx_data = crate::TransactionData::new_signed(rpc::v1::types::Bytes(tx_bytes), tx_hash.clone()); + + let amount_dec = u64_lamports_to_big_decimal(amount_u64, token.protocol_info.decimals); + + let fee_lamports = rpc + .get_fee_for_message(tx.message()) + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))?; + let network_fee_dec = u64_lamports_to_big_decimal(fee_lamports, super::solana_coin::SOLANA_DECIMALS); + let rent_dec = u64_lamports_to_big_decimal(rent_lamports, super::solana_coin::SOLANA_DECIMALS); + let total_fee_dec = &network_fee_dec + &rent_dec; + + let platform_coin_balance = coin + .my_balance() + .compat() + .await + .map_err(|e| WithdrawError::Transport(e.to_string()))? + .spendable; + + if total_fee_dec > platform_coin_balance { + return MmError::err(WithdrawError::NotSufficientPlatformBalanceForFee { + available: platform_coin_balance, + required: total_fee_dec, + coin: coin.ticker().to_owned(), + }); + } + + let received_by_me = if to == coin.address { + amount_dec.clone() + } else { + BigDecimal::zero() + }; + + Ok(crate::TransactionDetails { + tx: tx_data, + from: vec![coin.address.to_string()], + to: vec![to.to_string()], + total_amount: amount_dec.clone(), + spent_by_me: amount_dec.clone(), + my_balance_change: &received_by_me - &amount_dec, + received_by_me, + block_height: 0, + timestamp: now_sec(), + fee_details: Some(TxFeeDetails::Solana(SolanaFeeDetails { + fee_amount: network_fee_dec, + rent_amount: rent_dec, + total_amount: total_fee_dec, + })), + coin: req.coin, + internal_id: rpc::v1::types::Bytes(tx_hash.into_bytes()), + kmd_rewards: None, + transaction_type: crate::TransactionType::TokenTransfer(token.token_id()), + // TODO: Add memo instruction to the TX. + memo: None, + }) + }; + + Box::new(fut.boxed().compat()) } fn get_raw_transaction(&self, req: RawTransactionRequest) -> RawTransactionFut<'_> { @@ -249,7 +480,7 @@ impl MarketCoinOps for SolanaToken { } fn my_address(&self) -> MmResult { - self.platform_coin.my_address() + Ok(self.address.to_string()) } fn address_from_pubkey(&self, pubkey: &RpcH264) -> MmResult { @@ -290,11 +521,11 @@ impl MarketCoinOps for SolanaToken { } fn send_raw_tx(&self, tx: &str) -> Box + Send> { - todo!() + self.platform_coin.send_raw_tx(tx) } fn send_raw_tx_bytes(&self, tx: &[u8]) -> Box + Send> { - todo!() + self.platform_coin.send_raw_tx_bytes(tx) } #[inline(always)] @@ -324,7 +555,7 @@ impl MarketCoinOps for SolanaToken { #[inline] fn min_tx_amount(&self) -> BigDecimal { - todo!() + self.platform_coin.min_tx_amount() } #[inline] diff --git a/mm2src/coins/tendermint/tendermint_coin.rs b/mm2src/coins/tendermint/tendermint_coin.rs index 702b028a43..c90af94aac 100644 --- a/mm2src/coins/tendermint/tendermint_coin.rs +++ b/mm2src/coins/tendermint/tendermint_coin.rs @@ -4376,7 +4376,7 @@ pub(crate) fn tendermint_tx_internal_id(bytes: &[u8], token_id: Option for EnableTokenError { SolanaTokenInitErrorKind::QueryError { reason } => EnableTokenError::Transport(reason.to_string()), SolanaTokenInitErrorKind::Internal { reason } => EnableTokenError::Internal(reason.to_string()), SolanaTokenInitErrorKind::PlatformCoinMismatch { .. } => EnableTokenError::PlatformCoinMismatch, + SolanaTokenInitErrorKind::UnhealthyRPCs => { + EnableTokenError::Transport("None of the RPC servers are healthy.".to_owned()) + }, } } } @@ -62,6 +65,9 @@ impl From for InitTokensAsMmCoinsError { SolanaTokenInitErrorKind::QueryError { reason } => InitTokensAsMmCoinsError::Transport(reason.to_string()), SolanaTokenInitErrorKind::Internal { reason } => InitTokensAsMmCoinsError::Internal(reason.to_string()), SolanaTokenInitErrorKind::PlatformCoinMismatch { .. } => InitTokensAsMmCoinsError::PlatformCoinMismatch, + SolanaTokenInitErrorKind::UnhealthyRPCs => { + InitTokensAsMmCoinsError::Transport("None of the RPC servers are healthy.".to_owned()) + }, } } } @@ -81,7 +87,7 @@ impl TokenActivationOps for SolanaToken { protocol_conf: Self::ProtocolInfo, _is_custom: bool, ) -> Result<(Self, Self::ActivationResult), MmError> { - let token = SolanaToken::init(ticker.clone(), platform_coin, protocol_conf)?; + let token = SolanaToken::init(ticker.clone(), platform_coin, protocol_conf).await?; let address = token.my_address().map_err(|e| SolanaTokenInitError { ticker: ticker.clone(), diff --git a/mm2src/coins_activation/src/solana_with_assets.rs b/mm2src/coins_activation/src/solana_with_assets.rs index d81e703606..226f9c60bc 100644 --- a/mm2src/coins_activation/src/solana_with_assets.rs +++ b/mm2src/coins_activation/src/solana_with_assets.rs @@ -12,7 +12,7 @@ use coins::{ CoinBalance, CoinProtocol, MarketCoinOps, MmCoinEnum, PrivKeyBuildPolicy, }; use common::Future01CompatExt; -use futures::future::join_all; +use futures::future::try_join_all; use mm2_core::mm_ctx::MmArc; use mm2_err_handle::prelude::*; use mm2_number::BigDecimal; @@ -207,7 +207,7 @@ impl PlatformCoinWithTokensActivationOps for SolanaCoin { } }); - let tokens_balances: HashMap<_, _> = join_all(tasks).await.into_iter().collect::>()?; + let tokens_balances: HashMap<_, _> = try_join_all(tasks).await?.into_iter().collect(); Ok(SolanaActivationResult { ticker: self.ticker().to_owned(), @@ -251,10 +251,12 @@ impl TokenInitializer for SolanaCoin { &self, params: Vec>, ) -> Result, MmError> { - params - .into_iter() - .map(|param| SolanaToken::init(param.ticker, self.platform_coin().clone(), param.protocol.clone())) - .collect() + try_join_all( + params + .into_iter() + .map(|param| SolanaToken::init(param.ticker, self.platform_coin().clone(), param.protocol.clone())), + ) + .await } fn platform_coin(&self) -> &::PlatformCoin { diff --git a/mm2src/mm2_main/tests/mm2_tests/mod.rs b/mm2src/mm2_main/tests/mm2_tests/mod.rs index 793c430166..8ba3594263 100644 --- a/mm2src/mm2_main/tests/mm2_tests/mod.rs +++ b/mm2src/mm2_main/tests/mm2_tests/mod.rs @@ -7,6 +7,9 @@ mod mm2_tests_inner; mod orderbook_sync_tests; mod z_coin_tests; +#[cfg(feature = "enable-solana")] +mod solana_tests; + #[cfg(all(feature = "zhtlc-native-tests", not(target_arch = "wasm32")))] use mm2_test_helpers::for_tests::MarketMakerIt; #[cfg(all(feature = "zhtlc-native-tests", not(target_arch = "wasm32")))] diff --git a/mm2src/mm2_main/tests/mm2_tests/solana_tests.rs b/mm2src/mm2_main/tests/mm2_tests/solana_tests.rs new file mode 100644 index 0000000000..2102533ecf --- /dev/null +++ b/mm2src/mm2_main/tests/mm2_tests/solana_tests.rs @@ -0,0 +1,104 @@ +use common::{block_on, log}; +use mm2_number::BigDecimal; +use mm2_test_helpers::for_tests::Mm2TestConf; +use mm2_test_helpers::for_tests::{send_raw_transaction, withdraw_v1, MarketMakerIt}; + +use serde_json::json; + +const SOLANA_DEVNET_RPC_URL: &str = "https://api.devnet.solana.com"; +// TODO: Use different seed here. +const SOLANA_DEVNET_TEST_SEED: &str = "iris test seed"; + +fn solana_coin_config() -> serde_json::Value { + json!({ + "coin": "SOL-DEV", + "name": "solana", + "fname": "Solana", + "required_confirmations": 2, + "avg_blocktime": 3, + "protocol": { + "type": "SOLANA", + "protocol_data": {} + }, + "derivation_path": "m/44'/501'" + }) +} + +fn usdc_token_config() -> serde_json::Value { + json!({ + "coin": "USDC-SOL-DEV", + "name": "usd-coin-devnet", + "fname": "USD Coin (Devnet)", + "required_confirmations": 2, + "avg_blocktime": 3, + "protocol": { + "type": "SOLANATOKEN", + "protocol_data": { + "platform": "SOL-DEV", + "decimals": 6, + "mint_address": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "program_id": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + } + }, + "derivation_path": "m/44'/501'" + }) +} + +pub async fn enable_solana(mm: &MarketMakerIt, coin: &str, tokens: &[&str], rpc_urls: &[&str]) -> serde_json::Value { + let tokens: Vec<_> = tokens.iter().map(|ticker| json!({ "ticker": ticker })).collect(); + let nodes: Vec<_> = rpc_urls.iter().map(|u| json!({ "url": u })).collect(); + let method = "experimental::enable_solana_with_assets"; + + let request = json!({ + "userpass": mm.userpass, + "method": method, + "mmrpc": "2.0", + "params": { + "ticker": coin, + "tokens_params": tokens, + "nodes": nodes + } + }); + log!("{method} request {}", serde_json::to_string(&request).unwrap()); + + let request = mm.rpc(&request).await.unwrap(); + assert_eq!(request.0, http::StatusCode::OK, "'{method}' failed: {}", request.1); + log!("{method} response {}", request.1); + serde_json::from_str(&request.1).unwrap() +} + +#[test] +fn enable_with_tokens_and_withdraw() { + const MY_ADDRESS: &str = "5dbw8U6zrLFwtYQgy3gxvMFe6PzWRs8yXB7bXShCnbfT"; + + let coins = json!([solana_coin_config(), usdc_token_config()]); + let coin = coins[0]["coin"].as_str().unwrap(); + let usdc_token = coins[1]["coin"].as_str().unwrap(); + + let conf = Mm2TestConf::seednode(SOLANA_DEVNET_TEST_SEED, &coins); + let mm = MarketMakerIt::start(conf.conf, conf.rpc_password, None).unwrap(); + + let activation_res = block_on(enable_solana(&mm, coin, &[usdc_token], &[SOLANA_DEVNET_RPC_URL])); + log!("Activation {}", serde_json::to_string(&activation_res).unwrap()); + + let to_address = "devwuNsNYACyiEYxRNqMNseBpNnGfnd4ZwNHL7sphqv"; + // Just call withdraw without sending to check response correctness. + let tx_details = block_on(withdraw_v1(&mm, coin, to_address, "0.1", None)); + log!("Withdraw to other {}", serde_json::to_string(&tx_details).unwrap()); + assert_eq!(tx_details.received_by_me, BigDecimal::default()); + assert_eq!(tx_details.to, vec![to_address.to_owned()]); + assert_eq!(tx_details.from, vec![MY_ADDRESS.to_owned()]); + + // Withdraw and send transaction to ourselves. + let tx_details = block_on(withdraw_v1(&mm, coin, MY_ADDRESS, "0.1", None)); + log!("Withdraw to self {}", serde_json::to_string(&tx_details).unwrap()); + + let expected_received: BigDecimal = "0.1".parse().unwrap(); + assert_eq!(tx_details.received_by_me, expected_received); + + assert_eq!(tx_details.to, vec![MY_ADDRESS.to_owned()]); + assert_eq!(tx_details.from, vec![MY_ADDRESS.to_owned()]); + + let send_raw_tx = block_on(send_raw_transaction(&mm, coin, &tx_details.tx_hex)); + log!("Send raw tx {}", serde_json::to_string(&send_raw_tx).unwrap()); +}