@polkadot-apps/utils 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,37 @@ formatPlanck(1_000_000_000_000n, 12); // "1.0"
62
62
  parseToPlanck("1.0", 12); // 1_000_000_000_000n
63
63
  ```
64
64
 
65
+ ### Display formatting
66
+
67
+ Format planck values for display with locale-aware thousand separators, configurable decimal precision, and optional token symbol.
68
+
69
+ ```typescript
70
+ import { formatBalance } from "@polkadot-apps/utils";
71
+
72
+ formatBalance(10_000_000_000n); // "1"
73
+ formatBalance(15_000_000_000n, { symbol: "DOT" }); // "1.5 DOT"
74
+ formatBalance(10_000_000_000_000n, { symbol: "DOT" }); // "1,000 DOT"
75
+ formatBalance(12_345_678_900n, { maxDecimals: 2 }); // "1.23"
76
+ formatBalance(0n, { symbol: "DOT" }); // "0 DOT"
77
+
78
+ // Custom chain decimals and locale
79
+ formatBalance(1_000_000_000_000n, { decimals: 12, symbol: "KSM", locale: "de-DE" });
80
+ ```
81
+
82
+ Unlike the `Number()` approach used in some apps, `formatBalance` preserves full BigInt precision for balances of any size.
83
+
84
+ ### Balance querying
85
+
86
+ Query on-chain balances with a typed convenience wrapper. Works with any PAPI typed API via structural typing — no extra dependencies.
87
+
88
+ ```typescript
89
+ import { getBalance, formatBalance } from "@polkadot-apps/utils";
90
+
91
+ const balance = await getBalance(api.assetHub, aliceAddress);
92
+ console.log(formatBalance(balance.free, { symbol: "DOT" })); // "1,000.5 DOT"
93
+ console.log(formatBalance(balance.reserved, { symbol: "DOT" })); // "50 DOT"
94
+ ```
95
+
65
96
  ## API
66
97
 
67
98
  ### Encoding
@@ -87,6 +118,13 @@ parseToPlanck("1.0", 12); // 1_000_000_000_000n
87
118
  |---|---|---|
88
119
  | `formatPlanck` | `(planck: bigint, decimals?: number)` | `string` |
89
120
  | `parseToPlanck` | `(amount: string, decimals?: number)` | `bigint` |
121
+ | `formatBalance` | `(planck: bigint, options?: FormatBalanceOptions)` | `string` |
122
+
123
+ ### Balance querying
124
+
125
+ | Function | Signature | Returns |
126
+ |---|---|---|
127
+ | `getBalance` | `(api: BalanceApi, address: string)` | `Promise<AccountBalance>` |
90
128
 
91
129
  **`formatPlanck(planck, decimals = 10)`**
92
130
 
@@ -102,11 +140,25 @@ Parse a decimal string into its planck bigint representation. If the fractional
102
140
  - Throws `Error` if `amount` is empty or contains invalid characters.
103
141
  - Throws `RangeError` if `amount` is negative or `decimals` is invalid.
104
142
 
143
+ **`formatBalance(planck, options?)`**
144
+
145
+ Format a planck value for display with locale-aware thousand separators. Builds on `formatPlanck` for BigInt precision.
146
+
147
+ Options: `{ decimals?: number, maxDecimals?: number, symbol?: string, locale?: string }`. Defaults: `decimals = 10`, `maxDecimals = 4`, no symbol, user's locale.
148
+
149
+ - Throws `RangeError` if `planck < 0n` or `decimals` is invalid (delegated to `formatPlanck`).
150
+
151
+ **`getBalance(api, address): Promise<AccountBalance>`**
152
+
153
+ Query the free, reserved, and frozen balances for an address. Returns `{ free: bigint, reserved: bigint, frozen: bigint }`. Uses structural typing — works with any PAPI typed API that has `System.Account`.
154
+
105
155
  ## Common mistakes
106
156
 
107
157
  - **Passing a `0x`-prefixed string to `hexToBytes`.** The `@noble/hashes` implementation expects raw hex without a prefix. Strip it first: `hexToBytes(hex.slice(2))`.
108
158
  - **Using `formatPlanck` with the wrong `decimals` for a chain.** DOT uses 10, KSM uses 12, many parachains use 18. Always check the chain's token metadata.
109
159
  - **Assuming `parseToPlanck` rounds excess decimals.** It truncates, not rounds. `parseToPlanck("1.999999999999", 10)` gives the same result as `parseToPlanck("1.9999999999", 10)`.
160
+ - **Using `Number()` to format large balances.** `Number(raw) / 10**decimals` loses precision for values > 2^53 planck (~900 DOT). Use `formatBalance` which preserves full BigInt precision.
161
+ - **Passing the ChainAPI wrapper to `getBalance`.** Pass the chain-specific TypedApi (e.g., `api.assetHub`), not the multi-chain `ChainAPI` object.
110
162
 
111
163
  ## License
112
164
 
@@ -0,0 +1,52 @@
1
+ /** Balance breakdown from a Substrate `System.Account` query. */
2
+ export interface AccountBalance {
3
+ /** Available (transferable) balance in planck. */
4
+ free: bigint;
5
+ /** Reserved (locked by governance, staking, etc.) balance in planck. */
6
+ reserved: bigint;
7
+ /** Frozen (non-transferable but still counted) balance in planck. */
8
+ frozen: bigint;
9
+ }
10
+ /**
11
+ * Minimal structural type for a PAPI typed API with `System.Account`.
12
+ *
13
+ * Structural so it works with any chain that has the System pallet, without
14
+ * importing chain-specific descriptors.
15
+ */
16
+ export interface BalanceApi {
17
+ query: {
18
+ System: {
19
+ Account: {
20
+ getValue(address: string): Promise<{
21
+ data: {
22
+ free: bigint;
23
+ reserved: bigint;
24
+ frozen: bigint;
25
+ };
26
+ }>;
27
+ };
28
+ };
29
+ };
30
+ }
31
+ /**
32
+ * Query the free, reserved, and frozen balances for an on-chain address.
33
+ *
34
+ * Thin typed wrapper around `System.Account.getValue` that returns a clean
35
+ * {@link AccountBalance} object. Uses structural typing so it works with any
36
+ * PAPI typed API that has the System pallet — no chain-specific imports needed.
37
+ *
38
+ * @param api - A PAPI typed API with `query.System.Account`. Pass the chain-specific
39
+ * API (e.g., `api.assetHub`), not the multi-chain `ChainAPI` wrapper.
40
+ * @param address - The SS58 address to query.
41
+ * @returns The account's balance breakdown.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * import { getBalance } from "@polkadot-apps/utils";
46
+ * import { formatBalance } from "@polkadot-apps/utils";
47
+ *
48
+ * const balance = await getBalance(api.assetHub, aliceAddress);
49
+ * console.log(formatBalance(balance.free, { symbol: "DOT" })); // "1,000.5 DOT"
50
+ * ```
51
+ */
52
+ export declare function getBalance(api: BalanceApi, address: string): Promise<AccountBalance>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Query the free, reserved, and frozen balances for an on-chain address.
3
+ *
4
+ * Thin typed wrapper around `System.Account.getValue` that returns a clean
5
+ * {@link AccountBalance} object. Uses structural typing so it works with any
6
+ * PAPI typed API that has the System pallet — no chain-specific imports needed.
7
+ *
8
+ * @param api - A PAPI typed API with `query.System.Account`. Pass the chain-specific
9
+ * API (e.g., `api.assetHub`), not the multi-chain `ChainAPI` wrapper.
10
+ * @param address - The SS58 address to query.
11
+ * @returns The account's balance breakdown.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { getBalance } from "@polkadot-apps/utils";
16
+ * import { formatBalance } from "@polkadot-apps/utils";
17
+ *
18
+ * const balance = await getBalance(api.assetHub, aliceAddress);
19
+ * console.log(formatBalance(balance.free, { symbol: "DOT" })); // "1,000.5 DOT"
20
+ * ```
21
+ */
22
+ export async function getBalance(api, address) {
23
+ const account = await api.query.System.Account.getValue(address);
24
+ return {
25
+ free: account.data.free,
26
+ reserved: account.data.reserved,
27
+ frozen: account.data.frozen,
28
+ };
29
+ }
30
+ if (import.meta.vitest) {
31
+ const { describe, test, expect } = import.meta.vitest;
32
+ function createMockApi(data) {
33
+ return {
34
+ query: {
35
+ System: {
36
+ Account: {
37
+ getValue: async () => ({ data }),
38
+ },
39
+ },
40
+ },
41
+ };
42
+ }
43
+ describe("getBalance", () => {
44
+ test("returns correct AccountBalance from API", async () => {
45
+ const api = createMockApi({
46
+ free: 10000000000n,
47
+ reserved: 5000000000n,
48
+ frozen: 1000000000n,
49
+ });
50
+ const balance = await getBalance(api, "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY");
51
+ expect(balance.free).toBe(10000000000n);
52
+ expect(balance.reserved).toBe(5000000000n);
53
+ expect(balance.frozen).toBe(1000000000n);
54
+ });
55
+ test("propagates errors from getValue", async () => {
56
+ const api = {
57
+ query: {
58
+ System: {
59
+ Account: {
60
+ getValue: async () => {
61
+ throw new Error("RPC connection failed");
62
+ },
63
+ },
64
+ },
65
+ },
66
+ };
67
+ await expect(getBalance(api, "5GrwvaEF...")).rejects.toThrow("RPC connection failed");
68
+ });
69
+ test("works with zero balances", async () => {
70
+ const api = createMockApi({ free: 0n, reserved: 0n, frozen: 0n });
71
+ const balance = await getBalance(api, "5GrwvaEF...");
72
+ expect(balance.free).toBe(0n);
73
+ expect(balance.reserved).toBe(0n);
74
+ expect(balance.frozen).toBe(0n);
75
+ });
76
+ });
77
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * @polkadot-apps/utils — Encoding, hashing, and token formatting for the Polkadot app ecosystem.
2
+ * @polkadot-apps/utils — Encoding, hashing, token formatting, and balance querying for the Polkadot app ecosystem.
3
3
  *
4
4
  * Provides general-purpose byte encoding/decoding (`bytesToHex`, `hexToBytes`, `utf8ToBytes`,
5
5
  * `concatBytes`), 32-byte hash functions (`blake2b256`, `sha256`, `keccak256`),
6
- * and Substrate token formatting (`formatPlanck`, `parseToPlanck`).
7
- * All functions are synchronous and framework-agnostic.
6
+ * Substrate token formatting (`formatPlanck`, `parseToPlanck`, `formatBalance`),
7
+ * and typed balance queries (`getBalance`).
8
+ * All functions are framework-agnostic.
8
9
  *
9
10
  * @packageDocumentation
10
11
  */
11
12
  export * from "./encoding.js";
12
13
  export * from "./hashing.js";
13
14
  export * from "./planck.js";
15
+ export * from "./balance.js";
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * @polkadot-apps/utils — Encoding, hashing, and token formatting for the Polkadot app ecosystem.
2
+ * @polkadot-apps/utils — Encoding, hashing, token formatting, and balance querying for the Polkadot app ecosystem.
3
3
  *
4
4
  * Provides general-purpose byte encoding/decoding (`bytesToHex`, `hexToBytes`, `utf8ToBytes`,
5
5
  * `concatBytes`), 32-byte hash functions (`blake2b256`, `sha256`, `keccak256`),
6
- * and Substrate token formatting (`formatPlanck`, `parseToPlanck`).
7
- * All functions are synchronous and framework-agnostic.
6
+ * Substrate token formatting (`formatPlanck`, `parseToPlanck`, `formatBalance`),
7
+ * and typed balance queries (`getBalance`).
8
+ * All functions are framework-agnostic.
8
9
  *
9
10
  * @packageDocumentation
10
11
  */
11
12
  export * from "./encoding.js";
12
13
  export * from "./hashing.js";
13
14
  export * from "./planck.js";
15
+ export * from "./balance.js";
package/dist/planck.d.ts CHANGED
@@ -45,3 +45,39 @@ export declare function formatPlanck(planck: bigint, decimals?: number): string;
45
45
  * ```
