Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/dynamic-instructions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
"dependencies": {
"@codama/dynamic-address-resolution": "workspace:*",
"@codama/dynamic-codecs": "workspace:*",
"@codama/dynamic-parsers": "workspace:*",
"@codama/errors": "workspace:*",
"@solana/accounts": "^5.3.0",
"@solana/addresses": "^5.3.0",
"@solana/codecs": "^5.3.0",
"@solana/instructions": "^5.3.0",
Expand Down
97 changes: 97 additions & 0 deletions packages/dynamic-instructions/src/display/format-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type {
AmountNumberDisplayNode,
DateTimeNumberDisplayNode,
DurationNumberDisplayNode,
StringDisplayNode,
} from 'codama';

import { resolveInjectedValue } from './resolve-injected-value';
import type { DisplayResolutionContext } from './types';

/**
* Formats an integer as a scaled amount with an optional unit (e.g. `1100000000` → `"1.1 SOL"`).
*
* The scale (`decimals`) is treated as correctness: when it cannot be resolved, this returns
* `null` so callers fall back to the raw value rather than display a misscaled — and therefore
* misleading — amount. The `unit` is treated as enrichment: when it cannot be resolved, the
* scaled value is returned without a suffix.
*/
export async function formatAmountValue(
value: bigint | number,
node: AmountNumberDisplayNode,
context: DisplayResolutionContext,
): Promise<string | null> {
const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0;
if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) {
return null;
}

const scaled = scaleByDecimals(value, Number(decimals));
if (scaled === null) return null;

const unit = node.unit ? await resolveInjectedValue(node.unit, context) : null;
return typeof unit === 'string' && unit !== '' ? `${scaled} ${unit}` : scaled;
}

/**
* Formats an integer counting ticks since the Unix epoch as an ISO 8601 date-time string.
* `ticksPerSecond` (default `1`) converts the raw ticks back to seconds.
*/
export function formatDateTimeValue(value: bigint | number, node: DateTimeNumberDisplayNode): string | null {
const seconds = toSeconds(value, node.ticksPerSecond);
if (seconds === null) return null;
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}

/**
* Formats an integer counting ticks as an elapsed duration in `HH:mm:ss`.
* `ticksPerSecond` (default `1`) converts the raw ticks back to seconds.
*/
export function formatDurationValue(value: bigint | number, node: DurationNumberDisplayNode): string | null {
const totalSeconds = toSeconds(value, node.ticksPerSecond);
if (totalSeconds === null || totalSeconds < 0) return null;

const whole = Math.floor(totalSeconds);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const seconds = whole % 60;
return [hours, minutes, seconds].map(part => part.toString().padStart(2, '0')).join(':');
}

/**
* Presents a string by slicing it to the `[sliceStart, sliceEnd)` range of decoded characters.
* Both bounds are optional and default to the start and end of the string respectively.
*/
Comment on lines +63 to +66
export function formatStringValue(value: string, node: StringDisplayNode): string {
if (node.sliceStart === undefined && node.sliceEnd === undefined) return value;
return value.slice(node.sliceStart ?? 0, node.sliceEnd);
}

/**
* Divides an integer value by `10 ^ decimals`, trimming trailing zeros from the fractional part.
* Returns `null` for a non-integer value (which cannot be scaled as a fixed-point integer) or for
* negative decimals (which cannot describe a fixed-point scale).
*/
function scaleByDecimals(value: bigint | number, decimals: number): string | null {
if (typeof value === 'number' && !Number.isInteger(value)) return null;
if (!Number.isInteger(decimals) || decimals < 0) return null;
if (decimals === 0) return value.toString();

const negative = value < 0;
const digits = (negative ? -BigInt(value) : BigInt(value)).toString().padStart(decimals + 1, '0');

@mikhd mikhd Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also double-guard that value: bigint | number number is not float? Otherwise conversion to BigInt will throw RangeError.

const integerPart = digits.slice(0, digits.length - decimals);
const fractionPart = digits.slice(digits.length - decimals).replace(/0+$/, '');
const sign = negative ? '-' : '';
return fractionPart ? `${sign}${integerPart}.${fractionPart}` : `${sign}${integerPart}`;
}
Comment on lines +77 to +88

