From 7bb6f3ff23b1febc7bd76d261c45ba0ff2432d50 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Wed, 24 Jun 2026 12:41:08 +0100 Subject: [PATCH] Add display value formatting and provide/inject resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR begins the clear-signing display-text feature (sRFC 39) with its value layer. It adds resolveInjectedValue, which resolves the provide/inject graph — literal values, injected keys (with fallback), account references, and account fields read via an optional fetchAccountData hook returning Kit's decoded Account — within an explicit DisplayResolutionContext. On top of it, it adds the per-display-node value formatters: scaled token amounts (treating decimals as correctness, so an unresolvable scale yields no output rather than a misleading value, while an unresolvable unit merely drops the suffix), ISO 8601 date-times, HH:mm:ss durations, and sliced strings. These are internal building blocks and are not yet exported from the package; the resolution context is kept explicit so the value layer can be exercised in isolation ahead of the interpolation, fallback-list, and orchestration PRs. No changeset is included, consistent with the rest of the 1.7.0 stack. --- packages/dynamic-instructions/package.json | 1 + .../src/display/format-value.ts | 95 +++++++++ .../dynamic-instructions/src/display/index.ts | 3 + .../src/display/resolve-injected-value.ts | 86 ++++++++ .../dynamic-instructions/src/display/types.ts | 37 ++++ .../test/display/format-value.test.ts | 184 ++++++++++++++++++ .../display/resolve-injected-value.test.ts | 177 +++++++++++++++++ pnpm-lock.yaml | 3 + 8 files changed, 586 insertions(+) create mode 100644 packages/dynamic-instructions/src/display/format-value.ts create mode 100644 packages/dynamic-instructions/src/display/index.ts create mode 100644 packages/dynamic-instructions/src/display/resolve-injected-value.ts create mode 100644 packages/dynamic-instructions/src/display/types.ts create mode 100644 packages/dynamic-instructions/test/display/format-value.test.ts create mode 100644 packages/dynamic-instructions/test/display/resolve-injected-value.test.ts diff --git a/packages/dynamic-instructions/package.json b/packages/dynamic-instructions/package.json index 7dbbf6fb6..9b3dd2a6f 100644 --- a/packages/dynamic-instructions/package.json +++ b/packages/dynamic-instructions/package.json @@ -69,6 +69,7 @@ "@codama/dynamic-address-resolution": "workspace:*", "@codama/dynamic-codecs": "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..b7ea0e9fc --- /dev/null +++ b/packages/dynamic-instructions/src/display/format-value.ts @@ -0,0 +1,95 @@ +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 negative decimals, which cannot describe a fixed-point scale. + */ +function scaleByDecimals(value: bigint | number, decimals: number): string | 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..8ca971ad8 --- /dev/null +++ b/packages/dynamic-instructions/src/display/resolve-injected-value.ts @@ -0,0 +1,86 @@ +import type { Account } from '@solana/accounts'; +import type { Address } from '@solana/addresses'; +import { isNode, type Node } from 'codama'; + +import { isObjectRecord } from '../shared/util'; +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`. + * - `accountValueNode`: the referenced account's address. + * - `accountFieldValueNode`: a field of the referenced account's decoded data, fetched via + * `fetchAccountData`. + * + * 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, 'accountValueNode')) { + return context.accountAddresses.get(node.name) ?? null; + } + + if (isNode(node, 'accountFieldValueNode')) { + const address = context.accountAddresses.get(node.account); + if (!address || !context.fetchAccountData) return null; + const account = await context.fetchAccountData(address); + if (!account) return null; + return readAccountField(account, node.path); + } + + return 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 is not a record, or + * when the field is absent or not a primitive value. + */ +function readAccountField(account: Account, path: string | undefined): ResolvedDisplayValue { + if (path === undefined) return null; + if (!isObjectRecord(account.data)) return null; + return toResolvedValue(account.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..b9fe33223 --- /dev/null +++ b/packages/dynamic-instructions/src/display/types.ts @@ -0,0 +1,37 @@ +import type { Account } from '@solana/accounts'; +import type { Address } from '@solana/addresses'; +import type { ProvidedNode } from 'codama'; + +/** + * Fetches and decodes the account at a given address, returning Kit's decoded `Account` + * (whose `data` is the decoded layout) or `null` when the account does not exist. + * + * Required to resolve display values that live in account state — e.g. a token's + * `decimals`/`symbol` injected via the provide/inject pattern, or interpolation paths + * such as `${accounts.destination.data.owner}`. 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 FetchAccountDataFn = (address: Address) => Promise | 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 may also require reading account state, so the context carries the addresses of + * the surrounding instruction's accounts plus the `fetchAccountData` hook. + * + * 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 = { + /** Addresses of the surrounding instruction's accounts, keyed by account name. */ + readonly accountAddresses: ReadonlyMap; + /** Fetches and decodes account data; absent when running fully offline. */ + readonly fetchAccountData?: FetchAccountDataFn; + /** Values exposed by the surrounding host, keyed by the name they are provided under. */ + readonly provides: ReadonlyMap; +}; 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..822a2f05a --- /dev/null +++ b/packages/dynamic-instructions/test/display/format-value.test.ts @@ -0,0 +1,184 @@ +import type { Address } from '@solana/addresses'; +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'; + +function context(overrides: Partial = {}): DisplayResolutionContext { + return { + accountAddresses: new Map(), + provides: new Map(), + ...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'); + }); +}); + +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..830da7fab --- /dev/null +++ b/packages/dynamic-instructions/test/display/resolve-injected-value.test.ts @@ -0,0 +1,177 @@ +import type { Account } from '@solana/accounts'; +import type { Address } from '@solana/addresses'; +import { + accountFieldValueNode, + accountValueNode, + injectedValueNode, + numberValueNode, + type ProvidedNode, + providedNode, + stringValueNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { resolveInjectedValue } from '../../src/display/resolve-injected-value'; +import type { DisplayResolutionContext, FetchAccountDataFn } from '../../src/display/types'; + +const MINT = '86xCnPeV69n6t3DnyGvkKobf9FdN2H9oiVDdaMpo2MMY' as Address; + +function context(overrides: Partial = {}): DisplayResolutionContext { + return { + accountAddresses: new Map(), + provides: new Map(), + ...overrides, + }; +} + +function providesMap(...entries: ProvidedNode[]): ReadonlyMap { + return new Map(entries.map(entry => [entry.name, entry])); +} + +/** Builds a decoded Kit `Account` carrying the given data, filling the on-chain metadata. */ +function decodedAccount(data: object): Account { + return { + address: MINT, + data, + executable: false, + lamports: 0n as Account['lamports'], + programAddress: MINT, + space: 0n, + }; +} + +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 account value node to the account address', async () => { + // Given an account value node and a known account address. + const node = accountValueNode('mint'); + const accountAddresses = new Map([['mint', MINT]]); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ accountAddresses })); + + // Then we expect the address. + expect(result).toBe(MINT); + }); + + test('it resolves an account field value node via fetchAccountData', async () => { + // Given an account field value node and account data containing the field. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const accountAddresses = new Map([['mint', MINT]]); + const fetchAccountData: FetchAccountDataFn = () => Promise.resolve(decodedAccount({ decimals: 6 })); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ accountAddresses, fetchAccountData })); + + // Then we expect the 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 accountAddresses = new Map([['mint', MINT]]); + const fetchAccountData: FetchAccountDataFn = () => Promise.resolve(decodedAccount({ decimals: 6 })); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ accountAddresses, fetchAccountData })); + + // 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 fetchAccountData is provided', async () => { + // Given an account field value node but no fetch hook. + const node = accountFieldValueNode({ account: 'mint', path: 'decimals' }); + const accountAddresses = new Map([['mint', MINT]]); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ accountAddresses })); + + // 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 fetchAccountData: FetchAccountDataFn = () => Promise.resolve(decodedAccount({ decimals: 6 })); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ fetchAccountData })); + + // 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 accountAddresses = new Map([['mint', MINT]]); + const provides = providesMap( + providedNode('decimals', accountFieldValueNode({ account: 'mint', path: 'decimals' })), + ); + const fetchAccountData: FetchAccountDataFn = () => Promise.resolve(decodedAccount({ decimals: 8 })); + + // When we resolve it. + const result = await resolveInjectedValue(node, context({ accountAddresses, fetchAccountData, provides })); + + // Then we expect the account field value. + expect(result).toBe(8); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ffe0c13..78a8d1bdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: '@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)