Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dynamic-instructions-display-dictionary.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions packages/dynamic-instructions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address, EncodedAccount>;
// Human-readable names, keyed by address — a `.sol` domain, token symbol, program label, alias…
names: ReadonlyMap<Address, string>;
};
```

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`.
18 changes: 15 additions & 3 deletions packages/dynamic-instructions/src/display/build-display-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export async function buildDisplayContext(
parsedInstruction: ParsedInstruction,
options: GetInstructionDisplayOptions = {},
): Promise<DisplayContext> {
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<DisplayContext, 'consumedMemberNames'> {
const instruction = getLastNodeFromPath(parsedInstruction.path);

const provides = new Map<string, ProvidedNode>(
Expand All @@ -43,15 +57,13 @@ export async function buildDisplayContext(
const linkables = new LinkableDictionary();
visit(root, getRecordLinkablesVisitor(linkables));

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

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

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DisplayContext, 'consumedMemberNames'>;
Comment thread
lorisleiva marked this conversation as resolved.

/**
* 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');
}
195 changes: 195 additions & 0 deletions packages/dynamic-instructions/src/display/dictionary.ts
Original file line number Diff line number Diff line change
@@ -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<Address, EncodedAccount>;

/**
* 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<Address, string>;

/**
* 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<MaybeEncodedAccount[]>;

/**
* 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<DisplayAccountMap> {
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] : [])));
Comment thread
lorisleiva marked this conversation as resolved.
}

// An `EncodedAccount` minus its address, which is carried as the map key.
type AccountBody = Omit<EncodedAccount, 'address'>;

const accountBodyEncoder = (): Encoder<AccountBody> =>
getStructEncoder([
['data', addEncoderSizePrefix(getBytesEncoder(), getU32Encoder())],
['executable', getBooleanEncoder()],
['lamports', getU64Encoder()],
['programAddress', getAddressEncoder()],
['space', getU64Encoder()],
]);
const accountBodyDecoder = (): Decoder<AccountBody> =>
getStructDecoder([
['data', addDecoderSizePrefix(getBytesDecoder(), getU32Decoder())],
['executable', getBooleanDecoder()],
['lamports', getU64Decoder() as Decoder<EncodedAccount['lamports']>],
['programAddress', getAddressDecoder()],
['space', getU64Decoder()],
]);

/** Encoder for a {@link DisplayAccountMap}, keyed by address with the account body as the value. */
export function getDisplayAccountMapEncoder(): Encoder<DisplayAccountMap> {
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<DisplayAccountMap> {
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<DisplayAccountMap> {
return combineCodec(getDisplayAccountMapEncoder(), getDisplayAccountMapDecoder());
}

/** Encoder for a {@link DisplayNamedMap}, keyed by address with a length-prefixed UTF-8 name. */
export function getDisplayNamedMapEncoder(): Encoder<DisplayNamedMap> {
return transformEncoder(
getMapEncoder(getAddressEncoder(), addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder()), {
size: getU32Encoder(),
}),
(map: DisplayNamedMap) => new Map(map),
);
}

/** Decoder for a {@link DisplayNamedMap}. */
export function getDisplayNamedMapDecoder(): Decoder<DisplayNamedMap> {
return getMapDecoder(getAddressDecoder(), addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder()), {
size: getU32Decoder(),
});
}

/** Codec for a {@link DisplayNamedMap}. */
export function getDisplayNamedMapCodec(): Codec<DisplayNamedMap> {
return combineCodec(getDisplayNamedMapEncoder(), getDisplayNamedMapDecoder());
}

/** Encoder for a {@link DisplayDictionary}, composed from the two map encoders. */
export function getDisplayDictionaryEncoder(): Encoder<DisplayDictionary> {
return getStructEncoder([
['accounts', getDisplayAccountMapEncoder()],
['names', getDisplayNamedMapEncoder()],
]);
}

/** Decoder for a {@link DisplayDictionary}, composed from the two map decoders. */
export function getDisplayDictionaryDecoder(): Decoder<DisplayDictionary> {
return getStructDecoder([
['accounts', getDisplayAccountMapDecoder()],
['names', getDisplayNamedMapDecoder()],
]);
}

/** Codec for a {@link DisplayDictionary}. */
export function getDisplayDictionaryCodec(): Codec<DisplayDictionary> {
return combineCodec(getDisplayDictionaryEncoder(), getDisplayDictionaryDecoder());
}
1 change: 1 addition & 0 deletions packages/dynamic-instructions/src/display/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading