Skip to content

Commit b15ad3e

Browse files
committed
Add display value formatting and provide/inject resolution
This PR begins the clear-signing display-text feature (sRFC 39) with its value layer. It adds resolveInjectedValue, which resolves the provide/inject graph — literal values, injected keys (with fallback), account references, and account fields read via an optional fetchAccountData hook returning Kit's decoded Account — within an explicit DisplayResolutionContext. On top of it, it adds the per-display-node value formatters: scaled token amounts (treating decimals as correctness, so an unresolvable scale yields no output rather than a misleading value, while an unresolvable unit merely drops the suffix), ISO 8601 date-times, HH:mm:ss durations, and sliced strings. These are internal building blocks and are not yet exported from the package; the resolution context is kept explicit so the value layer can be exercised in isolation ahead of the interpolation, fallback-list, and orchestration PRs. No changeset is included, consistent with the rest of the 1.7.0 stack.
1 parent f4d3892 commit b15ad3e

9 files changed

Lines changed: 799 additions & 2 deletions

File tree

packages/dynamic-instructions/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@
6868
"dependencies": {
6969
"@codama/dynamic-address-resolution": "workspace:*",
7070
"@codama/dynamic-codecs": "workspace:*",
71+
"@codama/dynamic-parsers": "workspace:*",
7172
"@codama/errors": "workspace:*",
73+
"@solana/accounts": "^5.3.0",
7274
"@solana/addresses": "^5.3.0",
7375
"@solana/codecs": "^5.3.0",
7476
"@solana/instructions": "^5.3.0",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type {
2+
AmountNumberDisplayNode,
3+
DateTimeNumberDisplayNode,
4+
DurationNumberDisplayNode,
5+
StringDisplayNode,
6+
} from 'codama';
7+
8+
import { resolveInjectedValue } from './resolve-injected-value';
9+
import type { DisplayResolutionContext } from './types';
10+
11+
/**
12+
* Formats an integer as a scaled amount with an optional unit (e.g. `1100000000` → `"1.1 SOL"`).
13+
*
14+
* The scale (`decimals`) is treated as correctness: when it cannot be resolved, this returns
15+
* `null` so callers fall back to the raw value rather than display a misscaled — and therefore
16+
* misleading — amount. The `unit` is treated as enrichment: when it cannot be resolved, the
17+
* scaled value is returned without a suffix.
18+
*/
19+
export async function formatAmountValue(
20+
value: bigint | number,
21+
node: AmountNumberDisplayNode,
22+
context: DisplayResolutionContext,
23+
): Promise<string | null> {
24+
const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0;
25+
if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) {
26+
return null;
27+
}
28+
29+
const scaled = scaleByDecimals(value, Number(decimals));
30+
if (scaled === null) return null;
31+
32+
const unit = node.unit ? await resolveInjectedValue(node.unit, context) : null;
33+
return typeof unit === 'string' && unit !== '' ? `${scaled} ${unit}` : scaled;
34+
}
35+
36+
/**
37+
* Formats an integer counting ticks since the Unix epoch as an ISO 8601 date-time string.
38+
* `ticksPerSecond` (default `1`) converts the raw ticks back to seconds.
39+
*/
40+
export function formatDateTimeValue(value: bigint | number, node: DateTimeNumberDisplayNode): string | null {
41+
const seconds = toSeconds(value, node.ticksPerSecond);
42+
if (seconds === null) return null;
43+
const date = new Date(seconds * 1000);
44+
if (Number.isNaN(date.getTime())) return null;
45+
return date.toISOString();
46+
}
47+
48+
/**
49+
* Formats an integer counting ticks as an elapsed duration in `HH:mm:ss`.
50+
* `ticksPerSecond` (default `1`) converts the raw ticks back to seconds.
51+
*/
52+
export function formatDurationValue(value: bigint | number, node: DurationNumberDisplayNode): string | null {
53+
const totalSeconds = toSeconds(value, node.ticksPerSecond);
54+
if (totalSeconds === null || totalSeconds < 0) return null;
55+
56+
const whole = Math.floor(totalSeconds);
57+
const hours = Math.floor(whole / 3600);
58+
const minutes = Math.floor((whole % 3600) / 60);
59+
const seconds = whole % 60;
60+
return [hours, minutes, seconds].map(part => part.toString().padStart(2, '0')).join(':');
61+
}
62+
63+
/**
64+
* Presents a string by slicing it to the `[sliceStart, sliceEnd)` range of decoded characters.
65+
* Both bounds are optional and default to the start and end of the string respectively.
66+
*/
67+
export function formatStringValue(value: string, node: StringDisplayNode): string {
68+
if (node.sliceStart === undefined && node.sliceEnd === undefined) return value;
69+
return value.slice(node.sliceStart ?? 0, node.sliceEnd);
70+
}
71+
72+
/**
73+
* Divides an integer value by `10 ^ decimals`, trimming trailing zeros from the fractional part.
74+
* Returns `null` for a non-integer value (which cannot be scaled as a fixed-point integer) or for
75+
* negative decimals (which cannot describe a fixed-point scale).
76+
*/
77+
function scaleByDecimals(value: bigint | number, decimals: number): string | null {
78+
if (typeof value === 'number' && !Number.isInteger(value)) return null;
79+
if (!Number.isInteger(decimals) || decimals < 0) return null;
80+
if (decimals === 0) return value.toString();
81+
82+
const negative = value < 0;
83+
const digits = (negative ? -BigInt(value) : BigInt(value)).toString().padStart(decimals + 1, '0');
84+
const integerPart = digits.slice(0, digits.length - decimals);
85+
const fractionPart = digits.slice(digits.length - decimals).replace(/0+$/, '');
86+
const sign = negative ? '-' : '';
87+
return fractionPart ? `${sign}${integerPart}.${fractionPart}` : `${sign}${integerPart}`;
88+
}
89+
90+
/** Converts a tick value into whole/fractional seconds using `ticksPerSecond` (default `1`). */
91+
function toSeconds(value: bigint | number, ticksPerSecond: number | undefined): number | null {
92+
const ticks = Number(value);
93+
if (!Number.isFinite(ticks)) return null;
94+
const divisor = ticksPerSecond ?? 1;
95+
if (divisor <= 0) return null;
96+
return ticks / divisor;
97+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './format-value';
2+
export * from './resolve-injected-value';
3+
export * from './types';
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Address } from '@solana/addresses';
2+
import { isNode, type Node } from 'codama';
3+
4+
import type { DisplayResolutionContext } from './types';
5+
6+
/**
7+
* A value resolved from the provide/inject graph for display purposes.
8+
*
9+
* `number` and `string` come from literal value nodes or account fields; `Address` comes from
10+
* an `accountValueNode` reference. `null` means the value could not be resolved (no provider,
11+
* no fallback, or missing account data) and the caller should degrade gracefully.
12+
*/
13+
export type ResolvedDisplayValue = Address | bigint | number | string | null;
14+
15+
/**
16+
* Resolves a node to a concrete display value within a {@link DisplayResolutionContext}.
17+
*
18+
* Handles the value/contextual nodes the display layer relies on:
19+
* - `numberValueNode` / `stringValueNode`: the literal value.
20+
* - `injectedValueNode`: looks the key up in `provides`, resolving the matched provider's node;
21+
* when no provider supplies the key, falls back to the injection's own `fallback`.
22+
* - `argumentValueNode`: the decoded value of the referenced instruction argument.
23+
* - `accountValueNode`: the referenced account's address.
24+
* - `accountFieldValueNode`: a field of the referenced account's data — the account is fetched via
25+
* `fetchAccount`, then its bytes decoded against the account's `accountLink` via
26+
* `resolveAccountData`.
27+
*
28+
* Returns `null` when the value cannot be resolved so callers can fall back safely.
29+
*/
30+
export async function resolveInjectedValue(
31+
node: Node,
32+
context: DisplayResolutionContext,
33+
): Promise<ResolvedDisplayValue> {
34+
if (isNode(node, 'numberValueNode')) {
35+
return node.number;
36+
}
37+
38+
if (isNode(node, 'stringValueNode')) {
39+
return node.string;
40+
}
41+
42+
if (isNode(node, 'injectedValueNode')) {
43+
const provided = context.provides.get(node.key);
44+
if (provided) {
45+
return await resolveInjectedValue(provided.node, context);
46+
}
47+
if (node.fallback) {
48+
return await resolveInjectedValue(node.fallback, context);
49+
}
50+
return null;
51+
}
52+
53+
if (isNode(node, 'argumentValueNode')) {
54+
const data = context.parsedInstruction.data as Record<string, unknown>;
55+
return toResolvedValue(data[node.name]);
56+
}
57+
58+
if (isNode(node, 'accountValueNode')) {
59+
return findAccountAddress(context, node.name);
60+
}
61+
62+
if (isNode(node, 'accountFieldValueNode')) {
63+
const address = findAccountAddress(context, node.account);
64+
if (!address || !context.fetchAccount) return null;
65+
const account = await context.fetchAccount(address);
66+
if (!account.exists) return null;
67+
const data = context.resolveAccountData(node.account, account.data);
68+
return readAccountField(data, node.path);
69+
}
70+
71+
return null;
72+
}
73+
74+
/** Finds the concrete address of a named account of the surrounding instruction, or `null`. */
75+
function findAccountAddress(context: DisplayResolutionContext, name: string): Address | null {
76+
return context.parsedInstruction.accounts.find(account => account.name === name)?.address ?? null;
77+
}
78+
79+
/**
80+
* Reads a named field from a decoded account's data.
81+
* A path is required: the whole decoded struct is not a single displayable scalar, so a
82+
* path-less reference yields `null`. Also returns `null` when the data could not be decoded, or
83+
* when the field is absent or not a primitive value.
84+
*/
85+
function readAccountField(data: Record<string, unknown> | null, path: string | undefined): ResolvedDisplayValue {
86+
if (data === null || path === undefined) return null;
87+
return toResolvedValue(data[path]);
88+
}
89+
90+
/** Narrows an unknown decoded value to the primitive shapes the display layer can render. */
91+
function toResolvedValue(value: unknown): ResolvedDisplayValue {
92+
if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string') {
93+
return value;
94+
}
95+
return null;
96+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { ParsedInstruction } from '@codama/dynamic-parsers';
2+
import type { MaybeEncodedAccount } from '@solana/accounts';
3+
import type { Address } from '@solana/addresses';
4+
import type { ReadonlyUint8Array } from '@solana/codecs';
5+
import type { ProvidedNode } from 'codama';
6+
7+
/**
8+
* Fetches the on-chain account at a given address, returning Kit's `MaybeEncodedAccount` — an
9+
* `exists` flag plus, when the account exists, the raw account bytes and its on-chain metadata.
10+
*
11+
* Consumers simply forward what an RPC returns (e.g. `fetchEncodedAccount`) — no decoding required.
12+
* The display layer decodes the bytes itself using the referenced account's `accountLink` from the
13+
* IDL. Required to resolve display values that live in account state — e.g. a token's
14+
* `decimals`/`symbol` injected via the provide/inject pattern. When omitted, such values fall back
15+
* to their declared `fallback` (when present) or are treated as unresolvable.
16+
*
17+
* The eventual offline / hardware-wallet path can back this callback with a pre-fetched metadata
18+
* bundle instead of live RPC.
19+
*/
20+
export type FetchAccountFn = (address: Address) => Promise<MaybeEncodedAccount>;
21+
22+
/**
23+
* Decodes the raw bytes of a named instruction account against its `accountLink` layout, returning
24+
* the decoded record or `null` when the account carries no link or cannot be decoded.
25+
*
26+
* The IDL already describes how to decode a linked account, so decoding is done for the consumer
27+
* rather than asked of them. The orchestrator backs this with a `LinkableDictionary` populated from
28+
* the root.
29+
*/
30+
export type ResolveAccountDataFn = (accountName: string, bytes: ReadonlyUint8Array) => Record<string, unknown> | null;
31+
32+
/**
33+
* The contextual environment in which display values are resolved.
34+
*
35+
* Mirrors the provide/inject pattern: an instruction (or other host) exposes named values
36+
* through `provides`, and reusable types pull them by key via `injectedValueNode`. Resolving
37+
* those keys reads from the surrounding `parsedInstruction` (its decoded arguments and account
38+
* addresses) and, for account state, the `fetchAccount` hook plus `resolveAccountData` decoder.
39+
*
40+
* The orchestrator assembles this from a parsed instruction; it is kept explicit here so the
41+
* resolution and formatting layers can be exercised in isolation.
42+
*/
43+
export type DisplayResolutionContext = {
44+
/** Fetches the on-chain account at an address; absent when running fully offline. */
45+
readonly fetchAccount?: FetchAccountFn;
46+
/** The parsed instruction being presented: its decoded arguments, node path, and account metas. */
47+
readonly parsedInstruction: ParsedInstruction;
48+
/** Values exposed by the surrounding host, keyed by the name they are provided under. */
49+
readonly provides: ReadonlyMap<string, ProvidedNode>;
50+
/** Decodes a named account's raw bytes against its `accountLink` layout. */
51+
readonly resolveAccountData: ResolveAccountDataFn;
52+
};

0 commit comments

Comments
 (0)