/** Converts a tick value into whole/fractional seconds using `ticksPerSecond` (default `1`). */
function toSeconds(value: bigint | number, ticksPerSecond: number | undefined): number | null {
const ticks = Number(value);
if (!Number.isFinite(ticks)) return null;
const divisor = ticksPerSecond ?? 1;
if (divisor <= 0) return null;
return ticks / divisor;
}
Comment on lines +91 to +97
3 changes: 3 additions & 0 deletions packages/dynamic-instructions/src/display/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './format-value';
export * from './resolve-injected-value';
export * from './types';
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Address } from '@solana/addresses';
import { isNode, type Node } from 'codama';

import type { DisplayResolutionContext } from './types';

/**
* A value resolved from the provide/inject graph for display purposes.
*
* `number` and `string` come from literal value nodes or account fields; `Address` comes from
* an `accountValueNode` reference. `null` means the value could not be resolved (no provider,
* no fallback, or missing account data) and the caller should degrade gracefully.
*/
export type ResolvedDisplayValue = Address | bigint | number | string | null;

/**
* Resolves a node to a concrete display value within a {@link DisplayResolutionContext}.
*
* Handles the value/contextual nodes the display layer relies on:
* - `numberValueNode` / `stringValueNode`: the literal value.
* - `injectedValueNode`: looks the key up in `provides`, resolving the matched provider's node;
* when no provider supplies the key, falls back to the injection's own `fallback`.
* - `argumentValueNode`: the decoded value of the referenced instruction argument.
* - `accountValueNode`: the referenced account's address.
* - `accountFieldValueNode`: a field of the referenced account's data — the account is fetched via
* `fetchAccount`, then its bytes decoded against the account's `accountLink` via
* `resolveAccountData`.
*
* Returns `null` when the value cannot be resolved so callers can fall back safely.
*/
export async function resolveInjectedValue(

@mikhd mikhd Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is argument-sourced injection intentionally scoped out?

What made me unsure was that collectReferencedMembers does handle argumentValueNode (added in PR#1016) but resolveInjectedValue does not. The fix would look like just if (isNode(node, 'argumentValueNode')) return toResolvedValue(context.data[node.name]);, if i'm not mistaken.

Structure example:

instructionNode({
  arguments: [
    instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64', 'le', {
      display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }),
    })}),
    instructionArgumentNode({ name: 'decimals', type: numberTypeNode('u8') }),
  ],
  provides: [ providedNode('decimals', argumentValueNode('decimals')) ], // <-- argument-sourced provide
})

And something like these:

test('it resolves an injected value provided by an argumentValueNode', async () => {
    // Given an injected key provided by an argumentValueNode.
    const node = injectedValueNode({ key: 'decimals' });
    const provides = providesMap(providedNode('decimals', argumentValueNode('decimals')));

    // When we resolve it (the decoded argument is present in context.data).
    const result = await resolveInjectedValue(node, context({ data: { decimals: 6 }, provides }));

    // Then we expect the argument value.
    expect(result).toBe(6);
});

test('it resolves an argumentValueNode', async () => {
    // Given an argument value node resolved directly.
    const node = argumentValueNode('decimals');

    // When we resolve it.
    const result = await resolveInjectedValue(node, context({ data: { decimals: 6 } }));

    // Then we expect the argument value.
    expect(result).toBe(6);
});

node: Node,
context: DisplayResolutionContext,
): Promise<ResolvedDisplayValue> {
if (isNode(node, 'numberValueNode')) {
return node.number;
}

if (isNode(node, 'stringValueNode')) {
return node.string;
}

if (isNode(node, 'injectedValueNode')) {
const provided = context.provides.get(node.key);
if (provided) {
return await resolveInjectedValue(provided.node, context);
}
if (node.fallback) {
return await resolveInjectedValue(node.fallback, context);
}
return null;
}
Comment on lines +30 to +51

if (isNode(node, 'argumentValueNode')) {
const data = context.parsedInstruction.data as Record<string, unknown>;
return toResolvedValue(data[node.name]);
}

if (isNode(node, 'accountValueNode')) {
return findAccountAddress(context, node.name);
}

if (isNode(node, 'accountFieldValueNode')) {
const address = findAccountAddress(context, node.account);
if (!address || !context.fetchAccount) return null;
const account = await context.fetchAccount(address);
if (!account.exists) return null;
const data = context.resolveAccountData(node.account, account.data);
return readAccountField(data, node.path);
}

return null;
}

