diff --git a/explorer/client/src/__tests__/e2e.test.ts b/explorer/client/src/__tests__/e2e.test.ts index d00a2aa8aff41..afe4345417d56 100644 --- a/explorer/client/src/__tests__/e2e.test.ts +++ b/explorer/client/src/__tests__/e2e.test.ts @@ -402,4 +402,16 @@ describe('End-to-end Tests', () => { ).toBe('Balance200'); }); }); + describe('Transactions for ID', () => { + it('are displayed deduplicated from and to', async () => { + const address = 'ownsAllAddress'; + await page.goto(`${BASE_URL}/addresses/${address}`); + const fromResults = await cssInteract(page) + .with('#tx') + .get.textContent(); + expect(fromResults.replace(/\s/g, '')).toBe( + 'TxIdTxTypeStatusAddressesDa4vHc9IwbvOYblE8LnrVsqXwryt2Kmms+xnJ7Zx5E4=Transfer\u2714From:senderAddressTo:receiv...dressGHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8=Transfer\u2716From:senderAddressTo:receiv...dressXHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8=Transfer\u2714From:senderAddressTo:receiv...dress' + ); + }); + }); }); diff --git a/explorer/client/src/components/transaction-card/RecentTxCard.tsx b/explorer/client/src/components/transaction-card/RecentTxCard.tsx index d701442f0d80b..f0d1f3ac28dfd 100644 --- a/explorer/client/src/components/transaction-card/RecentTxCard.tsx +++ b/explorer/client/src/components/transaction-card/RecentTxCard.tsx @@ -1,14 +1,6 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { - getExecutionStatusType, - getTotalGasUsed, - getTransactions, - getTransactionDigest, - getTransactionKindName, - getTransferCoinTransaction, -} from '@mysten/sui.js'; import cl from 'classnames'; import { useEffect, useState, useContext } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; @@ -19,16 +11,16 @@ import theme from '../../styles/theme.module.css'; import { DefaultRpcClient as rpc, type Network, + getDataOnTxDigests, } from '../../utils/api/DefaultRpcClient'; import { IS_STATIC_ENV } from '../../utils/envUtil'; import { getAllMockTransaction } from '../../utils/static/searchUtil'; +import { truncate } from '../../utils/stringUtils'; import ErrorResult from '../error-result/ErrorResult'; import Pagination from '../pagination/Pagination'; import type { - CertifiedTransaction, GetTxnDigestsResponse, - TransactionEffectsResponse, ExecutionStatusType, TransactionKindName, } from '@mysten/sui.js'; @@ -88,81 +80,19 @@ async function getRecentTransactions( if (endGatewayTxSeqNumber < 0) { throw new Error('Invalid transaction number'); } - const transactions = await rpc(network) + return (await rpc(network) .getTransactionDigestsInRange( startGatewayTxSeqNumber, endGatewayTxSeqNumber ) - .then((res: GetTxnDigestsResponse) => res); - - const digests = transactions.map((tx) => tx[1]); - - const txLatest = await rpc(network) - .getTransactionWithEffectsBatch(digests) - .then((txEffs: TransactionEffectsResponse[]) => { - return txEffs.map((txEff, i) => { - const [seq, digest] = transactions.filter( - (transactionId) => - transactionId[1] === - getTransactionDigest(txEff.certificate) - )[0]; - const res: CertifiedTransaction = txEff.certificate; - // TODO: handle multiple transactions - const txns = getTransactions(res); - if (txns.length > 1) { - console.error( - 'Handling multiple transactions is not yet supported', - txEff - ); - return null; - } - const txn = txns[0]; - const txKind = getTransactionKindName(txn); - const recipient = - getTransferCoinTransaction(txn)?.recipient; - - return { - seq, - txId: digest, - status: getExecutionStatusType(txEff), - txGas: getTotalGasUsed(txEff), - kind: txKind, - From: res.data.sender, - ...(recipient - ? { - To: recipient, - } - : {}), - }; - }); - }); - - // Remove failed transactions and sort by sequence number - return txLatest - .filter((itm) => itm) - .sort((a, b) => b!.seq - a!.seq) as TxnData[]; + .then((res: GetTxnDigestsResponse) => + getDataOnTxDigests(network, res) + )) as TxnData[]; } catch (error) { throw error; } } -function truncate(fullStr: string, strLen: number, separator: string) { - if (fullStr.length <= strLen) return fullStr; - - separator = separator || '...'; - - const sepLen = separator.length, - charsToShow = strLen - sepLen, - frontChars = Math.ceil(charsToShow / 2), - backChars = Math.floor(charsToShow / 2); - - return ( - fullStr.substr(0, frontChars) + - separator + - fullStr.substr(fullStr.length - backChars) - ); -} - function LatestTxView({ results, }: { @@ -211,7 +141,7 @@ function LatestTxView({ styles.txstatus )} > - {tx.status === 'success' ? '✔' : '✖'} + {tx.status === 'success' ? '\u2714' : '\u2716'}
{tx.txGas}
diff --git a/explorer/client/src/components/transactions-for-id/TxForID.module.css b/explorer/client/src/components/transactions-for-id/TxForID.module.css new file mode 100644 index 0000000000000..4aad411667144 --- /dev/null +++ b/explorer/client/src/components/transactions-for-id/TxForID.module.css @@ -0,0 +1,37 @@ +.txheader { + @apply hidden bg-offblack text-offwhite py-2; +} + +.txheader, +.txrow { + @apply md:flex; +} + +.txheader > div, +.txrow > div { + @apply md:ml-[2vw]; +} + +.txid { + @apply md:w-[35vw]; +} + +.txtype { + @apply md:w-[10vw]; +} + +.txstatus { + @apply md:w-[5vw]; +} + +.txadd a { + @apply no-underline text-sky-600 hover:text-sky-900 cursor-pointer break-all; +} + +.failure { + @apply text-red-300; +} + +.success { + @apply text-green-400; +} diff --git a/explorer/client/src/components/transactions-for-id/TxForID.tsx b/explorer/client/src/components/transactions-for-id/TxForID.tsx new file mode 100644 index 0000000000000..d70fa8f67a41b --- /dev/null +++ b/explorer/client/src/components/transactions-for-id/TxForID.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2022, Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + type GetTxnDigestsResponse, + type ExecutionStatusType, + type TransactionKindName, +} from '@mysten/sui.js'; +import cl from 'classnames'; +import { useState, useEffect, useContext } from 'react'; +import { Link } from 'react-router-dom'; + +import { NetworkContext } from '../../context'; +import { + DefaultRpcClient as rpc, + getDataOnTxDigests, +} from '../../utils/api/DefaultRpcClient'; +import { IS_STATIC_ENV } from '../../utils/envUtil'; +import { deduplicate } from '../../utils/searchUtil'; +import { findTxfromID, findTxDatafromID } from '../../utils/static/searchUtil'; +import { truncate } from '../../utils/stringUtils'; +import ErrorResult from '../error-result/ErrorResult'; +import Longtext from '../longtext/Longtext'; + +import styles from './TxForID.module.css'; + +const DATATYPE_DEFAULT = { + loadState: 'pending', +}; + +type TxnData = { + seq: number; + txId: string; + status: ExecutionStatusType; + kind: TransactionKindName | undefined; + From: string; + To?: string; +}; + +const getTx = async ( + id: string, + network: string, + category: 'address' +): Promise => rpc(network).getTransactionsForAddress(id); + +function TxForIDView({ showData }: { showData: TxnData[] | undefined }) { + if (!showData || showData.length === 0) return <>; + + return ( + <> +
+
Transactions
+
+
+
TxId
+
TxType
+
Status
+
Addresses
+
+ + {showData.map((x, index) => ( +
+
+ +
+
{x.kind}
+
+ {x.status === 'success' ? '\u2714' : '\u2716'} +
+
+
+ From: + + {truncate(x.From, 14, '...')} + +
+ {x.To && ( +
+ To : + + {truncate(x.To, 14, '...')} + +
+ )} +
+
+ ))} +
+
+ + ); +} + +function TxForIDStatic({ id, category }: { id: string; category: 'address' }) { + const data = deduplicate( + findTxfromID(id)?.data as [number, string][] | undefined + ) + .map((id) => findTxDatafromID(id)) + .filter((x) => x !== undefined) as TxnData[]; + if (!data) return <>; + return ; +} + +function TxForIDAPI({ id, category }: { id: string; category: 'address' }) { + const [showData, setData] = + useState<{ data?: TxnData[]; loadState: string }>(DATATYPE_DEFAULT); + const [network] = useContext(NetworkContext); + useEffect(() => { + getTx(id, network, category).then((transactions) => { + //If the API method does not exist, the transactions will be undefined + if (!transactions?.[0]) { + setData({ + loadState: 'loaded', + }); + } else { + getDataOnTxDigests(network, transactions) + .then((data) => { + const subData = data.map((el) => ({ + seq: el!.seq, + txId: el!.txId, + status: el!.status, + kind: el!.kind, + From: el!.From, + To: el!.To, + })); + setData({ + data: subData, + loadState: 'loaded', + }); + }) + .catch((error) => { + console.log(error); + setData({ ...DATATYPE_DEFAULT, loadState: 'fail' }); + }); + } + }); + }, [id, network, category]); + + if (showData.loadState === 'pending') { + return
Loading ...
; + } + + if (showData.loadState === 'loaded') { + const data = showData.data; + return ; + } + + return ( + + ); +} + +export default function TxForID({ + id, + category, +}: { + id: string; + category: 'address'; +}) { + return IS_STATIC_ENV ? ( + + ) : ( + + ); +} diff --git a/explorer/client/src/pages/address-result/AddressResult.tsx b/explorer/client/src/pages/address-result/AddressResult.tsx index 111ffe4782199..ca5fa0e3f061e 100644 --- a/explorer/client/src/pages/address-result/AddressResult.tsx +++ b/explorer/client/src/pages/address-result/AddressResult.tsx @@ -6,6 +6,7 @@ import { useParams } from 'react-router-dom'; import ErrorResult from '../../components/error-result/ErrorResult'; import Longtext from '../../components/longtext/Longtext'; import OwnedObjects from '../../components/ownedobjects/OwnedObjects'; +import TxForID from '../../components/transactions-for-id/TxForID'; import theme from '../../styles/theme.module.css'; type DataType = { @@ -38,6 +39,7 @@ function AddressResult() { />
+
Owned Objects
diff --git a/explorer/client/src/utils/api/DefaultRpcClient.ts b/explorer/client/src/utils/api/DefaultRpcClient.ts index 042fecc537f21..ea8f2ae969919 100644 --- a/explorer/client/src/utils/api/DefaultRpcClient.ts +++ b/explorer/client/src/utils/api/DefaultRpcClient.ts @@ -1,10 +1,25 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { JsonRpcProvider } from '@mysten/sui.js'; +import { + getExecutionStatusType, + getTotalGasUsed, + getTransactions, + getTransactionDigest, + getTransactionKindName, + getTransferCoinTransaction, + JsonRpcProvider, +} from '@mysten/sui.js'; +import { deduplicate } from '../searchUtil'; import { getEndpoint, Network } from './rpcSetting'; +import type { + CertifiedTransaction, + TransactionEffectsResponse, + GetTxnDigestsResponse, +} from '@mysten/sui.js'; + // TODO: Remove these types with SDK types export type AddressBytes = number[]; export type AddressOwner = { AddressOwner: AddressBytes }; @@ -16,3 +31,53 @@ export { Network, getEndpoint }; export const DefaultRpcClient = (network: Network | string) => new JsonRpcProvider(getEndpoint(network)); + +export const getDataOnTxDigests = ( + network: Network | string, + transactions: GetTxnDigestsResponse +) => + DefaultRpcClient(network) + .getTransactionWithEffectsBatch(deduplicate(transactions)) + .then((txEffs: TransactionEffectsResponse[]) => { + return ( + txEffs + .map((txEff, i) => { + const [seq, digest] = transactions.filter( + (transactionId) => + transactionId[1] === + getTransactionDigest(txEff.certificate) + )[0]; + const res: CertifiedTransaction = txEff.certificate; + // TODO: handle multiple transactions + const txns = getTransactions(res); + if (txns.length > 1) { + console.error( + 'Handling multiple transactions is not yet supported', + txEff + ); + return null; + } + const txn = txns[0]; + const txKind = getTransactionKindName(txn); + const recipient = + getTransferCoinTransaction(txn)?.recipient; + + return { + seq, + txId: digest, + status: getExecutionStatusType(txEff), + txGas: getTotalGasUsed(txEff), + kind: txKind, + From: res.data.sender, + ...(recipient + ? { + To: recipient, + } + : {}), + }; + }) + // Remove failed transactions and sort by sequence number + .filter((itm) => itm) + .sort((a, b) => b!.seq - a!.seq) + ); + }); diff --git a/explorer/client/src/utils/searchUtil.ts b/explorer/client/src/utils/searchUtil.ts index 38f61df0ae04f..1b0e3cd49c9d0 100644 --- a/explorer/client/src/utils/searchUtil.ts +++ b/explorer/client/src/utils/searchUtil.ts @@ -1,5 +1,11 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +const deduplicate = (results: [number, string][] | undefined) => + results + ? results + .map((result) => result[1]) + .filter((value, index, self) => self.indexOf(value) === index) + : []; let navigateWithUnknown: Function; let overrideTypeChecks = false; @@ -15,4 +21,4 @@ if (process.env.REACT_APP_DATA === 'static') { ); } -export { navigateWithUnknown, overrideTypeChecks }; +export { navigateWithUnknown, overrideTypeChecks, deduplicate }; diff --git a/explorer/client/src/utils/static/latest_transactions.json b/explorer/client/src/utils/static/latest_transactions.json index 5c4f3d93d9dcd..742446e0a34f6 100644 --- a/explorer/client/src/utils/static/latest_transactions.json +++ b/explorer/client/src/utils/static/latest_transactions.json @@ -5,7 +5,7 @@ "To": "receiverAddress", "kind": "Transfer", "seq": 7787, - "status": "Success", + "status": "success", "txGas": 41, "txId": "Da4vHc9IwbvOYblE8LnrVsqXwryt2Kmms+xnJ7Zx5E4=" }, @@ -14,9 +14,18 @@ "To": "receiverAddress", "kind": "Transfer", "seq": 7787, - "status": "Failure", + "status": "failure", "txGas": 41, "txId": "GHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8=" + }, + { + "From": "senderAddress", + "To": "receiverAddress", + "kind": "Transfer", + "seq": 7787, + "status": "success", + "txGas": 41, + "txId": "XHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8=" } ] } diff --git a/explorer/client/src/utils/static/searchUtil.ts b/explorer/client/src/utils/static/searchUtil.ts index 440a13363cd1d..217bcb038ac4b 100644 --- a/explorer/client/src/utils/static/searchUtil.ts +++ b/explorer/client/src/utils/static/searchUtil.ts @@ -4,6 +4,7 @@ import latestTxData from './latest_transactions.json'; import mockData from './mock_data.json'; import mockOwnedObjectData from './owned_object.json'; +import mockTxData from './tx_for_id.json'; const navigateWithUnknown = async ( input: string, @@ -34,9 +35,17 @@ const findOwnedObjectsfromID = (targetID: string | undefined) => const getAllMockTransaction = () => latestTxData.data; +const findTxfromID = (targetID: string | undefined) => + mockTxData!.data!.find(({ id }) => id === targetID); + +const findTxDatafromID = (targetID: string | undefined) => + latestTxData!.data!.find(({ txId }) => txId === targetID); + export { findDataFromID, navigateWithUnknown, findOwnedObjectsfromID, + findTxfromID, + findTxDatafromID, getAllMockTransaction, }; diff --git a/explorer/client/src/utils/static/tx_for_id.json b/explorer/client/src/utils/static/tx_for_id.json new file mode 100644 index 0000000000000..8c32769d6bb50 --- /dev/null +++ b/explorer/client/src/utils/static/tx_for_id.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": "ownsAllAddress", + "data": [ + [0, "Da4vHc9IwbvOYblE8LnrVsqXwryt2Kmms+xnJ7Zx5E4="], + [1, "Da4vHc9IwbvOYblE8LnrVsqXwryt2Kmms+xnJ7Zx5E4="], + [0, "GHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8="], + [1, "GHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8="], + [2, "Da4vHc9IwbvOYblE8LnrVsqXwryt2Kmms+xnJ7Zx5E4="], + [3, "GHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8="], + [4, "XHTP9gcFmF5KTspnz3KxXjvSH8Bx0jv68KFhdqfpdK8="] + ] + } + ] +} diff --git a/explorer/client/src/utils/stringUtils.ts b/explorer/client/src/utils/stringUtils.ts index 4e53fde9d2f48..99ee995a903d0 100644 --- a/explorer/client/src/utils/stringUtils.ts +++ b/explorer/client/src/utils/stringUtils.ts @@ -40,6 +40,23 @@ function transformURL(url: string) { return `https://ipfs.io/ipfs/${found[1]}`; } +export function truncate(fullStr: string, strLen: number, separator: string) { + if (fullStr.length <= strLen) return fullStr; + + separator = separator || '...'; + + const sepLen = separator.length, + charsToShow = strLen - sepLen, + frontChars = Math.ceil(charsToShow / 2), + backChars = Math.floor(charsToShow / 2); + + return ( + fullStr.substr(0, frontChars) + + separator + + fullStr.substr(fullStr.length - backChars) + ); +} + /* Currently unused but potentially useful: * * export const isValidHttpUrl = (url: string) => { diff --git a/sdk/typescript/src/providers/json-rpc-provider.ts b/sdk/typescript/src/providers/json-rpc-provider.ts index 7fd06eca44176..9eaf2b8a4dbf4 100644 --- a/sdk/typescript/src/providers/json-rpc-provider.ts +++ b/sdk/typescript/src/providers/json-rpc-provider.ts @@ -8,7 +8,7 @@ import { isGetOwnedObjectsResponse, isGetTxnDigestsResponse, isTransactionEffectsResponse, - isTransactionResponse, + isTransactionResponse } from '../index.guard'; import { GatewayTxSeqNumber, @@ -17,7 +17,7 @@ import { SuiObjectInfo, TransactionDigest, TransactionEffectsResponse, - TransactionResponse, + TransactionResponse } from '../types'; const isNumber = (val: any): val is number => typeof val === 'number'; @@ -91,6 +91,30 @@ export class JsonRpcProvider extends Provider { } } + //Addresses + async getTransactionsForAddress(addressID: string): Promise { + const requests = [ + { + method: 'sui_getTransactionsToAddress', + args: [addressID] + }, + { + method: 'sui_getTransactionsFromAddress', + args: [addressID] + } + ] + + try { + const results = await this.client.batchRequestWithType( + requests, + isGetTxnDigestsResponse + ) + return [...results[0], ...results[1]]; + } catch (err) { + throw new Error(`Error getting transactions for address: ${err} for id ${addressID}`) + } + } + // Transactions async getTransactionWithEffects( digest: TransactionDigest