Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/dynamic-codecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,43 @@ if (isDecodedNode(amount, 'structTypeNode')) {
}
```

## Formatting

Decoded nodes carry the nodes that decoded them, so their values can be formatted for humans using the presentation metadata of those nodes: units, display nodes, scales and ticks. Each formatter takes the decoded node of its kind.

| Formatter | Decoded node | Example |
| ------------------ | --------------------------- | ------------------------------- |
| `formatInteger` | `DecodedIntegerTypeNode` | `"1.5 USDC"`, `"42 slots"` |
| `formatFloat` | `DecodedFloatTypeNode` | `"1.5 USD"` |
| `formatFixedPoint` | `DecodedFixedPointTypeNode` | `"123.45%"` |
| `formatDateTime` | `DecodedDateTimeTypeNode` | `"2024-01-01T00:00:00.5Z"` |
| `formatDuration` | `DecodedDurationTypeNode` | `"49:00:00"`, `"-00:00:01.5"` |
| `formatString` | `DecodedStringTypeNode` | `"abcd"`, sliced by its display |

```ts
import { formatInteger, getNodeCodec, isDecodedNode } from '@codama/dynamic-codecs';

// amount: u64 with amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') })
const decoded = getNodeCodec([root, program, amountType]).decode(bytes);
if (isDecodedNode(decoded.type, 'integerTypeNode')) {
formatInteger(decoded.type); // "1.5 USDC"
}
```

Numbers are formatted exactly, including 128-bit integers and binary fixed points, using the fixed points of `@solana/codecs`. String slices count Unicode code points, so they never split a character such as an emoji. Date-times are exact ISO 8601 UTC strings for any year.

- **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 `°`.
- **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.
- **Ticks:** `formatDateTime` and `formatDuration` return `null` when `ticksPerSecond` is not a positive integer.

`formatInteger`, `formatFloat` and `formatFixedPoint` accept options:

| Name | Type | Description |
| ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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. |
| `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. |

## Node paths

The full path is needed to resolve link nodes, which may point to other programs, and injected values, which are provided by the enclosing instructions.
Expand Down
334 changes: 334 additions & 0 deletions packages/dynamic-codecs/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
import {
AmountNumberDisplayNode,
InjectableIntegerValueNode,
InjectableStringValueNode,
InjectedValueNode,
IntegerFormat,
isNode,
Node,
UnitNumberDisplayNode,
} from '@codama/nodes';
import { getLastNodeFromPath, NodePath } from '@codama/visitors-core';
import {
BinaryFixedPoint,
binaryFixedPointToString,
DecimalFixedPoint,
decimalFixedPointToString,
formatBinaryFixedPoint,
formatDecimalFixedPoint,
rawBinaryFixedPoint,
rawDecimalFixedPoint,
Signedness,
} from '@solana/codecs';

import type {
DecodedDateTimeTypeNode,
DecodedDurationTypeNode,
DecodedFixedPointTypeNode,
DecodedFloatTypeNode,
DecodedIntegerTypeNode,
DecodedStringTypeNode,
} from './decoded';

/** Options shared by the formatters of decoded nodes. */
export type FormatOptions = {
/**
* Place a unit next to a formatted value, e.g. to write `"USD 40.5"`. Defaults to
* {@link formatUnit}, which appends the unit after a space, or without one for `%`, `‰` and `°`.
*/
formatUnit?: (value: string, unit: string) => string;
/**
* Format numbers for a locale, e.g. `new Intl.NumberFormat('en-US')`. Its options decide the
* digits shown, e.g. 3 fraction digits at most by default. Amounts and fixed points are given
* to it exactly, never through a JavaScript float. Defaults to plain digits with every
* fraction digit, e.g. `"1234.56789"`.
*/
numberFormat?: Intl.NumberFormat;
/**
* Resolve the value of an injected value node used by a display node, e.g. the `decimals` of
* an amount provided by its instruction, given its path through the decoded node, e.g.
* `[...decoded.path, amountNumberDisplayNode, injectedValueNode]`. The resolver handles
* providers and fallbacks. Returning `undefined` leaves the value unresolved.
*/
resolveInjectedValue?: (path: NodePath<InjectedValueNode>) => unknown;
};

