Skip to content

feat: add dynamic-instructions#987

Open
mikhd wants to merge 6 commits intocodama-idl:mainfrom
hoodieshq:main-dynamic-instructions
Open

feat: add dynamic-instructions#987
mikhd wants to merge 6 commits intocodama-idl:mainfrom
hoodieshq:main-dynamic-instructions

Conversation

@mikhd
Copy link
Copy Markdown

@mikhd mikhd commented Mar 25, 2026

Summary

Adds @codama/dynamic-instructions runtime instruction builder that creates Instruction from Codama IDLs without code generation.

Key Features

  • Anchor-like builder APIprogramClient.methods.transfer({}).accounts({}).instruction().
  • Auto-resolution — Accounts with defaultValue (PDAs, program IDs, constants) are resolved automatically.
  • Typed & untyped — Use without or with types createProgramClient<MyProgramClient>(idl).
  • Standalone PDA derivationprogramClient.pdas.myPda({seeds}).
  • CLI — Generate ProgramClient TypeScript types with npx @codama/dynamic-instructions generate-client-types <idl.json> <output-dir> for a given program.

Package Structure

  packages/dynamic-instructions/
  ├── src/
  │   ├── index.ts                      # Public API entry point
  │   ├── types/                        # Type re-exports
  │   ├── program-client/               # Public API client
  │   │   ├── create-program-client.ts  #   Client creation entry point
  │   │   ├── methods-builder.ts        #   Client methods builder for creating Instruction
  │   │   ├── collect-pdas.ts           #   PDA collection from IDL
  │   │   └── derive-standalone-pda.ts  #   Standalone PDA derivation
  │   ├── instruction-encoding/         # Instruction creation
  │   │   ├── instructions.ts           #   Instruction encoding pipeline
  │   │   ├── validators.ts             #   Validators for accounts, arguments and nodes (superstruct)
  │   │   ├── accounts/                 #   AccountMeta creation and validation
  │   │   ├── arguments/                #   Arguments encoding and validation
  │   │   ├── resolvers/                #   Account/PDA/conditional resolution
  │   │   └── visitors/                 #   IDL tree-traversal visitors
  │   ├── shared/                       # Utilities
  │   └── cli/                          # CLI with type generation (commanderjs)
  │       └── commands/
  ├── test/
  │   ├── unit/                         # Unit tests
  │   ├── programs/                     # Program integration tests via LiteSVM
  │   │   ├── system-program/           #   System program
  │   │   ├── token/                    #   SPL Token program
  │   │   ├── token-2022/               #   Token-2022 program
  │   │   ├── associated-token-account/ #   ATA program
  │   │   ├── mpl-token-metadata/       #   MPL Token Metadata program
  │   │   ├── pmp/                      #   PMP program
  │   │   ├── sas/                      #   SAS program
  │   │   ├── anchor/                   #   Custom Anchor program (custom and edge cases)
  │   │   ├── circular-account-refs/    #   Circular dependency edge case
  │   │   ├── custom-resolvers/         #   Custom resolver integration
  │   │   ├── idls/                     #   Codama IDL JSONs
  │   │   ├── generated/                #   Auto-generated types from Codama idls/
  │   │   └── dumps/                    #   Program .so dumps for LiteSVM
  │   └── svm-test-context.ts           # LiteSVM wrapper

Instruction Building Pipeline

const client = createProgramClient<MyProgramClientType>(idl) // ProgramClient

client.methods.<instructionName>(arguments)
    .accounts({}) // required accounts
    .signers([]) // clarified "either" signers
    .resolvers({}) // resolvers for ResolverValueNode
    .instruction() // Instruction

Under the hood it:

  1. Validates arguments and accounts input.
  2. Resolves arguments from custom resolvers (optionally).
  3. Encodes arguments to bytes.
  4. Resolves account addresses (PDAs, program IDs, conditionals).
  5. Returns final Instruction { programAddress, accounts, data }.

Tests

  • test/unit — contains tests for the package.
  • test/program — contains tests of ProgramClient public interface in LiteSVM environment. ProgramClient is used for building and sending instructions to Solana programs.

Notes:

  • CI/CD update:
    • We're using Anchor@0.32.1 for simulation of some custom IDL cases, thus Anchor and Rust are required during tests.
    • Solana version was updated v2 to v3 due to issues with anchor spl-token compilation.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 25, 2026

⚠️ No Changeset found

Latest commit: f5d8150

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Member

@lorisleiva lorisleiva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey guys, thank you so much for the heroic amount of work here. It's very hard for me to review everything thoroughly so I thought I'd try a quick "first pass" at the PR but randomly jumping into various source files and giving my first impressions. I apologise in advance if I lacked context within my comments but hopefully you can rectify me pretty quickly if that's the case. There's a mixture of small comments and comments that could lead to significant changes so please let me know if you don't have enough time to dedicate to some of these.

Comment on lines +58 to +59
if (!textEncoder) textEncoder = new TextEncoder();
return textEncoder;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here you want the getUtf8Encoder to support all environments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 81f531b

