diff --git a/.changeset/dynamic-instructions-display-dictionary.md b/.changeset/dynamic-instructions-display-dictionary.md
new file mode 100644
index 000000000..e805986b2
--- /dev/null
+++ b/.changeset/dynamic-instructions-display-dictionary.md
@@ -0,0 +1,7 @@
+---
+'@codama/dynamic-instructions': minor
+---
+
+Add an offline display dictionary so a renderer (e.g. a hardware wallet) can resolve an instruction's clear-signing display with no network access. `DisplayDictionary` bundles a `DisplayAccountMap` (address to `EncodedAccount`, the offline counterpart of the display layer's `fetchAccount`; only existing accounts are stored, so a missing key means "no data") and a generic `DisplayNamedMap` (address to name — a `.sol` domain, token symbol, program label, alias…). `getRequiredAccountsForDisplay` statically computes which accounts a display would read, `getDisplayAccountMap` batch-fetches them via a `FetchAccountsFn`, and `getDisplayDictionaryCodec` (with per-map codecs, each split into `…Encoder`/`…Decoder`) serialises the bundle. A filler for the named map is not provided: its data comes from sources Codama has no opinion on.
+
+The offline planner and the `whenInjected` skip rule now resolve the provide/inject graph through a single shared walk (`resolveInjectionTarget`): both follow an injection's `fallback` when no provider supplies its key, so a display value reachable only through a fallback is fetched (and hides its member) as it would be at runtime. The walk is cycle-safe, so a self-referential provider resolves to "no value" instead of overflowing the stack. `resolveInjectedValue` (the display-rendering path) now performs injection selection through the same shared walk, so it too is cycle-safe rather than overflowing on a cyclic provider graph.
diff --git a/packages/dynamic-instructions/README.md b/packages/dynamic-instructions/README.md
index 871c370a2..10624e86d 100644
--- a/packages/dynamic-instructions/README.md
+++ b/packages/dynamic-instructions/README.md
@@ -173,3 +173,43 @@ const display = await getInstructionDisplay(root, instruction, {
```
Address presentation (`.sol` names, address-book aliases, truncation) is intentionally left to the renderer: `fields` and `interpolatedIntent` contain raw base58 addresses that the consuming wallet/UI formats as it sees fit.
+
+## Offline display dictionary
+
+An offline renderer — typically a hardware wallet — cannot reach an RPC to resolve the values above, nor a name service to present addresses. The **display dictionary** is a serialisable bundle of exactly that external data, assembled by an online companion and handed to the device so it can resolve a display with no network access.
+
+```ts
+type DisplayDictionary = {
+ // Fetched on-chain account state, keyed by address (the offline counterpart of `fetchAccount`).
+ accounts: ReadonlyMap
;
+ // Human-readable names, keyed by address — a `.sol` domain, token symbol, program label, alias…
+ names: ReadonlyMap;
+};
+```
+
+Only accounts that exist are stored: a missing key means "no data for this address", which is all the renderer can act on. It cannot, nor does it need to, distinguish an account that was never fetched from one that does not exist on-chain — both degrade the display the same way.
+
+The `names` map is deliberately generic: it names an address, whatever the source. This is how an offline renderer recovers the presentation the online layer would delegate to it.
+
+### Building the dictionary (online)
+
+`getRequiredAccountsForDisplay(root, parsedInstruction)` returns the addresses whose account state a display would read — computed statically from the IDL and the instruction, with no network access. `getDisplayAccountMap` uses it to batch-fetch those accounts into the `accounts` map:
+
+```ts
+import { fetchEncodedAccounts } from '@solana/accounts';
+import { parseInstruction } from '@codama/dynamic-parsers';
+import { getDisplayAccountMap, getDisplayDictionaryCodec } from '@codama/dynamic-instructions';
+
+const parsed = parseInstruction(root, instruction);
+const accounts = await getDisplayAccountMap(root, parsed, addresses => fetchEncodedAccounts(rpc, addresses));
+
+const dictionary = { accounts, names /* built from your own name sources */ };
+const bytes = getDisplayDictionaryCodec().encode(dictionary);
+```
+
+> [!NOTE]
+> A filler for the `names` map is **not** provided: its data comes from sources Codama has no opinion on (name services, token registries, curated label lists). Populate it yourself from whichever sources you trust.
+
+### Consuming the dictionary (offline)
+
+`fetchAccounts` (batch) is the counterpart of the display layer's `fetchAccount`; wire it to Kit's `fetchEncodedAccounts` for a single `getMultipleAccounts` round-trip. The bundle is encoded with byte codecs — `getDisplayDictionaryCodec` (and per-map `getDisplayAccountMapCodec` / `getDisplayNamedMapCodec`, each also available as split `…Encoder` / `…Decoder`). The offline renderer decodes it and resolves the display from the maps: account bytes are decoded through the IDL's `accountLink` exactly as online, and addresses are named from `names`.
diff --git a/packages/dynamic-instructions/src/display/build-display-context.ts b/packages/dynamic-instructions/src/display/build-display-context.ts
index 6ead754ba..3a345fea8 100644
--- a/packages/dynamic-instructions/src/display/build-display-context.ts
+++ b/packages/dynamic-instructions/src/display/build-display-context.ts
@@ -34,6 +34,20 @@ export async function buildDisplayContext(
parsedInstruction: ParsedInstruction,
options: GetInstructionDisplayOptions = {},
): Promise {
+ const baseContext = buildBaseDisplayContext(root, parsedInstruction, options);
+ return { ...baseContext, consumedMemberNames: await resolveConsumedMemberNames(baseContext) };
+}
+
+/**
+ * Assembles the {@link DisplayContext} without its `consumedMemberNames`: the part that needs no
+ * account state and so builds synchronously. Callers needing only the static graph (e.g. the
+ * offline-dictionary planner) reuse it without triggering account fetches.
+ */
+export function buildBaseDisplayContext(
+ root: RootNode,
+ parsedInstruction: ParsedInstruction,
+ options: GetInstructionDisplayOptions = {},
+): Omit {
const instruction = getLastNodeFromPath(parsedInstruction.path);
const provides = new Map(
@@ -43,15 +57,13 @@ export async function buildDisplayContext(
const linkables = new LinkableDictionary();
visit(root, getRecordLinkablesVisitor(linkables));
- const baseContext: Omit = {
+ return {
fetchAccount: options.fetchAccount,
parsedInstruction,
provides,
resolveAccountData: createAccountDataResolver(parsedInstruction, linkables),
resolveDefinedType: linkPath => linkables.getPath(linkPath),
};
-
- return { ...baseContext, consumedMemberNames: await resolveConsumedMemberNames(baseContext) };
}
/**
diff --git a/packages/dynamic-instructions/src/display/collect-injected-nodes.ts b/packages/dynamic-instructions/src/display/collect-injected-nodes.ts
new file mode 100644
index 000000000..54b28338f
--- /dev/null
+++ b/packages/dynamic-instructions/src/display/collect-injected-nodes.ts
@@ -0,0 +1,50 @@
+import { getLastNodeFromPath, type InjectedValueNode, isNode, type Node, type NodePath, type TypeNode } from 'codama';
+
+import { resolveDisplayType } from './resolve-display-type';
+import type { DisplayContext } from './types';
+
+type BaseDisplayContext = Omit;
+
+/**
+ * Collects the `injectedValueNode`s requested by the instruction's argument displays, mirroring
+ * what the fallback list renders (see `list-fallback.ts`). A purely static walk shared by the
+ * consumed-member computation and the offline-dictionary planner so they agree on which injections
+ * exist. Nodes are returned (rather than bare keys) so callers can reach each injection's `fallback`.
+ */
+export function collectInjectedNodes(displayContext: BaseDisplayContext): InjectedValueNode[] {
+ const instructionPath = displayContext.parsedInstruction.path;
+ const instruction = getLastNodeFromPath(instructionPath);
+ return (instruction.arguments ?? []).flatMap(argument =>
+ collectMemberInjectedNodes(
+ argument.type,
+ argument.display?.flatten ?? false,
+ [...instructionPath, argument],
+ displayContext,
+ ),
+ );
+}
+
+// Amount displays carry the injectable inputs; a flattened struct surfaces its direct fields, so we
+// recurse one level into those. `ownerPath` locates the type so nested links resolve in the right program.
+function collectMemberInjectedNodes(
+ type: TypeNode,
+ flatten: boolean,
+ ownerPath: NodePath,
+ displayContext: BaseDisplayContext,
+): InjectedValueNode[] {
+ const resolved = resolveDisplayType(type, ownerPath, displayContext);
+ if (isNode(resolved.type, 'numberTypeNode') && resolved.type.display?.kind === 'amountNumberDisplayNode') {
+ return [resolved.type.display.decimals, resolved.type.display.unit].filter(isInjectedValueNode);
+ }
+ if (flatten && isNode(resolved.type, 'structTypeNode')) {
+ return (resolved.type.fields ?? []).flatMap(field =>
+ collectMemberInjectedNodes(field.type, false, [...resolved.ownerPath, field], displayContext),
+ );
+ }
+ return [];
+}
+
+/** Narrows an optional injectable input to an `injectedValueNode`. */
+function isInjectedValueNode(input: Node | undefined): input is InjectedValueNode {
+ return input !== undefined && isNode(input, 'injectedValueNode');
+}
diff --git a/packages/dynamic-instructions/src/display/dictionary.ts b/packages/dynamic-instructions/src/display/dictionary.ts
new file mode 100644
index 000000000..3c5f4ce27
--- /dev/null
+++ b/packages/dynamic-instructions/src/display/dictionary.ts
@@ -0,0 +1,195 @@
+import type { ParsedInstruction } from '@codama/dynamic-parsers';
+import type { EncodedAccount, MaybeEncodedAccount } from '@solana/accounts';
+import { type Address, getAddressDecoder, getAddressEncoder } from '@solana/addresses';
+import {
+ addDecoderSizePrefix,
+ addEncoderSizePrefix,
+ type Codec,
+ combineCodec,
+ type Decoder,
+ type Encoder,
+ getBooleanDecoder,
+ getBooleanEncoder,
+ getBytesDecoder,
+ getBytesEncoder,
+ getMapDecoder,
+ getMapEncoder,
+ getStructDecoder,
+ getStructEncoder,
+ getU32Decoder,
+ getU32Encoder,
+ getU64Decoder,
+ getU64Encoder,
+ getUtf8Decoder,
+ getUtf8Encoder,
+ transformDecoder,
+ transformEncoder,
+} from '@solana/codecs';
+import { isNode, type RootNode } from 'codama';
+
+import { buildBaseDisplayContext } from './build-display-context';
+import { collectInjectedNodes } from './collect-injected-nodes';
+import { resolveInjectionTarget } from './resolve-injection-target';
+
+/**
+ * A map from an account address to its fetched on-chain state (Kit's `EncodedAccount`).
+ *
+ * The offline counterpart of the display layer's `fetchAccount`: a renderer looks addresses up here
+ * rather than reaching an RPC. Only accounts that exist are stored, so a missing key means "no data".
+ */
+export type DisplayAccountMap = ReadonlyMap;
+
+/**
+ * A map from an address to a human-readable name.
+ *
+ * Deliberately generic: a `.sol` domain, token symbol, program label, alias — anything that names an
+ * address. It lets an offline renderer present addresses the display layer emits as raw base58.
+ */
+export type DisplayNamedMap = ReadonlyMap;
+
+/**
+ * A serialisable bundle of the external data an offline renderer needs to present an instruction
+ * without network access: account state ({@link DisplayAccountMap}) and address names
+ * ({@link DisplayNamedMap}).
+ */
+export type DisplayDictionary = {
+ /** Fetched on-chain account state, keyed by address. */
+ readonly accounts: DisplayAccountMap;
+ /** Human-readable names, keyed by address. */
+ readonly names: DisplayNamedMap;
+};
+
+/**
+ * Fetches multiple on-chain accounts in one call. The batch counterpart of the display layer's
+ * `fetchAccount`; wire it to Kit's `fetchEncodedAccounts` for a single `getMultipleAccounts` call.
+ */
+export type FetchAccountsFn = (addresses: Address[]) => Promise;
+
+/**
+ * Computes the addresses whose account state the display layer would fetch to present the given
+ * instruction, i.e. the exact set an offline renderer must pre-fetch. Deduplicated, and derived
+ * statically from the IDL and parsed instruction with no network access.
+ *
+ * @see {@link getDisplayAccountMap}
+ */
+export function getRequiredAccountsForDisplay(root: RootNode, parsedInstruction: ParsedInstruction): Address[] {
+ const context = buildBaseDisplayContext(root, parsedInstruction);
+ const accountNames = collectInjectedNodes(context).flatMap(node => {
+ const target = resolveInjectionTarget(node, context.provides);
+ // Only an `accountFieldValueNode` reads account state and therefore triggers a fetch; a bare
+ // `accountValueNode` names an existing instruction account and needs none.
+ return target && isNode(target, 'accountFieldValueNode') ? [target.account] : [];
+ });
+ const addresses = accountNames.flatMap(name => {
+ const address = parsedInstruction.accounts.find(account => account.name === name)?.address;
+ return address ? [address] : [];
+ });
+ return [...new Set(addresses)];
+}
+
+/**
+ * Builds the {@link DisplayAccountMap} for an instruction by batch-fetching the accounts its display
+ * would read (see {@link getRequiredAccountsForDisplay}). Non-existent accounts are dropped; an empty
+ * map is returned when no display value reads account state.
+ */
+export async function getDisplayAccountMap(
+ root: RootNode,
+ parsedInstruction: ParsedInstruction,
+ fetchAccounts: FetchAccountsFn,
+): Promise {
+ const addresses = getRequiredAccountsForDisplay(root, parsedInstruction);
+ if (addresses.length === 0) return new Map();
+
+ const accounts = await fetchAccounts(addresses);
+ return new Map(accounts.flatMap(account => (account.exists ? [[account.address, account] as const] : [])));
+}
+
+// An `EncodedAccount` minus its address, which is carried as the map key.
+type AccountBody = Omit;
+
+const accountBodyEncoder = (): Encoder =>
+ getStructEncoder([
+ ['data', addEncoderSizePrefix(getBytesEncoder(), getU32Encoder())],
+ ['executable', getBooleanEncoder()],
+ ['lamports', getU64Encoder()],
+ ['programAddress', getAddressEncoder()],
+ ['space', getU64Encoder()],
+ ]);
+const accountBodyDecoder = (): Decoder =>
+ getStructDecoder([
+ ['data', addDecoderSizePrefix(getBytesDecoder(), getU32Decoder())],
+ ['executable', getBooleanDecoder()],
+ ['lamports', getU64Decoder() as Decoder],
+ ['programAddress', getAddressDecoder()],
+ ['space', getU64Decoder()],
+ ]);
+
+/** Encoder for a {@link DisplayAccountMap}, keyed by address with the account body as the value. */
+export function getDisplayAccountMapEncoder(): Encoder {
+ return transformEncoder(
+ getMapEncoder(getAddressEncoder(), accountBodyEncoder(), { size: getU32Encoder() }),
+ (map: DisplayAccountMap) => new Map(map),
+ );
+}
+
+/** Decoder for a {@link DisplayAccountMap}. Re-attaches each entry's address onto its account body. */
+export function getDisplayAccountMapDecoder(): Decoder {
+ return transformDecoder(
+ getMapDecoder(getAddressDecoder(), accountBodyDecoder(), { size: getU32Decoder() }),
+ bodies =>
+ new Map(
+ [...bodies].map(([address, body]) => [
+ address,
+ { ...body, address, data: body.data as Uint8Array } satisfies EncodedAccount,
+ ]),
+ ),
+ );
+}
+
+/** Codec for a {@link DisplayAccountMap}. */
+export function getDisplayAccountMapCodec(): Codec {
+ return combineCodec(getDisplayAccountMapEncoder(), getDisplayAccountMapDecoder());
+}
+
+/** Encoder for a {@link DisplayNamedMap}, keyed by address with a length-prefixed UTF-8 name. */
+export function getDisplayNamedMapEncoder(): Encoder {
+ return transformEncoder(
+ getMapEncoder(getAddressEncoder(), addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder()), {
+ size: getU32Encoder(),
+ }),
+ (map: DisplayNamedMap) => new Map(map),
+ );
+}
+
+/** Decoder for a {@link DisplayNamedMap}. */
+export function getDisplayNamedMapDecoder(): Decoder {
+ return getMapDecoder(getAddressDecoder(), addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder()), {
+ size: getU32Decoder(),
+ });
+}
+
+/** Codec for a {@link DisplayNamedMap}. */
+export function getDisplayNamedMapCodec(): Codec {
+ return combineCodec(getDisplayNamedMapEncoder(), getDisplayNamedMapDecoder());
+}
+
+/** Encoder for a {@link DisplayDictionary}, composed from the two map encoders. */
+export function getDisplayDictionaryEncoder(): Encoder {
+ return getStructEncoder([
+ ['accounts', getDisplayAccountMapEncoder()],
+ ['names', getDisplayNamedMapEncoder()],
+ ]);
+}
+
+/** Decoder for a {@link DisplayDictionary}, composed from the two map decoders. */
+export function getDisplayDictionaryDecoder(): Decoder {
+ return getStructDecoder([
+ ['accounts', getDisplayAccountMapDecoder()],
+ ['names', getDisplayNamedMapDecoder()],
+ ]);
+}
+
+/** Codec for a {@link DisplayDictionary}. */
+export function getDisplayDictionaryCodec(): Codec {
+ return combineCodec(getDisplayDictionaryEncoder(), getDisplayDictionaryDecoder());
+}
diff --git a/packages/dynamic-instructions/src/display/index.ts b/packages/dynamic-instructions/src/display/index.ts
index 7a8d0cc17..644d4ec84 100644
--- a/packages/dynamic-instructions/src/display/index.ts
+++ b/packages/dynamic-instructions/src/display/index.ts
@@ -1,4 +1,5 @@
export * from './build-display-context';
+export * from './dictionary';
export * from './format-argument-value';
export * from './format-value';
export * from './get-instruction-display';
diff --git a/packages/dynamic-instructions/src/display/resolve-consumed-members.ts b/packages/dynamic-instructions/src/display/resolve-consumed-members.ts
index 32ff7b561..13e76f293 100644
--- a/packages/dynamic-instructions/src/display/resolve-consumed-members.ts
+++ b/packages/dynamic-instructions/src/display/resolve-consumed-members.ts
@@ -1,7 +1,8 @@
-import { getLastNodeFromPath, isNode, type Node, type NodePath, type TypeNode } from 'codama';
+import { isNode, type Node } from 'codama';
-import { resolveDisplayType } from './resolve-display-type';
+import { collectInjectedNodes } from './collect-injected-nodes';
import { resolveInjectedValue } from './resolve-injected-value';
+import { resolveInjectionTarget } from './resolve-injection-target';
import type { DisplayContext } from './types';
type BaseDisplayContext = Omit;
@@ -10,26 +11,42 @@ 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.
+ * A member is "consumed" when a display value injects it and that injection 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.
+ * Two independent gates must both hold for a member to count as consumed:
+ * - *rendered*: the injection point is actually displayed (see {@link collectInjectedNodes}, which
+ * is flatten-aware — an amount buried in a non-flattened struct is never surfaced);
+ * - *resolved*: the injection resolves to a concrete value. When it cannot (e.g. no `fetchAccount`
+ * offline, or the account does not exist), the member is not consumed and remains visible — which
+ * is what distinguishes the metadata-rich and offline fallback presentations.
+ *
+ * Selection follows the provide/inject protocol via {@link resolveInjectionTarget} (a matching
+ * provider wins, else the injection's own `fallback`), so members reachable only through a fallback
+ * are handled too. Cyclic provider chains resolve to nothing rather than recursing forever. Distinct
+ * targets are resolved once, so the same display value injected into several slots is fetched once.
*
* 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);
+ // Select each rendered injection to its terminal node (provider → fallback → chain,
+ // cycle-guarded), then deduplicate by the resolved target — not the injection key — so the same
+ // display value appearing in several slots is resolved (and fetched) only once, while two slots
+ // that share a key but resolve to different targets are both kept.
+ const targets = new Set(
+ collectInjectedNodes(displayContext)
+ .map(node => resolveInjectionTarget(node, displayContext.provides))
+ .filter((target): target is Node => target !== null),
+ );
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;
+ [...targets].map(async target => {
+ // Resolution gate: the terminal must resolve to a concrete value. The target is never an
+ // `injectedValueNode` (the walk already collapsed the chain), so resolving it cannot
+ // re-enter the graph or cycle.
+ const value = await resolveInjectedValue(target, displayContext);
+ return value === null ? null : target;
}),
);
@@ -40,63 +57,7 @@ export async function resolveConsumedMemberNames(displayContext: BaseDisplayCont
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). */
+/** Collects the member names a resolved 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);
diff --git a/packages/dynamic-instructions/src/display/resolve-injected-value.ts b/packages/dynamic-instructions/src/display/resolve-injected-value.ts
index 73d8350ac..5430a2534 100644
--- a/packages/dynamic-instructions/src/display/resolve-injected-value.ts
+++ b/packages/dynamic-instructions/src/display/resolve-injected-value.ts
@@ -1,6 +1,7 @@
import type { Address } from '@solana/addresses';
import { isNode, type Node } from 'codama';
+import { resolveInjectionTarget } from './resolve-injection-target';
import type { DisplayContext } from './types';
/**
@@ -17,8 +18,9 @@ export type ResolvedDisplayValue = Address | bigint | number | string | null;
*
* Handles the value/contextual nodes the display layer relies on:
* - `numberValueNode` / `stringValueNode`: the literal value.
- * - `injectedValueNode`: looks the key up in `provides`, resolving the matched provider's node;
- * when no provider supplies the key, falls back to the injection's own `fallback`.
+ * - `injectedValueNode`: selected to its terminal node via {@link resolveInjectionTarget} (a
+ * matching provider wins, else the injection's own `fallback`) before being evaluated. The
+ * selection is cycle-safe, so a cyclic provider chain resolves to `null` rather than overflowing.
* - `argumentValueNode`: the decoded value of the referenced instruction argument.
* - `accountValueNode`: the referenced account's address.
* - `accountFieldValueNode`: a field of the referenced account's data — the account is fetched via
@@ -40,14 +42,10 @@ export async function resolveInjectedValue(
}
if (isNode(node, 'injectedValueNode')) {
- const provided = context.provides.get(node.key);
- if (provided) {
- return await resolveInjectedValue(provided.node, context);
- }
- if (node.fallback) {
- return await resolveInjectedValue(node.fallback, context);
- }
- return null;
+ // Selection (provider → fallback → chain) is cycle-guarded and shared with the offline
+ // planner and the consumed-member walk; the terminal is never itself an `injectedValueNode`.
+ const target = resolveInjectionTarget(node, context.provides);
+ return target ? await resolveInjectedValue(target, context) : null;
}
if (isNode(node, 'argumentValueNode')) {
diff --git a/packages/dynamic-instructions/src/display/resolve-injection-target.ts b/packages/dynamic-instructions/src/display/resolve-injection-target.ts
new file mode 100644
index 000000000..39d4f32d5
--- /dev/null
+++ b/packages/dynamic-instructions/src/display/resolve-injection-target.ts
@@ -0,0 +1,47 @@
+import { isNode, type Node, type ProvidedNode } from 'codama';
+
+/**
+ * Resolves a value node to the terminal node the provide/inject protocol selects, following the
+ * graph statically without evaluating it.
+ *
+ * The protocol is pure selection: for an `injectedValueNode`, if the `provides` map supplies its
+ * key, that provider's node is chosen (the injection's own `fallback` is ignored); otherwise the
+ * `fallback` is chosen; otherwise the key is unsatisfied and resolves to `null`. A chosen node that
+ * is itself an `injectedValueNode` is followed in turn, so provider chains collapse to their
+ * terminal. Any non-injection node (e.g. `accountFieldValueNode`, `accountValueNode`,
+ * `argumentValueNode`, `numberValueNode`) is already terminal and returned as-is.
+ *
+ * Resolution is against a single, pre-assembled `provides` map. Deciding which provider wins for a
+ * given key is the caller's responsibility: today the map is built from the one instruction being
+ * displayed, but were several provider scopes ever merged (e.g. nested instructions where the
+ * closest ancestor should override), the caller must resolve that precedence when building the map.
+ * This walk simply trusts `provides.get(key)`.
+ *
+ * This is deliberately *not* a tree rewrite: the terminal is returned rather than spliced back into
+ * its slot, because display slots are statically typed (e.g. an amount's `decimals` accepts only
+ * `numberValueNode | injectedValueNode`) yet an injection routinely resolves to an
+ * `accountFieldValueNode`, which no such slot can hold. Callers interpret the terminal themselves —
+ * the offline planner reads the addresses it references, the consumed-member computation resolves
+ * and gates it — so both agree on what an injection resolves to without duplicating the walk.
+ *
+ * Provider chains may cycle (a provider re-injecting its own key); `seen` tracks the keys already
+ * visited so such a cycle terminates at `null` rather than recursing forever.
+ *
+ * @param node - The value node to resolve; typically an `injectedValueNode` from a display slot.
+ * @param provides - The assembled providers in scope, indexed by the key they expose; the caller
+ * must have already resolved any duplicate keys to the winning provider.
+ * @returns The selected terminal node, or `null` when the injection cannot be satisfied.
+ */
+export function resolveInjectionTarget(
+ node: Node,
+ provides: ReadonlyMap,
+ seen: ReadonlySet = new Set(),
+): Node | null {
+ if (!isNode(node, 'injectedValueNode')) return node;
+ if (seen.has(node.key)) return null;
+ const nextSeen = new Set([...seen, node.key]);
+ const provided = provides.get(node.key)?.node;
+ if (provided) return resolveInjectionTarget(provided, provides, nextSeen);
+ if (node.fallback) return resolveInjectionTarget(node.fallback, provides, nextSeen);
+ return null;
+}
diff --git a/packages/dynamic-instructions/test/display/dictionary.test.ts b/packages/dynamic-instructions/test/display/dictionary.test.ts
new file mode 100644
index 000000000..0c52749da
--- /dev/null
+++ b/packages/dynamic-instructions/test/display/dictionary.test.ts
@@ -0,0 +1,499 @@
+import type { EncodedAccount, MaybeEncodedAccount } from '@solana/accounts';
+import type { Address } from '@solana/addresses';
+import {
+ accountFieldValueNode,
+ accountLinkNode,
+ amountNumberDisplayNode,
+ injectedValueNode,
+ instructionAccountNode,
+ instructionArgumentNode,
+ instructionNode,
+ numberTypeNode,
+ numberValueNode,
+ providedNode,
+} from 'codama';
+import { describe, expect, test, vi } from 'vitest';
+
+import {
+ type DisplayAccountMap,
+ type DisplayDictionary,
+ type DisplayNamedMap,
+ type FetchAccountsFn,
+ getDisplayAccountMap,
+ getDisplayAccountMapCodec,
+ getDisplayAccountMapDecoder,
+ getDisplayAccountMapEncoder,
+ getDisplayDictionaryCodec,
+ getDisplayDictionaryDecoder,
+ getDisplayDictionaryEncoder,
+ getDisplayNamedMapCodec,
+ getDisplayNamedMapDecoder,
+ getDisplayNamedMapEncoder,
+ getRequiredAccountsForDisplay,
+} from '../../src/display/dictionary';
+import { encodeAccountData, makeParsedInstruction, makeRoot, mintAccountNode } from '../test-utils';
+
+const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address;
+const OWNER = '3Wnd5Df69KitZfUoPYZU438eFRNwGHkhLnSAWL65PxJX' as Address;
+const PROGRAM = '11111111111111111111111111111111' as Address;
+
+/** The `mint` account of the instruction, linked to the `mint` account node so it carries a layout. */
+function mintInstructionAccount() {
+ return instructionAccountNode({
+ accountLink: accountLinkNode('mint'),
+ isSigner: false,
+ isWritable: false,
+ name: 'mint',
+ });
+}
+
+/** An `amount` argument injecting `decimals` (and optionally `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('getRequiredAccountsForDisplay', () => {
+ test('it returns the address of an account whose field is injected into a display', () => {
+ // Given `decimals` injected from the mint account's `decimals` field.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the mint address is required.
+ expect(addresses).toEqual([MINT]);
+ });
+
+ test('it returns an empty list when no display value reads account state', () => {
+ // Given an amount that injects a literal-backed provider (no account field).
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [providedNode('decimals', numberValueNode(6))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then nothing needs fetching.
+ expect(addresses).toEqual([]);
+ });
+
+ test('it returns an empty list when the injection has no matching provider', () => {
+ // Given an amount injecting `decimals` but no provider supplies it.
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n });
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then nothing needs fetching.
+ expect(addresses).toEqual([]);
+ });
+
+ test('it follows an injection fallback that resolves to an account field', () => {
+ // Given `decimals` has no provider but falls back to injecting `mintDecimals`, itself an
+ // account field read. The runtime resolver would fetch the mint through the fallback, so
+ // the planner must list it too.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({
+ decimals: injectedValueNode({
+ fallback: injectedValueNode({ key: 'mintDecimals' }),
+ key: 'decimals',
+ }),
+ }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ provides: [providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the mint address (reached through the fallback) is required.
+ expect(addresses).toEqual([MINT]);
+ });
+
+ test('it ignores an injection fallback that reads no account state', () => {
+ // Given `decimals` has no provider and falls back to a literal, so nothing is fetched.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({
+ decimals: injectedValueNode({ fallback: numberValueNode(6), key: 'decimals' }),
+ }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then nothing needs fetching.
+ expect(addresses).toEqual([]);
+ });
+
+ test('it prefers a provider over the injection fallback', () => {
+ // Given `decimals` has BOTH a provider (a literal) and a fallback that would read an account
+ // field. The runtime resolver takes the provider, so no account is fetched.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({
+ decimals: injectedValueNode({
+ fallback: injectedValueNode({ key: 'mintDecimals' }),
+ key: 'decimals',
+ }),
+ }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ provides: [
+ providedNode('decimals', numberValueNode(6)),
+ providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })),
+ ],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the fallback is not consulted and nothing is fetched.
+ expect(addresses).toEqual([]);
+ });
+
+ test('it terminates on a cyclic provider chain instead of recursing forever', () => {
+ // Given `decimals` provided by re-injecting itself: a cycle the `seen` guard must break.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [providedNode('decimals', injectedValueNode({ key: 'decimals' }))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the walk terminates and nothing is fetched.
+ expect(addresses).toEqual([]);
+ });
+
+ test('it follows a provider that chains through another injection to an account field', () => {
+ // Given `decimals` provided by re-injecting `mintDecimals`, itself an account field read.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [
+ providedNode('decimals', injectedValueNode({ key: 'mintDecimals' })),
+ providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })),
+ ],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the mint address (reached through the provider chain) is required.
+ expect(addresses).toEqual([MINT]);
+ });
+
+ test('it deduplicates when several injections reference the same account', () => {
+ // Given both `decimals` and `symbol` injected from the same mint account.
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [
+ providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })),
+ providedNode('symbol', accountFieldValueNode({ account: 'mint', path: 'symbol' })),
+ ],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the mint appears once.
+ expect(addresses).toEqual([MINT]);
+ });
+
+ test('it resolves a key once when the same injection appears in several display slots', () => {
+ // Given two amounts both injecting the SAME `decimals` key, backed by one account field.
+ const amount = (name: string) =>
+ instructionArgumentNode({
+ name,
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }),
+ }),
+ });
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amount('inputAmount'), amount('outputAmount')],
+ name: 'swap',
+ provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(
+ root,
+ instruction,
+ { inputAmount: 1n, outputAmount: 2n },
+ new Map([['mint', MINT]]),
+ );
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then the duplicated key collapses to a single required address.
+ expect(addresses).toEqual([MINT]);
+ });
+
+ test('it omits an injected account with no concrete address in the instruction', () => {
+ // Given an account field injected and a `mint` account on the instruction, but the parsed
+ // instruction binds no concrete address for it (e.g. an optional account left unset).
+ const instruction = instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n });
+
+ // When we compute the required accounts.
+ const addresses = getRequiredAccountsForDisplay(root, parsed);
+
+ // Then there is nothing to fetch.
+ expect(addresses).toEqual([]);
+ });
+});
+
+/** A transfer whose amount injects the mint's `decimals`, so the mint must be fetched. */
+function transferInjectingMintDecimals() {
+ return instructionNode({
+ accounts: [mintInstructionAccount()],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+}
+
+describe('getDisplayAccountMap', () => {
+ test('it batch-fetches the required accounts and maps them by address', async () => {
+ // Given a transfer needing the mint account, and a mint fetchable through fetchAccounts.
+ const instruction = transferInjectingMintDecimals();
+ const mint = mintAccountNode();
+ const root = makeRoot([instruction], 'testProgram', [mint]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+ const encoded = { ...encodeAccountData(root, mint, { decimals: 6 }), address: MINT };
+
+ const fetchAccounts = vi.fn(addresses =>
+ Promise.resolve(addresses.map(address => ({ ...encoded, address }))),
+ );
+
+ // When we fill the account map.
+ const map = await getDisplayAccountMap(root, parsed, fetchAccounts);
+
+ // Then fetchAccounts was called exactly once with the deduped address list.
+ expect(fetchAccounts).toHaveBeenCalledOnce();
+ expect(fetchAccounts).toHaveBeenCalledWith([MINT]);
+
+ // And the map keys the fetched account by its address.
+ expect(map.get(MINT)).toEqual(encoded);
+ });
+
+ test('it returns an empty map without fetching when no account is required', async () => {
+ // Given an instruction whose display reads no account state.
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })],
+ name: 'transfer',
+ });
+ const root = makeRoot([instruction]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n });
+ const fetchAccounts = vi.fn(() => Promise.resolve([]));
+
+ // When we fill the account map.
+ const map = await getDisplayAccountMap(root, parsed, fetchAccounts);
+
+ // Then no fetch happens and the map is empty.
+ expect(fetchAccounts).not.toHaveBeenCalled();
+ expect(map.size).toBe(0);
+ });
+
+ test('it omits a non-existent account from the map', async () => {
+ // Given the mint required, but the RPC reports it does not exist.
+ const instruction = transferInjectingMintDecimals();
+ const mint = mintAccountNode();
+ const root = makeRoot([instruction], 'testProgram', [mint]);
+ const parsed = makeParsedInstruction(root, instruction, { amount: 1n }, new Map([['mint', MINT]]));
+ const fetchAccounts: FetchAccountsFn = addresses =>
+ Promise.resolve(addresses.map(address => ({ address, exists: false }) as MaybeEncodedAccount));
+
+ // When we fill the account map.
+ const map = await getDisplayAccountMap(root, parsed, fetchAccounts);
+
+ // Then the map has no entry: a missing key is all "no data" needs to convey.
+ expect(map.has(MINT)).toBe(false);
+ });
+});
+
+/** An account carrying the given bytes and simple metadata. */
+function makeEncodedAccount(address: Address, data: Uint8Array): EncodedAccount {
+ return {
+ address,
+ data,
+ executable: false,
+ lamports: 42n as EncodedAccount['lamports'],
+ programAddress: PROGRAM,
+ space: BigInt(data.length),
+ };
+}
+
+describe('getDisplayAccountMapCodec', () => {
+ test('it round-trips an account map, preserving bytes and metadata', () => {
+ // Given a map with two accounts, one carrying multi-byte data.
+ const map: DisplayAccountMap = new Map([
+ [MINT, makeEncodedAccount(MINT, new Uint8Array([6, 255, 0, 128]))],
+ [OWNER, makeEncodedAccount(OWNER, new Uint8Array([]))],
+ ]);
+
+ // When we encode then decode it.
+ const codec = getDisplayAccountMapCodec();
+ const decoded = codec.decode(codec.encode(map));
+
+ // Then it reproduces both entries faithfully.
+ expect(decoded.get(MINT)).toEqual(map.get(MINT));
+ expect(decoded.get(OWNER)).toEqual(map.get(OWNER));
+ });
+
+ test('it round-trips an empty account map', () => {
+ const codec = getDisplayAccountMapCodec();
+ const decoded = codec.decode(codec.encode(new Map()));
+ expect(decoded.size).toBe(0);
+ });
+
+ test('its standalone encoder and decoder interoperate', () => {
+ // Given the split encoder/decoder rather than the combined codec.
+ const map: DisplayAccountMap = new Map([[MINT, makeEncodedAccount(MINT, new Uint8Array([1, 2, 3]))]]);
+
+ // When we encode with the encoder and decode with the decoder.
+ const decoded = getDisplayAccountMapDecoder().decode(getDisplayAccountMapEncoder().encode(map));
+
+ // Then the split halves agree with the combined codec.
+ expect(decoded.get(MINT)).toEqual(map.get(MINT));
+ });
+});
+
+describe('getDisplayNamedMapCodec', () => {
+ test('it round-trips names including multi-byte UTF-8', () => {
+ // Given a domain name and a multi-byte token symbol.
+ const map: DisplayNamedMap = new Map([
+ [OWNER, 'toly.sol'],
+ [MINT, 'USD₮'],
+ ]);
+
+ // When we encode then decode it.
+ const codec = getDisplayNamedMapCodec();
+ const decoded = codec.decode(codec.encode(map));
+
+ // Then both names survive intact.
+ expect(decoded.get(OWNER)).toBe('toly.sol');
+ expect(decoded.get(MINT)).toBe('USD₮');
+ });
+
+ test('it round-trips an empty named map', () => {
+ const codec = getDisplayNamedMapCodec();
+ const decoded = codec.decode(codec.encode(new Map()));
+ expect(decoded.size).toBe(0);
+ });
+
+ test('its standalone encoder and decoder interoperate', () => {
+ const map: DisplayNamedMap = new Map([[OWNER, 'toly.sol']]);
+ const decoded = getDisplayNamedMapDecoder().decode(getDisplayNamedMapEncoder().encode(map));
+ expect(decoded.get(OWNER)).toBe('toly.sol');
+ });
+});
+
+describe('getDisplayDictionaryCodec', () => {
+ test('it round-trips a full dictionary composed of both maps', () => {
+ // Given a dictionary carrying an account map and a named map.
+ const dictionary: DisplayDictionary = {
+ accounts: new Map([[MINT, makeEncodedAccount(MINT, new Uint8Array([6]))]]),
+ names: new Map([[OWNER, 'toly.sol']]),
+ };
+
+ // When we encode then decode it.
+ const codec = getDisplayDictionaryCodec();
+ const decoded = codec.decode(codec.encode(dictionary));
+
+ // Then both maps are reproduced.
+ expect(decoded.accounts.get(MINT)).toEqual(dictionary.accounts.get(MINT));
+ expect(decoded.names.get(OWNER)).toBe('toly.sol');
+ });
+
+ test('its standalone encoder and decoder interoperate', () => {
+ const dictionary: DisplayDictionary = {
+ accounts: new Map([[MINT, makeEncodedAccount(MINT, new Uint8Array([9]))]]),
+ names: new Map([[OWNER, 'toly.sol']]),
+ };
+ const decoded = getDisplayDictionaryDecoder().decode(getDisplayDictionaryEncoder().encode(dictionary));
+ expect(decoded.accounts.get(MINT)).toEqual(dictionary.accounts.get(MINT));
+ expect(decoded.names.get(OWNER)).toBe('toly.sol');
+ });
+});
diff --git a/packages/dynamic-instructions/test/display/format-value.test.ts b/packages/dynamic-instructions/test/display/format-value.test.ts
index 254a4a51a..40feb9148 100644
--- a/packages/dynamic-instructions/test/display/format-value.test.ts
+++ b/packages/dynamic-instructions/test/display/format-value.test.ts
@@ -76,6 +76,20 @@ describe('formatAmountValue', () => {
expect(result).toBeNull();
});
+ test('it returns null on a cyclic injection instead of overflowing', async () => {
+ // Given an amount whose injected decimals are provided by re-injecting themselves.
+ const node = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) });
+ const provides = new Map([
+ ['decimals', providedNode('decimals', injectedValueNode({ key: 'decimals' }))],
+ ]);
+
+ // When we format the amount.
+ const result = await formatAmountValue(1_000_000n, node, context({ provides }));
+
+ // Then the cycle resolves to null rather than recursing forever.
+ expect(result).toBeNull();
+ });
+
test('it omits the unit but still scales when only the unit is unresolvable', async () => {
// Given resolvable decimals but an unresolvable unit.
const node = amountNumberDisplayNode({
diff --git a/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts b/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts
index 82be3d84a..cf15d4518 100644
--- a/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts
+++ b/packages/dynamic-instructions/test/display/resolve-consumed-members.test.ts
@@ -14,7 +14,7 @@ import {
structFieldTypeNode,
structTypeNode,
} from 'codama';
-import { describe, expect, test } from 'vitest';
+import { describe, expect, test, vi } from 'vitest';
import { resolveConsumedMemberNames } from '../../src/display/resolve-consumed-members';
import { accountFixture, displayContext, mintAccountNode, mockFetch, parsedInstruction } from '../test-utils';
@@ -201,4 +201,159 @@ describe('resolveConsumedMemberNames', () => {
// Then nothing is consumed.
expect(consumed).toEqual(new Set());
});
+
+ test('it marks an account consumed through an injection fallback', async () => {
+ // Given `decimals` has no provider but falls back to injecting `mintDecimals`, itself the
+ // mint's account field. The value resolves through the fallback, so the mint is consumed.
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({
+ decimals: injectedValueNode({
+ fallback: injectedValueNode({ key: 'mintDecimals' }),
+ key: 'decimals',
+ }),
+ }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ provides: [providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+
+ // When we resolve the consumed members with the mint 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 (reached through the fallback) is consumed.
+ expect(consumed).toEqual(new Set(['mint']));
+ });
+
+ test('it marks an account consumed through a provider chain', async () => {
+ // Given `decimals` provided by re-injecting `mintDecimals`, itself the mint's account field.
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [
+ providedNode('decimals', injectedValueNode({ key: 'mintDecimals' })),
+ providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })),
+ ],
+ });
+
+ // When we resolve the consumed members with the mint 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 (reached through the chain) is consumed.
+ expect(consumed).toEqual(new Set(['mint']));
+ });
+
+ test('it fetches an account once when the same key is injected into several slots', async () => {
+ // Given two amounts both injecting the SAME `decimals` key, backed by one account field.
+ const amount = (name: string) =>
+ instructionArgumentNode({
+ name,
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }),
+ }),
+ });
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [amount('inputAmount'), amount('outputAmount')],
+ name: 'swap',
+ provides: [providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+
+ // When we resolve the consumed members, counting fetches.
+ const mint = accountFixture(mintAccountNode(), { decimals: 6 });
+ const fetchAccount = vi.fn(mockFetch([[MINT, mint.encoded]]));
+ const consumed = await resolveConsumedMemberNames(
+ displayContext({
+ fetchAccount,
+ parsedInstruction: parsedInstruction({
+ accounts: [['mint', MINT]],
+ data: { inputAmount: 1n, outputAmount: 2n },
+ instruction,
+ }),
+ provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []),
+ resolveAccountData: mint.resolveAccountData,
+ }),
+ );
+
+ // Then the mint is consumed and the duplicated injection collapsed to a single fetch.
+ expect(consumed).toEqual(new Set(['mint']));
+ expect(fetchAccount).toHaveBeenCalledOnce();
+ });
+
+ test('it does not mark a fallback-resolved account consumed when it cannot resolve', async () => {
+ // Given the same fallback-to-account-field injection but offline (no fetchAccount).
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [
+ instructionArgumentNode({
+ name: 'amount',
+ type: numberTypeNode('u64', 'le', {
+ display: amountNumberDisplayNode({
+ decimals: injectedValueNode({
+ fallback: injectedValueNode({ key: 'mintDecimals' }),
+ key: 'decimals',
+ }),
+ }),
+ }),
+ }),
+ ],
+ name: 'transfer',
+ provides: [providedNode('mintDecimals', accountFieldValueNode({ account: 'mint', path: 'decimals' }))],
+ });
+
+ // When we resolve without fetching.
+ const consumed = await resolveConsumedMemberNames(
+ displayContext({
+ parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]], instruction }),
+ provides: new Map(instruction.provides?.map(p => [p.name, p]) ?? []),
+ }),
+ );
+
+ // Then the mint stays visible: the field could not be read.
+ expect(consumed).toEqual(new Set());
+ });
+
+ test('it terminates on a cyclic provider chain instead of recursing forever', async () => {
+ // Given `decimals` provided by re-injecting itself: a cycle the selection walk must break.
+ const instruction = instructionNode({
+ accounts: [],
+ arguments: [amountArgument()],
+ name: 'transfer',
+ provides: [providedNode('decimals', injectedValueNode({ key: 'decimals' }))],
+ });
+
+ // 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 walk terminates and nothing is consumed.
+ expect(consumed).toEqual(new Set());
+ });
});
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 e5c252be4..b68699fcd 100644
--- a/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts
+++ b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts
@@ -87,6 +87,18 @@ describe('resolveInjectedValue', () => {
expect(result).toBeNull();
});
+ test('it returns null on a cyclic injection instead of overflowing', async () => {
+ // Given `decimals` provided by re-injecting itself.
+ const node = injectedValueNode({ key: 'decimals' });
+ const provides = providesMap(providedNode('decimals', injectedValueNode({ key: 'decimals' })));
+
+ // When we resolve it.
+ const result = await resolveInjectedValue(node, context({ provides }));
+
+ // Then the cycle guard yields null rather than recursing forever.
+ expect(result).toBeNull();
+ });
+
test('it resolves an argument value node to the decoded argument', async () => {
// Given an argument value node and a decoded argument in context data.
const node = argumentValueNode('decimals');
diff --git a/packages/dynamic-instructions/test/display/resolve-injection-target.test.ts b/packages/dynamic-instructions/test/display/resolve-injection-target.test.ts
new file mode 100644
index 000000000..75adcd4ff
--- /dev/null
+++ b/packages/dynamic-instructions/test/display/resolve-injection-target.test.ts
@@ -0,0 +1,155 @@
+import {
+ accountFieldValueNode,
+ accountValueNode,
+ injectedValueNode,
+ numberValueNode,
+ type ProvidedNode,
+ providedNode,
+ stringValueNode,
+} from 'codama';
+import { describe, expect, test } from 'vitest';
+
+import { resolveInjectionTarget } from '../../src/display/resolve-injection-target';
+
+/** Builds a `provides` map from a list of `providedNode`s, keyed by the name each exposes. */
+function providesMap(...entries: ProvidedNode[]): ReadonlyMap {
+ return new Map(entries.map(entry => [entry.name, entry]));
+}
+
+describe('resolveInjectionTarget', () => {
+ test('it returns a non-injection node unchanged', () => {
+ // Given a node that is already terminal.
+ const node = accountFieldValueNode({ account: 'mint', path: 'decimals' });
+
+ // When we resolve it against any providers.
+ const target = resolveInjectionTarget(node, providesMap());
+
+ // Then the same node comes back.
+ expect(target).toBe(node);
+ });
+
+ test('it resolves an injection to its matching provider', () => {
+ // Given `decimals` provided by an account field.
+ const provider = accountFieldValueNode({ account: 'mint', path: 'decimals' });
+ const provides = providesMap(providedNode('decimals', provider));
+
+ // When we resolve the injection.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'decimals' }), provides);
+
+ // Then we reach the provider node.
+ expect(target).toBe(provider);
+ });
+
+ test('it prefers the provider over the fallback', () => {
+ // Given `decimals` has both a provider and a fallback.
+ const provider = numberValueNode(6);
+ const provides = providesMap(providedNode('decimals', provider));
+ const node = injectedValueNode({ fallback: numberValueNode(9), key: 'decimals' });
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(node, provides);
+
+ // Then the provider wins and the fallback is ignored.
+ expect(target).toBe(provider);
+ });
+
+ test('it uses the fallback when no provider supplies the key', () => {
+ // Given `decimals` has no provider but a fallback.
+ const fallback = numberValueNode(6);
+ const node = injectedValueNode({ fallback, key: 'decimals' });
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(node, providesMap());
+
+ // Then the fallback is selected.
+ expect(target).toBe(fallback);
+ });
+
+ test('it follows a fallback that injects another key', () => {
+ // Given `decimals` falls back to injecting `mintDecimals`, itself an account field.
+ const terminal = accountFieldValueNode({ account: 'mint', path: 'decimals' });
+ const provides = providesMap(providedNode('mintDecimals', terminal));
+ const node = injectedValueNode({ fallback: injectedValueNode({ key: 'mintDecimals' }), key: 'decimals' });
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(node, provides);
+
+ // Then we reach the account field through the fallback chain.
+ expect(target).toBe(terminal);
+ });
+
+ test('it follows a provider chain to its terminal', () => {
+ // Given `decimals` provided by re-injecting `mintDecimals`, itself an account field.
+ const terminal = accountFieldValueNode({ account: 'mint', path: 'decimals' });
+ const provides = providesMap(
+ providedNode('decimals', injectedValueNode({ key: 'mintDecimals' })),
+ providedNode('mintDecimals', terminal),
+ );
+
+ // When we resolve the head of the chain.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'decimals' }), provides);
+
+ // Then the chain collapses to the account field.
+ expect(target).toBe(terminal);
+ });
+
+ test('it resolves to null when the injection has neither provider nor fallback', () => {
+ // Given an unsatisfied injection.
+ const node = injectedValueNode({ key: 'decimals' });
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(node, providesMap());
+
+ // Then it is unresolved.
+ expect(target).toBeNull();
+ });
+
+ test('it terminates a self-referential provider cycle at null', () => {
+ // Given `decimals` provided by re-injecting itself.
+ const provides = providesMap(providedNode('decimals', injectedValueNode({ key: 'decimals' })));
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'decimals' }), provides);
+
+ // Then the cycle guard stops the walk and yields null.
+ expect(target).toBeNull();
+ });
+
+ test('it terminates a mutual provider cycle at null', () => {
+ // Given `a` provides `b` and `b` provides `a`.
+ const provides = providesMap(
+ providedNode('a', injectedValueNode({ key: 'b' })),
+ providedNode('b', injectedValueNode({ key: 'a' })),
+ );
+
+ // When we resolve one end.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'a' }), provides);
+
+ // Then the mutual cycle is broken and yields null.
+ expect(target).toBeNull();
+ });
+
+ test('it resolves a provider that names an account directly', () => {
+ // Given `owner` provided by an account reference (no fetch needed downstream).
+ const provider = accountValueNode('owner');
+ const provides = providesMap(providedNode('owner', provider));
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'owner' }), provides);
+
+ // Then we reach the account reference.
+ expect(target).toBe(provider);
+ });
+
+ test('it resolves a literal string provider', () => {
+ // Given a `symbol` provided by a literal.
+ const provider = stringValueNode('USDC');
+ const provides = providesMap(providedNode('symbol', provider));
+
+ // When we resolve it.
+ const target = resolveInjectionTarget(injectedValueNode({ key: 'symbol' }), provides);
+
+ // Then the literal is the terminal.
+ expect(target).toBe(provider);
+ });
+});