diff --git a/Cargo.lock b/Cargo.lock index 6c3c30dd35..eba3d3c01d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,6 +1042,7 @@ dependencies = [ "prost-build", "protobuf", "rand 0.7.3", + "regex", "rlp", "rmp-serde", "rpc", diff --git a/mm2src/coins/Cargo.toml b/mm2src/coins/Cargo.toml index 7bdb8be0a3..72255359b3 100644 --- a/mm2src/coins/Cargo.toml +++ b/mm2src/coins/Cargo.toml @@ -76,6 +76,7 @@ primitives = { path = "../mm2_bitcoin/primitives" } prost = "0.10" protobuf = "2.20" rand = { version = "0.7", features = ["std", "small_rng"] } +regex = "1" rlp = { version = "0.5" } rmp-serde = "0.14.3" rpc = { path = "../mm2_bitcoin/rpc" } diff --git a/mm2src/coins/nft.rs b/mm2src/coins/nft.rs index 03853a71af..be05c7c076 100644 --- a/mm2src/coins/nft.rs +++ b/mm2src/coins/nft.rs @@ -10,17 +10,19 @@ pub(crate) mod storage; use crate::{get_my_address, MyAddressReq, WithdrawError}; use nft_errors::{GetInfoFromUriError, GetNftInfoError, UpdateNftError}; -use nft_structs::{Chain, ContractType, ConvertChain, Nft, NftList, NftListReq, NftMetadataReq, NftTransferHistory, - NftTransferHistoryWrapper, NftTransfersReq, NftWrapper, NftsTransferHistoryList, +use nft_structs::{Chain, ContractType, ConvertChain, Nft, NftFromMoralis, NftList, NftListReq, NftMetadataReq, + NftTransferHistory, NftTransfersReq, NftTxHistoryFromMoralis, NftsTransferHistoryList, TransactionNftDetails, UpdateNftReq, WithdrawNftReq}; use crate::eth::{get_eth_address, withdraw_erc1155, withdraw_erc721}; -use crate::nft::nft_structs::{RefreshMetadataReq, TransferStatus, TxMeta, UriMeta}; +use crate::nft::nft_errors::ProtectFromSpamError; +use crate::nft::nft_structs::{NftCommon, NftTransferCommon, RefreshMetadataReq, TransferStatus, TxMeta, UriMeta}; use crate::nft::storage::{NftListStorageOps, NftStorageBuilder, NftTxHistoryStorageOps}; use common::{parse_rfc3339_to_timestamp, APPLICATION_JSON}; use http::header::ACCEPT; use mm2_err_handle::map_to_mm::MapToMmResult; use mm2_number::BigDecimal; +use regex::Regex; use serde_json::Value as Json; use std::cmp::Ordering; @@ -44,10 +46,16 @@ pub async fn get_nft_list(ctx: MmArc, req: NftListReq) -> MmResult MmResult MmResult MmResu token_address: req.token_address, token_id: req.token_id, chain: req.chain, + protect_from_spam: false, }; let mut nft_db = get_nft_metadata(ctx, req).await?; - let uri_meta = try_get_uri_meta(&moralis_meta.token_uri).await?; - nft_db.collection_name = moralis_meta.collection_name; - nft_db.symbol = moralis_meta.symbol; - nft_db.token_uri = moralis_meta.token_uri; - nft_db.metadata = moralis_meta.metadata; - nft_db.last_token_uri_sync = moralis_meta.last_token_uri_sync; - nft_db.last_metadata_sync = moralis_meta.last_metadata_sync; - nft_db.possible_spam = moralis_meta.possible_spam; + let token_uri = check_moralis_ipfs_bafy(moralis_meta.common.token_uri.as_deref()); + let uri_meta = get_uri_meta(token_uri.as_deref(), moralis_meta.common.metadata.as_deref()).await; + nft_db.common.collection_name = moralis_meta.common.collection_name; + nft_db.common.symbol = moralis_meta.common.symbol; + nft_db.common.token_uri = token_uri; + nft_db.common.metadata = moralis_meta.common.metadata; + nft_db.common.last_token_uri_sync = moralis_meta.common.last_token_uri_sync; + nft_db.common.last_metadata_sync = moralis_meta.common.last_metadata_sync; + nft_db.common.possible_spam = moralis_meta.common.possible_spam; nft_db.uri_meta = uri_meta; drop_mutability!(nft_db); storage .refresh_nft_metadata(&moralis_meta.chain, nft_db.clone()) .await?; - let tx_meta = TxMeta { - token_address: nft_db.token_address, - token_id: nft_db.token_id, - collection_name: nft_db.collection_name, - image: nft_db.uri_meta.image, - token_name: nft_db.uri_meta.token_name, - }; + let tx_meta = TxMeta::from(nft_db.clone()); storage.update_txs_meta_by_token_addr_id(&nft_db.chain, tx_meta).await?; Ok(()) } @@ -204,32 +218,12 @@ async fn get_moralis_nft_list(ctx: &MmArc, chain: &Chain, url: &Url) -> MmResult let response = send_request_to_uri(uri.as_str()).await?; if let Some(nfts_list) = response["result"].as_array() { for nft_json in nfts_list { - let nft_wrapper: NftWrapper = serde_json::from_str(&nft_json.to_string())?; - let contract_type = match nft_wrapper.contract_type { - Some(contract_type) => contract_type.0, + let nft_moralis: NftFromMoralis = serde_json::from_str(&nft_json.to_string())?; + let contract_type = match nft_moralis.contract_type { + Some(contract_type) => contract_type, None => continue, }; - let uri_meta = try_get_uri_meta(&nft_wrapper.token_uri).await?; - let nft = Nft { - chain: *chain, - token_address: nft_wrapper.token_address, - token_id: nft_wrapper.token_id.0, - amount: nft_wrapper.amount.0, - owner_of: nft_wrapper.owner_of, - token_hash: nft_wrapper.token_hash, - block_number_minted: *nft_wrapper.block_number_minted, - block_number: *nft_wrapper.block_number, - contract_type, - collection_name: nft_wrapper.name, - symbol: nft_wrapper.symbol, - token_uri: nft_wrapper.token_uri, - metadata: nft_wrapper.metadata, - last_token_uri_sync: nft_wrapper.last_token_uri_sync, - last_metadata_sync: nft_wrapper.last_metadata_sync, - minter_address: nft_wrapper.minter_address, - possible_spam: nft_wrapper.possible_spam, - uri_meta, - }; + let nft = build_nft_from_moralis(chain, nft_moralis, contract_type).await; // collect NFTs from the page res_list.push(nft); } @@ -250,7 +244,7 @@ async fn get_moralis_nft_list(ctx: &MmArc, chain: &Chain, url: &Url) -> MmResult async fn get_moralis_nft_transfers( ctx: &MmArc, chain: &Chain, - from_block: Option, + from_block: Option, url: &Url, ) -> MmResult, GetNftInfoError> { let mut res_list = Vec::new(); @@ -266,7 +260,7 @@ async fn get_moralis_nft_transfers( .push("transfers"); let from_block = match from_block { Some(block) => block.to_string(), - None => "0".into(), + None => "1".into(), }; uri_without_cursor .query_pairs_mut() @@ -284,36 +278,39 @@ async fn get_moralis_nft_transfers( let response = send_request_to_uri(uri.as_str()).await?; if let Some(transfer_list) = response["result"].as_array() { for transfer in transfer_list { - let transfer_wrapper: NftTransferHistoryWrapper = serde_json::from_str(&transfer.to_string())?; - let contract_type = match transfer_wrapper.contract_type { - Some(contract_type) => contract_type.0, + let transfer_moralis: NftTxHistoryFromMoralis = serde_json::from_str(&transfer.to_string())?; + let contract_type = match transfer_moralis.contract_type { + Some(contract_type) => contract_type, None => continue, }; - let status = get_tx_status(&wallet_address, &transfer_wrapper.to_address); - let block_timestamp = parse_rfc3339_to_timestamp(&transfer_wrapper.block_timestamp)?; + let status = get_tx_status(&wallet_address, &transfer_moralis.common.to_address); + let block_timestamp = parse_rfc3339_to_timestamp(&transfer_moralis.block_timestamp)?; let transfer_history = NftTransferHistory { + common: NftTransferCommon { + block_hash: transfer_moralis.common.block_hash, + transaction_hash: transfer_moralis.common.transaction_hash, + transaction_index: transfer_moralis.common.transaction_index, + log_index: transfer_moralis.common.log_index, + value: transfer_moralis.common.value, + transaction_type: transfer_moralis.common.transaction_type, + token_address: transfer_moralis.common.token_address, + token_id: transfer_moralis.common.token_id, + from_address: transfer_moralis.common.from_address, + to_address: transfer_moralis.common.to_address, + amount: transfer_moralis.common.amount, + verified: transfer_moralis.common.verified, + operator: transfer_moralis.common.operator, + possible_spam: transfer_moralis.common.possible_spam, + }, chain: *chain, - block_number: *transfer_wrapper.block_number, + block_number: *transfer_moralis.block_number, block_timestamp, - block_hash: transfer_wrapper.block_hash, - transaction_hash: transfer_wrapper.transaction_hash, - transaction_index: transfer_wrapper.transaction_index, - log_index: transfer_wrapper.log_index, - value: transfer_wrapper.value.0, contract_type, - transaction_type: transfer_wrapper.transaction_type, - token_address: transfer_wrapper.token_address, - token_id: transfer_wrapper.token_id.0, + token_uri: None, collection_name: None, - image: None, + image_url: None, token_name: None, - from_address: transfer_wrapper.from_address, - to_address: transfer_wrapper.to_address, status, - amount: transfer_wrapper.amount.0, - verified: transfer_wrapper.verified, - operator: transfer_wrapper.operator, - possible_spam: transfer_wrapper.possible_spam, }; // collect NFTs transfers from the page res_list.push(transfer_history); @@ -354,32 +351,12 @@ async fn get_moralis_metadata( drop_mutability!(uri); let response = send_request_to_uri(uri.as_str()).await?; - let nft_wrapper: NftWrapper = serde_json::from_str(&response.to_string())?; - let contract_type = match nft_wrapper.contract_type { - Some(contract_type) => contract_type.0, + let nft_moralis: NftFromMoralis = serde_json::from_str(&response.to_string())?; + let contract_type = match nft_moralis.contract_type { + Some(contract_type) => contract_type, None => return MmError::err(GetNftInfoError::ContractTypeIsNull), }; - let uri_meta = try_get_uri_meta(&nft_wrapper.token_uri).await?; - let nft_metadata = Nft { - chain: *chain, - token_address: nft_wrapper.token_address, - token_id: nft_wrapper.token_id.0, - amount: nft_wrapper.amount.0, - owner_of: nft_wrapper.owner_of, - token_hash: nft_wrapper.token_hash, - block_number_minted: *nft_wrapper.block_number_minted, - block_number: *nft_wrapper.block_number, - contract_type, - collection_name: nft_wrapper.name, - symbol: nft_wrapper.symbol, - token_uri: nft_wrapper.token_uri, - metadata: nft_wrapper.metadata, - last_token_uri_sync: nft_wrapper.last_token_uri_sync, - last_metadata_sync: nft_wrapper.last_metadata_sync, - minter_address: nft_wrapper.minter_address, - possible_spam: nft_wrapper.possible_spam, - uri_meta, - }; + let nft_metadata = build_nft_from_moralis(chain, nft_moralis, contract_type).await; Ok(nft_metadata) } @@ -444,18 +421,46 @@ async fn send_request_to_uri(uri: &str) -> MmResult { Ok(response) } -async fn try_get_uri_meta(token_uri: &Option) -> MmResult { - match token_uri { - Some(token_uri) => { - if let Ok(response_meta) = send_request_to_uri(token_uri).await { - let uri_meta_res: UriMeta = serde_json::from_str(&response_meta.to_string())?; - Ok(uri_meta_res) +/// `check_moralis_ipfs_bafy` inspects a given token URI and modifies it if certain conditions are met. +/// +/// It checks if the URI points to the Moralis IPFS domain `"ipfs.moralis.io"` and starts with a specific path prefix `"/ipfs/bafy"`. +/// If these conditions are satisfied, it modifies the URI to point to the `"ipfs.io"` domain. +/// This is due to certain "bafy"-prefixed hashes being banned on Moralis IPFS gateway due to abuse. +/// +/// If the URI does not meet these conditions or cannot be parsed, it is returned unchanged. +fn check_moralis_ipfs_bafy(token_uri: Option<&str>) -> Option { + token_uri.map(|uri| { + if let Ok(parsed_url) = Url::parse(uri) { + if parsed_url.host_str() == Some("ipfs.moralis.io") && parsed_url.path().starts_with("/ipfs/bafy") { + let parts: Vec<&str> = parsed_url.path().splitn(2, "/ipfs/").collect(); + format!("https://ipfs.io/ipfs/{}", parts[1]) } else { - Ok(UriMeta::default()) + uri.to_string() } - }, - None => Ok(UriMeta::default()), + } else { + uri.to_string() + } + }) +} + +async fn get_uri_meta(token_uri: Option<&str>, metadata: Option<&str>) -> UriMeta { + let mut uri_meta = UriMeta::default(); + if let Some(token_uri) = token_uri { + if let Ok(response_meta) = send_request_to_uri(token_uri).await { + if let Ok(token_uri_meta) = serde_json::from_value(response_meta) { + uri_meta = token_uri_meta; + } + } + } + if let Some(metadata) = metadata { + if let Ok(meta_from_meta) = serde_json::from_str::(metadata) { + uri_meta.try_to_fill_missing_fields_from(meta_from_meta) + } } + uri_meta.image_url = check_moralis_ipfs_bafy(uri_meta.image_url.as_deref()); + uri_meta.animation_url = check_moralis_ipfs_bafy(uri_meta.animation_url.as_deref()); + drop_mutability!(uri_meta); + uri_meta } fn get_tx_status(my_wallet: &str, to_address: &str) -> TransferStatus { @@ -473,7 +478,7 @@ async fn update_nft_list( ctx: MmArc, storage: &T, chain: &Chain, - scan_from_block: u32, + scan_from_block: u64, url: &Url, ) -> MmResult<(), UpdateNftError> { let txs = storage.get_txs_from_block(chain, scan_from_block).await?; @@ -512,22 +517,16 @@ async fn handle_send_erc721( tx: NftTransferHistory, ) -> MmResult<(), UpdateNftError> { let nft_db = storage - .get_nft(chain, tx.token_address.clone(), tx.token_id.clone()) + .get_nft(chain, tx.common.token_address.clone(), tx.common.token_id.clone()) .await? .ok_or_else(|| UpdateNftError::TokenNotFoundInWallet { - token_address: tx.token_address.clone(), - token_id: tx.token_id.to_string(), + token_address: tx.common.token_address.clone(), + token_id: tx.common.token_id.to_string(), })?; - let tx_meta = TxMeta { - token_address: nft_db.token_address, - token_id: nft_db.token_id, - collection_name: nft_db.collection_name, - image: nft_db.uri_meta.image, - token_name: nft_db.uri_meta.token_name, - }; + let tx_meta = TxMeta::from(nft_db); storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; storage - .remove_nft_from_list(chain, tx.token_address, tx.token_id, tx.block_number) + .remove_nft_from_list(chain, tx.common.token_address, tx.common.token_id, tx.block_number) .await?; Ok(()) } @@ -539,22 +538,40 @@ async fn handle_receive_erc721( url: &Url, my_address: &str, ) -> MmResult<(), UpdateNftError> { - let mut nft = get_moralis_metadata(tx.token_address, tx.token_id, chain, url).await?; - // sometimes moralis updates Get All NFTs (which also affects Get Metadata) later - // than History by Wallet update - nft.owner_of = my_address.to_string(); - nft.block_number = tx.block_number; - drop_mutability!(nft); - storage - .add_nfts_to_list(chain, vec![nft.clone()], tx.block_number as u32) - .await?; - let tx_meta = TxMeta { - token_address: nft.token_address, - token_id: nft.token_id, - collection_name: nft.collection_name, - image: nft.uri_meta.image, - token_name: nft.uri_meta.token_name, + let nft = match storage + .get_nft(chain, tx.common.token_address.clone(), tx.common.token_id.clone()) + .await? + { + Some(mut nft_db) => { + // An error is raised if user tries to receive an identical ERC-721 token they already own + // and if owner address != from address + if my_address != tx.common.from_address { + return MmError::err(UpdateNftError::AttemptToReceiveAlreadyOwnedErc721 { + tx_hash: tx.common.transaction_hash, + }); + } + nft_db.block_number = tx.block_number; + drop_mutability!(nft_db); + storage + .update_nft_amount_and_block_number(chain, nft_db.clone()) + .await?; + nft_db + }, + // If token isn't in NFT LIST table then add nft to the table. + None => { + let mut nft = get_moralis_metadata(tx.common.token_address, tx.common.token_id, chain, url).await?; + // sometimes moralis updates Get All NFTs (which also affects Get Metadata) later + // than History by Wallet update + nft.common.owner_of = my_address.to_string(); + nft.block_number = tx.block_number; + drop_mutability!(nft); + storage + .add_nfts_to_list(chain, vec![nft.clone()], tx.block_number) + .await?; + nft + }, }; + let tx_meta = TxMeta::from(nft); storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; Ok(()) } @@ -565,48 +582,33 @@ async fn handle_send_erc1155( tx: NftTransferHistory, ) -> MmResult<(), UpdateNftError> { let mut nft_db = storage - .get_nft(chain, tx.token_address.clone(), tx.token_id.clone()) + .get_nft(chain, tx.common.token_address.clone(), tx.common.token_id.clone()) .await? .ok_or_else(|| UpdateNftError::TokenNotFoundInWallet { - token_address: tx.token_address.clone(), - token_id: tx.token_id.to_string(), + token_address: tx.common.token_address.clone(), + token_id: tx.common.token_id.to_string(), })?; - match nft_db.amount.cmp(&tx.amount) { + match nft_db.common.amount.cmp(&tx.common.amount) { Ordering::Equal => { - let tx_meta = TxMeta { - token_address: nft_db.token_address, - token_id: nft_db.token_id, - collection_name: nft_db.collection_name, - image: nft_db.uri_meta.image, - token_name: nft_db.uri_meta.token_name, - }; - storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; storage - .remove_nft_from_list(chain, tx.token_address, tx.token_id, tx.block_number) + .remove_nft_from_list(chain, tx.common.token_address, tx.common.token_id, tx.block_number) .await?; }, Ordering::Greater => { - nft_db.amount -= tx.amount; - drop_mutability!(nft_db); + nft_db.common.amount -= tx.common.amount; storage .update_nft_amount(chain, nft_db.clone(), tx.block_number) .await?; - let tx_meta = TxMeta { - token_address: nft_db.token_address, - token_id: nft_db.token_id, - collection_name: nft_db.collection_name, - image: nft_db.uri_meta.image, - token_name: nft_db.uri_meta.token_name, - }; - storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; }, Ordering::Less => { return MmError::err(UpdateNftError::InsufficientAmountInCache { - amount_list: nft_db.amount.to_string(), - amount_history: tx.amount.to_string(), + amount_list: nft_db.common.amount.to_string(), + amount_history: tx.common.amount.to_string(), }); }, } + let tx_meta = TxMeta::from(nft_db); + storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; Ok(()) } @@ -617,64 +619,57 @@ async fn handle_receive_erc1155( url: &Url, my_address: &str, ) -> MmResult<(), UpdateNftError> { - // If token isn't in NFT LIST table then add nft to the table. - if let Some(mut nft_db) = storage - .get_nft(chain, tx.token_address.clone(), tx.token_id.clone()) + let nft = match storage + .get_nft(chain, tx.common.token_address.clone(), tx.common.token_id.clone()) .await? { - // if owner address == from address, then owner sent tokens to themself, - // which means that the amount will not change. - if my_address != tx.from_address { - nft_db.amount += tx.amount; - } - nft_db.block_number = tx.block_number; - drop_mutability!(nft_db); - storage - .update_nft_amount_and_block_number(chain, nft_db.clone()) - .await?; - let tx_meta = TxMeta { - token_address: nft_db.token_address, - token_id: nft_db.token_id, - collection_name: nft_db.collection_name, - image: nft_db.uri_meta.image, - token_name: nft_db.uri_meta.token_name, - }; - storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; - } else { - let moralis_meta = get_moralis_metadata(tx.token_address, tx.token_id.clone(), chain, url).await?; - let uri_meta = try_get_uri_meta(&moralis_meta.token_uri).await?; - let nft = Nft { - chain: *chain, - token_address: moralis_meta.token_address, - token_id: moralis_meta.token_id, - amount: tx.amount, - owner_of: my_address.to_string(), - token_hash: moralis_meta.token_hash, - block_number_minted: moralis_meta.block_number_minted, - block_number: tx.block_number, - contract_type: moralis_meta.contract_type, - collection_name: moralis_meta.collection_name, - symbol: moralis_meta.symbol, - token_uri: moralis_meta.token_uri, - metadata: moralis_meta.metadata, - last_token_uri_sync: moralis_meta.last_token_uri_sync, - last_metadata_sync: moralis_meta.last_metadata_sync, - minter_address: moralis_meta.minter_address, - possible_spam: moralis_meta.possible_spam, - uri_meta, - }; - storage - .add_nfts_to_list(chain, [nft.clone()], tx.block_number as u32) - .await?; - let tx_meta = TxMeta { - token_address: nft.token_address, - token_id: nft.token_id, - collection_name: nft.collection_name, - image: nft.uri_meta.image, - token_name: nft.uri_meta.token_name, - }; - storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; - } + Some(mut nft_db) => { + // if owner address == from address, then owner sent tokens to themself, + // which means that the amount will not change. + if my_address != tx.common.from_address { + nft_db.common.amount += tx.common.amount; + } + nft_db.block_number = tx.block_number; + drop_mutability!(nft_db); + storage + .update_nft_amount_and_block_number(chain, nft_db.clone()) + .await?; + nft_db + }, + // If token isn't in NFT LIST table then add nft to the table. + None => { + let moralis_meta = + get_moralis_metadata(tx.common.token_address, tx.common.token_id.clone(), chain, url).await?; + let token_uri = check_moralis_ipfs_bafy(moralis_meta.common.token_uri.as_deref()); + let uri_meta = get_uri_meta(token_uri.as_deref(), moralis_meta.common.metadata.as_deref()).await; + let nft = Nft { + common: NftCommon { + token_address: moralis_meta.common.token_address, + token_id: moralis_meta.common.token_id, + amount: tx.common.amount, + owner_of: my_address.to_string(), + token_hash: moralis_meta.common.token_hash, + collection_name: moralis_meta.common.collection_name, + symbol: moralis_meta.common.symbol, + token_uri, + metadata: moralis_meta.common.metadata, + last_token_uri_sync: moralis_meta.common.last_token_uri_sync, + last_metadata_sync: moralis_meta.common.last_metadata_sync, + minter_address: moralis_meta.common.minter_address, + possible_spam: moralis_meta.common.possible_spam, + }, + chain: *chain, + block_number_minted: moralis_meta.block_number_minted, + block_number: tx.block_number, + contract_type: moralis_meta.contract_type, + uri_meta, + }; + storage.add_nfts_to_list(chain, [nft.clone()], tx.block_number).await?; + nft + }, + }; + let tx_meta = TxMeta::from(nft); + storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; Ok(()) } @@ -697,7 +692,7 @@ pub(crate) async fn find_wallet_nft_amount( token_address, token_id: token_id.to_string(), })?; - Ok(nft_meta.amount) + Ok(nft_meta.common.amount) } async fn cache_nfts_from_moralis( @@ -722,13 +717,7 @@ where T: NftListStorageOps + NftTxHistoryStorageOps, { for nft in nfts.into_iter() { - let tx_meta = TxMeta { - token_address: nft.token_address, - token_id: nft.token_id, - collection_name: nft.collection_name, - image: nft.uri_meta.image, - token_name: nft.uri_meta.token_name, - }; + let tx_meta = TxMeta::from(nft); storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; } Ok(()) @@ -742,14 +731,126 @@ where let nft_token_addr_id = storage.get_txs_with_empty_meta(chain).await?; for addr_id_pair in nft_token_addr_id.into_iter() { let nft_meta = get_moralis_metadata(addr_id_pair.token_address, addr_id_pair.token_id, chain, url).await?; - let tx_meta = TxMeta { - token_address: nft_meta.token_address, - token_id: nft_meta.token_id, - collection_name: nft_meta.collection_name, - image: nft_meta.uri_meta.image, - token_name: nft_meta.uri_meta.token_name, - }; + let tx_meta = TxMeta::from(nft_meta); storage.update_txs_meta_by_token_addr_id(chain, tx_meta).await?; } Ok(()) } + +/// `contains_disallowed_scheme` function checks if the text contains some link. +fn contains_disallowed_url(text: &str) -> Result { + let url_regex = Regex::new( + r"(?:(?:https?|ftp|file|[^:\s]+:)/?|[^:\s]+:/|\b(?:[a-z\d]+\.))(?:(?:[^\s()<>]+|\((?:[^\s()<>]+|(?:\([^\s()<>]+\)))?\))+(?:\((?:[^\s()<>]+|(?:\(?:[^\s()<>]+\)))?\)|[^\s`!()\[\]{};:'.,<>?«»“”‘’]))?", + )?; + Ok(url_regex.is_match(text)) +} + +/// `check_and_redact_if_spam` checks if the text contains any links. +/// It doesn't matter if the link is valid or not, as this is a spam check. +/// If text contains some link, then it is a spam. +fn check_and_redact_if_spam(text: &mut Option) -> Result { + match text { + Some(s) if contains_disallowed_url(s)? => { + *text = Some("URL redacted for user protection".to_string()); + Ok(true) + }, + _ => Ok(false), + } +} + +/// `protect_from_history_spam` function checks and redact spam in `NftTransferHistory`. +/// +/// `collection_name` and `token_name` in `NftTransferHistory` shouldn't contain any links, +/// they must be just an arbitrary text, which represents NFT names. +fn protect_from_history_spam(tx: &mut NftTransferHistory) -> MmResult<(), ProtectFromSpamError> { + let collection_name_spam = check_and_redact_if_spam(&mut tx.collection_name)?; + let token_name_spam = check_and_redact_if_spam(&mut tx.token_name)?; + + if collection_name_spam || token_name_spam { + tx.common.possible_spam = true; + } + Ok(()) +} + +/// `protect_from_nft_spam` function checks and redact spam in `Nft`. +/// +/// `collection_name` and `token_name` in `Nft` shouldn't contain any links, +/// they must be just an arbitrary text, which represents NFT names. +/// `symbol` also must be a text or sign that represents a symbol. +fn protect_from_nft_spam(nft: &mut Nft) -> MmResult<(), ProtectFromSpamError> { + let collection_name_spam = check_and_redact_if_spam(&mut nft.common.collection_name)?; + let symbol_spam = check_and_redact_if_spam(&mut nft.common.symbol)?; + let token_name_spam = check_and_redact_if_spam(&mut nft.uri_meta.token_name)?; + let meta_spam = check_nft_metadata_for_spam(nft)?; + + if collection_name_spam || symbol_spam || token_name_spam || meta_spam { + nft.common.possible_spam = true; + } + Ok(()) +} +/// `check_nft_metadata_for_spam` function checks and redact spam in `metadata` field from `Nft`. +/// +/// **note:** `token_name` is usually called `name` in `metadata`. +fn check_nft_metadata_for_spam(nft: &mut Nft) -> MmResult { + if let Some(Ok(mut metadata)) = nft + .common + .metadata + .as_ref() + .map(|t| serde_json::from_str::>(t)) + { + if check_spam_and_redact_metadata_field(&mut metadata, "name")? { + nft.common.metadata = Some(serde_json::to_string(&metadata)?); + return Ok(true); + } + } + Ok(false) +} + +/// The `check_spam_and_redact_metadata_field` function scans a specified field in a JSON metadata object for potential spam. +/// +/// This function checks the provided `metadata` map for a field matching the `field` parameter. +/// If this field is found and its value contains some link, it's considered to contain spam. +/// To protect users, function redacts field containing spam link. +/// The function returns `true` if it detected spam link, or `false` otherwise. +fn check_spam_and_redact_metadata_field( + metadata: &mut serde_json::Map, + field: &str, +) -> MmResult { + match metadata.get(field).and_then(|v| v.as_str()) { + Some(text) if contains_disallowed_url(text)? => { + metadata.insert( + field.to_string(), + serde_json::Value::String("URL redacted for user protection".to_string()), + ); + Ok(true) + }, + _ => Ok(false), + } +} + +async fn build_nft_from_moralis(chain: &Chain, nft_moralis: NftFromMoralis, contract_type: ContractType) -> Nft { + let token_uri = check_moralis_ipfs_bafy(nft_moralis.common.token_uri.as_deref()); + let uri_meta = get_uri_meta(token_uri.as_deref(), nft_moralis.common.metadata.as_deref()).await; + Nft { + common: NftCommon { + token_address: nft_moralis.common.token_address, + token_id: nft_moralis.common.token_id, + amount: nft_moralis.common.amount, + owner_of: nft_moralis.common.owner_of, + token_hash: nft_moralis.common.token_hash, + collection_name: nft_moralis.common.collection_name, + symbol: nft_moralis.common.symbol, + token_uri, + metadata: nft_moralis.common.metadata, + last_token_uri_sync: nft_moralis.common.last_token_uri_sync, + last_metadata_sync: nft_moralis.common.last_metadata_sync, + minter_address: nft_moralis.common.minter_address, + possible_spam: nft_moralis.common.possible_spam, + }, + chain: *chain, + block_number_minted: nft_moralis.block_number_minted.map(|v| v.0), + block_number: *nft_moralis.block_number, + contract_type, + uri_meta, + } +} diff --git a/mm2src/coins/nft/nft_errors.rs b/mm2src/coins/nft/nft_errors.rs index 4889d74bba..62ddbefef8 100644 --- a/mm2src/coins/nft/nft_errors.rs +++ b/mm2src/coins/nft/nft_errors.rs @@ -36,6 +36,7 @@ pub enum GetNftInfoError { ParseRfc3339Err(ParseRfc3339Err), #[display(fmt = "The contract type is required and should not be null.")] ContractTypeIsNull, + ProtectFromSpamError(ProtectFromSpamError), } impl From for WithdrawError { @@ -98,6 +99,10 @@ impl From for GetNftInfoError { fn from(e: ParseRfc3339Err) -> Self { GetNftInfoError::ParseRfc3339Err(e) } } +impl From for GetNftInfoError { + fn from(e: ProtectFromSpamError) -> Self { GetNftInfoError::ProtectFromSpamError(e) } +} + impl HttpStatusCode for GetNftInfoError { fn status_code(&self) -> StatusCode { match self { @@ -108,7 +113,8 @@ impl HttpStatusCode for GetNftInfoError { | GetNftInfoError::Internal(_) | GetNftInfoError::GetEthAddressError(_) | GetNftInfoError::TokenNotFoundInWallet { .. } - | GetNftInfoError::DbError(_) => StatusCode::INTERNAL_SERVER_ERROR, + | GetNftInfoError::DbError(_) + | GetNftInfoError::ProtectFromSpamError(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -156,6 +162,10 @@ pub enum UpdateNftError { LastScannedBlockNotFound { last_nft_block: String, }, + #[display(fmt = "Attempt to receive duplicate ERC721 token in transaction hash: {}", tx_hash)] + AttemptToReceiveAlreadyOwnedErc721 { + tx_hash: String, + }, } impl From for UpdateNftError { @@ -188,7 +198,8 @@ impl HttpStatusCode for UpdateNftError { | UpdateNftError::TokenNotFoundInWallet { .. } | UpdateNftError::InsufficientAmountInCache { .. } | UpdateNftError::InvalidBlockOrder { .. } - | UpdateNftError::LastScannedBlockNotFound { .. } => StatusCode::INTERNAL_SERVER_ERROR, + | UpdateNftError::LastScannedBlockNotFound { .. } + | UpdateNftError::AttemptToReceiveAlreadyOwnedErc721 { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -219,3 +230,11 @@ impl From for GetInfoFromUriError { } } } + +#[derive(Clone, Debug, Deserialize, Display, EnumFromStringify, PartialEq, Serialize)] +pub enum ProtectFromSpamError { + #[from_stringify("regex::Error")] + RegexError(String), + #[from_stringify("serde_json::Error")] + SerdeError(String), +} diff --git a/mm2src/coins/nft/nft_structs.rs b/mm2src/coins/nft/nft_structs.rs index f48dc4d266..444fec469f 100644 --- a/mm2src/coins/nft/nft_structs.rs +++ b/mm2src/coins/nft/nft_structs.rs @@ -18,6 +18,8 @@ pub struct NftListReq { #[serde(default = "ten")] pub(crate) limit: usize, pub(crate) page_number: Option, + #[serde(default)] + pub(crate) protect_from_spam: bool, } #[derive(Debug, Deserialize)] @@ -25,6 +27,8 @@ pub struct NftMetadataReq { pub(crate) token_address: Address, pub(crate) token_id: BigDecimal, pub(crate) chain: Chain, + #[serde(default)] + pub(crate) protect_from_spam: bool, } #[derive(Debug, Deserialize)] @@ -128,27 +132,68 @@ impl fmt::Display for ContractType { } } +/// `UriMeta` structure is the object which we create from `token_uri` and `metadata`. +/// +/// `token_uri` and `metadata` usually contain either `image` or `image_url` with image url. +/// But most often nft creators use only `image` name for this value (from my observation), +/// less often they use both parameters with the same url. +/// +/// I suspect this is because some APIs only look for one of these image url names, so nft creators try to satisfy all sides. +/// In any case, since there is no clear standard, we have to look for both options, +/// when we build `UriMeta` from `token_uri` or `metadata`. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct UriMeta { - pub(crate) image: Option, - #[serde(rename(deserialize = "name"))] + #[serde(rename = "image")] + pub(crate) raw_image_url: Option, + pub(crate) image_url: Option, + #[serde(rename = "name")] pub(crate) token_name: Option, - description: Option, - attributes: Option, - animation_url: Option, + pub(crate) description: Option, + pub(crate) attributes: Option, + pub(crate) animation_url: Option, + pub(crate) external_url: Option, + pub(crate) image_details: Option, +} + +impl UriMeta { + /// `try_to_fill_missing_fields_from` function doesnt change `raw_image_url` field. + /// It tries to update `image_url` field instead, if it is None. + /// As `image` is the original name of `raw_image_url` field in data from `token_uri` or `metadata`, + /// try to find **Some()** in this field first. + pub(crate) fn try_to_fill_missing_fields_from(&mut self, other: UriMeta) { + if self.image_url.is_none() { + self.image_url = other.raw_image_url.or(other.image_url); + } + if self.token_name.is_none() { + self.token_name = other.token_name; + } + if self.description.is_none() { + self.description = other.description; + } + if self.attributes.is_none() { + self.attributes = other.attributes; + } + if self.animation_url.is_none() { + self.animation_url = other.animation_url; + } + if self.external_url.is_none() { + self.external_url = other.external_url; + } + if self.image_details.is_none() { + self.image_details = other.image_details; + } + } } +/// [`NftCommon`] structure contains common fields from [`Nft`] and [`NftFromMoralis`] #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Nft { - pub(crate) chain: Chain, +pub struct NftCommon { pub(crate) token_address: String, pub(crate) token_id: BigDecimal, pub(crate) amount: BigDecimal, pub(crate) owner_of: String, - pub(crate) token_hash: String, - pub(crate) block_number_minted: u64, - pub(crate) block_number: u64, - pub(crate) contract_type: ContractType, + pub(crate) token_hash: Option, + #[serde(rename = "name")] pub(crate) collection_name: Option, pub(crate) symbol: Option, pub(crate) token_uri: Option, @@ -156,30 +201,29 @@ pub struct Nft { pub(crate) last_token_uri_sync: Option, pub(crate) last_metadata_sync: Option, pub(crate) minter_address: Option, - pub(crate) possible_spam: Option, + #[serde(default)] + pub(crate) possible_spam: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Nft { + #[serde(flatten)] + pub(crate) common: NftCommon, + pub(crate) chain: Chain, + pub(crate) block_number_minted: Option, + pub(crate) block_number: u64, + pub(crate) contract_type: ContractType, pub(crate) uri_meta: UriMeta, } -/// This structure is for deserializing NFT json to struct. -/// Its needed to convert fields properly, because all fields in json have string type. +/// This structure is for deserializing moralis NFT json to struct. #[derive(Debug, Deserialize)] -pub(crate) struct NftWrapper { - pub(crate) token_address: String, - pub(crate) token_id: SerdeStringWrap, - pub(crate) amount: SerdeStringWrap, - pub(crate) owner_of: String, - pub(crate) token_hash: String, - pub(crate) block_number_minted: SerdeStringWrap, +pub(crate) struct NftFromMoralis { + #[serde(flatten)] + pub(crate) common: NftCommon, + pub(crate) block_number_minted: Option>, pub(crate) block_number: SerdeStringWrap, - pub(crate) contract_type: Option>, - pub(crate) name: Option, - pub(crate) symbol: Option, - pub(crate) token_uri: Option, - pub(crate) metadata: Option, - pub(crate) last_token_uri_sync: Option, - pub(crate) last_metadata_sync: Option, - pub(crate) minter_address: Option, - pub(crate) possible_spam: Option, + pub(crate) contract_type: Option, } #[derive(Debug)] @@ -277,6 +321,8 @@ pub struct NftTransfersReq { #[serde(default = "ten")] pub(crate) limit: usize, pub(crate) page_number: Option, + #[serde(default)] + pub(crate) protect_from_spam: bool, } #[derive(Debug, Display)] @@ -312,53 +358,50 @@ impl fmt::Display for TransferStatus { } } -#[derive(Debug, Deserialize, Serialize)] -pub struct NftTransferHistory { - pub(crate) chain: Chain, - pub(crate) block_number: u64, - pub(crate) block_timestamp: u64, - pub(crate) block_hash: String, +/// [`NftTransferCommon`] structure contains common fields from [`NftTransferHistory`] and [`NftTxHistoryFromMoralis`] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NftTransferCommon { + pub(crate) block_hash: Option, /// Transaction hash in hexadecimal format pub(crate) transaction_hash: String, - pub(crate) transaction_index: u64, - pub(crate) log_index: u64, - pub(crate) value: BigDecimal, - pub(crate) contract_type: ContractType, - pub(crate) transaction_type: String, + pub(crate) transaction_index: Option, + pub(crate) log_index: Option, + pub(crate) value: Option, + pub(crate) transaction_type: Option, pub(crate) token_address: String, pub(crate) token_id: BigDecimal, - pub(crate) collection_name: Option, - pub(crate) image: Option, - pub(crate) token_name: Option, pub(crate) from_address: String, pub(crate) to_address: String, - pub(crate) status: TransferStatus, pub(crate) amount: BigDecimal, - pub(crate) verified: u64, + pub(crate) verified: Option, pub(crate) operator: Option, - pub(crate) possible_spam: Option, + #[serde(default)] + pub(crate) possible_spam: bool, } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct NftTransferHistory { + #[serde(flatten)] + pub(crate) common: NftTransferCommon, + pub(crate) chain: Chain, + pub(crate) block_number: u64, + pub(crate) block_timestamp: u64, + pub(crate) contract_type: ContractType, + pub(crate) token_uri: Option, + pub(crate) collection_name: Option, + pub(crate) image_url: Option, + pub(crate) token_name: Option, + pub(crate) status: TransferStatus, +} + +/// This structure is for deserializing moralis NFT transaction json to struct. #[derive(Debug, Deserialize)] -pub(crate) struct NftTransferHistoryWrapper { +pub(crate) struct NftTxHistoryFromMoralis { + #[serde(flatten)] + pub(crate) common: NftTransferCommon, pub(crate) block_number: SerdeStringWrap, pub(crate) block_timestamp: String, - pub(crate) block_hash: String, - /// Transaction hash in hexadecimal format - pub(crate) transaction_hash: String, - pub(crate) transaction_index: u64, - pub(crate) log_index: u64, - pub(crate) value: SerdeStringWrap, - pub(crate) contract_type: Option>, - pub(crate) transaction_type: String, - pub(crate) token_address: String, - pub(crate) token_id: SerdeStringWrap, - pub(crate) from_address: String, - pub(crate) to_address: String, - pub(crate) amount: SerdeStringWrap, - pub(crate) verified: u64, - pub(crate) operator: Option, - pub(crate) possible_spam: Option, + pub(crate) contract_type: Option, } #[derive(Debug, Serialize)] @@ -368,11 +411,10 @@ pub struct NftsTransferHistoryList { pub(crate) total: usize, } -#[allow(dead_code)] -#[derive(Clone, Debug, Deserialize)] +#[derive(Copy, Clone, Debug, Deserialize)] pub struct NftTxHistoryFilters { #[serde(default)] - pub(crate) receive: bool, + pub receive: bool, #[serde(default)] pub(crate) send: bool, pub(crate) from_date: Option, @@ -385,18 +427,31 @@ pub struct UpdateNftReq { pub(crate) url: Url, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Eq, Hash, PartialEq)] pub struct NftTokenAddrId { pub(crate) token_address: String, pub(crate) token_id: BigDecimal, } -#[allow(dead_code)] #[derive(Debug)] pub struct TxMeta { pub(crate) token_address: String, pub(crate) token_id: BigDecimal, + pub(crate) token_uri: Option, pub(crate) collection_name: Option, - pub(crate) image: Option, + pub(crate) image_url: Option, pub(crate) token_name: Option, } + +impl From for TxMeta { + fn from(nft_db: Nft) -> Self { + TxMeta { + token_address: nft_db.common.token_address, + token_id: nft_db.common.token_id, + token_uri: nft_db.common.token_uri, + collection_name: nft_db.common.collection_name, + image_url: nft_db.uri_meta.image_url, + token_name: nft_db.uri_meta.token_name, + } + } +} diff --git a/mm2src/coins/nft/nft_tests.rs b/mm2src/coins/nft/nft_tests.rs index 1cbdacef24..6d3c0ea46d 100644 --- a/mm2src/coins/nft/nft_tests.rs +++ b/mm2src/coins/nft/nft_tests.rs @@ -5,75 +5,180 @@ const TEST_WALLET_ADDR_EVM: &str = "0x394d86994f954ed931b86791b62fe64f4c5dac37"; #[cfg(all(test, not(target_arch = "wasm32")))] mod native_tests { - use crate::nft::nft_structs::{NftTransferHistoryWrapper, NftWrapper, UriMeta}; + use crate::nft::nft_structs::{NftFromMoralis, NftTxHistoryFromMoralis, UriMeta}; use crate::nft::nft_tests::{NFT_HISTORY_URL_TEST, NFT_LIST_URL_TEST, NFT_METADATA_URL_TEST, TEST_WALLET_ADDR_EVM}; - use crate::nft::send_request_to_uri; + use crate::nft::storage::db_test_helpers::*; + use crate::nft::{check_and_redact_if_spam, check_moralis_ipfs_bafy, check_nft_metadata_for_spam, + send_request_to_uri}; use common::block_on; #[test] - fn test_moralis_nft_list() { - let response = block_on(send_request_to_uri(NFT_LIST_URL_TEST)).unwrap(); - let nfts_list = response["result"].as_array().unwrap(); - for nft_json in nfts_list { - let nft_wrapper: NftWrapper = serde_json::from_str(&nft_json.to_string()).unwrap(); - assert_eq!(TEST_WALLET_ADDR_EVM, nft_wrapper.owner_of); - } + fn test_moralis_ipfs_bafy() { + let uri = + "https://ipfs.moralis.io:2053/ipfs/bafybeifnek24coy5xj5qabdwh24dlp5omq34nzgvazkfyxgnqms4eidsiq/1.json"; + let res_uri = check_moralis_ipfs_bafy(Some(uri)); + let expected = "https://ipfs.io/ipfs/bafybeifnek24coy5xj5qabdwh24dlp5omq34nzgvazkfyxgnqms4eidsiq/1.json"; + assert_eq!(expected, res_uri.unwrap()); } #[test] - fn test_moralis_nft_transfer_history() { - let response = block_on(send_request_to_uri(NFT_HISTORY_URL_TEST)).unwrap(); - let mut transfer_list = response["result"].as_array().unwrap().clone(); - assert!(!transfer_list.is_empty()); - let first_tx = transfer_list.remove(transfer_list.len() - 1); - let transfer_wrapper: NftTransferHistoryWrapper = serde_json::from_str(&first_tx.to_string()).unwrap(); - assert_eq!(TEST_WALLET_ADDR_EVM, transfer_wrapper.to_address); + fn test_invalid_moralis_ipfs_link() { + let uri = "example.com/bafy?1=ipfs.moralis.io&e=https://"; + let res_uri = check_moralis_ipfs_bafy(Some(uri)); + assert_eq!(uri, res_uri.unwrap()); } #[test] - fn test_moralis_nft_metadata() { - let response = block_on(send_request_to_uri(NFT_METADATA_URL_TEST)).unwrap(); - let nft_wrapper: NftWrapper = serde_json::from_str(&response.to_string()).unwrap(); - assert_eq!(41237364, *nft_wrapper.block_number_minted); - let token_uri = nft_wrapper.token_uri.unwrap(); + fn test_check_for_spam() { + let mut spam_text = Some("https://arweave.net".to_string()); + assert!(check_and_redact_if_spam(&mut spam_text).unwrap()); + let url_redacted = "URL redacted for user protection"; + assert_eq!(url_redacted, spam_text.unwrap()); + + let mut spam_text = Some("ftp://123path ".to_string()); + assert!(check_and_redact_if_spam(&mut spam_text).unwrap()); + let url_redacted = "URL redacted for user protection"; + assert_eq!(url_redacted, spam_text.unwrap()); + + let mut spam_text = Some("/192.168.1.1/some.example.org?type=A".to_string()); + assert!(check_and_redact_if_spam(&mut spam_text).unwrap()); + let url_redacted = "URL redacted for user protection"; + assert_eq!(url_redacted, spam_text.unwrap()); + + let mut spam_text = Some(r"C:\Users\path\".to_string()); + assert!(check_and_redact_if_spam(&mut spam_text).unwrap()); + let url_redacted = "URL redacted for user protection"; + assert_eq!(url_redacted, spam_text.unwrap()); + + let mut valid_text = Some("Hello my name is NFT (The best ever!)".to_string()); + assert!(!check_and_redact_if_spam(&mut valid_text).unwrap()); + assert_eq!("Hello my name is NFT (The best ever!)", valid_text.unwrap()); + + let mut nft = nft(); + assert!(check_nft_metadata_for_spam(&mut nft).unwrap()); + let meta_redacted = "{\"name\":\"URL redacted for user protection\",\"image\":\"https://tikimetadata.s3.amazonaws.com/tiki_box.png\"}"; + assert_eq!(meta_redacted, nft.common.metadata.unwrap()) + } + + #[test] + fn test_moralis_requests() { + let response_nft_list = block_on(send_request_to_uri(NFT_LIST_URL_TEST)).unwrap(); + let nfts_list = response_nft_list["result"].as_array().unwrap(); + for nft_json in nfts_list { + let nft_moralis: NftFromMoralis = serde_json::from_str(&nft_json.to_string()).unwrap(); + assert_eq!(TEST_WALLET_ADDR_EVM, nft_moralis.common.owner_of); + } + + let response_tx_history = block_on(send_request_to_uri(NFT_HISTORY_URL_TEST)).unwrap(); + let mut transfer_list = response_tx_history["result"].as_array().unwrap().clone(); + assert!(!transfer_list.is_empty()); + let first_tx = transfer_list.remove(transfer_list.len() - 1); + let transfer_moralis: NftTxHistoryFromMoralis = serde_json::from_str(&first_tx.to_string()).unwrap(); + assert_eq!(TEST_WALLET_ADDR_EVM, transfer_moralis.common.to_address); + + let response_meta = block_on(send_request_to_uri(NFT_METADATA_URL_TEST)).unwrap(); + let nft_moralis: NftFromMoralis = serde_json::from_str(&response_meta.to_string()).unwrap(); + assert_eq!(41237364, *nft_moralis.block_number_minted.unwrap()); + let token_uri = nft_moralis.common.token_uri.unwrap(); let uri_response = block_on(send_request_to_uri(token_uri.as_str())).unwrap(); serde_json::from_str::(&uri_response.to_string()).unwrap(); } + + #[test] + fn test_add_get_nfts() { block_on(test_add_get_nfts_impl()) } + + #[test] + fn test_last_nft_blocks() { block_on(test_last_nft_blocks_impl()) } + + #[test] + fn test_nft_list() { block_on(test_nft_list_impl()) } + + #[test] + fn test_remove_nft() { block_on(test_remove_nft_impl()) } + + #[test] + fn test_refresh_metadata() { block_on(test_refresh_metadata_impl()) } + + #[test] + fn test_nft_amount() { block_on(test_nft_amount_impl()) } + + #[test] + fn test_add_get_txs() { block_on(test_add_get_txs_impl()) } + + #[test] + fn test_last_tx_block() { block_on(test_last_tx_block_impl()) } + + #[test] + fn test_tx_history() { block_on(test_tx_history_impl()) } + + #[test] + fn test_tx_history_filters() { block_on(test_tx_history_filters_impl()) } + + #[test] + fn test_get_update_tx_meta() { block_on(test_get_update_tx_meta_impl()) } } #[cfg(target_arch = "wasm32")] mod wasm_tests { - use crate::nft::nft_structs::{NftTransferHistoryWrapper, NftWrapper}; + use crate::nft::nft_structs::{NftFromMoralis, NftTxHistoryFromMoralis}; use crate::nft::nft_tests::{NFT_HISTORY_URL_TEST, NFT_LIST_URL_TEST, NFT_METADATA_URL_TEST, TEST_WALLET_ADDR_EVM}; use crate::nft::send_request_to_uri; + use crate::nft::storage::db_test_helpers::*; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] - async fn test_moralis_nft_list() { - let response = send_request_to_uri(NFT_LIST_URL_TEST).await.unwrap(); - let nfts_list = response["result"].as_array().unwrap(); + async fn test_moralis_requests() { + let response_nft_list = send_request_to_uri(NFT_LIST_URL_TEST).await.unwrap(); + let nfts_list = response_nft_list["result"].as_array().unwrap(); for nft_json in nfts_list { - let nft_wrapper: NftWrapper = serde_json::from_str(&nft_json.to_string()).unwrap(); - assert_eq!(TEST_WALLET_ADDR_EVM, nft_wrapper.owner_of); + let nft_moralis: NftFromMoralis = serde_json::from_str(&nft_json.to_string()).unwrap(); + assert_eq!(TEST_WALLET_ADDR_EVM, nft_moralis.common.owner_of); } - } - #[wasm_bindgen_test] - async fn test_moralis_nft_transfer_history() { - let response = send_request_to_uri(NFT_HISTORY_URL_TEST).await.unwrap(); - let mut transfer_list = response["result"].as_array().unwrap().clone(); + let response_tx_history = send_request_to_uri(NFT_HISTORY_URL_TEST).await.unwrap(); + let mut transfer_list = response_tx_history["result"].as_array().unwrap().clone(); assert!(!transfer_list.is_empty()); let first_tx = transfer_list.remove(transfer_list.len() - 1); - let transfer_wrapper: NftTransferHistoryWrapper = serde_json::from_str(&first_tx.to_string()).unwrap(); - assert_eq!(TEST_WALLET_ADDR_EVM, transfer_wrapper.to_address); + let transfer_moralis: NftTxHistoryFromMoralis = serde_json::from_str(&first_tx.to_string()).unwrap(); + assert_eq!(TEST_WALLET_ADDR_EVM, transfer_moralis.common.to_address); + + let response_meta = send_request_to_uri(NFT_METADATA_URL_TEST).await.unwrap(); + let nft_moralis: NftFromMoralis = serde_json::from_str(&response_meta.to_string()).unwrap(); + assert_eq!(41237364, *nft_moralis.block_number_minted.unwrap()); } #[wasm_bindgen_test] - async fn test_moralis_nft_metadata() { - let response = send_request_to_uri(NFT_METADATA_URL_TEST).await.unwrap(); - let nft_wrapper: NftWrapper = serde_json::from_str(&response.to_string()).unwrap(); - assert_eq!(41237364, *nft_wrapper.block_number_minted); - } + async fn test_add_get_nfts() { test_add_get_nfts_impl().await } + + #[wasm_bindgen_test] + async fn test_last_nft_blocks() { test_last_nft_blocks_impl().await } + + #[wasm_bindgen_test] + async fn test_nft_list() { test_nft_list_impl().await } + + #[wasm_bindgen_test] + async fn test_remove_nft() { test_remove_nft_impl().await } + + #[wasm_bindgen_test] + async fn test_nft_amount() { test_nft_amount_impl().await } + + #[wasm_bindgen_test] + async fn test_refresh_metadata() { test_refresh_metadata_impl().await } + + #[wasm_bindgen_test] + async fn test_add_get_txs() { test_add_get_txs_impl().await } + + #[wasm_bindgen_test] + async fn test_last_tx_block() { test_last_tx_block_impl().await } + + #[wasm_bindgen_test] + async fn test_tx_history() { test_tx_history_impl().await } + + #[wasm_bindgen_test] + async fn test_tx_history_filters() { test_tx_history_filters_impl().await } + + #[wasm_bindgen_test] + async fn test_get_update_tx_meta() { test_get_update_tx_meta_impl().await } } diff --git a/mm2src/coins/nft/storage/db_test_helpers.rs b/mm2src/coins/nft/storage/db_test_helpers.rs new file mode 100644 index 0000000000..e744538e15 --- /dev/null +++ b/mm2src/coins/nft/storage/db_test_helpers.rs @@ -0,0 +1,575 @@ +use crate::nft::nft_structs::{Chain, ContractType, Nft, NftCommon, NftTransferCommon, NftTransferHistory, + NftTxHistoryFilters, TransferStatus, TxMeta, UriMeta}; +use crate::nft::storage::{NftListStorageOps, NftStorageBuilder, NftTxHistoryStorageOps, RemoveNftResult}; +use mm2_number::BigDecimal; +use mm2_test_helpers::for_tests::mm_ctx_with_custom_db; +use std::num::NonZeroUsize; +use std::str::FromStr; + +cfg_wasm32! { + use wasm_bindgen_test::*; + + wasm_bindgen_test_configure!(run_in_browser); +} + +const TOKEN_ADD: &str = "0xfd913a305d70a60aac4faac70c739563738e1f81"; +const TOKEN_ID: &str = "214300044414"; +const TX_HASH: &str = "0x1e9f04e9b571b283bde02c98c2a97da39b2bb665b57c1f2b0b733f9b681debbe"; + +pub(crate) fn nft() -> Nft { + Nft { + common: NftCommon { + token_address: "0x5c7d6712dfaf0cb079d48981781c8705e8417ca0".to_string(), + token_id: Default::default(), + amount: BigDecimal::from_str("2").unwrap(), + owner_of: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + token_hash: Some("b34ddf294013d20a6d70691027625839".to_string()), + collection_name: None, + symbol: None, + token_uri: Some("https://tikimetadata.s3.amazonaws.com/tiki_box.json".to_string()), + metadata: Some( + "{\"name\":\"https://arweave.net\",\"image\":\"https://tikimetadata.s3.amazonaws.com/tiki_box.png\"}" + .to_string(), + ), + last_token_uri_sync: Some("2023-02-07T17:10:08.402Z".to_string()), + last_metadata_sync: Some("2023-02-07T17:10:16.858Z".to_string()), + minter_address: Some("ERC1155 tokens don't have a single minter".to_string()), + possible_spam: false, + }, + chain: Chain::Bsc, + block_number_minted: Some(25465916), + block_number: 25919780, + contract_type: ContractType::Erc1155, + + uri_meta: UriMeta { + image_url: Some("https://tikimetadata.s3.amazonaws.com/tiki_box.png".to_string()), + raw_image_url: Some("https://tikimetadata.s3.amazonaws.com/tiki_box.png".to_string()), + token_name: None, + description: Some("Born to usher in Bull markets.".to_string()), + attributes: None, + animation_url: None, + external_url: None, + image_details: None, + }, + } +} + +fn tx() -> NftTransferHistory { + NftTransferHistory { + common: NftTransferCommon { + block_hash: Some("0x3d68b78391fb3cf8570df27036214f7e9a5a6a45d309197936f51d826041bfe7".to_string()), + transaction_hash: "0x1e9f04e9b571b283bde02c98c2a97da39b2bb665b57c1f2b0b733f9b681debbe".to_string(), + transaction_index: Some(198), + log_index: Some(495), + value: Default::default(), + transaction_type: Some("Single".to_string()), + token_address: "0xfd913a305d70a60aac4faac70c739563738e1f81".to_string(), + token_id: BigDecimal::from_str("214300047252").unwrap(), + from_address: "0x6fad0ec6bb76914b2a2a800686acc22970645820".to_string(), + to_address: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + amount: BigDecimal::from_str("1").unwrap(), + verified: Some(1), + operator: None, + possible_spam: false, + }, + chain: Chain::Bsc, + block_number: 28056726, + block_timestamp: 1683627432, + contract_type: ContractType::Erc721, + token_uri: None, + collection_name: Some("Binance NFT Mystery Box-Back to Blockchain Future".to_string()), + image_url: Some("https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png".to_string()), + token_name: Some("Nebula Nodes".to_string()), + status: TransferStatus::Receive, + } +} + +fn nft_list() -> Vec { + let nft = Nft { + common: NftCommon { + token_address: "0x5c7d6712dfaf0cb079d48981781c8705e8417ca0".to_string(), + token_id: Default::default(), + amount: BigDecimal::from_str("2").unwrap(), + owner_of: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + token_hash: Some("b34ddf294013d20a6d70691027625839".to_string()), + collection_name: None, + symbol: None, + token_uri: Some("https://tikimetadata.s3.amazonaws.com/tiki_box.json".to_string()), + metadata: Some("{\"name\":\"Tiki box\"}".to_string()), + last_token_uri_sync: Some("2023-02-07T17:10:08.402Z".to_string()), + last_metadata_sync: Some("2023-02-07T17:10:16.858Z".to_string()), + minter_address: Some("ERC1155 tokens don't have a single minter".to_string()), + possible_spam: false, + }, + chain: Chain::Bsc, + block_number_minted: Some(25465916), + block_number: 25919780, + contract_type: ContractType::Erc1155, + uri_meta: UriMeta { + image_url: Some("https://tikimetadata.s3.amazonaws.com/tiki_box.png".to_string()), + raw_image_url: None, + token_name: None, + description: Some("Born to usher in Bull markets.".to_string()), + attributes: None, + animation_url: None, + external_url: None, + image_details: None, + }, + }; + + let nft1 = Nft { + common: NftCommon { + token_address: "0xfd913a305d70a60aac4faac70c739563738e1f81".to_string(), + token_id: BigDecimal::from_str("214300047252").unwrap(), + amount: BigDecimal::from_str("1").unwrap(), + owner_of: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + token_hash: Some("c5d1cfd75a0535b0ec750c0156e6ddfe".to_string()), + collection_name: Some("Binance NFT Mystery Box-Back to Blockchain Future".to_string()), + symbol: Some("BMBBBF".to_string()), + token_uri: Some("https://public.nftstatic.com/static/nft/BSC/BMBBBF/214300047252".to_string()), + metadata: Some( + "{\"image\":\"https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png\"}" + .to_string(), + ), + last_token_uri_sync: Some("2023-02-16T16:35:52.392Z".to_string()), + last_metadata_sync: Some("2023-02-16T16:36:04.283Z".to_string()), + minter_address: Some("0xdbdeb0895f3681b87fb3654b5cf3e05546ba24a9".to_string()), + possible_spam: false, + }, + chain: Chain::Bsc, + + block_number_minted: Some(25721963), + block_number: 28056726, + contract_type: ContractType::Erc721, + uri_meta: UriMeta { + image_url: Some( + "https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png".to_string(), + ), + raw_image_url: None, + token_name: Some("Nebula Nodes".to_string()), + description: Some("Interchain nodes".to_string()), + attributes: None, + animation_url: None, + external_url: None, + image_details: None, + }, + }; + + let nft2 = Nft { + common: NftCommon { + token_address: "0xfd913a305d70a60aac4faac70c739563738e1f81".to_string(), + token_id: BigDecimal::from_str("214300044414").unwrap(), + amount: BigDecimal::from_str("1").unwrap(), + owner_of: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + token_hash: Some("125f8f4e952e107c257960000b4b250e".to_string()), + collection_name: Some("Binance NFT Mystery Box-Back to Blockchain Future".to_string()), + symbol: Some("BMBBBF".to_string()), + token_uri: Some("https://public.nftstatic.com/static/nft/BSC/BMBBBF/214300044414".to_string()), + metadata: Some( + "{\"image\":\"https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png\"}" + .to_string(), + ), + last_token_uri_sync: Some("2023-02-19T19:12:09.471Z".to_string()), + last_metadata_sync: Some("2023-02-19T19:12:18.080Z".to_string()), + minter_address: Some("0xdbdeb0895f3681b87fb3654b5cf3e05546ba24a9".to_string()), + possible_spam: false, + }, + chain: Chain::Bsc, + + block_number_minted: Some(25810308), + block_number: 28056721, + contract_type: ContractType::Erc721, + uri_meta: UriMeta { + image_url: Some( + "https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png".to_string(), + ), + raw_image_url: None, + token_name: Some("Nebula Nodes".to_string()), + description: Some("Interchain nodes".to_string()), + attributes: None, + animation_url: None, + external_url: None, + image_details: None, + }, + }; + vec![nft, nft1, nft2] +} + +fn nft_tx_historty() -> Vec { + let tx = NftTransferHistory { + common: NftTransferCommon { + block_hash: Some("0xcb41654fc5cf2bf5d7fd3f061693405c74d419def80993caded0551ecfaeaae5".to_string()), + transaction_hash: "0x9c16b962f63eead1c5d2355cc9037dde178b14b53043c57eb40c27964d22ae6a".to_string(), + transaction_index: Some(57), + log_index: Some(139), + value: Default::default(), + transaction_type: Some("Single".to_string()), + token_address: "0x5c7d6712dfaf0cb079d48981781c8705e8417ca0".to_string(), + token_id: Default::default(), + from_address: "0x4ff0bbc9b64d635a4696d1a38554fb2529c103ff".to_string(), + to_address: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + amount: BigDecimal::from_str("1").unwrap(), + verified: Some(1), + operator: Some("0x4ff0bbc9b64d635a4696d1a38554fb2529c103ff".to_string()), + possible_spam: false, + }, + chain: Chain::Bsc, + block_number: 25919780, + block_timestamp: 1677166110, + contract_type: ContractType::Erc1155, + token_uri: None, + collection_name: None, + image_url: None, + token_name: None, + status: TransferStatus::Receive, + }; + + let tx1 = NftTransferHistory { + common: NftTransferCommon { + block_hash: Some("0x3d68b78391fb3cf8570df27036214f7e9a5a6a45d309197936f51d826041bfe7".to_string()), + transaction_hash: "0x1e9f04e9b571b283bde02c98c2a97da39b2bb665b57c1f2b0b733f9b681debbe".to_string(), + transaction_index: Some(198), + log_index: Some(495), + value: Default::default(), + transaction_type: Some("Single".to_string()), + token_address: "0xfd913a305d70a60aac4faac70c739563738e1f81".to_string(), + token_id: BigDecimal::from_str("214300047252").unwrap(), + from_address: "0x6fad0ec6bb76914b2a2a800686acc22970645820".to_string(), + to_address: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + amount: BigDecimal::from_str("1").unwrap(), + verified: Some(1), + operator: None, + possible_spam: false, + }, + chain: Chain::Bsc, + block_number: 28056726, + block_timestamp: 1683627432, + contract_type: ContractType::Erc721, + + token_uri: None, + collection_name: None, + image_url: None, + token_name: None, + + status: TransferStatus::Receive, + }; + + let tx2 = NftTransferHistory { + common: NftTransferCommon { + block_hash: Some("0x326db41c5a4fd5f033676d95c590ced18936ef2ef6079e873b23af087fd966c6".to_string()), + transaction_hash: "0x981bad702cc6e088f0e9b5e7287ff7a3487b8d269103cee3b9e5803141f63f91".to_string(), + transaction_index: Some(83), + log_index: Some(201), + value: Default::default(), + transaction_type: Some("Single".to_string()), + token_address: "0xfd913a305d70a60aac4faac70c739563738e1f81".to_string(), + token_id: BigDecimal::from_str("214300044414").unwrap(), + from_address: "0x6fad0ec6bb76914b2a2a800686acc22970645820".to_string(), + to_address: "0xf622a6c52c94b500542e2ae6bcad24c53bc5b6a2".to_string(), + amount: BigDecimal::from_str("1").unwrap(), + verified: Some(1), + operator: None, + possible_spam: false, + }, + chain: Chain::Bsc, + block_number: 28056721, + block_timestamp: 1683627417, + + contract_type: ContractType::Erc721, + + token_uri: None, + collection_name: Some("Binance NFT Mystery Box-Back to Blockchain Future".to_string()), + image_url: Some("https://public.nftstatic.com/static/nft/res/4df0a5da04174e1e9be04b22a805f605.png".to_string()), + token_name: Some("Nebula Nodes".to_string()), + + status: TransferStatus::Receive, + }; + vec![tx, tx1, tx2] +} + +async fn init_nft_list_storage(chain: &Chain) -> impl NftListStorageOps + NftTxHistoryStorageOps { + let ctx = mm_ctx_with_custom_db(); + let storage = NftStorageBuilder::new(&ctx).build().unwrap(); + NftListStorageOps::init(&storage, chain).await.unwrap(); + let is_initialized = NftListStorageOps::is_initialized(&storage, chain).await.unwrap(); + assert!(is_initialized); + storage +} + +async fn init_nft_history_storage(chain: &Chain) -> impl NftListStorageOps + NftTxHistoryStorageOps { + let ctx = mm_ctx_with_custom_db(); + let storage = NftStorageBuilder::new(&ctx).build().unwrap(); + NftTxHistoryStorageOps::init(&storage, chain).await.unwrap(); + let is_initialized = NftTxHistoryStorageOps::is_initialized(&storage, chain).await.unwrap(); + assert!(is_initialized); + storage +} + +pub(crate) async fn test_add_get_nfts_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let nft_list = nft_list(); + storage.add_nfts_to_list(&chain, nft_list, 28056726).await.unwrap(); + + let token_id = BigDecimal::from_str(TOKEN_ID).unwrap(); + let nft = storage + .get_nft(&chain, TOKEN_ADD.to_string(), token_id) + .await + .unwrap() + .unwrap(); + assert_eq!(nft.block_number, 28056721); +} + +pub(crate) async fn test_last_nft_blocks_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let nft_list = nft_list(); + storage.add_nfts_to_list(&chain, nft_list, 28056726).await.unwrap(); + + let token_id = BigDecimal::from_str(TOKEN_ID).unwrap(); + let nft = storage + .get_nft(&chain, TOKEN_ADD.to_string(), token_id) + .await + .unwrap() + .unwrap(); + assert_eq!(nft.block_number, 28056721); +} + +pub(crate) async fn test_nft_list_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let nft_list = nft_list(); + storage.add_nfts_to_list(&chain, nft_list, 28056726).await.unwrap(); + + let nft_list = storage + .get_nft_list(vec![chain], false, 1, Some(NonZeroUsize::new(2).unwrap())) + .await + .unwrap(); + assert_eq!(nft_list.nfts.len(), 1); + let nft = nft_list.nfts.get(0).unwrap(); + assert_eq!(nft.block_number, 28056721); + assert_eq!(nft_list.skipped, 1); + assert_eq!(nft_list.total, 3); +} + +pub(crate) async fn test_remove_nft_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let nft_list = nft_list(); + storage.add_nfts_to_list(&chain, nft_list, 28056726).await.unwrap(); + + let token_id = BigDecimal::from_str(TOKEN_ID).unwrap(); + let remove_rslt = storage + .remove_nft_from_list(&chain, TOKEN_ADD.to_string(), token_id, 28056800) + .await + .unwrap(); + assert_eq!(remove_rslt, RemoveNftResult::NftRemoved); + let list_len = storage + .get_nft_list(vec![chain], true, 1, None) + .await + .unwrap() + .nfts + .len(); + assert_eq!(list_len, 2); + let last_scanned_block = storage.get_last_scanned_block(&chain).await.unwrap().unwrap(); + assert_eq!(last_scanned_block, 28056800); +} + +pub(crate) async fn test_nft_amount_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let mut nft = nft(); + storage + .add_nfts_to_list(&chain, vec![nft.clone()], 25919780) + .await + .unwrap(); + + nft.common.amount -= BigDecimal::from(1); + storage.update_nft_amount(&chain, nft.clone(), 25919800).await.unwrap(); + let amount = storage + .get_nft_amount(&chain, nft.common.token_address.clone(), nft.common.token_id.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(amount, "1"); + let last_scanned_block = storage.get_last_scanned_block(&chain).await.unwrap().unwrap(); + assert_eq!(last_scanned_block, 25919800); + + nft.common.amount += BigDecimal::from(1); + nft.block_number = 25919900; + storage + .update_nft_amount_and_block_number(&chain, nft.clone()) + .await + .unwrap(); + let amount = storage + .get_nft_amount(&chain, nft.common.token_address, nft.common.token_id) + .await + .unwrap() + .unwrap(); + assert_eq!(amount, "2"); + let last_scanned_block = storage.get_last_scanned_block(&chain).await.unwrap().unwrap(); + assert_eq!(last_scanned_block, 25919900); +} + +pub(crate) async fn test_refresh_metadata_impl() { + let chain = Chain::Bsc; + let storage = init_nft_list_storage(&chain).await; + let new_symbol = "NEW_SYMBOL"; + let mut nft = nft(); + storage + .add_nfts_to_list(&chain, vec![nft.clone()], 25919780) + .await + .unwrap(); + nft.common.symbol = Some(new_symbol.to_string()); + drop_mutability!(nft); + let token_add = nft.common.token_address.clone(); + let token_id = nft.common.token_id.clone(); + storage.refresh_nft_metadata(&chain, nft).await.unwrap(); + let nft_upd = storage.get_nft(&chain, token_add, token_id).await.unwrap().unwrap(); + assert_eq!(new_symbol.to_string(), nft_upd.common.symbol.unwrap()); +} + +pub(crate) async fn test_add_get_txs_impl() { + let chain = Chain::Bsc; + let storage = init_nft_history_storage(&chain).await; + let txs = nft_tx_historty(); + storage.add_txs_to_history(&chain, txs).await.unwrap(); + + let token_id = BigDecimal::from_str(TOKEN_ID).unwrap(); + let tx1 = storage + .get_txs_by_token_addr_id(&chain, TOKEN_ADD.to_string(), token_id) + .await + .unwrap() + .get(0) + .unwrap() + .clone(); + assert_eq!(tx1.block_number, 28056721); + let tx2 = storage + .get_tx_by_tx_hash(&chain, TX_HASH.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(tx2.block_number, 28056726); + let tx_from = storage.get_txs_from_block(&chain, 28056721).await.unwrap(); + assert_eq!(tx_from.len(), 2); +} + +pub(crate) async fn test_last_tx_block_impl() { + let chain = Chain::Bsc; + let storage = init_nft_history_storage(&chain).await; + let txs = nft_tx_historty(); + storage.add_txs_to_history(&chain, txs).await.unwrap(); + + let last_block = NftTxHistoryStorageOps::get_last_block_number(&storage, &chain) + .await + .unwrap() + .unwrap(); + assert_eq!(last_block, 28056726); +} + +pub(crate) async fn test_tx_history_impl() { + let chain = Chain::Bsc; + let storage = init_nft_history_storage(&chain).await; + let txs = nft_tx_historty(); + storage.add_txs_to_history(&chain, txs).await.unwrap(); + + let tx_history = storage + .get_tx_history(vec![chain], false, 1, Some(NonZeroUsize::new(2).unwrap()), None) + .await + .unwrap(); + assert_eq!(tx_history.transfer_history.len(), 1); + let tx = tx_history.transfer_history.get(0).unwrap(); + assert_eq!(tx.block_number, 28056721); + assert_eq!(tx_history.skipped, 1); + assert_eq!(tx_history.total, 3); +} + +pub(crate) async fn test_tx_history_filters_impl() { + let chain = Chain::Bsc; + let storage = init_nft_history_storage(&chain).await; + let txs = nft_tx_historty(); + storage.add_txs_to_history(&chain, txs).await.unwrap(); + + let filters = NftTxHistoryFilters { + receive: true, + send: false, + from_date: None, + to_date: None, + }; + + let filters1 = NftTxHistoryFilters { + receive: false, + send: false, + from_date: None, + to_date: Some(1677166110), + }; + + let filters2 = NftTxHistoryFilters { + receive: false, + send: false, + from_date: Some(1677166110), + to_date: Some(1683627417), + }; + + let tx_history = storage + .get_tx_history(vec![chain], true, 1, None, Some(filters)) + .await + .unwrap(); + assert_eq!(tx_history.transfer_history.len(), 3); + let tx = tx_history.transfer_history.get(0).unwrap(); + assert_eq!(tx.block_number, 28056726); + + let tx_history1 = storage + .get_tx_history(vec![chain], true, 1, None, Some(filters1)) + .await + .unwrap(); + assert_eq!(tx_history1.transfer_history.len(), 1); + let tx1 = tx_history1.transfer_history.get(0).unwrap(); + assert_eq!(tx1.block_number, 25919780); + + let tx_history2 = storage + .get_tx_history(vec![chain], true, 1, None, Some(filters2)) + .await + .unwrap(); + assert_eq!(tx_history2.transfer_history.len(), 2); + let tx_0 = tx_history2.transfer_history.get(0).unwrap(); + assert_eq!(tx_0.block_number, 28056721); + let tx_1 = tx_history2.transfer_history.get(1).unwrap(); + assert_eq!(tx_1.block_number, 25919780); +} + +pub(crate) async fn test_get_update_tx_meta_impl() { + let chain = Chain::Bsc; + let storage = init_nft_history_storage(&chain).await; + let txs = nft_tx_historty(); + storage.add_txs_to_history(&chain, txs).await.unwrap(); + + let vec_token_add_id = storage.get_txs_with_empty_meta(&chain).await.unwrap(); + assert_eq!(vec_token_add_id.len(), 2); + + let token_add = "0x5c7d6712dfaf0cb079d48981781c8705e8417ca0".to_string(); + let tx_meta = TxMeta { + token_address: token_add.clone(), + token_id: Default::default(), + token_uri: None, + collection_name: None, + image_url: None, + token_name: Some("Tiki box".to_string()), + }; + storage.update_txs_meta_by_token_addr_id(&chain, tx_meta).await.unwrap(); + let tx_upd = storage + .get_txs_by_token_addr_id(&chain, token_add, Default::default()) + .await + .unwrap(); + let tx_upd = tx_upd.get(0).unwrap(); + assert_eq!(tx_upd.token_name, Some("Tiki box".to_string())); + + let tx_meta = tx(); + storage.update_tx_meta_by_hash(&chain, tx_meta).await.unwrap(); + let tx_by_hash = storage + .get_tx_by_tx_hash(&chain, TX_HASH.to_string()) + .await + .unwrap() + .unwrap(); + assert_eq!(tx_by_hash.token_name, Some("Nebula Nodes".to_string())) +} diff --git a/mm2src/coins/nft/storage/mod.rs b/mm2src/coins/nft/storage/mod.rs index db48342d93..c48c798875 100644 --- a/mm2src/coins/nft/storage/mod.rs +++ b/mm2src/coins/nft/storage/mod.rs @@ -10,11 +10,12 @@ use mm2_number::BigDecimal; use serde::{Deserialize, Serialize}; use std::num::NonZeroUsize; -#[cfg(not(target_arch = "wasm32"))] pub mod sql_storage; -#[cfg(target_arch = "wasm32")] pub mod wasm; +#[cfg(any(test, target_arch = "wasm32"))] +pub(crate) mod db_test_helpers; +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod sql_storage; +#[cfg(target_arch = "wasm32")] pub(crate) mod wasm; -#[allow(dead_code)] -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub enum RemoveNftResult { NftRemoved, NftDidNotExist, @@ -44,7 +45,7 @@ pub trait NftListStorageOps { page_number: Option, ) -> MmResult; - async fn add_nfts_to_list(&self, chain: &Chain, nfts: I, last_scanned_block: u32) -> MmResult<(), Self::Error> + async fn add_nfts_to_list(&self, chain: &Chain, nfts: I, last_scanned_block: u64) -> MmResult<(), Self::Error> where I: IntoIterator + Send + 'static, I::IntoIter: Send; @@ -74,11 +75,11 @@ pub trait NftListStorageOps { async fn refresh_nft_metadata(&self, chain: &Chain, nft: Nft) -> MmResult<(), Self::Error>; /// `get_last_block_number` function returns the height of last block in NFT LIST table - async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error>; + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error>; /// `get_last_scanned_block` function returns the height of last scanned block /// when token was added or removed from MFT LIST table. - async fn get_last_scanned_block(&self, chain: &Chain) -> MmResult, Self::Error>; + async fn get_last_scanned_block(&self, chain: &Chain) -> MmResult, Self::Error>; /// `update_nft_amount` function sets a new amount of a particular token in NFT LIST table async fn update_nft_amount(&self, chain: &Chain, nft: Nft, scanned_block: u64) -> MmResult<(), Self::Error>; @@ -110,14 +111,14 @@ pub trait NftTxHistoryStorageOps { I: IntoIterator + Send + 'static, I::IntoIter: Send; - async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error>; + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error>; /// `get_txs_from_block` function returns transfers sorted by /// block_number in ascending order. It is needed to update the NFT LIST table correctly. async fn get_txs_from_block( &self, chain: &Chain, - from_block: u32, + from_block: u64, ) -> MmResult, Self::Error>; async fn get_txs_by_token_addr_id( @@ -171,3 +172,14 @@ impl<'a> NftStorageBuilder<'a> { sql_storage::SqliteNftStorage::new(self.ctx) } } + +/// `get_offset_limit` function calculates offset and limit for final result if we use pagination. +fn get_offset_limit(max: bool, limit: usize, page_number: Option, total_count: usize) -> (usize, usize) { + if max { + return (0, total_count); + } + match page_number { + Some(page) => ((page.get() - 1) * limit, limit), + None => (0, limit), + } +} diff --git a/mm2src/coins/nft/storage/sql_storage.rs b/mm2src/coins/nft/storage/sql_storage.rs index 5e9cee8cb7..2e319ba5fa 100644 --- a/mm2src/coins/nft/storage/sql_storage.rs +++ b/mm2src/coins/nft/storage/sql_storage.rs @@ -1,7 +1,7 @@ use crate::nft::nft_structs::{Chain, ConvertChain, Nft, NftList, NftTokenAddrId, NftTransferHistory, NftTxHistoryFilters, NftsTransferHistoryList, TxMeta}; -use crate::nft::storage::{CreateNftStorageError, NftListStorageOps, NftStorageError, NftTxHistoryStorageOps, - RemoveNftResult}; +use crate::nft::storage::{get_offset_limit, CreateNftStorageError, NftListStorageOps, NftStorageError, + NftTxHistoryStorageOps, RemoveNftResult}; use async_trait::async_trait; use common::async_blocking; use db_common::sql_build::{SqlCondition, SqlQuery}; @@ -58,8 +58,9 @@ fn create_tx_history_table_sql(chain: &Chain) -> MmResult { token_id VARCHAR(256) NOT NULL, status TEXT NOT NULL, amount VARCHAR(256) NOT NULL, + token_uri TEXT, collection_name TEXT, - image TEXT, + image_url TEXT, token_name TEXT, details_json TEXT );", @@ -128,7 +129,7 @@ fn get_nft_tx_builder_preimage( .map(|chain| { let table_name = nft_tx_history_table_name(&chain); validate_table_name(&table_name)?; - let sql_builder = nft_history_table_builder_preimage(table_name.as_str(), filters.clone())?; + let sql_builder = nft_history_table_builder_preimage(table_name.as_str(), filters)?; let sql_string = sql_builder .sql() .map_err(|e| SqlError::ToSqlConversionFailure(e.into()))? @@ -236,7 +237,7 @@ fn insert_tx_in_history_sql(chain: &Chain) -> MmResult { let sql = format!( "INSERT INTO {} ( transaction_hash, chain, block_number, block_timestamp, contract_type, - token_address, token_id, status, amount, collection_name, image, token_name, details_json + token_address, token_id, status, amount, collection_name, image_url, token_name, details_json ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 );", @@ -274,7 +275,7 @@ fn update_meta_by_tx_hash_sql(chain: &Chain) -> MmResult { validate_table_name(&table_name)?; let sql = format!( - "UPDATE {} SET collection_name = ?1, image = ?2, token_name = ?3, details_json = ?4 WHERE transaction_hash = ?5;", + "UPDATE {} SET token_uri = ?1, collection_name = ?2, image_url = ?3, token_name = ?4, details_json = ?5 WHERE transaction_hash = ?6;", table_name ); Ok(sql) @@ -361,14 +362,14 @@ where Ok(sql) } -fn block_number_from_row(row: &Row<'_>) -> Result { row.get(0) } +fn block_number_from_row(row: &Row<'_>) -> Result { row.get::<_, i64>(0) } fn nft_amount_from_row(row: &Row<'_>) -> Result { row.get(0) } fn get_txs_from_block_builder<'a>( conn: &'a Connection, chain: &'a Chain, - from_block: u32, + from_block: u64, ) -> MmResult, SqlError> { let table_name = nft_tx_history_table_name(chain); validate_table_name(table_name.as_str())?; @@ -409,8 +410,9 @@ fn get_txs_with_empty_meta_builder<'a>(conn: &'a Connection, chain: &'a Chain) - .distinct() .field("token_address") .field("token_id") + .and_where_is_null("token_uri") .and_where_is_null("collection_name") - .and_where_is_null("image") + .and_where_is_null("image_url") .and_where_is_null("token_name"); drop_mutability!(sql_builder); Ok(sql_builder) @@ -478,14 +480,7 @@ impl NftListStorageOps for SqliteNftStorage { .query_row([], |row| row.get(0))?; let count_total = total.try_into().expect("count should not be failed"); - let (offset, limit) = if max { - (0, count_total) - } else { - match page_number { - Some(page) => ((page.get() - 1) * limit, limit), - None => (0, limit), - } - }; + let (offset, limit) = get_offset_limit(max, limit, page_number, count_total); let sql = finalize_nft_list_sql_builder(sql_builder, offset, limit)?; let nfts = conn .prepare(&sql)? @@ -501,7 +496,7 @@ impl NftListStorageOps for SqliteNftStorage { .await } - async fn add_nfts_to_list(&self, chain: &Chain, nfts: I, last_scanned_block: u32) -> MmResult<(), Self::Error> + async fn add_nfts_to_list(&self, chain: &Chain, nfts: I, last_scanned_block: u64) -> MmResult<(), Self::Error> where I: IntoIterator + Send + 'static, I::IntoIter: Send, @@ -515,10 +510,10 @@ impl NftListStorageOps for SqliteNftStorage { for nft in nfts { let nft_json = json::to_string(&nft).expect("serialization should not fail"); let params = [ - Some(nft.token_address), - Some(nft.token_id.to_string()), + Some(nft.common.token_address), + Some(nft.common.token_id.to_string()), Some(nft.chain.to_string()), - Some(nft.amount.to_string()), + Some(nft.common.amount.to_string()), Some(nft.block_number.to_string()), Some(nft.contract_type.to_string()), Some(nft_json), @@ -600,7 +595,7 @@ impl NftListStorageOps for SqliteNftStorage { async_blocking(move || { let mut conn = selfi.0.lock().unwrap(); let sql_transaction = conn.transaction()?; - let params = [nft_json, nft.token_address, nft.token_id.to_string()]; + let params = [nft_json, nft.common.token_address, nft.common.token_id.to_string()]; sql_transaction.execute(&sql, params)?; sql_transaction.commit()?; Ok(()) @@ -608,17 +603,20 @@ impl NftListStorageOps for SqliteNftStorage { .await } - async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { let sql = select_last_block_number_sql(chain, nft_list_table_name)?; let selfi = self.clone(); async_blocking(move || { let conn = selfi.0.lock().unwrap(); query_single_row(&conn, &sql, [], block_number_from_row).map_to_mm(SqlError::from) }) - .await + .await? + .map(|b| b.try_into()) + .transpose() + .map_to_mm(|e| SqlError::FromSqlConversionFailure(2, Type::Integer, Box::new(e))) } - async fn get_last_scanned_block(&self, chain: &Chain) -> MmResult, Self::Error> { + async fn get_last_scanned_block(&self, chain: &Chain) -> MmResult, Self::Error> { let sql = select_last_scanned_block_sql()?; let params = [chain.to_ticker()]; let selfi = self.clone(); @@ -626,7 +624,10 @@ impl NftListStorageOps for SqliteNftStorage { let conn = selfi.0.lock().unwrap(); query_single_row(&conn, &sql, params, block_number_from_row).map_to_mm(SqlError::from) }) - .await + .await? + .map(|b| b.try_into()) + .transpose() + .map_to_mm(|e| SqlError::FromSqlConversionFailure(2, Type::Integer, Box::new(e))) } async fn update_nft_amount(&self, chain: &Chain, nft: Nft, scanned_block: u64) -> MmResult<(), Self::Error> { @@ -638,10 +639,10 @@ impl NftListStorageOps for SqliteNftStorage { let mut conn = selfi.0.lock().unwrap(); let sql_transaction = conn.transaction()?; let params = [ - Some(nft.amount.to_string()), + Some(nft.common.amount.to_string()), Some(nft_json), - Some(nft.token_address), - Some(nft.token_id.to_string()), + Some(nft.common.token_address), + Some(nft.common.token_id.to_string()), ]; sql_transaction.execute(&sql, params)?; sql_transaction.execute(&upsert_last_scanned_block_sql()?, scanned_block_params)?; @@ -660,11 +661,11 @@ impl NftListStorageOps for SqliteNftStorage { let mut conn = selfi.0.lock().unwrap(); let sql_transaction = conn.transaction()?; let params = [ - Some(nft.amount.to_string()), + Some(nft.common.amount.to_string()), Some(nft.block_number.to_string()), Some(nft_json), - Some(nft.token_address), - Some(nft.token_id.to_string()), + Some(nft.common.token_address), + Some(nft.common.token_id.to_string()), ]; sql_transaction.execute(&sql, params)?; sql_transaction.execute(&upsert_last_scanned_block_sql()?, scanned_block_params)?; @@ -724,14 +725,7 @@ impl NftTxHistoryStorageOps for SqliteNftStorage { .query_row([], |row| row.get(0))?; let count_total = total.try_into().expect("count should not be failed"); - let (offset, limit) = if max { - (0, count_total) - } else { - match page_number { - Some(page) => ((page.get() - 1) * limit, limit), - None => (0, limit), - } - }; + let (offset, limit) = get_offset_limit(max, limit, page_number, count_total); let sql = finalize_nft_history_sql_builder(sql_builder, offset, limit)?; let txs = conn .prepare(&sql)? @@ -761,17 +755,17 @@ impl NftTxHistoryStorageOps for SqliteNftStorage { for tx in txs { let tx_json = json::to_string(&tx).expect("serialization should not fail"); let params = [ - Some(tx.transaction_hash), + Some(tx.common.transaction_hash), Some(tx.chain.to_string()), Some(tx.block_number.to_string()), Some(tx.block_timestamp.to_string()), Some(tx.contract_type.to_string()), - Some(tx.token_address), - Some(tx.token_id.to_string()), + Some(tx.common.token_address), + Some(tx.common.token_id.to_string()), Some(tx.status.to_string()), - Some(tx.amount.to_string()), + Some(tx.common.amount.to_string()), tx.collection_name, - tx.image, + tx.image_url, tx.token_name, Some(tx_json), ]; @@ -783,20 +777,23 @@ impl NftTxHistoryStorageOps for SqliteNftStorage { .await } - async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { let sql = select_last_block_number_sql(chain, nft_tx_history_table_name)?; let selfi = self.clone(); async_blocking(move || { let conn = selfi.0.lock().unwrap(); query_single_row(&conn, &sql, [], block_number_from_row).map_to_mm(SqlError::from) }) - .await + .await? + .map(|b| b.try_into()) + .transpose() + .map_to_mm(|e| SqlError::FromSqlConversionFailure(2, Type::Integer, Box::new(e))) } async fn get_txs_from_block( &self, chain: &Chain, - from_block: u32, + from_block: u64, ) -> MmResult, Self::Error> { let selfi = self.clone(); let chain = *chain; @@ -844,11 +841,12 @@ impl NftTxHistoryStorageOps for SqliteNftStorage { let sql = update_meta_by_tx_hash_sql(chain)?; let tx_json = json::to_string(&tx).expect("serialization should not fail"); let params = [ + tx.token_uri, tx.collection_name, - tx.image, + tx.image_url, tx.token_name, Some(tx_json), - Some(tx.transaction_hash), + Some(tx.common.transaction_hash), ]; let selfi = self.clone(); async_blocking(move || { @@ -867,8 +865,9 @@ impl NftTxHistoryStorageOps for SqliteNftStorage { .get_txs_by_token_addr_id(chain, tx_meta.token_address, tx_meta.token_id) .await?; for mut tx in txs.into_iter() { + tx.token_uri = tx_meta.token_uri.clone(); tx.collection_name = tx_meta.collection_name.clone(); - tx.image = tx_meta.image.clone(); + tx.image_url = tx_meta.image_url.clone(); tx.token_name = tx_meta.token_name.clone(); drop_mutability!(tx); selfi.update_tx_meta_by_hash(chain, tx).await?; diff --git a/mm2src/coins/nft/storage/wasm/mod.rs b/mm2src/coins/nft/storage/wasm/mod.rs index 76152223ec..6bbc8738c4 100644 --- a/mm2src/coins/nft/storage/wasm/mod.rs +++ b/mm2src/coins/nft/storage/wasm/mod.rs @@ -1,9 +1,10 @@ use crate::nft::storage::NftStorageError; use mm2_db::indexed_db::{DbTransactionError, InitDbError}; use mm2_err_handle::prelude::*; +use mm2_number::bigdecimal::ParseBigDecimalError; -pub mod nft_idb; -pub mod wasm_storage; +pub(crate) mod nft_idb; +pub(crate) mod wasm_storage; pub type WasmNftCacheResult = MmResult; @@ -18,6 +19,8 @@ pub enum WasmNftCacheError { ErrorClearing(String), NotSupported(String), InternalError(String), + GetLastNftBlockError(String), + ParseBigDecimalError(ParseBigDecimalError), } impl From for WasmNftCacheError { diff --git a/mm2src/coins/nft/storage/wasm/nft_idb.rs b/mm2src/coins/nft/storage/wasm/nft_idb.rs index c2e0112289..287bcab30a 100644 --- a/mm2src/coins/nft/storage/wasm/nft_idb.rs +++ b/mm2src/coins/nft/storage/wasm/nft_idb.rs @@ -1,4 +1,4 @@ -use crate::nft::storage::wasm::wasm_storage::{NftListTable, NftTxHistoryTable}; +use crate::nft::storage::wasm::wasm_storage::{LastScannedBlockTable, NftListTable, NftTxHistoryTable}; use async_trait::async_trait; use mm2_db::indexed_db::InitDbResult; use mm2_db::indexed_db::{DbIdentifier, DbInstance, DbLocked, IndexedDb, IndexedDbBuilder}; @@ -20,13 +20,13 @@ impl DbInstance for NftCacheIDB { .with_version(DB_VERSION) .with_table::() .with_table::() + .with_table::() .build() .await?; Ok(NftCacheIDB { inner }) } } -#[allow(dead_code)] impl NftCacheIDB { - fn get_inner(&self) -> &IndexedDb { &self.inner } + pub(crate) fn get_inner(&self) -> &IndexedDb { &self.inner } } diff --git a/mm2src/coins/nft/storage/wasm/wasm_storage.rs b/mm2src/coins/nft/storage/wasm/wasm_storage.rs index a0c46ae9d2..eb8c22e17b 100644 --- a/mm2src/coins/nft/storage/wasm/wasm_storage.rs +++ b/mm2src/coins/nft/storage/wasm/wasm_storage.rs @@ -2,18 +2,22 @@ use crate::nft::nft_structs::{Chain, ContractType, Nft, NftList, NftTransferHist TransferStatus, TxMeta}; use crate::nft::storage::wasm::nft_idb::{NftCacheIDB, NftCacheIDBLocked}; use crate::nft::storage::wasm::{WasmNftCacheError, WasmNftCacheResult}; -use crate::nft::storage::{CreateNftStorageError, NftListStorageOps, NftTokenAddrId, NftTxHistoryFilters, - NftTxHistoryStorageOps, RemoveNftResult}; +use crate::nft::storage::{get_offset_limit, CreateNftStorageError, NftListStorageOps, NftTokenAddrId, + NftTxHistoryFilters, NftTxHistoryStorageOps, RemoveNftResult}; use crate::CoinsContext; use async_trait::async_trait; +use common::is_initial_upgrade; use mm2_core::mm_ctx::MmArc; -use mm2_db::indexed_db::{BeBigUint, DbUpgrader, OnUpgradeResult, SharedDb, TableSignature}; +use mm2_db::indexed_db::{BeBigUint, DbTable, DbUpgrader, MultiIndex, OnUpgradeResult, SharedDb, TableSignature}; use mm2_err_handle::map_mm_error::MapMmError; use mm2_err_handle::map_to_mm::MapToMmResult; use mm2_err_handle::prelude::MmResult; use mm2_number::BigDecimal; -use serde_json::Value as Json; +use num_traits::ToPrimitive; +use serde_json::{self as json, Value as Json}; +use std::collections::HashSet; use std::num::NonZeroUsize; +use std::str::FromStr; #[derive(Clone)] pub struct IndexedDbNftStorage { @@ -28,78 +32,288 @@ impl IndexedDbNftStorage { }) } - #[allow(dead_code)] async fn lock_db(&self) -> WasmNftCacheResult> { self.db.get_or_initialize().await.mm_err(WasmNftCacheError::from) } + + fn take_nft_according_to_paging_opts( + mut nfts: Vec, + max: bool, + limit: usize, + page_number: Option, + ) -> WasmNftCacheResult { + let total_count = nfts.len(); + nfts.sort_by(|a, b| b.block_number.cmp(&a.block_number)); + let (offset, limit) = get_offset_limit(max, limit, page_number, total_count); + Ok(NftList { + nfts: nfts.into_iter().skip(offset).take(limit).collect(), + skipped: offset, + total: total_count, + }) + } + + fn take_txs_according_to_paging_opts( + mut txs: Vec, + max: bool, + limit: usize, + page_number: Option, + ) -> WasmNftCacheResult { + let total_count = txs.len(); + txs.sort_by(|a, b| b.block_timestamp.cmp(&a.block_timestamp)); + let (offset, limit) = get_offset_limit(max, limit, page_number, total_count); + Ok(NftsTransferHistoryList { + transfer_history: txs.into_iter().skip(offset).take(limit).collect(), + skipped: offset, + total: total_count, + }) + } + + fn take_txs_according_to_filters( + txs: I, + filters: Option, + ) -> WasmNftCacheResult> + where + I: Iterator, + { + let mut filtered_txs = Vec::new(); + for tx_table in txs { + let tx = tx_details_from_item(tx_table)?; + if let Some(filters) = &filters { + if filters.is_status_match(&tx) && filters.is_date_match(&tx) { + filtered_txs.push(tx); + } + } else { + filtered_txs.push(tx); + } + } + Ok(filtered_txs) + } +} + +impl NftTxHistoryFilters { + fn is_status_match(&self, tx: &NftTransferHistory) -> bool { + (!self.receive && !self.send) + || (self.receive && tx.status == TransferStatus::Receive) + || (self.send && tx.status == TransferStatus::Send) + } + + fn is_date_match(&self, tx: &NftTransferHistory) -> bool { + self.from_date.map_or(true, |from| tx.block_timestamp >= from) + && self.to_date.map_or(true, |to| tx.block_timestamp <= to) + } } #[async_trait] impl NftListStorageOps for IndexedDbNftStorage { type Error = WasmNftCacheError; - async fn init(&self, _chain: &Chain) -> MmResult<(), Self::Error> { todo!() } + async fn init(&self, _chain: &Chain) -> MmResult<(), Self::Error> { Ok(()) } - async fn is_initialized(&self, _chain: &Chain) -> MmResult { todo!() } + async fn is_initialized(&self, _chain: &Chain) -> MmResult { Ok(true) } async fn get_nft_list( &self, - _chains: Vec, - _max: bool, - _limit: usize, - _page_number: Option, + chains: Vec, + max: bool, + limit: usize, + page_number: Option, ) -> MmResult { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let mut nfts = Vec::new(); + for chain in chains { + let items = table.get_items("chain", chain.to_string()).await?; + for (_item_id, item) in items.into_iter() { + let nft_detail = nft_details_from_item(item)?; + nfts.push(nft_detail); + } + } + Self::take_nft_according_to_paging_opts(nfts, max, limit, page_number) } - async fn add_nfts_to_list(&self, _chain: &Chain, _nfts: I, _last_scanned_block: u32) -> MmResult<(), Self::Error> + async fn add_nfts_to_list(&self, chain: &Chain, nfts: I, last_scanned_block: u64) -> MmResult<(), Self::Error> where I: IntoIterator + Send + 'static, I::IntoIter: Send, { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let nft_table = db_transaction.table::().await?; + let last_scanned_block_table = db_transaction.table::().await?; + for nft in nfts { + let nft_item = NftListTable::from_nft(&nft)?; + nft_table.add_item(&nft_item).await?; + } + let last_scanned_block = LastScannedBlockTable { + chain: chain.to_string(), + last_scanned_block: BeBigUint::from(last_scanned_block), + }; + last_scanned_block_table + .replace_item_by_unique_index("chain", chain.to_string(), &last_scanned_block) + .await?; + Ok(()) } async fn get_nft( &self, - _chain: &Chain, - _token_address: String, - _token_id: BigDecimal, + chain: &Chain, + token_address: String, + token_id: BigDecimal, ) -> MmResult, Self::Error> { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&token_address)? + .with_value(token_id.to_string())?; + + if let Some((_item_id, item)) = table.get_item_by_unique_multi_index(index_keys).await? { + Ok(Some(nft_details_from_item(item)?)) + } else { + Ok(None) + } } async fn remove_nft_from_list( &self, - _chain: &Chain, - _token_address: String, - _token_id: BigDecimal, - _scanned_block: u64, + chain: &Chain, + token_address: String, + token_id: BigDecimal, + scanned_block: u64, ) -> MmResult { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let nft_table = db_transaction.table::().await?; + let last_scanned_block_table = db_transaction.table::().await?; + + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&token_address)? + .with_value(token_id.to_string())?; + + let last_scanned_block = LastScannedBlockTable { + chain: chain.to_string(), + last_scanned_block: BeBigUint::from(scanned_block), + }; + + let nft_removed = nft_table.delete_item_by_unique_multi_index(index_keys).await?.is_some(); + last_scanned_block_table + .replace_item_by_unique_index("chain", chain.to_string(), &last_scanned_block) + .await?; + if nft_removed { + Ok(RemoveNftResult::NftRemoved) + } else { + Ok(RemoveNftResult::NftDidNotExist) + } } async fn get_nft_amount( &self, - _chain: &Chain, - _token_address: String, - _token_id: BigDecimal, + chain: &Chain, + token_address: String, + token_id: BigDecimal, ) -> MmResult, Self::Error> { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&token_address)? + .with_value(token_id.to_string())?; + + if let Some((_item_id, item)) = table.get_item_by_unique_multi_index(index_keys).await? { + Ok(Some(nft_details_from_item(item)?.common.amount.to_string())) + } else { + Ok(None) + } } - async fn refresh_nft_metadata(&self, _chain: &Chain, _nft: Nft) -> MmResult<(), Self::Error> { todo!() } + async fn refresh_nft_metadata(&self, chain: &Chain, nft: Nft) -> MmResult<(), Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&nft.common.token_address)? + .with_value(nft.common.token_id.to_string())?; + + let nft_item = NftListTable::from_nft(&nft)?; + table.replace_item_by_unique_multi_index(index_keys, &nft_item).await?; + Ok(()) + } - async fn get_last_block_number(&self, _chain: &Chain) -> MmResult, Self::Error> { todo!() } + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + get_last_block_from_table(chain, table, NftListTable::CHAIN_BLOCK_NUMBER_INDEX).await + } - async fn get_last_scanned_block(&self, _chain: &Chain) -> MmResult, Self::Error> { todo!() } + async fn get_last_scanned_block(&self, chain: &Chain) -> MmResult, Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + if let Some((_item_id, item)) = table.get_item_by_unique_index("chain", chain.to_string()).await? { + let last_scanned_block = item + .last_scanned_block + .to_u64() + .ok_or_else(|| WasmNftCacheError::GetLastNftBlockError("height is too large".to_string()))?; + Ok(Some(last_scanned_block)) + } else { + Ok(None) + } + } - async fn update_nft_amount(&self, _chain: &Chain, _nft: Nft, _scanned_block: u64) -> MmResult<(), Self::Error> { - todo!() + async fn update_nft_amount(&self, chain: &Chain, nft: Nft, scanned_block: u64) -> MmResult<(), Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let nft_table = db_transaction.table::().await?; + let last_scanned_block_table = db_transaction.table::().await?; + + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&nft.common.token_address)? + .with_value(nft.common.token_id.to_string())?; + + let nft_item = NftListTable::from_nft(&nft)?; + nft_table + .replace_item_by_unique_multi_index(index_keys, &nft_item) + .await?; + let last_scanned_block = LastScannedBlockTable { + chain: chain.to_string(), + last_scanned_block: BeBigUint::from(scanned_block), + }; + last_scanned_block_table + .replace_item_by_unique_index("chain", chain.to_string(), &last_scanned_block) + .await?; + Ok(()) } - async fn update_nft_amount_and_block_number(&self, _chain: &Chain, _nft: Nft) -> MmResult<(), Self::Error> { - todo!() + async fn update_nft_amount_and_block_number(&self, chain: &Chain, nft: Nft) -> MmResult<(), Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let nft_table = db_transaction.table::().await?; + let last_scanned_block_table = db_transaction.table::().await?; + + let index_keys = MultiIndex::new(NftListTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&nft.common.token_address)? + .with_value(nft.common.token_id.to_string())?; + + let nft_item = NftListTable::from_nft(&nft)?; + nft_table + .replace_item_by_unique_multi_index(index_keys, &nft_item) + .await?; + let last_scanned_block = LastScannedBlockTable { + chain: chain.to_string(), + last_scanned_block: BeBigUint::from(nft.block_number), + }; + last_scanned_block_table + .replace_item_by_unique_index("chain", chain.to_string(), &last_scanned_block) + .await?; + Ok(()) } } @@ -107,65 +321,243 @@ impl NftListStorageOps for IndexedDbNftStorage { impl NftTxHistoryStorageOps for IndexedDbNftStorage { type Error = WasmNftCacheError; - async fn init(&self, _chain: &Chain) -> MmResult<(), Self::Error> { todo!() } + async fn init(&self, _chain: &Chain) -> MmResult<(), Self::Error> { Ok(()) } - async fn is_initialized(&self, _chain: &Chain) -> MmResult { todo!() } + async fn is_initialized(&self, _chain: &Chain) -> MmResult { Ok(true) } async fn get_tx_history( &self, - _chains: Vec, - _max: bool, - _limit: usize, - _page_number: Option, - _filters: Option, + chains: Vec, + max: bool, + limit: usize, + page_number: Option, + filters: Option, ) -> MmResult { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let mut txs = Vec::new(); + for chain in chains { + let tx_tables = table + .get_items("chain", chain.to_string()) + .await? + .into_iter() + .map(|(_item_id, tx)| tx); + let filtered = Self::take_txs_according_to_filters(tx_tables, filters)?; + txs.extend(filtered); + } + Self::take_txs_according_to_paging_opts(txs, max, limit, page_number) } - async fn add_txs_to_history(&self, _chain: &Chain, _txs: I) -> MmResult<(), Self::Error> + async fn add_txs_to_history(&self, _chain: &Chain, txs: I) -> MmResult<(), Self::Error> where I: IntoIterator + Send + 'static, I::IntoIter: Send, { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + for tx in txs { + let tx_item = NftTxHistoryTable::from_tx_history(&tx)?; + table.add_item(&tx_item).await?; + } + Ok(()) } - async fn get_last_block_number(&self, _chain: &Chain) -> MmResult, Self::Error> { todo!() } + async fn get_last_block_number(&self, chain: &Chain) -> MmResult, Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + get_last_block_from_table(chain, table, NftTxHistoryTable::CHAIN_BLOCK_NUMBER_INDEX).await + } async fn get_txs_from_block( &self, - _chain: &Chain, - _from_block: u32, + chain: &Chain, + from_block: u64, ) -> MmResult, Self::Error> { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let mut cursor = table + .cursor_builder() + .only("chain", chain.to_string()) + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + .bound("block_number", BeBigUint::from(from_block), BeBigUint::from(u64::MAX)) + .open_cursor(NftTxHistoryTable::CHAIN_BLOCK_NUMBER_INDEX) + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))?; + + let mut res = Vec::new(); + while let Some((_item_id, item)) = cursor + .next() + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + { + let tx = tx_details_from_item(item)?; + res.push(tx); + } + Ok(res) } async fn get_txs_by_token_addr_id( &self, - _chain: &Chain, - _token_address: String, - _token_id: BigDecimal, + chain: &Chain, + token_address: String, + token_id: BigDecimal, ) -> MmResult, Self::Error> { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + + let index_keys = MultiIndex::new(NftTxHistoryTable::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX) + .with_value(chain.to_string())? + .with_value(&token_address)? + .with_value(token_id.to_string())?; + + table + .get_items_by_multi_index(index_keys) + .await? + .into_iter() + .map(|(_item_id, item)| tx_details_from_item(item)) + .collect() } async fn get_tx_by_tx_hash( &self, - _chain: &Chain, - _transaction_hash: String, + chain: &Chain, + transaction_hash: String, ) -> MmResult, Self::Error> { - todo!() + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let index_keys = MultiIndex::new(NftTxHistoryTable::CHAIN_TX_HASH_INDEX) + .with_value(chain.to_string())? + .with_value(&transaction_hash)?; + + if let Some((_item_id, item)) = table.get_item_by_unique_multi_index(index_keys).await? { + Ok(Some(tx_details_from_item(item)?)) + } else { + Ok(None) + } } - async fn update_tx_meta_by_hash(&self, _chain: &Chain, _tx: NftTransferHistory) -> MmResult<(), Self::Error> { - todo!() + async fn update_tx_meta_by_hash(&self, chain: &Chain, tx: NftTransferHistory) -> MmResult<(), Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + + let index_keys = MultiIndex::new(NftTxHistoryTable::CHAIN_TX_HASH_INDEX) + .with_value(chain.to_string())? + .with_value(&tx.common.transaction_hash)?; + + let item = NftTxHistoryTable::from_tx_history(&tx)?; + table.replace_item_by_unique_multi_index(index_keys, &item).await?; + Ok(()) } - async fn update_txs_meta_by_token_addr_id(&self, _chain: &Chain, _tx_meta: TxMeta) -> MmResult<(), Self::Error> { - todo!() + async fn update_txs_meta_by_token_addr_id(&self, chain: &Chain, tx_meta: TxMeta) -> MmResult<(), Self::Error> { + let txs: Vec = self + .get_txs_by_token_addr_id(chain, tx_meta.token_address, tx_meta.token_id) + .await?; + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + for mut tx in txs { + tx.token_uri = tx_meta.token_uri.clone(); + tx.collection_name = tx_meta.collection_name.clone(); + tx.image_url = tx_meta.image_url.clone(); + tx.token_name = tx_meta.token_name.clone(); + drop_mutability!(tx); + + let index_keys = MultiIndex::new(NftTxHistoryTable::CHAIN_TX_HASH_INDEX) + .with_value(chain.to_string())? + .with_value(&tx.common.transaction_hash)?; + + let item = NftTxHistoryTable::from_tx_history(&tx)?; + table.replace_item_by_unique_multi_index(index_keys, &item).await?; + } + Ok(()) + } + + async fn get_txs_with_empty_meta(&self, chain: &Chain) -> MmResult, Self::Error> { + let locked_db = self.lock_db().await?; + let db_transaction = locked_db.get_inner().transaction().await?; + let table = db_transaction.table::().await?; + let mut cursor = table + .cursor_builder() + .only("chain", chain.to_string()) + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + .open_cursor("chain") + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))?; + + let mut res = HashSet::new(); + while let Some((_item_id, item)) = cursor + .next() + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + { + if item.token_uri.is_none() + && item.collection_name.is_none() + && item.image_url.is_none() + && item.token_name.is_none() + { + res.insert(NftTokenAddrId { + token_address: item.token_address, + token_id: BigDecimal::from_str(&item.token_id).map_err(WasmNftCacheError::ParseBigDecimalError)?, + }); + } + } + Ok(res.into_iter().collect()) } +} - async fn get_txs_with_empty_meta(&self, _chain: &Chain) -> MmResult, Self::Error> { todo!() } +/// `get_last_block_from_table` function returns the highest block in the table related to certain blockchain type. +async fn get_last_block_from_table( + chain: &Chain, + table: DbTable<'_, impl TableSignature + BlockNumberTable>, + cursor: &str, +) -> MmResult, WasmNftCacheError> { + let maybe_item = table + .cursor_builder() + .only("chain", chain.to_string()) + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + // Sets lower and upper bounds for block_number field + .bound("block_number", BeBigUint::from(0u64), BeBigUint::from(u64::MAX)) + // Cursor returns values from the lowest to highest key indexes. + // But we need to get the most highest block_number, so reverse the cursor direction. + .reverse() + // Opens a cursor by the specified index. + // In get_last_block_from_table case it is CHAIN_BLOCK_NUMBER_INDEX, as we need to search block_number for specific chain. + .open_cursor(cursor) + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))? + // Advances the iterator and returns the next value. + // Please note that the items are sorted by the index keys. + .next() + .await + .map_err(|e| WasmNftCacheError::GetLastNftBlockError(e.to_string()))?; + let maybe_item = maybe_item + .map(|(_, item)| { + item.get_block_number() + .to_u64() + .ok_or_else(|| WasmNftCacheError::GetLastNftBlockError("height is too large".to_string())) + }) + .transpose()?; + Ok(maybe_item) +} + +trait BlockNumberTable { + fn get_block_number(&self) -> &BeBigUint; +} + +impl BlockNumberTable for NftListTable { + fn get_block_number(&self) -> &BeBigUint { &self.block_number } +} + +impl BlockNumberTable for NftTxHistoryTable { + fn get_block_number(&self) -> &BeBigUint { &self.block_number } } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -180,21 +572,38 @@ pub(crate) struct NftListTable { } impl NftListTable { - pub const CHAIN_TOKEN_ADD_TOKEN_ID_INDEX: &str = "chain_token_add_token_id_index"; + const CHAIN_TOKEN_ADD_TOKEN_ID_INDEX: &str = "chain_token_add_token_id_index"; + + const CHAIN_BLOCK_NUMBER_INDEX: &str = "chain_block_number_index"; + + fn from_nft(nft: &Nft) -> WasmNftCacheResult { + let details_json = json::to_value(nft).map_to_mm(|e| WasmNftCacheError::ErrorSerializing(e.to_string()))?; + Ok(NftListTable { + token_address: nft.common.token_address.clone(), + token_id: nft.common.token_id.to_string(), + chain: nft.chain.to_string(), + amount: nft.common.amount.to_string(), + block_number: BeBigUint::from(nft.block_number), + contract_type: nft.contract_type, + details_json, + }) + } } impl TableSignature for NftListTable { fn table_name() -> &'static str { "nft_list_cache_table" } fn on_upgrade_needed(upgrader: &DbUpgrader, old_version: u32, new_version: u32) -> OnUpgradeResult<()> { - if let (0, 1) = (old_version, new_version) { + if is_initial_upgrade(old_version, new_version) { let table = upgrader.create_table(Self::table_name())?; table.create_multi_index( Self::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX, &["chain", "token_address", "token_id"], true, )?; + table.create_multi_index(Self::CHAIN_BLOCK_NUMBER_INDEX, &["chain", "block_number"], false)?; table.create_index("chain", false)?; + table.create_index("block_number", false)?; } Ok(()) } @@ -205,31 +614,89 @@ pub(crate) struct NftTxHistoryTable { transaction_hash: String, chain: String, block_number: BeBigUint, - block_timestamp: u64, + block_timestamp: BeBigUint, contract_type: ContractType, token_address: String, token_id: String, status: TransferStatus, amount: String, - collection_name: String, - image: String, - token_name: String, + token_uri: Option, + collection_name: Option, + image_url: Option, + token_name: Option, details_json: Json, } impl NftTxHistoryTable { - pub const CHAIN_TX_HASH_INDEX: &str = "chain_tx_hash_index"; + const CHAIN_TOKEN_ADD_TOKEN_ID_INDEX: &str = "chain_token_add_token_id_index"; + + const CHAIN_TX_HASH_INDEX: &str = "chain_tx_hash_index"; + + const CHAIN_BLOCK_NUMBER_INDEX: &str = "chain_block_number_index"; + + fn from_tx_history(tx: &NftTransferHistory) -> WasmNftCacheResult { + let details_json = json::to_value(tx).map_to_mm(|e| WasmNftCacheError::ErrorSerializing(e.to_string()))?; + Ok(NftTxHistoryTable { + transaction_hash: tx.common.transaction_hash.clone(), + chain: tx.chain.to_string(), + block_number: BeBigUint::from(tx.block_number), + block_timestamp: BeBigUint::from(tx.block_timestamp), + contract_type: tx.contract_type, + token_address: tx.common.token_address.clone(), + token_id: tx.common.token_id.to_string(), + status: tx.status, + amount: tx.common.amount.to_string(), + token_uri: tx.token_uri.clone(), + collection_name: tx.collection_name.clone(), + image_url: tx.image_url.clone(), + token_name: tx.token_name.clone(), + details_json, + }) + } } impl TableSignature for NftTxHistoryTable { fn table_name() -> &'static str { "nft_tx_history_cache_table" } fn on_upgrade_needed(upgrader: &DbUpgrader, old_version: u32, new_version: u32) -> OnUpgradeResult<()> { - if let (0, 1) = (old_version, new_version) { + if is_initial_upgrade(old_version, new_version) { let table = upgrader.create_table(Self::table_name())?; + table.create_multi_index( + Self::CHAIN_TOKEN_ADD_TOKEN_ID_INDEX, + &["chain", "token_address", "token_id"], + false, + )?; table.create_multi_index(Self::CHAIN_TX_HASH_INDEX, &["chain", "transaction_hash"], true)?; + table.create_multi_index(Self::CHAIN_BLOCK_NUMBER_INDEX, &["chain", "block_number"], false)?; + table.create_index("block_number", false)?; table.create_index("chain", false)?; } Ok(()) } } + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct LastScannedBlockTable { + chain: String, + last_scanned_block: BeBigUint, +} + +impl TableSignature for LastScannedBlockTable { + fn table_name() -> &'static str { "last_scanned_block_table" } + + fn on_upgrade_needed(upgrader: &DbUpgrader, old_version: u32, new_version: u32) -> OnUpgradeResult<()> { + if is_initial_upgrade(old_version, new_version) { + let table = upgrader.create_table(Self::table_name())?; + table.create_index("chain", true)?; + } + Ok(()) + } +} + +fn nft_details_from_item(item: NftListTable) -> WasmNftCacheResult { + json::from_value(item.details_json).map_to_mm(|e| WasmNftCacheError::ErrorDeserializing(e.to_string())) +} + +fn tx_details_from_item(item: NftTxHistoryTable) -> WasmNftCacheResult { + json::from_value(item.details_json).map_to_mm(|e| WasmNftCacheError::ErrorDeserializing(e.to_string())) +} diff --git a/mm2src/common/common.rs b/mm2src/common/common.rs index d43bb254f8..653ad11353 100644 --- a/mm2src/common/common.rs +++ b/mm2src/common/common.rs @@ -1040,3 +1040,8 @@ pub fn parse_rfc3339_to_timestamp(date_str: &str) -> Result = date_str.parse()?; Ok(date.timestamp().try_into()?) } + +/// `is_initial_upgrade` function checks if the database is being upgraded from version 0 to 1. +/// This function returns a boolean indicating whether the database is being upgraded from version 0 to 1. +#[cfg(target_arch = "wasm32")] +pub fn is_initial_upgrade(old_version: u32, new_version: u32) -> bool { old_version == 0 && new_version == 1 }