/** The units written straight after their value, without a space. */
const UNITS_WITHOUT_SPACE: readonly string[] = ['%', '‰', '°'];

/**
* Place a unit after a formatted value, separated by a space, except for units written
* straight after their value: `%`, `‰` and `°`.
*
* @example
* ```ts
* formatUnit('1.5', 'SOL'); // "1.5 SOL"
* formatUnit('12.5', '%'); // "12.5%"
* ```
*/
export function formatUnit(value: string, unit: string): string {
return UNITS_WITHOUT_SPACE.includes(unit) ? `${value}${unit}` : `${value} ${unit}`;
}

/**
* Format a decoded integer using its display node, if any, and its unit.
*
* - With an `amountNumberDisplayNode`, the integer is divided by `10 ^ decimals`, e.g. `1500000`
* with 6 decimals gives `"1.5"`. When `decimals` cannot be resolved, `null` is returned rather
* than a wrongly scaled amount, so callers can present the raw value instead.
* - The unit of its display node wins, and the unit of its type is the fallback whenever the
* former is absent or cannot be resolved.
*
* @example
* ```ts
* // u64 with amountNumberDisplayNode({ decimals: integerValueNode('6'), unit: stringValueNode('USDC') })
* formatInteger(decoded); // "1.5 USDC"
* ```
*/
export function formatInteger(decoded: DecodedIntegerTypeNode, options: FormatOptions = {}): string | null {
const node = getLastNodeFromPath(decoded.path);
const display = node.display;
let text: string;
if (isNode(display, 'amountNumberDisplayNode')) {
const decimals = resolveIntegerInput(decoded.path, display, display.decimals, options);
if (decimals === undefined) return null;
const { signedness, totalBits } = getIntegerLayout(node.format);
text = formatDecimal(rawDecimalFixedPoint(signedness, totalBits, decimals)(decoded.value), options);
} else {
text = options.numberFormat ? options.numberFormat.format(decoded.value) : decoded.value.toString();
}
return withUnit(text, getDisplayUnit(decoded.path, display, options) ?? node.unit, options);
}

/**
* Format a decoded float with its unit, the unit of its display node winning over the unit of
* its type.
*
* @example
* ```ts
* // f64 with unit 'USD'
* formatFloat(decoded); // "1.5 USD"
* ```
*/
export function formatFloat(decoded: DecodedFloatTypeNode, options: FormatOptions = {}): string {
const node = getLastNodeFromPath(decoded.path);
const text = options.numberFormat ? options.numberFormat.format(decoded.value) : decoded.value.toString();
Comment thread
lorisleiva marked this conversation as resolved.
return withUnit(text, getDisplayUnit(decoded.path, node.display, options) ?? node.unit, options);
}

/**
* Format a decoded fixed point as its exact value, `raw / base ^ scale`, with its unit, the
* unit of its display node winning over the unit of its type. Binary fixed points (base 2)
* have finite decimal expansions, so they are exact too.
*
* @example
* ```ts
* // fixedPointTypeNode(u32, 2, { unit: '%' })
* formatFixedPoint(decoded); // "123.45%"
* ```
*/
export function formatFixedPoint(decoded: DecodedFixedPointTypeNode, options: FormatOptions = {}): string {
const node = getLastNodeFromPath(decoded.path);
const base = node.base ?? 10;
const { signedness, totalBits } = getIntegerLayout(node.number.format);
const text =
base === 2
? formatBinary(rawBinaryFixedPoint(signedness, totalBits, node.scale)(decoded.value), options)
: formatDecimal(rawDecimalFixedPoint(signedness, totalBits, node.scale)(decoded.value), options);
return withUnit(text, getDisplayUnit(decoded.path, node.display, options) ?? node.unit, options);
}

