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
6 changes: 6 additions & 0 deletions .changeset/every-bikes-rhyme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@codama/dynamic-instructions': minor
'@codama/dynamic-parsers': minor
---

Add clear-signing instruction display text (sRFC 39). `getInstructionDisplay` resolves a concrete instruction into a human-readable intent label, an interpolated sentence, and a structured fallback list of labelled fields, honouring the display metadata on instructions, accounts, arguments, and types (amounts, dates, durations, strings, enum labels, struct flattening, and the provide/inject graph). `@codama/dynamic-parsers` now exports the `ParsedInstruction` type.
84 changes: 84 additions & 0 deletions packages/dynamic-instructions/src/display/build-display-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { getNodeCodec } from '@codama/dynamic-codecs';
import type { ParsedInstruction } from '@codama/dynamic-parsers';
import {
type AccountLinkNode,
camelCase,
getLastNodeFromPath,
getRecordLinkablesVisitor,
LinkableDictionary,
type NodePath,
type ProvidedNode,
type RootNode,
visit,
} from 'codama';

import { resolveConsumedMemberNames } from './resolve-consumed-members';
import type { DisplayContext, GetInstructionDisplayOptions, ResolveAccountDataFn } from './types';

/**
* Assembles the {@link DisplayContext} for a parsed instruction.
*
* Threads the {@link ParsedInstruction} (the static node, decoded argument data, and concrete
* account metas) and the root into the single context used by the display layer:
* - `provides` indexes the instruction's `providedNode`s by the name they are exposed under;
* - `resolveDefinedType` resolves `definedTypeLinkNode` paths against a `LinkableDictionary`
* built once from the root;
* - `resolveAccountData` decodes a named account's raw bytes against its `accountLink` layout,
* resolved through the same dictionary;
* - `consumedMemberNames` records which members were surfaced through the provide/inject graph.
*
* Resolving consumed members reads account state, so this function is asynchronous.
*/
export async function buildDisplayContext(
root: RootNode,
parsedInstruction: ParsedInstruction,
options: GetInstructionDisplayOptions = {},
): Promise<DisplayContext> {
const instruction = getLastNodeFromPath(parsedInstruction.path);

const provides = new Map<string, ProvidedNode>(
(instruction.provides ?? []).map(provided => [provided.name, provided]),
);

const linkables = new LinkableDictionary();
visit(root, getRecordLinkablesVisitor(linkables));

const baseContext: Omit<DisplayContext, 'consumedMemberNames'> = {
fetchAccount: options.fetchAccount,
parsedInstruction,
provides,
resolveAccountData: createAccountDataResolver(parsedInstruction, linkables),
resolveDefinedType: linkPath => linkables.getPath(linkPath),
};

return { ...baseContext, consumedMemberNames: await resolveConsumedMemberNames(baseContext) };
}

/**
* Builds a {@link ResolveAccountDataFn} that decodes a named instruction account's raw bytes.
*
* Follows the instruction account's `accountLink` to the linked `AccountNode` — resolved through
* the shared `LinkableDictionary`, so cross-program links resolve too — then decodes the bytes with
* that account's codec. Returns `null` when the account is unknown, carries no `accountLink`, or the
* link cannot be resolved.
*
* The link is resolved from a path rooted at the instruction (`[...parsedInstruction.path, link]`)
* so the dictionary can supply the program context for links that omit an explicit program.
*/
function createAccountDataResolver(
parsedInstruction: ParsedInstruction,
linkables: LinkableDictionary,
): ResolveAccountDataFn {
const instruction = getLastNodeFromPath(parsedInstruction.path);
return (accountName, bytes) => {
const target = camelCase(accountName);
const instructionAccount = instruction.accounts.find(account => account.name === target);
if (!instructionAccount?.accountLink) return null;

const linkPath = [...parsedInstruction.path, instructionAccount.accountLink] as NodePath<AccountLinkNode>;
const accountPath = linkables.getPath(linkPath);
if (!accountPath) return null;

return getNodeCodec(accountPath).decode(bytes) as Record<string, unknown>;
};
}
46 changes: 15 additions & 31 deletions packages/dynamic-instructions/src/display/format-argument-value.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import {
type EnumTypeNode,
isNode,
isScalarEnum,
pascalCase,
resolveNestedTypeNode,
titleCase,
type TypeNode,
} from 'codama';
import { type EnumTypeNode, isNode, isScalarEnum, type NodePath, pascalCase, titleCase, type TypeNode } from 'codama';

import { isObjectRecord } from '../shared/util';
import { formatAmountValue, formatDateTimeValue, formatDurationValue, formatStringValue } from './format-value';
import { resolveDisplayType } from './resolve-display-type';
import type { DisplayContext } from './types';

