From 6c3a5591a724b2f81d8fc6e4f39e4a901d697365 Mon Sep 17 00:00:00 2001 From: ieow Date: Tue, 30 Aug 2022 14:31:44 +0800 Subject: [PATCH 1/6] feat: activities isolation --- src/controllers/ActivitiesController.ts | 279 +++++++++++++++++ src/controllers/IActivitiesController.ts | 89 ++++++ src/controllers/TorusController.ts | 51 +++- src/modules/controllers.ts | 2 +- src/utils/activitiesHelper.ts | 367 +++++++++++++++++++++++ src/utils/const.ts | 3 +- src/utils/enums.ts | 4 + 7 files changed, 786 insertions(+), 9 deletions(-) create mode 100644 src/controllers/ActivitiesController.ts create mode 100644 src/controllers/IActivitiesController.ts create mode 100644 src/utils/activitiesHelper.ts diff --git a/src/controllers/ActivitiesController.ts b/src/controllers/ActivitiesController.ts new file mode 100644 index 00000000..76706c1d --- /dev/null +++ b/src/controllers/ActivitiesController.ts @@ -0,0 +1,279 @@ +import { PublicKey } from "@solana/web3.js"; +import { BaseConfig, BaseController, BaseState, TransactionStatus } from "@toruslabs/base-controllers"; +import { NetworkController, PreferencesController, TransactionPayload } from "@toruslabs/solana-controllers"; +import log from "loglevel"; + +import { formatBackendTxToActivity, formatTopUpTxToActivity, formatTransactionToActivity, formatTxToBackend } from "@/utils/activitiesHelper"; + +import { FetchedTransaction, SolanaTransactionActivity, TopupOrderTransaction } from "./IActivitiesController"; + +export interface ActivitiesControllerState extends BaseState { + address: { + [selectedAddress: string]: { + topupTransaction: TopupOrderTransaction[]; + backendTransactions: FetchedTransaction[]; + activities: { + [key: string]: SolanaTransactionActivity; + }; + }; + }; + state: string; + loading: boolean; +} +export type ActivitiesControllerConfig = BaseConfig; + +export default class ActivitiesController extends BaseController { + getProviderConfig: NetworkController["getProviderConfig"]; + + getConnection: NetworkController["getConnection"]; + + getTopUpOrders: PreferencesController["getTopUpOrders"]; + + getWalletOrders: PreferencesController["getWalletOrders"]; + + patchPastTx: PreferencesController["patchPastTx"]; + + postPastTx: PreferencesController["postPastTx"]; + + getSelectedAddress: () => string; + + /** + * Creates a ActivitiesController instance + * + * @param config - Initial options used to configure this controller + * @param state - Initial state to set on this controller + */ + constructor({ + config, + state, + getProviderConfig, + getConnection, + getTopUpOrders, + patchPastTx, + postPastTx, + getSelectedAddress, + getWalletOrders, + }: { + config?: BaseConfig; + state?: Partial; + getProviderConfig: NetworkController["getProviderConfig"]; + getConnection: NetworkController["getConnection"]; + getTopUpOrders: PreferencesController["getTopUpOrders"]; + getWalletOrders: PreferencesController["getWalletOrders"]; + patchPastTx: PreferencesController["patchPastTx"]; + postPastTx: PreferencesController["postPastTx"]; + getSelectedAddress: () => string; + }) { + log.info(state); + super({ + config, + state, + }); + this.getProviderConfig = getProviderConfig; + this.getConnection = getConnection; + this.getSelectedAddress = getSelectedAddress; + this.getTopUpOrders = getTopUpOrders; + this.getWalletOrders = getWalletOrders; + this.patchPastTx = patchPastTx; + this.postPastTx = postPastTx; + log.info(this.state); + this.update({ ...state, loading: false }); + } + + topupActivities(): SolanaTransactionActivity[] { + const tempMap: SolanaTransactionActivity[] = []; + const selectedAddress = this.getSelectedAddress(); + if (!this.state.address[selectedAddress].topupTransaction) return tempMap; + this.state.address[selectedAddress].topupTransaction.forEach((item) => { + if (item?.solana?.signature) { + const temp = formatTopUpTxToActivity(item); + if (temp) tempMap.push(temp); + } + }); + return tempMap; + } + + backendActivities(): SolanaTransactionActivity[] { + const tempMap: SolanaTransactionActivity[] = []; + const selectedAddress = this.getSelectedAddress(); + if (!this.state.address[selectedAddress].backendTransactions) return tempMap; + + this.state.address[selectedAddress].backendTransactions.forEach((tx) => { + if (tx.network === this.getProviderConfig().chainId) { + const temp = formatBackendTxToActivity(tx, selectedAddress); + if (temp) tempMap.push(temp); + } + }); + return tempMap; + } + + async updateTopUpTransaction(address: string) { + const response = await this.getTopUpOrders(address); + + this.update({ + address: { + ...this.state.address, + [address]: { + ...this.state.address[address], + topupTransaction: response, + }, + }, + }); + } + + async updateBackendTransaction(address: string) { + const response = await this.getWalletOrders(address); + this.update({ + address: { + ...this.state.address, + [address]: { + ...this.state.address[address], + backendTransactions: response, + }, + }, + }); + } + + async onChainActivities(): Promise { + const providerConfig = this.getProviderConfig(); + const selectedAddress = this.getSelectedAddress(); + const connection = this.getConnection(); + let signatureInfo = await connection.getSignaturesForAddress(new PublicKey(selectedAddress), { limit: 40 }); + + signatureInfo = signatureInfo.filter((s) => this.state.address[selectedAddress].activities[s.signature]?.status !== TransactionStatus.finalized); + if (!signatureInfo.length) { + // this.updateState({ newTransaction: [] }, address); + return []; + } + + const onChainTransactions = await connection.getParsedTransactions(signatureInfo.map((s) => s.signature)); + + const temp = formatTransactionToActivity({ + transactions: onChainTransactions, + signaturesInfo: signatureInfo, + chainId: providerConfig.chainId, + blockExplorerUrl: providerConfig.blockExplorerUrl, + selectedAddress, + }); + return temp; + } + + async fullRefresh() { + this.update({ + loading: true, + address: { + ...this.state.address, + [this.getSelectedAddress()]: { + ...this.state.address[this.getSelectedAddress()], + activities: {}, + }, + }, + }); + log.info("fullRefresh"); + const initial: { [key: string]: SolanaTransactionActivity } = {}; + [...this.backendActivities(), ...this.topupActivities()].forEach((item) => { + initial[item.signature] = item; + }); + this.refreshActivities(initial); + } + + async refreshActivities(initialActivities?: { [key: string]: SolanaTransactionActivity }): Promise { + if (this.state.loading && !initialActivities) return; + this.update({ loading: true }); + + log.info("refreshing"); + const selectedAddress = this.getSelectedAddress(); + const activities = initialActivities || this.state.address[selectedAddress].activities; + + const newActvities: SolanaTransactionActivity[] = await this.onChainActivities(); + + log.info(newActvities); + newActvities.forEach((item) => { + const activity = activities[item.signature]; + // new incoming transaction from blockchain + if (!activity) { + activities[item.signature] = item; + } else if (item.status !== activity.status) { + activity.status = item.status; + if (activity.id) { + this.updateIncomingTransaction(activity.status, activity.id); + } + } + }); + + this.update({ + address: { + ...this.state.address, + + [selectedAddress]: { + ...(this.state.address[selectedAddress] || {}), + activities, + }, + }, + loading: false, + }); + } + + updateIncomingTransaction(status: TransactionStatus, id: number): void { + const selectedAddress = this.getSelectedAddress(); + this.patchPastTx({ id: id.toString(), status, updated_at: new Date().toISOString() }, selectedAddress); + const { backendTransactions } = this.state.address[selectedAddress]; + const idx = backendTransactions.findIndex((item) => item.id === id); + backendTransactions[idx].status = status; + + this.update({ + address: { + ...this.state.address, + [selectedAddress]: { + ...this.state.address[selectedAddress], + backendTransactions, + }, + }, + }); + } + + // When a new Transaction is submitted, append to incoming Transaction and post to backend + async patchNewTx(formattedTx: SolanaTransactionActivity, address: string): Promise { + const selectedAddress = this.getSelectedAddress(); + const { backendTransactions, activities } = this.state.address[selectedAddress]; + + const duplicateIndex = backendTransactions.findIndex((x) => x.signature === formattedTx.signature); + if (duplicateIndex === -1 && formattedTx.status === TransactionStatus.submitted) { + // No duplicate found + + // Update display activites locally (optimistic) + activities[formattedTx.signature] = formattedTx; + // incomingBackendTransactions = [...incomingBackendTransactions, formattedTx]; + + this.update({ + address: { + ...this.state.address, + [selectedAddress]: { + ...this.state.address[selectedAddress], + activities, + }, + }, + }); + + // Updated torus backend + const txFormattedToBackend = formatTxToBackend(formattedTx, ""); + const { response } = await this.postPastTx(txFormattedToBackend, address); + + // Update local transactions with backendId + const idx = backendTransactions.findIndex((item) => item.signature === formattedTx.signature); // && item.chainId === formattedTx.chainId); + if (idx >= 0) { + activities[formattedTx.signature].id = response[0] || -1; + backendTransactions[idx].id = response[0] || -1; + this.update({ + address: { + // ...this.state.address, + [selectedAddress]: { + ...this.state.address[selectedAddress], + activities, + }, + }, + }); + } + } + } +} diff --git a/src/controllers/IActivitiesController.ts b/src/controllers/IActivitiesController.ts new file mode 100644 index 00000000..78d44ed1 --- /dev/null +++ b/src/controllers/IActivitiesController.ts @@ -0,0 +1,89 @@ +import { TransactionStatus } from "@toruslabs/base-controllers"; + +export interface SolanaTransactionActivity { + id?: number; + status: TransactionStatus; + updatedAt?: number; // iso date string + signature: string; + slot?: string; + rawTransaction?: string; + txReceipt?: unknown; + error?: Error; + warning?: { + error?: string; + message?: string; + }; + action: string; + to?: string; + from?: string; + cryptoAmount?: number; + cryptoCurrency?: string; + decimal: number; + transaction?: string; + fee?: number; + send?: boolean; + type: string; + + currency?: string; + currencyAmount?: number; + + totalAmountString?: string; + blockExplorerUrl: string; + transactionType?: string; + meta?: any; + chainId: string; + network: string; + rawDate: string; + logoURI?: string; + mintAddress?: string; +} + +export interface FetchedTransaction { + id: number; + from: string; + to: string; + crypto_amount: string; + crypto_currency: string; + decimal: number; + currency_amount: string; + selected_currency: string; + // raw_transaction: string; + is_cancel: boolean; + status: string; + network: string; + signature: string; + transaction_category: string; + fee: string; + gasless: boolean; + gasless_relayer_public_key: string; + mint_address: string; + created_at: string; + updated_at: string; +} + +export interface TopupOrderTransaction { + action: string; + amount: string; + currencyAmountString: string; + currencyUsed: string; + date: string; + ethRate: string; + etherscanLink?: string; + from: string; + id: string; + slicedFrom: string; + slicedTo: string; + solana: { + amount: string; + currencyAmount: string; + currencyUsed: string; + decimal?: string; + rate: string; + signature?: string; + symbol: string; + }; + status: string; + to: string; + totalAmount: string; + totalAmountString: string; +} diff --git a/src/controllers/TorusController.ts b/src/controllers/TorusController.ts index 7547efa4..75c64b3d 100644 --- a/src/controllers/TorusController.ts +++ b/src/controllers/TorusController.ts @@ -83,6 +83,7 @@ import stringify from "safe-stable-stringify"; import OpenLoginHandler from "@/auth/OpenLoginHandler"; import config from "@/config"; import { topupPlugin } from "@/plugins/Topup"; +import { formatNewTxToActivity } from "@/utils/activitiesHelper"; import { retrieveNftOwner } from "@/utils/bonfida"; import { WALLET_SUPPORTED_NETWORKS } from "@/utils/const"; import { @@ -103,6 +104,7 @@ import TorusStorageLayer from "@/utils/tkey/storageLayer"; import { TOPUP } from "@/utils/topup"; import { PKG } from "../const"; +import ActivitiesController from "./ActivitiesController"; const TARGET_NETWORK = "mainnet"; const SOL_TLD_AUTHORITY = new PublicKey("58PwtjSDuFHuUkYjH9BYnnQKHfwo9reZhC2zMJv9JPkx"); @@ -132,6 +134,7 @@ export const DEFAULT_CONFIG = { supportedCurrencies: config.supportedCurrencies, api: config.api, }, + ActivitiesControllerConfig: {}, }; export const DEFAULT_STATE = { AccountTrackerState: { accounts: {} }, @@ -183,6 +186,11 @@ export const DEFAULT_STATE = { RelayMap: {}, RelayKeyHostMap: {}, UserDapp: new Map(), + ActivitiesControllerState: { + address: {}, + state: "", + loading: false, + }, }; export const EPHERMAL_KEY = `${CONTROLLER_MODULE_KEY}-ephemeral`; @@ -210,6 +218,8 @@ export default class TorusController extends BaseController this.preferencesController.state.selectedAddress, + getProviderConfig: this.networkController.getProviderConfig.bind(this.networkController), + getWalletOrders: this.preferencesController.getWalletOrders.bind(this.preferencesController), + getTopUpOrders: this.preferencesController.getTopUpOrders.bind(this.preferencesController), + patchPastTx: this.preferencesController.patchPastTx.bind(this.preferencesController), + postPastTx: this.preferencesController.postPastTx.bind(this.preferencesController), + }); + this.txController.on(TX_EVENTS.TX_UNAPPROVED, ({ txMeta, req }) => { this.emit(TX_EVENTS.TX_UNAPPROVED, { txMeta, req }); }); @@ -433,7 +455,8 @@ export default class TorusController extends BaseController { if (this.selectedAddress) { try { - this.preferencesController.initializeDisplayActivity(); + // this.preferencesController.initializeDisplayActivity(); + this.activitiesController.fullRefresh(); } catch (e) { log.error(e, this.selectedAddress); } @@ -488,7 +512,6 @@ export default class TorusController extends BaseController) => { @@ -502,9 +525,16 @@ export default class TorusController extends BaseController { - log.error(err, "error while patching a new tx"); - }); + + const conversionRate = tokenTransfer ? tokenTransfer.conversionRate[this.currencyController.getCurrentCurrency()] : this.conversionRate; + const currencyData = { + selectedCurrency: this.currentCurrency, + conversionRate, + }; + const { blockExplorerUrl } = this.networkController.state.providerConfig; + const formattedTx = formatNewTxToActivity(state2.transactions[txId], currencyData, this.selectedAddress, blockExplorerUrl, tokenTransfer); + + this.activitiesController.patchNewTx(formattedTx, this.selectedAddress); } }); }); @@ -513,6 +543,9 @@ export default class TorusController extends BaseController { + this.update({ ActivitiesControllerState: state2 }); + }); this.updateRelayMap(); } @@ -706,10 +739,14 @@ export default class TorusController extends BaseController { diff --git a/src/modules/controllers.ts b/src/modules/controllers.ts index 31380a06..8c7f446b 100644 --- a/src/modules/controllers.ts +++ b/src/modules/controllers.ts @@ -93,7 +93,7 @@ class ControllerModule extends VuexModule { } get selectedNetworkTransactions(): SolanaTransactionActivity[] { - const txns = Object.values(this.selectedAccountPreferences.displayActivities || {}); + const txns = Object.values(this.torusState.ActivitiesControllerState.address[this.selectedAddress]?.activities); return txns.map((item) => { if (item.mintAddress) { if (item.decimal === 0) { diff --git a/src/utils/activitiesHelper.ts b/src/utils/activitiesHelper.ts new file mode 100644 index 00000000..8cfd45f5 --- /dev/null +++ b/src/utils/activitiesHelper.ts @@ -0,0 +1,367 @@ +import { ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { + ConfirmedSignatureInfo, + LAMPORTS_PER_SOL, + ParsedInstruction, + ParsedTransactionWithMeta, + SystemInstruction, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + ACTIVITY_ACTION_BURN, + ACTIVITY_ACTION_RECEIVE, + ACTIVITY_ACTION_SEND, + ACTIVITY_ACTION_TOPUP, + TransactionMeta, + TransactionStatus, +} from "@toruslabs/base-controllers"; +import { + ACTIVITY_ACTION_UNKNOWN, + BURN_ADDRESS_INC, + CHAIN_ID_NETWORK_MAP, + FetchedTransaction, + SolanaTransactionActivity, + TokenTransactionData, + TransactionPayload, +} from "@toruslabs/solana-controllers"; +import log from "loglevel"; + +import { TopupOrderTransaction } from "@/controllers/IActivitiesController"; + +import { WALLET_SUPPORTED_NETWORKS } from "./const"; + +const CHAIN_ID_NETWORK_MAP_OBJ: { [key: string]: string } = { ...CHAIN_ID_NETWORK_MAP }; + +const getSolanaTransactionLink = (blockExplorerUrl: string, signature: string, chainId: string): string => { + return `${blockExplorerUrl}/tx/${signature}/?cluster=${CHAIN_ID_NETWORK_MAP_OBJ[chainId]}`; +}; + +export function lamportToSol(lamport: number, fixedDecimals = 4): string { + return ((lamport / LAMPORTS_PER_SOL) as number).toFixed(fixedDecimals); +} + +export function cryptoAmountToUiAmount(amount: number, decimals: number, fixedDecimals = 4): string { + return ((amount / 10 ** decimals) as number).toFixed(fixedDecimals); +} + +// Formatting Parsed Transaction from Blockchain(Solana) to Display Activity format +export const formatTransactionToActivity = (params: { + transactions: (ParsedTransactionWithMeta | null)[]; + signaturesInfo: ConfirmedSignatureInfo[]; + chainId: string; + blockExplorerUrl: string; + selectedAddress: string; +}) => { + const { transactions, signaturesInfo, chainId, blockExplorerUrl, selectedAddress } = params; + const finalTxs = signaturesInfo.map((info, index) => { + const tx = transactions[index]; + const finalObject: SolanaTransactionActivity = { + slot: info.slot.toString(), + status: tx?.meta?.err ? TransactionStatus.failed : TransactionStatus.finalized, + updatedAt: (info.blockTime || 0) * 1000, + signature: info.signature, + txReceipt: info.signature, + blockExplorerUrl: getSolanaTransactionLink(blockExplorerUrl, info.signature, chainId), + chainId, + network: CHAIN_ID_NETWORK_MAP_OBJ[chainId], + rawDate: new Date((info.blockTime || 0) * 1000).toISOString(), + action: ACTIVITY_ACTION_UNKNOWN, + type: "unknown", + decimal: 9, + }; + + // return as unknown transaction if tx/meta is undefined as further decoding require tx.meta + if (!tx?.meta) return finalObject; + + // TODO: Need to Decode for Token Account Creation and Transfer Instruction which bundle in 1 Transaction. + let interestedTransactionInstructionidx = -1; + const instructionLength = tx.transaction.message.instructions.length; + + if (instructionLength > 1 && instructionLength <= 3) { + const createInstructionIdx = tx.transaction.message.instructions.findIndex((inst) => { + if (inst.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) { + return (inst as unknown as ParsedInstruction).parsed?.type === "create"; + } + return false; + }); + if (createInstructionIdx >= 0) { + const transferIdx = tx.transaction.message.instructions.findIndex((inst) => { + return ["transfer", "transferChecked"].includes((inst as unknown as ParsedInstruction).parsed?.type); + }); + interestedTransactionInstructionidx = transferIdx; + } else { + const burnIdx = tx.transaction.message.instructions.findIndex((inst) => { + return ["burn", "burnChecked"].includes((inst as unknown as ParsedInstruction).parsed?.type); + }); + interestedTransactionInstructionidx = burnIdx; + } + } + + const interestedTransactionType = ["transfer", "transferChecked", "burn", "burnChecked"]; + + // Expecting SPL/SOL transfer Transaction to have only 1 instruction + if (tx.transaction.message.instructions.length === 1 || interestedTransactionInstructionidx >= 0) { + if (tx.transaction.message.instructions.length === 1) interestedTransactionInstructionidx = 0; + const inst: ParsedInstruction = tx.transaction.message.instructions[interestedTransactionInstructionidx] as unknown as ParsedInstruction; + if (inst.parsed && interestedTransactionType.includes(inst.parsed.type)) { + if (inst.program === "spl-token") { + // set spl-token parameter + // authority is the signer(sender) + const source = inst.parsed.info.authority; + if ((tx?.meta?.postTokenBalances?.length || 0) <= 1) { + finalObject.from = source; + finalObject.to = source; + } else if (tx?.meta?.postTokenBalances) { + finalObject.from = source; + finalObject.to = tx.meta.postTokenBalances[0].owner === source ? tx.meta.postTokenBalances[1].owner : tx.meta.postTokenBalances[0].owner; + } + + let mint = tx?.meta?.postTokenBalances?.length ? tx?.meta?.postTokenBalances[0].mint : ""; + mint = ["burn", "burnChecked"].includes(inst.parsed.type) ? inst.parsed.info.mint : mint; + // "transferCheck" is parsed differently from "transfer" instruction + const amount = ["burnChecked", "transferChecked"].includes(inst.parsed.type) + ? inst.parsed.info.tokenAmount.amount + : inst.parsed.info.amount; + const decimals = ["burnChecked", "transferChecked"].includes(inst.parsed.type) + ? inst.parsed.info.tokenAmount.decimals + : inst.parsed.info.decimals; + finalObject.cryptoAmount = amount; + finalObject.cryptoCurrency = "-"; + finalObject.fee = tx.meta.fee; + finalObject.type = inst.parsed.type; + finalObject.send = finalObject.from === selectedAddress; + finalObject.action = finalObject.send ? ACTIVITY_ACTION_SEND : ACTIVITY_ACTION_RECEIVE; + finalObject.decimal = decimals; + finalObject.totalAmountString = cryptoAmountToUiAmount(amount, decimals); + finalObject.logoURI = ""; + finalObject.mintAddress = mint; + } else if (inst.program === "system") { + finalObject.from = inst.parsed.info.source; + finalObject.to = inst.parsed.info.destination; + finalObject.cryptoAmount = inst.parsed.info.lamports; + finalObject.cryptoCurrency = "SOL"; + finalObject.fee = tx.meta.fee; + finalObject.type = inst.parsed.type; + finalObject.send = inst.parsed.info.source === selectedAddress; + finalObject.action = finalObject.send ? ACTIVITY_ACTION_SEND : ACTIVITY_ACTION_RECEIVE; + finalObject.decimal = 9; + finalObject.totalAmountString = lamportToSol(inst.parsed.info.lamports); + // finalObject.logoURI = default sol logo + // No converstion to current currency rate as the backend use transaction date currency rate + } + } + } + return finalObject; + }); + return finalTxs; +}; + +// Formatting a Transaction (From Transaction Controller) to Display Activity Format +export const formatNewTxToActivity = ( + tx: TransactionMeta, + currencyData: { selectedCurrency: string; conversionRate: number }, + selectedAddress: string, + blockExplorerUrl: string, + tokenTransfer?: TokenTransactionData +): SolanaTransactionActivity => { + const isoDateString = new Date(tx.time).toISOString(); + + // Default display parameter for unknown Transaction + const finalObject: SolanaTransactionActivity = { + slot: "n/a", + status: tx.status as TransactionStatus, + signature: tx.transactionHash || "", + updatedAt: tx.time, + rawDate: isoDateString, + blockExplorerUrl: getSolanaTransactionLink(blockExplorerUrl, tx.transactionHash || "", tx.chainId), + network: CHAIN_ID_NETWORK_MAP_OBJ[tx.chainId], + chainId: tx.chainId, + action: ACTIVITY_ACTION_UNKNOWN, + type: "unknown", + decimal: 9, + currencyAmount: 0, + currency: currencyData.selectedCurrency, + // for Unkown transaction, default "from" as selectedAddress and "to" as arbitrary string + // Probably will not be used for display (Backend do not accept empty string) + from: selectedAddress, + to: "unknown-unknown-unknown-unknown-", + // fee: tx., + }; + + // Check for decodable instruction (SOL transfer) + // Expect SOL transfer to have only 1 instruction in 1 transaction + if (tx.transaction.instructions.length === 1) { + const instruction1 = tx.transaction.instructions[0]; + if (SystemProgram.programId.equals(instruction1.programId) && SystemInstruction.decodeInstructionType(instruction1) === "Transfer") { + const parsedInst = SystemInstruction.decodeTransfer(instruction1); + + finalObject.from = parsedInst.fromPubkey.toBase58(); + finalObject.to = parsedInst.toPubkey.toBase58(); + finalObject.cryptoAmount = Number(parsedInst.lamports); + finalObject.cryptoCurrency = "SOL"; + finalObject.type = "transfer"; + finalObject.totalAmountString = lamportToSol(Number(parsedInst.lamports)); + finalObject.currency = currencyData.selectedCurrency.toUpperCase(); + finalObject.decimal = 9; + finalObject.send = selectedAddress === finalObject.from; + finalObject.action = finalObject.send ? ACTIVITY_ACTION_SEND : ACTIVITY_ACTION_RECEIVE; + finalObject.currencyAmount = (finalObject.cryptoAmount / LAMPORTS_PER_SOL) * currencyData.conversionRate; + } + } + + // Check for if it is SPL Token Transfer (tokenTransfer will be undefined if it is not SPL token transfer) + // SPL token info is decoded before pass in as tokenTransfer to patchNewTransaction + if (tokenTransfer) { + finalObject.from = tokenTransfer.from; + finalObject.to = tokenTransfer.to; + finalObject.cryptoAmount = tokenTransfer.amount; + finalObject.cryptoCurrency = tokenTransfer.tokenName; + finalObject.type = tokenTransfer?.to === BURN_ADDRESS_INC ? "burn" : "transfer"; + finalObject.decimal = tokenTransfer.decimals; + finalObject.currency = currencyData.selectedCurrency.toUpperCase(); + finalObject.currencyAmount = Number(cryptoAmountToUiAmount(tokenTransfer.amount, tokenTransfer.decimals)) * currencyData.conversionRate; + finalObject.totalAmountString = + tokenTransfer?.to === BURN_ADDRESS_INC + ? tokenTransfer?.amount.toString() + : cryptoAmountToUiAmount(tokenTransfer.amount, tokenTransfer.decimals); + finalObject.logoURI = tokenTransfer.logoURI; + finalObject.send = selectedAddress === finalObject.from; + finalObject.action = finalObject.send ? ACTIVITY_ACTION_SEND : ACTIVITY_ACTION_RECEIVE; + finalObject.mintAddress = tokenTransfer.mintAddress; + } + return finalObject; +}; + +// Formatting Backend data to Display Activity Format +export const formatBackendTxToActivity = (tx: FetchedTransaction, selectedAddress: string): SolanaTransactionActivity => { + // Default parameter for Unknown Transaction + const finalObject: SolanaTransactionActivity = { + action: ACTIVITY_ACTION_UNKNOWN, + status: tx.status as TransactionStatus, + id: tx.id, + from: tx.from, + to: tx.to, + rawDate: tx.created_at, + updatedAt: new Date(tx.created_at).valueOf(), + blockExplorerUrl: getSolanaTransactionLink( + WALLET_SUPPORTED_NETWORKS[CHAIN_ID_NETWORK_MAP_OBJ[tx.network]].blockExplorerUrl, + tx.signature, + tx.network + ), + network: CHAIN_ID_NETWORK_MAP_OBJ[tx.network], + chainId: tx.network, + signature: tx.signature, + fee: parseFloat(tx.fee), + type: tx.transaction_category.toLowerCase(), + decimal: 9, + logoURI: "", + mintAddress: tx.mint_address || undefined, + cryptoAmount: 0, + cryptoCurrency: "sol", + currencyAmount: 0, + currency: "usd", + }; + log.info(selectedAddress); + + // transction_category "transfer" is either SPL or SOL transfer Transaction + if (["transfer", "burn"].includes(tx.transaction_category.toLowerCase())) { + finalObject.currencyAmount = parseFloat(tx.currency_amount); + finalObject.currency = tx.selected_currency; + finalObject.cryptoAmount = parseInt(tx.crypto_amount, 10); + finalObject.cryptoCurrency = tx.crypto_currency.toUpperCase(); + finalObject.decimal = tx.decimal; + finalObject.totalAmountString = cryptoAmountToUiAmount(finalObject.cryptoAmount, finalObject.decimal); + finalObject.send = selectedAddress === finalObject.from; + + if (tx.transaction_category === "burn") finalObject.action = ACTIVITY_ACTION_BURN; + else if (finalObject.send) finalObject.action = ACTIVITY_ACTION_SEND; + else finalObject.action = ACTIVITY_ACTION_RECEIVE; + } + return finalObject; +}; + +// Reclassification of status +export const reclassifyStatus = (status: string): TransactionStatus => { + if (status === "success") { + return TransactionStatus.finalized; + } + if (status === "failed") { + return TransactionStatus.failed; + } + return TransactionStatus.submitted; +}; + +// Formatting Backend data to Display Activity Format +export const formatTopUpTxToActivity = (tx: TopupOrderTransaction): SolanaTransactionActivity | undefined => { + try { + // Default parameter for Unknown Transaction + // expect topup happen on mainnet only + const chainId = "0x1"; + const network = CHAIN_ID_NETWORK_MAP_OBJ[chainId]; + + // status reclassification + const status = reclassifyStatus(tx.status); + + const finalObject: SolanaTransactionActivity = { + action: ACTIVITY_ACTION_TOPUP, + status, + id: Number(tx.id), + from: tx.from, + to: tx.to, + rawDate: tx.date, + updatedAt: new Date(tx.date).valueOf(), + blockExplorerUrl: tx.solana?.signature + ? getSolanaTransactionLink(WALLET_SUPPORTED_NETWORKS[network].blockExplorerUrl, tx.solana.signature, chainId) + : "", + network, + chainId, + signature: tx.solana.signature || "", + // fee: parseFloat(tx.solana.fee), + type: tx.action, + decimal: tx.solana.decimal === undefined ? 9 : Number(tx.solana.decimal), + logoURI: "", + + currencyAmount: Number(tx.solana.currencyAmount), + currency: tx.currencyUsed, + cryptoAmount: Number(tx.solana.amount), // (tx.solana.decimal === undefined ? 1 : 10 ** Number(tx.solana.decimal)), + cryptoCurrency: tx.solana.symbol.toUpperCase(), + }; + finalObject.totalAmountString = (finalObject.cryptoAmount || 0).toString(); + + return finalObject; + } catch (e) { + log.error(e); + return undefined; + } +}; + +// Formatting Display Activity to Backend Format which will be used to update backend +export const formatTxToBackend = (tx: SolanaTransactionActivity, gaslessRelayer = ""): TransactionPayload => { + // For Unknown Transaction default cryptoAmount to 0, cryptoCurrency to SOL, transaction category to unknown + const finalObject: TransactionPayload = { + from: tx.from, + to: tx.to, + crypto_amount: tx.cryptoAmount?.toString() || "0", + crypto_currency: tx.cryptoCurrency || "SOL", + decimal: tx.decimal, + currency_amount: (tx.currencyAmount || 0).toString(), + selected_currency: (tx.currency || "").toUpperCase(), + status: tx.status, + signature: tx.signature, + fee: tx.fee?.toString() || "n/a", + network: tx.chainId, + created_at: tx.rawDate, + transaction_category: tx.type.toLowerCase(), + gasless: !!gaslessRelayer, + gasless_relayer_public_key: gaslessRelayer, + is_cancel: false, + mint_address: tx.mintAddress || "", + }; + return finalObject; +}; + +export function getChainIdToNetwork(chainId: string): string { + log.info(CHAIN_ID_NETWORK_MAP_OBJ); + return CHAIN_ID_NETWORK_MAP_OBJ[chainId]; +} diff --git a/src/utils/const.ts b/src/utils/const.ts index e771fdd8..c82634a8 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -1,6 +1,7 @@ +import { ProviderConfig } from "@toruslabs/base-controllers"; import { SUPPORTED_NETWORKS } from "@toruslabs/solana-controllers"; -export const WALLET_SUPPORTED_NETWORKS = { +export const WALLET_SUPPORTED_NETWORKS: { [key: string]: ProviderConfig } = { ...SUPPORTED_NETWORKS, mainnet: { ...SUPPORTED_NETWORKS.mainnet, diff --git a/src/utils/enums.ts b/src/utils/enums.ts index 2012f1d9..a3932292 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -24,6 +24,8 @@ import { SolanaNetworkState } from "@toruslabs/solana-controllers/dist/types/Net import { TokenInfoState, TokensInfoConfig } from "@toruslabs/solana-controllers/dist/types/Tokens/TokenInfoController"; import { TokensTrackerConfig, TokensTrackerState } from "@toruslabs/solana-controllers/dist/types/Tokens/TokensTrackerController"; +import { ActivitiesControllerConfig, ActivitiesControllerState } from "@/controllers/ActivitiesController"; + export const LOCAL_STORAGE_KEY = "localStorage"; export const SESSION_STORAGE_KEY = "sessionStorage"; export type STORAGE_TYPE = typeof LOCAL_STORAGE_KEY | typeof SESSION_STORAGE_KEY; @@ -68,6 +70,7 @@ export interface TorusControllerState extends BaseState { RelayMap: { [relay: string]: string }; RelayKeyHostMap: { [Pubkey: string]: string }; UserDapp: Map; + ActivitiesControllerState: ActivitiesControllerState; } export interface TorusControllerConfig extends BaseConfig { @@ -80,6 +83,7 @@ export interface TorusControllerConfig extends BaseConfig { TokensTrackerConfig: TokensTrackerConfig; TokensInfoConfig: TokensInfoConfig; RelayHost: { [relay: string]: string }; + ActivitiesControllerConfig: ActivitiesControllerConfig; } export const CONTROLLER_MODULE_KEY = "controllerModule"; From 55eec4b51f64449eda4eaf83150efd115e7d5ca3 Mon Sep 17 00:00:00 2001 From: ieow Date: Thu, 1 Sep 2022 17:30:31 +0800 Subject: [PATCH 2/6] fix: fix initialization fix test --- src/controllers/ActivitiesController.ts | 4 +++- tests/controller/controllerModule.test.ts | 15 ++------------- tests/controller/nockRequest.ts | 5 +++++ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/controllers/ActivitiesController.ts b/src/controllers/ActivitiesController.ts index 76706c1d..771cddef 100644 --- a/src/controllers/ActivitiesController.ts +++ b/src/controllers/ActivitiesController.ts @@ -165,6 +165,8 @@ export default class ActivitiesController extends BaseController { let popupResult = { approve: true }; let popupStub: sinon.SinonStub; - let spyPrefIntializeDisp: sinon.SinonSpy; - // init once only controllerModule.init({ state: cloneDeep(DEFAULT_STATE), origin: "https://localhost:8080/" }); @@ -91,7 +89,6 @@ describe("Controller Module", () => { log.info({ popupStub }); // add sinon method stubs & spies on Controllers and TorusController sandbox.stub(NetworkController.prototype, "getConnection").callsFake(mockGetConnection); - spyPrefIntializeDisp = sandbox.spy(PreferencesController.prototype, "initializeDisplayActivity"); // addToStub = sandbox.spy(app.value.toastMessages, "addToast"); // init @@ -316,8 +313,6 @@ describe("Controller Module", () => { await controllerModule.triggerLogin({ loginProvider: "google" }); assert.equal(controllerModule.torusState.KeyringControllerState.wallets.length, 1); - assert(spyPrefIntializeDisp.calledOnce); - log.info(sKeyPair[3]); // await controllerModule.torus.loginWithPrivateKey(base58.encode(sKeyPair[3].secretKey)); // validate state @@ -556,10 +551,7 @@ describe("Controller Module", () => { it("embed sendTransaction flow", async () => { const tx = new Transaction({ recentBlockhash: sKeyPair[0].publicKey.toBase58(), feePayer: sKeyPair[0].publicKey }); // Transaction.serialize const msg = tx.add(transferInstruction()).serialize({ requireAllSignatures: false }).toString("hex"); - assert.equal( - Object.keys(controllerModule.torusState.PreferencesControllerState.identities[sKeyPair[0].publicKey.toBase58()].displayActivities).length, - 0 - ); + assert.equal(Object.keys(controllerModule.selectedNetworkTransactions).length, 0); // validate state before const result = await controllerModule.torus.provider.sendAsync({ method: "send_transaction", @@ -569,10 +561,7 @@ describe("Controller Module", () => { }); // validate state after - assert.equal( - Object.keys(controllerModule.torusState.PreferencesControllerState.identities[sKeyPair[0].publicKey.toBase58()].displayActivities).length, - 1 - ); + assert.equal(Object.keys(controllerModule.selectedNetworkTransactions).length, 1); // log.error(result); tx.sign(sKeyPair[0]); diff --git a/tests/controller/nockRequest.ts b/tests/controller/nockRequest.ts index 47f9e8c1..87257f29 100644 --- a/tests/controller/nockRequest.ts +++ b/tests/controller/nockRequest.ts @@ -99,6 +99,11 @@ export default () => { nockBackend.post("/transaction").reply(200, () => JSON.stringify(mockData.backend.transaction)); + nockBackend.get("/transaction").reply(200, () => JSON.stringify(mockData.backend.transaction)); + + const nockCommonApiBackend = nock("https://common-api.tor.us").persist(); + + nockCommonApiBackend.get("/transaction").reply(200, () => JSON.stringify([])); // api.mainnet-beta nock // nock("https://api.mainnet-beta.solana.com") nock(WALLET_SUPPORTED_NETWORKS.mainnet.rpcTarget) From 41f61a957b036637718d87b825b0c99c24b389a2 Mon Sep 17 00:00:00 2001 From: ieow Date: Mon, 5 Sep 2022 11:24:33 +0800 Subject: [PATCH 3/6] fix: display topup activites update state variable name fix initialization and fullrefresh --- src/components/activity/ActivityItem.vue | 11 ++- src/controllers/ActivitiesController.ts | 105 +++++++++++------------ src/controllers/IActivitiesController.ts | 17 +++- src/controllers/TorusController.ts | 6 +- src/modules/controllers.ts | 16 +++- src/utils/enums.ts | 8 +- 6 files changed, 101 insertions(+), 62 deletions(-) diff --git a/src/components/activity/ActivityItem.vue b/src/components/activity/ActivityItem.vue index d5e28f3d..62c9524c 100644 --- a/src/components/activity/ActivityItem.vue +++ b/src/components/activity/ActivityItem.vue @@ -1,5 +1,5 @@ + From 37859225bc48bbab7e67dba886b5aadf6d71323f Mon Sep 17 00:00:00 2001 From: Guru Ramu Date: Wed, 26 Oct 2022 11:34:11 +0530 Subject: [PATCH 5/6] feat: vtx --- src/controllers/ActivitiesController.ts | 6 ++++-- src/utils/activitiesHelper.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/controllers/ActivitiesController.ts b/src/controllers/ActivitiesController.ts index 32f36465..4ecfa3da 100644 --- a/src/controllers/ActivitiesController.ts +++ b/src/controllers/ActivitiesController.ts @@ -146,7 +146,10 @@ export default class ActivitiesController extends BaseController s.signature)); + const onChainTransactions = await connection.getParsedTransactions( + signatureInfo.map((s) => s.signature), + { maxSupportedTransactionVersion: 1 } + ); const temp = formatTransactionToActivity({ transactions: onChainTransactions, @@ -180,7 +183,6 @@ export default class ActivitiesController extends BaseController { if (this.state.loading && !initialActivities) return; - this.update({ loading: true }); log.info("refreshing"); const selectedAddress = this.getSelectedAddress(); diff --git a/src/utils/activitiesHelper.ts b/src/utils/activitiesHelper.ts index 8cfd45f5..34620209 100644 --- a/src/utils/activitiesHelper.ts +++ b/src/utils/activitiesHelper.ts @@ -6,7 +6,8 @@ import { ParsedTransactionWithMeta, SystemInstruction, SystemProgram, - Transaction, + TransactionMessage, + VersionedTransaction, } from "@solana/web3.js"; import { ACTIVITY_ACTION_BURN, @@ -159,7 +160,7 @@ export const formatTransactionToActivity = (params: { // Formatting a Transaction (From Transaction Controller) to Display Activity Format export const formatNewTxToActivity = ( - tx: TransactionMeta, + tx: TransactionMeta, currencyData: { selectedCurrency: string; conversionRate: number }, selectedAddress: string, blockExplorerUrl: string, @@ -191,8 +192,9 @@ export const formatNewTxToActivity = ( // Check for decodable instruction (SOL transfer) // Expect SOL transfer to have only 1 instruction in 1 transaction - if (tx.transaction.instructions.length === 1) { - const instruction1 = tx.transaction.instructions[0]; + const { instructions } = TransactionMessage.decompile(tx.transaction.message); + if (instructions.length === 1) { + const instruction1 = instructions[0]; if (SystemProgram.programId.equals(instruction1.programId) && SystemInstruction.decodeInstructionType(instruction1) === "Transfer") { const parsedInst = SystemInstruction.decodeTransfer(instruction1); From 47cfbcac9f0a50dc8d294678363825ee6fbd881a Mon Sep 17 00:00:00 2001 From: Guru Ramu Date: Mon, 31 Oct 2022 11:17:50 +0530 Subject: [PATCH 6/6] fix: chainid mismatch txs --- src/modules/controllers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/controllers.ts b/src/modules/controllers.ts index 70ad751f..5c30b005 100644 --- a/src/modules/controllers.ts +++ b/src/modules/controllers.ts @@ -100,7 +100,8 @@ class ControllerModule extends VuexModule { } get selectedNetworkTransactions(): SolanaTransactionActivity[] { - const txns = Object.values(this.torusState.ActivitiesControllerState.accounts[this.selectedAddress]?.activities); + let txns = Object.values(this.torusState.ActivitiesControllerState.accounts[this.selectedAddress]?.activities); + txns = txns.filter((txn) => txn.chainId === this.torus.chainId); return txns.map((item) => { // Top up if (item.action === ACTIVITY_ACTION_TOPUP) {