diff --git a/api/src/initServices.ts b/api/src/initServices.ts index 93f93bf15..b16e3b8ec 100644 --- a/api/src/initServices.ts +++ b/api/src/initServices.ts @@ -22,11 +22,12 @@ import { LegacyStatsService } from "./services/legacy/legacyStatsService"; import { ZmqService } from "./services/legacy/zmqService"; import { LocalStorageService } from "./services/localStorageService"; import { NetworkService } from "./services/networkService"; +import { ChronicleService as ChronicleServiceNova } from "./services/nova/chronicleService"; import { NovaFeed } from "./services/nova/feed/novaFeed"; import { NodeInfoService as NodeInfoServiceNova } from "./services/nova/nodeInfoService"; import { NovaApiService } from "./services/nova/novaApiService"; import { NovaStatsService } from "./services/nova/stats/novaStatsService"; -import { ChronicleService } from "./services/stardust/chronicleService"; +import { ChronicleService as ChronicleServiceStardust } from "./services/stardust/chronicleService"; import { StardustFeed } from "./services/stardust/feed/stardustFeed"; import { InfluxDBService } from "./services/stardust/influx/influxDbService"; import { NodeInfoService as NodeInfoServiceStardust } from "./services/stardust/nodeInfoService"; @@ -170,7 +171,7 @@ function initStardustServices(networkConfig: INetwork): void { // Related: https://github.com/iotaledger/inx-chronicle/issues/1302 stardustClientParams.ignoreNodeHealth = true; - const chronicleService = new ChronicleService(networkConfig); + const chronicleService = new ChronicleServiceStardust(networkConfig); ServiceFactory.register(`chronicle-${networkConfig.network}`, () => chronicleService); } @@ -221,7 +222,7 @@ function initNovaServices(networkConfig: INetwork): void { }; novaClientParams.primaryNodes.push(chronicleNode); - const chronicleService = new ChronicleService(networkConfig); + const chronicleService = new ChronicleServiceNova(networkConfig); ServiceFactory.register(`chronicle-${networkConfig.network}`, () => chronicleService); } diff --git a/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts index 32f275a9f..d8615fa61 100644 --- a/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts +++ b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts @@ -1,15 +1,25 @@ import { IResponse } from "../../IResponse"; +interface IManaBalance { + stored: number; + potential: number; +} + +interface IBalance { + amount: number; + mana: IManaBalance; +} + export interface IAddressBalanceResponse extends IResponse { /** * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) */ - totalBalance?: number; + totalBalance?: IBalance; /** * The balance of all spendable outputs by the address at this time. */ - availableBalance?: number; + availableBalance?: IBalance; /** * The ledger index at which this balance data was valid. diff --git a/api/src/models/api/nova/chronicle/ITransactionHistoryRequest.ts b/api/src/models/api/nova/chronicle/ITransactionHistoryRequest.ts new file mode 100644 index 000000000..fa8437df6 --- /dev/null +++ b/api/src/models/api/nova/chronicle/ITransactionHistoryRequest.ts @@ -0,0 +1,34 @@ +/** + * The request for Transaction History on nova. + */ +export interface ITransactionHistoryRequest { + /** + * The network in context. + */ + network: string; + + /** + * The address to get the history for. + */ + address: string; + + /** + * The page size of the request (default is 100). + */ + pageSize?: number; + + /** + * The sort by date to use. + */ + sort?: string; + + /** + * The lower bound slot index to use. + */ + startSlotIndex?: number; + + /** + * The cursor state for the request. + */ + cursor?: string; +} diff --git a/api/src/models/api/nova/chronicle/ITransactionHistoryResponse.ts b/api/src/models/api/nova/chronicle/ITransactionHistoryResponse.ts new file mode 100644 index 000000000..422dd6c4a --- /dev/null +++ b/api/src/models/api/nova/chronicle/ITransactionHistoryResponse.ts @@ -0,0 +1,41 @@ +import { IResponse } from "../../IResponse"; + +/** + * A transaction history item. + */ +export interface ITransactionHistoryItem { + /** + * The slot index this item is included in. + */ + slotIndex: number; + + /** + * The outputId. + */ + outputId: string; + + /** + * Is the output spent. + */ + isSpent: boolean; +} + +/* + * The transaction history response. + */ +export interface ITransactionHistoryResponse extends IResponse { + /** + * Address the history is for. + */ + address?: string; + + /** + * The history items. + */ + items?: ITransactionHistoryItem[]; + + /** + * The cursor for next request. + */ + cursor?: string; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 725bda925..13ac5518a 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -242,6 +242,12 @@ export const routes: IRoute[] = [ folder: "nova/account/foundries", func: "get", }, + { + path: "/nova/transactionhistory/:network/:address", + method: "get", + folder: "nova/transactionhistory", + func: "get", + }, { path: "/nova/transaction/:network/:transactionId", method: "get", diff --git a/api/src/routes/nova/transactionhistory/get.ts b/api/src/routes/nova/transactionhistory/get.ts new file mode 100644 index 000000000..5239ec849 --- /dev/null +++ b/api/src/routes/nova/transactionhistory/get.ts @@ -0,0 +1,34 @@ +import { ServiceFactory } from "../../../factories/serviceFactory"; +import { ITransactionHistoryRequest } from "../../../models/api/nova/chronicle/ITransactionHistoryRequest"; +import { ITransactionHistoryResponse } from "../../../models/api/nova/chronicle/ITransactionHistoryResponse"; +import { IConfiguration } from "../../../models/configuration/IConfiguration"; +import { NOVA } from "../../../models/db/protocolVersion"; +import { NetworkService } from "../../../services/networkService"; +import { ChronicleService } from "../../../services/nova/chronicleService"; +import { ValidationHelper } from "../../../utils/validationHelper"; + +/** + * Fetch the transaction history from chronicle nova. + * @param config The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(config: IConfiguration, request: ITransactionHistoryRequest): Promise { + const networkService = ServiceFactory.get("network"); + const networks = networkService.networkNames(); + ValidationHelper.oneOf(request.network, networks, "network"); + + const networkConfig = networkService.get(request.network); + + if (networkConfig.protocolVersion !== NOVA) { + return {}; + } + + if (!networkConfig.permaNodeEndpoint) { + return {}; + } + + const chronicleService = ServiceFactory.get(`chronicle-${networkConfig.network}`); + + return chronicleService.transactionHistory(request); +} diff --git a/api/src/services/nova/chronicleService.ts b/api/src/services/nova/chronicleService.ts index 8fc0274ff..6d880e0c4 100644 --- a/api/src/services/nova/chronicleService.ts +++ b/api/src/services/nova/chronicleService.ts @@ -1,9 +1,12 @@ import logger from "../../logger"; import { IAddressBalanceResponse } from "../../models/api/nova/chronicle/IAddressBalanceResponse"; +import { ITransactionHistoryRequest } from "../../models/api/nova/chronicle/ITransactionHistoryRequest"; +import { ITransactionHistoryResponse } from "../../models/api/nova/chronicle/ITransactionHistoryResponse"; import { INetwork } from "../../models/db/INetwork"; import { FetchHelper } from "../../utils/fetchHelper"; const CHRONICLE_ENDPOINTS = { + updatesByAddress: "/api/explorer/v3/ledger/updates/by-address/", balance: "/api/explorer/v3/balance/", }; @@ -40,4 +43,29 @@ export class ChronicleService { logger.warn(`[ChronicleService (Nova)] Failed fetching address balance for ${address} on ${network}. Cause: ${error}`); } } + + /** + * Get the transaction history of an address. + * @param request The ITransactionHistoryRequest. + * @returns The history reponse. + */ + public async transactionHistory(request: ITransactionHistoryRequest): Promise { + try { + const params = { + pageSize: request.pageSize, + sort: request.sort, + startSlotIndex: request.startSlotIndex, + cursor: request.cursor, + }; + + return await FetchHelper.json( + this.chronicleEndpoint, + `${CHRONICLE_ENDPOINTS.updatesByAddress}${request.address}${params ? `${FetchHelper.urlParams(params)}` : ""}`, + "get", + ); + } catch (error) { + const network = this.networkConfig.network; + logger.warn(`[ChronicleService] Failed fetching tx history for ${request.address} on ${network}. Cause: ${error}`); + } + } } diff --git a/client/src/app/components/nova/address/AccountAddressView.tsx b/client/src/app/components/nova/address/AccountAddressView.tsx index 51ec141d5..524a1cd8b 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -49,6 +49,8 @@ const AccountAddressView: React.FC = ({ accountAddress key={addressDetails.bech32} addressState={state} setAssociatedOutputsLoading={(val) => setState({ isAssociatedOutputsLoading: val })} + setTransactionHistoryLoading={(isLoading) => setState({ isAddressHistoryLoading: isLoading })} + setTransactionHistoryDisabled={(val) => setState({ isAddressHistoryDisabled: val })} /> )} diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index c07fd99d2..da36bc8f8 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -49,6 +49,8 @@ const AnchorAddressView: React.FC = ({ anchorAddress }) key={addressDetails.bech32} addressState={state} setAssociatedOutputsLoading={(val) => setState({ isAssociatedOutputsLoading: val })} + setTransactionHistoryLoading={(isLoading) => setState({ isAddressHistoryLoading: isLoading })} + setTransactionHistoryDisabled={(val) => setState({ isAddressHistoryDisabled: val })} /> )} diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 375adcfa8..97131eaf1 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -49,6 +49,8 @@ const Ed25519AddressView: React.FC = ({ ed25519Address key={addressDetails.bech32} addressState={state} setAssociatedOutputsLoading={(val) => setState({ isAssociatedOutputsLoading: val })} + setTransactionHistoryLoading={(isLoading) => setState({ isAddressHistoryLoading: isLoading })} + setTransactionHistoryDisabled={(val) => setState({ isAddressHistoryDisabled: val })} /> )} diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index 60162fdc5..2831e064a 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -49,6 +49,8 @@ const ImplicitAccountCreationAddressView: React.FC setState({ isAssociatedOutputsLoading: val })} + setTransactionHistoryLoading={(isLoading) => setState({ isAddressHistoryLoading: isLoading })} + setTransactionHistoryDisabled={(val) => setState({ isAddressHistoryDisabled: val })} /> )} diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index 3646e9ddb..cb54c241d 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -49,6 +49,8 @@ const NftAddressView: React.FC = ({ nftAddress }) => { key={addressDetails.bech32} addressState={state} setAssociatedOutputsLoading={(val) => setState({ isAssociatedOutputsLoading: val })} + setTransactionHistoryLoading={(isLoading) => setState({ isAddressHistoryLoading: isLoading })} + setTransactionHistoryDisabled={(val) => setState({ isAddressHistoryDisabled: val })} /> )} diff --git a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx index e548927c5..1603e595f 100644 --- a/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx +++ b/client/src/app/components/nova/address/section/AddressPageTabbedSections.tsx @@ -6,6 +6,7 @@ import bicMessage from "~assets/modals/nova/account/bic.json"; import TabbedSection from "../../../hoc/TabbedSection"; import AssociatedOutputs from "./association/AssociatedOutputs"; import nativeTokensMessage from "~assets/modals/stardust/address/assets-in-wallet.json"; +import transactionHistoryMessage from "~assets/modals/stardust/address/transaction-history.json"; import { IAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressState"; import { INftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; import { IAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; @@ -14,10 +15,13 @@ import AssetsTable from "./native-tokens/AssetsTable"; import { IImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; import { AddressType } from "@iota/sdk-wasm-nova/web"; import AccountFoundriesSection from "./account/AccountFoundriesSection"; +import TransactionHistory from "../../history/TransactionHistoryView"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; import AccountBlockIssuanceSection from "./account/AccountBlockIssuanceSection"; import AnchorStateSection from "./anchor/AnchorStateSection"; enum DEFAULT_TABS { + Transactions = "Transactions", AssocOutputs = "Outputs", NativeTokens = "Native Tokens", } @@ -31,7 +35,18 @@ enum ANCHOR_TABS { State = "State", } -const buildDefaultTabsOptions = (tokensCount: number, associatedOutputCount: number) => ({ +const buildDefaultTabsOptions = ( + tokensCount: number, + associatedOutputCount: number, + isAddressHistoryLoading: boolean, + isAddressHistoryDisabled: boolean, +) => ({ + [DEFAULT_TABS.Transactions]: { + disabled: isAddressHistoryDisabled, + hidden: isAddressHistoryDisabled, + isLoading: isAddressHistoryLoading, + infoContent: transactionHistoryMessage, + }, [DEFAULT_TABS.AssocOutputs]: { disabled: associatedOutputCount === 0, hidden: associatedOutputCount === 0, @@ -83,18 +98,35 @@ interface IAddressPageTabbedSectionsProps { | IAnchorAddressState | IImplicitAccountCreationAddressState; readonly setAssociatedOutputsLoading: (isLoading: boolean) => void; + readonly setTransactionHistoryLoading: (isLoading: boolean) => void; + readonly setTransactionHistoryDisabled: (isDisabled: boolean) => void; } -export const AddressPageTabbedSections: React.FC = ({ addressState, setAssociatedOutputsLoading }) => { +export const AddressPageTabbedSections: React.FC = ({ + addressState, + setAssociatedOutputsLoading, + setTransactionHistoryLoading, + setTransactionHistoryDisabled, +}) => { const [outputCount, setOutputCount] = useState(0); const [tokensCount, setTokensCount] = useState(0); + const networkInfo = useNetworkInfoNova((s) => s.networkInfo); if (!addressState.addressDetails) { return null; } - const { addressDetails, addressBasicOutputs } = addressState; + const { addressDetails, addressBasicOutputs, isAddressHistoryLoading, isAddressHistoryDisabled } = addressState; + const { bech32: addressBech32 } = addressDetails; + const { name: network } = networkInfo; const defaultSections = [ + , >; + + /** + * The formatted transaction amount. + */ + balanceChangeFormatted: string; + + /** + * The transaction link. + */ + transactionLink: string; +} diff --git a/client/src/app/components/nova/history/TransactionHistoryCard.tsx b/client/src/app/components/nova/history/TransactionHistoryCard.tsx new file mode 100644 index 000000000..7d0d26f8f --- /dev/null +++ b/client/src/app/components/nova/history/TransactionHistoryCard.tsx @@ -0,0 +1,41 @@ +import classNames from "classnames"; +import React from "react"; +import TruncatedId from "../../stardust/TruncatedId"; +import { ITransactionHistoryEntryProps } from "./ITransactionHistoryEntryProps"; + +const TransactionHistoryCard: React.FC = ({ + transactionLink, + dateFormatted, + balanceChangeFormatted, + transactionId, + isSpent, + isFormattedAmounts, + setIsFormattedAmounts, +}) => { + const valueView = ( + setIsFormattedAmounts(!isFormattedAmounts)}> + {balanceChangeFormatted} + + ); + + return ( +
+
+
Date
+
{dateFormatted}
+
+
+
Transaction Id
+
+ +
+
+
+
Value
+
{valueView}
+
+
+ ); +}; + +export default TransactionHistoryCard; diff --git a/client/src/app/components/nova/history/TransactionHistoryRow.tsx b/client/src/app/components/nova/history/TransactionHistoryRow.tsx new file mode 100644 index 000000000..2960ebfd9 --- /dev/null +++ b/client/src/app/components/nova/history/TransactionHistoryRow.tsx @@ -0,0 +1,34 @@ +import classNames from "classnames"; +import React from "react"; +import TruncatedId from "../../stardust/TruncatedId"; +import { ITransactionHistoryEntryProps } from "./ITransactionHistoryEntryProps"; + +const TransactionHistoryRow: React.FC = ({ + transactionLink, + dateFormatted, + balanceChangeFormatted, + transactionId, + isSpent, + isFormattedAmounts, + setIsFormattedAmounts, +}) => { + const valueView = ( + setIsFormattedAmounts(!isFormattedAmounts)}> + {balanceChangeFormatted} + + ); + + return ( + + {dateFormatted} + +
+ +
+ + {valueView} + + ); +}; + +export default TransactionHistoryRow; diff --git a/client/src/app/components/nova/history/TransactionHistoryView.scss b/client/src/app/components/nova/history/TransactionHistoryView.scss new file mode 100644 index 000000000..fe95e75bb --- /dev/null +++ b/client/src/app/components/nova/history/TransactionHistoryView.scss @@ -0,0 +1,169 @@ +@import "../../../../scss/fonts"; +@import "../../../../scss/mixins"; +@import "../../../../scss/media-queries"; +@import "../../../../scss/variables"; +@import "../../../../scss/themes"; + +.section.transaction-history--section { + padding-top: 24px; + + .section--header { + width: 100%; + min-height: 40px; + } + + .transaction-history--table { + width: 100%; + border-spacing: 0 0; + + @include tablet-down { + display: none; + } + + tr { + @include font-size(14px); + + border: 1px solid; + border-radius: 4px; + color: $gray-7; + font-family: $inter; + letter-spacing: 0.5px; + + &.dark { + background: var(--transaction-history-dark-row); + } + + th, + td { + padding: 16px; + } + + th { + @include font-size(12px); + + color: $gray-6; + font-weight: 600; + text-align: center; + text-transform: uppercase; + } + + td { + text-align: center; + width: 33%; + text-wrap: nowrap; + + &.transaction-id, + &.output-id { + @include font-size(14px); + color: var(--link-color); + font-family: $ibm-plex-mono; + font-weight: normal; + letter-spacing: 0.02em; + line-height: 20px; + word-break: break-all; + text-align: -webkit-center; + } + + &.output-id .highlight { + font-weight: 500; + color: $gray-6; + margin-left: 2px; + } + + &.transaction-id a { + max-width: 100px; + } + + &.output-id a { + max-width: 100px; + } + + &.date { + @include font-size(14px); + font-family: $ibm-plex-mono; + } + + &.amount { + color: var(--amount-color); + @include font-size(16px, 21px); + font-weight: 700; + text-align: right; + + &.negative { + color: var(--expanded-color); + } + } + } + + td, + th { + &:first-child { + text-align: left; + } + &:last-child { + text-align: right; + } + } + } + } + + .transaction-history--cards { + display: none; + + @include tablet-down { + display: block; + + .card { + padding: 12px; + margin-bottom: 16px; + + .field { + margin-bottom: 8px; + + .card--label { + @include font-size(14px, 21px); + } + + .amount { + font-family: $inter; + font-size: 0.875rem; + font-weight: 700; + letter-spacing: 0.5px; + + &.positive { + color: $mint-green-7; + } + + &.negative { + color: var(--expanded-color); + } + } + } + } + } + } + + .load-more--button { + margin: 24px 0 8px 0; + align-self: center; + font-family: $metropolis; + width: fit-content; + padding: 4px 8px; + cursor: pointer; + + &:hover { + button { + color: var(--link-highlight); + } + } + + button { + padding: 6px; + color: var(--header-icon-color); + } + } + + .pagination { + margin-top: 8px; + } +} diff --git a/client/src/app/components/nova/history/TransactionHistoryView.tsx b/client/src/app/components/nova/history/TransactionHistoryView.tsx new file mode 100644 index 000000000..7f902c67a --- /dev/null +++ b/client/src/app/components/nova/history/TransactionHistoryView.tsx @@ -0,0 +1,111 @@ +/* eslint-disable no-void */ +import React, { useEffect, useMemo, useState } from "react"; +import { useAddressHistory } from "~helpers/nova/hooks/useAddressHistory"; +import { useNetworkInfoNova } from "~helpers/nova/networkInfo"; +import TransactionHistoryRow from "./TransactionHistoryRow"; +import TransactionHistoryCard from "./TransactionHistoryCard"; +import { getTransactionHistoryRecords } from "~/helpers/nova/transactionHistoryUtils"; +import "./TransactionHistoryView.scss"; +import { useNovaTimeConvert } from "~/helpers/nova/hooks/useNovaTimeConvert"; + +export interface TransactionHistoryProps { + readonly network: string; + readonly address?: string; + readonly setLoading: (isLoadin: boolean) => void; + readonly setDisabled?: (isDisabled: boolean) => void; +} + +const TransactionHistory: React.FC = ({ network, address, setLoading, setDisabled }) => { + const [transactionIdToOutputs, loadMore, isLoading, hasMore] = useAddressHistory(network, address, setDisabled); + const { slotIndexToUnixTimeRange } = useNovaTimeConvert(); + + const [isFormattedAmounts, setIsFormattedAmounts] = useState(true); + const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); + + if (slotIndexToUnixTimeRange === null) { + return null; + } + + useEffect(() => { + setLoading(isLoading); + }, [isLoading]); + + const transactions = useMemo(() => { + const transactionsLocal = getTransactionHistoryRecords( + transactionIdToOutputs, + network, + tokenInfo, + isFormattedAmounts, + slotIndexToUnixTimeRange, + ); + if (hasMore) { + // remove last transaction, as it's potentially doesn't have all outputs + transactionsLocal.pop(); + } + return transactionsLocal; + }, [transactionIdToOutputs, tokenInfo, isFormattedAmounts, hasMore, slotIndexToUnixTimeRange]); + + return transactions.length > 0 && address ? ( +
+ + + + + + + + + + {transactions?.map((c, idx) => ( + + + + ))} + +
DateTransaction IdValue
+ + {/** Only visible in mobile -- Card transactions */} +
+ {transactions.map((c, idx) => { + return ( + + + + ); + })} +
+ {hasMore && transactions.length > 0 && ( +
+ +
+ )} +
+ ) : ( +
+
+
+

There are no transactions for this address.

+
+
+
+ ); +}; + +TransactionHistory.defaultProps = { + address: undefined, + setDisabled: undefined, +}; + +export default TransactionHistory; diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index 09c5ec62d..b360cd231 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -31,6 +31,8 @@ export interface IAccountAddressState { isAssociatedOutputsLoading: boolean; isBasicOutputsLoading: boolean; isFoundriesLoading: boolean; + isAddressHistoryLoading: boolean; + isAddressHistoryDisabled: boolean; isCongestionLoading: boolean; } @@ -47,6 +49,8 @@ const initialState = { isAssociatedOutputsLoading: false, isBasicOutputsLoading: false, isFoundriesLoading: false, + isAddressHistoryLoading: true, + isAddressHistoryDisabled: false, isCongestionLoading: false, }; diff --git a/client/src/helpers/nova/hooks/useAddressBalance.ts b/client/src/helpers/nova/hooks/useAddressBalance.ts index b7d0dd1cd..6bbe50a71 100644 --- a/client/src/helpers/nova/hooks/useAddressBalance.ts +++ b/client/src/helpers/nova/hooks/useAddressBalance.ts @@ -31,19 +31,22 @@ export function useAddressBalance( addressDetails?.type === AddressType.Account || addressDetails?.type === AddressType.Nft || addressDetails?.type === AddressType.Anchor; - const canLoad = address && (!needsOutputToProceed || (needsOutputToProceed && output)); + const canLoad = address && (!needsOutputToProceed || (needsOutputToProceed && output !== null)); + if (canLoad) { // eslint-disable-next-line no-void void (async () => { const response = await apiClient.addressBalanceChronicle({ network, address }); - if (response?.totalBalance !== undefined && isMounted) { - let totalBalance = response.totalBalance; - let availableBalance = response.availableBalance ?? 0; + if (isMounted) { + let totalBalance = response?.totalBalance?.amount ?? 0; + let availableBalance = response?.availableBalance?.amount ?? 0; + if (output) { totalBalance = Number(totalBalance) + Number(output.amount); availableBalance = Number(availableBalance) + Number(output.amount); } + setTotalBalance(totalBalance); setAvailableBalance(availableBalance > 0 ? availableBalance : null); } diff --git a/client/src/helpers/nova/hooks/useAddressHistory.ts b/client/src/helpers/nova/hooks/useAddressHistory.ts new file mode 100644 index 000000000..8423de2d3 --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressHistory.ts @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { ITransactionHistoryRequest } from "~models/api/nova/ITransactionHistoryRequest"; +import { ITransactionHistoryItem, ITransactionHistoryResponse } from "~models/api/nova/ITransactionHistoryResponse"; +import { NOVA } from "~/models/config/protocolVersion"; +import { NovaApiClient } from "~services/nova/novaApiClient"; +import { OutputResponse } from "@iota/sdk-wasm-nova/web"; +import { groupOutputsByTransactionId } from "../transactionHistoryUtils"; + +const OUTPUT_PAGE_SIZE = 10; +const TX_PAGE_SIZE = 10; + +const SORT = "newest"; + +interface IHistoryState { + transactionIdToOutputs: Map; + outputsWithDetails: OutputWithDetails[]; + isAddressHistoryLoading: boolean; + cursor: string | undefined; +} + +export type OutputWithDetails = ITransactionHistoryItem & { details: OutputResponse | null; amount?: string }; + +/** + * Fetch Address history + * @param network The Network in context + * @param address The address in bech32 format + * @param setDisabled Optional callback to signal there is no history. + * @returns The history items, The map of outputId to output details, callback load next page, isLoading, hasMore + */ +export function useAddressHistory( + network: string, + address?: string, + setDisabled?: (isDisabled: boolean) => void, +): [Map, () => void, boolean, boolean] { + const isMounted = useIsMounted(); + const [apiClient] = useState(() => ServiceFactory.get(`api-client-${NOVA}`)); + const [historyState, setHistoryState] = useState({ + transactionIdToOutputs: new Map(), + outputsWithDetails: [], + isAddressHistoryLoading: true, + cursor: undefined, + }); + + useEffect(() => { + if (!address || !isMounted) return; + (async () => { + await loadHistory(); + })(); + }, [address, isMounted]); + + const requestOutputsList = async ( + cursor: string | undefined, + ): Promise<{ outputs: ITransactionHistoryItem[]; cursor: string | undefined }> => { + if (!address) return { outputs: [], cursor: undefined }; + + const request: ITransactionHistoryRequest = { + network, + address, + pageSize: OUTPUT_PAGE_SIZE, + sort: SORT, + cursor, + }; + + const response = (await apiClient.transactionHistory(request)) as ITransactionHistoryResponse | undefined; + const items = response?.items ?? []; + return { + outputs: items, + cursor: response?.cursor, + }; + }; + + const requestOutputDetails = async (outputId: string): Promise => { + if (!outputId) return null; + + try { + const response = await apiClient.outputDetails({ network, outputId }); + const details = response.output; + + if (!response.error && details?.output && details?.metadata) { + return details; + } + return null; + } catch { + console.error("Failed loading transaction history details!"); + return null; + } + }; + + const loadHistory = async () => { + let { transactionIdToOutputs, outputsWithDetails, cursor } = historyState; + // Search one more than the desired so the incomplete transaction can be removed with .pop() + const targetSize = transactionIdToOutputs.size + TX_PAGE_SIZE + 1; + let searchMore = true; + + setHistoryState((prevState) => ({ ...prevState, isAddressHistoryLoading: true })); + + while (transactionIdToOutputs.size < targetSize && searchMore) { + try { + const { outputs, cursor: newCursor } = await requestOutputsList(cursor); + + if (!newCursor) { + // Note: newCursor can be null if there are no more pages, and undefined if there are no results + searchMore = false; + } + + if (!newCursor && outputs.length === 0 && transactionIdToOutputs.size === 0) { + // hide the tab only if there are no results + setDisabled?.(true); + } + + const fulfilledOutputs: OutputWithDetails[] = await Promise.all( + outputs.map(async (output) => { + const details = await requestOutputDetails(output.outputId); + return { + ...output, + details, + amount: details?.output?.amount, + }; + }), + ); + + outputsWithDetails = [...outputsWithDetails, ...fulfilledOutputs].sort((a, b) => { + // Ensure that entries with equal timestamp, but different isSpent, + // have the spending before the depositing + if (a.slotIndex === b.slotIndex && a.isSpent !== b.isSpent) { + return a.isSpent ? 1 : -1; + } + return 1; + }); + + transactionIdToOutputs = groupOutputsByTransactionId(outputsWithDetails); + cursor = newCursor; + } catch (error) { + console.error("Failed loading transaction history", error); + searchMore = false; + } + } + setHistoryState({ transactionIdToOutputs, outputsWithDetails, isAddressHistoryLoading: false, cursor }); + }; + + return [historyState.transactionIdToOutputs, loadHistory, historyState.isAddressHistoryLoading, Boolean(historyState.cursor)]; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index a688fd186..77e38b409 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -18,6 +18,8 @@ export interface IAnchorAddressState { isBasicOutputsLoading: boolean; isAnchorDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; + isAddressHistoryLoading: boolean; + isAddressHistoryDisabled: boolean; } const initialState = { @@ -29,6 +31,8 @@ const initialState = { isBasicOutputsLoading: false, isAnchorDetailsLoading: true, isAssociatedOutputsLoading: false, + isAddressHistoryLoading: true, + isAddressHistoryDisabled: false, }; /** diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index cc2866a50..c5846e784 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -14,6 +14,8 @@ export interface IEd25519AddressState { addressBasicOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; + isAddressHistoryLoading: boolean; + isAddressHistoryDisabled: boolean; } const initialState = { @@ -23,6 +25,8 @@ const initialState = { addressBasicOutputs: null, isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, + isAddressHistoryLoading: true, + isAddressHistoryDisabled: false, }; /** diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index d0277b00e..74ac1562a 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -15,6 +15,8 @@ export interface IImplicitAccountCreationAddressState { addressBasicOutputs: OutputResponse[] | null; isBasicOutputsLoading: boolean; isAssociatedOutputsLoading: boolean; + isAddressHistoryLoading: boolean; + isAddressHistoryDisabled: boolean; } const initialState = { @@ -24,6 +26,8 @@ const initialState = { addressBasicOutputs: null, isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, + isAddressHistoryLoading: true, + isAddressHistoryDisabled: false, }; /** diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index ebc43c8dd..276d901e8 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -18,6 +18,8 @@ export interface INftAddressState { isBasicOutputsLoading: boolean; isNftDetailsLoading: boolean; isAssociatedOutputsLoading: boolean; + isAddressHistoryLoading: boolean; + isAddressHistoryDisabled: boolean; } const initialState = { @@ -29,6 +31,8 @@ const initialState = { addressBasicOutputs: null, isBasicOutputsLoading: false, isAssociatedOutputsLoading: false, + isAddressHistoryLoading: true, + isAddressHistoryDisabled: false, }; /** diff --git a/client/src/helpers/nova/transactionHistoryUtils.ts b/client/src/helpers/nova/transactionHistoryUtils.ts new file mode 100644 index 000000000..b36f79249 --- /dev/null +++ b/client/src/helpers/nova/transactionHistoryUtils.ts @@ -0,0 +1,105 @@ +import { CommonOutput, INodeInfoBaseToken } from "@iota/sdk-wasm-nova/web"; +import moment from "moment/moment"; + +import { DateHelper } from "~helpers/dateHelper"; +import { OutputWithDetails } from "~helpers/nova/hooks/useAddressHistory"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; + +export interface ITransactionHistoryRecord { + isSpent: boolean; + transactionLink: string; + transactionId: string; + timestamp: number; + dateFormatted: string; + balanceChange: number; + balanceChangeFormatted: string; + outputs: OutputWithDetails[]; +} + +export const groupOutputsByTransactionId = (outputsWithDetails: OutputWithDetails[]) => { + const transactionIdToOutputs = new Map(); + outputsWithDetails.forEach((output) => { + const detailsMetadata = output?.details?.metadata; + if (!detailsMetadata) { + return; + } + + const transactionId = output.isSpent ? detailsMetadata.spent?.transactionId : detailsMetadata.included?.transactionId; + + if (!transactionId) { + return; + } + + const addOutputToTransactionId = (transactionId: string, output: OutputWithDetails) => { + // if we don't have the transaction + const previousOutputs = transactionIdToOutputs.get(transactionId); + if (previousOutputs) { + transactionIdToOutputs.set(transactionId, [...previousOutputs, output]); + } else { + transactionIdToOutputs.set(transactionId, [output]); + } + }; + addOutputToTransactionId(transactionId, output); + }); + + return transactionIdToOutputs; +}; + +export const getTransactionHistoryRecords = ( + transactionIdToOutputs: Map, + network: string, + tokenInfo: INodeInfoBaseToken, + isFormattedAmounts: boolean, + slotIndexToUnixTimeRange: (slotIndex: number) => { from: number; to: number }, +): ITransactionHistoryRecord[] => { + const calculatedTransactions: ITransactionHistoryRecord[] = []; + + transactionIdToOutputs.forEach((outputs, transactionId) => { + const lastOutputTime = Math.max( + ...outputs.map((t) => { + const slotIncluded = t.slotIndex; + const slotRange = slotIndexToUnixTimeRange(slotIncluded); + return slotRange.to - 1; + }), + ); + const balanceChange = calculateBalanceChange(outputs); + const ago = moment(lastOutputTime * 1000).fromNow(); + + const transactionLink = getTransactionLink(network, transactionId); + + const isSpent = balanceChange <= 0; + + calculatedTransactions.push({ + isSpent: isSpent, + transactionLink: transactionLink, + transactionId: transactionId, + timestamp: lastOutputTime, + dateFormatted: `${DateHelper.formatShort(lastOutputTime * 1000)} (${ago})`, + balanceChange: balanceChange, + balanceChangeFormatted: (isSpent ? `-` : `+`) + formatAmount(Math.abs(balanceChange), tokenInfo, !isFormattedAmounts, 2, true), + outputs: outputs, + }); + }); + return calculatedTransactions; +}; + +export const calculateBalanceChange = (outputs: OutputWithDetails[]) => { + return outputs.reduce((acc, output) => { + const outputFromDetails = output?.details?.output as CommonOutput; + + if (!outputFromDetails?.amount) { + console.warn("Output details not found for: ", output); + return acc; + } + + let amount = Number(outputFromDetails.amount); + if (output.isSpent) { + amount = -1 * amount; + } + return acc + amount; + }, 0); +}; + +export const getTransactionLink = (network: string, transactionId: string) => { + return `/${network}/transaction/${transactionId}`; +}; diff --git a/client/src/models/api/nova/ITransactionHistoryRequest.ts b/client/src/models/api/nova/ITransactionHistoryRequest.ts new file mode 100644 index 000000000..06939f464 --- /dev/null +++ b/client/src/models/api/nova/ITransactionHistoryRequest.ts @@ -0,0 +1,34 @@ +/** + * The request for Transaction History on nova. + */ +export interface ITransactionHistoryRequest { + /** + * The network in context. + */ + network: string; + + /** + * The address to get the history for. + */ + address: string; + + /** + * The page size of the request (default is 100). + */ + pageSize?: number; + + /** + * The sort by date to use (asc/desc). + */ + sort?: string; + + /** + * The lower bound slot index to use. + */ + startSlotIndex?: number; + + /** + * The cursor state for the request. + */ + cursor?: string; +} diff --git a/client/src/models/api/nova/ITransactionHistoryResponse.ts b/client/src/models/api/nova/ITransactionHistoryResponse.ts new file mode 100644 index 000000000..933e06a39 --- /dev/null +++ b/client/src/models/api/nova/ITransactionHistoryResponse.ts @@ -0,0 +1,41 @@ +import { IResponse } from "../IResponse"; + +/** + * A transaction history item. + */ +export interface ITransactionHistoryItem { + /** + * The slot index this item is included in. + */ + slotIndex: number; + + /** + * The outputId. + */ + outputId: string; + + /** + * Is the output spent. + */ + isSpent: boolean; +} + +/* + * The transaction history response. + */ +export interface ITransactionHistoryResponse extends IResponse { + /** + * Address the history is for. + */ + address?: string; + + /** + * The history items. + */ + items?: ITransactionHistoryItem[]; + + /** + * The cursor for next request. + */ + cursor?: string; +} diff --git a/client/src/models/api/nova/address/IAddressBalanceResponse.ts b/client/src/models/api/nova/address/IAddressBalanceResponse.ts index 4e65c9c7b..36ea92ec1 100644 --- a/client/src/models/api/nova/address/IAddressBalanceResponse.ts +++ b/client/src/models/api/nova/address/IAddressBalanceResponse.ts @@ -1,15 +1,25 @@ import { IResponse } from "../../IResponse"; +interface IManaBalance { + stored: number; + potential: number; +} + +interface IBalance { + amount: number; + mana: IManaBalance; +} + export interface IAddressBalanceResponse extends IResponse { /** * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) */ - totalBalance?: number; + totalBalance?: IBalance; /** * The balance of trivialy unlockable outputs with address unlock condition. */ - availableBalance?: number; + availableBalance?: IBalance; /** * The ledger index at which this balance data was valid. diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 6441f66f9..b2b53a5b3 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -29,6 +29,9 @@ import { IAddressDetailsRequest } from "~/models/api/nova/address/IAddressDetail import { IAddressDetailsResponse } from "~/models/api/nova/address/IAddressDetailsResponse"; import { IFoundriesResponse } from "~/models/api/nova/foundry/IFoundriesResponse"; import { IFoundriesRequest } from "~/models/api/nova/foundry/IFoundriesRequest"; +import { ITransactionHistoryRequest } from "~/models/api/nova/ITransactionHistoryRequest"; +import { ITransactionHistoryResponse } from "~/models/api/nova/ITransactionHistoryResponse"; +import { FetchHelper } from "~/helpers/fetchHelper"; import { ISlotRequest } from "~/models/api/nova/ISlotRequest"; import { ISlotResponse } from "~/models/api/nova/ISlotResponse"; import { ITransactionDetailsRequest } from "~/models/api/nova/ITransactionDetailsRequest"; @@ -210,6 +213,25 @@ export class NovaApiClient extends ApiClient { ); } + /** + * Get the transaction history of an address (chronicle). + * @param request The request to send. + * @returns The response from the request. + */ + public async transactionHistory(request: ITransactionHistoryRequest): Promise { + const params = { + pageSize: request.pageSize, + sort: request.sort, + startSlotIndex: request.startSlotIndex, + cursor: request.cursor, + }; + + return this.callApi( + `nova/transactionhistory/${request.network}/${request.address}${FetchHelper.urlParams(params)}`, + "get", + ); + } + /** * Find items from the tangle. * @param request The request to send.