-
Notifications
You must be signed in to change notification settings - Fork 85
Add offline display dictionary to dynamic-instructions #1028
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lorisleiva
merged 1 commit into
main
from
07-20-add_offline_display_dictionary_to_dynamic-instructions
Aug 6, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
packages/dynamic-instructions/src/display/collect-injected-nodes.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'>; | ||
|
|
||
| /** | ||
| * 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
195
packages/dynamic-instructions/src/display/dictionary.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] : []))); | ||
|
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()); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.