Comment on lines +7 to +8
* `pdaValueNode > pdaNode` definitions inside instruction account
* `defaultValue` nodes. Deduplicates by PDA name.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary anymore since this PR: #984

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for flagging this!

Here's my understanding:

The extractPdasVisitor from #984 runs in the nodes-from-anchor pipeline (rootNodeFromAnchor -> defaultVisitor) where it extracts pdaNode definitions from instructions into a program-level pdas: PdaNodes[] and replaces them with pdaLinkNode inline references. That works in the Anchor IDL -> Codama IDL conversion.

dynamic-instructions operates with raw user's Codama IDL json. RootNode is created from createFromJson(json) (in create-program-client.ts), which parses the raw user's Codama IDL JSON.
So the RootNode it receives may still contain inline pdaNode entries inside instruction account defaultValue fields.

We could potentially apply extractPdasVisitor early in the createProgramClient flow so that the IDL is always normalized before processing it (user's raw IDL JSON -> createFromJson(json) -> getRoot() -> extractPdasVisitor).

But this would:

  • Add a nodes-from-anchor dependency to dynamic-instructions.
  • Change the shape of the user's provided RootNode which might not be transparent.

Does this make sense, or were you thinking of a different approach? Please correct me if i'm missing something.

): TClient {
const json = typeof idl === 'string' ? idl : JSON.stringify(idl);
const codama = createFromJson(json);
let root = codama.getRoot();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just get the root after the if-statement?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Updated 607ea72


const pdaNodes = collectPdaNodes(root);

const pdas =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is technically returning a dynamic client with instructions and PDAs, I wonder if we shouldn't call that package dynamic-client instead?

Perhaps we should also extract the code for instructions and PDAs inside dynamic-instructions and dynamic-pdas package such that dynamic-client becomes a thin wrapper that puts everything together.

If we do all that, we give room for future improvements such as adding client.accounts.* to the dynamic client.

Comment on lines +25 to +32
export async function createAccountMeta(
root: RootNode,
ixNode: InstructionNode,
argumentsInput: ArgumentsInput = {},
accountsInput: AccountsInput = {},
signers: EitherSigners = [],
resolversInput: ResolversInput = {},
): Promise<AccountMeta[]> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if functions like these wouldn't be better suited as Visitors since they require an instruction and a root which you can get with a NodePath<InstructionNode>.

You can achieve that be recording a NodeStack in your visitors and passing them down to sub-visitors if needed so you keep the full history of visits. You can then even use a LinkableDictionary to jump all over the place and push/pop these paths in the stack.

It looks something like this:

const linkables = new LinkableDictionary();
const stack = new NodeStack();
const visitor = pipe(
    baseVisitor,
    v => recordNodeStackVisitor(v, stack),
    v => recordLinkablesOnFirstVisitVisitor(v, linkables),
);

Then you can pass the stack or linkables variable to any sub-visitors that need them. Make sure you also use recordNodeStackVisitor on these sub-visitors so they continue to update the stack.

If you haven't already I recommend you look into the visitors documentation here.

* keypair management and transaction building behind the scenes.
* Use the config parameter to include additional programs.
*/
export class SvmTestContext {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note that LiteSVM will soon be publishing a new version that's compatible with Kit so perhaps we can use that when it's released? It will avoid having to install web3.js in Codama's dev dependencies.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good timing looks like it was released this morning haha:
https://www.npmjs.com/package/litesvm/v/1.0.0

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome! :)
Fixed: f5d8150

"@solana/instructions": "^5.3.0",
"codama": "workspace:*",
"commander": "^14.0.2",
"superstruct": "^2.0.2"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you just tell me what we use superstruct for here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey,

We validate user input when building an instruction. And superstruct builds validators for arguments (based on NodeType - arrays, numbers, strings, pubkeys, etc.) and for required accounts (Address validation).

/**
* Concatenates multiple byte arrays into a single Uint8Array.
*/
export function concatBytes(chunks: ReadonlyUint8Array[]): Uint8Array {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have mergeBytes in @solana/codecs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, fixed: af86455

@@ -0,0 +1,34 @@
export class DynamicInstructionsError extends Error {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something to do as a very last step but these should be extracted as specific CodamaErrors that have a unique code and message.

* Evaluates a ConditionalValueNode's condition.
* Returns the matching branch (ifTrue or ifFalse) as an InstructionInputValueNode or undefined if no branch matches.
*/
export async function resolveConditionalValueNodeCondition({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if you guys had a look at the getResolvedInstructionInputsVisitor from visitors-core (sorry if I missed it) but it could help a lot with the resolution ordering.

---------

Co-authored-by: Alex S <alexander.shibaev@hoodies.team>
Co-authored-by: Sergo <rogaldh@radsh.red>
mikhd added 5 commits April 2, 2026 14:03
* fix: replace concatBytes with mergeBytes

* fix: types
* chore: update litesvm@1.0.0

* chore: fix tests and svm-test-context with solana kit

* feat: fix writable program address while programId optional strategy [edge case]

* chore: uninstall @solana/web3js and @types/bn.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants