Skip to content

Commit 210ca10

Browse files
authored
Rewrite the instruction display on formatted nodes (#1229)
1 parent e2aeb87 commit 210ca10

31 files changed

Lines changed: 976 additions & 4398 deletions

‎packages/dynamic-instructions/README.md‎

Lines changed: 18 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -111,99 +111,41 @@ const accountMetas = await createAccountMetas({
111111

112112
## Instruction display (clear signing)
113113

114-
Given an IDL enriched with `display` metadata (per [sRFC 39](https://github.com/solana-foundation/SRFCs/discussions/4)), this package resolves a concrete instruction into human-readable text for user verification. The result carries both presentation modes and lets the renderer choose:
114+
Given an IDL enriched with `display` metadata, this package presents a concrete instruction to humans, e.g. to clear sign it. The result carries both presentation modes and lets the renderer choose:
115115

116116
```ts
117117
type InstructionDisplay = {
118-
// A short imperative label, e.g. "Transfer" (derived from the instruction name when absent).
118+
// A short imperative label, e.g. "Transfer", derived from the instruction identifier when absent.
119119
intent: string;
120-
// The interpolated sentence, e.g. "Transfer 1.5 USDC to toly.sol", or `null` when a
121-
// placeholder cannot be resolved (the renderer then falls back to `fields`).
120+
// The interpolated sentence, e.g. "Transfer 1.5 USDC to Alice", or `null` when it cannot be
121+
// presented with confidence, in which case the renderer falls back to `fields`.
122122
interpolatedIntent: string | null;
123-
// The structured fallback list of labelled fields, e.g. [{ label: 'Amount', value: '1.5 USDC' }].
123+
// The rows of the fallback list, e.g. [{ label: 'Amount', value: '1.5 USDC' }].
124124
fields: { label: string; value: string }[];
125125
};
126126
```
127127

128-
### `getInstructionDisplay(root, instruction, options?)`
129-
130-
Parses a raw `Instruction` (`@solana/instructions`) against the root and resolves its display. Returns `null` when the instruction cannot be identified or decoded (e.g. an instruction from an unknown program).
131-
132-
```ts
133-
import { getInstructionDisplay } from '@codama/dynamic-instructions';
134-
135-
const display = await getInstructionDisplay(root, instruction);
136-
// => { intent: 'Transfer', interpolatedIntent: 'Transfer 1500000 to 3Wnd5…5PxJX', fields: [...] } | null
137-
```
138-
139128
### `getInstructionDisplayFromParsedInstruction(root, parsedInstruction, options?)`
140129

141-
The same, starting from an already-parsed instruction (`ParsedInstruction` from [`@codama/dynamic-parsers`](../dynamic-parsers/README.md)). Useful when you have already called `parseInstruction`.
130+
Presents an instruction parsed with `parseInstruction` from [`@codama/dynamic-parsers`](../dynamic-parsers/README.md). Values are formatted with the formatters of [`@codama/dynamic-codecs`](../dynamic-codecs/README.md#formatting), whose options it accepts, e.g. `formatAddress` to name addresses or `numberFormat` for a locale.
142131

143132
```ts
144133
import { parseInstruction } from '@codama/dynamic-parsers';
145134
import { getInstructionDisplayFromParsedInstruction } from '@codama/dynamic-instructions';
146135

147136
const parsed = parseInstruction(root, instruction);
148-
if (parsed) {
149-
const display = await getInstructionDisplayFromParsedInstruction(root, parsed);
150-
}
151-
```
152-
153-
### Options
154-
155-
Some display values live in on-chain account state (e.g. a token's `decimals`/`symbol` injected into an amount, or interpolation paths that read an account field). Supply `fetchAccount` to resolve them; without it, such values degrade gracefully and visibly: an amount whose scale cannot be resolved renders marked as raw in the field list (e.g. `1500000 (raw)`) so it cannot be mistaken for a scaled amount, any sentence referencing it is suppressed (`interpolatedIntent` becomes `null`, falling back to the fields), and `whenInjected` members remain visible.
156-
157-
`fetchAccount` returns Kit's `MaybeEncodedAccount` — an `exists` flag plus, when the account exists, its raw bytes. No decoding is required on your side: the display layer decodes the bytes itself using the referenced account's `accountLink` from the IDL, which already describes the layout. This makes `fetchEncodedAccount` a drop-in.
158-
159-
```ts
160-
import type { Address } from '@solana/addresses';
161-
import { fetchEncodedAccount } from '@solana/accounts';
162-
163-
const display = await getInstructionDisplay(root, instruction, {
164-
// Forward Kit's MaybeEncodedAccount for an address.
165-
fetchAccount: (address: Address) => fetchEncodedAccount(rpc, address),
166-
});
167-
```
168-
169-
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.
170-
171-
## Offline display dictionary
172-
173-
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.
174-
175-
```ts
176-
type DisplayDictionary = {
177-
// Fetched on-chain account state, keyed by address (the offline counterpart of `fetchAccount`).
178-
accounts: ReadonlyMap<Address, EncodedAccount>;
179-
// Human-readable names, keyed by address — a `.sol` domain, token symbol, program label, alias…
180-
names: ReadonlyMap<Address, string>;
181-
};
182-
```
183-
184-
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.
185-
186-
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.
187-
188-
### Building the dictionary (online)
189-
190-
`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:
191-
192-
```ts
193-
import { fetchEncodedAccounts } from '@solana/accounts';
194-
import { parseInstruction } from '@codama/dynamic-parsers';
195-
import { getDisplayAccountMap, getDisplayDictionaryCodec } from '@codama/dynamic-instructions';
196-
197-
const parsed = parseInstruction(root, instruction);
198-
const accounts = await getDisplayAccountMap(root, parsed, addresses => fetchEncodedAccounts(rpc, addresses));
199-
200-
const dictionary = { accounts, names /* built from your own name sources */ };
201-
const bytes = getDisplayDictionaryCodec().encode(dictionary);
137+
const display =
138+
parsed &&
139+
getInstructionDisplayFromParsedInstruction(root, parsed, {
140+
formatAddress: address => names.get(address) ?? address,
141+
});
142+
// { intent: 'Transfer', interpolatedIntent: 'Transfer 1.5 USDC to Alice', fields: [{ label: 'Amount', value: '1.5 USDC' }, …] }
202143
```
203144

204-
> [!NOTE]
205-
> 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.
206-
207-
### Consuming the dictionary (offline)
145+
- **Rows:** one per data field, then one per account and remaining account. Optional accounts that are omitted, by leaving them out or by passing the program address, are left out, and remaining accounts are shown even when the IDL declares no groups for them. Labels come from display nodes, or from identifiers in title case. Members skipped `always` are left out, flattened structs are replaced by their prefixed fields, and lists of addresses show one numbered row per address. Other structs, lists and maps show as a single row, e.g. `Config: Fee: 25, Owner: Alice`, and empty values, e.g. an empty list or string, show as `Empty`.
146+
- **Value labels:** a field matching one of its value labels shows the label rather than its formatted value, in its row and in the sentence.
147+
- **Addresses:** every address, in public key fields, account rows, placeholders and address units, goes through the `formatAddress` option.
148+
- **Injected values:** injected values of display nodes, e.g. the `decimals` of an amount, resolve from the `provides` of the instruction and its parents: literal values, accounts, the program, or values of the instruction data.
149+
- **Degraded amounts:** an amount whose decimals cannot be resolved shows its raw value followed by `(raw)`, so it cannot be mistaken for a scaled amount, and makes the sentence `null`. So do placeholders referring to omitted accounts or empty values.
208150

209-
`fetchAccounts` (a `FetchMaybeAccountsFn`, since missing accounts are dropped) is the batch 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`.
151+
Account data is not read yet, so values read from accounts, e.g. `${accounts.mint.data.decimals}`, cannot be presented, and members skipped `whenInjected` are always shown.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ParsedInstruction } from '@codama/dynamic-parsers';
2+
import type { Address } from '@solana/addresses';
3+
import { findProgramNodeFromPath, getLastNodeFromPath, parseOptionalAccountStrategy } from 'codama';
4+
5+
/**
6+
* The address of a named account of a parsed instruction, or `undefined` when it is omitted.
7+
*
8+
* Optional accounts are omitted either by leaving them out, with the `omitted` strategy, or by
9+
* passing the address of the program, with the `programId` strategy, the default. The parser
10+
* keeps that address in the slot, so it is treated as omitted here, rather than presented as a
11+
* real account.
12+
*/
13+
export function getAccountAddress(parsed: ParsedInstruction, identifier: string): Address | undefined {
14+
const address = parsed.accounts.find(account => account.identifier === identifier)?.address;
15+
if (address === undefined) return undefined;
16+
const instruction = getLastNodeFromPath(parsed.path);
17+
const account = (instruction.accounts ?? []).find(account => account.identifier === identifier);
18+
if (!account?.isOptional || parseOptionalAccountStrategy(instruction.optionalAccountStrategy) !== 'programId') {
19+
return address;
20+
}
21+
return address === findProgramNodeFromPath(parsed.path)?.publicKey ? undefined : address;
22+
}

‎packages/dynamic-instructions/src/display/build-display-context.ts‎

Lines changed: 0 additions & 96 deletions
This file was deleted.

‎packages/dynamic-instructions/src/display/collect-injected-nodes.ts‎

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)