@polkadot-apps/utils 0.2.1 → 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
@@ -23,6 +23,23 @@ const text = utf8ToBytes("hello"); // Uint8Array [104, 101, 108, 108, 111]
23
23
  const combined = concatBytes(header, payload);
24
24
  ```
25
25
 
26
+ ### Hashing
27
+
28
+ Deterministic 32-byte hash functions used across the Polkadot ecosystem.
29
+
30
+ ```typescript
31
+ import { blake2b256, sha256, keccak256, bytesToHex } from "@polkadot-apps/utils";
32
+
33
+ const hash = blake2b256(new TextEncoder().encode("hello"));
34
+ console.log(bytesToHex(hash)); // 64-char hex string
35
+
36
+ // SHA2-256 (bulletin-deploy default)
37
+ const sha = sha256(data);
38
+
39
+ // Keccak-256 (Ethereum compatibility)
40
+ const kek = keccak256(data);
41
+ ```
42
+
26
43
  ### Token formatting
27
44
 
28
45
  Convert between raw planck values (the smallest indivisible token unit on Substrate chains) and human-readable decimal strings.
@@ -45,6 +62,37 @@ formatPlanck(1_000_000_000_000n, 12); // "1.0"
45
62
  parseToPlanck("1.0", 12); // 1_000_000_000_000n
46
63
  ```
47
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
+
48
96
  ## API
49
97
 
50
98
  ### Encoding
@@ -56,12 +104,27 @@ parseToPlanck("1.0", 12); // 1_000_000_000_000n
56
104
  | `utf8ToBytes` | `(str: string)` | `Uint8Array` |
57
105
  | `concatBytes` | `(...arrays: Uint8Array[])` | `Uint8Array` |
58
106
 
107
+ ### Hashing
108
+
109
+ | Function | Signature | Returns | Description |
110
+ |---|---|---|---|
111
+ | `blake2b256` | `(data: Uint8Array)` | `Uint8Array` (32 bytes) | BLAKE2b-256 — Polkadot default |
112
+ | `sha256` | `(data: Uint8Array)` | `Uint8Array` (32 bytes) | SHA2-256 — bulletin-deploy default |
113
+ | `keccak256` | `(data: Uint8Array)` | `Uint8Array` (32 bytes) | Keccak-256 — Ethereum compatibility |
114
+
59
115
  ### Token formatting
60
116
 
61
117
  | Function | Signature | Returns |
62
118
  |---|---|---|
63
119
  | `formatPlanck` | `(planck: bigint, decimals?: number)` | `string` |
64
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>` |
65
128
 
66
129
  **`formatPlanck(planck, decimals = 10)`**
67
130
 
@@ -77,11 +140,25 @@ Parse a decimal string into its planck bigint representation. If the fractional
77
140
  - Throws `Error` if `amount` is empty or contains invalid characters.
78
141
  - Throws `RangeError` if `amount` is negative or `decimals` is invalid.
79
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
+
80
155
  ## Common mistakes
81
156
 
82
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))`.
83
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.
84
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.
85
162
 
86
163
  ## License
