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) =>
-
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
*/