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
1 change: 1 addition & 0 deletions packages/dynamic-instructions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@codama/dynamic-address-resolution": "workspace:*",
"@codama/dynamic-codecs": "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
95 changes: 95 additions & 0 deletions packages/dynamic-instructions/src/display/format-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 negative decimals, which cannot describe a fixed-point scale.
*/
function scaleByDecimals(value: bigint | number, decimals: number): string | 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 +76 to +86

/** 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 +89 to +95
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,86 @@
import type { Account } from '@solana/accounts';
import type { Address } from '@solana/addresses';
import { isNode, type Node } from 'codama';

import { isObjectRecord } from '../shared/util';
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`.
* - `accountValueNode`: the referenced account's address.
* - `accountFieldValueNode`: a field of the referenced account's decoded data, fetched via
* `fetchAccountData`.
*
* 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, 'accountValueNode')) {
return context.accountAddresses.get(node.name) ?? null;
}

if (isNode(node, 'accountFieldValueNode')) {
const address = context.accountAddresses.get(node.account);
if (!address || !context.fetchAccountData) return null;
const account = await context.fetchAccountData(address);
if (!account) return null;
return readAccountField(account, node.path);
}

return 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 is not a record, or
* when the field is absent or not a primitive value.
*/
function readAccountField(account: Account<object>, path: string | undefined): ResolvedDisplayValue {
if (path === undefined) return null;
if (!isObjectRecord(account.data)) return null;
return toResolvedValue(account.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;
}
37 changes: 37 additions & 0 deletions packages/dynamic-instructions/src/display/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Account } from '@solana/accounts';
import type { Address } from '@solana/addresses';
import type { ProvidedNode } from 'codama';

/**
* Fetches and decodes the account at a given address, returning Kit's decoded `Account`
* (whose `data` is the decoded layout) or `null` when the account does not exist.
*
* Required to resolve display values that live in account state — e.g. a token's
* `decimals`/`symbol` injected via the provide/inject pattern, or interpolation paths
* such as `${accounts.destination.data.owner}`. 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 FetchAccountDataFn = (address: Address) => Promise<Account<object> | 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 may also require reading account state, so the context carries the addresses of
* the surrounding instruction's accounts plus the `fetchAccountData` hook.
*
* 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 = {
/** Addresses of the surrounding instruction's accounts, keyed by account name. */
readonly accountAddresses: ReadonlyMap<string, Address>;
/** Fetches and decodes account data; absent when running fully offline. */
readonly fetchAccountData?: FetchAccountDataFn;
/** Values exposed by the surrounding host, keyed by the name they are provided under. */
readonly provides: ReadonlyMap<string, ProvidedNode>;
};
Loading
Loading