/**
* Format a decoded date-time, a number of ticks since the Unix epoch, as an ISO 8601 UTC
* date-time, exact for any year. Years beyond `0000`–`9999` use the expanded form of
* `Date.prototype.toISOString`, e.g. `+275761` or `-000001`. Fractions of a second are shown
* when not zero, exactly when `ticksPerSecond` is a power of 10 and rounded to the
* nanosecond otherwise.
*
* Returns `null` when `ticksPerSecond` is not a positive integer.
*
* @example
* ```ts
* formatDateTime(decoded); // "2024-01-01T00:00:00Z"
* ```
*/
export function formatDateTime(decoded: DecodedDateTimeTypeNode): string | null {
const time = toSeconds(decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond);
if (!time) return null;
const days = floorDiv(time.seconds, SECONDS_PER_DAY);
const secondsOfDay = time.seconds - days * SECONDS_PER_DAY;
const [year, month, day] = getCivilDate(days);
const date = `${formatYear(year)}-${pad(month)}-${pad(day)}`;
return `${date}T${formatClock(secondsOfDay)}${time.fraction}Z`;
}

/**
* Format a decoded duration, a number of ticks, as `HH:mm:ss`, hours going beyond 24 when
* needed, e.g. `"49:00:00"`, with a leading `-` when negative. Fractions of a second are shown
* as for {@link formatDateTime}.
*
* Returns `null` when `ticksPerSecond` is not a positive integer.
*
* @example
* ```ts
* formatDuration(decoded); // "01:30:00"
* ```
*/
export function formatDuration(decoded: DecodedDurationTypeNode): string | null {
const negative = decoded.value < 0n;
const time = toSeconds(negative ? -decoded.value : decoded.value, getLastNodeFromPath(decoded.path).ticksPerSecond);
if (!time) return null;
return `${negative ? '-' : ''}${formatClock(time.seconds)}${time.fraction}`;
}

/**
* Format a decoded string, sliced to the `[sliceStart, sliceEnd)` range of its display node,
* if any. Indices count Unicode code points, so a slice never splits a character such as an
* emoji.
*
* @example
* ```ts
* // stringTypeNode('utf8', { display: stringDisplayNode({ sliceEnd: 4 }) })
* formatString(decoded); // "abcd"
* ```
*/
export function formatString(decoded: DecodedStringTypeNode): string {
const display = getLastNodeFromPath(decoded.path).display;
if (!display) return decoded.value;
return Array.from(decoded.value)
.slice(display.sliceStart ?? 0, display.sliceEnd)
.join('');
}

const SECONDS_PER_DAY = 86_400n;

/** The signedness and bit width of an integer format, for Kit's fixed points. */
function getIntegerLayout(format: IntegerFormat): { signedness: Signedness; totalBits: number } {
// `shortU16` is a variable-size encoding of an unsigned 16-bit integer.
if (format === 'shortU16') return { signedness: 'unsigned', totalBits: 16 };
return { signedness: format.startsWith('i') ? 'signed' : 'unsigned', totalBits: Number(format.slice(1)) };
}

function formatDecimal(value: DecimalFixedPoint<Signedness, number, number>, options: FormatOptions): string {
return options.numberFormat
? formatDecimalFixedPoint(options.numberFormat, value)
: decimalFixedPointToString(value);
}

function formatBinary(value: BinaryFixedPoint<Signedness, number, number>, options: FormatOptions): string {
return options.numberFormat ? formatBinaryFixedPoint(options.numberFormat, value) : binaryFixedPointToString(value);
}

function withUnit(text: string, unit: string | undefined, options: FormatOptions): string {
if (!unit) return text;
return (options.formatUnit ?? formatUnit)(text, unit);
}

/** The unit of a number's display node, if it has one that resolves. */
function getDisplayUnit(
path: NodePath,
display: AmountNumberDisplayNode | UnitNumberDisplayNode | undefined,
options: FormatOptions,
): string | undefined {
if (!display?.unit) return undefined;
return resolveStringInput(path, display, display.unit, options);
}

/** A non-negative integer input of a display node, e.g. the `decimals` of an amount, if it resolves. */
function resolveIntegerInput(
path: NodePath,
display: Node,
input: InjectableIntegerValueNode,
options: FormatOptions,
): number | undefined {
const value: unknown = isNode(input, 'integerValueNode')
? BigInt(input.value)
: options.resolveInjectedValue?.([...path, display, input]);
if (typeof value === 'bigint' && value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER)) return Number(value);
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
Comment thread
lorisleiva marked this conversation as resolved.
return undefined;
}

