Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/dynamic-address-resolution/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion packages/dynamic-address-resolution/src/resolvers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,6 +12,7 @@ import {
findProgramNodeFromPath,
getLastNodeFromPath,
getRecordLinkablesVisitor,
type IdentifierString,
type InstructionNode,
isNode,
LinkableDictionary,
Expand All @@ -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<Node, LinkableDictionary>();
Expand Down Expand Up @@ -66,6 +69,26 @@ export function createResolutionContext<TAccounts extends AccountsInput, TData e
};
}

/**
* The address provided for the given account, if any. Throws
* `UNEXPECTED_ADDRESS_TYPE` when given a list of addresses, which only
* remaining accounts accept.
*/
export function getAccountInput(
ctx: Pick<ResolutionContext, 'accountsInput'>,
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -21,7 +21,7 @@ export async function resolveAccountAddress(
ctx: ResolutionContext,
): Promise<Address | null> {
// 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,7 +19,7 @@ export async function resolveAccountValueNodeAddress(
node: AccountValueNode,
ctx: ResolutionContext,
): Promise<Address | null> {
const providedAddress = ctx.accountsInput?.[node.identifier];
const providedAddress = getAccountInput(ctx, node.identifier);
if (providedAddress !== undefined && providedAddress !== null) {
return toAddress(providedAddress);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/dynamic-address-resolution/src/shared/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/dynamic-address-resolution/src/shared/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, AddressInput | null | undefined>>;
/**
* 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<Record<string, AddressInput | readonly AddressInput[] | null | undefined>>;

/**
* The data provided for an instruction, as accepted by the codec of its `data`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,7 +50,7 @@ export function createAccountDefaultValueVisitor(
ixAccountNode: InstructionAccountNode,
ctx: ResolutionContext,
): Visitor<Promise<Address | null>, 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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/dynamic-address-resolution/test/shared/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountsInput>().toExtend<Partial<Record<string, AddressInput | null>>>();
test('it accepts a partial record of AddressInput, lists of AddressInput or null', () => {
expectTypeOf<AccountsInput>().toExtend<
Partial<Record<string, AddressInput | readonly AddressInput[] | null>>
>();
expectTypeOf<{ signers: Address[] }>().toExtend<AccountsInput>();
expectTypeOf<{ mint: null }>().toExtend<AccountsInput>();
expectTypeOf<{ mint: Address }>().toExtend<AccountsInput>();
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
Expand Down
6 changes: 3 additions & 3 deletions packages/dynamic-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 32 additions & 52 deletions packages/dynamic-instructions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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

Expand All @@ -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/<idl-name>-instruction-types';

const build = createInstructionsBuilder<CreateItemArgs, CreateItemAccounts, [], CreateItemResolvers>(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/<idl-name>-instruction-types';

> Types are generated via [`generate-types`](#types-generation).

```ts
import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/<idl-name>-instruction-types';

const accountMetas = await createAccountMeta<CreateItemAccounts, CreateItemArgs, CreateItemResolvers>(
root,
ixNode,
{ name: 'item' },
{ authority },
['owner'],
{ resolveOwner: async (args, accounts) => accounts.authority },
);
const build = createInstructionsBuilder<TransferInstructionDataArgs, TransferAccounts, TransferSigners>(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/<idl-name>-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<TransferArgs>(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)
Expand Down
3 changes: 1 addition & 2 deletions packages/dynamic-instructions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading