Skip to content

feat: add dynamic-client#987

Merged
lorisleiva merged 11 commits intocodama-idl:mainfrom
hoodieshq:main-dynamic-instructions
Apr 14, 2026
Merged

feat: add dynamic-client#987
lorisleiva merged 11 commits intocodama-idl:mainfrom
hoodieshq:main-dynamic-instructions

Conversation

@mikhd
Copy link
Copy Markdown
Contributor

@mikhd mikhd commented Mar 25, 2026

Summary

Adds @codama/dynamic-client 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-client/
  ├── 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

🦋 Changeset detected

Latest commit: a4ce63c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@codama/dynamic-client Minor
@codama/errors Minor
@codama/dynamic-codecs Patch
@codama/dynamic-parsers Patch
codama Minor
@codama/nodes-from-anchor Patch
@codama/nodes Minor
@codama/renderers-core Patch
@codama/validators Minor
@codama/visitors-core Minor
@codama/visitors Minor
@codama/cli Patch
@codama/node-types Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another 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 +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
Contributor 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.


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.

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.

@mikhd Before I dig too much into the other parts of this review, I'd love your opinion on that one. I think splitting this package into 3 might make more sense and be consistent with the way we currently do things with dynamic-codecs and dynamic-parsers. Wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hey,
Reviewed this with guys, at the moment, it seem that the current package contains single united feature.
The question is would dynamic-instructions and dynamic-pdas be used as standalone packages or are they for internal-only usage?
It seems that renaming to dynamic-client makes sense but splitting into three packages would turn internal coupling into cross-package dependencies - more maintenance overhead without real decoupling.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you!
We checked NodeStack + LinkableDictionary adoption for the resolution pipeline.

Key constraint: all visitor infrastructure is synchronous (recordNodeStackVisitor does sync push/pop), while our resolution pipeline is async due to getProgramDerivedAddress and async custom resolver functions.

But here is a minimal example of what can be implemented (as I understood it). I guess, it doesn't fully align to your suggestion, but:

  • The NodeStack holds the [Root, Program, Instruction] path. It is created once before the sequential resolution loop and never modified during async operations. NodeStack path replaces root/ixNode parameters with getting them from Stack.
  • LinkableDictionary is created and passed as args for PDA and linked accounts lookup.

The main issue for full visitor conversion and push/pop NodeStack is that the current runtime resolution is not a normal tree transform - it is async. It also stateful and depends on runtime inputs: accountsInput, argumentsInput, custom resolvers.

What was your thoughts?

"@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
Contributor 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).

* 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you! Can you please explain what was your thougs about its usage?

Not sure I understood it correctly, but we created a minimal example of getResolvedInstructionInputsVisitor integration in a separate PR hoodieshq#14.
Here promise.all accounts were replaced by sequential resolution from getResolvedInstructionInputsVisitor.

It can help with catching circular dependencies early, but other that that we still need visitors and resolvers to evaluate conditionals, derive pdas, encode seeds based on user inputs at runtime. Maybe we are addressing different things?

Also one real-world limitation we hit was that the getResolvedInstructionInputsVisitor visitor static analysis cant resolve nested argument dependencies in resolverValueNode with dependsOn.

For example, create instruction from Metaplex mpl-token-metadata has resolverValueNode({ dependsOn: [argumentValueNode('tokenStandard')] }), where tokenStandard is a key of a struct createArgs {}.

getResolvedInstructionInputsVisitor uses getAllInstructionArguments which only returns top-level args discriminator, createArgs, doesn't find "tokenStandard" and throws CODAMA_ERROR__VISITORS__INVALID_INSTRUCTION_DEFAULT_VALUE_DEPENDENCY.

CodamaError: Dependency [tokenStandard] of kind [argumentValueNode] is not a valid dependency of [splTokenProgram] of kind [instructionAccountNode] in the [create] instruction

Our solution was to use fallback strategy: when getResolvedInstructionInputsVisitor throws, we fallback to a custom iterative loop.

mikhd and others added 8 commits April 9, 2026 15:05
---------

Co-authored-by: Alex S <alexander.shibaev@hoodies.team>
Co-authored-by: Sergo <rogaldh@radsh.red>
* 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
* fix: move errors to @codama/errors and adjust

* feat: replacing dynamic-instruction errors to codama/errors [WIP]

* feat: fix errors

* fix: errors

* fix: tests

* chore: reorder error codes

* fix: cleanup

* fix: cleanup

* fix: availableIxs

* chore: implement getMaybeNodeKind helper
@mikhd mikhd force-pushed the main-dynamic-instructions branch from 6549faa to 64dc6cc Compare April 9, 2026 12:05
@lorisleiva
Copy link
Copy Markdown
Member

@trevor-cortex Please provide a thorough review of this PR. Take all the time you need as this is a big PR and we wanna make sure we're not missing anything important before merging. Double check your findings before adding comments to your review to minimise noise. God speed.

Copy link
Copy Markdown

@trevor-cortex trevor-cortex left a comment

Choose a reason for hiding this comment

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

Summary

This is a substantial and well-structured PR that adds @codama/dynamic-instructions — a runtime instruction builder that creates Instruction objects from Codama IDLs without code generation. The package provides an Anchor-like builder API with auto-resolution of PDA accounts, program IDs, and conditional defaults.

The architecture is solid: clean separation between the public API (program-client/), instruction encoding pipeline (instruction-encoding/), and CLI. The visitor pattern for node resolution is well-applied. The test suite is impressively thorough — integration tests against real Solana programs (System, Token, ATA, MPL Token Metadata, PMP) via LiteSVM, plus edge cases for circular dependencies, custom resolvers, and collection types.

Key Issues

Must fix

  1. Wrong package name in generated types header@hoodieshq/dynamic-instructions should be @codama/dynamic-instructions
  2. CI path mismatchsetup-anchor/action.yml defaults reference test/anchor/ but the actual path is test/programs/anchor/
  3. Unused Codec value import — should be import type { Codec } (tree-shaking concern)

Worth reviewing

  1. Parallel account resolution with Promise.all — resolving accounts in parallel may cause issues with dependent accounts that have shared mutable state (the resolutionPath circular dependency detection works correctly since each path is forked, but this is worth noting)
  2. createAccountMeta role downgrade — all accounts matching programAddress get downgraded to readonly, but this only matters for optional accounts resolved to programId strategy. The check is broader than necessary.

Areas for subsequent reviewers

  • The validators.ts file (514 lines) is large — consider splitting into per-type-node validators in a future refactor
  • The generate-client-types.ts string-concatenation approach works but could benefit from a proper AST builder if the generated output grows
  • The mapTypeNode in codamaTypeToTS always emits Record<string, V> regardless of the actual key type — this is a known simplification but worth documenting
  • Verify the Solana version bump from v2 to v3 doesn't affect other packages in the monorepo
  • The .so dumps in test/programs/dumps/ are binary files committed to git — consider documenting how to regenerate them

}
case 'mapTypeNode': {
const v = codamaTypeToTS(type.value, definedTypes);
return `Record<string, ${v}>`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Map keys are always typed as string regardless of the actual node.key type. This works for most IDLs (where map keys are strings), but if a program uses publicKeyTypeNode or numberTypeNode keys, the generated type won't reflect that. Worth a comment noting this is a known simplification.

if (!Number.isInteger(node.number) || node.number < 0 || node.number > 0xff) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_INSTRUCTIONS__INVARIANT_VIOLATION, {
message: `NumberValueNode PDA seed is out of range: must be a valid u8 (0–255), got ${node.number}`,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This assumes NumberValueNode in PDA seeds is always a single u8 byte. That's currently true for all known IDLs (PDA seeds are raw bytes), but the assertion message says "must be a valid u8" without citing why. Consider adding a brief comment explaining that PDA constant seeds using numberValueNode are always single-byte values per the Codama spec, so future readers understand this isn't an arbitrary limitation.

@mikhd mikhd changed the title feat: add dynamic-instructions feat: add dynamic-client Apr 14, 2026
mikhd added 2 commits April 14, 2026 12:34
* fix: anchor paths in setup-anchor.yml

* fix: codec type

* fix: improve downgrading to readonly role of optional acc

* fix: cleanup
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.

As discussed offline, reviewing this is extremely challenging so let's get this merge first to unblock the explorer integration and we can make incremental improvements later on.

@lorisleiva lorisleiva merged commit 2428220 into codama-idl:main Apr 14, 2026
4 checks passed
@github-actions github-actions bot mentioned this pull request Apr 14, 2026
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.

4 participants