diff --git a/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts b/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts new file mode 100644 index 000000000..7b632de16 --- /dev/null +++ b/api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressBalanceRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the balance for. + */ + address: string; +} diff --git a/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts new file mode 100644 index 000000000..32f275a9f --- /dev/null +++ b/api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts @@ -0,0 +1,18 @@ +import { IResponse } from "../../IResponse"; + +export interface IAddressBalanceResponse extends IResponse { + /** + * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) + */ + totalBalance?: number; + + /** + * The balance of all spendable outputs by the address at this time. + */ + availableBalance?: number; + + /** + * The ledger index at which this balance data was valid. + */ + ledgerIndex?: number; +} diff --git a/api/src/routes.ts b/api/src/routes.ts index 3e99da481..4c4f2e0cc 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -202,6 +202,12 @@ export const routes: IRoute[] = [ func: "get", }, // Nova + { + path: "/nova/balance/chronicle/:network/:address", + method: "get", + folder: "nova/address/balance/chronicle", + func: "get", + }, { path: "/nova/search/:network/:query", method: "get", folder: "nova", func: "search" }, { path: "/nova/output/:network/:outputId", method: "get", folder: "nova/output", func: "get" }, { path: "/nova/output/rewards/:network/:outputId", method: "get", folder: "nova/output/rewards", func: "get" }, diff --git a/api/src/routes/nova/address/balance/chronicle/get.ts b/api/src/routes/nova/address/balance/chronicle/get.ts new file mode 100644 index 000000000..2e5c01252 --- /dev/null +++ b/api/src/routes/nova/address/balance/chronicle/get.ts @@ -0,0 +1,34 @@ +import { ServiceFactory } from "../../../../../factories/serviceFactory"; +import { IAddressBalanceRequest } from "../../../../../models/api/nova/chronicle/IAddressBalanceRequest"; +import { IAddressBalanceResponse } from "../../../../../models/api/nova/chronicle/IAddressBalanceResponse"; +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 address balance from chronicle nova. + * @param _ The configuration. + * @param request The request. + * @returns The response. + */ +export async function get(_: IConfiguration, request: IAddressBalanceRequest): 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.addressBalance(request.address); +} diff --git a/api/src/services/nova/chronicleService.ts b/api/src/services/nova/chronicleService.ts new file mode 100644 index 000000000..8fc0274ff --- /dev/null +++ b/api/src/services/nova/chronicleService.ts @@ -0,0 +1,43 @@ +import logger from "../../logger"; +import { IAddressBalanceResponse } from "../../models/api/nova/chronicle/IAddressBalanceResponse"; +import { INetwork } from "../../models/db/INetwork"; +import { FetchHelper } from "../../utils/fetchHelper"; + +const CHRONICLE_ENDPOINTS = { + balance: "/api/explorer/v3/balance/", +}; + +export class ChronicleService { + /** + * The endpoint for performing communications. + */ + private readonly chronicleEndpoint: string; + + /** + * The network config in context. + */ + private readonly networkConfig: INetwork; + + constructor(config: INetwork) { + this.networkConfig = config; + this.chronicleEndpoint = config.permaNodeEndpoint; + } + + /** + * Get the current address balance info. + * @param address The address to fetch the balance for. + * @returns The address balance response. + */ + public async addressBalance(address: string): Promise { + try { + return await FetchHelper.json( + this.chronicleEndpoint, + `${CHRONICLE_ENDPOINTS.balance}${address}`, + "get", + ); + } catch (error) { + const network = this.networkConfig.network; + logger.warn(`[ChronicleService (Nova)] Failed fetching address balance for ${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 9ef70952f..d0580c7ac 100644 --- a/client/src/app/components/nova/address/AccountAddressView.tsx +++ b/client/src/app/components/nova/address/AccountAddressView.tsx @@ -4,13 +4,15 @@ import { useAccountAddressState } from "~/helpers/nova/hooks/useAccountAddressSt import Spinner from "../../Spinner"; import Bech32Address from "../../nova/address/Bech32Address"; import AssociatedOutputs from "./section/association/AssociatedOutputs"; +import AddressBalance from "./AddressBalance"; interface AccountAddressViewProps { accountAddress: AccountAddress; } const AccountAddressView: React.FC = ({ accountAddress }) => { - const { accountAddressDetails, isAccountDetailsLoading } = useAccountAddressState(accountAddress); + const [state] = useAccountAddressState(accountAddress); + const { accountAddressDetails, totalBalance, availableBalance, isAccountDetailsLoading } = state; const isPageLoading = isAccountDetailsLoading; return ( @@ -33,6 +35,13 @@ const AccountAddressView: React.FC = ({ accountAddress
+ {totalBalance !== null && ( + + )}
diff --git a/client/src/app/components/nova/address/AddressBalance.scss b/client/src/app/components/nova/address/AddressBalance.scss new file mode 100644 index 000000000..ce9177294 --- /dev/null +++ b/client/src/app/components/nova/address/AddressBalance.scss @@ -0,0 +1,77 @@ +@import "../../../../scss/media-queries"; + +.balance-wrapper { + margin-top: 40px; + + .icon { + margin-right: 16px; + } + + .balance-wrapper--inner { + display: flex; + + .balance { + display: flex; + flex-direction: row; + + .icon { + align-self: center; + + @include tablet-down { + display: none; + } + } + + &:not(:last-child) { + margin-right: 40px; + + @include tablet-down { + margin-right: 0px; + } + } + + .balance-value { + display: flex; + flex-direction: column; + + @include tablet-down { + flex-direction: row; + } + + .balance-value--inline { + @include tablet-down { + margin-left: 5px; + } + } + } + + .balance-base-token, + .balance-fiat { + color: #b0bfd9; + font-size: 18px; + } + + .balance-heading { + height: 20px; + + .material-icons { + font-size: 18px; + color: #b0bfd9; + padding-left: 5px; + } + } + } + } + + @include tablet-down { + .balance-wrapper--inner { + flex-direction: column; + + .balance { + &:not(:first-child) { + margin-top: 26px; + } + } + } + } +} diff --git a/client/src/app/components/nova/address/AddressBalance.tsx b/client/src/app/components/nova/address/AddressBalance.tsx new file mode 100644 index 000000000..8d7434cb1 --- /dev/null +++ b/client/src/app/components/nova/address/AddressBalance.tsx @@ -0,0 +1,99 @@ +import React, { useState } from "react"; +import { useNetworkInfoNova } from "~/helpers/nova/networkInfo"; +import { formatAmount } from "~helpers/stardust/valueFormatHelper"; +import CopyButton from "../../CopyButton"; +import Icon from "../../Icon"; +import Tooltip from "../../Tooltip"; +import "./AddressBalance.scss"; + +interface AddressBalanceProps { + /** + * The totalBalance amount from chronicle (representing trivial + conditional balance). + */ + readonly totalBalance: number; + /** + * The trivially unlockable portion of the balance, fetched from chronicle. + */ + readonly availableBalance: number | null; + /** + * The storage rent balance. + */ + readonly storageDeposit: number | null; +} + +const CONDITIONAL_BALANCE_INFO = + "These funds reside within outputs with additional unlock conditions which might be potentially un-lockable"; + +const AddressBalance: React.FC = ({ totalBalance, availableBalance, storageDeposit }) => { + const { tokenInfo } = useNetworkInfoNova((s) => s.networkInfo); + const [formatBalanceFull, setFormatBalanceFull] = useState(false); + const [formatConditionalBalanceFull, setFormatConditionalBalanceFull] = useState(false); + const [formatStorageBalanceFull, setFormatStorageBalanceFull] = useState(false); + + const buildBalanceView = ( + label: string, + isFormatFull: boolean, + setIsFormatFull: React.Dispatch>, + showInfo: boolean, + showWallet: boolean, + amount: number | null, + ) => ( +
+ {showWallet && } +
+
+
{label}
+ {showInfo && ( + + info + + )} +
+
+ {amount !== null && amount > 0 ? ( +
+
+ setIsFormatFull(!isFormatFull)} className="balance-base-token pointer margin-r-5"> + {formatAmount(amount, tokenInfo, isFormatFull)} + + +
+
+ ) : ( + 0 + )} +
+
+
+ ); + + const conditionalBalance = availableBalance === null ? undefined : totalBalance - availableBalance; + const shouldShowExtendedBalance = conditionalBalance !== undefined && availableBalance !== undefined; + + return ( +
+
+ {buildBalanceView( + "Available Balance", + formatBalanceFull, + setFormatBalanceFull, + false, + true, + shouldShowExtendedBalance ? availableBalance : totalBalance, + )} + {shouldShowExtendedBalance && + buildBalanceView( + "Conditionally Locked Balance", + formatConditionalBalanceFull, + setFormatConditionalBalanceFull, + true, + false, + conditionalBalance, + )} + {buildBalanceView("Storage Deposit", formatStorageBalanceFull, setFormatStorageBalanceFull, false, false, storageDeposit)} +
+
+ ); +}; + +export default AddressBalance; diff --git a/client/src/app/components/nova/address/AnchorAddressView.tsx b/client/src/app/components/nova/address/AnchorAddressView.tsx index 6a0b2b161..19045e588 100644 --- a/client/src/app/components/nova/address/AnchorAddressView.tsx +++ b/client/src/app/components/nova/address/AnchorAddressView.tsx @@ -2,6 +2,7 @@ import { AnchorAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useAnchorAddressState } from "~/helpers/nova/hooks/useAnchorAddressState"; import Spinner from "../../Spinner"; +import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import AssociatedOutputs from "./section/association/AssociatedOutputs"; @@ -10,7 +11,8 @@ interface AnchorAddressViewProps { } const AnchorAddressView: React.FC = ({ anchorAddress }) => { - const { anchorAddressDetails, isAnchorDetailsLoading } = useAnchorAddressState(anchorAddress); + const [state] = useAnchorAddressState(anchorAddress); + const { anchorAddressDetails, totalBalance, availableBalance, isAnchorDetailsLoading } = state; const isPageLoading = isAnchorDetailsLoading; return ( @@ -33,6 +35,13 @@ const AnchorAddressView: React.FC = ({ anchorAddress })
+ {totalBalance !== null && ( + + )}
diff --git a/client/src/app/components/nova/address/Ed25519AddressView.tsx b/client/src/app/components/nova/address/Ed25519AddressView.tsx index 17232782d..4b6ea5adc 100644 --- a/client/src/app/components/nova/address/Ed25519AddressView.tsx +++ b/client/src/app/components/nova/address/Ed25519AddressView.tsx @@ -1,6 +1,7 @@ import { Ed25519Address } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useEd25519AddressState } from "~/helpers/nova/hooks/useEd25519AddressState"; +import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import AssociatedOutputs from "./section/association/AssociatedOutputs"; @@ -9,7 +10,8 @@ interface Ed25519AddressViewProps { } const Ed25519AddressView: React.FC = ({ ed25519Address }) => { - const { ed25519AddressDetails } = useEd25519AddressState(ed25519Address); + const [state] = useEd25519AddressState(ed25519Address); + const { ed25519AddressDetails, totalBalance, availableBalance } = state; return (
@@ -30,6 +32,13 @@ const Ed25519AddressView: React.FC = ({ ed25519Address
+ {totalBalance !== null && ( + + )}
diff --git a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx index eee1977ba..e989dbf1a 100644 --- a/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx +++ b/client/src/app/components/nova/address/ImplicitAccountCreationAddressView.tsx @@ -1,6 +1,7 @@ import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useImplicitAccountCreationAddressState } from "~/helpers/nova/hooks/useImplicitAccountCreationAddressState"; +import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import AssociatedOutputs from "./section/association/AssociatedOutputs"; @@ -9,7 +10,8 @@ interface ImplicitAccountCreationAddressViewProps { } const ImplicitAccountCreationAddressView: React.FC = ({ implicitAccountCreationAddress }) => { - const { implicitAccountCreationAddressDetails } = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); + const [state] = useImplicitAccountCreationAddressState(implicitAccountCreationAddress); + const { implicitAccountCreationAddressDetails, totalBalance, availableBalance } = state; return (
@@ -30,6 +32,13 @@ const ImplicitAccountCreationAddressView: React.FC
+ {totalBalance !== null && ( + + )}
diff --git a/client/src/app/components/nova/address/NftAddressView.tsx b/client/src/app/components/nova/address/NftAddressView.tsx index f898f124f..e47ab889b 100644 --- a/client/src/app/components/nova/address/NftAddressView.tsx +++ b/client/src/app/components/nova/address/NftAddressView.tsx @@ -2,6 +2,7 @@ import { NftAddress } from "@iota/sdk-wasm-nova/web"; import React from "react"; import { useNftAddressState } from "~/helpers/nova/hooks/useNftAddressState"; import Spinner from "../../Spinner"; +import AddressBalance from "./AddressBalance"; import Bech32Address from "./Bech32Address"; import AssociatedOutputs from "./section/association/AssociatedOutputs"; @@ -10,7 +11,8 @@ interface NftAddressViewProps { } const NftAddressView: React.FC = ({ nftAddress }) => { - const { nftAddressDetails, isNftDetailsLoading } = useNftAddressState(nftAddress); + const [state] = useNftAddressState(nftAddress); + const { nftAddressDetails, totalBalance, availableBalance, isNftDetailsLoading } = state; const isPageLoading = isNftDetailsLoading; return ( @@ -33,6 +35,13 @@ const NftAddressView: React.FC = ({ nftAddress }) => {
+ {totalBalance !== null && ( + + )}
diff --git a/client/src/helpers/nova/hooks/useAccountAddressState.ts b/client/src/helpers/nova/hooks/useAccountAddressState.ts index e6a65e878..2ffcb4698 100644 --- a/client/src/helpers/nova/hooks/useAccountAddressState.ts +++ b/client/src/helpers/nova/hooks/useAccountAddressState.ts @@ -6,16 +6,21 @@ import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IAccountAddressState { accountAddressDetails: IAddressDetails | null; accountOutput: AccountOutput | null; + totalBalance: number | null; + availableBalance: number | null; isAccountDetailsLoading: boolean; } const initialState = { accountAddressDetails: null, accountOutput: null, + totalBalance: null, + availableBalance: null, isAccountDetailsLoading: true, }; @@ -26,7 +31,7 @@ interface IAddressPageLocationProps { addressDetails: IAddressDetails; } -export const useAccountAddressState = (address: AccountAddress): IAccountAddressState => { +export const useAccountAddressState = (address: AccountAddress): [IAccountAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,6 +41,7 @@ export const useAccountAddressState = (address: AccountAddress): IAccountAddress ); const { accountOutput, isLoading: isAccountDetailsLoading } = useAccountDetails(network, address.accountId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.accountAddressDetails, accountOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -53,12 +59,10 @@ export const useAccountAddressState = (address: AccountAddress): IAccountAddress setState({ accountOutput, isAccountDetailsLoading, + totalBalance, + availableBalance, }); - }, [accountOutput, isAccountDetailsLoading]); + }, [accountOutput, totalBalance, availableBalance, isAccountDetailsLoading]); - return { - accountAddressDetails: state.accountAddressDetails, - accountOutput: state.accountOutput, - isAccountDetailsLoading: state.isAccountDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useAddressBalance.ts b/client/src/helpers/nova/hooks/useAddressBalance.ts new file mode 100644 index 000000000..b7d0dd1cd --- /dev/null +++ b/client/src/helpers/nova/hooks/useAddressBalance.ts @@ -0,0 +1,57 @@ +import { AddressType, NftOutput, AccountOutput, AnchorOutput } from "@iota/sdk-wasm-nova/web"; +import { useEffect, useState } from "react"; +import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; +import { NovaApiClient } from "~/services/nova/novaApiClient"; +import { ServiceFactory } from "~factories/serviceFactory"; +import { useIsMounted } from "~helpers/hooks/useIsMounted"; +import { NOVA } from "~models/config/protocolVersion"; + +/** + * Fetch the address balance from chronicle nova. + * @param network The Network in context + * @param address The bech32 address + * @param output The output wrapping the address, used to add the output amount to the balance + * @returns The address balance, signature locked balance and a loading bool. + */ +export function useAddressBalance( + network: string, + addressDetails: IAddressDetails | null, + output: AccountOutput | NftOutput | AnchorOutput | null, +): { totalBalance: number | null; availableBalance: number | null; isLoading: boolean } { + const isMounted = useIsMounted(); + const [apiClient] = useState(ServiceFactory.get(`api-client-${NOVA}`)); + const [totalBalance, setTotalBalance] = useState(null); + const [availableBalance, setAvailableBalance] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + const address = addressDetails?.bech32; + const needsOutputToProceed = + addressDetails?.type === AddressType.Account || + addressDetails?.type === AddressType.Nft || + addressDetails?.type === AddressType.Anchor; + const canLoad = address && (!needsOutputToProceed || (needsOutputToProceed && output)); + 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 (output) { + totalBalance = Number(totalBalance) + Number(output.amount); + availableBalance = Number(availableBalance) + Number(output.amount); + } + setTotalBalance(totalBalance); + setAvailableBalance(availableBalance > 0 ? availableBalance : null); + } + })(); + } else { + setIsLoading(false); + } + }, [network, addressDetails, output]); + + return { totalBalance, availableBalance, isLoading }; +} diff --git a/client/src/helpers/nova/hooks/useAnchorAddressState.ts b/client/src/helpers/nova/hooks/useAnchorAddressState.ts index d37f6580f..9392ffb39 100644 --- a/client/src/helpers/nova/hooks/useAnchorAddressState.ts +++ b/client/src/helpers/nova/hooks/useAnchorAddressState.ts @@ -6,16 +6,21 @@ import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IAnchorAddressState { anchorAddressDetails: IAddressDetails | null; anchorOutput: AnchorOutput | null; + availableBalance: number | null; + totalBalance: number | null; isAnchorDetailsLoading: boolean; } const initialState = { anchorAddressDetails: null, anchorOutput: null, + totalBalance: null, + availableBalance: null, isAnchorDetailsLoading: true, }; @@ -26,7 +31,7 @@ interface IAddressPageLocationProps { addressDetails: IAddressDetails; } -export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressState => { +export const useAnchorAddressState = (address: AnchorAddress): [IAnchorAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,6 +41,7 @@ export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressSta ); const { anchorOutput, isLoading: isAnchorDetailsLoading } = useAnchorDetails(network, address.anchorId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.anchorAddressDetails, anchorOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -52,13 +58,11 @@ export const useAnchorAddressState = (address: AnchorAddress): IAnchorAddressSta useEffect(() => { setState({ anchorOutput, + totalBalance, + availableBalance, isAnchorDetailsLoading, }); - }, [anchorOutput, isAnchorDetailsLoading]); + }, [anchorOutput, totalBalance, availableBalance, isAnchorDetailsLoading]); - return { - anchorAddressDetails: state.anchorAddressDetails, - anchorOutput: state.anchorOutput, - isAnchorDetailsLoading: state.isAnchorDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useEd25519AddressState.ts b/client/src/helpers/nova/hooks/useEd25519AddressState.ts index aa1f0859d..eba0b46f0 100644 --- a/client/src/helpers/nova/hooks/useEd25519AddressState.ts +++ b/client/src/helpers/nova/hooks/useEd25519AddressState.ts @@ -4,13 +4,18 @@ import { useLocation } from "react-router-dom"; import { useNetworkInfoNova } from "../networkInfo"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IEd25519AddressState { ed25519AddressDetails: IAddressDetails | null; + totalBalance: number | null; + availableBalance: number | null; } const initialState = { ed25519AddressDetails: null, + totalBalance: null, + availableBalance: null, }; /** @@ -20,14 +25,16 @@ interface IAddressPageLocationProps { addressDetails: IAddressDetails; } -export const useEd25519AddressState = (address: Ed25519Address) => { +export const useEd25519AddressState = (address: Ed25519Address): [IEd25519AddressState, React.Dispatch>] => { const location = useLocation(); - const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); + const { name: network, bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); const [state, setState] = useReducer>>( (currentState, newState) => ({ ...currentState, ...newState }), initialState, ); + const { totalBalance, availableBalance } = useAddressBalance(network, state.ed25519AddressDetails, null); + useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails @@ -35,12 +42,16 @@ export const useEd25519AddressState = (address: Ed25519Address) => { : { addressDetails: AddressHelper.buildAddress(bech32Hrp, address) }; setState({ - ...initialState, ed25519AddressDetails: addressDetails, }); }, []); - return { - ed25519AddressDetails: state.ed25519AddressDetails, - }; + useEffect(() => { + setState({ + totalBalance, + availableBalance, + }); + }, [totalBalance, availableBalance]); + + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts index 6f7131cc9..bf5052233 100644 --- a/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts +++ b/client/src/helpers/nova/hooks/useImplicitAccountCreationAddressState.ts @@ -1,16 +1,22 @@ import { ImplicitAccountCreationAddress } from "@iota/sdk-wasm-nova/web"; import { Reducer, useEffect, useReducer } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; +import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { IAddressDetails } from "~/models/api/nova/IAddressDetails"; import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface IImplicitAccountCreationAddressState { implicitAccountCreationAddressDetails: IAddressDetails | null; + totalBalance: number | null; + availableBalance: number | null; } const initialState = { implicitAccountCreationAddressDetails: null, + totalBalance: null, + availableBalance: null, }; /** @@ -20,14 +26,19 @@ interface IAddressPageLocationProps { addressDetails: IAddressDetails; } -export const useImplicitAccountCreationAddressState = (address: ImplicitAccountCreationAddress) => { +export const useImplicitAccountCreationAddressState = ( + address: ImplicitAccountCreationAddress, +): [IImplicitAccountCreationAddressState, React.Dispatch>] => { const location = useLocation(); + const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); const [state, setState] = useReducer>>( (currentState, newState) => ({ ...currentState, ...newState }), initialState, ); + const { totalBalance, availableBalance } = useAddressBalance(network, state.implicitAccountCreationAddressDetails, null); + useEffect(() => { const locationState = location.state as IAddressPageLocationProps; const { addressDetails } = locationState?.addressDetails @@ -40,7 +51,12 @@ export const useImplicitAccountCreationAddressState = (address: ImplicitAccountC }); }, []); - return { - implicitAccountCreationAddressDetails: state.implicitAccountCreationAddressDetails, - }; + useEffect(() => { + setState({ + totalBalance, + availableBalance, + }); + }, [totalBalance, availableBalance]); + + return [state, setState]; }; diff --git a/client/src/helpers/nova/hooks/useNftAddressState.ts b/client/src/helpers/nova/hooks/useNftAddressState.ts index 8cdd33b49..0b8035ade 100644 --- a/client/src/helpers/nova/hooks/useNftAddressState.ts +++ b/client/src/helpers/nova/hooks/useNftAddressState.ts @@ -6,10 +6,13 @@ import { useLocation, useParams } from "react-router-dom"; import { AddressRouteProps } from "~/app/routes/AddressRouteProps"; import { useNetworkInfoNova } from "../networkInfo"; import { AddressHelper } from "~/helpers/nova/addressHelper"; +import { useAddressBalance } from "./useAddressBalance"; export interface INftAddressState { nftAddressDetails: IAddressDetails | null; nftOutput: NftOutput | null; + totalBalance: number | null; + availableBalance: number | null; isNftDetailsLoading: boolean; } @@ -17,6 +20,8 @@ const initialState = { nftAddressDetails: null, nftOutput: null, isNftDetailsLoading: true, + totalBalance: null, + availableBalance: null, }; /** @@ -26,7 +31,7 @@ interface IAddressPageLocationProps { addressDetails: IAddressDetails; } -export const useNftAddressState = (address: NftAddress): INftAddressState => { +export const useNftAddressState = (address: NftAddress): [INftAddressState, React.Dispatch>] => { const location = useLocation(); const { network } = useParams(); const { bech32Hrp } = useNetworkInfoNova((s) => s.networkInfo); @@ -36,6 +41,7 @@ export const useNftAddressState = (address: NftAddress): INftAddressState => { ); const { nftOutput, isLoading: isNftDetailsLoading } = useNftDetails(network, address.nftId); + const { totalBalance, availableBalance } = useAddressBalance(network, state.nftAddressDetails, nftOutput); useEffect(() => { const locationState = location.state as IAddressPageLocationProps; @@ -52,13 +58,11 @@ export const useNftAddressState = (address: NftAddress): INftAddressState => { useEffect(() => { setState({ nftOutput, + totalBalance, + availableBalance, isNftDetailsLoading, }); - }, [nftOutput, isNftDetailsLoading]); + }, [nftOutput, totalBalance, availableBalance, isNftDetailsLoading]); - return { - nftAddressDetails: state.nftAddressDetails, - nftOutput: state.nftOutput, - isNftDetailsLoading: state.isNftDetailsLoading, - }; + return [state, setState]; }; diff --git a/client/src/models/api/nova/address/IAddressBalanceRequest.ts b/client/src/models/api/nova/address/IAddressBalanceRequest.ts new file mode 100644 index 000000000..7b632de16 --- /dev/null +++ b/client/src/models/api/nova/address/IAddressBalanceRequest.ts @@ -0,0 +1,11 @@ +export interface IAddressBalanceRequest { + /** + * The network to search on. + */ + network: string; + + /** + * The bech32 address to get the balance for. + */ + address: string; +} diff --git a/client/src/models/api/nova/address/IAddressBalanceResponse.ts b/client/src/models/api/nova/address/IAddressBalanceResponse.ts new file mode 100644 index 000000000..4e65c9c7b --- /dev/null +++ b/client/src/models/api/nova/address/IAddressBalanceResponse.ts @@ -0,0 +1,18 @@ +import { IResponse } from "../../IResponse"; + +export interface IAddressBalanceResponse extends IResponse { + /** + * The total balance (including Expiration, Timelock and StorageDepositReturn outputs) + */ + totalBalance?: number; + + /** + * The balance of trivialy unlockable outputs with address unlock condition. + */ + availableBalance?: number; + + /** + * The ledger index at which this balance data was valid. + */ + ledgerIndex?: number; +} diff --git a/client/src/services/nova/novaApiClient.ts b/client/src/services/nova/novaApiClient.ts index 8264a7c47..bd466c835 100644 --- a/client/src/services/nova/novaApiClient.ts +++ b/client/src/services/nova/novaApiClient.ts @@ -1,4 +1,6 @@ import { INetworkBoundGetRequest } from "~/models/api/INetworkBoundGetRequest"; +import { IAddressBalanceRequest } from "~/models/api/nova/address/IAddressBalanceRequest"; +import { IAddressBalanceResponse } from "~/models/api/nova/address/IAddressBalanceResponse"; import { IBlockRequest } from "~/models/api/nova/block/IBlockRequest"; import { IBlockResponse } from "~/models/api/nova/block/IBlockResponse"; import { IOutputDetailsRequest } from "~/models/api/IOutputDetailsRequest"; @@ -35,6 +37,15 @@ export class NovaApiClient extends ApiClient { return this.callApi(`node-info/${request.network}`, "get"); } + /** + * Get the balance of and address from chronicle. + * @param request The Address Balance request. + * @returns The Address balance reponse + */ + public async addressBalanceChronicle(request: IAddressBalanceRequest): Promise { + return this.callApi(`nova/balance/chronicle/${request.network}/${request.address}`, "get"); + } + /** * Get a block. * @param request The request to send.