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 @@ + diff --git a/src/utils/activitiesHelper.ts b/src/utils/activitiesHelper.ts new file mode 100644 index 00000000..34620209 --- /dev/null +++ b/src/utils/activitiesHelper.ts @@ -0,0 +1,369 @@ +import { ASSOCIATED_TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { + ConfirmedSignatureInfo, + LAMPORTS_PER_SOL, + ParsedInstruction, + ParsedTransactionWithMeta, + SystemInstruction, + SystemProgram, + TransactionMessage, + VersionedTransaction, +} 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 + 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); + + 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 9c69d9b7..ac1dccbd 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/IActivitiesController"; + 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"; @@ -354,3 +358,9 @@ export enum SORT_SPL_TOKEN { TOKEN_CURRENCY_VALUE = "token_currency_value", TOKEN_AMOUNT = "noOfTokens", } + +export enum CHAINID { + MAINNET = "0x1", + TESTNET = "0x2", + DEVNET = "0x3", +} diff --git a/tests/controller/controllerModule.test.ts b/tests/controller/controllerModule.test.ts index 89381cd8..77593d98 100644 --- a/tests/controller/controllerModule.test.ts +++ b/tests/controller/controllerModule.test.ts @@ -45,8 +45,6 @@ describe("Controller Module", () => { 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/" }); @@ -90,7 +88,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 @@ -315,8 +312,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 @@ -596,10 +591,7 @@ describe("Controller Module", () => { })) as string; // 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); transactionV0.sign([sKeyPair[0]]); diff --git a/tests/controller/mockConnection.ts b/tests/controller/mockConnection.ts index 62dc840a..3e844285 100644 --- a/tests/controller/mockConnection.ts +++ b/tests/controller/mockConnection.ts @@ -1,6 +1,6 @@ import { Creator, Metadata, MetadataData, MetadataDataData } from "@metaplex-foundation/mpl-token-metadata"; import { Mint, MintLayout, RawMint, TOKEN_PROGRAM_ID } from "@solana/spl-token"; -import { AccountInfo, Commitment, Connection, Message, ParsedAccountData, PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; +import { AccountInfo, Commitment, Connection, Message, ParsedAccountData, PublicKey, VersionedTransaction } from "@solana/web3.js"; import base58 from "bs58"; import crypto from "crypto"; import log from "loglevel"; @@ -375,13 +375,6 @@ export const mockConnection: Partial = { }; }, - simulateTransaction: async (_transactionOrMessage: Transaction | VersionedTransaction | Message) => { - return { - context: { slot: slotCounter }, - value: mockSimulateTransaction, - }; - }, - getParsedAccountInfo: async (accountAddress: PublicKey) => { const tokenOwned = parsedTokenAccountInfo.find((item) => { return item.pubkey === accountAddress; diff --git a/tests/controller/nockRequest.ts b/tests/controller/nockRequest.ts index 5f5f16ce..9c3d416f 100644 --- a/tests/controller/nockRequest.ts +++ b/tests/controller/nockRequest.ts @@ -107,6 +107,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)