Skip to content

Commit

Permalink
Feat: Add address balance to address pages (#1118)
Browse files Browse the repository at this point in the history
* feat: Add ChornicleService for nova. Add endpoint to fetch address balance.

* feat: Add AddressBalance hook and component. Include AddressBalance in Ed25519AddressView.

* feat: Add AddressBalance.scss

* feat: Hook the addressBalance hook in addressState hooks. Render AddressBalance in AddressView components.

* feat: Return state and state setter from address state hooks and destructure in AddressViews

* fix: Improve a css class naming

* feat: Improve available balance JSdoc in IAddressBalanceResponse

---------

Co-authored-by: Begoña Alvarez <[email protected]>
  • Loading branch information
msarcev and begonaalvarezd authored Feb 14, 2024
1 parent 788ea46 commit 7093e7b
Show file tree
Hide file tree
Showing 21 changed files with 506 additions and 37 deletions.
11 changes: 11 additions & 0 deletions api/src/models/api/nova/chronicle/IAddressBalanceRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface IAddressBalanceRequest {
/**
* The network to search on.
*/
network: string;

/**
* The bech32 address to get the balance for.
*/
address: string;
}
18 changes: 18 additions & 0 deletions api/src/models/api/nova/chronicle/IAddressBalanceResponse.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
34 changes: 34 additions & 0 deletions api/src/routes/nova/address/balance/chronicle/get.ts
Original file line number Diff line number Diff line change
@@ -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<IAddressBalanceResponse> {
const networkService = ServiceFactory.get<NetworkService>("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<ChronicleService>(`chronicle-${networkConfig.network}`);

return chronicleService.addressBalance(request.address);
}
43 changes: 43 additions & 0 deletions api/src/services/nova/chronicleService.ts
Original file line number Diff line number Diff line change
@@ -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<IAddressBalanceResponse | undefined> {
try {
return await FetchHelper.json<never, IAddressBalanceResponse>(
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}`);
}
}
}
11 changes: 10 additions & 1 deletion client/src/app/components/nova/address/AccountAddressView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountAddressViewProps> = ({ accountAddress }) => {
const { accountAddressDetails, isAccountDetailsLoading } = useAccountAddressState(accountAddress);
const [state] = useAccountAddressState(accountAddress);
const { accountAddressDetails, totalBalance, availableBalance, isAccountDetailsLoading } = state;
const isPageLoading = isAccountDetailsLoading;

return (
Expand All @@ -33,6 +35,13 @@ const AccountAddressView: React.FC<AccountAddressViewProps> = ({ accountAddress
<div className="general-content">
<div className="section--data">
<Bech32Address addressDetails={accountAddressDetails} advancedMode={true} />
{totalBalance !== null && (
<AddressBalance
totalBalance={totalBalance}
availableBalance={availableBalance}
storageDeposit={null}
/>
)}
</div>
</div>
</div>
Expand Down
77 changes: 77 additions & 0 deletions client/src/app/components/nova/address/AddressBalance.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
}
99 changes: 99 additions & 0 deletions client/src/app/components/nova/address/AddressBalance.tsx
Original file line number Diff line number Diff line change
@@ -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<AddressBalanceProps> = ({ 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<React.SetStateAction<boolean>>,
showInfo: boolean,
showWallet: boolean,
amount: number | null,
) => (
<div className="balance">
{showWallet && <Icon icon="wallet" boxed />}
<div>
<div className="row middle balance-heading">
<div className="label">{label}</div>
{showInfo && (
<Tooltip tooltipContent={CONDITIONAL_BALANCE_INFO}>
<span className="material-icons">info</span>
</Tooltip>
)}
</div>
<div className="value featured">
{amount !== null && amount > 0 ? (
<div className="balance-value middle">
<div className="row middle">
<span onClick={() => setIsFormatFull(!isFormatFull)} className="balance-base-token pointer margin-r-5">
{formatAmount(amount, tokenInfo, isFormatFull)}
</span>
<CopyButton copy={String(amount)} />
</div>
</div>
) : (
<span className="margin-r-5">0</span>
)}
</div>
</div>
</div>
);

const conditionalBalance = availableBalance === null ? undefined : totalBalance - availableBalance;
const shouldShowExtendedBalance = conditionalBalance !== undefined && availableBalance !== undefined;

return (
<div className="row middle balance-wrapper">
<div className="balance-wrapper--inner">
{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)}
</div>
</div>
);
};

export default AddressBalance;
11 changes: 10 additions & 1 deletion client/src/app/components/nova/address/AnchorAddressView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -10,7 +11,8 @@ interface AnchorAddressViewProps {
}

const AnchorAddressView: React.FC<AnchorAddressViewProps> = ({ anchorAddress }) => {
const { anchorAddressDetails, isAnchorDetailsLoading } = useAnchorAddressState(anchorAddress);
const [state] = useAnchorAddressState(anchorAddress);
const { anchorAddressDetails, totalBalance, availableBalance, isAnchorDetailsLoading } = state;
const isPageLoading = isAnchorDetailsLoading;

return (
Expand All @@ -33,6 +35,13 @@ const AnchorAddressView: React.FC<AnchorAddressViewProps> = ({ anchorAddress })
<div className="general-content">
<div className="section--data">
<Bech32Address addressDetails={anchorAddressDetails} advancedMode={true} />
{totalBalance !== null && (
<AddressBalance
totalBalance={totalBalance}
availableBalance={availableBalance}
storageDeposit={null}
/>
)}
</div>
</div>
</div>
Expand Down
11 changes: 10 additions & 1 deletion client/src/app/components/nova/address/Ed25519AddressView.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,7 +10,8 @@ interface Ed25519AddressViewProps {
}

const Ed25519AddressView: React.FC<Ed25519AddressViewProps> = ({ ed25519Address }) => {
const { ed25519AddressDetails } = useEd25519AddressState(ed25519Address);
const [state] = useEd25519AddressState(ed25519Address);
const { ed25519AddressDetails, totalBalance, availableBalance } = state;

return (
<div className="address-page">
Expand All @@ -30,6 +32,13 @@ const Ed25519AddressView: React.FC<Ed25519AddressViewProps> = ({ ed25519Address
<div className="general-content">
<div className="section--data">
<Bech32Address addressDetails={ed25519AddressDetails} advancedMode={true} />
{totalBalance !== null && (
<AddressBalance
totalBalance={totalBalance}
availableBalance={availableBalance}
storageDeposit={null}
/>
)}
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit 7093e7b

Please sign in to comment.