From ee5d6a305cd1bfe9213ea82d5c81d0e1bcce2dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n=20Kh=E1=BA=AFc?= Date: Wed, 13 Nov 2024 21:35:45 +1300 Subject: [PATCH] feat(vue): `useNativeToken` & `useSpendableBalance` composables (#334) --- .changeset/ninety-jeans-behave.md | 7 ++ examples/vue/src/query-section.vue | 33 +++-- packages/core/package.json | 1 + packages/core/src/maths/index.ts | 1 + .../core/src/maths/spendable-balance.test.ts | 24 ++++ packages/core/src/maths/spendable-balance.ts | 26 ++++ packages/react/src/hooks/use-balance.ts | 42 ++++--- packages/vue/src/composables/use-accounts.ts | 4 +- .../vue/src/composables/use-async-data.ts | 4 +- packages/vue/src/composables/use-balance.ts | 118 +++++++++++++++++ .../src/composables/use-chain-spec-data.ts | 4 +- .../vue/src/composables/use-native-token.ts | 45 +++++++ packages/vue/src/composables/use-query.ts | 119 ++++++++++-------- packages/vue/src/index.ts | 2 + packages/vue/src/types.ts | 6 + packages/vue/src/utils/refreshable.ts | 2 +- 16 files changed, 351 insertions(+), 87 deletions(-) create mode 100644 .changeset/ninety-jeans-behave.md create mode 100644 packages/core/src/maths/index.ts create mode 100644 packages/core/src/maths/spendable-balance.test.ts create mode 100644 packages/core/src/maths/spendable-balance.ts create mode 100644 packages/vue/src/composables/use-balance.ts create mode 100644 packages/vue/src/composables/use-native-token.ts diff --git a/.changeset/ninety-jeans-behave.md b/.changeset/ninety-jeans-behave.md new file mode 100644 index 00000000..b96e66ad --- /dev/null +++ b/.changeset/ninety-jeans-behave.md @@ -0,0 +1,7 @@ +--- +"@reactive-dot/core": minor +"@reactive-dot/vue": minor +"@reactive-dot/react": patch +--- + +Added `useNativeToken` and `useSpendableBalance` composables. diff --git a/examples/vue/src/query-section.vue b/examples/vue/src/query-section.vue index de85ebd8..d8d3e3de 100644 --- a/examples/vue/src/query-section.vue +++ b/examples/vue/src/query-section.vue @@ -1,16 +1,27 @@ @@ -24,11 +35,11 @@ const { data } = await useQuery((builder) =>
{{ data[0].toLocaleString() }}
Total issuance
-
{{ data[1].toLocaleString() }}
+
{{ totalIssuance.toLocaleString() }}
-
-
Balance of: {{ accounts?.at(index)?.address }}
-
{{ balance.data.free.toLocaleString() }}
+
+
Balance of: {{ accounts.at(index)?.address }}
+
{{ balance.toLocaleString() }}
diff --git a/packages/core/package.json b/packages/core/package.json index b5efba4c..fff845cf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ "exports": { ".": "./build/index.js", "./wallets.js": "./build/wallets/index.js", + "./internal/maths.js": "./build/maths/index.js", "./internal.js": "./build/internal.js" }, "scripts": { diff --git a/packages/core/src/maths/index.ts b/packages/core/src/maths/index.ts new file mode 100644 index 00000000..f72746d6 --- /dev/null +++ b/packages/core/src/maths/index.ts @@ -0,0 +1 @@ +export { spendableBalance } from "./spendable-balance.js"; diff --git a/packages/core/src/maths/spendable-balance.test.ts b/packages/core/src/maths/spendable-balance.test.ts new file mode 100644 index 00000000..c8115e6b --- /dev/null +++ b/packages/core/src/maths/spendable-balance.test.ts @@ -0,0 +1,24 @@ +import { spendableBalance } from "./spendable-balance"; +import { expect, test } from "vitest"; + +// https://wiki.polkadot.network/docs/learn-account-balances +test.each([ + { free: 100n, frozen: 0n, reserved: 0n, spendable: 99n }, + { free: 100n, frozen: 80n, reserved: 0n, spendable: 20n }, + { free: 80n, frozen: 80n, reserved: 20n, spendable: 20n }, +])( + "$spendable = $free - max($frozen - $reserved, existentialDeposit)", + ({ free, frozen, reserved, spendable }) => { + const existentialDeposit = 1n; + + expect( + spendableBalance({ + free, + frozen, + reserved, + existentialDeposit, + includesExistentialDeposit: false, + }), + ).toBe(spendable); + }, +); diff --git a/packages/core/src/maths/spendable-balance.ts b/packages/core/src/maths/spendable-balance.ts new file mode 100644 index 00000000..0dab58d1 --- /dev/null +++ b/packages/core/src/maths/spendable-balance.ts @@ -0,0 +1,26 @@ +import { BigIntMath } from "@reactive-dot/utils"; + +type SpendableBalanceParam = { + free: bigint; + reserved: bigint; + frozen: bigint; + existentialDeposit: bigint; + includesExistentialDeposit?: boolean; +}; + +export function spendableBalance({ + free, + reserved, + frozen, + existentialDeposit, + includesExistentialDeposit = false, +}: SpendableBalanceParam) { + return BigIntMath.max( + 0n, + free - + BigIntMath.max( + frozen - reserved, + includesExistentialDeposit ? 0n : existentialDeposit, + ), + ); +} diff --git a/packages/react/src/hooks/use-balance.ts b/packages/react/src/hooks/use-balance.ts index 2a944611..a2ac3104 100644 --- a/packages/react/src/hooks/use-balance.ts +++ b/packages/react/src/hooks/use-balance.ts @@ -1,8 +1,11 @@ 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 { flatHead } from "@reactive-dot/core/internal.js"; +import { spendableBalance } from "@reactive-dot/core/internal/maths.js"; +import { type DenominatedNumber } from "@reactive-dot/utils"; import type { SS58String } from "polkadot-api"; +import { useMemo } from "react"; type SystemAccount = { nonce: number; @@ -63,23 +66,26 @@ export function useSpendableBalance( const nativeTokenFromPlanck = useNativeTokenAmountFromPlanck(options); - const spendableNativeTokenFromAccount = ({ - data: { free, reserved, frozen }, - }: SystemAccount) => - nativeTokenFromPlanck( - BigIntMath.max( - 0n, - free - - BigIntMath.max( - frozen - reserved, - includesExistentialDeposit ? 0n : existentialDeposit, + return useMemo( + () => + flatHead( + accounts.map(({ data: { free, reserved, frozen } }) => + nativeTokenFromPlanck( + spendableBalance({ + free, + reserved, + frozen, + existentialDeposit, + includesExistentialDeposit, + }), ), + ), ), - ); - - const spendableBalances = accounts.map(spendableNativeTokenFromAccount); - - return Array.isArray(addressOrAddresses) - ? spendableBalances - : spendableBalances.at(0)!; + [ + accounts, + existentialDeposit, + includesExistentialDeposit, + nativeTokenFromPlanck, + ], + ); } diff --git a/packages/vue/src/composables/use-accounts.ts b/packages/vue/src/composables/use-accounts.ts index 25783d89..c9a0cfdd 100644 --- a/packages/vue/src/composables/use-accounts.ts +++ b/packages/vue/src/composables/use-accounts.ts @@ -1,7 +1,7 @@ import type { ChainComposableOptions } from "../types.js"; import { useAsyncData } from "./use-async-data.js"; import { internal_useChainId } from "./use-chain-id.js"; -import { useChainSpecPromise } from "./use-chain-spec-data.js"; +import { useChainSpecDataPromise } from "./use-chain-spec-data.js"; import { useLazyValue } from "./use-lazy-value.js"; import { useConnectedWalletsObservable } from "./use-wallets.js"; import { getAccounts } from "@reactive-dot/core"; @@ -20,7 +20,7 @@ export function useAccounts(options?: ChainComposableOptions) { function useAccountsPromise(options?: ChainComposableOptions) { const chainId = internal_useChainId({ ...options, optionalChainId: true }); const connectedWalletsObservable = useConnectedWalletsObservable(); - const chainSpecPromise = useChainSpecPromise(options); + const chainSpecPromise = useChainSpecDataPromise(options); return useLazyValue( computed(() => diff --git a/packages/vue/src/composables/use-async-data.ts b/packages/vue/src/composables/use-async-data.ts index 49dffd7a..7881b603 100644 --- a/packages/vue/src/composables/use-async-data.ts +++ b/packages/vue/src/composables/use-async-data.ts @@ -1,4 +1,4 @@ -import type { AsyncState } from "../types.js"; +import type { AsyncState, PromiseLikeAsyncState } from "../types.js"; import { refresh } from "../utils/refreshable.js"; import { useAsyncState } from "./use-async-state.js"; import type { lazyValue } from "./use-lazy-value.js"; @@ -101,5 +101,5 @@ export function useAsyncData< onfulfilled: () => unknown, onrejected: (reason: unknown) => unknown, ) => promiseLike.then(onfulfilled, onrejected), - } as AsyncState & PromiseLike>; + } as PromiseLikeAsyncState; } diff --git a/packages/vue/src/composables/use-balance.ts b/packages/vue/src/composables/use-balance.ts new file mode 100644 index 00000000..6011d71a --- /dev/null +++ b/packages/vue/src/composables/use-balance.ts @@ -0,0 +1,118 @@ +import type { + ChainComposableOptions, + PromiseLikeAsyncState, +} from "../types.js"; +import { useAsyncData } from "./use-async-data.js"; +import { internal_useChainId } from "./use-chain-id.js"; +import { useLazyValue } from "./use-lazy-value.js"; +import { useNativeTokenPromise } from "./use-native-token.js"; +import { useQueryObservable } from "./use-query.js"; +import { spendableBalance } from "@reactive-dot/core/internal/maths.js"; +import { type DenominatedNumber } from "@reactive-dot/utils"; +import type { SS58String } from "polkadot-api"; +import { combineLatest, from } from "rxjs"; +import { map } from "rxjs/operators"; +import { computed, type MaybeRefOrGetter, toValue } from "vue"; + +type SystemAccount = { + nonce: number; + consumers: number; + providers: number; + sufficients: number; + data: { + free: bigint; + reserved: bigint; + frozen: bigint; + flags: bigint; + }; +}; + +type Options = ChainComposableOptions & { + includesExistentialDeposit?: MaybeRefOrGetter; +}; + +/** + * Composable 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: MaybeRefOrGetter, + options?: Options, +): PromiseLikeAsyncState; +/** + * Composable for getting accounts’ spendable balances. + * + * @param addresses - The account-addresses + * @param options - Additional options + * @returns The accounts’ spendable balances + */ +export function useSpendableBalance( + addresses: MaybeRefOrGetter, + options?: Options, +): PromiseLikeAsyncState; +export function useSpendableBalance( + addressOrAddresses: MaybeRefOrGetter, + options?: Options, +): PromiseLikeAsyncState { + const chainId = internal_useChainId(options); + + const addresses = computed(() => { + const addressOrAddressesValue = toValue(addressOrAddresses); + return Array.isArray(addressOrAddressesValue) + ? toValue(addressOrAddressesValue) + : [toValue(addressOrAddressesValue)]; + }); + + const includesExistentialDeposit = computed( + () => toValue(options?.includesExistentialDeposit) ?? false, + ); + + const dataObservable = useQueryObservable( + (builder) => + builder.getConstant("Balances", "ExistentialDeposit").readStorages( + "System", + "Account", + addresses.value.map((address) => [address] as const), + ), + options, + ); + + const nativeTokenPromise = useNativeTokenPromise(options); + + return useAsyncData( + useLazyValue( + computed(() => [ + "spendable-balances", + chainId.value, + ...addresses.value.toSorted(), + ]), + () => { + const includesExistentialDepositValue = + includesExistentialDeposit.value; + + return combineLatest([ + dataObservable.value, + from(nativeTokenPromise.value), + ]).pipe( + map(([[existentialDeposit, accounts], { amountFromPlanck }]) => + (accounts as SystemAccount[]).map( + ({ data: { free, reserved, frozen } }) => + amountFromPlanck( + spendableBalance({ + free, + reserved, + frozen, + existentialDeposit: existentialDeposit as bigint, + includesExistentialDeposit: includesExistentialDepositValue, + }), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/packages/vue/src/composables/use-chain-spec-data.ts b/packages/vue/src/composables/use-chain-spec-data.ts index 872e2626..ee246fc9 100644 --- a/packages/vue/src/composables/use-chain-spec-data.ts +++ b/packages/vue/src/composables/use-chain-spec-data.ts @@ -12,13 +12,13 @@ import { computed } from "vue"; * @returns The [JSON-RPC spec](https://paritytech.github.io/json-rpc-interface-spec/api/chainSpec.html) */ export function useChainSpecData(options?: ChainComposableOptions) { - return useAsyncData(useChainSpecPromise(options)); + return useAsyncData(useChainSpecDataPromise(options)); } /** * @internal */ -export function useChainSpecPromise(options?: ChainComposableOptions) { +export function useChainSpecDataPromise(options?: ChainComposableOptions) { const chainId = internal_useChainId(options); const clientPromise = useClientPromise(options); diff --git a/packages/vue/src/composables/use-native-token.ts b/packages/vue/src/composables/use-native-token.ts new file mode 100644 index 00000000..9547ff8b --- /dev/null +++ b/packages/vue/src/composables/use-native-token.ts @@ -0,0 +1,45 @@ +import type { ChainComposableOptions } from "../types.js"; +import { useAsyncData } from "./use-async-data.js"; +import { internal_useChainId } from "./use-chain-id.js"; +import { useChainSpecDataPromise } from "./use-chain-spec-data.js"; +import { useLazyValue } from "./use-lazy-value.js"; +import { DenominatedNumber } from "@reactive-dot/utils"; +import { computed } from "vue"; + +/** + * Composable for getting the chain's native token + * @param options - Additional options + * @returns The chain's native token + */ +export function useNativeToken(options?: ChainComposableOptions) { + return useAsyncData(useNativeTokenPromise(options)); +} + +/** + * @internal + */ +export function useNativeTokenPromise(options?: ChainComposableOptions) { + const chainId = internal_useChainId(options); + const chainSpecDataPromise = useChainSpecDataPromise(options); + + return useLazyValue( + computed(() => ["native-token", chainId.value]), + () => + chainSpecDataPromise.value.then((chainSpecData) => ({ + symbol: chainSpecData.properties.tokenSymbol as string, + decimals: chainSpecData.properties.tokenDecimals as number, + amountFromPlanck: (planck: bigint | number | string) => + new DenominatedNumber( + planck, + chainSpecData.properties.tokenDecimals, + chainSpecData.properties.tokenSymbol, + ), + amountFromNumber: (number: number | string) => + DenominatedNumber.fromNumber( + number, + chainSpecData.properties.tokenDecimals, + chainSpecData.properties.tokenSymbol, + ), + })), + ); +} diff --git a/packages/vue/src/composables/use-query.ts b/packages/vue/src/composables/use-query.ts index b5d46a83..0469dec2 100644 --- a/packages/vue/src/composables/use-query.ts +++ b/packages/vue/src/composables/use-query.ts @@ -1,5 +1,9 @@ -import type { AsyncState, ChainComposableOptions } from "../types.js"; -import { refresh, refreshable } from "../utils/refreshable.js"; +import type { ChainComposableOptions } from "../types.js"; +import { + refresh, + type Refreshable, + refreshable, +} from "../utils/refreshable.js"; import { useAsyncData } from "./use-async-data.js"; import { internal_useChainId } from "./use-chain-id.js"; import { lazyValue, useLazyValuesCache } from "./use-lazy-value.js"; @@ -9,7 +13,6 @@ import { query as executeQuery, preflight, Query, - type QueryError, } from "@reactive-dot/core"; import { type ChainDescriptorOf, @@ -26,6 +29,7 @@ import { combineLatest, from, type Observable, of } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { computed, + type ComputedRef, type MaybeRefOrGetter, type ShallowRef, toValue, @@ -49,6 +53,23 @@ export function useQuery< ChainDescriptorOf > | Falsy, +>(builder: TQuery, options?: ChainComposableOptions) { + return useAsyncData(useQueryObservable(builder, options)); +} + +/** + * @internal + */ +export function useQueryObservable< + TChainId extends ChainId | undefined, + TQuery extends ( + builder: Query<[], ChainDescriptorOf>, + ) => + | Query< + QueryInstruction>[], + ChainDescriptorOf + > + | Falsy, >(builder: TQuery, options?: ChainComposableOptions) { const chainId = internal_useChainId(options); const typedApiPromise = useTypedApiPromise(options); @@ -89,62 +110,58 @@ export function useQuery< }); }); - type Data = FlatHead< - InferQueryPayload< - Exclude, Falsy>>, Falsy> - > - >; - - type Return = - Data extends Array - ? AsyncState & - PromiseLike> - : AsyncState & PromiseLike>; - - return useAsyncData( - refreshable( - computed(() => { - if (responses.value === undefined) { - return; - } + return refreshable( + computed(() => { + if (responses.value === undefined) { + return; + } - return combineLatest( - responses.value.map((response) => { - if (!Array.isArray(response)) { - return from(response.value); - } + return combineLatest( + responses.value.map((response) => { + if (!Array.isArray(response)) { + return from(response.value); + } - const responses = response.map((response) => response.value); + const responses = response.map((response) => response.value); - if (responses.length === 0) { - return of([]); - } + if (responses.length === 0) { + return of([]); + } - return combineLatest(response.map((response) => response.value)); - }), - ).pipe(map(flatHead)); - }), - () => { - if (!responses.value) { - return; - } + return combineLatest(response.map((response) => response.value)); + }), + ).pipe(map(flatHead)); + }), + () => { + if (!responses.value) { + return; + } - if (!Array.isArray(responses.value)) { - return void refresh(responses.value); - } + if (!Array.isArray(responses.value)) { + return void refresh(responses.value); + } - for (const response of responses.value) { - if (!Array.isArray(response)) { - refresh(response); - } else { - for (const subResponse of response) { - refresh(subResponse); - } + for (const response of responses.value) { + if (!Array.isArray(response)) { + refresh(response); + } else { + for (const subResponse of response) { + refresh(subResponse); } } - }, - ), - ) as Return; + } + }, + ) as Refreshable< + ComputedRef< + Observable< + FlatHead< + InferQueryPayload< + Exclude, Falsy>>, Falsy> + > + > + > + > + >; } function queryInstruction( diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 33efd4f6..e0fa606f 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,10 +1,12 @@ export { useAccounts } from "./composables/use-accounts.js"; +export { useSpendableBalance } from "./composables/use-balance.js"; export { useBlock } from "./composables/use-block.js"; export { useChainId, useChainIds } from "./composables/use-chain-id.js"; export { useChainSpecData } from "./composables/use-chain-spec-data.js"; export { useClient } from "./composables/use-client.js"; export { useConfig } from "./composables/use-config.js"; export { useMutation } from "./composables/use-mutation.js"; +export { useNativeToken } from "./composables/use-native-token.js"; export { useQueryErrorResetter } from "./composables/use-query-error-resetter.js"; export { useQuery } from "./composables/use-query.js"; export { useSigner } from "./composables/use-signer.js"; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index e3480fa2..ffb49872 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -28,6 +28,12 @@ export type AsyncState< refresh: () => void; }; +export type PromiseLikeAsyncState = AsyncState< + TData, + TError +> & + PromiseLike>; + export type MutationEvent = BaseMutationEvent & ( | { status: "pending" } diff --git a/packages/vue/src/utils/refreshable.ts b/packages/vue/src/utils/refreshable.ts index f8a2f148..32e72b9e 100644 --- a/packages/vue/src/utils/refreshable.ts +++ b/packages/vue/src/utils/refreshable.ts @@ -1,7 +1,7 @@ // TODO: weird TypeScript error const refreshSymbol = Symbol("refresh") as unknown as "_refresh"; -type Refreshable = T & { +export type Refreshable = T & { /** * @internal */