Skip to content

Commit 617b8d2

Browse files
authored
Add formatters for booleans, bytes, enums and public keys (#1217)
This PR adds formatters for the remaining leaf values of decoded nodes, and makes every formatter share the same `(decoded, options)` signature. ```ts formatBoolean(decoded); // "true" formatBytes(decoded); // "0x0102", whatever encoding the bytes were decoded with formatEnum(decoded); // the variant's label, otherwise its identifier in title case, e.g. "Move To" formatPublicKey(decoded, { formatAddress: address => names.get(address) ?? address }); // "USDC" ``` - **Addresses:** the new `formatAddress` option formats addresses however the caller needs, e.g. to name them from a trusted token list or address book, or to truncate them. It defaults to the address itself. - **Uniform signatures:** every formatter now accepts `FormatOptions`, even those that use none of them yet, so any formatter can stand in for another. - **Invalid ticks:** `formatDateTime` and `formatDuration` now throw the new `CODAMA_ERROR__INVALID_TICKS_PER_SECOND` when `ticksPerSecond` is not a positive integer, instead of returning `null`. `formatInteger` is now the only formatter returning `null`, for amounts whose `decimals` cannot be resolved.
1 parent 61a35e6 commit 617b8d2

13 files changed

Lines changed: 260 additions & 49 deletions

File tree

‎packages/dynamic-codecs/README.md‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ Decoded nodes carry the nodes that decoded them, so their values can be formatte
8282
| `formatDateTime` | `DecodedDateTimeTypeNode` | `"2024-01-01T00:00:00.5Z"` |
8383
| `formatDuration` | `DecodedDurationTypeNode` | `"49:00:00"`, `"-00:00:01.5"` |
8484
| `formatString` | `DecodedStringTypeNode` | `"abcd"`, sliced by its display |
85+
| `formatBoolean` | `DecodedBooleanTypeNode` | `"true"` |
86+
| `formatBytes` | `DecodedBytesTypeNode` | `"0x0102"` |
87+
| `formatEnum` | `DecodedEnumTypeNode` | `"Move"`, `"Move To"` |
88+
| `formatPublicKey` | `DecodedPublicKeyTypeNode` | `"USDC"`, or its address |
8589

8690
```ts
8791
import { formatInteger, getNodeCodec, isDecodedNode } from '@codama/dynamic-codecs';
@@ -97,15 +101,19 @@ Numbers are formatted exactly, including 128-bit integers and binary fixed point
97101

98102
- **Units:** the unit of a display node wins over the unit of a type, which is the fallback whenever the former is absent or cannot be resolved. Units follow their value after a space, except `%`, `‰` and `°`.
99103
- **Amounts:** when the `decimals` of an `amountNumberDisplayNode` cannot be resolved, `formatInteger` returns `null` rather than a wrongly scaled amount, so you can present the raw value instead.
100-
- **Ticks:** `formatDateTime` and `formatDuration` return `null` when `ticksPerSecond` is not a positive integer.
101-
102-
`formatInteger`, `formatFloat` and `formatFixedPoint` accept options:
103-
104-
| Name | Type | Description |
105-
| ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
106-
| `formatUnit` | `(value: string, unit: string) => string` | Place a unit next to a value, e.g. `` (value, unit) => `${unit} ${value}` `` for `"USD 40.5"`. Defaults to `formatUnit`, also exported. |
107-
| `numberFormat` | `Intl.NumberFormat` | Format numbers for a locale, e.g. `"1,234,567.89"`. Its options decide the digits shown. Amounts and fixed points are given to it exactly, never through a JavaScript float. |
108-
| `resolveInjectedValue` | `(path: NodePath<InjectedValueNode>) => unknown` | Resolve injected values of display nodes, e.g. `decimals` provided by an instruction, from their path through the decoded node. `undefined` means unresolved. |
104+
- **Ticks:** `formatDateTime` and `formatDuration` throw `CODAMA_ERROR__INVALID_TICKS_PER_SECOND` when `ticksPerSecond` is not a positive integer.
105+
- **Bytes:** bytes are written in hexadecimal whatever the encoding they were decoded with. Bytes decoded as `utf8` cannot be recovered exactly when they are not valid UTF-8, so decode them with another encoding.
106+
- **Enums:** the label of the variant's display node, or the variant's identifier in title case otherwise. The data of the variant is not included.
107+
- **Public keys:** formatted by the `formatAddress` option, e.g. to name or truncate them, or written as the address otherwise.
108+
109+
All formatters share the `(decoded, options)` signature and accept the following options, though each only uses those relevant to it, e.g. `formatAddress` for public keys and `numberFormat` for numbers:
110+
111+
| Name | Type | Description |
112+
| ---------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113+
| `formatUnit` | `(value: string, unit: string) => string` | Place a unit next to a value, e.g. `` (value, unit) => `${unit} ${value}` `` for `"USD 40.5"`. Defaults to `formatUnit`, also exported. |
114+
| `formatAddress` | `(address: Address) => string` | Format an address, e.g. to name it from sources you trust such as a token list or an address book, or to truncate it: `address => names.get(address) ?? address`. Defaults to the address itself. |
115+
| `numberFormat` | `Intl.NumberFormat` | Format numbers for a locale, e.g. `"1,234,567.89"`. Its options decide the digits shown. Amounts and fixed points are given to it exactly, never through a JavaScript float. |
116+
| `resolveInjectedValue` | `(path: NodePath<InjectedValueNode>) => unknown` | Resolve injected values of display nodes, e.g. `decimals` provided by an instruction, from their path through the decoded node. `undefined` means unresolved. |
109117

110118
## Node paths
111119

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING, CodamaError } from '@codama/errors';
2+
import { BytesEncoding } from '@codama/nodes';
3+
import { getBase16Codec, getBase58Codec, getBase64Codec, getUtf8Codec } from '@solana/codecs';
4+
5+
/** The codec of a bytes encoding, e.g. a base58 codec for `'base58'`. */
6+
export function getCodecFromBytesEncoding(encoding: BytesEncoding) {
7+
switch (encoding) {
8+
case 'base16':
9+
return getBase16Codec();
10+
case 'base58':
11+
return getBase58Codec();
12+
case 'base64':
13+
return getBase64Codec();
14+
case 'utf8':
15+
return getUtf8Codec();
16+
default:
17+
throw new CodamaError(CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING, {
18+
encoding: encoding satisfies never,
19+
});
20+
}
21+
}

‎packages/dynamic-codecs/src/codecs.ts‎

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
CODAMA_ERROR__DEFINED_TYPE_HAS_NO_FINITE_VALUE,
33
CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION,
44
CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE,
5-
CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING,
65
CODAMA_ERROR__UNRECOGNIZED_NUMBER_FORMAT,
76
CodamaError,
87
} from '@codama/errors';
@@ -52,9 +51,7 @@ import {
5251
fixCodecSize,
5352
FixedSizeNumberCodec,
5453
getArrayCodec,
55-
getBase16Codec,
5654
getBase58Codec,
57-
getBase64Codec,
5855
getBooleanCodec,
5956
getConstantCodec,
6057
getF32Codec,
@@ -77,7 +74,6 @@ import {
7774
getU128Codec,
7875
getUnionCodec,
7976
getUnitCodec,
80-
getUtf8Codec,
8177
isFixedSize,
8278
isOption,
8379
isSome,
@@ -93,6 +89,7 @@ import {
9389
transformCodec,
9490
} from '@solana/codecs';
9591

92+
import { getCodecFromBytesEncoding } from './bytes';
9693
import type {
9794
DecodedAccountNode,
9895
DecodedNodeCodec,
@@ -937,23 +934,6 @@ export function getConstantValueBytes(
937934
return visit(node.type, codecVisitor).encode(visit(node.value, valueVisitor));
938935
}
939936

940-
function getCodecFromBytesEncoding(encoding: BytesEncoding) {
941-
switch (encoding) {
942-
case 'base16':
943-
return getBase16Codec();
944-
case 'base58':
945-
return getBase58Codec();
946-
case 'base64':
947-
return getBase64Codec();
948-
case 'utf8':
949-
return getUtf8Codec();
950-
default:
951-
throw new CodamaError(CODAMA_ERROR__UNRECOGNIZED_BYTES_ENCODING, {
952-
encoding: encoding satisfies never,
953-
});
954-
}
955-
}
956-
957937
/** Integers always decode as `bigint`s, whatever their size, and encode from `number`s or `bigint`s. */
958938
function getIntegerCodec(node: IntegerTypeNode): Codec<bigint | number, bigint> {
959939
const config = { endian: node.endian === 'be' ? Endian.Big : Endian.Little };

‎packages/dynamic-codecs/src/format.ts‎

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { CODAMA_ERROR__INVALID_TICKS_PER_SECOND, CodamaError } from '@codama/errors';
2+
import { titleCase } from '@codama/fragments/casing';
13
import {
24
AmountNumberDisplayNode,
5+
DateTimeTypeNode,
6+
DurationTypeNode,
7+
getTextNodeContent,
38
InjectableIntegerValueNode,
49
InjectableStringValueNode,
510
InjectedValueNode,
@@ -9,28 +14,38 @@ import {
914
UnitNumberDisplayNode,
1015
} from '@codama/nodes';
1116
import { getLastNodeFromPath, NodePath } from '@codama/visitors-core';
17+
import type { Address } from '@solana/addresses';
1218
import {
1319
BinaryFixedPoint,
1420
binaryFixedPointToString,
1521
DecimalFixedPoint,
1622
decimalFixedPointToString,
1723
formatBinaryFixedPoint,
1824
formatDecimalFixedPoint,
25+
getBase16Decoder,
1926
rawBinaryFixedPoint,
2027
rawDecimalFixedPoint,
2128
Signedness,
2229
} from '@solana/codecs';
2330

31+
import { getCodecFromBytesEncoding } from './bytes';
2432
import type {
33+
DecodedBooleanTypeNode,
34+
DecodedBytesTypeNode,
2535
DecodedDateTimeTypeNode,
2636
DecodedDurationTypeNode,
37+
DecodedEnumTypeNode,
2738
DecodedFixedPointTypeNode,
2839
DecodedFloatTypeNode,
2940
DecodedIntegerTypeNode,
41+
DecodedPublicKeyTypeNode,
3042
DecodedStringTypeNode,
3143
} from './decoded';
3244

33-
/** Options shared by the formatters of decoded nodes. */
45+
/**
46+
* Options shared by the formatters of decoded nodes. Every formatter accepts them, even those
47+
* that use none of them yet, so all formatters share the `(decoded, options)` signature.
48+
*/
3449
export type FormatOptions = {
3550
/**
3651
* Place a unit next to a formatted value, e.g. to write `"USD 40.5"`. Defaults to
@@ -44,6 +59,12 @@ export type FormatOptions = {
4459
* fraction digit, e.g. `"1234.56789"`.
4560
*/
4661
numberFormat?: Intl.NumberFormat;
62+
/**
63+
* Format an address, e.g. to name it from sources the caller trusts, such as a token list or
64+
* an address book, or to truncate it: `address => names.get(address) ?? address`. Defaults to
65+
* the address itself.
66+
*/
67+
formatAddress?: (address: Address) => string;
4768
/**
4869
* Resolve the value of an injected value node used by a display node, e.g. the `decimals` of
4970
* an amount provided by its instruction, given its path through the decoded node, e.g.
@@ -145,16 +166,15 @@ export function formatFixedPoint(decoded: DecodedFixedPointTypeNode, options: Fo
145166
* when not zero, exactly when `ticksPerSecond` is a power of 10 and rounded to the
146167
* nanosecond otherwise.
147168
*
148-
* Returns `null` when `ticksPerSecond` is not a positive integer.
169+
* @throws `CODAMA_ERROR__INVALID_TICKS_PER_SECOND` when `ticksPerSecond` is not a positive integer.
149170
*
150171
* @example
151172
* ```ts
152173
* formatDateTime(decoded); // "2024-01-01T00:00:00Z"
153174
* ```
154175
*/
155-
export function formatDateTime(decoded: DecodedDateTimeTypeNode): string | null {
156-
const time = toSeconds(decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond);
157-
if (!time) return null;
176+
export function formatDateTime(decoded: DecodedDateTimeTypeNode, _options: FormatOptions = {}): string {
177+
const time = toSeconds(decoded.value, decoded.path);
158178
const days = floorDiv(time.seconds, SECONDS_PER_DAY);
159179
const secondsOfDay = time.seconds - days * SECONDS_PER_DAY;
160180
const [year, month, day] = getCivilDate(days);
@@ -167,17 +187,16 @@ export function formatDateTime(decoded: DecodedDateTimeTypeNode): string | null
167187
* needed, e.g. `"49:00:00"`, with a leading `-` when negative. Fractions of a second are shown
168188
* as for {@link formatDateTime}.
169189
*
170-
* Returns `null` when `ticksPerSecond` is not a positive integer.
190+
* @throws `CODAMA_ERROR__INVALID_TICKS_PER_SECOND` when `ticksPerSecond` is not a positive integer.
171191
*
172192
* @example
173193
* ```ts
174194
* formatDuration(decoded); // "01:30:00"
175195
* ```
176196
*/
177-
export function formatDuration(decoded: DecodedDurationTypeNode): string | null {
197+
export function formatDuration(decoded: DecodedDurationTypeNode, _options: FormatOptions = {}): string {
178198
const negative = decoded.value < 0n;
179-
const time = toSeconds(negative ? -decoded.value : decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond);
180-
if (!time) return null;
199+
const time = toSeconds(negative ? -decoded.value : decoded.value, decoded.path);
181200
return `${negative ? '-' : ''}${formatClock(time.seconds)}${time.fraction}`;
182201
}
183202

@@ -192,14 +211,73 @@ export function formatDuration(decoded: DecodedDurationTypeNode): string | null
192211
* formatString(decoded); // "abcd"
193212
* ```
194213
*/
195-
export function formatString(decoded: DecodedStringTypeNode): string {
214+
export function formatString(decoded: DecodedStringTypeNode, _options: FormatOptions = {}): string {
196215
const display = getLastNodeFromPath(decoded.path).display;
197216
if (!display) return decoded.value;
198217
return Array.from(decoded.value)
199218
.slice(display.sliceStart ?? 0, display.sliceEnd)
200219
.join('');
201220
}
202221

222+
/**
223+
* Format a decoded boolean as `"true"` or `"false"`.
224+
*
225+
* @example
226+
* ```ts
227+
* formatBoolean(decoded); // "true"
228+
* ```
229+
*/
230+
export function formatBoolean(decoded: DecodedBooleanTypeNode, _options: FormatOptions = {}): string {
231+
return decoded.value ? 'true' : 'false';
232+
}
233+
234+
/**
235+
* Format decoded bytes as hexadecimal with a `0x` prefix, whatever the encoding they were
236+
* decoded with, e.g. `["base64", "AQI="]` gives `"0x0102"`. Bytes decoded as `utf8` are
237+
* re-encoded from their text, so invalid UTF-8 sequences, already replaced when decoding, are
238+
* not recovered: decode bytes with another encoding to format them exactly.
239+
*
240+
* @example
241+
* ```ts
242+
* formatBytes(decoded); // "0x0102"
243+
* ```
244+
*/
245+
export function formatBytes(decoded: DecodedBytesTypeNode, _options: FormatOptions = {}): string {
246+
const [encoding, data] = decoded.value;
247+
return `0x${getBase16Decoder().decode(getCodecFromBytesEncoding(encoding).encode(data))}`;
248+
}
249+
250+
/**
251+
* Format a decoded enum as the label of its variant's display node, or the identifier of its
252+
* variant in title case otherwise, e.g. `"Move To"` for `moveTo`. The data of the variant, if
253+
* any, is not included.
254+
*
255+
* @example
256+
* ```ts
257+
* // enumVariantTypeNode('moveTo', { display: enumVariantDisplayNode({ label: 'Move' }) })
258+
* formatEnum(decoded); // "Move"
259+
* ```
260+
*/
261+
export function formatEnum(decoded: DecodedEnumTypeNode, _options: FormatOptions = {}): string {
262+
const variant = getLastNodeFromPath(decoded.variant.path);
263+
const label = variant.display?.label;
264+
return label === undefined ? titleCase(variant.identifier) : getTextNodeContent(label);
265+
}
266+
267+
/**
268+
* Format a decoded public key with `formatAddress`, if any, or as its address otherwise.
269+
*
270+
* @example
271+
* ```ts
272+
* formatPublicKey(decoded); // "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
273+
* formatPublicKey(decoded, { formatAddress: address => names.get(address) ?? address }); // "USDC"
274+
* ```
275+
*/
276+
export function formatPublicKey(decoded: DecodedPublicKeyTypeNode, options: FormatOptions = {}): string {
277+
const address = decoded.value as Address;
278+
return options.formatAddress ? options.formatAddress(address) : address;
279+
}
280+
203281
const SECONDS_PER_DAY = 86_400n;
204282

205283
/** The signedness and bit width of an integer format, for Kit's fixed points. */
@@ -263,14 +341,18 @@ function resolveStringInput(
263341
}
264342

265343
/**
266-
* Split a non-negative number of ticks into whole seconds and the fraction of a second,
267-
* e.g. `".5"`. Ticks that are not powers of 10 of a second are rounded to the nanosecond.
344+
* Split a non-negative number of ticks of a date-time or duration into whole seconds and the
345+
* fraction of a second, e.g. `".5"`. Ticks that are not powers of 10 of a second are rounded to
346+
* the nanosecond.
268347
*/
269348
function toSeconds(
270349
ticks: bigint,
271-
ticksPerSecond: number | undefined = 1,
272-
): { fraction: string; seconds: bigint } | undefined {
273-
if (!Number.isSafeInteger(ticksPerSecond) || ticksPerSecond <= 0) return undefined;
350+
path: NodePath<DateTimeTypeNode | DurationTypeNode>,
351+
): { fraction: string; seconds: bigint } {
352+
const ticksPerSecond = getLastNodeFromPath(path).ticksPerSecond ?? 1;
353+
if (!Number.isSafeInteger(ticksPerSecond) || ticksPerSecond <= 0) {
354+
throw new CodamaError(CODAMA_ERROR__INVALID_TICKS_PER_SECOND, { path, ticksPerSecond });
355+
}
274356
let perSecond = BigInt(ticksPerSecond);
275357
let digits = getPowerOfTen(perSecond);
276358
if (digits === undefined) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { booleanTypeNode } from '@codama/nodes';
2+
import { expect, test } from 'vitest';
3+
4+
import { formatBoolean, getNodeCodec } from '../../src';
5+
import { hex } from '../_setup';
6+
7+
test('it formats true booleans', () => {
8+
const decoded = getNodeCodec([booleanTypeNode()]).decode(hex('01'));
9+
expect(formatBoolean(decoded)).toBe('true');
10+
});
11+
12+
test('it formats false booleans', () => {
13+
const decoded = getNodeCodec([booleanTypeNode()]).decode(hex('00'));
14+
expect(formatBoolean(decoded)).toBe('false');
15+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { bytesTypeNode } from '@codama/nodes';
2+
import { expect, test } from 'vitest';
3+
4+
import { formatBytes, getNodeCodec } from '../../src';
5+
import { hex } from '../_setup';
6+
7+
test('it formats bytes as hexadecimal', () => {
8+
const decoded = getNodeCodec([bytesTypeNode()]).decode(hex('01020aff'));
9+
expect(formatBytes(decoded)).toBe('0x01020aff');
10+
});
11+
12+
test('it formats bytes as hexadecimal whatever the encoding they were decoded with', () => {
13+
const decoded = getNodeCodec([bytesTypeNode()], { bytesEncoding: 'base58' }).decode(hex('0001020aff'));
14+
expect(decoded.value[0]).toBe('base58');
15+
expect(formatBytes(decoded)).toBe('0x0001020aff');
16+
});
17+
18+
test('it formats bytes decoded as utf8 as hexadecimal', () => {
19+
const decoded = getNodeCodec([bytesTypeNode()], { bytesEncoding: 'utf8' }).decode(hex('6869'));
20+
expect(decoded.value).toStrictEqual(['utf8', 'hi']);
21+
expect(formatBytes(decoded)).toBe('0x6869');
22+
});
23+
24+
test('it formats empty bytes', () => {
25+
const decoded = getNodeCodec([bytesTypeNode()]).decode(hex(''));
26+
expect(formatBytes(decoded)).toBe('0x');
27+
});

‎packages/dynamic-codecs/test/format/formatDateTime.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CODAMA_ERROR__INVALID_TICKS_PER_SECOND, CodamaError } from '@codama/errors';
12
import { dateTimeTypeNode, integerTypeNode } from '@codama/nodes';
23
import { getI64Encoder } from '@solana/codecs';
34
import { expect, test } from 'vitest';
@@ -65,6 +66,10 @@ test('it matches JavaScript dates within their range', () => {
6566
expect(seconds.map(second => formatDateTime(decodeDateTime(second)))).toStrictEqual(expected);
6667
});
6768

68-
test('it does not format date-times whose ticks per second are not positive integers', () => {
69-
expect(formatDateTime(decodeDateTime(1n, 0))).toBeNull();
69+
test('it throws when the ticks per second of a date-time are not a positive integer', () => {
70+
const node = dateTimeTypeNode(integerTypeNode('i64'), { ticksPerSecond: 0 });
71+
const decoded = getNodeCodec([node]).decode(getI64Encoder().encode(1n));
72+
expect(() => formatDateTime(decoded)).toThrow(
73+
new CodamaError(CODAMA_ERROR__INVALID_TICKS_PER_SECOND, { path: [node], ticksPerSecond: 0 }),
74+
);
7075
});

0 commit comments

Comments
 (0)