46
46
  */
47
47
  export declare function parseToPlanck(amount: string, decimals?: number): bigint;
48
+ /** Options for {@link formatBalance}. */
49
+ export interface FormatBalanceOptions {
50
+ /** Token decimals. Default: 10 (DOT). */
51
+ decimals?: number;
52
+ /** Maximum fraction digits to display. Default: 4. */
53
+ maxDecimals?: number;
54
+ /** Token symbol to append (e.g., `"DOT"`, `"PAS"`). Omitted by default. */
55
+ symbol?: string;
56
+ /** BCP 47 locale tag for grouping and decimal separators (e.g., `"en-US"` → `","` grouping + `"."` decimal, `"de-DE"` → `"."` grouping + `","` decimal). Default: user's locale. */
57
+ locale?: string;
58
+ }
59
+ /**
60
+ * Format a planck value for display with locale-aware thousand separators,
61
+ * decimal truncation, and an optional token symbol.
62
+ *
63
+ * Builds on {@link formatPlanck} for BigInt-safe conversion, then applies
64
+ * presentation formatting. Unlike {@link formatPlanck}, trailing `.0` is
65
+ * omitted — display values show `"1,000"` not `"1,000.0"`.
66
+ *
67
+ * @param planck - The raw planck value as a bigint. Must be non-negative.
68
+ * @param options - Formatting options.
69
+ * @returns A display-ready string (e.g. `"1,000.5 DOT"`).
70
+ * @throws {RangeError} If `planck` is negative or `decimals` is invalid (delegated to {@link formatPlanck}).
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { formatBalance } from "@polkadot-apps/utils";
75
+ *
76
+ * formatBalance(10_000_000_000n); // "1"
77
+ * formatBalance(15_000_000_000n, { symbol: "DOT" }); // "1.5 DOT"
78
+ * formatBalance(10_000_000_000_000n, { symbol: "DOT" }); // "1,000 DOT"
79
+ * formatBalance(12_345_678_900n, { maxDecimals: 2 }); // "1.23"
80
+ * formatBalance(0n, { symbol: "DOT" }); // "0 DOT"
81
+ * ```
82
+ */
83
+ export declare function formatBalance(planck: bigint, options?: FormatBalanceOptions): string;
package/dist/planck.js CHANGED
@@ -104,6 +104,54 @@ export function parseToPlanck(amount, decimals = 10) {
104
104
  const fraction = decimals > 0 ? BigInt(paddedFraction) : 0n;
105
105
  return whole + fraction;
106
106
  }