/** A non-empty string input of a display node, e.g. the `unit` of an amount, if it resolves. */
function resolveStringInput(
path: NodePath,
display: Node,
input: InjectableStringValueNode,
options: FormatOptions,
): string | undefined {
const value: unknown = isNode(input, 'stringValueNode')
? input.string
: options.resolveInjectedValue?.([...path, display, input]);
return typeof value === 'string' && value !== '' ? value : undefined;
}

/**
* Split a non-negative number of ticks into whole seconds and the fraction of a second,
* e.g. `".5"`. Ticks that are not powers of 10 of a second are rounded to the nanosecond.
*/
function toSeconds(
ticks: bigint,
ticksPerSecond: number | undefined = 1,
): { fraction: string; seconds: bigint } | undefined {
if (!Number.isSafeInteger(ticksPerSecond) || ticksPerSecond <= 0) return undefined;
let perSecond = BigInt(ticksPerSecond);
let digits = getPowerOfTen(perSecond);
if (digits === undefined) {
// Round to the nearest nanosecond, halves away from zero.
const nanoseconds = ticks * 1_000_000_000n;
const half = nanoseconds < 0n ? -perSecond / 2n : perSecond / 2n;
ticks = (nanoseconds + half) / perSecond;
perSecond = 1_000_000_000n;
digits = 9;
}
const seconds = floorDiv(ticks, perSecond);
const remainder = ticks - seconds * perSecond;
const fraction = remainder === 0n ? '' : `.${remainder.toString().padStart(digits, '0').replace(/0+$/, '')}`;
return { fraction, seconds };
}

/** The number of zeros of a power of 10, e.g. 3 for 1000, if it is one. */
function getPowerOfTen(value: bigint): number | undefined {
const digits = value.toString();
return /^10*$/.test(digits) ? digits.length - 1 : undefined;
}

function floorDiv(dividend: bigint, divisor: bigint): bigint {
const quotient = dividend / divisor;
return dividend % divisor !== 0n && dividend < 0n !== divisor < 0n ? quotient - 1n : quotient;
}

/**
* The proleptic Gregorian year, month and day of a number of days since the Unix epoch,
* using Howard Hinnant's `civil_from_days` algorithm, exact for any number of days.
*/
function getCivilDate(daysSinceEpoch: bigint): [year: bigint, month: bigint, day: bigint] {
const days = daysSinceEpoch + 719_468n;
const era = floorDiv(days, 146_097n);
const dayOfEra = days - era * 146_097n;
const yearOfEra = (dayOfEra - dayOfEra / 1_460n + dayOfEra / 36_524n - dayOfEra / 146_096n) / 365n;
const dayOfYear = dayOfEra - (365n * yearOfEra + yearOfEra / 4n - yearOfEra / 100n);
const shiftedMonth = (5n * dayOfYear + 2n) / 153n;
const day = dayOfYear - (153n * shiftedMonth + 2n) / 5n + 1n;
const month = shiftedMonth < 10n ? shiftedMonth + 3n : shiftedMonth - 9n;
const year = yearOfEra + era * 400n + (month <= 2n ? 1n : 0n);
return [year, month, day];
}

/** An ISO 8601 year: 4 digits within `0000`–`9999`, and a sign with at least 6 digits otherwise. */
function formatYear(year: bigint): string {
if (year >= 0n && year <= 9999n) return year.toString().padStart(4, '0');
return `${year < 0n ? '-' : '+'}${(year < 0n ? -year : year).toString().padStart(6, '0')}`;
}

/** `HH:mm:ss` for a non-negative number of seconds, hours going beyond 24 when needed. */
function formatClock(totalSeconds: bigint): string {
const hours = totalSeconds / 3_600n;
const minutes = (totalSeconds % 3_600n) / 60n;
const seconds = totalSeconds % 60n;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}

function pad(value: bigint): string {
return value.toString().padStart(2, '0');
}
1 change: 1 addition & 0 deletions packages/dynamic-codecs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getValueNodeVisitor } from './values';

export * from './codecs';
export * from './decoded';
export * from './format';
export * from './values';

export type { ReadonlyUint8Array };
Expand Down
Loading
Loading