diff --git a/packages/dynamic-instructions/src/display/format-argument-value.ts b/packages/dynamic-instructions/src/display/format-argument-value.ts new file mode 100644 index 000000000..4184fae55 --- /dev/null +++ b/packages/dynamic-instructions/src/display/format-argument-value.ts @@ -0,0 +1,112 @@ +import { + type EnumTypeNode, + isNode, + isScalarEnum, + pascalCase, + resolveNestedTypeNode, + titleCase, + type TypeNode, +} from 'codama'; + +import { isObjectRecord } from '../shared/util'; +import { formatAmountValue, formatDateTimeValue, formatDurationValue, formatStringValue } from './format-value'; +import type { DisplayContext } from './types'; + +/** + * Formats a single decoded value according to the presentation metadata on its type. + * + * Numbers, strings, and enum variants are rendered through their value-display nodes when + * 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. + */ +export async function formatArgumentValue( + type: TypeNode, + value: unknown, + displayContext: DisplayContext, +): Promise { + const resolvedType = resolveDisplayType(type, displayContext); + + if (isNode(resolvedType, 'numberTypeNode') && resolvedType.display && isNumeric(value)) { + const formatted = await formatNumber(resolvedType.display, value, displayContext); + if (formatted !== null) return formatted; + } + + if (isNode(resolvedType, 'stringTypeNode') && resolvedType.display && typeof value === 'string') { + return formatStringValue(value, resolvedType.display); + } + + if (isNode(resolvedType, 'enumTypeNode')) { + return formatEnumValue(resolvedType, 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, +): Promise { + switch (display.kind) { + case 'amountNumberDisplayNode': + return await formatAmountValue(value, display, displayContext); + case 'dateTimeNumberDisplayNode': + return formatDateTimeValue(value, display); + case 'durationNumberDisplayNode': + return formatDurationValue(value, display); + } +} + +/** + * Formats an enum value using the matched variant's display label. + * Scalar enums decode to the variant name; data enums decode to `{ __kind: 'PascalVariant', ... }`. + */ +function formatEnumValue(enumType: EnumTypeNode, value: unknown): string { + const decodedName = enumVariantName(value); + if (decodedName === null) return rawValue(value); + + // Scalar enums decode to the variant name as-is; data enum `__kind` is the PascalCase form. + const variant = enumType.variants.find(candidate => + isScalarEnum(enumType) ? candidate.name === decodedName : pascalCase(candidate.name) === decodedName, + ); + if (!variant) return rawValue(value); + + return variant.display?.label ?? titleCase(variant.name); +} + +/** Extracts the variant name from a decoded enum value (scalar name string or data enum `__kind`). */ +function enumVariantName(value: unknown): string | null { + if (typeof value === 'string') return value; + if (isObjectRecord(value) && typeof value.__kind === 'string') return value.__kind; + return null; +} + +/** Renders a value without any display metadata as a safe, human-readable string. */ +function rawValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'boolean') { + return value.toString(); + } + if (isObjectRecord(value) && typeof value.__kind === 'string') return titleCase(value.__kind); + return JSON.stringify(value, (_key, v: unknown) => (typeof v === 'bigint' ? v.toString() : v)); +} + +function isNumeric(value: unknown): value is bigint | number { + return typeof value === 'bigint' || typeof value === 'number'; +} diff --git a/packages/dynamic-instructions/src/display/format-value.ts b/packages/dynamic-instructions/src/display/format-value.ts index 701a24687..38c96af5d 100644 --- a/packages/dynamic-instructions/src/display/format-value.ts +++ b/packages/dynamic-instructions/src/display/format-value.ts @@ -6,7 +6,7 @@ import type { } from 'codama'; import { resolveInjectedValue } from './resolve-injected-value'; -import type { DisplayResolutionContext } from './types'; +import type { DisplayContext } from './types'; /** * Formats an integer as a scaled amount with an optional unit (e.g. `1100000000` → `"1.1 SOL"`). @@ -19,7 +19,7 @@ import type { DisplayResolutionContext } from './types'; export async function formatAmountValue( value: bigint | number, node: AmountNumberDisplayNode, - context: DisplayResolutionContext, + context: DisplayContext, ): 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/index.ts b/packages/dynamic-instructions/src/display/index.ts index 38b13400f..7f0ab40a1 100644 --- a/packages/dynamic-instructions/src/display/index.ts +++ b/packages/dynamic-instructions/src/display/index.ts @@ -1,3 +1,6 @@ +export * from './format-argument-value'; export * from './format-value'; +export * from './interpolate-intent'; +export * from './list-fallback'; 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 new file mode 100644 index 000000000..28ff04c96 --- /dev/null +++ b/packages/dynamic-instructions/src/display/interpolate-intent.ts @@ -0,0 +1,54 @@ +import { getLastNodeFromPath } from 'codama'; + +import { formatArgumentValue } from './format-argument-value'; +import type { DisplayContext } from './types'; + +/** Matches a `${root.path}` placeholder, capturing the root and the (flat) path after it. */ +const PLACEHOLDER_PATTERN = /\$\{\s*(data|accounts)\.([a-zA-Z0-9_]+)\s*\}/g; + +/** + * Renders an instruction's `interpolatedIntent` template into a concrete sentence. + * + * Placeholders use the flat form `${data.}` and `${accounts.}`. Each is + * replaced by the referent's own presentation: arguments through their value-display nodes, + * accounts by their address. + * + * Returns `null` when the instruction has no `interpolatedIntent`, or when any placeholder + * cannot be resolved (an unknown name, or a value whose formatting fails). A `null` result + * signals the caller to fall back to the structured field list. + */ +export async function interpolateIntent(displayContext: DisplayContext): Promise { + const instruction = getLastNodeFromPath(displayContext.parsedInstruction.path); + const template = instruction.display?.interpolatedIntent; + if (template === undefined) return null; + + // Resolve each distinct placeholder in parallel, then substitute them back into the template. + const placeholders = [...template.matchAll(PLACEHOLDER_PATTERN)].filter( + ([token], index, all) => all.findIndex(([other]) => other === token) === index, + ); + const resolved = await Promise.all( + placeholders.map(async ([token, root, name]) => { + return [token, await resolvePlaceholder(root, name, displayContext)] as const; + }), + ); + + if (resolved.some(([, value]) => value === null)) return null; + const replacements = new Map(resolved); + + return template.replace(PLACEHOLDER_PATTERN, match => replacements.get(match) ?? match); +} + +/** Resolves a single placeholder to its rendered string, or `null` when it cannot be resolved. */ +async function resolvePlaceholder(root: string, name: string, displayContext: DisplayContext): Promise { + const { data, path } = displayContext.parsedInstruction; + if (root === 'data') { + const instruction = getLastNodeFromPath(path); + 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); + } + + // root === 'accounts' + return displayContext.parsedInstruction.accounts.find(account => account.name === name)?.address ?? null; +} diff --git a/packages/dynamic-instructions/src/display/list-fallback.ts b/packages/dynamic-instructions/src/display/list-fallback.ts new file mode 100644 index 000000000..56df946a3 --- /dev/null +++ b/packages/dynamic-instructions/src/display/list-fallback.ts @@ -0,0 +1,88 @@ +import { + type DisplaySkip, + getLastNodeFromPath, + type InstructionArgumentNode, + isNode, + type StructTypeNode, + titleCase, +} from 'codama'; + +import { isObjectRecord } from '../shared/util'; +import { formatArgumentValue, resolveDisplayType } from './format-argument-value'; +import type { DisplayContext, DisplayField } from './types'; + +/** + * Builds the fallback display: a flat, ordered list of labelled fields for an instruction's + * arguments and accounts. + * + * Honours each member's display metadata — `skip` (hidden when `'always'`, or when + * `'whenInjected'` and the value was surfaced through the provide/inject graph), `label` + * overrides, and struct `flatten`/`flattenPrefix`. The instruction's intent/title is not + * included here; the caller composes it around this list. + */ +export async function listFallback(displayContext: DisplayContext): Promise { + const instruction = getLastNodeFromPath(displayContext.parsedInstruction.path); + const argumentFieldGroups = await Promise.all( + instruction.arguments.map(argument => argumentFields(argument, displayContext)), + ); + return [...argumentFieldGroups.flat(), ...accountFields(displayContext)]; +} + +/** Produces the display fields for a single instruction argument (one field, or many when flattened). */ +async function argumentFields( + argument: InstructionArgumentNode, + displayContext: DisplayContext, +): Promise { + 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; + + if (argument.display?.flatten && structType && isObjectRecord(value)) { + return await flattenedFields(structType, value, argument.display.flattenPrefix, displayContext); + } + + const label = argument.display?.label ?? titleCase(argument.name); + return [{ label, value: await formatArgumentValue(argument.type, value, displayContext) }]; +} + +/** Lifts a struct's fields into the parent list, prefixing each label and reading nested values. */ +async function flattenedFields( + struct: StructTypeNode, + value: Record, + prefix: string | undefined, + displayContext: DisplayContext, +): Promise { + const visibleFields = struct.fields.filter(field => !isSkipped(field.display?.skip, field.name, displayContext)); + 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); + return { label, value: formatted }; + }), + ); +} + +/** Produces the display fields for the instruction's accounts. */ +function accountFields(displayContext: DisplayContext): DisplayField[] { + const instruction = getLastNodeFromPath(displayContext.parsedInstruction.path); + return instruction.accounts.flatMap(account => { + if (isSkipped(account.display?.skip, account.name, displayContext)) return []; + const address = displayContext.parsedInstruction.accounts.find(a => a.name === account.name)?.address; + if (!address) return []; + const label = account.display?.label ?? titleCase(account.name); + return [{ label, value: address }]; + }); +} + +/** + * 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. + */ +function isSkipped(skip: DisplaySkip | undefined, name: string, displayContext: DisplayContext): boolean { + if (skip === 'always') return true; + if (skip === 'whenInjected') return displayContext.provides.has(name); + return false; +} diff --git a/packages/dynamic-instructions/src/display/resolve-injected-value.ts b/packages/dynamic-instructions/src/display/resolve-injected-value.ts index 1a1095cc8..265e84235 100644 --- a/packages/dynamic-instructions/src/display/resolve-injected-value.ts +++ b/packages/dynamic-instructions/src/display/resolve-injected-value.ts @@ -1,7 +1,7 @@ import type { Address } from '@solana/addresses'; import { isNode, type Node } from 'codama'; -import type { DisplayResolutionContext } from './types'; +import type { DisplayContext } from './types'; /** * A value resolved from the provide/inject graph for display purposes. @@ -13,7 +13,7 @@ import type { DisplayResolutionContext } from './types'; export type ResolvedDisplayValue = Address | bigint | number | string | null; /** - * Resolves a node to a concrete display value within a {@link DisplayResolutionContext}. + * Resolves a node to a concrete display value within a {@link DisplayContext}. * * Handles the value/contextual nodes the display layer relies on: * - `numberValueNode` / `stringValueNode`: the literal value. @@ -27,10 +27,7 @@ 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: DisplayResolutionContext, -): Promise { +export async function resolveInjectedValue(node: Node, context: DisplayContext): Promise { if (isNode(node, 'numberValueNode')) { return node.number; } @@ -72,7 +69,7 @@ export async function resolveInjectedValue( } /** Finds the concrete address of a named account of the surrounding instruction, or `null`. */ -function findAccountAddress(context: DisplayResolutionContext, name: string): Address | null { +function findAccountAddress(context: DisplayContext, 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 2039533b7..5881e757f 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 { ProvidedNode } from 'codama'; +import type { DefinedTypeLinkNode, DefinedTypeNode, ProvidedNode } from 'codama'; /** * Fetches the on-chain account at a given address, returning Kit's `MaybeEncodedAccount` — an @@ -30,17 +30,34 @@ export type FetchAccountFn = (address: Address) => Promise; export type ResolveAccountDataFn = (accountName: string, bytes: ReadonlyUint8Array) => Record | null; /** - * The contextual environment in which display values are resolved. + * Resolves a `definedTypeLinkNode` to its underlying `DefinedTypeNode`, or `undefined` when + * the link cannot be resolved. * - * Mirrors the provide/inject pattern: an instruction (or other host) exposes named values - * through `provides`, and reusable types pull them by key via `injectedValueNode`. Resolving - * those keys reads from the surrounding `parsedInstruction` (its decoded arguments and account - * addresses) and, for account state, the `fetchAccount` hook plus `resolveAccountData` decoder. + * 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. + */ +export type ResolveDefinedTypeFn = (link: DefinedTypeLinkNode) => DefinedTypeNode | undefined; + +/** A single labelled field of the fallback display list (e.g. `{ label: 'Amount', value: '1.5 USDC' }`). */ +export type DisplayField = { + /** The human-readable label for the field. */ + readonly label: string; + /** The formatted value for the field. */ + readonly value: string; +}; + +/** + * 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. * - * The orchestrator assembles this from a parsed instruction; it is kept explicit here so the - * resolution and formatting layers 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 DisplayResolutionContext = { +export type DisplayContext = { /** 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. */ @@ -49,4 +66,6 @@ export type DisplayResolutionContext = { 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. */ + readonly resolveDefinedType: ResolveDefinedTypeFn; }; diff --git a/packages/dynamic-instructions/test/display/format-argument-value.test.ts b/packages/dynamic-instructions/test/display/format-argument-value.test.ts new file mode 100644 index 000000000..516b2e179 --- /dev/null +++ b/packages/dynamic-instructions/test/display/format-argument-value.test.ts @@ -0,0 +1,148 @@ +import { + amountNumberDisplayNode, + dateTimeNumberDisplayNode, + definedTypeLinkNode, + type DefinedTypeNode, + definedTypeNode, + durationNumberDisplayNode, + enumEmptyVariantTypeNode, + enumTypeNode, + enumVariantDisplayNode, + injectedValueNode, + numberTypeNode, + numberValueNode, + stringDisplayNode, + stringTypeNode, + type TypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { formatArgumentValue } from '../../src/display/format-argument-value'; +import { displayContext } from '../test-utils'; + +describe('formatArgumentValue', () => { + test('it formats a number with an amount display node', async () => { + // Given a u64 typed with an amount display. + const type = numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: numberValueNode(9) }), + }); + + // When we format a raw amount. + const result = await formatArgumentValue(type, 1_500_000_000n, displayContext()); + + // Then we expect the scaled value. + expect(result).toBe('1.5'); + }); + + test('it formats a number with a date-time display node', async () => { + // Given a u32 typed as a date-time. + const type = numberTypeNode('u32', 'le', { display: dateTimeNumberDisplayNode({}) }); + + // When we format a seconds timestamp. + 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'); + }); + + test('it formats a number with a duration display node', async () => { + // Given a u64 typed as a duration. + const type = numberTypeNode('u64', 'le', { display: durationNumberDisplayNode({}) }); + + // When we format a duration in seconds. + const result = await formatArgumentValue(type, 3600n, displayContext()); + + // Then we expect the HH:mm:ss form. + expect(result).toBe('01:00:00'); + }); + + test('it slices a string with a string display node', async () => { + // Given a string typed with a slice display. + const type = stringTypeNode('utf8', { display: stringDisplayNode({ sliceEnd: 3, sliceStart: 0 }) }); + + // When we format a string value. + const result = await formatArgumentValue(type, 'SOLANA', displayContext()); + + // Then we expect the sliced substring. + expect(result).toBe('SOL'); + }); + + test('it falls back to raw when amount decimals cannot be resolved', async () => { + // Given an amount whose injected decimals have no provider. + const type = numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }), + }); + + // When we format the amount. + const result = await formatArgumentValue(type, 1_000_000n, displayContext()); + + // Then we expect the raw value as a string. + expect(result).toBe('1000000'); + }); + + test('it renders a raw number when the type has no display node', async () => { + // Given a plain number type with no display. + const type = numberTypeNode('u64'); + + // When we format the value. + const result = await formatArgumentValue(type, 42n, displayContext()); + + // Then we expect the raw string. + expect(result).toBe('42'); + }); + + test('it renders an empty string for an undefined value rather than the string "undefined"', async () => { + // Given a plain number type and a missing (undefined) decoded value. + const type = numberTypeNode('u64'); + + // When we format the value. + const result = await formatArgumentValue(type, undefined, displayContext()); + + // Then we expect an empty string, not `JSON.stringify(undefined)`. + expect(result).toBe(''); + }); + + test('it labels a scalar enum variant using its display label', async () => { + // Given a scalar enum whose variant carries a display label. + const type = enumTypeNode([ + enumEmptyVariantTypeNode('buy', undefined, { display: enumVariantDisplayNode({ label: 'Buy' }) }), + enumEmptyVariantTypeNode('sell', undefined, { display: enumVariantDisplayNode({ label: 'Sell' }) }), + ]); + + // When we format the decoded variant name. + const result = await formatArgumentValue(type, 'buy', displayContext()); + + // Then we expect the variant label. + expect(result).toBe('Buy'); + }); + + test('it title-cases a scalar enum variant without a display label', async () => { + // Given a scalar enum variant with no display. + const type = enumTypeNode([enumEmptyVariantTypeNode('buyNow'), enumEmptyVariantTypeNode('sell')]); + + // When we format the decoded variant name. + const result = await formatArgumentValue(type, 'buyNow', displayContext()); + + // Then we expect the title-cased variant name. + expect(result).toBe('Buy Now'); + }); + + test('it resolves a defined-type link to a linked enum', async () => { + // Given an argument typed as a link to a defined enum. + const orderType: DefinedTypeNode = definedTypeNode({ + name: 'orderType', + type: enumTypeNode([ + enumEmptyVariantTypeNode('buy', undefined, { display: enumVariantDisplayNode({ label: 'Buy' }) }), + enumEmptyVariantTypeNode('sell', undefined, { display: enumVariantDisplayNode({ label: 'Sell' }) }), + ]), + }); + 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 })); + + // Then we expect the linked variant label. + expect(result).toBe('Sell'); + }); +}); diff --git a/packages/dynamic-instructions/test/display/format-value.test.ts b/packages/dynamic-instructions/test/display/format-value.test.ts index c00a2271a..254a4a51a 100644 --- a/packages/dynamic-instructions/test/display/format-value.test.ts +++ b/packages/dynamic-instructions/test/display/format-value.test.ts @@ -17,17 +17,7 @@ import { formatDurationValue, formatStringValue, } from '../../src/display/format-value'; -import type { DisplayResolutionContext } from '../../src/display/types'; -import { parsedInstruction } from '../test-utils'; - -function context(overrides: Partial = {}): DisplayResolutionContext { - return { - parsedInstruction: parsedInstruction(), - provides: new Map(), - resolveAccountData: () => null, - ...overrides, - }; -} +import { displayContext as context } from '../test-utils'; describe('formatAmountValue', () => { test('it scales an amount by literal decimals and appends a literal unit', async () => { diff --git a/packages/dynamic-instructions/test/display/interpolate-intent.test.ts b/packages/dynamic-instructions/test/display/interpolate-intent.test.ts new file mode 100644 index 000000000..cbfe55b98 --- /dev/null +++ b/packages/dynamic-instructions/test/display/interpolate-intent.test.ts @@ -0,0 +1,131 @@ +import type { Address } from '@solana/addresses'; +import { + amountNumberDisplayNode, + injectedValueNode, + instructionAccountNode, + instructionArgumentNode, + instructionDisplayNode, + instructionNode, + numberTypeNode, + numberValueNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { interpolateIntent } from '../../src/display/interpolate-intent'; +import { displayContext, parsedInstruction } from '../test-utils'; + +const DESTINATION = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +describe('interpolateIntent', () => { + test('it interpolates data and account placeholders into the sentence', async () => { + // Given an instruction with an interpolated intent referencing an amount and an account. + const instruction = instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: true, name: 'destination' })], + arguments: [ + instructionArgumentNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: numberValueNode(9) }), + }), + }), + ], + display: instructionDisplayNode({ + intent: 'Transfer', + interpolatedIntent: 'Transfer ${data.amount} to ${accounts.destination}', + }), + name: 'transfer', + }); + + // When we interpolate the intent. + const result = await interpolateIntent( + displayContext({ + parsedInstruction: parsedInstruction({ + accounts: [['destination', DESTINATION]], + data: { amount: 1_500_000_000n }, + instruction, + }), + }), + ); + + // Then we expect the rendered sentence. + expect(result).toBe(`Transfer 1.5 to ${DESTINATION}`); + }); + + test('it returns null when the instruction has no interpolated intent', async () => { + // Given an instruction without an interpolated intent. + const instruction = instructionNode({ + accounts: [], + arguments: [], + display: instructionDisplayNode({ intent: 'Transfer' }), + name: 'transfer', + }); + + // When we interpolate the intent. + const result = await interpolateIntent( + displayContext({ parsedInstruction: parsedInstruction({ instruction }) }), + ); + + // Then we expect null. + expect(result).toBeNull(); + }); + + test('it returns null when a data placeholder references an unknown argument', async () => { + // Given an intent referencing an argument that does not exist. + const instruction = instructionNode({ + accounts: [], + arguments: [], + display: instructionDisplayNode({ interpolatedIntent: 'Transfer ${data.amount}' }), + name: 'transfer', + }); + + // When we interpolate the intent. + const result = await interpolateIntent( + displayContext({ parsedInstruction: parsedInstruction({ instruction }) }), + ); + + // Then we expect null so the caller falls back to the list. + expect(result).toBeNull(); + }); + + test('it returns null when an account placeholder references an unknown account', async () => { + // Given an intent referencing an account with no resolved address. + const instruction = instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: true, name: 'destination' })], + arguments: [], + display: instructionDisplayNode({ interpolatedIntent: 'Transfer to ${accounts.destination}' }), + name: 'transfer', + }); + + // When we interpolate the intent without supplying the account address. + const result = await interpolateIntent( + displayContext({ parsedInstruction: parsedInstruction({ instruction }) }), + ); + + // Then we expect null. + expect(result).toBeNull(); + }); + + test('it returns null when a value formatter cannot resolve its inputs', async () => { + // Given an amount whose injected decimals cannot be resolved. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + name: 'amount', + type: numberTypeNode('u64', 'le', { + display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }), + }), + }), + ], + display: instructionDisplayNode({ interpolatedIntent: 'Transfer ${data.amount}' }), + name: 'transfer', + }); + + // When we interpolate the intent. + // Then it still resolves: an unresolved amount falls back to its raw value rather than failing. + const result = await interpolateIntent( + displayContext({ parsedInstruction: parsedInstruction({ data: { amount: 1_000_000n }, instruction }) }), + ); + expect(result).toBe('Transfer 1000000'); + }); +}); diff --git a/packages/dynamic-instructions/test/display/list-fallback.test.ts b/packages/dynamic-instructions/test/display/list-fallback.test.ts new file mode 100644 index 000000000..809d2a112 --- /dev/null +++ b/packages/dynamic-instructions/test/display/list-fallback.test.ts @@ -0,0 +1,199 @@ +import type { Address } from '@solana/addresses'; +import { + definedTypeLinkNode, + definedTypeNode, + instructionAccountDisplayNode, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + numberTypeNode, + numberValueNode, + type ProvidedNode, + providedNode, + structFieldDisplayNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { listFallback } from '../../src/display/list-fallback'; +import { displayContext, parsedInstruction } from '../test-utils'; + +const AUTHORITY = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +describe('listFallback', () => { + test('it lists arguments and accounts with derived labels', async () => { + // Given an instruction with one argument and one account. + const instruction = instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: true, name: 'destination' })], + arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], + name: 'transfer', + }); + + // When we build the fallback list. + const result = await listFallback( + displayContext({ + parsedInstruction: parsedInstruction({ + accounts: [['destination', AUTHORITY]], + data: { amount: 42n }, + instruction, + }), + }), + ); + + // Then we expect labelled fields for the argument and the account. + expect(result).toEqual([ + { label: 'Amount', value: '42' }, + { label: 'Destination', value: AUTHORITY }, + ]); + }); + + test('it honours explicit labels for arguments and accounts', async () => { + // Given display labels on the argument and account. + const instruction = instructionNode({ + accounts: [ + instructionAccountNode({ + display: instructionAccountDisplayNode({ label: 'To' }), + isSigner: false, + isWritable: true, + name: 'destination', + }), + ], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ label: 'Lamports' }), + name: 'amount', + type: numberTypeNode('u64'), + }), + ], + name: 'transfer', + }); + + // When we build the fallback list. + const result = await listFallback( + displayContext({ + parsedInstruction: parsedInstruction({ + accounts: [['destination', AUTHORITY]], + data: { amount: 42n }, + instruction, + }), + }), + ); + + // Then we expect the overridden labels. + expect(result).toEqual([ + { label: 'Lamports', value: '42' }, + { label: 'To', value: AUTHORITY }, + ]); + }); + + test('it skips members marked skip: always', async () => { + // Given an argument hidden with skip: always. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ skip: 'always' }), + name: 'discriminator', + type: numberTypeNode('u8'), + }), + instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') }), + ], + name: 'transfer', + }); + + // When we build the fallback list. + const result = await listFallback( + displayContext({ + parsedInstruction: parsedInstruction({ data: { amount: 42n, discriminator: 3 }, instruction }), + }), + ); + + // Then we expect only the visible argument. + 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. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ skip: 'whenInjected' }), + name: 'decimals', + type: numberTypeNode('u8'), + }), + ], + name: 'transfer', + }); + const provides = new Map([['decimals', providedNode('decimals', numberValueNode(6))]]); + + // When we build the fallback list with that provider present. + const result = await listFallback( + displayContext({ parsedInstruction: parsedInstruction({ data: { decimals: 6 }, instruction }), provides }), + ); + + // 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. + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ skip: 'whenInjected' }), + name: 'decimals', + type: numberTypeNode('u8'), + }), + ], + name: 'transfer', + }); + + // When we build the fallback list. + const result = await listFallback( + displayContext({ parsedInstruction: parsedInstruction({ data: { decimals: 6 }, instruction }) }), + ); + + // Then we expect the argument to be shown as a backup. + expect(result).toEqual([{ label: 'Decimals', value: '6' }]); + }); + + test('it flattens a linked struct argument with a prefix', async () => { + // Given an argument whose type links to a struct and is flattened with a prefix. + const orderArgs = definedTypeNode({ + name: 'orderArgs', + type: structTypeNode([ + structFieldTypeNode({ name: 'price', type: numberTypeNode('u64') }), + structFieldTypeNode({ name: 'size', type: numberTypeNode('u64') }), + ]), + }); + const instruction = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + display: structFieldDisplayNode({ flatten: true, flattenPrefix: 'args.' }), + name: 'args', + type: definedTypeLinkNode('orderArgs'), + }), + ], + 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, + }), + ); + + // Then we expect the struct fields lifted into the list with the prefix. + expect(result).toEqual([ + { label: 'args.Price', value: '100' }, + { label: 'args.Size', value: '5' }, + ]); + }); +}); diff --git a/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts index a31550a24..e5c252be4 100644 --- a/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts +++ b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts @@ -16,8 +16,7 @@ import { import { describe, expect, test } from 'vitest'; import { resolveInjectedValue } from '../../src/display/resolve-injected-value'; -import type { DisplayResolutionContext } from '../../src/display/types'; -import { accountFixture, parsedInstruction } from '../test-utils'; +import { accountFixture, displayContext as context, parsedInstruction } from '../test-utils'; const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; @@ -27,15 +26,6 @@ const MINT_ACCOUNT = accountNode({ name: 'mint', }); -function context(overrides: Partial = {}): DisplayResolutionContext { - return { - parsedInstruction: parsedInstruction(), - provides: new Map(), - resolveAccountData: () => null, - ...overrides, - }; -} - function providesMap(...entries: ProvidedNode[]): ReadonlyMap { return new Map(entries.map(entry => [entry.name, entry])); } diff --git a/packages/dynamic-instructions/test/test-utils.ts b/packages/dynamic-instructions/test/test-utils.ts index 6dd1e3180..cd9aa4d9e 100644 --- a/packages/dynamic-instructions/test/test-utils.ts +++ b/packages/dynamic-instructions/test/test-utils.ts @@ -11,10 +11,11 @@ import { instructionNode, type NodePath, programNode, + type ProvidedNode, rootNode, } from 'codama'; -import type { ResolveAccountDataFn } from '../src/display/types'; +import type { DisplayContext, ResolveAccountDataFn } from '../src/display/types'; export async function generateAddress(): Promise
{ const signer = await generateKeyPairSigner(); @@ -22,6 +23,7 @@ export async function generateAddress(): Promise
{ } const FIXTURE_ADDRESS = '11111111111111111111111111111111' as Address; +const DEFAULT_INSTRUCTION = instructionNode({ accounts: [], arguments: [], name: 'noop' }); /** Wraps raw account bytes in a Kit {@link MaybeEncodedAccount} that exists, filling its metadata. */ export function encodedAccount(bytes: ReadonlyUint8Array): MaybeEncodedAccount { @@ -57,15 +59,19 @@ export function accountFixture( } /** - * Builds a {@link ParsedInstruction} fixture from decoded argument data and named account - * addresses. The `path` is a stub: helpers that only read `data`/`accounts` never consult it. + * 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 + * `[root, program, instruction]` that display helpers walk to recover the node. */ export function parsedInstruction( overrides: { accounts?: ReadonlyArray; data?: Record; + instruction?: InstructionNode; } = {}, ): ParsedInstruction { + const instruction = overrides.instruction ?? DEFAULT_INSTRUCTION; + const root = makeRoot([instruction]); return { accounts: (overrides.accounts ?? []).map(([name, address]) => ({ address, @@ -73,7 +79,18 @@ export function parsedInstruction( role: AccountRole.READONLY, })), data: overrides.data ?? {}, - path: [] as unknown as NodePath, + path: [root, root.program, instruction] as NodePath, + }; +} + +/** Builds a {@link DisplayContext} with empty defaults, overridable per test. */ +export function displayContext(overrides: Partial = {}): DisplayContext { + return { + parsedInstruction: parsedInstruction(), + provides: new Map(), + resolveAccountData: () => null, + resolveDefinedType: () => undefined, + ...overrides, }; }