diff --git a/packages/dynamic-instructions/package.json b/packages/dynamic-instructions/package.json index 7dbbf6fb6..fdb6cbe3f 100644 --- a/packages/dynamic-instructions/package.json +++ b/packages/dynamic-instructions/package.json @@ -68,7 +68,9 @@ "dependencies": { "@codama/dynamic-address-resolution": "workspace:*", "@codama/dynamic-codecs": "workspace:*", + "@codama/dynamic-parsers": "workspace:*", "@codama/errors": "workspace:*", + "@solana/accounts": "^5.3.0", "@solana/addresses": "^5.3.0", "@solana/codecs": "^5.3.0", "@solana/instructions": "^5.3.0", diff --git a/packages/dynamic-instructions/src/display/format-value.ts b/packages/dynamic-instructions/src/display/format-value.ts new file mode 100644 index 000000000..701a24687 --- /dev/null +++ b/packages/dynamic-instructions/src/display/format-value.ts @@ -0,0 +1,97 @@ +import type { + AmountNumberDisplayNode, + DateTimeNumberDisplayNode, + DurationNumberDisplayNode, + StringDisplayNode, +} from 'codama'; + +import { resolveInjectedValue } from './resolve-injected-value'; +import type { DisplayResolutionContext } from './types'; + +/** + * Formats an integer as a scaled amount with an optional unit (e.g. `1100000000` → `"1.1 SOL"`). + * + * The scale (`decimals`) is treated as correctness: when it cannot be resolved, this returns + * `null` so callers fall back to the raw value rather than display a misscaled — and therefore + * misleading — amount. The `unit` is treated as enrichment: when it cannot be resolved, the + * scaled value is returned without a suffix. + */ +export async function formatAmountValue( + value: bigint | number, + node: AmountNumberDisplayNode, + context: DisplayResolutionContext, +): Promise { + const decimals = node.decimals ? await resolveInjectedValue(node.decimals, context) : 0; + if (decimals === null || (typeof decimals !== 'bigint' && typeof decimals !== 'number')) { + return null; + } + + const scaled = scaleByDecimals(value, Number(decimals)); + if (scaled === null) return null; + + const unit = node.unit ? await resolveInjectedValue(node.unit, context) : null; + return typeof unit === 'string' && unit !== '' ? `${scaled} ${unit}` : scaled; +} + +/** + * Formats an integer counting ticks since the Unix epoch as an ISO 8601 date-time string. + * `ticksPerSecond` (default `1`) converts the raw ticks back to seconds. + */ +export function formatDateTimeValue(value: bigint | number, node: DateTimeNumberDisplayNode): string | null { + const seconds = toSeconds(value, node.ticksPerSecond); + if (seconds === null) return null; + const date = new Date(seconds * 1000); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +/** + * Formats an integer counting ticks as an elapsed duration in `HH:mm:ss`. + * `ticksPerSecond` (default `1`) converts the raw ticks back to seconds. + */ +export function formatDurationValue(value: bigint | number, node: DurationNumberDisplayNode): string | null { + const totalSeconds = toSeconds(value, node.ticksPerSecond); + if (totalSeconds === null || totalSeconds < 0) return null; + + const whole = Math.floor(totalSeconds); + const hours = Math.floor(whole / 3600); + const minutes = Math.floor((whole % 3600) / 60); + const seconds = whole % 60; + return [hours, minutes, seconds].map(part => part.toString().padStart(2, '0')).join(':'); +} + +/** + * Presents a string by slicing it to the `[sliceStart, sliceEnd)` range of decoded characters. + * Both bounds are optional and default to the start and end of the string respectively. + */ +export function formatStringValue(value: string, node: StringDisplayNode): string { + if (node.sliceStart === undefined && node.sliceEnd === undefined) return value; + return value.slice(node.sliceStart ?? 0, node.sliceEnd); +} + +/** + * Divides an integer value by `10 ^ decimals`, trimming trailing zeros from the fractional part. + * Returns `null` for a non-integer value (which cannot be scaled as a fixed-point integer) or for + * negative decimals (which cannot describe a fixed-point scale). + */ +function scaleByDecimals(value: bigint | number, decimals: number): string | null { + if (typeof value === 'number' && !Number.isInteger(value)) return null; + if (!Number.isInteger(decimals) || decimals < 0) return null; + if (decimals === 0) return value.toString(); + + const negative = value < 0; + const digits = (negative ? -BigInt(value) : BigInt(value)).toString().padStart(decimals + 1, '0'); + const integerPart = digits.slice(0, digits.length - decimals); + const fractionPart = digits.slice(digits.length - decimals).replace(/0+$/, ''); + const sign = negative ? '-' : ''; + return fractionPart ? `${sign}${integerPart}.${fractionPart}` : `${sign}${integerPart}`; +} + +/** Converts a tick value into whole/fractional seconds using `ticksPerSecond` (default `1`). */ +function toSeconds(value: bigint | number, ticksPerSecond: number | undefined): number | null { + const ticks = Number(value); + if (!Number.isFinite(ticks)) return null; + const divisor = ticksPerSecond ?? 1; + if (divisor <= 0) return null; + return ticks / divisor; +} diff --git a/packages/dynamic-instructions/src/display/index.ts b/packages/dynamic-instructions/src/display/index.ts new file mode 100644 index 000000000..38b13400f --- /dev/null +++ b/packages/dynamic-instructions/src/display/index.ts @@ -0,0 +1,3 @@ +export * from './format-value'; +export * from './resolve-injected-value'; +export * from './types'; diff --git a/packages/dynamic-instructions/src/display/resolve-injected-value.ts b/packages/dynamic-instructions/src/display/resolve-injected-value.ts new file mode 100644 index 000000000..1a1095cc8 --- /dev/null +++ b/packages/dynamic-instructions/src/display/resolve-injected-value.ts @@ -0,0 +1,96 @@ +import type { Address } from '@solana/addresses'; +import { isNode, type Node } from 'codama'; + +import type { DisplayResolutionContext } from './types'; + +/** + * A value resolved from the provide/inject graph for display purposes. + * + * `number` and `string` come from literal value nodes or account fields; `Address` comes from + * an `accountValueNode` reference. `null` means the value could not be resolved (no provider, + * no fallback, or missing account data) and the caller should degrade gracefully. + */ +export type ResolvedDisplayValue = Address | bigint | number | string | null; + +/** + * Resolves a node to a concrete display value within a {@link DisplayResolutionContext}. + * + * 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`. + * - `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 + * `fetchAccount`, then its bytes decoded against the account's `accountLink` via + * `resolveAccountData`. + * + * Returns `null` when the value cannot be resolved so callers can fall back safely. + */ +export async function resolveInjectedValue( + node: Node, + context: DisplayResolutionContext, +): Promise { + if (isNode(node, 'numberValueNode')) { + return node.number; + } + + if (isNode(node, 'stringValueNode')) { + return node.string; + } + + 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; + } + + if (isNode(node, 'argumentValueNode')) { + const data = context.parsedInstruction.data as Record; + return toResolvedValue(data[node.name]); + } + + if (isNode(node, 'accountValueNode')) { + return findAccountAddress(context, node.name); + } + + if (isNode(node, 'accountFieldValueNode')) { + const address = findAccountAddress(context, node.account); + if (!address || !context.fetchAccount) return null; + const account = await context.fetchAccount(address); + if (!account.exists) return null; + const data = context.resolveAccountData(node.account, account.data); + return readAccountField(data, node.path); + } + + return null; +} + +/** Finds the concrete address of a named account of the surrounding instruction, or `null`. */ +function findAccountAddress(context: DisplayResolutionContext, name: string): Address | null { + return context.parsedInstruction.accounts.find(account => account.name === name)?.address ?? null; +} + +/** + * Reads a named field from a decoded account's data. + * A path is required: the whole decoded struct is not a single displayable scalar, so a + * path-less reference yields `null`. Also returns `null` when the data could not be decoded, or + * when the field is absent or not a primitive value. + */ +function readAccountField(data: Record | null, path: string | undefined): ResolvedDisplayValue { + if (data === null || path === undefined) return null; + return toResolvedValue(data[path]); +} + +/** Narrows an unknown decoded value to the primitive shapes the display layer can render. */ +function toResolvedValue(value: unknown): ResolvedDisplayValue { + if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string') { + return value; + } + return null; +} diff --git a/packages/dynamic-instructions/src/display/types.ts b/packages/dynamic-instructions/src/display/types.ts new file mode 100644 index 000000000..2039533b7 --- /dev/null +++ b/packages/dynamic-instructions/src/display/types.ts @@ -0,0 +1,52 @@ +import type { ParsedInstruction } from '@codama/dynamic-parsers'; +import type { MaybeEncodedAccount } from '@solana/accounts'; +import type { Address } from '@solana/addresses'; +import type { ReadonlyUint8Array } from '@solana/codecs'; +import type { ProvidedNode } from 'codama'; + +/** + * Fetches the on-chain account at a given address, returning Kit's `MaybeEncodedAccount` — an + * `exists` flag plus, when the account exists, the raw account bytes and its on-chain metadata. + * + * Consumers simply forward what an RPC returns (e.g. `fetchEncodedAccount`) — no decoding required. + * The display layer decodes the bytes itself using the referenced account's `accountLink` from the + * IDL. Required to resolve display values that live in account state — e.g. a token's + * `decimals`/`symbol` injected via the provide/inject pattern. When omitted, such values fall back + * to their declared `fallback` (when present) or are treated as unresolvable. + * + * The eventual offline / hardware-wallet path can back this callback with a pre-fetched metadata + * bundle instead of live RPC. + */ +export type FetchAccountFn = (address: Address) => Promise; + +/** + * Decodes the raw bytes of a named instruction account against its `accountLink` layout, returning + * the decoded record or `null` when the account carries no link or cannot be decoded. + * + * The IDL already describes how to decode a linked account, so decoding is done for the consumer + * rather than asked of them. The orchestrator backs this with a `LinkableDictionary` populated from + * the root. + */ +export type ResolveAccountDataFn = (accountName: string, bytes: ReadonlyUint8Array) => Record | null; + +/** + * The contextual environment in which display values are resolved. + * + * Mirrors the provide/inject pattern: an instruction (or other host) exposes named values + * through `provides`, and reusable types pull them by key via `injectedValueNode`. Resolving + * those keys reads from the surrounding `parsedInstruction` (its decoded arguments and account + * addresses) and, for account state, the `fetchAccount` hook plus `resolveAccountData` decoder. + * + * The orchestrator assembles this from a parsed instruction; it is kept explicit here so the + * resolution and formatting layers can be exercised in isolation. + */ +export type DisplayResolutionContext = { + /** Fetches the on-chain account at an address; absent when running fully offline. */ + readonly fetchAccount?: FetchAccountFn; + /** The parsed instruction being presented: its decoded arguments, node path, and account metas. */ + readonly parsedInstruction: ParsedInstruction; + /** Values exposed by the surrounding host, keyed by the name they are provided under. */ + readonly provides: ReadonlyMap; + /** Decodes a named account's raw bytes against its `accountLink` layout. */ + readonly resolveAccountData: ResolveAccountDataFn; +}; diff --git a/packages/dynamic-instructions/test/display/format-value.test.ts b/packages/dynamic-instructions/test/display/format-value.test.ts new file mode 100644 index 000000000..c00a2271a --- /dev/null +++ b/packages/dynamic-instructions/test/display/format-value.test.ts @@ -0,0 +1,196 @@ +import { + amountNumberDisplayNode, + dateTimeNumberDisplayNode, + durationNumberDisplayNode, + injectedValueNode, + numberValueNode, + type ProvidedNode, + providedNode, + stringDisplayNode, + stringValueNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { + formatAmountValue, + formatDateTimeValue, + formatDurationValue, + formatStringValue, +} from '../../src/display/format-value'; +import type { DisplayResolutionContext } from '../../src/display/types'; +import { parsedInstruction } from '../test-utils'; + +function context(overrides: Partial = {}): DisplayResolutionContext { + return { + parsedInstruction: parsedInstruction(), + provides: new Map(), + resolveAccountData: () => null, + ...overrides, + }; +} + +describe('formatAmountValue', () => { + test('it scales an amount by literal decimals and appends a literal unit', async () => { + // Given an amount display with literal decimals and unit. + const node = amountNumberDisplayNode({ decimals: numberValueNode(9), unit: stringValueNode('SOL') }); + + // When we format a raw integer amount. + const result = await formatAmountValue(1_100_000_000n, node, context()); + + // Then we expect the scaled value with the unit. + expect(result).toBe('1.1 SOL'); + }); + + test('it scales an amount without a unit when none is provided', async () => { + // Given an amount display with decimals only. + const node = amountNumberDisplayNode({ decimals: numberValueNode(6) }); + + // When we format a raw integer amount. + const result = await formatAmountValue(1_500_000n, node, context()); + + // Then we expect the scaled value with no suffix. + expect(result).toBe('1.5'); + }); + + test('it trims trailing fractional zeros when scaling', async () => { + // Given an amount display with decimals. + const node = amountNumberDisplayNode({ decimals: numberValueNode(9) }); + + // When we format an amount that scales to a whole number. + const result = await formatAmountValue(2_000_000_000n, node, context()); + + // Then we expect no fractional part. + expect(result).toBe('2'); + }); + + test('it resolves injected decimals from the provide/inject context', async () => { + // Given an amount whose decimals are injected. + const node = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + const provides = new Map([['decimals', providedNode('decimals', numberValueNode(6))]]); + + // When we format the amount. + const result = await formatAmountValue(1_000_000n, node, context({ provides })); + + // Then we expect the value scaled by the injected decimals. + expect(result).toBe('1'); + }); + + test('it returns null when decimals cannot be resolved (correctness over enrichment)', async () => { + // Given an amount whose injected decimals have no provider and no fallback. + const node = amountNumberDisplayNode({ decimals: injectedValueNode({ key: 'decimals' }) }); + + // When we format the amount. + const result = await formatAmountValue(1_000_000n, node, context()); + + // Then we expect null so the caller can fall back to the raw value. + 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({ + decimals: numberValueNode(6), + unit: injectedValueNode({ key: 'symbol' }), + }); + + // When we format the amount. + const result = await formatAmountValue(2_500_000n, node, context()); + + // Then we expect the scaled value with no unit. + expect(result).toBe('2.5'); + }); + + test('it treats an absent decimals node as no scaling', async () => { + // Given an amount display with neither decimals nor unit. + const node = amountNumberDisplayNode({}); + + // When we format a raw integer amount. + const result = await formatAmountValue(42n, node, context()); + + // Then we expect the raw value as a string. + expect(result).toBe('42'); + }); + + test('it returns null for a non-integer value rather than throwing', async () => { + // Given an amount display and a non-integer value that cannot be scaled as a fixed-point integer. + const node = amountNumberDisplayNode({ decimals: numberValueNode(2) }); + + // When we format the amount. + const result = await formatAmountValue(1.5, node, context()); + + // Then we expect null so the caller falls back to the raw value. + expect(result).toBeNull(); + }); +}); + +describe('formatDateTimeValue', () => { + test('it formats a seconds timestamp as an ISO 8601 string', () => { + // Given a date-time display in seconds. + const node = dateTimeNumberDisplayNode({}); + + // When we format a Unix timestamp in seconds. + const result = formatDateTimeValue(1_761_365_183, node); + + // Then we expect the ISO 8601 representation. + expect(result).toBe('2025-10-25T04:06:23.000Z'); + }); + + test('it converts ticks to seconds using ticksPerSecond', () => { + // Given a date-time display in milliseconds. + const node = dateTimeNumberDisplayNode({ ticksPerSecond: 1000 }); + + // When we format a millisecond timestamp. + const result = formatDateTimeValue(1_761_365_183_000n, node); + + // Then we expect the same instant as the seconds case. + expect(result).toBe('2025-10-25T04:06:23.000Z'); + }); +}); + +describe('formatDurationValue', () => { + test('it formats a duration as HH:mm:ss', () => { + // Given a duration display in seconds. + const node = durationNumberDisplayNode({}); + + // When we format a one-hour duration. + const result = formatDurationValue(3600, node); + + // Then we expect the padded HH:mm:ss form. + expect(result).toBe('01:00:00'); + }); + + test('it converts duration ticks to seconds using ticksPerSecond', () => { + // Given a duration display in milliseconds. + const node = durationNumberDisplayNode({ ticksPerSecond: 1000 }); + + // When we format a duration of 90 seconds expressed in milliseconds. + const result = formatDurationValue(90_000n, node); + + // Then we expect one minute and thirty seconds. + expect(result).toBe('00:01:30'); + }); +}); + +describe('formatStringValue', () => { + test('it returns the whole string when no slice bounds are set', () => { + // Given a string display with no bounds. + const node = stringDisplayNode({}); + + // When we format a string. + const result = formatStringValue('SOLANA', node); + + // Then we expect the unchanged string. + expect(result).toBe('SOLANA'); + }); + + test('it slices a string by the given bounds', () => { + // Given a string display with a slice range. + const node = stringDisplayNode({ sliceEnd: 3, sliceStart: 0 }); + + // When we format a string. + const result = formatStringValue('SOLANA', node); + + // Then we expect the sliced substring. + expect(result).toBe('SOL'); + }); +}); diff --git a/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts new file mode 100644 index 000000000..a31550a24 --- /dev/null +++ b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts @@ -0,0 +1,270 @@ +import type { Address } from '@solana/addresses'; +import { + accountFieldValueNode, + accountNode, + accountValueNode, + argumentValueNode, + injectedValueNode, + numberTypeNode, + numberValueNode, + type ProvidedNode, + providedNode, + stringValueNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { resolveInjectedValue } from '../../src/display/resolve-injected-value'; +import type { DisplayResolutionContext } from '../../src/display/types'; +import { accountFixture, parsedInstruction } from '../test-utils'; + +const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +/** A `mint` account whose data is a single `decimals: u8` field. */ +const MINT_ACCOUNT = accountNode({ + data: structTypeNode([structFieldTypeNode({ name: 'decimals', type: numberTypeNode('u8') })]), + name: 'mint', +}); + +function context(overrides: Partial = {}): DisplayResolutionContext { + return { + parsedInstruction: parsedInstruction(), + provides: new Map(), + resolveAccountData: () => null, + ...overrides, + }; +} + +function providesMap(...entries: ProvidedNode[]): ReadonlyMap { + return new Map(entries.map(entry => [entry.name, entry])); +} + +describe('resolveInjectedValue', () => { + test('it resolves a literal number value node', async () => { + // Given a literal number value node. + const node = numberValueNode(6); + + // When we resolve it. + const result = await resolveInjectedValue(node, context()); + + // Then we expect the number. + expect(result).toBe(6); + }); + + test('it resolves a literal string value node', async () => { + // Given a literal string value node. + const node = stringValueNode('USDC'); + + // When we resolve it. + const result = await resolveInjectedValue(node, context()); + + // Then we expect the string. + expect(result).toBe('USDC'); + }); + + test('it resolves an injected value from a matching provider', async () => { + // Given an injected value whose key is provided as a literal. + const node = injectedValueNode({ key: 'decimals' }); + const provides = providesMap(providedNode('decimals', numberValueNode(9))); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ provides })); + + // Then we expect the provided value. + expect(result).toBe(9); + }); + + test('it falls back to the injection fallback when no provider supplies the key', async () => { + // Given an injected value with a fallback and no provider. + const node = injectedValueNode({ fallback: numberValueNode(0), key: 'decimals' }); + + // When we resolve it. + const result = await resolveInjectedValue(node, context()); + + // Then we expect the fallback value. + expect(result).toBe(0); + }); + + test('it returns null when an injected value has neither provider nor fallback', async () => { + // Given an injected value with no provider and no fallback. + const node = injectedValueNode({ key: 'decimals' }); + + // When we resolve it. + const result = await resolveInjectedValue(node, context()); + + // Then we expect null. + 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'); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ parsedInstruction: parsedInstruction({ data: { decimals: 6 } }) }), + ); + + // Then we expect the argument value. + expect(result).toBe(6); + }); + + test('it resolves an injected value provided by an argument value node', async () => { + // Given an injected key provided by an argument value node. + const node = injectedValueNode({ key: 'decimals' }); + const provides = providesMap(providedNode('decimals', argumentValueNode('decimals'))); + + // When we resolve it (the decoded argument is present in context data). + const result = await resolveInjectedValue( + node, + context({ parsedInstruction: parsedInstruction({ data: { decimals: 6 } }), provides }), + ); + + // Then we expect the argument value. + expect(result).toBe(6); + }); + + test('it resolves an account value node to the account address', async () => { + // Given an account value node and a known account address. + const node = accountValueNode('mint'); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }) }), + ); + + // Then we expect the address. + expect(result).toBe(MINT); + }); + + test('it resolves an account field value node by decoding fetched bytes via its accountLink', async () => { + // Given a `mint` account whose bytes decode (via its codec) to `{ decimals: 6 }`. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const { encoded, resolveAccountData } = accountFixture(MINT_ACCOUNT, { decimals: 6 }); + + // When we resolve it, fetching raw bytes and decoding them ourselves. + const result = await resolveInjectedValue( + node, + context({ + fetchAccount: () => Promise.resolve(encoded), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }), + resolveAccountData, + }), + ); + + // Then we expect the decoded field value. + expect(result).toBe(6); + }); + + test('it returns null for an account field value node with no path (whole struct is not a scalar)', async () => { + // Given an account field value node without a path. + const node = accountFieldValueNode({ account: 'mint' }); + const { encoded, resolveAccountData } = accountFixture(MINT_ACCOUNT, { decimals: 6 }); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ + fetchAccount: () => Promise.resolve(encoded), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }), + resolveAccountData, + }), + ); + + // Then we expect null since the whole decoded struct is not a single displayable value. + expect(result).toBeNull(); + }); + + test('it returns null for an account field value node when no fetchAccount is provided', async () => { + // Given an account field value node but no fetch hook. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }) }), + ); + + // Then we expect null. + expect(result).toBeNull(); + }); + + test('it returns null for an account field value node when the account is unknown', async () => { + // Given an account field value node referencing an account with no known address. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const { encoded, resolveAccountData } = accountFixture(MINT_ACCOUNT, { decimals: 6 }); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ fetchAccount: () => Promise.resolve(encoded), resolveAccountData }), + ); + + // Then we expect null. + expect(result).toBeNull(); + }); + + test('it returns null for an account field value node when the fetched account does not exist', async () => { + // Given a known mint address whose account is reported as non-existent on-chain. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const { resolveAccountData } = accountFixture(MINT_ACCOUNT, { decimals: 6 }); + + // When we resolve it with a fetch hook that returns a non-existent account. + const result = await resolveInjectedValue( + node, + context({ + fetchAccount: () => Promise.resolve({ address: MINT, exists: false }), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }), + resolveAccountData, + }), + ); + + // Then we expect null since there are no bytes to decode. + expect(result).toBeNull(); + }); + + test('it returns null for an account field value node when the account carries no link to decode', async () => { + // Given an account whose bytes cannot be decoded (no accountLink resolver). + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const { encoded } = accountFixture(MINT_ACCOUNT, { decimals: 6 }); + + // When we resolve it with a resolveAccountData that cannot decode the account. + const result = await resolveInjectedValue( + node, + context({ + fetchAccount: () => Promise.resolve(encoded), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }), + resolveAccountData: () => null, + }), + ); + + // Then we expect null. + expect(result).toBeNull(); + }); + + test('it resolves an injected value indirectly through an account field provider', async () => { + // Given an injected key provided by an account field value node. + const node = injectedValueNode({ key: 'decimals' }); + const provides = providesMap( + providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })), + ); + const { encoded, resolveAccountData } = accountFixture(MINT_ACCOUNT, { decimals: 8 }); + + // When we resolve it. + const result = await resolveInjectedValue( + node, + context({ + fetchAccount: () => Promise.resolve(encoded), + parsedInstruction: parsedInstruction({ accounts: [['mint', MINT]] }), + provides, + resolveAccountData, + }), + ); + + // Then we expect the account field value. + expect(result).toBe(8); + }); +}); diff --git a/packages/dynamic-instructions/test/test-utils.ts b/packages/dynamic-instructions/test/test-utils.ts index 88ca4bfa2..6dd1e3180 100644 --- a/packages/dynamic-instructions/test/test-utils.ts +++ b/packages/dynamic-instructions/test/test-utils.ts @@ -1,15 +1,90 @@ +import { getNodeCodec, type ReadonlyUint8Array } from '@codama/dynamic-codecs'; +import type { ParsedInstruction } from '@codama/dynamic-parsers'; +import type { EncodedAccount, MaybeEncodedAccount } from '@solana/accounts'; import type { Address } from '@solana/addresses'; +import { AccountRole } from '@solana/instructions'; import { generateKeyPairSigner } from '@solana/kit'; -import { instructionNode, programNode, rootNode } from 'codama'; +import { + type AccountNode, + camelCase, + type InstructionNode, + instructionNode, + type NodePath, + programNode, + rootNode, +} from 'codama'; + +import type { ResolveAccountDataFn } from '../src/display/types'; export async function generateAddress(): Promise
{ const signer = await generateKeyPairSigner(); return signer.address; } -export function makeRoot(instructions: ReturnType[], name = 'testProgram') { +const FIXTURE_ADDRESS = '11111111111111111111111111111111' as Address; + +/** Wraps raw account bytes in a Kit {@link MaybeEncodedAccount} that exists, filling its metadata. */ +export function encodedAccount(bytes: ReadonlyUint8Array): MaybeEncodedAccount { + return { + address: FIXTURE_ADDRESS, + data: bytes as Uint8Array, + executable: false, + exists: true, + lamports: 0n as EncodedAccount['lamports'], + programAddress: FIXTURE_ADDRESS, + space: BigInt(bytes.length), + }; +} + +/** + * Encodes an account's data with its real Codama codec, returning the encoded account and a + * matching {@link ResolveAccountDataFn} that decodes its bytes — exercising the actual `accountLink` + * decoding path rather than hand-rolled data. + */ +export function accountFixture( + account: AccountNode, + value: Record, + accountName = account.name, +): { encoded: MaybeEncodedAccount; resolveAccountData: ResolveAccountDataFn } { + const root = makeRoot([], 'testProgram', [account]); + const codec = getNodeCodec([root, root.program, account] as NodePath); + const bytes = codec.encode(value); + return { + encoded: encodedAccount(bytes), + resolveAccountData: (name, data) => + name === camelCase(accountName) ? (codec.decode(data) as Record) : null, + }; +} + +/** + * Builds a {@link ParsedInstruction} fixture from decoded argument data and named account + * addresses. The `path` is a stub: helpers that only read `data`/`accounts` never consult it. + */ +export function parsedInstruction( + overrides: { + accounts?: ReadonlyArray; + data?: Record; + } = {}, +): ParsedInstruction { + return { + accounts: (overrides.accounts ?? []).map(([name, address]) => ({ + address, + name: camelCase(name), + role: AccountRole.READONLY, + })), + data: overrides.data ?? {}, + path: [] as unknown as NodePath, + }; +} + +export function makeRoot( + instructions: ReturnType[], + name = 'testProgram', + accounts: AccountNode[] = [], +) { return rootNode( programNode({ + accounts, instructions, name, publicKey: '11111111111111111111111111111111', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ffe0c13..cbf1396b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,9 +188,15 @@ importers: '@codama/dynamic-codecs': specifier: workspace:* version: link:../dynamic-codecs + '@codama/dynamic-parsers': + specifier: workspace:* + version: link:../dynamic-parsers '@codama/errors': specifier: workspace:* version: link:../errors + '@solana/accounts': + specifier: ^5.3.0 + version: 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@solana/addresses': specifier: ^5.3.0 version: 5.5.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)