From e16d9d1b98b87eb923d61d3357be157571f07ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n=20Kh=E1=BA=AFc?= Date: Tue, 6 Aug 2024 14:46:44 +1200 Subject: [PATCH] feat: hook for getting account's spendable balance --- .changeset/poor-ghosts-obey.md | 5 + .changeset/serious-mayflies-sneeze.md | 5 + apps/example/src/app.tsx | 93 ++++++++++++++----- packages/react/src/hooks/use-balance.ts | 85 +++++++++++++++++ .../src/hooks/use-native-token-amount.ts | 4 +- packages/react/src/index.ts | 1 + packages/utils/src/big-int.test.ts | 26 ++++++ packages/utils/src/big-int.ts | 23 +++++ packages/utils/src/index.ts | 1 + packages/utils/vitest.config.ts | 3 + 10 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 .changeset/poor-ghosts-obey.md create mode 100644 .changeset/serious-mayflies-sneeze.md create mode 100644 packages/react/src/hooks/use-balance.ts create mode 100644 packages/utils/src/big-int.test.ts create mode 100644 packages/utils/src/big-int.ts create mode 100644 packages/utils/vitest.config.ts diff --git a/.changeset/poor-ghosts-obey.md b/.changeset/poor-ghosts-obey.md new file mode 100644 index 00000000..96bad278 --- /dev/null +++ b/.changeset/poor-ghosts-obey.md @@ -0,0 +1,5 @@ +--- +"@reactive-dot/utils": minor +--- + +Add utilities for getting the minimum and/or maximum value from a list of big integers. diff --git a/.changeset/serious-mayflies-sneeze.md b/.changeset/serious-mayflies-sneeze.md new file mode 100644 index 00000000..b7dcab6b --- /dev/null +++ b/.changeset/serious-mayflies-sneeze.md @@ -0,0 +1,5 @@ +--- +"@reactive-dot/react": minor +--- + +Add hook for getting account's spendable balance. diff --git a/apps/example/src/app.tsx b/apps/example/src/app.tsx index 9da09f5d..e9ad367a 100644 --- a/apps/example/src/app.tsx +++ b/apps/example/src/app.tsx @@ -1,5 +1,10 @@ import { config } from "./config"; -import { IDLE, MutationError, PENDING } from "@reactive-dot/core"; +import { + IDLE, + MutationError, + PENDING, + type PolkadotAccount, +} from "@reactive-dot/core"; import type { Wallet } from "@reactive-dot/core/wallets.js"; import { ReDotChainProvider, @@ -13,6 +18,7 @@ import { useMutationEffect, useNativeTokenAmountFromPlanck, useQueryErrorResetter, + useSpendableBalance, useWalletConnector, useWalletDisconnector, useWallets, @@ -23,11 +29,16 @@ import { Suspense, useState, useTransition } from "react"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import toast, { Toaster } from "react-hot-toast"; -function PendingRewards(props: { address: string; rewards: bigint }) { +type PendingRewardsProps = { + account: PolkadotAccount; + rewards: bigint; +}; + +function PendingRewards({ account, rewards }: PendingRewardsProps) { return (
  • - {props.address}:{" "} - {useNativeTokenAmountFromPlanck(props.rewards).toLocaleString()} + {account.name ?? account.address}:{" "} + {useNativeTokenAmountFromPlanck(rewards).toLocaleString()}
  • ); } @@ -68,7 +79,7 @@ function PendingPoolRewards() { ))} @@ -77,6 +88,46 @@ function PendingPoolRewards() { ); } +type SpendableBalanceProps = { + account: PolkadotAccount; +}; + +function SpendableBalance({ account }: SpendableBalanceProps) { + return ( +
  • + {account.name ?? account.address}:{" "} + {useSpendableBalance(account.address).toLocaleString()} +
  • + ); +} + +function SpendableBalances() { + const accounts = useAccounts(); + + if (accounts.length === 0) { + return ( +
    +

    Balances

    +

    Please connect accounts to see balances

    +
    + ); + } + + return ( +
    +

    Spendable balances

    +
      + {accounts.map((account) => ( + + ))} +
    +
    + ); +} + function Query() { const block = useBlock(); @@ -159,6 +210,7 @@ function Query() {

    {x.asText()}

    ))} + ); @@ -168,16 +220,16 @@ type WalletItemProps = { wallet: Wallet; }; -function WalletItem(props: WalletItemProps) { +function WalletItem({ wallet }: WalletItemProps) { const connectedWallets = useConnectedWallets(); - const [connectingState, connect] = useWalletConnector(props.wallet); - const [disconnectingState, disconnect] = useWalletDisconnector(props.wallet); + const [connectingState, connect] = useWalletConnector(wallet); + const [disconnectingState, disconnect] = useWalletDisconnector(wallet); return (
  • - {props.wallet.name}:{" "} - {connectedWallets.includes(props.wallet) ? ( + {wallet.name}:{" "} + {connectedWallets.includes(wallet) ? ( @@ -315,7 +364,7 @@ function ErrorFallback(props: FallbackProps) { type ExampleProps = { chainName: string }; -function Example(props: ExampleProps) { +function Example({ chainName }: ExampleProps) { const resetQueryError = useQueryErrorResetter(); useMutationEffect((event) => { @@ -353,8 +402,8 @@ function Example(props: ExampleProps) { } }} > - Loading {props.chainName}...}> -

    {props.chainName}

    + Loading {chainName}...}> +

    {chainName}

    @@ -373,14 +422,10 @@ export function App() { - Loading Kusama...}> - - + - Loading Westend...}> - - + diff --git a/packages/react/src/hooks/use-balance.ts b/packages/react/src/hooks/use-balance.ts new file mode 100644 index 00000000..d8e6413e --- /dev/null +++ b/packages/react/src/hooks/use-balance.ts @@ -0,0 +1,85 @@ +import type { ChainHookOptions } from "./types.js"; +import { useNativeTokenAmountFromPlanck } from "./use-native-token-amount.js"; +import { useLazyLoadQuery } from "./use-query.js"; +import { type DenominatedNumber, BigIntMath } from "@reactive-dot/utils"; +import type { SS58String } from "polkadot-api"; + +type SystemAccount = { + nonce: number; + consumers: number; + providers: number; + sufficients: number; + data: { + free: bigint; + reserved: bigint; + frozen: bigint; + flags: bigint; + }; +}; + +type Options = ChainHookOptions & { + includesExistentialDeposit?: boolean; +}; + +/** + * Hook for getting an account's spendable balance. + * + * @param address - The account's address + * @param options - Additional options + * @returns The account's spendable balance + */ +export function useSpendableBalance( + address: SS58String, + options?: Options, +): DenominatedNumber; +/** + * Hook for getting accounts’ spendable balances. + * + * @param addresses - The account-addresses + * @param options - Additional options + * @returns The accounts’ spendable balances + */ +export function useSpendableBalance( + addresses: SS58String[], + options?: Options, +): DenominatedNumber[]; +export function useSpendableBalance( + addressOrAddresses: SS58String | SS58String[], + { includesExistentialDeposit = false, ...options }: Options = {}, +): DenominatedNumber | DenominatedNumber[] { + const addresses = Array.isArray(addressOrAddresses) + ? addressOrAddresses + : [addressOrAddresses]; + + const [existentialDeposit, accounts] = useLazyLoadQuery( + (builder) => + builder.getConstant("Balances", "ExistentialDeposit").readStorages( + "System", + "Account", + addresses.map((address) => [address]), + ), + options, + ) as [bigint, SystemAccount[]]; + + const nativeTokenFromPlanck = useNativeTokenAmountFromPlanck(options); + + const spendableNativeTokenFromAccount = ({ + data: { free, reserved, frozen }, + }: SystemAccount) => + nativeTokenFromPlanck( + BigIntMath.max( + 0n, + free - + BigIntMath.max( + frozen - reserved, + includesExistentialDeposit ? 0n : existentialDeposit, + ), + ), + ); + + const spendableBalances = accounts.map(spendableNativeTokenFromAccount); + + return Array.isArray(addressOrAddresses) + ? spendableBalances + : spendableBalances.at(0)!; +} diff --git a/packages/react/src/hooks/use-native-token-amount.ts b/packages/react/src/hooks/use-native-token-amount.ts index bc2faa0d..a1b741a8 100644 --- a/packages/react/src/hooks/use-native-token-amount.ts +++ b/packages/react/src/hooks/use-native-token-amount.ts @@ -3,7 +3,7 @@ import { useChainSpecData } from "./use-chain-spec-data.js"; import { DenominatedNumber } from "@reactive-dot/utils"; /** - * Hook for returning the native token amount from a planck value + * Hook for returning the native token amount from a planck value. * * @param planck - The planck value * @param options - Additional options @@ -14,7 +14,7 @@ export function useNativeTokenAmountFromPlanck( options?: ChainHookOptions, ): DenominatedNumber; /** - * Hook for returning a function that converts planck value to native token amount + * Hook for returning a function that converts planck value to native token amount. * * @param options - Additional options * @returns Function for getting the native token amount from a planck value diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 32fe25b4..7ceaee3d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -14,6 +14,7 @@ export { useChainSpecData } from "./hooks/use-chain-spec-data.js"; export { useClient } from "./hooks/use-client.js"; export { useMutationEffect } from "./hooks/use-mutation-effect.js"; export { useMutation } from "./hooks/use-mutation.js"; +export { useSpendableBalance } from "./hooks/use-balance.js"; export { useNativeTokenAmountFromNumber, useNativeTokenAmountFromPlanck, diff --git a/packages/utils/src/big-int.test.ts b/packages/utils/src/big-int.test.ts new file mode 100644 index 00000000..e3f0cc37 --- /dev/null +++ b/packages/utils/src/big-int.test.ts @@ -0,0 +1,26 @@ +import { BigIntMath } from "./big-int"; +import { describe, expect, it } from "vitest"; + +describe("BigIntMath", () => { + describe("min", () => { + it("gets the correct minimum value", () => + expect(BigIntMath.min(-1n, 0n, 1n)).toBe(-1n)); + + it("returns the first value if only one value is passed", () => + expect(BigIntMath.min(1n)).toBe(1n)); + + it("returns 0n if no values is passed", () => + expect(BigIntMath.min()).toBe(0n)); + }); + + describe("max", () => { + it("gets the correct maximum value", () => + expect(BigIntMath.max(-1n, 0n, 1n)).toBe(1n)); + + it("returns the first value if only one value is passed", () => + expect(BigIntMath.max(-1n)).toBe(-1n)); + + it("returns 0n if no values is passed", () => + expect(BigIntMath.max()).toBe(0n)); + }); +}); diff --git a/packages/utils/src/big-int.ts b/packages/utils/src/big-int.ts new file mode 100644 index 00000000..f625c68b --- /dev/null +++ b/packages/utils/src/big-int.ts @@ -0,0 +1,23 @@ +export const BigIntMath = Object.freeze({ + min(...values: bigint[]) { + if (values.length === 0) { + return 0n; + } + + return values.reduce((previousValue, currentValue) => + currentValue < previousValue ? currentValue : previousValue, + ); + }, + max(...values: bigint[]) { + if (values.length === 0) { + return 0n; + } + + return values.reduce((previousValue, currentValue) => + currentValue > previousValue ? currentValue : previousValue, + ); + }, + get [Symbol.toStringTag]() { + return "BigIntMath"; + }, +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7a7cd929..ca91ef06 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1 +1,2 @@ +export { BigIntMath } from "./big-int.js"; export { DenominatedNumber } from "./denominated-number.js"; diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts new file mode 100644 index 00000000..8fb6f2dc --- /dev/null +++ b/packages/utils/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({});