107
+ /**
108
+ * Format a planck value for display with locale-aware thousand separators,
109
+ * decimal truncation, and an optional token symbol.
110
+ *
111
+ * Builds on {@link formatPlanck} for BigInt-safe conversion, then applies
112
+ * presentation formatting. Unlike {@link formatPlanck}, trailing `.0` is
113
+ * omitted — display values show `"1,000"` not `"1,000.0"`.
114
+ *
115
+ * @param planck - The raw planck value as a bigint. Must be non-negative.
116
+ * @param options - Formatting options.
117
+ * @returns A display-ready string (e.g. `"1,000.5 DOT"`).
118
+ * @throws {RangeError} If `planck` is negative or `decimals` is invalid (delegated to {@link formatPlanck}).
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import { formatBalance } from "@polkadot-apps/utils";
123
+ *
124
+ * formatBalance(10_000_000_000n); // "1"
125
+ * formatBalance(15_000_000_000n, { symbol: "DOT" }); // "1.5 DOT"
126
+ * formatBalance(10_000_000_000_000n, { symbol: "DOT" }); // "1,000 DOT"
127
+ * formatBalance(12_345_678_900n, { maxDecimals: 2 }); // "1.23"
128
+ * formatBalance(0n, { symbol: "DOT" }); // "0 DOT"
129
+ * ```
130
+ */
131
+ export function formatBalance(planck, options) {
132
+ const decimals = options?.decimals ?? 10;
133
+ const maxDecimals = options?.maxDecimals ?? 4;
134
+ const symbol = options?.symbol;
135
+ const locale = options?.locale;
136
+ if (maxDecimals < 0 || !Number.isInteger(maxDecimals)) {
137
+ throw new RangeError(`maxDecimals must be a non-negative integer, got ${maxDecimals}`);
138
+ }
139
+ const raw = formatPlanck(planck, decimals);
140
+ const dotIndex = raw.indexOf(".");
141
+ const wholePart = dotIndex === -1 ? raw : raw.slice(0, dotIndex);
142
+ const fractionPart = dotIndex === -1 ? "" : raw.slice(dotIndex + 1);
143
+ const formatter = new Intl.NumberFormat(locale, { useGrouping: true });
144
+ // Format whole part with locale-aware grouping (BigInt overload avoids precision loss)
145
+ const formattedWhole = formatter.format(BigInt(wholePart));
146
+ // Extract the locale's decimal separator (e.g., "." for en-US, "," for de-DE)
147
+ const decimalSep = formatter.formatToParts(1.1).find((p) => p.type === "decimal")?.value ?? ".";
148
+ // Truncate fraction to maxDecimals, trim trailing zeros
149
+ const truncated = fractionPart.slice(0, maxDecimals);
150
+ const trimmed = truncated.replace(/0+$/, "");
151
+ const fractionSuffix = trimmed ? `${decimalSep}${trimmed}` : "";
152
+ const symbolSuffix = symbol ? ` ${symbol}` : "";
153
+ return `${formattedWhole}${fractionSuffix}${symbolSuffix}`;
154
+ }
107
155
  if (import.meta.vitest) {
108
156
  const { describe, test, expect } = import.meta.vitest;
109
157
  describe("formatPlanck", () => {
@@ -191,4 +239,66 @@ if (import.meta.vitest) {
191
239
  expect(parseToPlanck(formatted)).toBe(original);
192
240
  });
193
241
  });
