Skip to content

Commit 9db43b5

Browse files
authored
Add the instruction display orchestrator and public API (#1016)
This PR completes the clear-signing display-text feature (sRFC 39) by assembling the value and render-mode layers into a public API. getInstructionDisplay parses a concrete instruction and resolves it into a human-readable display — an intent label, an interpolated sentence, and a structured fallback list — returning null when the instruction cannot be parsed; getInstructionDisplayFromParsedInstruction does the same from an already-parsed instruction. buildDisplayContext bridges a parsed instruction and its root into the single context threaded through the layer, resolving defined-type links by path against a linkable dictionary (correct even for links nested in types from other programs) and computing which members were surfaced through the provide/inject graph so whenInjected fields hide correctly when their value is presented elsewhere. @codama/dynamic-parsers now exports ParsedInstruction. The whole display layer is exported from the package, and a changeset records the minor bumps.
1 parent 482b565 commit 9db43b5

19 files changed

Lines changed: 1080 additions & 83 deletions

.changeset/every-bikes-rhyme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@codama/dynamic-instructions': minor
3+
'@codama/dynamic-parsers': minor
4+
---
5+
6+
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.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { getNodeCodec } from '@codama/dynamic-codecs';
2+
import type { ParsedInstruction } from '@codama/dynamic-parsers';
3+
import {
4+
type AccountLinkNode,
5+
camelCase,
6+
getLastNodeFromPath,
7+
getRecordLinkablesVisitor,
8+
LinkableDictionary,
9+
type NodePath,
10+
type ProvidedNode,
11+
type RootNode,
12+
visit,
13+
} from 'codama';
14+
15+
import { resolveConsumedMemberNames } from './resolve-consumed-members';
16+
import type { DisplayContext, GetInstructionDisplayOptions, ResolveAccountDataFn } from './types';
17+
18+
/**
19+
* Assembles the {@link DisplayContext} for a parsed instruction.
20+
*
21+
* Threads the {@link ParsedInstruction} (the static node, decoded argument data, and concrete
22+
* account metas) and the root into the single context used by the display layer:
23+
* - `provides` indexes the instruction's `providedNode`s by the name they are exposed under;
24+
* - `resolveDefinedType` resolves `definedTypeLinkNode` paths against a `LinkableDictionary`
25+
* built once from the root;
26+
* - `resolveAccountData` decodes a named account's raw bytes against its `accountLink` layout,
27+
* resolved through the same dictionary;
28+
* - `consumedMemberNames` records which members were surfaced through the provide/inject graph.
29+
*
30+
* Resolving consumed members reads account state, so this function is asynchronous.
31+
*/
32+
export async function buildDisplayContext(
33+
root: RootNode,
34+
parsedInstruction: ParsedInstruction,
35+
options: GetInstructionDisplayOptions = {},
36+
): Promise<DisplayContext> {
37+
const instruction = getLastNodeFromPath(parsedInstruction.path);
38+
39+
const provides = new Map<string, ProvidedNode>(
40+
(instruction.provides ?? []).map(provided => [provided.name, provided]),
41+
);
42+
43+
const linkables = new LinkableDictionary();
44+
visit(root, getRecordLinkablesVisitor(linkables));
45+
46+
const baseContext: Omit<DisplayContext, 'consumedMemberNames'> = {
47+
fetchAccount: options.fetchAccount,
48+
parsedInstruction,
49+
provides,
50+
resolveAccountData: createAccountDataResolver(parsedInstruction, linkables),
51+
resolveDefinedType: linkPath => linkables.getPath(linkPath),
52+
};
53+
54+
return { ...baseContext, consumedMemberNames: await resolveConsumedMemberNames(baseContext) };
55+
}
56+
57+
/**
58+
* Builds a {@link ResolveAccountDataFn} that decodes a named instruction account's raw bytes.
59+
*
60+
* Follows the instruction account's `accountLink` to the linked `AccountNode` — resolved through
61+
* the shared `LinkableDictionary`, so cross-program links resolve too — then decodes the bytes with
62+
* that account's codec. Returns `null` when the account is unknown, carries no `accountLink`, or the
63+
* link cannot be resolved.
64+
*
65+
* The link is resolved from a path rooted at the instruction (`[...parsedInstruction.path, link]`)
66+
* so the dictionary can supply the program context for links that omit an explicit program.
67+
*/
68+
function createAccountDataResolver(
69+
parsedInstruction: ParsedInstruction,
70+
linkables: LinkableDictionary,
71+
): ResolveAccountDataFn {
72+
const instruction = getLastNodeFromPath(parsedInstruction.path);
73+
return (accountName, bytes) => {
74+
const target = camelCase(accountName);
75+
const instructionAccount = instruction.accounts.find(account => account.name === target);
76+
if (!instructionAccount?.accountLink) return null;
77+
78+
const linkPath = [...parsedInstruction.path, instructionAccount.accountLink] as NodePath<AccountLinkNode>;
79+
const accountPath = linkables.getPath(linkPath);
80+
if (!accountPath) return null;
81+
82+
return getNodeCodec(accountPath).decode(bytes) as Record<string, unknown>;
83+
};
84+
}

packages/dynamic-instructions/src/display/format-argument-value.ts

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import {
2-
type EnumTypeNode,
3-
isNode,
4-
isScalarEnum,
5-
pascalCase,
6-
resolveNestedTypeNode,
7-
titleCase,
8-
type TypeNode,
9-
} from 'codama';
1+
import { type EnumTypeNode, isNode, isScalarEnum, type NodePath, pascalCase, titleCase, type TypeNode } from 'codama';
102

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

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

30-
if (isNode(resolvedType, 'numberTypeNode') && resolvedType.display && isNumeric(value)) {
31-
const formatted = await formatNumber(resolvedType.display, value, displayContext);
27+
if (isNode(resolved.type, 'numberTypeNode') && resolved.type.display && isNumeric(value)) {
28+
const formatted = await formatNumber(resolved.type.display, value, displayContext);
3229
if (formatted !== null) return formatted;
3330
}
3431

35-
if (isNode(resolvedType, 'stringTypeNode') && resolvedType.display && typeof value === 'string') {
36-
return formatStringValue(value, resolvedType.display);
32+
if (isNode(resolved.type, 'stringTypeNode') && resolved.type.display && typeof value === 'string') {
33+
return formatStringValue(value, resolved.type.display);
3734
}
3835

39-
if (isNode(resolvedType, 'enumTypeNode')) {
40-
return formatEnumValue(resolvedType, value);
36+
if (isNode(resolved.type, 'enumTypeNode')) {
37+
return formatEnumValue(resolved.type, value);
4138
}
4239

4340
return rawValue(value);
4441
}
4542

46-
/**
47-
* Resolves nested type wrappers and follows a single `definedTypeLinkNode` to its underlying type.
48-
* Shared by the value formatter and the fallback list so links resolve identically in both.
49-
*/
50-
export function resolveDisplayType(type: TypeNode, displayContext: DisplayContext): TypeNode {
51-
const resolved = resolveNestedTypeNode(type);
52-
if (isNode(resolved, 'definedTypeLinkNode')) {
53-
const definedType = displayContext.resolveDefinedType(resolved);
54-
if (definedType) return resolveNestedTypeNode(definedType.type);
55-
}
56-
return resolved;
57-
}
58-
5943
/** Dispatches a number to the matching number-display formatter. */
6044
async function formatNumber(
6145
display: NonNullable<Extract<TypeNode, { kind: 'numberTypeNode' }>['display']>,
6246
value: bigint | number,
63-
displayContext: DisplayContext,
47+
displayContext: Omit<DisplayContext, 'consumedMemberNames'>,
6448
): Promise<string | null> {
6549
switch (display.kind) {
6650
case 'amountNumberDisplayNode':

packages/dynamic-instructions/src/display/format-value.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type { DisplayContext } from './types';
1919
export async function formatAmountValue(
2020
value: bigint | number,
2121
node: AmountNumberDisplayNode,
22-
context: DisplayContext,
22+
context: Omit<DisplayContext, 'consumedMemberNames'>,
2323
): Promise<string | null> {
2424
const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0;
2525
if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { type ParsedInstruction, parseInstruction } from '@codama/dynamic-parsers';
2+
import { getLastNodeFromPath, type RootNode, titleCase } from 'codama';
3+
4+
import { buildDisplayContext } from './build-display-context';
5+
import { interpolateIntent } from './interpolate-intent';
6+
import { listFallback } from './list-fallback';
7+
import type { GetInstructionDisplayOptions, InstructionDisplay } from './types';
8+
9+
/** A concrete on-chain instruction, as accepted by `@codama/dynamic-parsers`' `parseInstruction`. */
10+
type Instruction = Parameters<typeof parseInstruction>[1];
11+
12+
/**
13+
* Resolves a concrete instruction into its human-readable display.
14+
*
15+
* Parses the raw instruction against the root, then derives its display. Returns `null` when
16+
* the instruction cannot be identified or decoded — parsing failure is an expected outcome
17+
* (e.g. when probing an instruction from an unknown program), not an error.
18+
*/
19+
export async function getInstructionDisplay(
20+
root: RootNode,
21+
instruction: Instruction,
22+
options: GetInstructionDisplayOptions = {},
23+
): Promise<InstructionDisplay | null> {
24+
const parsedInstruction = parseInstruction(root, instruction);
25+
if (!parsedInstruction) return null;
26+
return await getInstructionDisplayFromParsedInstruction(root, parsedInstruction, options);
27+
}
28+
29+
/**
30+
* Resolves an already-parsed instruction into its human-readable display.
31+
*
32+
* Computes the intent label and renders both display modes — the interpolated sentence and the
33+
* structured fallback list — leaving the choice between them to the renderer.
34+
*/
35+
export async function getInstructionDisplayFromParsedInstruction(
36+
root: RootNode,
37+
parsedInstruction: ParsedInstruction,
38+
options: GetInstructionDisplayOptions = {},
39+
): Promise<InstructionDisplay> {
40+
const displayContext = await buildDisplayContext(root, parsedInstruction, options);
41+
const instruction = getLastNodeFromPath(displayContext.parsedInstruction.path);
42+
43+
const intent = instruction.display?.intent ?? titleCase(instruction.name);
44+
const [interpolatedIntent, fields] = await Promise.all([
45+
interpolateIntent(displayContext),
46+
listFallback(displayContext),
47+
]);
48+
49+
return { fields, intent, interpolatedIntent };
50+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
export * from './build-display-context';
12
export * from './format-argument-value';
23
export * from './format-value';
4+
export * from './get-instruction-display';
35
export * from './interpolate-intent';
46
export * from './list-fallback';
7+
export * from './resolve-consumed-members';
8+
export * from './resolve-display-type';
59
export * from './resolve-injected-value';
610
export * from './types';

packages/dynamic-instructions/src/display/interpolate-intent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ async function resolvePlaceholder(root: string, name: string, displayContext: Di
4646
const argument = instruction.arguments.find(arg => arg.name === name);
4747
const decodedData = data as Record<string, unknown>;
4848
if (!argument || !(name in decodedData)) return null;
49-
return await formatArgumentValue(argument.type, decodedData[name], displayContext);
49+
const ownerPath = [...path, argument];
50+
return await formatArgumentValue(argument.type, ownerPath, decodedData[name], displayContext);
5051
}
5152

5253
// root === 'accounts'

packages/dynamic-instructions/src/display/list-fallback.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {
33
getLastNodeFromPath,
44
type InstructionArgumentNode,
55
isNode,
6+
type NodePath,
67
type StructTypeNode,
78
titleCase,
89
} from 'codama';
910

1011
import { isObjectRecord } from '../shared/util';
11-
import { formatArgumentValue, resolveDisplayType } from './format-argument-value';
12+
import { formatArgumentValue } from './format-argument-value';
13+
import { resolveDisplayType } from './resolve-display-type';
1214
import type { DisplayContext, DisplayField } from './types';
1315

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

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

42-
if (argument.display?.flatten && structType && isObjectRecord(value)) {
43-
return await flattenedFields(structType, value, argument.display.flattenPrefix, displayContext);
44+
if (argument.display?.flatten && isNode(resolved.type, 'structTypeNode') && isObjectRecord(value)) {
45+
return await flattenedFields(
46+
resolved.type,
47+
resolved.ownerPath,
48+
value,
49+
argument.display.flattenPrefix,
50+
displayContext,
51+
);
4452
}
4553

4654
const label = argument.display?.label ?? titleCase(argument.name);
47-
return [{ label, value: await formatArgumentValue(argument.type, value, displayContext) }];
55+
return [{ label, value: await formatArgumentValue(argument.type, ownerPath, value, displayContext) }];
4856
}
4957

5058
/** Lifts a struct's fields into the parent list, prefixing each label and reading nested values. */
5159
async function flattenedFields(
5260
struct: StructTypeNode,
61+
structPath: NodePath,
5362
value: Record<string, unknown>,
5463
prefix: string | undefined,
5564
displayContext: DisplayContext,
@@ -58,7 +67,12 @@ async function flattenedFields(
5867
return await Promise.all(
5968
visibleFields.map(async field => {
6069
const label = `${prefix ?? ''}${field.display?.label ?? titleCase(field.name)}`;
61-
const formatted = await formatArgumentValue(field.type, value[field.name], displayContext);
70+
const formatted = await formatArgumentValue(
71+
field.type,
72+
[...structPath, field],
73+
value[field.name],
74+
displayContext,
75+
);
6276
return { label, value: formatted };
6377
}),
6478
);
@@ -78,11 +92,11 @@ function accountFields(displayContext: DisplayContext): DisplayField[] {
7892

7993
/**
8094
* Determines whether a member is hidden from the fallback list given its `skip` strategy.
81-
* `'always'` always hides; `'whenInjected'` hides when a provider exposes the member's name
82-
* (its value is surfaced elsewhere through the provide/inject graph); `'never'`/absent shows.
95+
* `'always'` always hides; `'whenInjected'` hides when the member's value was surfaced elsewhere
96+
* through the provide/inject graph (see `consumedMemberNames`); `'never'`/absent shows.
8397
*/
8498
function isSkipped(skip: DisplaySkip | undefined, name: string, displayContext: DisplayContext): boolean {
8599
if (skip === 'always') return true;
86-
if (skip === 'whenInjected') return displayContext.provides.has(name);
100+
if (skip === 'whenInjected') return displayContext.consumedMemberNames.has(name);
87101
return false;
88102
}

0 commit comments

Comments
 (0)