/** Finds the concrete address of a named account of the surrounding instruction, or `null`. */
function findAccountAddress(context: DisplayResolutionContext, name: string): Address | null {
return context.parsedInstruction.accounts.find(account => account.name === name)?.address ?? null;
}

/**
* Reads a named field from a decoded account's data.
* A path is required: the whole decoded struct is not a single displayable scalar, so a
* path-less reference yields `null`. Also returns `null` when the data could not be decoded, or
* when the field is absent or not a primitive value.
*/
function readAccountField(data: Record<string, unknown> | null, path: string | undefined): ResolvedDisplayValue {
if (data === null || path === undefined) return null;
return toResolvedValue(data[path]);
}

/** Narrows an unknown decoded value to the primitive shapes the display layer can render. */
function toResolvedValue(value: unknown): ResolvedDisplayValue {
if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string') {
return value;
}
return null;
}
52 changes: 52 additions & 0 deletions packages/dynamic-instructions/src/display/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ParsedInstruction } from '@codama/dynamic-parsers';
import type { MaybeEncodedAccount } from '@solana/accounts';
import type { Address } from '@solana/addresses';
import type { ReadonlyUint8Array } from '@solana/codecs';
import type { ProvidedNode } from 'codama';

/**
* Fetches the on-chain account at a given address, returning Kit's `MaybeEncodedAccount` — an
* `exists` flag plus, when the account exists, the raw account bytes and its on-chain metadata.
*
* Consumers simply forward what an RPC returns (e.g. `fetchEncodedAccount`) — no decoding required.
* The display layer decodes the bytes itself using the referenced account's `accountLink` from the
* IDL. Required to resolve display values that live in account state — e.g. a token's
* `decimals`/`symbol` injected via the provide/inject pattern. When omitted, such values fall back
* to their declared `fallback` (when present) or are treated as unresolvable.
*
* The eventual offline / hardware-wallet path can back this callback with a pre-fetched metadata
* bundle instead of live RPC.
*/
export type FetchAccountFn = (address: Address) => Promise<MaybeEncodedAccount>;

/**
* Decodes the raw bytes of a named instruction account against its `accountLink` layout, returning
* the decoded record or `null` when the account carries no link or cannot be decoded.
*
* The IDL already describes how to decode a linked account, so decoding is done for the consumer
* rather than asked of them. The orchestrator backs this with a `LinkableDictionary` populated from
* the root.
*/
export type ResolveAccountDataFn = (accountName: string, bytes: ReadonlyUint8Array) => Record<string, unknown> | null;

/**
* The contextual environment in which display values are resolved.
*
* Mirrors the provide/inject pattern: an instruction (or other host) exposes named values
* through `provides`, and reusable types pull them by key via `injectedValueNode`. Resolving
* those keys reads from the surrounding `parsedInstruction` (its decoded arguments and account
* addresses) and, for account state, the `fetchAccount` hook plus `resolveAccountData` decoder.
*
* The orchestrator assembles this from a parsed instruction; it is kept explicit here so the
* resolution and formatting layers can be exercised in isolation.
*/
export type DisplayResolutionContext = {
/** Fetches the on-chain account at an address; absent when running fully offline. */
readonly fetchAccount?: FetchAccountFn;
/** The parsed instruction being presented: its decoded arguments, node path, and account metas. */
readonly parsedInstruction: ParsedInstruction;
/** Values exposed by the surrounding host, keyed by the name they are provided under. */
readonly provides: ReadonlyMap<string, ProvidedNode>;
/** Decodes a named account's raw bytes against its `accountLink` layout. */
readonly resolveAccountData: ResolveAccountDataFn;
};
Loading
Loading