From 3d77f564a6c01c08590262be88f539aa11a0721a Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Thu, 25 Jun 2026 15:19:44 +0100 Subject: [PATCH] Add the instruction display orchestrator and public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/every-bikes-rhyme.md | 6 + .../src/display/build-display-context.ts | 84 ++++++ .../src/display/format-argument-value.ts | 46 ++-- .../src/display/format-value.ts | 2 +- .../src/display/get-instruction-display.ts | 50 ++++ .../dynamic-instructions/src/display/index.ts | 4 + .../src/display/interpolate-intent.ts | 3 +- .../src/display/list-fallback.ts | 34 ++- .../src/display/resolve-consumed-members.ts | 104 ++++++++ .../src/display/resolve-display-type.ts | 44 ++++ .../src/display/resolve-injected-value.ts | 7 +- .../dynamic-instructions/src/display/types.ts | 56 +++- packages/dynamic-instructions/src/index.ts | 1 + .../display/build-display-context.test.ts | 131 ++++++++++ .../display/format-argument-value.test.ts | 28 +- .../display/get-instruction-display.test.ts | 243 ++++++++++++++++++ .../test/display/list-fallback.test.ts | 25 +- .../display/resolve-consumed-members.test.ts | 204 +++++++++++++++ .../dynamic-instructions/test/test-utils.ts | 91 ++++++- 19 files changed, 1080 insertions(+), 83 deletions(-) create mode 100644 .changeset/every-bikes-rhyme.md create mode 100644 packages/dynamic-instructions/src/display/build-display-context.ts create mode 100644 packages/dynamic-instructions/src/display/get-instruction-display.ts create mode 100644 packages/dynamic-instructions/src/display/resolve-consumed-members.ts create mode 100644 packages/dynamic-instructions/src/display/resolve-display-type.ts create mode 100644 packages/dynamic-instructions/test/display/build-display-context.test.ts create mode 100644 packages/dynamic-instructions/test/display/get-instruction-display.test.ts create mode 100644 packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts diff --git a/.changeset/every-bikes-rhyme.md b/.changeset/every-bikes-rhyme.md new file mode 100644 index 000000000..aab0930f0 --- /dev/null +++ b/.changeset/every-bikes-rhyme.md @@ -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. diff --git a/packages/dynamic-instructions/src/display/build-display-context.ts b/packages/dynamic-instructions/src/display/build-display-context.ts new file mode 100644 index 000000000..e1aed45d5 --- /dev/null +++ b/packages/dynamic-instructions/src/display/build-display-context.ts @@ -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 { + const instruction = getLastNodeFromPath(parsedInstruction.path); + + const provides = new Map( + (instruction.provides ?? []).map(provided => [provided.name, provided]), + ); + + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + + const baseContext: Omit = { + 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; + const accountPath = linkables.getPath(linkPath); + if (!accountPath) return null; + + return getNodeCodec(accountPath).decode(bytes) as Record; + }; +} diff --git a/packages/dynamic-instructions/src/display/format-argument-value.ts b/packages/dynamic-instructions/src/display/format-argument-value.ts index 4184fae55..21f2edea1 100644 --- a/packages/dynamic-instructions/src/display/format-argument-value.ts +++ b/packages/dynamic-instructions/src/display/format-argument-value.ts @@ -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'; /** @@ -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, ): Promise { - 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['display']>, value: bigint | number, - displayContext: DisplayContext, + displayContext: Omit, ): Promise { switch (display.kind) { case 'amountNumberDisplayNode': diff --git a/packages/dynamic-instructions/src/display/format-value.ts b/packages/dynamic-instructions/src/display/format-value.ts index 38c96af5d..0db39e62b 100644 --- a/packages/dynamic-instructions/src/display/format-value.ts +++ b/packages/dynamic-instructions/src/display/format-value.ts @@ -19,7 +19,7 @@ import type { DisplayContext } from './types'; export async function formatAmountValue( value: bigint | number, node: AmountNumberDisplayNode, - context: DisplayContext, + context: Omit, ): Promise { const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0; if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) { diff --git a/packages/dynamic-instructions/src/display/get-instruction-display.ts b/packages/dynamic-instructions/src/display/get-instruction-display.ts new file mode 100644 index 000000000..c6d64621e --- /dev/null +++ b/packages/dynamic-instructions/src/display/get-instruction-display.ts @@ -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[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 { + 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 { + 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 }; +} diff --git a/packages/dynamic-instructions/src/display/index.ts b/packages/dynamic-instructions/src/display/index.ts index 7f0ab40a1..7a8d0cc17 100644 --- a/packages/dynamic-instructions/src/display/index.ts +++ b/packages/dynamic-instructions/src/display/index.ts @@ -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'; diff --git a/packages/dynamic-instructions/src/display/interpolate-intent.ts b/packages/dynamic-instructions/src/display/interpolate-intent.ts index 28ff04c96..f8bacb082 100644 --- a/packages/dynamic-instructions/src/display/interpolate-intent.ts +++ b/packages/dynamic-instructions/src/display/interpolate-intent.ts @@ -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; 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' diff --git a/packages/dynamic-instructions/src/display/list-fallback.ts b/packages/dynamic-instructions/src/display/list-fallback.ts index 56df946a3..46208dfd9 100644 --- a/packages/dynamic-instructions/src/display/list-fallback.ts +++ b/packages/dynamic-instructions/src/display/list-fallback.ts @@ -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'; /** @@ -36,20 +38,27 @@ async function argumentFields( if (isSkipped(argument.display?.skip, argument.name, displayContext)) return []; const value = (displayContext.parsedInstruction.data as Record)[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, prefix: string | undefined, displayContext: DisplayContext, @@ -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 }; }), ); @@ -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; } diff --git a/packages/dynamic-instructions/src/display/resolve-consumed-members.ts b/packages/dynamic-instructions/src/display/resolve-consumed-members.ts new file mode 100644 index 000000000..e3e843099 --- /dev/null +++ b/packages/dynamic-instructions/src/display/resolve-consumed-members.ts @@ -0,0 +1,104 @@ +import { getLastNodeFromPath, isNode, type Node, type NodePath, type TypeNode } from 'codama'; + +import { resolveDisplayType } from './resolve-display-type'; +import { resolveInjectedValue } from './resolve-injected-value'; +import type { DisplayContext } from './types'; + +type BaseDisplayContext = Omit; + +/** + * Computes the set of member names (accounts or arguments) whose value was surfaced through the + * provide/inject graph. + * + * A member is "consumed" when a `providedNode` that references it is injected into a display + * value that actually resolves — e.g. a mint whose `decimals` were injected into an amount. + * Such members back the `whenInjected` skip rule: they are hidden from the fallback list because + * their value is already represented elsewhere. + * + * When the referenced provide cannot resolve (e.g. no `fetchAccount`), the member is not + * consumed and remains visible — which is what distinguishes the metadata-rich and offline + * fallback presentations. + * + * Accepts the context without `consumedMemberNames` so it can run before the full context exists. + */ +export async function resolveConsumedMemberNames(displayContext: BaseDisplayContext): Promise> { + const injectedKeys = collectInjectedKeys(displayContext); + + const consumedNodes = await Promise.all( + [...injectedKeys].map(async key => { + const provided = displayContext.provides.get(key); + if (!provided) return null; + const value = await resolveInjectedValue(provided.node, displayContext); + return value === null ? null : provided.node; + }), + ); + + const consumed = new Set(); + consumedNodes.forEach(node => { + if (node) collectReferencedMembers(node, consumed); + }); + return consumed; +} + +/** + * Collects the keys requested by `injectedValueNode`s in the instruction's argument displays. + * + * Mirrors what the fallback list actually renders (see `list-fallback.ts`): a struct argument's + * fields are only surfaced when the argument opts into `flatten`, so we only descend into a struct + * whose owner is flattened. Fields are rendered one level deep — a nested struct field is rendered + * raw, never re-flattened — so we do not recurse past that level. Amount displays are the only ones + * carrying injectable inputs (`decimals` and `unit`). + */ +function collectInjectedKeys(displayContext: BaseDisplayContext): Set { + const keys = new Set(); + const instructionPath = displayContext.parsedInstruction.path; + const instruction = getLastNodeFromPath(instructionPath); + instruction.arguments.forEach(argument => { + collectMemberInjectedKeys( + argument.type, + argument.display?.flatten ?? false, + [...instructionPath, argument], + keys, + displayContext, + ); + }); + return keys; +} + +/** + * Collects injectable keys from a displayed member's type, following links. `ownerPath` locates the + * type so nested links resolve against the correct program. + * + * When the member is rendered as an amount, its injectable inputs are collected. When it is a struct + * that its owner flattened, its direct fields are surfaced individually, so amount inputs on those + * fields are collected too — matching how the fallback list renders a flattened struct. + */ +function collectMemberInjectedKeys( + type: TypeNode, + flatten: boolean, + ownerPath: NodePath, + keys: Set, + displayContext: BaseDisplayContext, +): void { + const resolved = resolveDisplayType(type, ownerPath, displayContext); + if (isNode(resolved.type, 'numberTypeNode') && resolved.type.display?.kind === 'amountNumberDisplayNode') { + addInjectedKey(resolved.type.display.decimals, keys); + addInjectedKey(resolved.type.display.unit, keys); + } else if (flatten && isNode(resolved.type, 'structTypeNode')) { + resolved.type.fields.forEach(field => { + collectMemberInjectedKeys(field.type, false, [...resolved.ownerPath, field], keys, displayContext); + }); + } +} + +/** Adds an injectable input's key to the set when it is an `injectedValueNode`. */ +function addInjectedKey(input: Node | undefined, keys: Set): void { + if (input && isNode(input, 'injectedValueNode')) keys.add(input.key); +} + +/** Collects the member names a provided value node references (an account, its field, or an argument). */ +function collectReferencedMembers(node: Node, members: Set): void { + if (isNode(node, 'accountValueNode')) members.add(node.name); + else if (isNode(node, 'accountFieldValueNode')) members.add(node.account); + else if (isNode(node, 'argumentValueNode')) members.add(node.name); +} diff --git a/packages/dynamic-instructions/src/display/resolve-display-type.ts b/packages/dynamic-instructions/src/display/resolve-display-type.ts new file mode 100644 index 000000000..d3336befd --- /dev/null +++ b/packages/dynamic-instructions/src/display/resolve-display-type.ts @@ -0,0 +1,44 @@ +import { + type DefinedTypeNode, + getLastNodeFromPath, + isNode, + type NodePath, + resolveNestedTypeNode, + type TypeNode, +} from 'codama'; + +import type { DisplayContext } from './types'; + +/** A type resolved through its display links, paired with the path to where it now lives. */ +export type ResolvedDisplayType = { + /** The path locating the resolved type's owner, used to resolve any links nested within it. */ + readonly ownerPath: NodePath; + /** The resolved type, with wrappers stripped and any top-level link followed. */ + readonly type: TypeNode; +}; + +/** + * Resolves nested type wrappers and follows a single `definedTypeLinkNode` to its underlying type. + * Shared by the value formatter, the fallback list, and consumed-member collection so links resolve + * identically everywhere. + * + * The link is resolved by its full path (`ownerPath` plus the link), so the linkable dictionary + * targets the program the link appears in. When a link is followed, the returned `ownerPath` is + * rebased onto the resolved defined type so links nested within it — possibly in another program — + * resolve from the correct location. + */ +export function resolveDisplayType( + type: TypeNode, + ownerPath: NodePath, + displayContext: Omit, +): ResolvedDisplayType { + const resolved = resolveNestedTypeNode(type); + if (isNode(resolved, 'definedTypeLinkNode')) { + const definedTypePath = displayContext.resolveDefinedType([...ownerPath, resolved]); + if (definedTypePath) { + const definedType = getLastNodeFromPath(definedTypePath); + return { ownerPath: definedTypePath, type: resolveNestedTypeNode(definedType.type) }; + } + } + return { ownerPath, type: resolved }; +} diff --git a/packages/dynamic-instructions/src/display/resolve-injected-value.ts b/packages/dynamic-instructions/src/display/resolve-injected-value.ts index 265e84235..73d8350ac 100644 --- a/packages/dynamic-instructions/src/display/resolve-injected-value.ts +++ b/packages/dynamic-instructions/src/display/resolve-injected-value.ts @@ -27,7 +27,10 @@ export type ResolvedDisplayValue = Address | bigint | number | string | null; * * Returns `null` when the value cannot be resolved so callers can fall back safely. */ -export async function resolveInjectedValue(node: Node, context: DisplayContext): Promise { +export async function resolveInjectedValue( + node: Node, + context: Omit, +): Promise { if (isNode(node, 'numberValueNode')) { return node.number; } @@ -69,7 +72,7 @@ export async function resolveInjectedValue(node: Node, context: DisplayContext): } /** Finds the concrete address of a named account of the surrounding instruction, or `null`. */ -function findAccountAddress(context: DisplayContext, name: string): Address | null { +function findAccountAddress(context: Omit, name: string): Address | null { return context.parsedInstruction.accounts.find(account => account.name === name)?.address ?? null; } diff --git a/packages/dynamic-instructions/src/display/types.ts b/packages/dynamic-instructions/src/display/types.ts index 5881e757f..4dc14ba9c 100644 --- a/packages/dynamic-instructions/src/display/types.ts +++ b/packages/dynamic-instructions/src/display/types.ts @@ -2,7 +2,7 @@ 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 { DefinedTypeLinkNode, DefinedTypeNode, ProvidedNode } from 'codama'; +import type { DefinedTypeLinkNode, DefinedTypeNode, NodePath, ProvidedNode } from 'codama'; /** * Fetches the on-chain account at a given address, returning Kit's `MaybeEncodedAccount` — an @@ -30,14 +30,18 @@ export type FetchAccountFn = (address: Address) => Promise; export type ResolveAccountDataFn = (accountName: string, bytes: ReadonlyUint8Array) => Record | null; /** - * Resolves a `definedTypeLinkNode` to its underlying `DefinedTypeNode`, or `undefined` when - * the link cannot be resolved. + * Resolves a `definedTypeLinkNode` (addressed by its full path) to the path of its underlying + * `DefinedTypeNode`, or `undefined` when the link cannot be resolved. * - * Lets the display layer follow links (e.g. an argument typed as a linked enum) without - * depending on `NodePath` construction. The orchestrator backs this with a `LinkableDictionary` - * populated from the root; tests can supply a simple map-backed resolver. + * The input path locates the link within the tree so the linkable dictionary can resolve it + * against the correct program. Returning the resolved node's *path* (rather than just the node) + * lets callers continue resolving links nested inside it from the correct location — including + * links that cross into another program. + * + * The orchestrator backs this with a `LinkableDictionary` populated from the root; tests can + * supply a simple map-backed resolver. */ -export type ResolveDefinedTypeFn = (link: DefinedTypeLinkNode) => DefinedTypeNode | undefined; +export type ResolveDefinedTypeFn = (linkPath: NodePath) => NodePath | undefined; /** A single labelled field of the fallback display list (e.g. `{ label: 'Amount', value: '1.5 USDC' }`). */ export type DisplayField = { @@ -47,17 +51,47 @@ export type DisplayField = { readonly value: string; }; +/** + * The human-readable presentation of one concrete instruction. + * + * Carries both display modes so the renderer can choose: a short `intent` label, the + * `interpolatedIntent` sentence (or `null` when it cannot be fully resolved), and the + * structured `fields` fallback list. Presentation strategy is left to the renderer. + */ +export type InstructionDisplay = { + /** The structured fallback list of labelled fields for the instruction's members. */ + readonly fields: DisplayField[]; + /** A short imperative label (e.g. `"Transfer"`); derived from the instruction name when absent. */ + readonly intent: string; + /** The resolved interpolated sentence, or `null` when a placeholder could not be resolved. */ + readonly interpolatedIntent: string | null; +}; + +/** Consumer-supplied hooks that enrich the generated display. */ +export type GetInstructionDisplayOptions = { + /** Fetches the on-chain account needed to resolve display values that live in account state. */ + readonly fetchAccount?: FetchAccountFn; +}; + /** * Everything needed to present one concrete instruction. * * A single context threaded through the whole display layer: the parsed instruction (its decoded * arguments, node path, and account metas), the provide/inject graph, account fetching + decoding, - * and link resolution. Lower-level helpers read only the parts they need. + * link resolution, and the members already surfaced through provide/inject. Helpers that run before + * the full context exists — e.g. the consumed-member computation — accept + * `Omit`. * - * The orchestrator assembles this from a parsed instruction; it is kept explicit so every - * layer can be exercised in isolation. + * The orchestrator assembles this from a parsed instruction; it is kept explicit so every layer + * can be exercised in isolation. */ export type DisplayContext = { + /** + * Names of members (accounts or arguments) whose value was surfaced elsewhere through the + * provide/inject graph. Used to hide `whenInjected` members whose value the display already + * presented indirectly (e.g. a mint hidden because its decimals were injected into an amount). + */ + readonly consumedMemberNames: ReadonlySet; /** 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. */ @@ -66,6 +100,6 @@ export type DisplayContext = { readonly provides: ReadonlyMap; /** Decodes a named account's raw bytes against its `accountLink` layout. */ readonly resolveAccountData: ResolveAccountDataFn; - /** Resolves any `definedTypeLinkNode` reached while following an argument's type. */ + /** Resolves a `definedTypeLinkNode` (by its full path) reached while following a type. */ readonly resolveDefinedType: ResolveDefinedTypeFn; }; diff --git a/packages/dynamic-instructions/src/index.ts b/packages/dynamic-instructions/src/index.ts index 93fe01c86..62e1e6973 100644 --- a/packages/dynamic-instructions/src/index.ts +++ b/packages/dynamic-instructions/src/index.ts @@ -1,5 +1,6 @@ export { createAccountMeta } from './accounts'; export { encodeInstructionArguments } from './arguments'; +export * from './display'; export { createInstructionsBuilder } from './instructions-builder'; export type { InstructionsBuilderFn, EitherSigners } from './shared/types'; diff --git a/packages/dynamic-instructions/test/display/build-display-context.test.ts b/packages/dynamic-instructions/test/display/build-display-context.test.ts new file mode 100644 index 000000000..315dac33c --- /dev/null +++ b/packages/dynamic-instructions/test/display/build-display-context.test.ts @@ -0,0 +1,131 @@ +import type { Address } from '@solana/addresses'; +import { + definedTypeLinkNode, + definedTypeNode, + enumEmptyVariantTypeNode, + enumTypeNode, + getLastNodeFromPath, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + type NodePath, + numberTypeNode, + numberValueNode, + providedNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { buildDisplayContext } from '../../src/display/build-display-context'; +import { makeParsedInstruction, makeRoot } from '../test-utils'; + +const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +describe('buildDisplayContext', () => { + test('it threads the parsed instruction onto the context', async () => { + // Given a parsed instruction with one account and one decoded argument. + const instruction = instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: false, name: 'mint' })], + arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], + name: 'transfer', + }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction, { amount: 42n }, new Map([['mint', MINT]])); + + // When we build the display context. + const context = await buildDisplayContext(root, parsed); + + // Then the parsed instruction (data, accounts, path) is threaded through as-is. + expect(context.parsedInstruction).toBe(parsed); + }); + + test('it indexes the instruction provides by name', async () => { + // Given an instruction exposing a provided value. + const instruction = instructionNode({ + accounts: [], + arguments: [], + name: 'transfer', + provides: [providedNode('decimals', numberValueNode(6))], + }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction); + + // When we build the display context. + const context = await buildDisplayContext(root, parsed); + + // Then the provided value is keyed by its name. + expect(context.provides.get('decimals')).toEqual(providedNode('decimals', numberValueNode(6))); + }); + + test('it has an empty provides map when the instruction exposes nothing', async () => { + // Given an instruction with no provides. + const instruction = instructionNode({ accounts: [], arguments: [], name: 'transfer' }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction); + + // When we build the display context. + const context = await buildDisplayContext(root, parsed); + + // Then the provides map is empty. + expect(context.provides.size).toBe(0); + }); + + test('it resolves a defined-type link against the root', async () => { + // Given a root whose program defines an enum referenced by the instruction. + const orderType = definedTypeNode({ + name: 'orderType', + type: enumTypeNode([enumEmptyVariantTypeNode('buy'), enumEmptyVariantTypeNode('sell')]), + }); + const instruction = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'order', type: definedTypeLinkNode('orderType') })], + name: 'placeOrder', + }); + const root = makeRoot([instruction], 'testProgram', [], [orderType]); + const parsed = makeParsedInstruction(root, instruction); + + // When we resolve a link path rooted at the program. + const context = await buildDisplayContext(root, parsed); + const linkPath = [root, root.program, definedTypeLinkNode('orderType')] as NodePath< + ReturnType + >; + const resolvedPath = context.resolveDefinedType(linkPath); + + // Then the path to the underlying defined type is returned. + expect(resolvedPath && getLastNodeFromPath(resolvedPath)).toBe(orderType); + }); + + test('it returns undefined when a defined-type link cannot be resolved', async () => { + // Given a root that does not define the referenced type. + const instruction = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'order', type: definedTypeLinkNode('orderType') })], + name: 'placeOrder', + }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction); + + // When we resolve an unknown link path. + const context = await buildDisplayContext(root, parsed); + const linkPath = [root, root.program, definedTypeLinkNode('missing')] as NodePath< + ReturnType + >; + const resolvedPath = context.resolveDefinedType(linkPath); + + // Then we get undefined. + expect(resolvedPath).toBeUndefined(); + }); + + test('it threads the fetchAccount option through', async () => { + // Given a fetchAccount hook. + const instruction = instructionNode({ accounts: [], arguments: [], name: 'transfer' }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction); + const fetchAccount = (address: Address) => Promise.resolve({ address, exists: false } as const); + + // When we build the display context with that option. + const context = await buildDisplayContext(root, parsed, { fetchAccount }); + + // Then the hook is carried on the context. + expect(context.fetchAccount).toBe(fetchAccount); + }); +}); diff --git a/packages/dynamic-instructions/test/display/format-argument-value.test.ts b/packages/dynamic-instructions/test/display/format-argument-value.test.ts index 516b2e179..bea76e384 100644 --- a/packages/dynamic-instructions/test/display/format-argument-value.test.ts +++ b/packages/dynamic-instructions/test/display/format-argument-value.test.ts @@ -18,7 +18,7 @@ import { import { describe, expect, test } from 'vitest'; import { formatArgumentValue } from '../../src/display/format-argument-value'; -import { displayContext } from '../test-utils'; +import { displayContext, mockResolveDefinedType } from '../test-utils'; describe('formatArgumentValue', () => { test('it formats a number with an amount display node', async () => { @@ -28,7 +28,7 @@ describe('formatArgumentValue', () => { }); // When we format a raw amount. - const result = await formatArgumentValue(type, 1_500_000_000n, displayContext()); + const result = await formatArgumentValue(type, [], 1_500_000_000n, displayContext()); // Then we expect the scaled value. expect(result).toBe('1.5'); @@ -39,7 +39,7 @@ describe('formatArgumentValue', () => { const type = numberTypeNode('u32', 'le', { display: dateTimeNumberDisplayNode({}) }); // When we format a seconds timestamp. - const result = await formatArgumentValue(type, 1_761_365_183, displayContext()); + const result = await formatArgumentValue(type, [], 1_761_365_183, displayContext()); // Then we expect the ISO 8601 form. expect(result).toBe('2025-10-25T04:06:23.000Z'); @@ -50,7 +50,7 @@ describe('formatArgumentValue', () => { const type = numberTypeNode('u64', 'le', { display: durationNumberDisplayNode({}) }); // When we format a duration in seconds. - const result = await formatArgumentValue(type, 3600n, displayContext()); + const result = await formatArgumentValue(type, [], 3600n, displayContext()); // Then we expect the HH:mm:ss form. expect(result).toBe('01:00:00'); @@ -61,7 +61,7 @@ describe('formatArgumentValue', () => { const type = stringTypeNode('utf8', { display: stringDisplayNode({ sliceEnd: 3, sliceStart: 0 }) }); // When we format a string value. - const result = await formatArgumentValue(type, 'SOLANA', displayContext()); + const result = await formatArgumentValue(type, [], 'SOLANA', displayContext()); // Then we expect the sliced substring. expect(result).toBe('SOL'); @@ -74,7 +74,7 @@ describe('formatArgumentValue', () => { }); // When we format the amount. - const result = await formatArgumentValue(type, 1_000_000n, displayContext()); + const result = await formatArgumentValue(type, [], 1_000_000n, displayContext()); // Then we expect the raw value as a string. expect(result).toBe('1000000'); @@ -85,7 +85,7 @@ describe('formatArgumentValue', () => { const type = numberTypeNode('u64'); // When we format the value. - const result = await formatArgumentValue(type, 42n, displayContext()); + const result = await formatArgumentValue(type, [], 42n, displayContext()); // Then we expect the raw string. expect(result).toBe('42'); @@ -96,7 +96,7 @@ describe('formatArgumentValue', () => { const type = numberTypeNode('u64'); // When we format the value. - const result = await formatArgumentValue(type, undefined, displayContext()); + const result = await formatArgumentValue(type, [], undefined, displayContext()); // Then we expect an empty string, not `JSON.stringify(undefined)`. expect(result).toBe(''); @@ -110,7 +110,7 @@ describe('formatArgumentValue', () => { ]); // When we format the decoded variant name. - const result = await formatArgumentValue(type, 'buy', displayContext()); + const result = await formatArgumentValue(type, [], 'buy', displayContext()); // Then we expect the variant label. expect(result).toBe('Buy'); @@ -121,7 +121,7 @@ describe('formatArgumentValue', () => { const type = enumTypeNode([enumEmptyVariantTypeNode('buyNow'), enumEmptyVariantTypeNode('sell')]); // When we format the decoded variant name. - const result = await formatArgumentValue(type, 'buyNow', displayContext()); + const result = await formatArgumentValue(type, [], 'buyNow', displayContext()); // Then we expect the title-cased variant name. expect(result).toBe('Buy Now'); @@ -137,10 +137,14 @@ describe('formatArgumentValue', () => { ]), }); const type: TypeNode = definedTypeLinkNode('orderType'); - const resolveDefinedType = (link: { name: string }) => (link.name === 'orderType' ? orderType : undefined); // When we format the decoded variant name. - const result = await formatArgumentValue(type, 'sell', displayContext({ resolveDefinedType })); + const result = await formatArgumentValue( + type, + [], + 'sell', + displayContext({ resolveDefinedType: mockResolveDefinedType(orderType) }), + ); // Then we expect the linked variant label. expect(result).toBe('Sell'); diff --git a/packages/dynamic-instructions/test/display/get-instruction-display.test.ts b/packages/dynamic-instructions/test/display/get-instruction-display.test.ts new file mode 100644 index 000000000..3707a904a --- /dev/null +++ b/packages/dynamic-instructions/test/display/get-instruction-display.test.ts @@ -0,0 +1,243 @@ +import type { Address } from '@solana/addresses'; +import { + accountFieldValueNode, + accountLinkNode, + amountNumberDisplayNode, + definedTypeLinkNode, + definedTypeNode, + enumEmptyVariantTypeNode, + enumTypeNode, + enumVariantDisplayNode, + injectedValueNode, + instructionAccountDisplayNode, + instructionAccountNode, + instructionArgumentNode, + instructionDisplayNode, + instructionNode, + numberTypeNode, + programNode, + providedNode, + rootNode, + stringValueNode, + structFieldDisplayNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { + getInstructionDisplay, + getInstructionDisplayFromParsedInstruction, +} from '../../src/display/get-instruction-display'; +import { + encodeAccountData, + instructionPathOf, + makeParsedInstruction, + makeRoot, + mintAccountNode, + mockFetch, +} from '../test-utils'; + +const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; +const DESTINATION = '3Wnd5Df69KitZfUoPYZU438eFRNwGHkhLnSAWL65PxJX' as Address; + +describe('getInstructionDisplayFromParsedInstruction', () => { + // A full token `transferChecked`, enriched with clear-signing display metadata, resolved + // across the spec's three presentation tiers (interpolated, fallback-with-metadata, offline). + test('it resolves a token transferChecked across all presentation tiers', async () => { + // Given a transferChecked whose amount injects its mint decimals, transferring 1.5 USDC. + const instruction = instructionNode({ + accounts: [ + instructionAccountNode({ + display: instructionAccountDisplayNode({ skip: 'always' }), + isSigner: false, + isWritable: true, + name: 'source', + }), + instructionAccountNode({ + accountLink: accountLinkNode('mint'), + display: instructionAccountDisplayNode({ label: 'Token Mint', skip: 'whenInjected' }), + isSigner: false, + isWritable: false, + name: 'mint', + }), + instructionAccountNode({ + display: instructionAccountDisplayNode({ label: 'To' }), + isSigner: false, + isWritable: true, + name: 'destination', + }), + instructionAccountNode({ + display: instructionAccountDisplayNode({ skip: 'always' }), + isSigner: true, + isWritable: false, + name: 'authority', + }), + ], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ skip: 'always' }), + name: 'discriminator', + type: numberTypeNode('u8'), + }), + instructionArgumentNode({ + display: structFieldDisplayNode({ label: 'Amount' }), + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ + decimals: injectedValueNode({ key: 'decimals' }), + unit: injectedValueNode({ key: 'symbol' }), + }), + }), + }), + instructionArgumentNode({ + display: structFieldDisplayNode({ skip: 'always' }), + name: 'decimals', + type: numberTypeNode('u8'), + }), + ], + display: instructionDisplayNode({ + intent: 'Transfer', + interpolatedIntent: 'Transfer ${data.amount} to ${accounts.destination}', + }), + name: 'transferChecked', + provides: [ + providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })), + providedNode('symbol', stringValueNode('USDC')), + ], + }); + const mint = mintAccountNode(); + const root = makeRoot([instruction], 'testProgram', [mint]); + const parsed = makeParsedInstruction( + root, + instruction, + { amount: 1_500_000n, decimals: 6, discriminator: 12 }, + new Map([ + ['mint', MINT], + ['destination', DESTINATION], + ]), + ); + + // When metadata is available, the amount is scaled and the consumed mint is hidden. + const withMetadata = await getInstructionDisplayFromParsedInstruction(root, parsed, { + fetchAccount: mockFetch([[MINT, encodeAccountData(root, mint, { decimals: 6 })]]), + }); + expect(withMetadata.intent).toBe('Transfer'); + expect(withMetadata.interpolatedIntent).toBe(`Transfer 1.5 USDC to ${DESTINATION}`); + expect(withMetadata.fields).toEqual([ + { label: 'Amount', value: '1.5 USDC' }, + { label: 'To', value: DESTINATION }, + ]); + + // When offline, the amount stays raw and the mint reappears as a backup (arguments first). + const offline = await getInstructionDisplayFromParsedInstruction(root, parsed); + expect(offline.interpolatedIntent).toBe(`Transfer 1500000 to ${DESTINATION}`); + expect(offline.fields).toEqual([ + { label: 'Amount', value: '1500000' }, + { label: 'Token Mint', value: MINT }, + { label: 'To', value: DESTINATION }, + ]); + }); + + test('it derives the intent from the instruction name when no display metadata exists', async () => { + // Given an instruction with no display metadata. + const instruction = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], + name: 'transferTokens', + }); + const root = makeRoot([instruction]); + const parsed = makeParsedInstruction(root, instruction, { amount: 42n }); + + // When we resolve its display. + const display = await getInstructionDisplayFromParsedInstruction(root, parsed); + + // Then the intent is the title-cased name, there is no sentence, and the field shows raw data. + expect(display.intent).toBe('Transfer Tokens'); + expect(display.interpolatedIntent).toBeNull(); + expect(display.fields).toEqual([{ label: 'Amount', value: '42' }]); + }); +}); + +describe('getInstructionDisplay', () => { + test('it returns null when the instruction cannot be parsed', async () => { + // Given a root whose program has no instruction matching the raw bytes. + const root = makeRoot([instructionNode({ accounts: [], arguments: [], name: 'noop' })]); + + // When we resolve the display of an unrecognized instruction. + const display = await getInstructionDisplay(root, { + accounts: [], + data: new Uint8Array([255, 255, 255, 255]), + programAddress: MINT, + }); + + // Then we get null rather than an error. + expect(display).toBeNull(); + }); +}); + +describe('cross-program link resolution', () => { + // A link nested inside a type from another program must resolve in THAT program, not the + // instruction's. Both programs define an `inner` enum with different labels; the field linking + // to it (with no explicit program) must pick the one local to its enclosing defined type. + test('it resolves a link nested in a foreign defined type against the foreign program', async () => { + // Given program B defining `inner` (label "B Buy") and a `wrapper` struct using it. + const innerInB = definedTypeNode({ + name: 'inner', + type: enumTypeNode([ + enumEmptyVariantTypeNode('buy', undefined, { display: enumVariantDisplayNode({ label: 'B Buy' }) }), + ]), + }); + const wrapperInB = definedTypeNode({ + name: 'wrapper', + // The nested link carries no program, so it must resolve within program B. + type: structTypeNode([structFieldTypeNode({ name: 'mode', type: definedTypeLinkNode('inner') })]), + }); + const programB = programNode({ + definedTypes: [innerInB, wrapperInB], + instructions: [], + name: 'programB', + publicKey: '22222222222222222222222222222222', + }); + + // And program A defining its OWN `inner` (label "A Buy") — the wrong one to resolve. + const innerInA = definedTypeNode({ + name: 'inner', + type: enumTypeNode([ + enumEmptyVariantTypeNode('buy', undefined, { display: enumVariantDisplayNode({ label: 'A Buy' }) }), + ]), + }); + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + // Flatten so the display descends into wrapper's fields, reaching the nested link. + display: structFieldDisplayNode({ flatten: true }), + name: 'order', + // Links to wrapper in program B. + type: definedTypeLinkNode('wrapper', 'programB'), + }), + ], + name: 'placeOrder', + }); + const programA = programNode({ + definedTypes: [innerInA], + instructions: [instruction], + name: 'programA', + publicKey: '11111111111111111111111111111111', + }); + const root = rootNode(programA, [programB]); + const parsed = { + accounts: [], + // `inner` is an all-empty-variant (scalar) enum, decoded as the variant name. + data: { order: { mode: 'buy' } }, + path: instructionPathOf(root, instruction), + }; + + // When we resolve the display. + const display = await getInstructionDisplayFromParsedInstruction(root, parsed); + + // Then the nested link resolves to program B's `inner`, not program A's. + expect(display.fields).toEqual([{ label: 'Mode', value: 'B Buy' }]); + }); +}); diff --git a/packages/dynamic-instructions/test/display/list-fallback.test.ts b/packages/dynamic-instructions/test/display/list-fallback.test.ts index 809d2a112..c70b6a8db 100644 --- a/packages/dynamic-instructions/test/display/list-fallback.test.ts +++ b/packages/dynamic-instructions/test/display/list-fallback.test.ts @@ -7,9 +7,6 @@ import { instructionArgumentNode, instructionNode, numberTypeNode, - numberValueNode, - type ProvidedNode, - providedNode, structFieldDisplayNode, structFieldTypeNode, structTypeNode, @@ -17,7 +14,7 @@ import { import { describe, expect, test } from 'vitest'; import { listFallback } from '../../src/display/list-fallback'; -import { displayContext, parsedInstruction } from '../test-utils'; +import { displayContext, mockResolveDefinedType, parsedInstruction } from '../test-utils'; const AUTHORITY = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; @@ -113,8 +110,8 @@ describe('listFallback', () => { expect(result).toEqual([{ label: 'Amount', value: '42' }]); }); - test('it hides whenInjected members whose value is provided', async () => { - // Given an argument marked whenInjected and a provider exposing its name. + test('it hides whenInjected members whose value was consumed', async () => { + // Given an argument marked whenInjected whose name is in the consumed set. const instruction = instructionNode({ accounts: [], arguments: [ @@ -126,19 +123,21 @@ describe('listFallback', () => { ], name: 'transfer', }); - const provides = new Map([['decimals', providedNode('decimals', numberValueNode(6))]]); - // When we build the fallback list with that provider present. + // When we build the fallback list with that member marked consumed. const result = await listFallback( - displayContext({ parsedInstruction: parsedInstruction({ data: { decimals: 6 }, instruction }), provides }), + displayContext({ + consumedMemberNames: new Set(['decimals']), + parsedInstruction: parsedInstruction({ data: { decimals: 6 }, instruction }), + }), ); // Then we expect the whenInjected argument to be hidden. expect(result).toEqual([]); }); - test('it shows whenInjected members when no provider exposes them', async () => { - // Given an argument marked whenInjected and no matching provider. + test('it shows whenInjected members when their value was not consumed', async () => { + // Given an argument marked whenInjected that is not in the consumed set. const instruction = instructionNode({ accounts: [], arguments: [ @@ -180,13 +179,11 @@ describe('listFallback', () => { ], name: 'placeOrder', }); - const resolveDefinedType = (link: { name: string }) => (link.name === 'orderArgs' ? orderArgs : undefined); - // When we build the fallback list. const result = await listFallback( displayContext({ parsedInstruction: parsedInstruction({ data: { args: { price: 100n, size: 5n } }, instruction }), - resolveDefinedType, + resolveDefinedType: mockResolveDefinedType(orderArgs), }), ); diff --git a/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts b/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts new file mode 100644 index 000000000..82be3d84a --- /dev/null +++ b/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts @@ -0,0 +1,204 @@ +import type { Address } from '@solana/addresses'; +import { + accountFieldValueNode, + accountValueNode, + amountNumberDisplayNode, + injectedValueNode, + instructionArgumentNode, + instructionNode, + numberTypeNode, + numberValueNode, + providedNode, + stringValueNode, + structFieldDisplayNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { resolveConsumedMemberNames } from '../../src/display/resolve-consumed-members'; +import { accountFixture, displayContext, mintAccountNode, mockFetch, parsedInstruction } from '../test-utils'; + +const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +/** An `amount` argument that injects `decimals` and `symbol` from the surrounding providers. */ +function amountArgument() { + return instructionArgumentNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ + decimals: injectedValueNode({ key: 'decimals' }), + unit: injectedValueNode({ key: 'symbol' }), + }), + }), + }); +} + +describe('resolveConsumedMemberNames', () => { + test('it marks an account consumed when its field is injected and resolves', async () => { + // Given `decimals` injected from the mint account's field, with the mint fetchable. + const instruction = instructionNode({ + accounts: [], + arguments: [amountArgument()], + name: 'transfer', + provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))], + }); + + // When we resolve the consumed members. + const mint = accountFixture(mintAccountNode(), { decimals: 6 }); + const consumed = await resolveConsumedMemberNames( + displayContext({ + fetchAccount: mockFetch([[MINT, mint.encoded]]), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }), + provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []), + resolveAccountData: mint.resolveAccountData, + }), + ); + + // Then the mint is marked consumed. + expect(consumed).toEqual(new Set(['mint'])); + }); + + test('it does not mark an account consumed when its field cannot resolve', async () => { + // Given the same injection but no fetchAccount (offline). + const instruction = instructionNode({ + accounts: [], + arguments: [amountArgument()], + name: 'transfer', + provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))], + }); + + // When we resolve the consumed members without fetching. + const consumed = await resolveConsumedMemberNames( + displayContext({ + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }), + provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []), + }), + ); + + // Then nothing is consumed because the field could not be read. + expect(consumed).toEqual(new Set()); + }); + + test('it marks an account consumed through an accountValueNode provider', async () => { + // Given `symbol` injected by referencing the mint account directly. + const instruction = instructionNode({ + accounts: [], + arguments: [amountArgument()], + name: 'transfer', + provides: [ + providedNode('decimals', stringValueNode('6')), + providedNode('symbol', accountValueNode('mint')), + ], + }); + + // When we resolve the consumed members. + const consumed = await resolveConsumedMemberNames( + displayContext({ + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }), + provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []), + }), + ); + + // Then the mint is consumed via the account reference. + expect(consumed).toEqual(new Set(['mint'])); + }); + + test('it marks an account consumed by an amount nested in a flattened struct field', async () => { + // Given an `amount` (injecting `decimals`) nested inside a flattened struct argument. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ flatten: true }), + name: 'order', + type: structTypeNode([ + structFieldTypeNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }), + }), + }), + ]), + }), + ], + name: 'transfer', + provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))], + }); + + // When we resolve the consumed members. + const mint = accountFixture(mintAccountNode(), { decimals: 6 }); + const consumed = await resolveConsumedMemberNames( + displayContext({ + fetchAccount: mockFetch([[MINT, mint.encoded]]), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }), + provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []), + resolveAccountData: mint.resolveAccountData, + }), + ); + + // Then the mint is consumed even though the injecting amount is nested in a struct. + expect(consumed).toEqual(new Set(['mint'])); + }); + + test('it does not mark an account consumed by an amount nested in a non-flattened struct', async () => { + // Given the same nested amount, but in a struct the argument does NOT flatten — so the + // struct renders as a single raw value and the nested amount is never surfaced. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + name: 'order', + type: structTypeNode([ + structFieldTypeNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }), + }), + }), + ]), + }), + ], + name: 'transfer', + provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))], + }); + + // When we resolve the consumed members, with the mint fully fetchable. + const mint = accountFixture(mintAccountNode(), { decimals: 6 }); + const consumed = await resolveConsumedMemberNames( + displayContext({ + fetchAccount: mockFetch([[MINT, mint.encoded]]), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }), + provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []), + resolveAccountData: mint.resolveAccountData, + }), + ); + + // Then the mint stays visible: its decimals were never displayed, so it is not consumed. + expect(consumed).toEqual(new Set()); + }); + + test('it returns an empty set when no display value injects anything', async () => { + // Given an amount that uses literal decimals (no injection). + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: numberValueNode(6) }), + }), + }), + ], + name: 'transfer', + }); + + // When we resolve the consumed members. + const consumed = await resolveConsumedMemberNames( + displayContext({ parsedInstruction: parsedInstruction({ instruction }) }), + ); + + // Then nothing is consumed. + expect(consumed).toEqual(new Set()); + }); +}); diff --git a/packages/dynamic-instructions/test/test-utils.ts b/packages/dynamic-instructions/test/test-utils.ts index cd9aa4d9e..573c309be 100644 --- a/packages/dynamic-instructions/test/test-utils.ts +++ b/packages/dynamic-instructions/test/test-utils.ts @@ -6,16 +6,23 @@ import { AccountRole } from '@solana/instructions'; import { generateKeyPairSigner } from '@solana/kit'; import { type AccountNode, + accountNode, camelCase, + type DefinedTypeNode, + getLastNodeFromPath, type InstructionNode, instructionNode, type NodePath, + numberTypeNode, programNode, type ProvidedNode, + type RootNode, rootNode, + structFieldTypeNode, + structTypeNode, } from 'codama'; -import type { DisplayContext, ResolveAccountDataFn } from '../src/display/types'; +import type { DisplayContext, FetchAccountFn, ResolveAccountDataFn, ResolveDefinedTypeFn } from '../src/display/types'; export async function generateAddress(): Promise
{ const signer = await generateKeyPairSigner(); @@ -58,6 +65,28 @@ export function accountFixture( }; } +/** + * Encodes a value against an {@link AccountNode} living in the given root, returning a + * {@link MaybeEncodedAccount}. Used by orchestrator tests where the account must sit in the *same* + * root as the instruction so the display layer can follow the instruction account's `accountLink`. + */ +export function encodeAccountData( + root: RootNode, + account: AccountNode, + value: Record, +): MaybeEncodedAccount { + const codec = getNodeCodec([root, root.program, account] as NodePath); + return encodedAccount(codec.encode(value)); +} + +/** An {@link AccountNode} for a token mint carrying a single `decimals: u8` field. */ +export function mintAccountNode(): AccountNode { + return accountNode({ + data: structTypeNode([structFieldTypeNode({ name: 'decimals', type: numberTypeNode('u8') })]), + name: 'mint', + }); +} + /** * Builds a {@link ParsedInstruction} fixture from an instruction, decoded argument data, and named * account addresses. The instruction is wrapped in a fresh root so `path` is the real @@ -86,6 +115,7 @@ export function parsedInstruction( /** Builds a {@link DisplayContext} with empty defaults, overridable per test. */ export function displayContext(overrides: Partial = {}): DisplayContext { return { + consumedMemberNames: new Set(), parsedInstruction: parsedInstruction(), provides: new Map(), resolveAccountData: () => null, @@ -94,17 +124,76 @@ export function displayContext(overrides: Partial = {}): Display }; } +/** + * Builds a {@link DisplayContext} presenting the given instruction (wrapped in a fresh root). + * Overrides are applied last. + */ +export function displayContextFor( + instruction: InstructionNode, + overrides: Partial = {}, +): DisplayContext { + return displayContext({ parsedInstruction: parsedInstruction({ instruction }), ...overrides }); +} + +/** The `[root, program, instruction]` path for an instruction within a root. */ +export function instructionPathOf(root: RootNode, instruction: InstructionNode): NodePath { + return [root, root.program, instruction] as NodePath; +} + +/** Builds a {@link ResolveDefinedTypeFn} that resolves the given defined types by name. */ +export function mockResolveDefinedType(...definedTypes: DefinedTypeNode[]): ResolveDefinedTypeFn { + const byName = new Map(definedTypes.map(definedType => [definedType.name, definedType])); + return linkPath => { + const definedType = byName.get(getLastNodeFromPath(linkPath).name); + return definedType ? ([definedType] as NodePath) : undefined; + }; +} + +/** + * Builds a {@link FetchAccountFn} from a literal address-to-account mapping, keeping the fetched + * account visible at the call site — e.g. `mockFetch([[MINT, mintAccount.encoded]])`. The encoded + * accounts are produced by {@link accountFixture} so the display layer decodes them through the + * real `accountLink` path; unknown addresses resolve to a non-existent account. + */ +export function mockFetch(entries: ReadonlyArray): FetchAccountFn { + const accounts = new Map(entries.map(([address, account]) => [address, { ...account, address }])); + return address => Promise.resolve(accounts.get(address) ?? { address, exists: false }); +} + export function makeRoot( instructions: ReturnType[], name = 'testProgram', accounts: AccountNode[] = [], + definedTypes: DefinedTypeNode[] = [], ) { return rootNode( programNode({ accounts, + definedTypes, instructions, name, publicKey: '11111111111111111111111111111111', }), ); } + +/** + * Builds a {@link ParsedInstruction} for an instruction within a root, with the decoded data and + * concrete account addresses supplied by the test. The node path mirrors what `parseInstruction` + * returns: `[root, program, instruction]`. + */ +export function makeParsedInstruction( + root: RootNode, + instruction: InstructionNode, + data: Record = {}, + accountAddresses: ReadonlyMap = new Map(), +): ParsedInstruction { + return { + accounts: instruction.accounts.flatMap(account => { + const address = accountAddresses.get(account.name); + return address ? [{ address, name: account.name, role: AccountRole.READONLY }] : []; + }), + data, + path: [root, root.program, instruction] as NodePath, + }; +}