diff --git a/packages/dynamic-address-resolution/src/index.ts b/packages/dynamic-address-resolution/src/index.ts index 639cf05ee..73596f7d8 100644 --- a/packages/dynamic-address-resolution/src/index.ts +++ b/packages/dynamic-address-resolution/src/index.ts @@ -3,7 +3,7 @@ export { resolveInstructionAccountAddress, resolveStandalonePda } from './resolv export type { ResolveInstructionAccountAddressInput, ResolveStandalonePdaInput } from './resolvers'; // Helpers -export { isPublicKeyLike, isAddressConvertible, toAddress } from './shared/address'; +export { isAddressConvertible, isPublicKeyLike, toAddress, toAddressOrThrow } from './shared/address'; export { OPTIONAL_NODE_KINDS } from './shared/nodes'; // Types diff --git a/packages/dynamic-address-resolution/src/resolvers/context.ts b/packages/dynamic-address-resolution/src/resolvers/context.ts index b038986cf..75d39570e 100644 --- a/packages/dynamic-address-resolution/src/resolvers/context.ts +++ b/packages/dynamic-address-resolution/src/resolvers/context.ts @@ -3,6 +3,7 @@ import { CODAMA_ERROR__CANNOT_RESOLVE_PATH, CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, CodamaError, } from '@codama/errors'; import type { Address } from '@solana/addresses'; @@ -11,6 +12,7 @@ import { findProgramNodeFromPath, getLastNodeFromPath, getRecordLinkablesVisitor, + type IdentifierString, type InstructionNode, isNode, LinkableDictionary, @@ -28,8 +30,9 @@ import { visit, } from 'codama'; -import { toAddress } from '../shared/address'; +import { type AddressInput, toAddress } from '../shared/address'; import type { AccountsInput, DataInput } from '../shared/types'; +import { formatValueType } from '../shared/util'; import type { ResolutionContext } from './types'; const linkablesCache = new WeakMap(); @@ -66,6 +69,26 @@ export function createResolutionContext, + accountName: IdentifierString, +): AddressInput | null | undefined { + const input = ctx.accountsInput?.[accountName]; + if (Array.isArray(input)) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { + accountName, + actualType: formatValueType(input), + expectedType: 'Address | PublicKey', + }); + } + return input as AddressInput | null | undefined; +} + export function getInstruction(ctx: ResolutionContext): InstructionNode { return getLastNodeFromPath(ctx.instructionPath); } diff --git a/packages/dynamic-address-resolution/src/resolvers/resolve-account-address.ts b/packages/dynamic-address-resolution/src/resolvers/resolve-account-address.ts index bad2685bd..c78912128 100644 --- a/packages/dynamic-address-resolution/src/resolvers/resolve-account-address.ts +++ b/packages/dynamic-address-resolution/src/resolvers/resolve-account-address.ts @@ -9,7 +9,7 @@ import { visitOrElse } from 'codama'; import { safeStringify } from '../shared/util'; import { createAccountDefaultValueVisitor, unexpectedAccountDefaultValueNode } from '../visitors/account-default-value'; -import { getInstruction, getProgramAddress } from './context'; +import { getAccountInput, getInstruction, getProgramAddress } from './context'; import type { ResolutionContext } from './types'; /** @@ -21,7 +21,7 @@ export async function resolveAccountAddress( ctx: ResolutionContext, ): Promise
{ // Optional accounts explicitly provided as null resolve using the optional account strategy. - if (ctx.accountsInput?.[ixAccountNode.identifier] === null && ixAccountNode.isOptional) { + if (getAccountInput(ctx, ixAccountNode.identifier) === null && ixAccountNode.isOptional) { return resolveOptionalAccountWithStrategy(ixAccountNode, ctx); } diff --git a/packages/dynamic-address-resolution/src/resolvers/resolve-account-value-node-address.ts b/packages/dynamic-address-resolution/src/resolvers/resolve-account-value-node-address.ts index 4de863513..706192e3a 100644 --- a/packages/dynamic-address-resolution/src/resolvers/resolve-account-value-node-address.ts +++ b/packages/dynamic-address-resolution/src/resolvers/resolve-account-value-node-address.ts @@ -7,7 +7,7 @@ import type { Address } from '@solana/addresses'; import type { AccountValueNode } from 'codama'; import { toAddress } from '../shared/address'; -import { getInstruction } from './context'; +import { getAccountInput, getInstruction } from './context'; import { resolveAccountAddress } from './resolve-account-address'; import type { ResolutionContext, ResolutionPath } from './types'; @@ -19,7 +19,7 @@ export async function resolveAccountValueNodeAddress( node: AccountValueNode, ctx: ResolutionContext, ): Promise
{ - const providedAddress = ctx.accountsInput?.[node.identifier]; + const providedAddress = getAccountInput(ctx, node.identifier); if (providedAddress !== undefined && providedAddress !== null) { return toAddress(providedAddress); } diff --git a/packages/dynamic-address-resolution/src/resolvers/resolve-instruction-account-address.ts b/packages/dynamic-address-resolution/src/resolvers/resolve-instruction-account-address.ts index 0b4081fc6..574b28a43 100644 --- a/packages/dynamic-address-resolution/src/resolvers/resolve-instruction-account-address.ts +++ b/packages/dynamic-address-resolution/src/resolvers/resolve-instruction-account-address.ts @@ -8,7 +8,7 @@ import { getLastNodeFromPath, getNodePathUntilLastNode, type InstructionAccountN import { toAddress } from '../shared/address'; import type { AccountsInput, DataInput } from '../shared/types'; -import { createResolutionContext } from './context'; +import { createResolutionContext, getAccountInput } from './context'; import { resolveAccountAddress } from './resolve-account-address'; export type ResolveInstructionAccountAddressInput< @@ -57,7 +57,7 @@ export async function resolveInstructionAccountAddress< }); } - const addressInput = accountsInput?.[ixAccountNode.identifier]; + const addressInput = getAccountInput({ accountsInput }, ixAccountNode.identifier); if (addressInput !== undefined && addressInput !== null) { return toAddress(addressInput); } diff --git a/packages/dynamic-address-resolution/src/shared/address.ts b/packages/dynamic-address-resolution/src/shared/address.ts index 12af93862..132d3a618 100644 --- a/packages/dynamic-address-resolution/src/shared/address.ts +++ b/packages/dynamic-address-resolution/src/shared/address.ts @@ -5,7 +5,6 @@ import { } from '@codama/errors'; import type { Address } from '@solana/addresses'; import { address, isAddress } from '@solana/addresses'; -import type { IdentifierString } from 'codama'; import { formatValueType, safeStringify } from './util'; @@ -33,9 +32,10 @@ export function toAddress(input: AddressInput): Address { /** * Convert a value to the address of the given account, throwing - * `UNEXPECTED_ADDRESS_TYPE` when it is not address convertible. + * `UNEXPECTED_ADDRESS_TYPE` when it is not address convertible. The account + * name may locate an item of remaining accounts, e.g. `signers[1]`. */ -export function toAddressOrThrow(value: unknown, accountName: IdentifierString): Address { +export function toAddressOrThrow(value: unknown, accountName: string): Address { if (!isAddressConvertible(value)) { throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { accountName, diff --git a/packages/dynamic-address-resolution/src/shared/types.ts b/packages/dynamic-address-resolution/src/shared/types.ts index 5272e5765..dee6ed80f 100644 --- a/packages/dynamic-address-resolution/src/shared/types.ts +++ b/packages/dynamic-address-resolution/src/shared/types.ts @@ -1,7 +1,11 @@ import type { AddressInput } from './address'; -/** The addresses provided for the accounts of an instruction, keyed by account identifier. */ -export type AccountsInput = Partial>; +/** + * The addresses provided for the accounts of an instruction, keyed by account + * identifier. Remaining accounts are provided as lists of addresses, e.g. + * `{ authority, signers: [signerA, signerB] }`. + */ +export type AccountsInput = Partial>; /** * The data provided for an instruction, as accepted by the codec of its `data` diff --git a/packages/dynamic-address-resolution/src/visitors/account-default-value.ts b/packages/dynamic-address-resolution/src/visitors/account-default-value.ts index 12afeab73..6e5f2ae4f 100644 --- a/packages/dynamic-address-resolution/src/visitors/account-default-value.ts +++ b/packages/dynamic-address-resolution/src/visitors/account-default-value.ts @@ -13,7 +13,7 @@ import { visitOrElse, } from 'codama'; -import { getInstruction, getProgramAddress, getRequiredDataValue } from '../resolvers/context'; +import { getAccountInput, getInstruction, getProgramAddress, getRequiredDataValue } from '../resolvers/context'; import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address'; import { resolveConditionalValueNodeCondition } from '../resolvers/resolve-conditional'; import { resolvePdaAddress } from '../resolvers/resolve-pda-address'; @@ -50,7 +50,7 @@ export function createAccountDefaultValueVisitor( ixAccountNode: InstructionAccountNode, ctx: ResolutionContext, ): Visitor, AccountDefaultValueSupportedNodeKind> { - const accountAddressInput = ctx.accountsInput?.[ixAccountNode.identifier]; + const accountAddressInput = getAccountInput(ctx, ixAccountNode.identifier); const requireProvidedAccount = () => { if (accountAddressInput === undefined || accountAddressInput === null) { throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, { diff --git a/packages/dynamic-address-resolution/src/visitors/condition-node-value.ts b/packages/dynamic-address-resolution/src/visitors/condition-node-value.ts index 3e10068a2..13e418c03 100644 --- a/packages/dynamic-address-resolution/src/visitors/condition-node-value.ts +++ b/packages/dynamic-address-resolution/src/visitors/condition-node-value.ts @@ -1,7 +1,7 @@ import { CODAMA_ERROR__UNEXPECTED_NODE_KIND, CodamaError } from '@codama/errors'; import type { Node, Visitor } from 'codama'; -import { getDataValue, getInstruction } from '../resolvers/context'; +import { getAccountInput, getDataValue, getInstruction } from '../resolvers/context'; import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address'; import type { ResolutionContext } from '../resolvers/types'; @@ -24,7 +24,7 @@ export function createConditionNodeValueVisitor( return { visitAccountValue: async node => { // An account explicitly provided as null does not exist. - const input = ctx.accountsInput?.[node.identifier]; + const input = getAccountInput(ctx, node.identifier); if (input === null) return null; // Neither does an account that is not provided and cannot be resolved from a default value. const account = (getInstruction(ctx).accounts ?? []).find( diff --git a/packages/dynamic-address-resolution/test/resolvers/resolve-instruction-account-address.test.ts b/packages/dynamic-address-resolution/test/resolvers/resolve-instruction-account-address.test.ts index 012423a81..dba5b8dac 100644 --- a/packages/dynamic-address-resolution/test/resolvers/resolve-instruction-account-address.test.ts +++ b/packages/dynamic-address-resolution/test/resolvers/resolve-instruction-account-address.test.ts @@ -73,6 +73,19 @@ describe('provided accounts', () => { await expectCodamaError(resolveInstructionAccountAddress({ path }), error); await expectCodamaError(resolveInstructionAccountAddress({ accountsInput: { myAccount: null }, path }), error); }); + + test('it throws when a list of addresses is provided for an account', async () => { + const address = await generateAddress(); + const { path } = getAccountPath(account('myAccount')); + await expectCodamaError( + resolveInstructionAccountAddress({ accountsInput: { myAccount: [address] }, path }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { + accountName: path[3].identifier, + actualType: 'array (length 1)', + expectedType: 'Address | PublicKey', + }), + ); + }); }); describe('optional accounts', () => { diff --git a/packages/dynamic-address-resolution/test/shared/types.test.ts b/packages/dynamic-address-resolution/test/shared/types.test.ts index 95fd91d7c..b2d471ae2 100644 --- a/packages/dynamic-address-resolution/test/shared/types.test.ts +++ b/packages/dynamic-address-resolution/test/shared/types.test.ts @@ -5,8 +5,11 @@ import type { AddressInput } from '../../src/shared/address'; import type { AccountsInput, DataInput } from '../../src/shared/types'; describe('AccountsInput', () => { - test('it accepts a partial record of AddressInput or null', () => { - expectTypeOf().toExtend>>(); + test('it accepts a partial record of AddressInput, lists of AddressInput or null', () => { + expectTypeOf().toExtend< + Partial> + >(); + expectTypeOf<{ signers: Address[] }>().toExtend(); expectTypeOf<{ mint: null }>().toExtend(); expectTypeOf<{ mint: Address }>().toExtend(); // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/packages/dynamic-client/src/index.ts b/packages/dynamic-client/src/index.ts index 312e168b7..2bb83ebde 100644 --- a/packages/dynamic-client/src/index.ts +++ b/packages/dynamic-client/src/index.ts @@ -7,16 +7,16 @@ export { CodamaError, isCodamaError } from '@codama/errors'; export { CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_RESOLVER_MISSING, - CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__CANNOT_CONVERT_TO_ADDRESS, CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY, + CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA, - CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, + CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, CODAMA_ERROR__DYNAMIC_CLIENT__INSTRUCTION_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, - CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, diff --git a/packages/dynamic-instructions/README.md b/packages/dynamic-instructions/README.md index 359b3dc16..3886d3d07 100644 --- a/packages/dynamic-instructions/README.md +++ b/packages/dynamic-instructions/README.md @@ -7,7 +7,7 @@ [npm-image]: https://img.shields.io/npm/v/@codama/dynamic-instructions.svg?style=flat&label=%40codama%2Fdynamic-instructions [npm-url]: https://www.npmjs.com/package/@codama/dynamic-instructions -This package provides a runtime Solana instruction builder that dynamically constructs `Instruction` (`@solana/instructions`). It provides instruction arguments encoding and validation, accounts resolution. Powers [`@codama/dynamic-client`](../dynamic-client/README.md) with `InstructionsBuilder`. +This package provides a runtime Solana instruction builder that dynamically constructs `Instruction` (`@solana/instructions`). It encodes and validates instruction data and resolves instruction accounts. Powers [`@codama/dynamic-client`](../dynamic-client/README.md) with `InstructionsBuilder`. It also provides a **clear-signing display** layer that turns a concrete instruction into human-readable text — see [Instruction display](#instruction-display-clear-signing). @@ -22,9 +22,9 @@ pnpm install @codama/dynamic-instructions ## Types generation -This package can emit TypeScript types per-instruction - `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`, and `${Name}Signers` aliases, plus an aggregate `${Program}InstructionBuilders` map. +This package can emit TypeScript types per instruction: `${Name}InstructionDataArgs`, `${Name}Accounts` and `${Name}AccountsWithData`, `${Name}Signers` aliases, plus an aggregate `${Program}InstructionBuilders` map. -The `${Name}Args` / `${Name}Accounts` / `${Name}Resolvers` type contracts that resolvers operate on are emitted by [`@codama/dynamic-address-resolution/codegen`](../dynamic-address-resolution/README.md) and re-used here. The builder depends on resolution because the input shape it accepts (e.g. optional auto-resolvable accounts) is a direct consequence of resolution rules. +The `${Name}InstructionDataArgs` and `${Name}Accounts` types are emitted by [`@codama/dynamic-address-resolution/codegen`](../dynamic-address-resolution/README.md) and re-used here, since the accounts that may be omitted depend on the resolution rules. ### CLI @@ -44,75 +44,55 @@ const source = generateTypes(idl); ## Functions -### `createInstructionsBuilder(root, ixNode)` +Every function takes the path of the instruction from the root node, e.g. `[root, root.program, instruction]`, so links and injected values resolve from the program defining the instruction, which may be an additional program. -Creates an async instruction builder function for a given `InstructionNode`. The returned function validates inputs, resolves defaults, encodes arguments, and assembles the final `Instruction`. +### `createInstructionsBuilder(path)` -**Untyped:** +Creates an async function building the `Instruction` (`@solana/instructions`) of an instruction. It encodes the provided data, resolves the accounts that are not provided from their default values, and uses the address of the program defining the instruction. ```ts -const build = createInstructionsBuilder(root, ixNode); -const instruction = await build(args, accounts, signers, resolvers); -``` - -**Typed:** - -> Types are generated via [`generate-types`](#types-generation). - -```ts -import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-instruction-types'; - -const build = createInstructionsBuilder(root, ixNode); -const instruction = await build({ name: 'item' }, { authority }, [], { - resolveOwner: async (args, accounts) => accounts.authority, +const build = createInstructionsBuilder([root, root.program, transfer]); +const instruction = await build({ + // Remaining accounts are provided as lists of addresses under their identifier. + accounts: { authority, destination, signers: [signerA, signerB], source }, + data: { amount: 1_000_000_000n }, + // Accounts with `isSigner: 'either'` to mark as signers. + signers: ['authority'], }); ``` -### `createAccountMeta(root, ixNode, argumentsInput?, accountsInput?, signers?, resolversInput?)` - -Resolves and builds `AccountMeta[]` for an instruction. Handles PDA derivation, default value resolution, optional accounts, and signer disambiguation. - -**Untyped:** +Types generated via [`generate-types`](#types-generation) can type its inputs. ```ts -const accountMetas = await createAccountMeta(root, ixNode, args, accounts, ['owner'], resolvers); -``` - -**Typed:** +import type { + TransferAccounts, + TransferInstructionDataArgs, + TransferSigners, +} from './generated/-instruction-types'; -> Types are generated via [`generate-types`](#types-generation). - -```ts -import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-instruction-types'; - -const accountMetas = await createAccountMeta( - root, - ixNode, - { name: 'item' }, - { authority }, - ['owner'], - { resolveOwner: async (args, accounts) => accounts.authority }, -); +const build = createInstructionsBuilder(path); ``` -### `encodeInstructionArguments(root, ixNode, argumentsInput?)` +### `encodeInstructionData(path, data?)` -Encodes instruction arguments into a `ReadonlyUint8Array` buffer according to the Codama schema. Auto-encodes arguments with `defaultValueStrategy: 'omitted'` (e.g. discriminators). - -**Untyped:** +Encodes the data of an instruction using its codec from [`@codama/dynamic-codecs`](../dynamic-codecs/README.md), including the default values of its fields, e.g. discriminators. ```ts -const data = encodeInstructionArguments(root, ixNode, { amount: 1_000_000_000 }); +const bytes = encodeInstructionData([root, root.program, transfer], { amount: 1_000_000_000n }); ``` -**Typed:** +Codama errors raised while encoding are thrown as is, e.g. a `CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error for a value of the wrong type. Other encoding errors, e.g. an integer out of range, throw a `CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA` error whose `cause` is the original error. Use `createInstructionDataEncoder(path)` to create the codec once and encode several times. -> Types are generated via [`generate-types`](#types-generation). +### `createAccountMetas({ path, accounts?, data?, signers? })` -```ts -import type { TransferArgs } from './generated/-instruction-types'; +Creates the `AccountMeta`s of an instruction: its accounts, in order, followed by its remaining accounts. Accounts that are not provided are resolved from their default values, e.g. PDAs derived from the data, and optional accounts provided as `null` follow the `optionalAccountStrategy` of the instruction. -const data = encodeInstructionArguments(root, ixNode, { amount: 1_000_000_000n }); +```ts +const accountMetas = await createAccountMetas({ + accounts: { authority, destination, source }, + data: { amount: 1_000_000_000n }, + path: [root, root.program, transfer], +}); ``` ## Instruction display (clear signing) diff --git a/packages/dynamic-instructions/package.json b/packages/dynamic-instructions/package.json index 174648677..c01de4907 100644 --- a/packages/dynamic-instructions/package.json +++ b/packages/dynamic-instructions/package.json @@ -82,8 +82,7 @@ "@solana/codecs": "^8.4.0", "@solana/instructions": "^8.4.0", "codama": "workspace:*", - "commander": "^15.0.0", - "superstruct": "^2.0.2" + "commander": "^15.0.0" }, "devDependencies": { "@solana/kit": "8.4.0" diff --git a/packages/dynamic-instructions/src/accounts/create-account-meta.ts b/packages/dynamic-instructions/src/accounts/create-account-meta.ts deleted file mode 100644 index 089c7e386..000000000 --- a/packages/dynamic-instructions/src/accounts/create-account-meta.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { - AccountsInput, - ArgumentsInput, - ResolverFnInput, - ResolversInput, -} from '@codama/dynamic-address-resolution'; -import { isAddressConvertible, resolveInstructionAccountAddress, toAddress } from '@codama/dynamic-address-resolution'; -import { - CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, - CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT, - CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, - CODAMA_ERROR__UNEXPECTED_NODE_KIND, - CodamaError, -} from '@codama/errors'; -import type { Address } from '@solana/addresses'; -import type { AccountMeta } from '@solana/instructions'; -import { AccountRole } from '@solana/instructions'; -import { type InstructionAccountNode, type InstructionNode, isNode, type RootNode } from 'codama'; - -import type { EitherSigners } from '../shared/types'; -import { formatValueType, safeStringify } from '../shared/util'; - -type ResolvedAccount = { - address: Address | null; - optional: boolean; - role: AccountRole; -}; - -type ResolvedAccountWithAddress = ResolvedAccount & { address: Address }; - -/** - * Resolves account addresses and creates AccountMeta for each account in the instruction by evaluating their default values. - * Handles optional accounts based on the instruction's optionalAccountStrategy. - * Throws errors if required accounts are missing or cannot be resolved. - */ -export async function createAccountMeta< - TAccounts extends AccountsInput = AccountsInput, - TArgs extends ArgumentsInput = ArgumentsInput, - TResolvers extends ResolverFnInput = ResolversInput, ->( - root: RootNode, - ixNode: InstructionNode, - argumentsInput?: TArgs, - accountsInput?: TAccounts, - signers: EitherSigners = [], - resolversInput?: TResolvers, -): Promise { - const programAddress = toAddress(root.program.publicKey); - const resolvedAccounts = await Promise.all( - (ixNode.accounts ?? []).map>(async ixAccountNode => { - const finalAddress = await resolveInstructionAccountAddress({ - accountsInput, - argumentsInput, - ixAccountNode, - ixNode, - resolversInput, - root, - }); - - // Optional accounts resolved via "programId" optionalAccountStrategy get the program address, - // which cannot be writable on-chain — downgrade to readonly. - // E.g. PMP's setData instruction `buffer` account. (isWritable, isOptional and "programId" strategy). - // But when buffer is null it resolves to the program address which cannot be writable, hence must be downgraded to readonly. - const accountAddressInput = accountsInput?.[ixAccountNode.identifier]; - const isAccountProvided = accountAddressInput !== undefined && accountAddressInput !== null; - const role = - ixAccountNode.isOptional && - !isAccountProvided && - ixNode.optionalAccountStrategy === 'programId' && - finalAddress === programAddress - ? getReadonlyAccountRole(ixAccountNode, signers) - : getAccountRole(ixAccountNode, signers); - - return { - address: finalAddress, - optional: Boolean(ixAccountNode.isOptional), - role, - }; - }), - ); - - const accountMetas: AccountMeta[] = resolvedAccounts - // Filter out optional accounts with "omitted" strategy (nulls). - .filter((acc): acc is ResolvedAccountWithAddress => acc.address !== null) - .map(acc => ({ - address: acc.address, - role: acc.role, - })); - - // Resolve remaining accounts from argument values - // https://github.com/codama-idl/spec/blob/main/v1/docs/InstructionRemainingAccountsNode.md - for (const remainingNode of ixNode.remainingAccounts ?? []) { - if (!isNode(remainingNode.value, 'argumentValueNode')) { - throw new CodamaError(CODAMA_ERROR__UNEXPECTED_NODE_KIND, { - expectedKinds: ['argumentValueNode'], - kind: remainingNode.value.kind, - node: remainingNode.value, - }); - } - const addresses = argumentsInput?.[remainingNode.value.name]; - - if (addresses === undefined) { - // Required remaining accounts must be provided. - if (!remainingNode.isOptional) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, { - argumentName: remainingNode.value.name, - instructionName: ixNode.identifier, - }); - } - // Optional remaining accounts can be safely omitted. - continue; - } - - if (!Array.isArray(addresses)) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT, { - argumentName: remainingNode.value.name, - expectedType: 'Address[]', - value: safeStringify(addresses), - }); - } - const role = getRemainingAccountRole(remainingNode.isSigner, remainingNode.isWritable); - for (let i = 0; i < addresses.length; i++) { - const addr: unknown = addresses[i]; - if (!isAddressConvertible(addr)) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { - accountName: `${remainingNode.value.name}[${i}]`, - actualType: formatValueType(addr), - expectedType: 'Address | PublicKey', - }); - } - accountMetas.push({ address: toAddress(addr), role }); - } - } - - return accountMetas; -} - -// TODO: 'either' is treated as signer — this works for Token Program multisig signers, -// but may need refinement for programs where 'either' accounts are sometimes non-signers. -function getRemainingAccountRole(isSigner?: boolean | 'either', isWritable?: boolean): AccountRole { - const signer = isSigner === true || isSigner === 'either'; - const writable = isWritable === true; - if (writable && signer) return AccountRole.WRITABLE_SIGNER; - if (writable) return AccountRole.WRITABLE; - if (signer) return AccountRole.READONLY_SIGNER; - return AccountRole.READONLY; -} - -function getAccountRole(acc: InstructionAccountNode, signers: string[] | undefined): AccountRole { - const isSigner = isSignerAccount(acc, signers ?? []); - if (acc.isWritable && isSigner) { - return AccountRole.WRITABLE_SIGNER; - } - if (acc.isWritable) { - return AccountRole.WRITABLE; - } - if (isSigner) { - return AccountRole.READONLY_SIGNER; - } - return AccountRole.READONLY; -} - -function getReadonlyAccountRole(acc: InstructionAccountNode, signers: string[] | undefined): AccountRole { - const isSigner = isSignerAccount(acc, signers ?? []); - return isSigner ? AccountRole.READONLY_SIGNER : AccountRole.READONLY; -} - -function isSignerAccount(acc: InstructionAccountNode, signers: string[]) { - if (acc.isSigner === 'either') { - return signers.includes(acc.identifier); - } - return acc.isSigner === true; -} diff --git a/packages/dynamic-instructions/src/accounts/create-account-metas.ts b/packages/dynamic-instructions/src/accounts/create-account-metas.ts new file mode 100644 index 000000000..2666c6150 --- /dev/null +++ b/packages/dynamic-instructions/src/accounts/create-account-metas.ts @@ -0,0 +1,158 @@ +import { + type AccountsInput, + type DataInput, + isAddressConvertible, + resolveInstructionAccountAddress, + toAddressOrThrow, +} from '@codama/dynamic-address-resolution'; +import { + CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, + CodamaError, +} from '@codama/errors'; +import { type AccountMeta, AccountRole } from '@solana/instructions'; +import { + getLastNodeFromPath, + type InstructionAccountNode, + type InstructionNode, + type InstructionRemainingAccountsNode, + type NodePath, +} from 'codama'; + +import { getInstructionProgramAddress } from '../shared/program'; +import type { EitherSigners } from '../shared/types'; +import { safeStringify } from '../shared/util'; + +export type CreateAccountMetasInput< + TAccounts extends AccountsInput = AccountsInput, + TData extends DataInput = DataInput, +> = { + /** + * The addresses of the accounts, keyed by account identifier. Remaining + * accounts are provided as lists of addresses, e.g. `{ signers: [a, b] }`. + */ + accounts?: TAccounts; + /** The instruction data, used to resolve default values, e.g. PDA seeds. */ + data?: TData; + /** The path of the instruction from the root node, e.g. `[root, program, instruction]`. */ + path: NodePath; + /** The accounts with `isSigner: 'either'` to mark as signers. */ + signers?: EitherSigners; +}; + +/** + * Create the `AccountMeta`s of an instruction: its accounts, in order, followed by + * its remaining accounts. + * + * Accounts that are not provided are resolved from their default values, and + * optional accounts provided as `null` follow the `optionalAccountStrategy` of the + * instruction, see `resolveInstructionAccountAddress`. Remaining accounts are read + * from the list of addresses provided under their identifier. Required remaining + * accounts must be provided, possibly as an empty list, whereas optional ones may + * be omitted. + * + * @example + * ```ts + * const accountMetas = await createAccountMetas({ + * accounts: { authority, signers: [signerA, signerB] }, + * data: { amount: 42n }, + * path: [root, program, transfer], + * signers: ['authority'], + * }); + * ``` + */ +export async function createAccountMetas< + TAccounts extends AccountsInput = AccountsInput, + TData extends DataInput = DataInput, +>({ accounts, data, path, signers = [] }: CreateAccountMetasInput): Promise { + const instruction = getLastNodeFromPath(path); + assertValidAccountAddresses(instruction, accounts); + + const programAddress = getInstructionProgramAddress(path); + const accountMetas = await Promise.all( + (instruction.accounts ?? []).map(async account => { + const address = await resolveInstructionAccountAddress({ + accountsInput: accounts, + dataInput: data, + path: [...path, account], + }); + if (address === null) return null; + + // Optional accounts resolved to the program address via the `programId` strategy + // cannot be writable on-chain, so they are downgraded to readonly. + const input = accounts?.[account.identifier]; + const isResolvedToProgramId = + account.isOptional && + (input === undefined || input === null) && + (instruction.optionalAccountStrategy ?? 'programId') === 'programId' && + address === programAddress; + const isSigner = isSignerAccount(account, signers); + const role = getAccountRole(isSigner, !isResolvedToProgramId && Boolean(account.isWritable)); + return { address, role } satisfies AccountMeta; + }), + ); + + const remainingAccountMetas = (instruction.remainingAccounts ?? []).flatMap(remainingAccounts => + getRemainingAccountMetas(instruction, remainingAccounts, accounts), + ); + + return [...accountMetas.filter(meta => meta !== null), ...remainingAccountMetas]; +} + +/** Ensure the provided addresses of the named accounts of the instruction are valid. */ +function assertValidAccountAddresses(instruction: InstructionNode, accounts: AccountsInput | undefined): void { + for (const account of instruction.accounts ?? []) { + const input = accounts?.[account.identifier]; + // Lists are rejected when resolving the account, as only remaining accounts accept them. + if (input === undefined || input === null || Array.isArray(input) || isAddressConvertible(input)) continue; + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, { + accountName: account.identifier, + value: safeStringify(input), + }); + } +} + +function getRemainingAccountMetas( + instruction: InstructionNode, + remainingAccounts: InstructionRemainingAccountsNode, + accounts: AccountsInput | undefined, +): AccountMeta[] { + const accountName = remainingAccounts.identifier; + const addresses = accounts?.[accountName]; + if (addresses === undefined) { + if (remainingAccounts.isOptional) return []; + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, { + accountName, + instructionName: instruction.identifier, + }); + } + if (!Array.isArray(addresses)) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, { + accountName, + expectedType: 'Address[]', + value: safeStringify(addresses), + }); + } + + // TODO: 'either' is treated as signer — this works for Token Program multisig signers, + // but may need refinement for programs where 'either' accounts are sometimes non-signers. + const isSigner = remainingAccounts.isSigner === true || remainingAccounts.isSigner === 'either'; + const role = getAccountRole(isSigner, Boolean(remainingAccounts.isWritable)); + return (addresses as unknown[]).map((address, index): AccountMeta => ({ + address: toAddressOrThrow(address, `${accountName}[${index}]`), + role, + })); +} + +function isSignerAccount(account: InstructionAccountNode, signers: EitherSigners): boolean { + if (account.isSigner === 'either') return signers.includes(account.identifier); + return account.isSigner === true; +} + +function getAccountRole(isSigner: boolean, isWritable: boolean): AccountRole { + if (isWritable && isSigner) return AccountRole.WRITABLE_SIGNER; + if (isWritable) return AccountRole.WRITABLE; + if (isSigner) return AccountRole.READONLY_SIGNER; + return AccountRole.READONLY; +} diff --git a/packages/dynamic-instructions/src/accounts/index.ts b/packages/dynamic-instructions/src/accounts/index.ts index c5f1a1f47..1d51764e7 100644 --- a/packages/dynamic-instructions/src/accounts/index.ts +++ b/packages/dynamic-instructions/src/accounts/index.ts @@ -1,2 +1 @@ -export { createAccountMeta } from './create-account-meta'; -export { createAccountsInputValidator } from './validate-accounts-input'; +export { createAccountMetas, type CreateAccountMetasInput } from './create-account-metas'; diff --git a/packages/dynamic-instructions/src/accounts/validate-accounts-input.ts b/packages/dynamic-instructions/src/accounts/validate-accounts-input.ts deleted file mode 100644 index e4dc2cdce..000000000 --- a/packages/dynamic-instructions/src/accounts/validate-accounts-input.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AccountsInput } from '@codama/dynamic-address-resolution'; -import { - CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, - CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, - CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, - CodamaError, -} from '@codama/errors'; -import { camelCase, type InstructionNode } from 'codama'; -import { assert, StructError } from 'superstruct'; - -import { safeStringify } from '../shared/util'; -import { createIxAccountsValidator } from '../validators'; - -/** - * Creates a validation function for InstructionAccountNodes. - * Pre-built superstruct validator ensures all required accounts are provided and have valid addresses. - * Skips validation for instructions without accounts. - */ -export function createAccountsInputValidator(ixNode: InstructionNode) { - const accounts = ixNode.accounts ?? []; - const validator = accounts.length ? createIxAccountsValidator(accounts) : null; - - return (accountsInput: AccountsInput = {}) => { - if (!validator) return; - - try { - assert(accountsInput, validator); - } catch (error) { - if (error instanceof StructError) { - const key = error.key as string; - const value = error.value as unknown; - if (value == null) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, { - accountName: camelCase(key), - instructionName: ixNode.identifier, - }); - } else { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, { - accountName: camelCase(key), - value: safeStringify(value), - }); - } - } - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, { - cause: error, - message: 'Unexpected validation error', - }); - } - }; -} diff --git a/packages/dynamic-instructions/src/arguments/encode-instruction-arguments.ts b/packages/dynamic-instructions/src/arguments/encode-instruction-arguments.ts deleted file mode 100644 index f33d5b724..000000000 --- a/packages/dynamic-instructions/src/arguments/encode-instruction-arguments.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - type ArgumentsInput, - createCodecInputTransformer, - createDefaultValueEncoderVisitor, - DEFAULT_VALUE_ENCODER_SUPPORTED_NODE_KINDS, -} from '@codama/dynamic-address-resolution'; -import { getNodeCodec, type ReadonlyUint8Array } from '@codama/dynamic-codecs'; -import { - CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, - CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, - CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, - CODAMA_ERROR__UNEXPECTED_NODE_KIND, - CodamaError, -} from '@codama/errors'; -import { type Codec, mergeBytes } from '@solana/codecs'; -import type { InstructionNode, RootNode } from 'codama'; -import { visitOrElse } from 'codama'; - -import { isOmittedArgument, isOptionalArgument } from './shared'; - -/** - * Encodes all instruction arguments into a single byte array. - * Iterates over each InstructionArgumentNode and encodes based on its category: - * - * Omitted arguments use their default value. - * Optional arguments are encoded as null. - * Required arguments are transformed from user input and then encoded. - */ -export function encodeInstructionArguments( - root: RootNode, - ix: InstructionNode, - argumentsInput?: TArgs, -): ReadonlyUint8Array { - const chunks = (ix.arguments ?? []).map(ixArgumentNode => { - const input = argumentsInput?.[ixArgumentNode.identifier]; - const nodeCodec = getNodeCodec([root, root.program, ix, ixArgumentNode]); - if (isOmittedArgument(ixArgumentNode)) { - return encodeOmittedArgument(ix, ixArgumentNode, nodeCodec); - } else if (isOptionalArgument(ixArgumentNode, input)) { - return encodeOptionalArgument(ix, ixArgumentNode, nodeCodec); - } else { - return encodeRequiredArgument(root, ix, ixArgumentNode, input, nodeCodec); - } - }); - - return mergeBytes(chunks.map(chunk => Uint8Array.from(chunk))); -} - -function encodeOmittedArgument( - ix: InstructionNode, - ixArgumentNode: NonNullable[number], - nodeCodec: Codec, -): ReadonlyUint8Array { - const defaultValue = ixArgumentNode.defaultValue; - if (defaultValue === undefined) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, { - argumentName: ixArgumentNode.identifier, - instructionName: ix.identifier, - }); - } - - const visitor = createDefaultValueEncoderVisitor(nodeCodec); - return visitOrElse(defaultValue, visitor, node => { - throw new CodamaError(CODAMA_ERROR__UNEXPECTED_NODE_KIND, { - expectedKinds: [...DEFAULT_VALUE_ENCODER_SUPPORTED_NODE_KINDS], - kind: node.kind, - node, - }); - }); -} - -function encodeOptionalArgument( - ix: InstructionNode, - ixArgumentNode: NonNullable[number], - nodeCodec: Codec, -): ReadonlyUint8Array { - try { - return nodeCodec.encode(null); - } catch (error) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, { - argumentName: ixArgumentNode.identifier, - cause: error, - instructionName: ix.identifier, - }); - } -} - -function encodeRequiredArgument( - root: RootNode, - ix: InstructionNode, - ixArgumentNode: NonNullable[number], - input: ArgumentsInput[string], - nodeCodec: Codec, -): ReadonlyUint8Array { - if (input === undefined) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, { - argumentName: ixArgumentNode.identifier, - instructionName: ix.identifier, - }); - } - - const transformer = createCodecInputTransformer(ixArgumentNode.type, root, { - bytesEncoding: 'base16', - }); - const transformedInput = transformer(input); - try { - return nodeCodec.encode(transformedInput); - } catch (error) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, { - argumentName: ixArgumentNode.identifier, - cause: error, - instructionName: ix.identifier, - }); - } -} diff --git a/packages/dynamic-instructions/src/arguments/index.ts b/packages/dynamic-instructions/src/arguments/index.ts deleted file mode 100644 index d674d812a..000000000 --- a/packages/dynamic-instructions/src/arguments/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { encodeInstructionArguments } from './encode-instruction-arguments'; -export { createArgumentsInputValidator } from './validate-arguments-input'; -export { resolveArgumentDefaultsFromCustomResolvers } from './resolve-argument-from-custom-resolvers'; diff --git a/packages/dynamic-instructions/src/arguments/resolve-argument-from-custom-resolvers.ts b/packages/dynamic-instructions/src/arguments/resolve-argument-from-custom-resolvers.ts deleted file mode 100644 index d38ceec14..000000000 --- a/packages/dynamic-instructions/src/arguments/resolve-argument-from-custom-resolvers.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - AccountsInput, - ArgumentsInput, - ResolverFn, - ResolverFnInput, - ResolversInput, -} from '@codama/dynamic-address-resolution'; -import { CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, CodamaError } from '@codama/errors'; -import type { InstructionNode } from 'codama'; -import { isNode } from 'codama'; - -import { isOmittedArgument } from './shared'; - -/** - * Resolves argument defaults from user-provided resolvers. - * For each argument that has a ResolverValueNode and is not provided by argumentsInput, - * try to invoke the corresponding resolver function and fill ArgumentsInput with the resolved values. - */ -export async function resolveArgumentDefaultsFromCustomResolvers< - TArgs extends ArgumentsInput = ArgumentsInput, - TAccounts extends AccountsInput = AccountsInput, - TResolvers extends ResolverFnInput = ResolversInput, ->( - ixNode: InstructionNode, - argumentsInput?: TArgs, - accountsInput?: TAccounts, - resolversInput?: TResolvers, -): Promise { - const resolvedArgumentsInput: Record = { ...argumentsInput }; - - const allArguments = [...(ixNode.arguments ?? []), ...(ixNode.extraArguments ?? [])]; - for (const argumentNode of allArguments) { - if (resolvedArgumentsInput[argumentNode.identifier] !== undefined) continue; - if (isOmittedArgument(argumentNode)) continue; - if (!isNode(argumentNode.defaultValue, 'resolverValueNode')) continue; - - const resolverFn: ResolverFn | undefined = resolversInput?.[argumentNode.defaultValue.name]; - // If no resolver provided — skip and let the encoding step handle it: - // Optional arguments will be encoded as none - // Required arguments will emit error - if (!resolverFn) continue; - - try { - resolvedArgumentsInput[argumentNode.identifier] = await resolverFn( - resolvedArgumentsInput as TArgs, - (accountsInput ?? {}) as TAccounts, - ); - } catch (error) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, { - cause: error, - resolverName: argumentNode.defaultValue.name, - targetKind: 'instructionArgumentNode', - targetName: argumentNode.identifier, - }); - } - } - - return resolvedArgumentsInput as TArgs; -} diff --git a/packages/dynamic-instructions/src/arguments/shared.ts b/packages/dynamic-instructions/src/arguments/shared.ts deleted file mode 100644 index e9c122b72..000000000 --- a/packages/dynamic-instructions/src/arguments/shared.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { OPTIONAL_NODE_KINDS } from '@codama/dynamic-address-resolution'; -import type { InstructionArgumentNode } from 'codama'; - -export function isOmittedArgument(node: InstructionArgumentNode) { - return node.defaultValueStrategy === 'omitted'; -} - -export function isOptionalArgument(ixArgumentNode: InstructionArgumentNode, input: unknown) { - return OPTIONAL_NODE_KINDS.includes(ixArgumentNode.type.kind) && (input === null || input === undefined); -} diff --git a/packages/dynamic-instructions/src/arguments/validate-arguments-input.ts b/packages/dynamic-instructions/src/arguments/validate-arguments-input.ts deleted file mode 100644 index 9d10003fa..000000000 --- a/packages/dynamic-instructions/src/arguments/validate-arguments-input.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { ArgumentsInput } from '@codama/dynamic-address-resolution'; -import { CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, CodamaError } from '@codama/errors'; -import type { InstructionNode, RootNode } from 'codama'; -import type { Failure } from 'superstruct'; -import { assert, StructError } from 'superstruct'; - -import { safeStringify } from '../shared/util'; -import { createIxArgumentsValidator } from '../validators'; -import { isOmittedArgument } from './shared'; - -/** - * Creates a cached validation function for InstructionArgumentNodes. - * - * Skips "omitted" arguments. - * Arguments with resolverValueNode defaults are expected to use optionTypeNode and NOT filtered out here. - * Optional validation allows undefined so custom resolvers will fill default values after validation. - */ -export function createArgumentsInputValidator(root: RootNode, ixNode: InstructionNode) { - const requiredArguments = (ixNode.arguments ?? []).filter(arg => arg?.defaultValueStrategy !== 'omitted'); - const validator = requiredArguments.length - ? createIxArgumentsValidator(ixNode.identifier, requiredArguments, root.program.definedTypes ?? []) - : null; - - return (argumentsInput: ArgumentsInput = {}) => { - // Ensure arguments with "omitted" defaultValueStrategy are not provided in argumentsInput. - validateOmittedArguments(ixNode, argumentsInput); - - if (!validator) return; - - const filteredInput = filterRemainingAccountArguments(ixNode, argumentsInput); - - try { - assert(filteredInput, validator); - } catch (error) { - if (!(error instanceof StructError)) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, { - cause: error, - message: 'Unexpected validation error', - }); - } - const formattedMessage = error.failures().map(failure => { - const fieldPath = formatFailurePath(failure); - const value = formatFailureValue(failure.value); - return `Invalid argument "${fieldPath}", value: ${value}. ${failure.message}\n`; - }); - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, { - cause: error, - message: formattedMessage.join(''), - }); - } - }; -} - -/** - * Formats a full dotted path from failure, e.g. "command", "innerStruct.pubkey", "enumsArray[1]" - */ -function formatFailurePath(failure: Failure): string { - const path = failure.path; - if (!path || path.length === 0) return String(failure.key ?? ''); - return path - .map((segment, i) => { - if (typeof segment === 'number') { - return `[${segment}]`; - } - return `${i === 0 ? '' : '.'}${String(segment)}`; - }) - .join(''); -} - -/** - * Formats failure values for error messages, truncating long values and stringifying objects. - */ -const MAX_VALUE_LENGTH = 120; -function formatFailureValue(value: unknown): string { - const raw = typeof value === 'object' ? safeStringify(value) : String(value as unknown); - return raw.length > MAX_VALUE_LENGTH ? `${raw.slice(0, MAX_VALUE_LENGTH)}...` : raw; -} - -/** - * Ensures that arguments with "omitted" defaultValueStrategy are not provided by the user (e.g. discriminator). - */ -function validateOmittedArguments(ixNode: InstructionNode, argumentsInput: ArgumentsInput = {}) { - (ixNode.arguments ?? []).filter(isOmittedArgument).forEach(ixArgumentNode => { - if (Object.hasOwn(argumentsInput, ixArgumentNode.identifier)) { - throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, { - message: 'Omitted argument must not be provided', - }); - } - }); -} - -function getRemainingAccountArgNames(ixNode: InstructionNode): string[] { - return (ixNode.remainingAccounts ?? []) - .filter(node => node.value.kind === 'argumentValueNode') - .map(node => node.value.name); -} - -/** - * Filters out remaining account argument names from the arguments input. - * So superstruct's object() doesn't reject them as extra keys. - */ -function filterRemainingAccountArguments(ixNode: InstructionNode, argumentsInput: ArgumentsInput): ArgumentsInput { - const remainingAccountArgNames = getRemainingAccountArgNames(ixNode); - if (!remainingAccountArgNames.length) { - return argumentsInput; - } - - const remainingAccountArgNamesSet = new Set(remainingAccountArgNames); - return Object.fromEntries(Object.entries(argumentsInput).filter(([key]) => !remainingAccountArgNamesSet.has(key))); -} diff --git a/packages/dynamic-instructions/src/cli/commands/generate-types/register-command.ts b/packages/dynamic-instructions/src/cli/commands/generate-types/register-command.ts index 01a2e662b..b817aebac 100644 --- a/packages/dynamic-instructions/src/cli/commands/generate-types/register-command.ts +++ b/packages/dynamic-instructions/src/cli/commands/generate-types/register-command.ts @@ -6,7 +6,7 @@ export function registerGenerateTypesCommand(program: Command): void { program .command('generate-types') .description( - 'Generate TypeScript instruction types (Args/Accounts/Resolvers, Signers, InstructionBuilders map) from a Codama IDL JSON file', + 'Generate TypeScript instruction types (data, accounts, signers and the instruction builders map) from a Codama IDL JSON file', ) .argument('', 'Path to a Codama IDL JSON file (e.g., ./idl/codama.json)') .argument('', 'Path to the output directory for the generated .ts file, e.g., ./generated') diff --git a/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts b/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts index b57879449..91e1f4084 100644 --- a/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts +++ b/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts @@ -18,12 +18,11 @@ export function generateInstructionBuildersMap(idl: RootNode): string { export type ${programName}InstructionBuilders = {\n`; for (const ix of idl.program.instructions ?? []) { - const refs = getResolutionRefs(ix); + const refs = getResolutionRefs(ix, idl.program.definedTypes ?? []); const signerRef = getInstructionSignerRef(ix); - const argsGeneric = refs.argsRef ?? 'Record'; + const dataGeneric = refs.dataRef ?? 'undefined'; const signersGeneric = signerRef.signersRef ?? 'string[]'; - const resolversGeneric = refs.resolversRef ? `, ${refs.resolversRef}` : ''; - output += ` ${ix.identifier}: InstructionsBuilderFn<${argsGeneric}, ${refs.accountsRef}, ${signersGeneric}${resolversGeneric}>;\n`; + output += ` ${ix.identifier}: InstructionsBuilderFn<${dataGeneric}, ${refs.accountsRef}, ${signersGeneric}>;\n`; } output += '};\n'; diff --git a/packages/dynamic-instructions/src/codegen/generate-types.ts b/packages/dynamic-instructions/src/codegen/generate-types.ts index de23ce45a..a0cbb3af8 100644 --- a/packages/dynamic-instructions/src/codegen/generate-types.ts +++ b/packages/dynamic-instructions/src/codegen/generate-types.ts @@ -6,7 +6,7 @@ import { generateSignerTypes } from './generate-signer-types'; /** * Generate a self-contained TypeScript file with all instruction types for a Codama IDL: - * - Per-instruction `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`. + * - Per-instruction `${Name}InstructionDataArgs`, `${Name}Accounts` and `${Name}AccountsWithData`. * - Per-instruction `${Name}Signers` aliases. * - The aggregate `${Program}InstructionBuilders` map. */ diff --git a/packages/dynamic-instructions/src/data/encode-instruction-data.ts b/packages/dynamic-instructions/src/data/encode-instruction-data.ts new file mode 100644 index 000000000..75467a74b --- /dev/null +++ b/packages/dynamic-instructions/src/data/encode-instruction-data.ts @@ -0,0 +1,47 @@ +import type { DataInput } from '@codama/dynamic-address-resolution'; +import { getNodeValueCodec, type ReadonlyUint8Array } from '@codama/dynamic-codecs'; +import { CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CodamaError, isCodamaError } from '@codama/errors'; +import { getLastNodeFromPath, type InstructionNode, type NodePath } from 'codama'; + +/** + * Create a function encoding the data of the instruction at the end of the + * given path, e.g. `[root, program, instruction]`. + * + * Codama errors raised while encoding are thrown as is, e.g. a + * `DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE` error for a value of the wrong type or + * an `INJECTED_VALUE_NOT_PROVIDED` error for a missing injected default value. + * Other encoding errors, e.g. an integer out of range, throw a + * `DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA` error whose cause is the original error. + */ +export function createInstructionDataEncoder( + path: NodePath, +): (data?: TData) => ReadonlyUint8Array { + const codec = getNodeValueCodec(path); + return data => { + try { + return codec.encode(data); + } catch (error) { + if (isCodamaError(error)) throw error; + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, { + cause: error, + instructionName: getLastNodeFromPath(path).identifier, + }); + } + }; +} + +/** + * Encode the data of the instruction at the end of the given path, see + * {@link createInstructionDataEncoder}. + * + * @example + * ```ts + * const bytes = encodeInstructionData([root, program, transfer], { amount: 42n }); + * ``` + */ +export function encodeInstructionData( + path: NodePath, + data?: TData, +): ReadonlyUint8Array { + return createInstructionDataEncoder(path)(data); +} diff --git a/packages/dynamic-instructions/src/data/index.ts b/packages/dynamic-instructions/src/data/index.ts new file mode 100644 index 000000000..d8dc64c44 --- /dev/null +++ b/packages/dynamic-instructions/src/data/index.ts @@ -0,0 +1 @@ +export { createInstructionDataEncoder, encodeInstructionData } from './encode-instruction-data'; diff --git a/packages/dynamic-instructions/src/index.ts b/packages/dynamic-instructions/src/index.ts index 62e1e6973..24e190811 100644 --- a/packages/dynamic-instructions/src/index.ts +++ b/packages/dynamic-instructions/src/index.ts @@ -1,18 +1,15 @@ -export { createAccountMeta } from './accounts'; -export { encodeInstructionArguments } from './arguments'; +export { createAccountMetas, type CreateAccountMetasInput } from './accounts'; +export { createInstructionDataEncoder, encodeInstructionData } from './data'; export * from './display'; export { createInstructionsBuilder } from './instructions-builder'; -export type { InstructionsBuilderFn, EitherSigners } from './shared/types'; +export type { EitherSigners, InstructionInput, InstructionsBuilderFn } from './shared/types'; // Re-exports export { type AccountsInput, type AddressInput, - type ArgumentsInput, + type DataInput, isPublicKeyLike, type PublicKeyLike, - type ResolverFn, - type ResolverFnInput, - type ResolversInput, toAddress, } from '@codama/dynamic-address-resolution'; diff --git a/packages/dynamic-instructions/src/instructions-builder.ts b/packages/dynamic-instructions/src/instructions-builder.ts index 14b21ee6d..adf75cb05 100644 --- a/packages/dynamic-instructions/src/instructions-builder.ts +++ b/packages/dynamic-instructions/src/instructions-builder.ts @@ -1,74 +1,36 @@ -import type { - AccountsInput, - ArgumentsInput, - ResolverFnInput, - ResolversInput, -} from '@codama/dynamic-address-resolution'; -import { address } from '@solana/addresses'; -import type { InstructionNode, RootNode } from 'codama'; +import type { AccountsInput, DataInput } from '@codama/dynamic-address-resolution'; +import type { InstructionNode, NodePath } from 'codama'; -import { createAccountMeta, createAccountsInputValidator } from './accounts'; -import { - createArgumentsInputValidator, - encodeInstructionArguments, - resolveArgumentDefaultsFromCustomResolvers, -} from './arguments'; +import { createAccountMetas } from './accounts'; +import { createInstructionDataEncoder } from './data'; +import { getInstructionProgramAddress } from './shared/program'; import type { EitherSigners, InstructionsBuilderFn } from './shared/types'; /** - * Creates an async instruction builder function for a given `InstructionNode`. + * Create an async function building the `Instruction` of the instruction at the + * end of the given path, e.g. `[root, program, instruction]`. * - * The returned function validates arguments and accounts against the Codama schema, - * resolves custom resolver defaults, encodes arguments into a data buffer, - * and assembles the final `Instruction` with account metas and program address. + * The returned function encodes the provided data, resolves the addresses of the + * accounts that are not provided from their default values, and uses the address + * of the program defining the instruction. * * @example * ```ts - * const build = createInstructionsBuilder(root, ixNode); - * const instruction = await build(args, accounts, signers, resolvers); + * const build = createInstructionsBuilder([root, program, transfer]); + * const instruction = await build({ accounts: { destination, source }, data: { amount: 42n } }); * ``` */ export function createInstructionsBuilder< - TArgs extends ArgumentsInput = ArgumentsInput, + TData extends DataInput = DataInput, TAccounts extends AccountsInput = AccountsInput, TSigners extends EitherSigners = EitherSigners, - TResolvers extends ResolverFnInput = ResolversInput, ->(root: RootNode, ixNode: InstructionNode): InstructionsBuilderFn { - const programAddress = address(root.program.publicKey); - const validateArguments = createArgumentsInputValidator(root, ixNode); - const validateAccounts = createAccountsInputValidator(ixNode); - - return async (argumentsInput, accountsInput, signers, resolversInput) => { - // Validate arguments according to Codama schema. - validateArguments(argumentsInput); - - // Ensure required accounts are present and validate provided pubkey addresses. - validateAccounts(accountsInput); - - // Resolve arguments that depend on custom resolvers. - const enrichedArgumentsInput = await resolveArgumentDefaultsFromCustomResolvers( - ixNode, - argumentsInput, - accountsInput, - resolversInput, - ); - - // Encode arguments into buffer. - const argumentsData = encodeInstructionArguments(root, ixNode, enrichedArgumentsInput); - - const accountsData = await createAccountMeta( - root, - ixNode, - enrichedArgumentsInput, - accountsInput, - signers, - resolversInput, - ); - - return { - accounts: accountsData, - data: argumentsData, - programAddress, - }; +>(path: NodePath): InstructionsBuilderFn { + const programAddress = getInstructionProgramAddress(path); + const encodeData = createInstructionDataEncoder(path); + + return async ({ accounts, data, signers } = {}) => { + const instructionData = encodeData(data); + const accountMetas = await createAccountMetas({ accounts, data, path, signers }); + return { accounts: accountMetas, data: instructionData, programAddress }; }; } diff --git a/packages/dynamic-instructions/src/shared/program.ts b/packages/dynamic-instructions/src/shared/program.ts new file mode 100644 index 000000000..b8c8bd79f --- /dev/null +++ b/packages/dynamic-instructions/src/shared/program.ts @@ -0,0 +1,14 @@ +import { CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, CodamaError } from '@codama/errors'; +import { type Address, address } from '@solana/addresses'; +import { findProgramNodeFromPath, getLastNodeFromPath, type InstructionNode, type NodePath } from 'codama'; + +/** The address of the program defining the instruction at the end of the given path. */ +export function getInstructionProgramAddress(path: NodePath): Address { + const program = findProgramNodeFromPath(path); + if (!program) { + throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, { + message: `The path of instruction [${getLastNodeFromPath(path).identifier}] must include its program.`, + }); + } + return address(program.publicKey); +} diff --git a/packages/dynamic-instructions/src/shared/types.ts b/packages/dynamic-instructions/src/shared/types.ts index 44b05474d..ecd69649f 100644 --- a/packages/dynamic-instructions/src/shared/types.ts +++ b/packages/dynamic-instructions/src/shared/types.ts @@ -1,26 +1,31 @@ -import type { - AccountsInput, - ArgumentsInput, - ResolverFnInput, - ResolversInput, -} from '@codama/dynamic-address-resolution'; +import type { AccountsInput, DataInput } from '@codama/dynamic-address-resolution'; import type { Instruction } from '@solana/instructions'; type AccountName = string; + +/** The accounts with `isSigner: 'either'` to mark as signers, e.g. `['owner']`. */ export type EitherSigners = AccountName[]; +/** The inputs of an instruction, as accepted by {@link InstructionsBuilderFn}. */ +export type InstructionInput< + TData extends DataInput = DataInput, + TAccounts extends AccountsInput = AccountsInput, + TSigners extends EitherSigners = EitherSigners, +> = { + /** + * The addresses of the accounts, keyed by account identifier, e.g. `{ payer: '111..' }`. + * Remaining accounts are provided as lists of addresses, e.g. `{ signers: [a, b] }`. + */ + accounts?: TAccounts; + /** The instruction data, as accepted by its codec, e.g. `{ amount: 1_000_000_000n }`. */ + data?: TData; + /** The accounts with `isSigner: 'either'` to mark as signers. */ + signers?: TSigners; +}; + +/** Build an `Instruction` from the given inputs, see `createInstructionsBuilder`. */ export type InstructionsBuilderFn< - TArgs extends ArgumentsInput = ArgumentsInput, + TData extends DataInput = DataInput, TAccounts extends AccountsInput = AccountsInput, TSigners extends EitherSigners = EitherSigners, - TResolvers extends ResolverFnInput = ResolversInput, -> = ( - /** Instruction argument values (e.g. `{ amount: 1_000_000_000 }`). */ - argumentsInput?: TArgs, - /** Account addresses keyed by name (e.g. `{ payer: '111..' }`). */ - accountsInput?: TAccounts, - /** Account names to mark as signers when the account has ambiguous `isSigner: 'either'`. */ - signers?: TSigners, - /** Custom resolver functions for arguments with `ResolverValueNode`. */ - resolversInput?: TResolvers, -) => Promise; +> = (input?: InstructionInput) => Promise; diff --git a/packages/dynamic-instructions/src/shared/util.ts b/packages/dynamic-instructions/src/shared/util.ts index 0a8bf69c1..eefbe7364 100644 --- a/packages/dynamic-instructions/src/shared/util.ts +++ b/packages/dynamic-instructions/src/shared/util.ts @@ -1,5 +1,3 @@ -import type { NodeKind } from 'codama'; - /** * Checks if a value is a plain object record (struct-like). */ @@ -7,12 +5,6 @@ export function isObjectRecord(value: unknown): value is Record return typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype; } -/** Returns the `NodeKind` of a node or `null`. */ -export function getMaybeNodeKind(node: unknown): NodeKind | null { - if (!isObjectRecord(node)) return null; - return (node as { kind: NodeKind }).kind ?? null; -} - export function formatValueType(value: unknown): string { if (value === null) return 'null'; if (Array.isArray(value)) return `array (length ${value.length})`; diff --git a/packages/dynamic-instructions/src/validators.ts b/packages/dynamic-instructions/src/validators.ts deleted file mode 100644 index 6c2e2eacf..000000000 --- a/packages/dynamic-instructions/src/validators.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { isPublicKeyLike } from '@codama/dynamic-address-resolution'; -import { isAddress } from '@solana/addresses'; -import { getUtf8Encoder } from '@solana/codecs'; -import type { - ArrayTypeNode, - CountNode, - DefinedTypeNode, - EnumVariantTypeNode, - InstructionAccountNode, - InstructionArgumentNode, - SetTypeNode, - TypeNode, -} from 'codama'; -import { pascalCase } from 'codama'; -import { - array, - boolean, - define, - intersection, - number, - object, - size, - string, - Struct, - StructError, - tuple, -} from 'superstruct'; - -import { formatValueType, getMaybeNodeKind, safeStringify } from './shared/util'; - -type StructUnknown = Struct; - -/** - * Creates a superstruct validator for InstructionAccountNodes. - * - * if node is optional, then validate only if it's provided. - * if node has defaultValue, then consider it as optional and validate only if it's provided because it will be resolved from defaultValue. - */ -export function createIxAccountsValidator(ixAccountNodes: InstructionAccountNode[]): StructUnknown { - const shape = ixAccountNodes.reduce>((acc, node) => { - acc[node.identifier] = - node.isOptional || node.defaultValue ? OptionalSolanaAddressValidator : SolanaAddressValidator; - return acc; - }, {}); - return object(shape) as StructUnknown; -} - -/** - * Creates a superstruct validator for instruction InstructionArgumentNodes. - */ -export function createIxArgumentsValidator( - ixNodeName: string, - ixArgumentNodes: InstructionArgumentNode[], - definedTypes: DefinedTypeNode[], -): StructUnknown { - const shape = ixArgumentNodes.reduce>((acc, argumentNode, index) => { - if (!argumentNode.type) { - throw new Error(`Argument ${argumentNode.identifier} of instruction ${ixNodeName} does not have a type`); - } - acc[argumentNode.identifier] = createValidatorForTypeNode( - `${ixNodeName}_${argumentNode.identifier}_${index}`, - argumentNode.type, - definedTypes, - ); - return acc; - }, {}); - return object(shape) as StructUnknown; -} - -function createValidatorForTypeNode(nodeName: string, node: TypeNode, definedTypes: DefinedTypeNode[]): StructUnknown { - if (!node) { - throw new Error( - `Node ${nodeName} is not defined. ${definedTypes.length} defined types were provided: ${definedTypes.map(t => t.identifier).join(', ')}`, - ); - } - switch (node.kind) { - case 'arrayTypeNode': { - return ArrayValidator(`${nodeName}_array`, node, definedTypes); - } - case 'booleanTypeNode': { - return boolean() as StructUnknown; - } - case 'numberTypeNode': { - const format = node.format; - if (format === 'u64' || format === 'u128' || format === 'i64' || format === 'i128') { - return NumberOrBigintValidator; - } - return number() as StructUnknown; - } - case 'publicKeyTypeNode': { - return SolanaAddressValidator; - } - case 'setTypeNode': { - // array of unique items - return intersection([ - UniqueItemsValidator, - ArrayValidator(`${nodeName}_set`, node, definedTypes), - ]) as StructUnknown; - } - case 'stringTypeNode': { - return string() as StructUnknown; - } - case 'fixedSizeTypeNode': { - // fixedSizeTypeNode wraps an inner type and constrains its byte size - if (node.type.kind === 'stringTypeNode') { - // For fixed-size strings, validate that UTF-8 bytes fit within the size - return StringValidatorForFixedSize(node.size); - } - if (node.type.kind === 'bytesTypeNode') { - // For fixed-size bytes, validate exact byte length - return BytesWithSizeValidator(node.size); - } - // For other types, delegate to the inner type validator - // The size constraint is handled during encoding - return createValidatorForTypeNode(`${nodeName}_fixed_size`, node.type, definedTypes); - } - case 'bytesTypeNode': { - return BytesLikeValidator; - } - case 'dateTimeTypeNode': { - return createValidatorForTypeNode(`${nodeName}_date_time`, node.number, definedTypes); - } - case 'definedTypeLinkNode': { - const definedType = definedTypes.find(d => d.identifier === node.identifier); - if (!definedType) { - throw new Error(`Undefined type: ${node.identifier} ${node.kind}`); - } - return createValidatorForTypeNode(`${nodeName}_defined_type`, definedType.type, definedTypes); - } - case 'mapTypeNode': { - const keyValidator = createValidatorForTypeNode( - `${nodeName}_map_key_${node.key.kind}`, - node.key, - definedTypes, - ); - const valueValidator = createValidatorForTypeNode( - `${nodeName}_map_value_${node.value.kind}`, - node.value, - definedTypes, - ); - const sizeValidator = MapCountValidator(node.count); - const keyValueValidator = KeyValueValidator(nodeName, keyValidator, valueValidator); - if (sizeValidator) { - return intersection([keyValueValidator, sizeValidator]) as StructUnknown; - } - return keyValueValidator; - } - case 'structTypeNode': { - const structShape = (node.fields ?? []).reduce>((acc, field) => { - acc[field.identifier] = createValidatorForTypeNode( - `${nodeName}_struct_${field.identifier}`, - field.type, - definedTypes, - ); - return acc; - }, {}); - return object(structShape) as StructUnknown; - } - case 'tupleTypeNode': { - const validators = (node.items ?? []).map((typeNode, index) => - createValidatorForTypeNode(`${nodeName}_tuple${typeNode.kind}_${index}`, typeNode, definedTypes), - ); - return tuple(validators as [StructUnknown, ...StructUnknown[]]) as StructUnknown; - } - case 'zeroableOptionTypeNode': { - const innerValidator = createValidatorForTypeNode( - `${nodeName}_zeroable_option_item`, - node.item, - definedTypes, - ); - return ZeroableOptionValidator(`${nodeName}_zeroable_option`, innerValidator); - } - case 'optionTypeNode': { - // TODO: Do we need to validate node.fixed and node.prefix of OptionTypeNode? - const SomeValueValidator = createValidatorForTypeNode(`${nodeName}_option_item`, node.item, definedTypes); - return OptionValueValidator(`${nodeName}_option`, SomeValueValidator); - } - case 'remainderOptionTypeNode': { - const innerValidator = RemainderOptionTypeItemValidator( - `${nodeName}_remainder_option_item`, - node.item, - definedTypes, - ); - return OptionValueValidator(`${nodeName}_remainder_option`, innerValidator); - } - case 'hiddenPrefixTypeNode': - case 'hiddenSuffixTypeNode': - case 'sentinelTypeNode': - case 'postOffsetTypeNode': - case 'preOffsetTypeNode': - case 'sizePrefixTypeNode': { - return createValidatorForTypeNode(`${nodeName}_size_prefix`, node.type, definedTypes); - } - case 'enumTypeNode': { - return EnumVariantValidator(nodeName, node.variants ?? [], definedTypes); - } - case 'amountTypeNode': { - return AmountTypeValidator(nodeName); - } - case 'solAmountTypeNode': { - return AmountTypeValidator(nodeName); - } - default: { - node['kind'] satisfies never; - throw new Error(`Validator for TypeNode "${nodeName}" kind: ${getMaybeNodeKind(node)} is not implemented!`); - } - } -} - -function RemainderOptionTypeItemValidator( - nodeName: string, - itemNode: TypeNode, - definedTypes: DefinedTypeNode[], -): StructUnknown { - if (itemNode.kind === 'fixedSizeTypeNode' && itemNode.type.kind === 'stringTypeNode') { - // For fixed-size strings in remainder options, accept any string - return StringValidatorForFixedSize(itemNode.size); - } - - if (itemNode.kind === 'definedTypeLinkNode') { - const definedType = definedTypes.find(d => d.identifier === itemNode.identifier); - if (definedType?.type.kind === 'fixedSizeTypeNode' && definedType.type.type.kind === 'stringTypeNode') { - return StringValidatorForFixedSize(definedType.type.size); - } - } - - return createValidatorForTypeNode(nodeName, itemNode, definedTypes); -} - -function StringValidatorForFixedSize(maxSize: number): StructUnknown { - return define(`StringForFixedSize_max_${maxSize}`, (value: unknown) => { - if (typeof value !== 'string') { - return `Expected a string, received: ${formatValueType(value)}`; - } - const encoder = getUtf8Encoder(); - const bytes = encoder.encode(value); - return ( - bytes.length <= maxSize || - `String exceeds max size: ${bytes.length} bytes (UTF-8), limit is ${maxSize} bytes` - ); - }) as StructUnknown; -} - -/** - * Validator for enum variants. - * Handles both scalar enums and enums with data. - */ -function EnumVariantValidator( - nodeName: string, - variants: EnumVariantTypeNode[], - definedTypes: DefinedTypeNode[], -): StructUnknown { - // Keyed by PascalCase so both raw node names and the codec's decoded - // `__kind` casing match the same variant. - const variantMap = new Map(variants.map(v => [pascalCase(v.identifier), v])); - const variantNames = variants.map(v => v.identifier); - - // Eagerly build per-variant payload validators for struct and tuple variants - const variantValidators = new Map(); - for (const variant of variants) { - if (variant.kind === 'enumStructVariantTypeNode') { - variantValidators.set( - pascalCase(variant.identifier), - createValidatorForTypeNode(`${nodeName}_${variant.identifier}`, variant.struct, definedTypes), - ); - } else if (variant.kind === 'enumTupleVariantTypeNode') { - variantValidators.set( - pascalCase(variant.identifier), - createValidatorForTypeNode(`${nodeName}_${variant.identifier}`, variant.tuple, definedTypes), - ); - } - } - - return define(`${nodeName}_EnumVariant`, (value: unknown) => { - // Scalar enum: plain string variant name (e.g. 'foo', 'bar') - if (typeof value === 'string') - return ( - variantMap.has(pascalCase(value)) || - `Invalid enum value "${value}". Expected one of: ${variantNames.join(', ')}` - ); - - // Data enum variant: object with __kind (e.g. { __kind: 'tokenTransfer', amount: 1000 }) - if (typeof value === 'object' && value !== null && '__kind' in value) { - const kind = (value as Record)['__kind']; - if (typeof kind !== 'string') { - return `Expected __kind to be a string, received: ${formatValueType(kind)}`; - } - const variant = variantMap.get(pascalCase(kind)); - if (!variant) { - return `Invalid enum variant "${kind}". Expected one of: ${variantNames.join(', ')}`; - } - - if (variant.kind === 'enumEmptyVariantTypeNode') { - return true; - } - - // Validations of enum payloads - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { __discriminator: _d, __kind: _, ...rest } = value as Record; - const payloadValidator = variantValidators.get(pascalCase(kind)); - if (!payloadValidator) { - return true; - } - - if (variant.kind === 'enumStructVariantTypeNode') { - const [structError] = payloadValidator.validate(rest); - return structError ? formatErrorForEnumTypeNode(kind, structError) : true; - } - - if (variant.kind === 'enumTupleVariantTypeNode') { - const fields = (rest as { fields?: unknown }).fields; - const [structError] = payloadValidator.validate(fields); - return structError ? formatErrorForEnumTypeNode(kind, structError) : true; - } - } - - return `Expected an enum variant (string or object with __kind), received: ${formatValueType(value)}`; - }) as StructUnknown; -} - -function formatErrorForEnumTypeNode(enumVariantKind: string, error: StructError) { - const failures = error.failures(); - const first = failures?.[0]; - if (first) { - return `Enum variant "${enumVariantKind}" has invalid "${String(first.key)}"`; - } - return `Enum variant "${enumVariantKind}" has invalid payload`; -} - -const SolanaAddressValidator: StructUnknown = /* @__PURE__ */ define('SolanaAddress', (value: unknown) => { - if (typeof value === 'string') { - return isAddress(value) || `Expected a valid Solana address (base58), received string: "${value}"`; - } - if (isPublicKeyLike(value)) { - return isAddress(value.toBase58()) || 'Expected a valid Solana address, received an invalid PublicKey'; - } - return `Expected a Solana address (base58 string or PublicKey), received: ${formatValueType(value)}`; -}); - -const OptionalSolanaAddressValidator: StructUnknown = /* @__PURE__ */ define( - 'OptionalSolanaAddress', - (value: unknown) => { - if (value === undefined || value === null) return true; - const [error] = SolanaAddressValidator.validate(value); - if (!error) return true; - return error.failures()[0]?.message ?? 'Expected a valid Solana address or null/undefined'; - }, -); - -const NumberOrBigintValidator: StructUnknown = /* @__PURE__ */ define('NumberOrBigint', (value: unknown) => { - if (typeof value === 'number') { - return Number.isSafeInteger(value) || `Expected a safe integer, received unsafe number: ${value}`; - } - if (typeof value === 'bigint') return true; - return `Expected a number or bigint, received: ${formatValueType(value)}`; -}); - -const BytesLikeValidator: StructUnknown = /* @__PURE__ */ define('BytesLike', (value: unknown) => { - if (value instanceof Uint8Array) return true; - if (!Array.isArray(value)) { - return `Expected a Uint8Array or number[] (bytes 0-255), received: ${formatValueType(value)}`; - } - const invalidIndex = value.findIndex(n => typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 255); - if (invalidIndex !== -1) { - return `Expected byte values (integers 0-255), invalid element at index ${invalidIndex}: ${String(value[invalidIndex])}`; - } - return true; -}); - -/** - * Validator for bytes that must be exactly a specific size. - * Used for fixedSizeTypeNode wrapping bytesTypeNode. - */ -function BytesWithSizeValidator(exactSize: number): StructUnknown { - return define(`BytesWithSize_${exactSize}`, (value: unknown) => { - if (value instanceof Uint8Array) { - return value.length === exactSize || `Expected exactly ${exactSize} bytes, received ${value.length} bytes`; - } - if (!Array.isArray(value)) { - return `Expected a Uint8Array or number[] of exactly ${exactSize} bytes, received: ${formatValueType(value)}`; - } - if (value.length !== exactSize) { - return `Expected exactly ${exactSize} bytes, received ${value.length} elements`; - } - const invalidIndex = value.findIndex(n => typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 255); - if (invalidIndex !== -1) { - return `Expected byte values (integers 0-255), invalid element at index ${invalidIndex}: ${String(value[invalidIndex])}`; - } - return true; - }) as StructUnknown; -} - -// Validates value only if it is not null or undefined (i.e. if it's provided). -// SomeValueValidator validates the provided value (i.e. Some(value)). -function OptionValueValidator(name: string, SomeValueValidator: StructUnknown): StructUnknown { - return define(`${name}_OptionValueValidator`, (value: unknown) => { - if (value === null || value === undefined) return true; - const [error] = SomeValueValidator.validate(value); - if (!error) return true; - return error.failures()[0]?.message ?? 'Invalid value for optional field'; - }) as StructUnknown; -} - -// Validates zeroable option: null is valid, otherwise validates the inner validator. -function ZeroableOptionValidator(name: string, innerValidator: StructUnknown): StructUnknown { - return define(name, (value: unknown) => { - if (value == null) return true; - const [error] = innerValidator.validate(value); - if (!error) return true; - return error.failures()[0]?.message ?? 'Expected a valid value or null for zeroable option'; - }) as StructUnknown; -} - -// Checks that all items in the array are unique. -const UniqueItemsValidator: StructUnknown = /* @__PURE__ */ define('UniqueItems', (value: unknown) => { - if (!Array.isArray(value)) { - return `Expected an array with unique items, received: ${formatValueType(value)}`; - } - - const unique = new Map(); - for (let i = 0; i < value.length; i++) { - const key = safeStringify(value[i]); - const index = unique.get(key); - if (index !== undefined) { - return `Expected all items to be unique, found duplicate at indices ${index} and ${i}`; - } - unique.set(key, i); - } - return true; -}) as StructUnknown; - -// Validates every key of an object according to KeyValidator. -// Validates every value of an object according to ValueValidator. -// Used in MapTypeNode, where the keys and values are of the same type. -function KeyValueValidator(name: string, KeyValidator: StructUnknown, ValueValidator: StructUnknown): StructUnknown { - return define(`${name}_KeyValueValidator`, (value: unknown) => { - if (typeof value !== 'object' || value === null) { - return `Expected a map (object), received: ${formatValueType(value)}`; - } - const record = value as Record; - const invalidKeys: string[] = []; - const invalidValues: string[] = []; - for (const key of Object.keys(record)) { - if (KeyValidator.validate(key)[0]) invalidKeys.push(key); - if (ValueValidator.validate(record[key])[0]) invalidValues.push(key); - } - if (!invalidKeys.length && !invalidValues.length) return true; - const parts: string[] = []; - if (invalidKeys.length) parts.push(`invalid keys: ${invalidKeys.join(', ')}`); - if (invalidValues.length) parts.push(`invalid values: ${invalidValues.join(', ')}`); - return `Map validation failed: ${parts.join('; ')}`; - }) as StructUnknown; -} - -function MapCountValidator(node: CountNode): StructUnknown | null { - switch (node.kind) { - case 'fixedCountNode': - return KeysLengthValidator(node.value); - case 'remainderCountNode': - case 'prefixedCountNode': - return null; // the number of items is unknown or arbitrary, like vec![] - default: - throw new Error(`Unsupported map count type: ${getMaybeNodeKind(node)}`); - } -} - -// Validates the number of keys in an object -// Can be used in MapTypeNode with "fixed" CountNode type -function KeysLengthValidator(count: number): StructUnknown { - return define(`KeysLengthValidator_len_${count}`, (value: unknown) => { - if (typeof value !== 'object' || value === null) { - return `Expected a map with exactly ${count} entries, received: ${formatValueType(value)}`; - } - const actual = Object.keys(value).length; - return actual === count || `Expected exactly ${count} map entries, received ${actual}`; - }) as StructUnknown; -} - -// Handles both fixed-size and variable-size arrays -function ArrayValidator( - nodeName: string, - node: ArrayTypeNode | SetTypeNode, - definedTypes: DefinedTypeNode[], -): StructUnknown { - // First define a validator for every array item - const itemValidator = createValidatorForTypeNode(nodeName, node.item, definedTypes); - // Then validate CountNode representing array size: - // https://github.com/codama-idl/spec/blob/main/v1/docs/typeNodes/ArrayTypeNode.md - switch (node.count.kind) { - case 'fixedCountNode': { - return size(array(itemValidator), node.count.value) as StructUnknown; - } - case 'remainderCountNode': - case 'prefixedCountNode': { - return array(itemValidator) as StructUnknown; - } - default: { - // This should be unreachable with the current `CountNode` union but helps - // guard against future Codama expansions. - throw new Error(`Node: ${nodeName}. Unsupported array count type`); - } - } -} - -/** - * Validator for amountTypeNode and solAmountTypeNode. - * Accepts number, bigint. - */ -function AmountTypeValidator(nodeName: string): StructUnknown { - return define(`AmountType_${nodeName}`, (value: unknown) => { - if (typeof value === 'number') { - return Number.isSafeInteger(value) || `Expected a safe integer, received unsafe number: ${value}`; - } - if (typeof value === 'bigint') { - return true; - } - return `Expected a number or bigint, received: ${formatValueType(value)}`; - }) as StructUnknown; -} diff --git a/packages/dynamic-instructions/test/_setup.ts b/packages/dynamic-instructions/test/_setup.ts new file mode 100644 index 000000000..d0c96a8d2 --- /dev/null +++ b/packages/dynamic-instructions/test/_setup.ts @@ -0,0 +1,34 @@ +import type { Address } from '@solana/addresses'; +import { generateKeyPairSigner } from '@solana/kit'; +import { + type InstructionNode, + type ProgramNode, + programNode, + type ProgramNodeInput, + type RootNode, + rootNode, +} from 'codama'; + +export const PROGRAM_ADDRESS = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address; + +export async function generateAddress(): Promise
{ + return (await generateKeyPairSigner()).address; +} + +/** Wrap instructions in a root node whose program has the given identifier. */ +export function makeRoot( + instructions: InstructionNode[], + identifier = 'testProgram', + programInput: Partial = {}, +): RootNode { + return rootNode(programNode({ identifier, publicKey: PROGRAM_ADDRESS, ...programInput, instructions })); +} + +/** The path of an instruction wrapped in a root node, e.g. `[root, program, instruction]`. */ +export function getInstructionPath( + instruction: InstructionNode, + programInput: Partial = {}, +): readonly [RootNode, ProgramNode, InstructionNode] { + const root = makeRoot([instruction], 'testProgram', programInput); + return [root, root.program, instruction]; +} diff --git a/packages/dynamic-instructions/test/accounts/create-account-meta.test.ts b/packages/dynamic-instructions/test/accounts/create-account-meta.test.ts deleted file mode 100644 index 7ab54b405..000000000 --- a/packages/dynamic-instructions/test/accounts/create-account-meta.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { address } from '@solana/addresses'; -import { AccountRole } from '@solana/instructions'; -import type { InstructionNode } from 'codama'; -import { - argumentValueNode, - identityValueNode, - instructionAccountNode, - instructionArgumentNode, - instructionNode, - instructionRemainingAccountsNode, - numberTypeNode, - optionTypeNode, - programNode, - publicKeyTypeNode, - publicKeyValueNode, - rootNode, -} from 'codama'; -import { describe, expect, test } from 'vitest'; - -import { createAccountMeta } from '../../src/accounts/create-account-meta'; - -const ADDR_1 = address('11111111111111111111111111111111'); -const ADDR_2 = address('22222222222222222222222222222222222222222222'); -const ADDR_3 = address('33333333333333333333333333333333333333333333'); -const MULTISIG_ADDR = address('ALUceMetVMsZCKsVmB5JhmvswTFsbkAgmj8fGLLv2wwL'); -const PROGRAM_KEY = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; - -function makeRoot(ix: InstructionNode) { - return rootNode(programNode({ instructions: [ix], name: 'test', publicKey: PROGRAM_KEY })); -} - -// initializeMultisig: 2 accounts + remainingAccounts(signers) -const initMultisigIx = instructionNode({ - accounts: [ - instructionAccountNode({ isSigner: false, isWritable: true, name: 'multisig' }), - instructionAccountNode({ - defaultValue: publicKeyValueNode('SysvarRent111111111111111111111111111111111'), - isSigner: false, - isWritable: false, - name: 'rent', - }), - ], - arguments: [instructionArgumentNode({ name: 'm', type: numberTypeNode('u8') })], - name: 'initializeMultisig', - remainingAccounts: [ - instructionRemainingAccountsNode(argumentValueNode('signers'), { isOptional: false, isSigner: false }), - ], -}); -const initMultisigRoot = makeRoot(initMultisigIx); - -// transfer: 3 accounts + optional remainingAccounts(multiSigners, isSigner) -const transferIx = instructionNode({ - accounts: [ - instructionAccountNode({ isSigner: false, isWritable: true, name: 'source' }), - instructionAccountNode({ isSigner: false, isWritable: true, name: 'destination' }), - instructionAccountNode({ - defaultValue: identityValueNode(), - isSigner: 'either', - isWritable: false, - name: 'authority', - }), - ], - arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], - name: 'transfer', - remainingAccounts: [ - instructionRemainingAccountsNode(argumentValueNode('multiSigners'), { isOptional: true, isSigner: true }), - ], -}); -const transferRoot = makeRoot(transferIx); - -// initializeMint: 2 accounts, no remainingAccounts -const initMintMintAccount = instructionAccountNode({ isSigner: false, isWritable: true, name: 'mint' }); -const initMintIx = instructionNode({ - accounts: [ - initMintMintAccount, - instructionAccountNode({ - defaultValue: publicKeyValueNode('SysvarRent111111111111111111111111111111111'), - isSigner: false, - isWritable: false, - name: 'rent', - }), - ], - arguments: [ - instructionArgumentNode({ name: 'decimals', type: numberTypeNode('u8') }), - instructionArgumentNode({ name: 'mintAuthority', type: publicKeyTypeNode() }), - instructionArgumentNode({ name: 'freezeAuthority', type: optionTypeNode(publicKeyTypeNode()) }), - ], - name: 'initializeMint', -}); -const initMintRoot = makeRoot(initMintIx); - -describe('createAccountMeta: remaining accounts', () => { - test('should append remaining accounts from argumentsInput', async () => { - const result = await createAccountMeta( - initMultisigRoot, - initMultisigIx, - { m: 2, signers: [ADDR_1, ADDR_2, ADDR_3] }, - { multisig: MULTISIG_ADDR }, - ); - - // 2 regular accounts (multisig + rent) + 3 remaining accounts - expect(result).toHaveLength(5); - const remainingAccounts = result.slice(2); - expect(remainingAccounts[0]).toEqual({ address: ADDR_1, role: AccountRole.READONLY }); - expect(remainingAccounts[1]).toEqual({ address: ADDR_2, role: AccountRole.READONLY }); - expect(remainingAccounts[2]).toEqual({ address: ADDR_3, role: AccountRole.READONLY }); - }); - - test('should use READONLY_SIGNER role when isSigner is true', async () => { - const result = await createAccountMeta( - transferRoot, - transferIx, - { amount: 100, multiSigners: [ADDR_1, ADDR_2] }, - { authority: ADDR_3, destination: MULTISIG_ADDR, source: ADDR_3 }, - ); - - const remainingAccounts = result.slice(3); - expect(remainingAccounts).toHaveLength(2); - expect(remainingAccounts[0]).toEqual({ address: ADDR_1, role: AccountRole.READONLY_SIGNER }); - expect(remainingAccounts[1]).toEqual({ address: ADDR_2, role: AccountRole.READONLY_SIGNER }); - }); - - test('should skip optional remaining accounts when not provided', async () => { - const result = await createAccountMeta( - transferRoot, - transferIx, - { amount: 100 }, - { authority: ADDR_1, destination: MULTISIG_ADDR, source: ADDR_3 }, - ); - - expect(result).toHaveLength(3); - }); - - test('should append empty array as no remaining accounts', async () => { - const result = await createAccountMeta( - initMultisigRoot, - initMultisigIx, - { m: 1, signers: [] }, - { multisig: MULTISIG_ADDR }, - ); - - expect(result).toHaveLength(2); - }); - - test('should return no remaining accounts when instruction has none defined', async () => { - const result = await createAccountMeta( - initMintRoot, - initMintIx, - { decimals: 9, freezeAuthority: null, mintAuthority: ADDR_1 }, - { mint: MULTISIG_ADDR }, - ); - - expect(result).toHaveLength(2); - }); - - test('should throw when remaining account argument is not an array', async () => { - await expect( - createAccountMeta(initMultisigRoot, initMultisigIx, { m: 2, signers: ADDR_1 }, { multisig: MULTISIG_ADDR }), - ).rejects.toThrow( - 'Invalid argument input [signers]: ["11111111111111111111111111111111"]. Expected [Address[]].', - ); - }); - - test('should throw when remaining account value kind is not argumentValueNode', async () => { - const remainingAccount = initMultisigIx.remainingAccounts?.[0]; - const modifiedRemainingAccount = Object.assign({}, remainingAccount, { - value: { kind: 'resolverValueNode', name: 'someResolver' }, - }) as typeof remainingAccount; - const modifiedIx: InstructionNode = Object.assign({}, initMultisigIx, { - remainingAccounts: [modifiedRemainingAccount], - }); - - await expect( - createAccountMeta(initMultisigRoot, modifiedIx, { m: 2 }, { multisig: MULTISIG_ADDR }), - ).rejects.toThrow(/Expected node of kind \[argumentValueNode\], got \[resolverValueNode\]/); - }); - - test('should throw when remaining account array contains invalid element types', async () => { - await expect( - createAccountMeta( - initMultisigRoot, - initMultisigIx, - { m: 2, signers: [ADDR_1, 123] }, - { multisig: MULTISIG_ADDR }, - ), - ).rejects.toThrow(/Expected \[Address \| PublicKey\] for account \[signers\[1\]\]/); - }); - - test('should throw when required remaining account argument is not provided', async () => { - await expect( - createAccountMeta( - initMultisigRoot, - initMultisigIx, - { m: 2, signers: undefined }, - { multisig: MULTISIG_ADDR }, - ), - ).rejects.toThrow(/Missing argument \[signers\]/); - }); -}); - -describe('createAccountMeta: UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY', () => { - test('should throw when optionalAccountStrategy is unsupported', async () => { - const optionalAccount = { - ...initMintMintAccount, - defaultValue: undefined, - isOptional: true, - }; - - const modifiedIx: InstructionNode = { - ...initMintIx, - accounts: [optionalAccount], - // @ts-expect-error - we're intentionally passing an invalid strategy to test error handling - optionalAccountStrategy: 'invalid', - }; - - await expect(createAccountMeta(initMintRoot, modifiedIx, {}, { [optionalAccount.name]: null })).rejects.toThrow( - 'Unsupported optional account strategy ["invalid"] for account [mint] in [initializeMint].', - ); - }); -}); diff --git a/packages/dynamic-instructions/test/accounts/create-account-metas.test.ts b/packages/dynamic-instructions/test/accounts/create-account-metas.test.ts new file mode 100644 index 000000000..f7985f331 --- /dev/null +++ b/packages/dynamic-instructions/test/accounts/create-account-metas.test.ts @@ -0,0 +1,304 @@ +import { + CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, + CodamaError, +} from '@codama/errors'; +import { type Address, getAddressEncoder, getProgramDerivedAddress } from '@solana/addresses'; +import { AccountRole } from '@solana/instructions'; +import { + dataValueNode, + instructionAccountNode, + type InstructionAccountNodeInput, + instructionNode, + type InstructionNodeInput, + instructionRemainingAccountsNode, + integerTypeNode, + pdaNode, + pdaSeedValueNode, + pdaValueNode, + programNode, + publicKeyTypeNode, + publicKeyValueNode, + rootNode, + structFieldTypeNode, + structTypeNode, + variablePdaSeedNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { createAccountMetas } from '../../src'; +import { generateAddress, getInstructionPath, PROGRAM_ADDRESS } from '../_setup'; + +const RENT = 'SysvarRent111111111111111111111111111111111' as Address; + +function account(identifier: string, input: Partial = {}) { + return instructionAccountNode({ identifier, isSigner: false, isWritable: false, ...input }); +} + +function instruction(input: Partial = {}) { + return instructionNode({ identifier: 'testInstruction', ...input }); +} + +/** Expect the promise to reject with a Codama error with exactly the given context. */ +async function expectCodamaError(promise: Promise, expected: CodamaError): Promise { + await expect(promise).rejects.toThrow(expect.objectContaining({ context: expected.context })); +} + +describe('accounts', () => { + test('it creates account metas in order with their roles', async () => { + const [a, b, c, d] = await Promise.all([ + generateAddress(), + generateAddress(), + generateAddress(), + generateAddress(), + ]); + const path = getInstructionPath( + instruction({ + accounts: [ + account('readonly'), + account('writable', { isWritable: true }), + account('signer', { isSigner: true }), + account('writableSigner', { isSigner: true, isWritable: true }), + ], + }), + ); + const accounts = { readonly: a, signer: c, writable: b, writableSigner: d }; + expect(await createAccountMetas({ accounts, path })).toStrictEqual([ + { address: a, role: AccountRole.READONLY }, + { address: b, role: AccountRole.WRITABLE }, + { address: c, role: AccountRole.READONLY_SIGNER }, + { address: d, role: AccountRole.WRITABLE_SIGNER }, + ]); + }); + + test('it resolves accounts that are not provided from their default values', async () => { + const path = getInstructionPath( + instruction({ accounts: [account('rent', { defaultValue: publicKeyValueNode(RENT) })] }), + ); + expect(await createAccountMetas({ path })).toStrictEqual([{ address: RENT, role: AccountRole.READONLY }]); + }); + + test('it resolves default values from the instruction data', async () => { + // Given an account whose default value is a PDA derived from the instruction data. + const owner = await generateAddress(); + const vault = pdaNode({ identifier: 'vault', seeds: [variablePdaSeedNode('owner', publicKeyTypeNode())] }); + const path = getInstructionPath( + instruction({ + accounts: [ + account('vault', { + defaultValue: pdaValueNode(vault, { + seeds: [pdaSeedValueNode('owner', dataValueNode('owner'))], + }), + }), + ], + data: structTypeNode([structFieldTypeNode({ identifier: 'owner', type: publicKeyTypeNode() })]), + }), + ); + + // Then the PDA is derived from the provided data. + const [expected] = await getProgramDerivedAddress({ + programAddress: PROGRAM_ADDRESS, + seeds: [getAddressEncoder().encode(owner)], + }); + expect(await createAccountMetas({ data: { owner }, path })).toStrictEqual([ + { address: expected, role: AccountRole.READONLY }, + ]); + }); + + test('it throws when a required account is missing', async () => { + const path = getInstructionPath(instruction({ accounts: [account('mint')] })); + await expectCodamaError( + createAccountMetas({ path }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, { + accountName: 'mint', + instructionName: 'testInstruction', + }), + ); + }); + + test('it throws when a provided address is invalid', async () => { + const address = await generateAddress(); + const path = getInstructionPath( + instruction({ accounts: [account('mint', { defaultValue: publicKeyValueNode(RENT) })] }), + ); + await expectCodamaError( + createAccountMetas({ accounts: { mint: 'abc' }, path }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, { + accountName: 'mint', + value: '"abc"', + }), + ); + // Lists are only accepted by remaining accounts. + await expectCodamaError( + createAccountMetas({ accounts: { mint: [address] }, path }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { + accountName: 'mint', + actualType: 'array (length 1)', + expectedType: 'Address | PublicKey', + }), + ); + }); + + test('it ignores provided addresses of unknown accounts', async () => { + const [mint, other] = await Promise.all([generateAddress(), generateAddress()]); + const path = getInstructionPath(instruction({ accounts: [account('mint')] })); + expect(await createAccountMetas({ accounts: { mint, other }, path })).toStrictEqual([ + { address: mint, role: AccountRole.READONLY }, + ]); + }); +}); + +describe('optional accounts', () => { + test('it uses the program address as readonly with the programId strategy', async () => { + // Given a writable optional account, with the default `programId` strategy. + const path = getInstructionPath( + instruction({ accounts: [account('buffer', { isOptional: true, isWritable: true })] }), + ); + + // Then it resolves to the program address, downgraded to readonly. + expect(await createAccountMetas({ accounts: { buffer: null }, path })).toStrictEqual([ + { address: PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ]); + }); + + test('it keeps the role of provided optional accounts', async () => { + const buffer = await generateAddress(); + const path = getInstructionPath( + instruction({ accounts: [account('buffer', { isOptional: true, isWritable: true })] }), + ); + expect(await createAccountMetas({ accounts: { buffer }, path })).toStrictEqual([ + { address: buffer, role: AccountRole.WRITABLE }, + ]); + }); + + test('it omits optional accounts with the omitted strategy', async () => { + const mint = await generateAddress(); + const path = getInstructionPath( + instruction({ + accounts: [account('buffer', { isOptional: true }), account('mint')], + optionalAccountStrategy: 'omitted', + }), + ); + expect(await createAccountMetas({ accounts: { buffer: null, mint }, path })).toStrictEqual([ + { address: mint, role: AccountRole.READONLY }, + ]); + }); + + test('it uses the address of the program defining the instruction', async () => { + // Given an instruction of an additional program. + const otherAddress = await generateAddress(); + const ix = instruction({ accounts: [account('buffer', { isOptional: true, isWritable: true })] }); + const other = programNode({ identifier: 'other', instructions: [ix], publicKey: otherAddress }); + const root = rootNode(programNode({ identifier: 'main', publicKey: PROGRAM_ADDRESS }), { + additionalPrograms: [other], + }); + + // Then optional accounts resolve to the address of that program, downgraded to readonly. + expect(await createAccountMetas({ accounts: { buffer: null }, path: [root, other, ix] })).toStrictEqual([ + { address: otherAddress, role: AccountRole.READONLY }, + ]); + }); +}); + +describe('either signers', () => { + test('it marks accounts with isSigner either as signers when listed', async () => { + const authority = await generateAddress(); + const path = getInstructionPath(instruction({ accounts: [account('authority', { isSigner: 'either' })] })); + expect(await createAccountMetas({ accounts: { authority }, path })).toStrictEqual([ + { address: authority, role: AccountRole.READONLY }, + ]); + expect(await createAccountMetas({ accounts: { authority }, path, signers: ['authority'] })).toStrictEqual([ + { address: authority, role: AccountRole.READONLY_SIGNER }, + ]); + }); +}); + +describe('remaining accounts', () => { + const multisig = instruction({ + accounts: [account('multisig', { isWritable: true })], + data: structTypeNode([structFieldTypeNode({ identifier: 'm', type: integerTypeNode('u8') })]), + remainingAccounts: [instructionRemainingAccountsNode('signers')], + }); + const multisigPath = getInstructionPath(multisig); + + test('it appends remaining accounts from their list of addresses', async () => { + const [multisigAddress, a, b] = await Promise.all([generateAddress(), generateAddress(), generateAddress()]); + expect( + await createAccountMetas({ accounts: { multisig: multisigAddress, signers: [a, b] }, path: multisigPath }), + ).toStrictEqual([ + { address: multisigAddress, role: AccountRole.WRITABLE }, + { address: a, role: AccountRole.READONLY }, + { address: b, role: AccountRole.READONLY }, + ]); + }); + + test('it uses the roles of the remaining accounts', async () => { + const [a, b] = await Promise.all([generateAddress(), generateAddress()]); + const path = getInstructionPath( + instruction({ + remainingAccounts: [ + instructionRemainingAccountsNode('signers', { isSigner: true }), + instructionRemainingAccountsNode('writables', { isWritable: true }), + ], + }), + ); + expect(await createAccountMetas({ accounts: { signers: [a], writables: [b] }, path })).toStrictEqual([ + { address: a, role: AccountRole.READONLY_SIGNER }, + { address: b, role: AccountRole.WRITABLE }, + ]); + }); + + test('it accepts empty lists and missing optional remaining accounts', async () => { + const multisigAddress = await generateAddress(); + const expected = [{ address: multisigAddress, role: AccountRole.WRITABLE }]; + expect( + await createAccountMetas({ accounts: { multisig: multisigAddress, signers: [] }, path: multisigPath }), + ).toStrictEqual(expected); + + const optionalPath = getInstructionPath({ + ...multisig, + remainingAccounts: [instructionRemainingAccountsNode('signers', { isOptional: true })], + }); + expect(await createAccountMetas({ accounts: { multisig: multisigAddress }, path: optionalPath })).toStrictEqual( + expected, + ); + }); + + test('it throws when required remaining accounts are missing', async () => { + const multisigAddress = await generateAddress(); + await expectCodamaError( + createAccountMetas({ accounts: { multisig: multisigAddress }, path: multisigPath }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING, { + accountName: 'signers', + instructionName: 'testInstruction', + }), + ); + }); + + test('it throws when remaining accounts are not a list', async () => { + const [multisigAddress, a] = await Promise.all([generateAddress(), generateAddress()]); + await expectCodamaError( + createAccountMetas({ accounts: { multisig: multisigAddress, signers: a }, path: multisigPath }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, { + accountName: 'signers', + expectedType: 'Address[]', + value: `"${a}"`, + }), + ); + }); + + test('it throws when a remaining account is not an address', async () => { + const [multisigAddress, a] = await Promise.all([generateAddress(), generateAddress()]); + const signers = [a, 123] as unknown as Address[]; + await expectCodamaError( + createAccountMetas({ accounts: { multisig: multisigAddress, signers }, path: multisigPath }), + new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_ADDRESS_TYPE, { + accountName: 'signers[1]', + actualType: 'number', + expectedType: 'Address | PublicKey', + }), + ); + }); +}); diff --git a/packages/dynamic-instructions/test/arguments/encode-instruction-arguments.test.ts b/packages/dynamic-instructions/test/arguments/encode-instruction-arguments.test.ts deleted file mode 100644 index 6dcabb6ae..000000000 --- a/packages/dynamic-instructions/test/arguments/encode-instruction-arguments.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { CodamaError } from '@codama/errors'; -import { getBase16Codec, getU32Encoder, getU64Encoder, mergeBytes } from '@solana/codecs'; -import type { InstructionNode } from 'codama'; -import { - bytesTypeNode, - bytesValueNode, - definedTypeLinkNode, - definedTypeNode, - enumEmptyVariantTypeNode, - enumTypeNode, - fixedSizeTypeNode, - instructionArgumentNode, - instructionNode, - numberTypeNode, - numberValueNode, - optionTypeNode, - programNode, - remainderOptionTypeNode, - rootNode, -} from 'codama'; -import { describe, expect, test } from 'vitest'; - -import { encodeInstructionArguments } from '../../src/arguments/encode-instruction-arguments'; -import { createArgumentsInputValidator } from '../../src/arguments/validate-arguments-input'; - -const PROGRAM_KEY = '11111111111111111111111111111111'; - -function makeRoot(ix: InstructionNode, definedTypes: Parameters[0]['definedTypes'] = []) { - return rootNode(programNode({ definedTypes, instructions: [ix], name: 'test', publicKey: PROGRAM_KEY })); -} - -const writeIx = instructionNode({ - arguments: [ - instructionArgumentNode({ - defaultValue: numberValueNode(0), - defaultValueStrategy: 'omitted', - name: 'discriminator', - type: numberTypeNode('u8'), - }), - instructionArgumentNode({ name: 'offset', type: numberTypeNode('u32') }), - instructionArgumentNode({ name: 'data', type: remainderOptionTypeNode(bytesTypeNode()) }), - ], - name: 'write', -}); -const writeRoot = makeRoot(writeIx); - -describe('encodeInstructionArguments', () => { - test('should encode omitted discriminator using default numberValueNode', () => { - const encoded = encodeInstructionArguments(writeRoot, writeIx, { data: null, offset: 2 }); - - // discriminator: u8 + offset: u32 - expect(encoded).toEqual(new Uint8Array([0, 2, 0, 0, 0])); - }); - - test('should encode omitted discriminator using default bytesValueNode', () => { - const ix = instructionNode({ - arguments: [ - instructionArgumentNode({ - defaultValue: bytesValueNode('base16', '1f094566b31b79c7'), - defaultValueStrategy: 'omitted', - name: 'discriminator', - type: fixedSizeTypeNode(bytesTypeNode(), 8), - }), - instructionArgumentNode({ name: 'input', type: numberTypeNode('u64') }), - instructionArgumentNode({ name: 'optionalInput', type: optionTypeNode(numberTypeNode('u64')) }), - ], - name: 'updateOptionalInput', - }); - const root = makeRoot(ix); - - const encoded = encodeInstructionArguments(root, ix, { input: 42n, optionalInput: null }); - - const expectedDiscriminator = getBase16Codec().encode('1f094566b31b79c7'); - const expectedInput = getU64Encoder().encode(42n); - const expectedOptionalInput = new Uint8Array([0]); - expect(encoded).toEqual( - mergeBytes([ - expectedDiscriminator as Uint8Array, - expectedInput as Uint8Array, - expectedOptionalInput as Uint8Array, - ]), - ); - }); - - test('should transform Uint8Array in remainderOptionTypeNode argument', () => { - const testData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); - const encoded = encodeInstructionArguments(writeRoot, writeIx, { data: testData, offset: 10 }); - - const expected = mergeBytes([new Uint8Array([0]), getU32Encoder().encode(10) as Uint8Array, testData]); - expect(encoded).toEqual(expected); - }); - - test('should resolve definedTypeLinkNode to enumTypeNode', () => { - const ix = instructionNode({ - arguments: [instructionArgumentNode({ name: 'encoding', type: definedTypeLinkNode('encoding') })], - name: 'testEnum', - }); - const root = makeRoot(ix, [ - definedTypeNode({ - name: 'encoding', - type: enumTypeNode([ - enumEmptyVariantTypeNode('none'), - enumEmptyVariantTypeNode('utf8'), - enumEmptyVariantTypeNode('base64'), - ]), - }), - ]); - - const encoded = encodeInstructionArguments(root, ix, { encoding: 'base64' }); - - // base64 is variant index 2 - expect(encoded).toEqual(new Uint8Array([2])); - }); - - test('should throw ARGUMENT_MISSING for missing required argument', () => { - expect(() => encodeInstructionArguments(writeRoot, writeIx, {})).toThrow( - 'Missing argument [offset] in [write].', - ); - }); - - test('should throw DEFAULT_VALUE_MISSING when omitted argument has no defaultValue', () => { - const modifiedIx: InstructionNode = { - ...writeIx, - arguments: (writeIx.arguments ?? []).map(arg => - arg.name === 'discriminator' ? { ...arg, defaultValue: undefined } : arg, - ), - }; - - expect(() => encodeInstructionArguments(writeRoot, modifiedIx, { data: null, offset: 0 })).toThrow( - 'Default value is missing for argument [discriminator] in [write].', - ); - }); - - test('should throw ValidationError when omitted argument is provided', () => { - const validate = createArgumentsInputValidator(writeRoot, writeIx); - expect(() => validate({ data: null, discriminator: 99, offset: 0 })).toThrow(CodamaError); - }); - - test('should encode instruction with only omitted discriminator (no user args)', () => { - const closeIx = instructionNode({ - arguments: [ - instructionArgumentNode({ - defaultValue: numberValueNode(6), - defaultValueStrategy: 'omitted', - name: 'discriminator', - type: numberTypeNode('u8'), - }), - ], - name: 'close', - }); - const root = makeRoot(closeIx); - - const encoded = encodeInstructionArguments(root, closeIx, {}); - expect(encoded).toEqual(new Uint8Array([6])); - }); -}); diff --git a/packages/dynamic-instructions/test/arguments/validate-arguments-input.test.ts b/packages/dynamic-instructions/test/arguments/validate-arguments-input.test.ts deleted file mode 100644 index 9d0e2d329..000000000 --- a/packages/dynamic-instructions/test/arguments/validate-arguments-input.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { address } from '@solana/addresses'; -import type { InstructionNode } from 'codama'; -import { - argumentValueNode, - definedTypeLinkNode, - definedTypeNode, - enumEmptyVariantTypeNode, - enumStructVariantTypeNode, - enumTypeNode, - instructionArgumentNode, - instructionNode, - instructionRemainingAccountsNode, - numberTypeNode, - programNode, - rootNode, - structFieldTypeNode, - structTypeNode, -} from 'codama'; -import { describe, expect, test } from 'vitest'; - -import { encodeInstructionArguments } from '../../src/arguments/encode-instruction-arguments'; -import { createArgumentsInputValidator } from '../../src/arguments/validate-arguments-input'; - -const PROGRAM_KEY = '11111111111111111111111111111111'; - -function makeRoot(ix: InstructionNode, definedTypes: Parameters[0]['definedTypes'] = []) { - return rootNode(programNode({ definedTypes, instructions: [ix], name: 'test', publicKey: PROGRAM_KEY })); -} - -describe('Instruction validation: remaining account arguments', () => { - const ADDR_1 = address('11111111111111111111111111111111'); - const ADDR_2 = address('22222222222222222222222222222222222222222222'); - - const multisigIx = instructionNode({ - arguments: [instructionArgumentNode({ name: 'm', type: numberTypeNode('u8') })], - name: 'initializeMultisig', - remainingAccounts: [ - instructionRemainingAccountsNode(argumentValueNode('signers'), { isOptional: false, isSigner: false }), - ], - }); - const multisigRoot = makeRoot(multisigIx); - - const transferIx = instructionNode({ - arguments: [instructionArgumentNode({ name: 'amount', type: numberTypeNode('u64') })], - name: 'transfer', - remainingAccounts: [ - instructionRemainingAccountsNode(argumentValueNode('multiSigners'), { isOptional: true, isSigner: false }), - ], - }); - const transferRoot = makeRoot(transferIx); - - test('should not reject remaining account args as extra keys', () => { - const validate = createArgumentsInputValidator(multisigRoot, multisigIx); - expect(() => validate({ m: 2, signers: [ADDR_1, ADDR_2] })).not.toThrow(); - }); - - test('should still validate regular arguments when remaining account args are present', () => { - const validate = createArgumentsInputValidator(multisigRoot, multisigIx); - expect(() => validate({ m: 'invalid', signers: [ADDR_1] })).toThrow('Invalid argument "m"'); - }); - - test('should not reject optional remaining account args when omitted', () => { - const validate = createArgumentsInputValidator(transferRoot, transferIx); - expect(() => validate({ amount: 100 })).not.toThrow(); - }); - - test('should not reject optional remaining account args when provided', () => { - const validate = createArgumentsInputValidator(transferRoot, transferIx); - expect(() => validate({ amount: 100, multiSigners: [ADDR_1] })).not.toThrow(); - }); - - test('should not encode remaining account args as instruction data', () => { - const withSigners = encodeInstructionArguments(multisigRoot, multisigIx, { - m: 2, - signers: [ADDR_1, ADDR_2], - }); - const withoutSigners = encodeInstructionArguments(multisigRoot, multisigIx, { m: 2 }); - - expect(withSigners).toEqual(withoutSigners); - }); -}); - -describe('Instruction validation: enum inputs accept the shape the codec decodes', () => { - const seedEnum = definedTypeNode({ - name: 'seedEnum', - type: enumTypeNode([ - enumEmptyVariantTypeNode('arm'), - enumEmptyVariantTypeNode('bar'), - enumEmptyVariantTypeNode('car'), - ]), - }); - const command = definedTypeNode({ - name: 'command', - type: enumTypeNode([ - enumEmptyVariantTypeNode('quit'), - enumStructVariantTypeNode( - 'move', - structTypeNode([structFieldTypeNode({ name: 'x', type: numberTypeNode('u8') })]), - ), - ]), - }); - const enumIx = instructionNode({ - arguments: [ - instructionArgumentNode({ name: 'seedEnum', type: definedTypeLinkNode('seedEnum') }), - instructionArgumentNode({ name: 'command', type: definedTypeLinkNode('command') }), - ], - name: 'nestedExampleIx', - }); - const enumRoot = makeRoot(enumIx, [seedEnum, command]); - const validate = createArgumentsInputValidator(enumRoot, enumIx); - - const validSeed = 'arm'; - const validCommand = { __kind: 'quit' }; - - test('accepts an empty variant as a PascalCase __kind object', () => { - expect(() => validate({ command: validCommand, seedEnum: { __kind: 'Arm' } })).not.toThrow(); - }); - - test('accepts an empty variant as a PascalCase bare name', () => { - expect(() => validate({ command: validCommand, seedEnum: 'Arm' })).not.toThrow(); - }); - - test('accepts a struct variant as a PascalCase __kind object', () => { - expect(() => validate({ command: { __kind: 'Move', x: 12 }, seedEnum: validSeed })).not.toThrow(); - }); - - test('still accepts the raw camelCase shapes', () => { - expect(() => validate({ command: { __kind: 'move', x: 12 }, seedEnum: 'arm' })).not.toThrow(); - }); - - test('still rejects an invalid payload under a PascalCase __kind', () => { - expect(() => validate({ command: { __kind: 'Move', x: 'oops' }, seedEnum: validSeed })).toThrow( - /Enum variant "Move" has invalid/, - ); - }); - - test('still rejects an unknown variant', () => { - expect(() => validate({ command: validCommand, seedEnum: { __kind: 'Leg' } })).toThrow( - /Invalid enum variant "Leg"/, - ); - }); -}); diff --git a/packages/dynamic-instructions/test/cli/generate-types.test.ts b/packages/dynamic-instructions/test/cli/generate-types.test.ts index 92c3056f0..b6c0318c8 100644 --- a/packages/dynamic-instructions/test/cli/generate-types.test.ts +++ b/packages/dynamic-instructions/test/cli/generate-types.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { afterAll, describe, expect, test } from 'vitest'; -import { makeRoot } from '../test-utils'; +import { makeRoot } from '../_setup'; const CLI_PATH = path.resolve('bin/cli.cjs'); diff --git a/packages/dynamic-instructions/test/codegen/collect-either-signer-names.test.ts b/packages/dynamic-instructions/test/codegen/collect-either-signer-names.test.ts index 28e9ac02d..66ef5bdbc 100644 --- a/packages/dynamic-instructions/test/codegen/collect-either-signer-names.test.ts +++ b/packages/dynamic-instructions/test/codegen/collect-either-signer-names.test.ts @@ -7,21 +7,19 @@ describe('collectEitherSignerNames', () => { test('should return the names of accounts with isSigner: "either"', () => { const ix = instructionNode({ accounts: [ - instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' }), - instructionAccountNode({ isSigner: true, isWritable: true, name: 'payer' }), - instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'delegate' }), + instructionAccountNode({ identifier: 'authority', isSigner: 'either', isWritable: false }), + instructionAccountNode({ identifier: 'payer', isSigner: true, isWritable: true }), + instructionAccountNode({ identifier: 'delegate', isSigner: 'either', isWritable: false }), ], - arguments: [], - name: 'transfer', + identifier: 'transfer', }); expect(collectEitherSignerNames(ix)).toEqual(['authority', 'delegate']); }); test('should return an empty array when no account is isSigner: "either"', () => { const ix = instructionNode({ - accounts: [instructionAccountNode({ isSigner: true, isWritable: true, name: 'payer' })], - arguments: [], - name: 'pay', + accounts: [instructionAccountNode({ identifier: 'payer', isSigner: true, isWritable: true })], + identifier: 'pay', }); expect(collectEitherSignerNames(ix)).toEqual([]); }); diff --git a/packages/dynamic-instructions/test/codegen/generate-instruction-builder.test.ts b/packages/dynamic-instructions/test/codegen/generate-instruction-builder.test.ts index 7e511a25f..55bc759cc 100644 --- a/packages/dynamic-instructions/test/codegen/generate-instruction-builder.test.ts +++ b/packages/dynamic-instructions/test/codegen/generate-instruction-builder.test.ts @@ -1,30 +1,27 @@ -import { instructionAccountNode, instructionArgumentNode, instructionNode } from 'codama'; +import { instructionAccountNode, instructionNode, integerTypeNode, structFieldTypeNode, structTypeNode } from 'codama'; import { describe, expect, test } from 'vitest'; import { generateInstructionBuildersMap } from '../../src/codegen/generate-instruction-builder'; -import { makeRoot } from '../test-utils'; +import { makeRoot } from '../_setup'; describe('generateInstructionBuildersMap', () => { test('should generate InstructionBuilders aggregate map type', () => { const root = makeRoot( [ instructionNode({ - accounts: [instructionAccountNode({ isSigner: false, isWritable: true, name: 'source' })], - arguments: [ - instructionArgumentNode({ - name: 'amount', - type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, - }), - ], - name: 'transfer', + accounts: [instructionAccountNode({ identifier: 'source', isSigner: false, isWritable: true })], + data: structTypeNode([structFieldTypeNode({ identifier: 'amount', type: integerTypeNode('u64') })]), + identifier: 'transfer', }), - instructionNode({ name: 'close' }), + instructionNode({ identifier: 'close' }), ], 'token', ); const output = generateInstructionBuildersMap(root); expect(output).toContain('export type TokenInstructionBuilders'); - expect(output).toContain('transfer: InstructionsBuilderFn;'); - expect(output).toContain('close: InstructionsBuilderFn, CloseAccounts, string[]>;'); + expect(output).toContain( + 'transfer: InstructionsBuilderFn;', + ); + expect(output).toContain('close: InstructionsBuilderFn;'); }); }); diff --git a/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts b/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts index 3a5a4d7e6..44c151962 100644 --- a/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts +++ b/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts @@ -2,17 +2,17 @@ import { instructionAccountNode, instructionNode } from 'codama'; import { describe, expect, test } from 'vitest'; import { generateSignerTypes, getInstructionSignerRef } from '../../src/codegen/generate-signer-types'; -import { makeRoot } from '../test-utils'; +import { makeRoot } from '../_setup'; describe('generateSignerTypes', () => { test('should generate Signers type when isSigner: "either" exists', () => { const root = makeRoot([ instructionNode({ accounts: [ - instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' }), - instructionAccountNode({ isSigner: false, isWritable: true, name: 'source' }), + instructionAccountNode({ identifier: 'authority', isSigner: 'either', isWritable: false }), + instructionAccountNode({ identifier: 'source', isSigner: false, isWritable: true }), ], - name: 'transfer', + identifier: 'transfer', }), ]); const output = generateSignerTypes(root); @@ -22,8 +22,8 @@ describe('generateSignerTypes', () => { test('should not generate Signers block when there are no isSigner: "either" accounts', () => { const root = makeRoot([ instructionNode({ - accounts: [instructionAccountNode({ isSigner: true, isWritable: true, name: 'payer' })], - name: 'noEither', + accounts: [instructionAccountNode({ identifier: 'payer', isSigner: true, isWritable: true })], + identifier: 'noEither', }), ]); const output = generateSignerTypes(root); @@ -34,9 +34,8 @@ describe('generateSignerTypes', () => { describe('getInstructionSignerRef', () => { test('should return ${Name}Signers when an account has isSigner: "either"', () => { const ix = instructionNode({ - accounts: [instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' })], - arguments: [], - name: 'transfer', + accounts: [instructionAccountNode({ identifier: 'authority', isSigner: 'either', isWritable: false })], + identifier: 'transfer', }); const ref = getInstructionSignerRef(ix); expect(ref.signersRef).toBe('TransferSigners'); @@ -45,9 +44,8 @@ describe('getInstructionSignerRef', () => { test('should return null signersRef when no account has isSigner: "either"', () => { const ix = instructionNode({ - accounts: [instructionAccountNode({ isSigner: true, isWritable: true, name: 'payer' })], - arguments: [], - name: 'pay', + accounts: [instructionAccountNode({ identifier: 'payer', isSigner: true, isWritable: true })], + identifier: 'pay', }); const ref = getInstructionSignerRef(ix); expect(ref.signersRef).toBeNull(); diff --git a/packages/dynamic-instructions/test/codegen/generate-types.test.ts b/packages/dynamic-instructions/test/codegen/generate-types.test.ts index ffd50c1f0..1f90a6925 100644 --- a/packages/dynamic-instructions/test/codegen/generate-types.test.ts +++ b/packages/dynamic-instructions/test/codegen/generate-types.test.ts @@ -1,8 +1,8 @@ -import { instructionAccountNode, instructionArgumentNode, instructionNode } from 'codama'; +import { instructionAccountNode, instructionNode, integerTypeNode, structFieldTypeNode, structTypeNode } from 'codama'; import { describe, expect, test } from 'vitest'; import { generateTypes } from '../../src/codegen/generate-types'; -import { makeRoot } from '../test-utils'; +import { makeRoot } from '../_setup'; describe('generateTypes', () => { test('should compose header, instruction blocks, signers, and instruction builders map', () => { @@ -10,16 +10,11 @@ describe('generateTypes', () => { [ instructionNode({ accounts: [ - instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' }), - instructionAccountNode({ isSigner: false, isWritable: true, name: 'source' }), + instructionAccountNode({ identifier: 'authority', isSigner: 'either', isWritable: false }), + instructionAccountNode({ identifier: 'source', isSigner: false, isWritable: true }), ], - arguments: [ - instructionArgumentNode({ - name: 'amount', - type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, - }), - ], - name: 'transfer', + data: structTypeNode([structFieldTypeNode({ identifier: 'amount', type: integerTypeNode('u64') })]), + identifier: 'transfer', }), ], 'token', @@ -28,10 +23,12 @@ describe('generateTypes', () => { // Header expect(output).toContain('Auto-generated instruction types'); expect(output).toContain("import type { InstructionsBuilderFn } from '@codama/dynamic-instructions';"); - expect(output).toContain('export type TransferArgs'); + expect(output).toContain('export type TransferInstructionDataArgs'); expect(output).toContain('export type TransferAccounts'); expect(output).toContain("export type TransferSigners = ('authority')[];"); expect(output).toContain('export type TokenInstructionBuilders'); - expect(output).toContain('transfer: InstructionsBuilderFn', + ); }); }); diff --git a/packages/dynamic-instructions/test/data/encode-instruction-data.test.ts b/packages/dynamic-instructions/test/data/encode-instruction-data.test.ts new file mode 100644 index 000000000..54afb2770 --- /dev/null +++ b/packages/dynamic-instructions/test/data/encode-instruction-data.test.ts @@ -0,0 +1,110 @@ +import { + CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, + CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED, +} from '@codama/errors'; +import { getBase16Encoder } from '@solana/codecs'; +import { + definedTypeLinkNode, + definedTypeNode, + injectedValueNode, + instructionNode, + integerTypeNode, + integerValueNode, + providedNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { encodeInstructionData } from '../../src'; +import { getInstructionPath } from '../_setup'; + +const hex = (value: string) => getBase16Encoder().encode(value); + +const amountType = integerTypeNode('u8'); +const amount = structFieldTypeNode({ identifier: 'amount', type: amountType }); +const discriminator = structFieldTypeNode({ + defaultValue: integerValueNode('3'), + defaultValueStrategy: 'omitted', + identifier: 'discriminator', + type: integerTypeNode('u8'), +}); +const data = structTypeNode([discriminator, amount]); + +describe('encodeInstructionData', () => { + test('it encodes the data of instructions, including their default values', () => { + const path = getInstructionPath(instructionNode({ data, identifier: 'transfer' })); + expect(encodeInstructionData(path, { amount: 42 })).toStrictEqual(hex('032a')); + }); + + test('it encodes instructions without data to empty bytes', () => { + const path = getInstructionPath(instructionNode({ identifier: 'noop' })); + expect(encodeInstructionData(path)).toStrictEqual(hex('')); + }); + + test('it encodes linked data types', () => { + const args = definedTypeNode({ identifier: 'transferArgs', type: data }); + const path = getInstructionPath( + instructionNode({ data: definedTypeLinkNode('transferArgs'), identifier: 'transfer' }), + { definedTypes: [args] }, + ); + expect(encodeInstructionData(path, { amount: 42 })).toStrictEqual(hex('032a')); + }); + + test('it resolves injected default values from the instruction', () => { + const fee = structFieldTypeNode({ + defaultValue: injectedValueNode({ key: 'fee' }), + identifier: 'fee', + type: integerTypeNode('u8'), + }); + const path = getInstructionPath( + instructionNode({ + data: structTypeNode([fee]), + identifier: 'pay', + provides: [providedNode('fee', integerValueNode('7'))], + }), + ); + expect(encodeInstructionData(path, {})).toStrictEqual(hex('07')); + }); + + test('it throws value type errors as is', () => { + const instruction = instructionNode({ data, identifier: 'transfer' }); + const path = getInstructionPath(instruction); + expect(() => encodeInstructionData(path, { amount: 'abc' })).toThrow( + expect.objectContaining({ + context: { + __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE, + actualType: 'string', + expectedType: 'integer (number | bigint)', + nodeKind: 'integerTypeNode', + nodePath: [...path, data, amount, amountType], + }, + }), + ); + }); + + test('it throws other Codama errors as is', () => { + const fee = structFieldTypeNode({ + defaultValue: injectedValueNode({ key: 'fee' }), + identifier: 'fee', + type: integerTypeNode('u8'), + }); + const path = getInstructionPath(instructionNode({ data: structTypeNode([fee]), identifier: 'pay' })); + expect(() => encodeInstructionData(path, {})).toThrow( + expect.objectContaining({ + context: expect.objectContaining({ __code: CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED, key: 'fee' }), + }), + ); + }); + + test('it wraps other encoding errors', () => { + const path = getInstructionPath(instructionNode({ data, identifier: 'transfer' })); + expect(() => encodeInstructionData(path, { amount: 300 })).toThrow( + expect.objectContaining({ + cause: expect.objectContaining({ message: expect.stringContaining('300') }), + context: { __code: CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, instructionName: 'transfer' }, + }), + ); + }); +}); diff --git a/packages/dynamic-instructions/test/instructions-builder.test.ts b/packages/dynamic-instructions/test/instructions-builder.test.ts new file mode 100644 index 000000000..776ceeb8e --- /dev/null +++ b/packages/dynamic-instructions/test/instructions-builder.test.ts @@ -0,0 +1,78 @@ +import { CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE } from '@codama/errors'; +import { getBase16Encoder } from '@solana/codecs'; +import { AccountRole } from '@solana/instructions'; +import { + instructionAccountNode, + instructionNode, + instructionRemainingAccountsNode, + integerTypeNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { createInstructionsBuilder } from '../src'; +import { generateAddress, getInstructionPath, PROGRAM_ADDRESS } from './_setup'; + +const transfer = instructionNode({ + accounts: [ + instructionAccountNode({ identifier: 'source', isSigner: false, isWritable: true }), + instructionAccountNode({ identifier: 'authority', isSigner: 'either', isWritable: false }), + ], + data: structTypeNode([structFieldTypeNode({ identifier: 'amount', type: integerTypeNode('u16') })]), + identifier: 'transfer', + remainingAccounts: [instructionRemainingAccountsNode('signers', { isOptional: true, isSigner: true })], +}); + +describe('createInstructionsBuilder', () => { + test('it builds instructions from their accounts, data and signers', async () => { + const [source, authority, signer] = await Promise.all([ + generateAddress(), + generateAddress(), + generateAddress(), + ]); + const build = createInstructionsBuilder(getInstructionPath(transfer)); + const instruction = await build({ + accounts: { authority, signers: [signer], source }, + data: { amount: 42 }, + signers: ['authority'], + }); + expect(instruction).toStrictEqual({ + accounts: [ + { address: source, role: AccountRole.WRITABLE }, + { address: authority, role: AccountRole.READONLY_SIGNER }, + { address: signer, role: AccountRole.READONLY_SIGNER }, + ], + data: getBase16Encoder().encode('2a00'), + programAddress: PROGRAM_ADDRESS, + }); + }); + + test('it builds instructions without inputs', async () => { + const build = createInstructionsBuilder(getInstructionPath(instructionNode({ identifier: 'noop' }))); + expect(await build()).toStrictEqual({ accounts: [], data: new Uint8Array(), programAddress: PROGRAM_ADDRESS }); + }); + + test('it uses the address of the program defining the instruction', async () => { + const otherAddress = await generateAddress(); + const noop = instructionNode({ identifier: 'noop' }); + const other = programNode({ identifier: 'other', instructions: [noop], publicKey: otherAddress }); + const root = rootNode(programNode({ identifier: 'main', publicKey: PROGRAM_ADDRESS }), { + additionalPrograms: [other], + }); + const build = createInstructionsBuilder([root, other, noop]); + expect((await build()).programAddress).toBe(otherAddress); + }); + + test('it rejects data of the wrong type', async () => { + const [source, authority] = await Promise.all([generateAddress(), generateAddress()]); + const build = createInstructionsBuilder(getInstructionPath(transfer)); + await expect(build({ accounts: { authority, source }, data: { amount: 'abc' } })).rejects.toThrow( + expect.objectContaining({ + context: expect.objectContaining({ __code: CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE }), + }), + ); + }); +}); diff --git a/packages/dynamic-instructions/test/shared/types.test.ts b/packages/dynamic-instructions/test/shared/types.test.ts index f0ff9f559..17d4d7ba0 100644 --- a/packages/dynamic-instructions/test/shared/types.test.ts +++ b/packages/dynamic-instructions/test/shared/types.test.ts @@ -1,24 +1,33 @@ +import type { Address } from '@solana/addresses'; import type { Instruction } from '@solana/instructions'; import { describe, expectTypeOf, test } from 'vitest'; -import type { EitherSigners, InstructionsBuilderFn } from '../../src/shared/types'; +import type { EitherSigners, InstructionInput, InstructionsBuilderFn } from '../../src/shared/types'; describe('InstructionsBuilderFn', () => { - test('should return a Promise of Instruction', () => { + test('it returns a Promise of Instruction', () => { expectTypeOf().returns.toEqualTypeOf>(); }); - test('should allow calling with no arguments', () => { + test('it can be called without inputs', () => { expectTypeOf().toBeCallableWith(); }); - test('should accept all four parameters', () => { - expectTypeOf().toBeCallableWith({}, {}, [], {}); + test('it accepts accounts, data and signers', () => { + expectTypeOf().toBeCallableWith({ accounts: {}, data: {}, signers: [] }); + }); + + test('it types its inputs with its type parameters', () => { + type Build = InstructionsBuilderFn<{ amount: bigint }, { signers: Address[]; source: Address }, 'owner'[]>; + expectTypeOf[0]>().toEqualTypeOf< + InstructionInput<{ amount: bigint }, { signers: Address[]; source: Address }, 'owner'[]> | undefined + >(); + expectTypeOf[0]>['data']>().toEqualTypeOf<{ amount: bigint } | undefined>(); }); }); describe('EitherSigners', () => { - test('should be an array of strings', () => { + test('it is an array of strings', () => { expectTypeOf().toEqualTypeOf(); }); }); diff --git a/packages/dynamic-instructions/test/validators/validators.test.ts b/packages/dynamic-instructions/test/validators/validators.test.ts deleted file mode 100644 index d70f3a38f..000000000 --- a/packages/dynamic-instructions/test/validators/validators.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { InstructionArgumentNode } from 'codama'; -import { describe, expect, test } from 'vitest'; - -import { createIxArgumentsValidator } from '../../src/validators'; - -describe('Validators', () => { - test('should throw for unsupported TypeNode kind', () => { - const fakeIxArguments = [ - { - name: 'testArg', - type: { kind: 'fooBarTypeNode' }, - }, - ] as unknown as InstructionArgumentNode[]; - - expect(() => createIxArgumentsValidator('testInstruction', fakeIxArguments, [])).toThrow( - 'Validator for TypeNode "testInstruction_testArg_0" kind: fooBarTypeNode is not implemented!', - ); - }); -}); diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index c24f0df77..11fcedf29 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -91,10 +91,11 @@ export const CODAMA_ERROR__DYNAMIC_CLIENT__CIRCULAR_ACCOUNT_DEPENDENCY = 2500008 export const CODAMA_ERROR__DYNAMIC_CLIENT__UNSUPPORTED_OPTIONAL_ACCOUNT_STRATEGY = 2500009; export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA = 2500010; export const CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING = 2500011; +/** @deprecated No longer thrown as of Codama v2, where codecs encode default values themselves. */ export const CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING = 2500012; -export const CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT = 2500013; +export const CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT = 2500013; export const CODAMA_ERROR__DYNAMIC_CLIENT__UNEXPECTED_VALUE_TYPE = 2500014; -export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT = 2500015; +export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA = 2500015; /** @deprecated No longer thrown as of Codama v2, which removed `resolverValueNode`. */ export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER = 2500016; export const CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT = 2500017; @@ -141,12 +142,12 @@ export type CodamaErrorCode = | typeof CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING | typeof CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA - | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT + | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER | typeof CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT | typeof CODAMA_ERROR__DYNAMIC_CLIENT__INSTRUCTION_NOT_FOUND | typeof CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS - | typeof CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT + | typeof CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT | typeof CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION | typeof CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND | typeof CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index f55b9bbba..d05a15acb 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -43,12 +43,12 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA, - CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, + CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, CODAMA_ERROR__DYNAMIC_CLIENT__INSTRUCTION_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, - CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, @@ -167,8 +167,7 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA]: { accountName: IdentifierString; }; - [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT]: { - argumentName: IdentifierString; + [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA]: { instructionName: IdentifierString; }; [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER]: { @@ -187,8 +186,8 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ accountName: IdentifierString; value: string; }; - [CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT]: { - argumentName: IdentifierString; + [CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT]: { + accountName: IdentifierString; expectedType: string; value: string; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 2be354292..b31096af7 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -22,12 +22,12 @@ import { CODAMA_ERROR__DYNAMIC_CLIENT__DATA_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA, - CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT, + CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER, CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT, CODAMA_ERROR__DYNAMIC_CLIENT__INSTRUCTION_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS, - CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT, + CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT, CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND, CODAMA_ERROR__DYNAMIC_CLIENT__PDA_NOT_FOUND, @@ -103,16 +103,15 @@ export const CodamaErrorMessages: Readonly<{ [CODAMA_ERROR__DYNAMIC_CLIENT__DEFAULT_VALUE_MISSING]: 'Default value is missing for argument [$argumentName] in [$instructionName].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_DERIVE_PDA]: 'Failed to derive PDA for account [$accountName].', - [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_ARGUMENT]: - 'Failed to encode argument [$argumentName] in [$instructionName].', + [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_ENCODE_DATA]: 'Failed to encode the data of [$instructionName].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_EXECUTE_RESOLVER]: 'Resolver [$resolverName] threw an error while resolving [$targetKind] [$targetName].', [CODAMA_ERROR__DYNAMIC_CLIENT__FAILED_TO_VALIDATE_INPUT]: 'Failed to validate input: [$message].', [CODAMA_ERROR__DYNAMIC_CLIENT__INSTRUCTION_NOT_FOUND]: 'Instruction [$instructionName] not found in IDL. Available: [$availableIxs].', [CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_ADDRESS]: 'Invalid account address [$accountName]: [$value].', - [CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ARGUMENT_INPUT]: - 'Invalid argument input [$argumentName]: [$value]. Expected [$expectedType].', + [CODAMA_ERROR__DYNAMIC_CLIENT__INVALID_ACCOUNT_INPUT]: + 'Invalid input for account [$accountName]: [$value]. Expected [$expectedType].', [CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION]: 'Internal invariant violation: [$message].', [CODAMA_ERROR__DYNAMIC_CLIENT__NODE_REFERENCE_NOT_FOUND]: 'Referenced node [$referencedName] not found in [$instructionName].', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf5ba7aeb..cceb6b1d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,9 +225,6 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 - superstruct: - specifier: ^2.0.2 - version: 2.0.2 devDependencies: '@solana/kit': specifier: 8.4.0