Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add address balance to address pages #1118

Merged
merged 8 commits into from
Feb 14, 2024
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
Loading