87
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
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Compute a 32-byte BLAKE2b-256 hash.
3
+ *
4
+ * This is the default hash algorithm used by the Polkadot ecosystem and the
5
+ * Bulletin Chain. Deterministic: same input always produces the same output.
6
+ *
7
+ * @param data - Arbitrary bytes to hash.
8
+ * @returns 32-byte BLAKE2b-256 digest.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { blake2b256, bytesToHex } from "@polkadot-apps/utils";
13
+ *
14
+ * const hash = blake2b256(new TextEncoder().encode("hello"));
15
+ * console.log(bytesToHex(hash)); // 64-char hex string
16
+ * ```
17
+ */
18
+ export declare function blake2b256(data: Uint8Array): Uint8Array;
19
+ /**
20
+ * Compute a 32-byte SHA2-256 hash.
21
+ *
22
+ * Used by bulletin-deploy and supported by the Bulletin Chain as an
23
+ * alternative hashing algorithm.
24
+ *
25
+ * @param data - Arbitrary bytes to hash.
26
+ * @returns 32-byte SHA2-256 digest.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { sha256, bytesToHex } from "@polkadot-apps/utils";
31
+ *
32
+ * const hash = sha256(new TextEncoder().encode("hello"));
33
+ * console.log(bytesToHex(hash)); // 64-char hex string
34
+ * ```
35
+ */
36
+ export declare function sha256(data: Uint8Array): Uint8Array;
37
+ /**
38
+ * Compute a 32-byte Keccak-256 hash.
39
+ *
40
+ * Used for Ethereum-compatible operations (address derivation, EVM function
41
+ * selectors) and supported by the Bulletin Chain for cross-chain compatibility.
42
+ *
43
+ * @param data - Arbitrary bytes to hash.
44
+ * @returns 32-byte Keccak-256 digest.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { keccak256, bytesToHex } from "@polkadot-apps/utils";
49
+ *
50
+ * const hash = keccak256(new TextEncoder().encode("hello"));
51
+ * console.log(bytesToHex(hash)); // 64-char hex string
52
+ * ```
53
+ */
54
+ export declare function keccak256(data: Uint8Array): Uint8Array;
@@ -0,0 +1,149 @@
1
+ import { blake2b } from "@noble/hashes/blake2.js";
2
+ import { sha256 as _sha256 } from "@noble/hashes/sha2.js";
3
+ import { bytesToHex as _bytesToHex } from "@noble/hashes/utils.js";
4
+ import { keccak_256 } from "@noble/hashes/sha3.js";
5
+ /**
6
+ * Compute a 32-byte BLAKE2b-256 hash.
7
+ *
8
+ * This is the default hash algorithm used by the Polkadot ecosystem and the
9
+ * Bulletin Chain. Deterministic: same input always produces the same output.
10
+ *
11
+ * @param data - Arbitrary bytes to hash.
12
+ * @returns 32-byte BLAKE2b-256 digest.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { blake2b256, bytesToHex } from "@polkadot-apps/utils";
17
+ *
18
+ * const hash = blake2b256(new TextEncoder().encode("hello"));
19
+ * console.log(bytesToHex(hash)); // 64-char hex string
20
+ * ```
21
+ */
22
+ export function blake2b256(data) {
23
+ return blake2b(data, { dkLen: 32 });
24
+ }
25
+ /**
26
+ * Compute a 32-byte SHA2-256 hash.
27
+ *
28
+ * Used by bulletin-deploy and supported by the Bulletin Chain as an
29
+ * alternative hashing algorithm.
30
+ *
31
+ * @param data - Arbitrary bytes to hash.
32
+ * @returns 32-byte SHA2-256 digest.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { sha256, bytesToHex } from "@polkadot-apps/utils";
37
+ *
38
+ * const hash = sha256(new TextEncoder().encode("hello"));
39
+ * console.log(bytesToHex(hash)); // 64-char hex string
40
+ * ```
41
+ */
42
+ export function sha256(data) {
43
+ return _sha256(data);
44
+ }
45
+ /**
46
+ * Compute a 32-byte Keccak-256 hash.
47
+ *
48
+ * Used for Ethereum-compatible operations (address derivation, EVM function
49
+ * selectors) and supported by the Bulletin Chain for cross-chain compatibility.
50
+ *
51
+ * @param data - Arbitrary bytes to hash.
52
+ * @returns 32-byte Keccak-256 digest.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { keccak256, bytesToHex } from "@polkadot-apps/utils";
57
+ *
58
+ * const hash = keccak256(new TextEncoder().encode("hello"));
59
+ * console.log(bytesToHex(hash)); // 64-char hex string
60
+ * ```
61
+ */
62
+ export function keccak256(data) {
63
+ return keccak_256(data);
64
+ }
65
+ if (import.meta.vitest) {
66
+ const { describe, test, expect } = import.meta.vitest;
67
+ describe("blake2b256", () => {
68
+ test("produces a 32-byte hash", () => {
69
+ const hash = blake2b256(new TextEncoder().encode("hello"));
70
+ expect(hash).toBeInstanceOf(Uint8Array);
71
+ expect(hash.length).toBe(32);
72
+ });
73
+ test("deterministic — same input, same output", () => {
74
+ const data = new TextEncoder().encode("test");
75
+ expect(blake2b256(data)).toEqual(blake2b256(data));
76
+ });
77
+ test("different inputs produce different hashes", () => {
78
+ const a = blake2b256(new Uint8Array([1]));
79
+ const b = blake2b256(new Uint8Array([2]));
80
+ expect(a).not.toEqual(b);
81
+ });
82
+ test("empty input produces valid 32-byte hash", () => {
83
+ const hash = blake2b256(new Uint8Array(0));
84
+ expect(hash.length).toBe(32);
85
+ });
86
+ test("matches direct @noble/hashes import", () => {
87
+ const data = new TextEncoder().encode("wrapper transparency check");
88
+ const direct = blake2b(data, { dkLen: 32 });
89
+ expect(blake2b256(data)).toEqual(direct);
90
+ });
91
+ });
92
+ describe("sha256", () => {
93
+ test("produces a 32-byte hash", () => {
94
+ const hash = sha256(new TextEncoder().encode("hello"));
95
+ expect(hash).toBeInstanceOf(Uint8Array);
96
+ expect(hash.length).toBe(32);
97
+ });
98
+ test("deterministic — same input, same output", () => {
99
+ const data = new TextEncoder().encode("test");
100
+ expect(sha256(data)).toEqual(sha256(data));
101
+ });
102
+ test("different inputs produce different hashes", () => {
103
+ const a = sha256(new Uint8Array([1]));
104
+ const b = sha256(new Uint8Array([2]));
105
+ expect(a).not.toEqual(b);
106
+ });
107
+ test("empty input produces valid 32-byte hash", () => {
108
+ const hash = sha256(new Uint8Array(0));
109
+ expect(hash.length).toBe(32);
110
+ });
111
+ test("differs from blake2b256 for same input", () => {
112
+ const data = new TextEncoder().encode("cross-check");
113
+ expect(sha256(data)).not.toEqual(blake2b256(data));
114
+ });
115
+ test("matches known SHA-256 test vector", () => {
116
+ const hash = sha256(new TextEncoder().encode("hello"));
117
+ expect(_bytesToHex(hash)).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
118
+ });
119
+ });
120
+ describe("keccak256", () => {
121
+ test("produces a 32-byte hash", () => {
122
+ const hash = keccak256(new TextEncoder().encode("hello"));
123
+ expect(hash).toBeInstanceOf(Uint8Array);
124
+ expect(hash.length).toBe(32);
125
+ });
126
+ test("deterministic — same input, same output", () => {
127
+ const data = new TextEncoder().encode("test");
128
+ expect(keccak256(data)).toEqual(keccak256(data));
129
+ });
130
+ test("different inputs produce different hashes", () => {
131
+ const a = keccak256(new Uint8Array([1]));
132
+ const b = keccak256(new Uint8Array([2]));
133
+ expect(a).not.toEqual(b);
134
+ });
135
+ test("empty input produces valid 32-byte hash", () => {
136
+ const hash = keccak256(new Uint8Array(0));
137
+ expect(hash.length).toBe(32);
138
+ });
139
+ test("differs from sha256 and blake2b256 for same input", () => {
140
+ const data = new TextEncoder().encode("cross-check");
141
+ expect(keccak256(data)).not.toEqual(sha256(data));
142
+ expect(keccak256(data)).not.toEqual(blake2b256(data));
143
+ });
144
+ test("matches known Keccak-256 test vector", () => {
145
+ const hash = keccak256(new TextEncoder().encode("hello"));
146
+ expect(_bytesToHex(hash)).toBe("1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8");
147
+ });
148
+ });
149
+ }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
- * @polkadot-apps/utils — Encoding utilities 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
- * `concatBytes`) and Substrate token formatting (`formatPlanck`, `parseToPlanck`).
6
- * All functions are synchronous and framework-agnostic.
5
+ * `concatBytes`), 32-byte hash functions (`blake2b256`, `sha256`, `keccak256`),
6
+ * Substrate token formatting (`formatPlanck`, `parseToPlanck`, `formatBalance`),
7
+ * and typed balance queries (`getBalance`).
8
+ * All functions are framework-agnostic.
7
9
  *
8
10
  * @packageDocumentation
9
11
  */
10
12
  export * from "./encoding.js";
13
+ export * from "./hashing.js";
11
14
  export * from "./planck.js";
15
+ export * from "./balance.js";
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
- * @polkadot-apps/utils — Encoding utilities 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
- * `concatBytes`) and Substrate token formatting (`formatPlanck`, `parseToPlanck`).
6
- * All functions are synchronous and framework-agnostic.
5
+ * `concatBytes`), 32-byte hash functions (`blake2b256`, `sha256`, `keccak256`),
6
+ * Substrate token formatting (`formatPlanck`, `parseToPlanck`, `formatBalance`),
7
+ * and typed balance queries (`getBalance`).
8
+ * All functions are framework-agnostic.
7
9
  *
8
10
  * @packageDocumentation
9
11
  */
10
12
  export * from "./encoding.js";
13
+ export * from "./hashing.js";
11
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.2.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",