/**
Expand All @@ -19,48 +12,39 @@ import type { DisplayContext } from './types';
* present; `definedTypeLinkNode`s are followed via the context's link resolver so linked
* enums resolve to their variants. Any value without applicable display metadata — and any
* value whose formatter cannot resolve its inputs — falls back to a raw string form.
*
* `ownerPath` is the path to the node owning `type` (e.g. an instruction argument), used to
* resolve any link the type follows against the correct program.
*/
export async function formatArgumentValue(
type: TypeNode,
ownerPath: NodePath,
value: unknown,
displayContext: DisplayContext,
displayContext: Omit<DisplayContext, 'consumedMemberNames'>,
): Promise<string> {
const resolvedType = resolveDisplayType(type, displayContext);
const resolved = resolveDisplayType(type, ownerPath, displayContext);

if (isNode(resolvedType, 'numberTypeNode') && resolvedType.display && isNumeric(value)) {
const formatted = await formatNumber(resolvedType.display, value, displayContext);
if (isNode(resolved.type, 'numberTypeNode') && resolved.type.display && isNumeric(value)) {
const formatted = await formatNumber(resolved.type.display, value, displayContext);
if (formatted !== null) return formatted;
}

if (isNode(resolvedType, 'stringTypeNode') && resolvedType.display && typeof value === 'string') {
return formatStringValue(value, resolvedType.display);
if (isNode(resolved.type, 'stringTypeNode') && resolved.type.display && typeof value === 'string') {
return formatStringValue(value, resolved.type.display);
}

if (isNode(resolvedType, 'enumTypeNode')) {
return formatEnumValue(resolvedType, value);
if (isNode(resolved.type, 'enumTypeNode')) {
return formatEnumValue(resolved.type, value);
}

return rawValue(value);
}

/**
* Resolves nested type wrappers and follows a single `definedTypeLinkNode` to its underlying type.
* Shared by the value formatter and the fallback list so links resolve identically in both.
*/
export function resolveDisplayType(type: TypeNode, displayContext: DisplayContext): TypeNode {
const resolved = resolveNestedTypeNode(type);
if (isNode(resolved, 'definedTypeLinkNode')) {
const definedType = displayContext.resolveDefinedType(resolved);
if (definedType) return resolveNestedTypeNode(definedType.type);
}
return resolved;
}

/** Dispatches a number to the matching number-display formatter. */
async function formatNumber(
display: NonNullable<Extract<TypeNode, { kind: 'numberTypeNode' }>['display']>,
value: bigint | number,
displayContext: DisplayContext,
displayContext: Omit<DisplayContext, 'consumedMemberNames'>,
): Promise<string | null> {
switch (display.kind) {
case 'amountNumberDisplayNode':
Expand Down
2 changes: 1 addition & 1 deletion packages/dynamic-instructions/src/display/format-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { DisplayContext } from './types';
export async function formatAmountValue(
value: bigint | number,
node: AmountNumberDisplayNode,
context: DisplayContext,
context: Omit<DisplayContext, 'consumedMemberNames'>,
): Promise<string | null> {
const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0;
if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ParsedInstruction, parseInstruction } from '@codama/dynamic-parsers';
import { getLastNodeFromPath, type RootNode, titleCase } from 'codama';

import { buildDisplayContext } from './build-display-context';
import { interpolateIntent } from './interpolate-intent';
import { listFallback } from './list-fallback';
import type { GetInstructionDisplayOptions, InstructionDisplay } from './types';

/** A concrete on-chain instruction, as accepted by `@codama/dynamic-parsers`' `parseInstruction`. */
type Instruction = Parameters<typeof parseInstruction>[1];

/**
* Resolves a concrete instruction into its human-readable display.
*
* Parses the raw instruction against the root, then derives its display. Returns `null` when
* the instruction cannot be identified or decoded — parsing failure is an expected outcome
* (e.g. when probing an instruction from an unknown program), not an error.
*/
export async function getInstructionDisplay(
root: RootNode,
instruction: Instruction,
options: GetInstructionDisplayOptions = {},
): Promise<InstructionDisplay | null> {
const parsedInstruction = parseInstruction(root, instruction);
if (!parsedInstruction) return null;
return await getInstructionDisplayFromParsedInstruction(root, parsedInstruction, options);
}

/**
* Resolves an already-parsed instruction into its human-readable display.
*
* Computes the intent label and renders both display modes — the interpolated sentence and the
* structured fallback list — leaving the choice between them to the renderer.
*/
export async function getInstructionDisplayFromParsedInstruction(
root: RootNode,
parsedInstruction: ParsedInstruction,
options: GetInstructionDisplayOptions = {},
): Promise<InstructionDisplay> {
const displayContext = await buildDisplayContext(root, parsedInstruction, options);
const instruction = getLastNodeFromPath(displayContext.parsedInstruction.path);

const intent = instruction.display?.intent ?? titleCase(instruction.name);
const [interpolatedIntent, fields] = await Promise.all([
interpolateIntent(displayContext),
listFallback(displayContext),
]);

return { fields, intent, interpolatedIntent };
}
4 changes: 4 additions & 0 deletions packages/dynamic-instructions/src/display/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export * from './build-display-context';
export * from './format-argument-value';
export * from './format-value';
export * from './get-instruction-display';
export * from './interpolate-intent';
export * from './list-fallback';
export * from './resolve-consumed-members';
export * from './resolve-display-type';
export * from './resolve-injected-value';
export * from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ async function resolvePlaceholder(root: string, name: string, displayContext: Di
const argument = instruction.arguments.find(arg => arg.name === name);
const decodedData = data as Record<string, unknown>;
if (!argument || !(name in decodedData)) return null;
return await formatArgumentValue(argument.type, decodedData[name], displayContext);
const ownerPath = [...path, argument];
return await formatArgumentValue(argument.type, ownerPath, decodedData[name], displayContext);
}

// root === 'accounts'
Expand Down
34 changes: 24 additions & 10 deletions packages/dynamic-instructions/src/display/list-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
getLastNodeFromPath,
type InstructionArgumentNode,
isNode,
type NodePath,
type StructTypeNode,
titleCase,
} from 'codama';

import { isObjectRecord } from '../shared/util';
import { formatArgumentValue, resolveDisplayType } from './format-argument-value';
import { formatArgumentValue } from './format-argument-value';
import { resolveDisplayType } from './resolve-display-type';
import type { DisplayContext, DisplayField } from './types';

/**
Expand Down Expand Up @@ -36,20 +38,27 @@ async function argumentFields(
if (isSkipped(argument.display?.skip, argument.name, displayContext)) return [];

const value = (displayContext.parsedInstruction.data as Record<string, unknown>)[argument.name];
const resolvedType = resolveDisplayType(argument.type, displayContext);
const structType = isNode(resolvedType, 'structTypeNode') ? resolvedType : undefined;
const ownerPath: NodePath = [...displayContext.parsedInstruction.path, argument];
const resolved = resolveDisplayType(argument.type, ownerPath, displayContext);

if (argument.display?.flatten && structType && isObjectRecord(value)) {
return await flattenedFields(structType, value, argument.display.flattenPrefix, displayContext);
if (argument.display?.flatten && isNode(resolved.type, 'structTypeNode') && isObjectRecord(value)) {
return await flattenedFields(
resolved.type,
resolved.ownerPath,
value,
argument.display.flattenPrefix,
displayContext,
);
}

const label = argument.display?.label ?? titleCase(argument.name);
return [{ label, value: await formatArgumentValue(argument.type, value, displayContext) }];
return [{ label, value: await formatArgumentValue(argument.type, ownerPath, value, displayContext) }];
}

/** Lifts a struct's fields into the parent list, prefixing each label and reading nested values. */
async function flattenedFields(
struct: StructTypeNode,
structPath: NodePath,
value: Record<string, unknown>,
prefix: string | undefined,
displayContext: DisplayContext,
Expand All @@ -58,7 +67,12 @@ async function flattenedFields(
return await Promise.all(
visibleFields.map(async field => {
const label = `${prefix ?? ''}${field.display?.label ?? titleCase(field.name)}`;
const formatted = await formatArgumentValue(field.type, value[field.name], displayContext);
const formatted = await formatArgumentValue(
field.type,
[...structPath, field],
value[field.name],
displayContext,
);
return { label, value: formatted };
}),
);
Expand All @@ -78,11 +92,11 @@ function accountFields(displayContext: DisplayContext): DisplayField[] {

/**
* Determines whether a member is hidden from the fallback list given its `skip` strategy.
* `'always'` always hides; `'whenInjected'` hides when a provider exposes the member's name
* (its value is surfaced elsewhere through the provide/inject graph); `'never'`/absent shows.
* `'always'` always hides; `'whenInjected'` hides when the member's value was surfaced elsewhere
* through the provide/inject graph (see `consumedMemberNames`); `'never'`/absent shows.
*/
function isSkipped(skip: DisplaySkip | undefined, name: string, displayContext: DisplayContext): boolean {
if (skip === 'always') return true;
if (skip === 'whenInjected') return displayContext.provides.has(name);
if (skip === 'whenInjected') return displayContext.consumedMemberNames.has(name);
return false;
}
Loading
Loading