242
+ describe("formatBalance", () => {
243
+ test("formats with default options (no symbol, max 4 decimals)", () => {
244
+ expect(formatBalance(15000000000n)).toBe("1.5");
245
+ expect(formatBalance(12345678900n)).toBe("1.2345");
246
+ });
247
+ test("applies thousand separators", () => {
248
+ expect(formatBalance(10000000000000n, { locale: "en-US" })).toBe("1,000");
249
+ expect(formatBalance(1234567000000000n, { locale: "en-US", symbol: "DOT" })).toBe("123,456.7 DOT");
250
+ });
251
+ test("truncates fraction to maxDecimals", () => {
252
+ expect(formatBalance(12345678900n, { maxDecimals: 2 })).toBe("1.23");
253
+ expect(formatBalance(12345678900n, { maxDecimals: 8 })).toBe("1.23456789");
254
+ });
255
+ test("appends symbol", () => {
256
+ expect(formatBalance(15000000000n, { symbol: "DOT" })).toBe("1.5 DOT");
257
+ expect(formatBalance(15000000000n, { symbol: "PAS" })).toBe("1.5 PAS");
258
+ });
259
+ test("omits fraction when all zeros after truncation", () => {
260
+ expect(formatBalance(10000000000n)).toBe("1");
261
+ expect(formatBalance(20000000000n, { symbol: "DOT" })).toBe("2 DOT");
262
+ });
263
+ test("respects maxDecimals: 0", () => {
264
+ expect(formatBalance(15000000000n, { maxDecimals: 0 })).toBe("1");
265
+ expect(formatBalance(19999999999n, { maxDecimals: 0, symbol: "DOT" })).toBe("1 DOT");
266
+ });
267
+ test("handles zero", () => {
268
+ expect(formatBalance(0n)).toBe("0");
269
+ expect(formatBalance(0n, { symbol: "DOT" })).toBe("0 DOT");
270
+ });
271
+ test("handles sub-unit amounts", () => {
272
+ // 1 planck is below 4-decimal display threshold → shows "0"
273
+ expect(formatBalance(1n)).toBe("0");
274
+ // 0.0001 DOT is exactly at the threshold
275
+ expect(formatBalance(1000000n)).toBe("0.0001");
276
+ // With more maxDecimals, sub-unit amounts become visible
277
+ expect(formatBalance(1n, { maxDecimals: 10 })).toBe("0.0000000001");
278
+ });
279
+ test("uses locale-correct decimal separator", () => {
280
+ // German uses . for grouping and , for decimal
281
+ expect(formatBalance(15000000000n, { locale: "de-DE" })).toBe("1,5");
282
+ expect(formatBalance(10000000000000n, { locale: "de-DE" })).toBe("1.000");
283
+ // With fraction
284
+ expect(formatBalance(10005000000000n, { locale: "de-DE" })).toBe("1.000,5");
285
+ });
286
+ test("preserves BigInt precision for large amounts", () => {
287
+ // 2^53 + 1 in planck — would lose precision with Number()
288
+ const largePlanck = 90071992547409920000n; // ~9 billion DOT
289
+ const result = formatBalance(largePlanck, { locale: "en-US", symbol: "DOT" });
290
+ expect(result).toContain("9,007,199,254");
291
+ expect(result).toContain("DOT");
292
+ });
293
+ test("throws on negative planck (delegates to formatPlanck)", () => {
294
+ expect(() => formatBalance(-1n)).toThrow(RangeError);
295
+ });
296
+ test("throws on invalid decimals (delegates to formatPlanck)", () => {
297
+ expect(() => formatBalance(0n, { decimals: -1 })).toThrow(RangeError);
298
+ });
299
+ test("throws on invalid maxDecimals", () => {
300
+ expect(() => formatBalance(0n, { maxDecimals: -1 })).toThrow(RangeError);
301
+ expect(() => formatBalance(0n, { maxDecimals: 1.5 })).toThrow(RangeError);
302
+ });
303
+ });
194
304
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@polkadot-apps/utils",
3
3
  "description": "Encoding utilities and token formatting for the @polkadot-apps ecosystem",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",