diff --git a/.changeset/loud-taxis-appear.md b/.changeset/loud-taxis-appear.md new file mode 100644 index 000000000..a68a96fa2 --- /dev/null +++ b/.changeset/loud-taxis-appear.md @@ -0,0 +1,7 @@ +--- +'@codama/dynamic-address-resolution': minor +'@codama/dynamic-instructions': minor +'@codama/dynamic-client': patch +--- + +Split types generation in `@codama/dynamic-address-resolution`, `@codama/dynamic-instructions` and `@codama/dynamic-client`. diff --git a/packages/dynamic-address-resolution/README.md b/packages/dynamic-address-resolution/README.md index 4267abd41..2f4fe25d7 100644 --- a/packages/dynamic-address-resolution/README.md +++ b/packages/dynamic-address-resolution/README.md @@ -26,7 +26,23 @@ pnpm install @codama/dynamic-address-resolution ## Types generation -> For now, types for arguments, accounts, custom resolvers, and PDA seeds can be produced via [`@codama/dynamic-client`](../dynamic-client/README.md)'s `generate-client-types` command, and passed as generics to the functions. Types generation for this package will be added in a follow-up release. +This package can emit TypeScript the input types required for address resolution of each instruction — `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`. + +### CLI + +```sh +npx @codama/dynamic-address-resolution generate-types +``` + +Writes `-address-resolution-types.ts` to the output directory. + +### Programmatic + +```ts +import { generateTypes } from '@codama/dynamic-address-resolution/codegen'; + +const source = generateTypes(idl); +``` ## Functions @@ -49,7 +65,7 @@ const address = await resolveInstructionAccountAddress({ **Typed:** ```ts -import type { TransferSolAccounts, TransferSolArgs } from './generated/system-program-idl-types'; +import type { TransferSolAccounts, TransferSolArgs } from './generated/system-program-idl-address-resolution-types'; const address = await resolveInstructionAccountAddress({ accountsInput: { source, destination }, diff --git a/packages/dynamic-address-resolution/bin/cli.cjs b/packages/dynamic-address-resolution/bin/cli.cjs new file mode 100755 index 000000000..0e2d748a0 --- /dev/null +++ b/packages/dynamic-address-resolution/bin/cli.cjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const run = require('../dist/cli.cjs').run; + +run(process.argv); diff --git a/packages/dynamic-address-resolution/package.json b/packages/dynamic-address-resolution/package.json index 03365a4a2..55460b59b 100644 --- a/packages/dynamic-address-resolution/package.json +++ b/packages/dynamic-address-resolution/package.json @@ -2,16 +2,26 @@ "name": "@codama/dynamic-address-resolution", "version": "0.2.0", "description": "Address resolution functionality for instruction accounts in Codama IDLs", + "bin": { + "dynamic-address-resolution": "./bin/cli.cjs" + }, "exports": { - "types": "./dist/types/index.d.ts", - "react-native": "./dist/index.react-native.mjs", - "browser": { - "import": "./dist/index.browser.mjs", - "require": "./dist/index.browser.cjs" + ".": { + "types": "./dist/types/index.d.ts", + "react-native": "./dist/index.react-native.mjs", + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + } }, - "node": { - "import": "./dist/index.node.mjs", - "require": "./dist/index.node.cjs" + "./codegen": { + "types": "./dist/types/codegen/index.d.ts", + "import": "./dist/codegen.node.mjs", + "require": "./dist/codegen.node.cjs" } }, "browser": { @@ -22,10 +32,20 @@ "module": "./dist/index.node.mjs", "react-native": "./dist/index.react-native.mjs", "types": "./dist/types/index.d.ts", + "typesVersions": { + "*": { + "codegen": [ + "./dist/types/codegen/index.d.ts" + ] + } + }, "type": "commonjs", "files": [ "./dist/types", - "./dist/index.*" + "./dist/index.*", + "./dist/cli.*", + "./dist/codegen.*", + "./bin/*" ], "sideEffects": false, "keywords": [ @@ -50,7 +70,8 @@ "@codama/errors": "workspace:*", "@solana/addresses": "^5.3.0", "@solana/codecs": "^5.3.0", - "codama": "workspace:*" + "codama": "workspace:*", + "commander": "^14.0.2" }, "devDependencies": { "@solana/kit": "6.5.0" diff --git a/packages/dynamic-address-resolution/src/cli/commands/generate-types/generate-types-from-file.ts b/packages/dynamic-address-resolution/src/cli/commands/generate-types/generate-types-from-file.ts new file mode 100644 index 000000000..8ee2216b2 --- /dev/null +++ b/packages/dynamic-address-resolution/src/cli/commands/generate-types/generate-types-from-file.ts @@ -0,0 +1,11 @@ +import { generateTypes } from '../../../codegen/generate-types'; +import { generateTypesFromFile as generateTypesFromFileShared } from '../../../codegen/generate-types-from-file'; + +export function generateTypesFromFile(codamaIdlPath: string, outputDirPath: string): void { + generateTypesFromFileShared({ + codamaIdlPath, + generate: generateTypes, + outputDirPath, + outputFileSuffix: 'address-resolution-types', + }); +} diff --git a/packages/dynamic-address-resolution/src/cli/commands/generate-types/register-command.ts b/packages/dynamic-address-resolution/src/cli/commands/generate-types/register-command.ts new file mode 100644 index 000000000..4eb8f2f87 --- /dev/null +++ b/packages/dynamic-address-resolution/src/cli/commands/generate-types/register-command.ts @@ -0,0 +1,21 @@ +import type { Command } from 'commander'; + +import { generateTypesFromFile } from './generate-types-from-file'; + +export function registerGenerateTypesCommand(program: Command): void { + program + .command('generate-types') + .description( + 'Generate TypeScript address-resolution types (PDA seeds, instruction Args/Accounts/Resolvers) 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') + .action((idlArg: string, outputDirArg: string) => { + try { + generateTypesFromFile(idlArg, outputDirArg); + } catch (err) { + console.error(err); + process.exit(1); + } + }); +} diff --git a/packages/dynamic-address-resolution/src/cli/commands/index.ts b/packages/dynamic-address-resolution/src/cli/commands/index.ts new file mode 100644 index 000000000..71f02c1ec --- /dev/null +++ b/packages/dynamic-address-resolution/src/cli/commands/index.ts @@ -0,0 +1,7 @@ +import type { Command } from 'commander'; + +import { registerGenerateTypesCommand } from './generate-types/register-command'; + +export function registerCommands(program: Command): void { + registerGenerateTypesCommand(program); +} diff --git a/packages/dynamic-address-resolution/src/cli/index.ts b/packages/dynamic-address-resolution/src/cli/index.ts new file mode 100644 index 000000000..6dd9bd6b4 --- /dev/null +++ b/packages/dynamic-address-resolution/src/cli/index.ts @@ -0,0 +1,12 @@ +import { createProgram } from './program'; + +const program = createProgram(); + +export function run(argv: string[]): void { + if (argv.length <= 2) { + program.outputHelp(); + return; + } + + program.parse(argv); +} diff --git a/packages/dynamic-address-resolution/src/cli/program.ts b/packages/dynamic-address-resolution/src/cli/program.ts new file mode 100644 index 000000000..f86c815d8 --- /dev/null +++ b/packages/dynamic-address-resolution/src/cli/program.ts @@ -0,0 +1,15 @@ +import { Command } from 'commander'; + +import { registerCommands } from './commands'; + +export function createProgram(): Command { + const program = new Command(); + + program + .name('dynamic-address-resolution') + .description('CLI for @codama/dynamic-address-resolution') + .showHelpAfterError(true); + + registerCommands(program); + return program; +} diff --git a/packages/dynamic-address-resolution/src/codegen/codama-type-to-ts.ts b/packages/dynamic-address-resolution/src/codegen/codama-type-to-ts.ts new file mode 100644 index 000000000..a90298362 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/codama-type-to-ts.ts @@ -0,0 +1,94 @@ +import type { DefinedTypeNode, TypeNode } from 'codama'; + +/** + * Convert Codama type to TypeScript type string. + */ +export function codamaTypeToTS(type: TypeNode | undefined, definedTypes: DefinedTypeNode[]): string { + if (!type || typeof type !== 'object') return 'unknown'; + + switch (type.kind) { + case 'numberTypeNode': + return ['u64', 'u128', 'i64', 'i128'].includes(type.format) ? 'number | bigint' : 'number'; + case 'publicKeyTypeNode': + return 'Address'; + case 'stringTypeNode': + return 'string'; + case 'booleanTypeNode': + return 'boolean'; + case 'optionTypeNode': + return `${codamaTypeToTS(type.item, definedTypes)} | null`; + case 'remainderOptionTypeNode': + case 'zeroableOptionTypeNode': + return `${codamaTypeToTS(type.item, definedTypes)} | null`; + case 'bytesTypeNode': + return 'Uint8Array'; + case 'fixedSizeTypeNode': + case 'sizePrefixTypeNode': + case 'hiddenPrefixTypeNode': + case 'preOffsetTypeNode': + case 'postOffsetTypeNode': + case 'hiddenSuffixTypeNode': + case 'sentinelTypeNode': + return codamaTypeToTS(type.type, definedTypes); + case 'amountTypeNode': + case 'solAmountTypeNode': + return 'number | bigint'; + case 'structTypeNode': { + if (!type.fields || type.fields.length === 0) return '{}'; + const fields = type.fields + .filter(f => f.defaultValueStrategy !== 'omitted') + .map(f => `${f.name}: ${codamaTypeToTS(f.type, definedTypes)}`); + if (fields.length === 0) return '{}'; + return `{ ${fields.join('; ')} }`; + } + case 'enumTypeNode': { + if (!type.variants || type.variants.length === 0) return 'unknown /** empty variants in enumTypeNode */'; + const allEmpty = type.variants.every(v => v.kind === 'enumEmptyVariantTypeNode'); + if (allEmpty) { + return type.variants.map(v => `'${v.name}'`).join(' | '); + } + const variantTypes = type.variants.map(v => { + if (v.kind === 'enumEmptyVariantTypeNode') { + return `{ __kind: '${v.name}' }`; + } + if (v.kind === 'enumStructVariantTypeNode' && v.struct) { + const inner = codamaTypeToTS(v.struct, definedTypes); + return `{ __kind: '${v.name}' } & ${inner}`; + } + if (v.kind === 'enumTupleVariantTypeNode' && v.tuple) { + const inner = codamaTypeToTS(v.tuple, definedTypes); + return `{ __kind: '${v.name}'; fields: ${inner} }`; + } + return `{ __kind: '${v.name}' }`; + }); + return variantTypes.join(' | '); + } + case 'tupleTypeNode': { + if (!type.items || type.items.length === 0) return '[]'; + const items = type.items.map(i => codamaTypeToTS(i, definedTypes)); + return `[${items.join(', ')}]`; + } + case 'arrayTypeNode': + case 'setTypeNode': { + const itemType = codamaTypeToTS(type.item, definedTypes); + const needsParens = itemType.includes(' | ') || itemType.includes(' & '); + return needsParens ? `(${itemType})[]` : `${itemType}[]`; + } + case 'mapTypeNode': { + const v = codamaTypeToTS(type.value, definedTypes); + return `Record`; + } + case 'definedTypeLinkNode': { + if (!type.name) return 'unknown /** name missing in definedTypeLinkNode */'; + const def = definedTypes.find(d => d.name === type.name); + if (!def) return 'unknown /** DefinedTypeNode not found for definedTypeLinkNode */'; + return codamaTypeToTS(def.type, definedTypes); + } + case 'dateTimeTypeNode': { + return codamaTypeToTS(type.number, definedTypes); + } + default: + type['kind'] satisfies never; + return 'unknown'; + } +} diff --git a/packages/dynamic-address-resolution/src/codegen/collect-pda-nodes.ts b/packages/dynamic-address-resolution/src/codegen/collect-pda-nodes.ts new file mode 100644 index 000000000..6cbe1467e --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/collect-pda-nodes.ts @@ -0,0 +1,25 @@ +import type { PdaNode, RootNode } from 'codama'; + +/** + * Collects all PdaNodes referenced in an IDL. + */ +export function collectPdaNodesFromIdl(idl: RootNode): Map { + const pdas = new Map(); + + for (const pda of idl.program.pdas ?? []) { + pdas.set(pda.name, pda); + } + + for (const ix of idl.program.instructions) { + for (const acc of ix.accounts) { + if (!acc.defaultValue || acc.defaultValue.kind !== 'pdaValueNode') continue; + const pdaDef = acc.defaultValue.pda; + if (!pdaDef || pdaDef.kind !== 'pdaNode') continue; + if (!pdas.has(pdaDef.name)) { + pdas.set(pdaDef.name, pdaDef); + } + } + } + + return pdas; +} diff --git a/packages/dynamic-address-resolution/src/codegen/collect-resolver-names.ts b/packages/dynamic-address-resolution/src/codegen/collect-resolver-names.ts new file mode 100644 index 000000000..0811c8b6a --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/collect-resolver-names.ts @@ -0,0 +1,28 @@ +import type { InstructionInputValueNode, InstructionNode } from 'codama'; + +/** + * Collects all unique resolverValueNode names from an instruction's accounts and arguments. + */ +export function collectResolverNames(ix: InstructionNode): Set { + const names = new Set(); + + for (const acc of ix.accounts) { + extractResolverNodeName(acc.defaultValue, names); + } + for (const arg of ix.arguments) { + extractResolverNodeName(arg.defaultValue, names); + } + + return names; +} + +function extractResolverNodeName(node: InstructionInputValueNode | undefined, names: Set): void { + if (!node) return; + if (node.kind === 'resolverValueNode' && node.name) { + names.add(node.name); + } else if (node.kind === 'conditionalValueNode') { + extractResolverNodeName(node.condition, names); + extractResolverNodeName(node.ifTrue, names); + extractResolverNodeName(node.ifFalse, names); + } +} diff --git a/packages/dynamic-address-resolution/src/codegen/generate-pda-types.ts b/packages/dynamic-address-resolution/src/codegen/generate-pda-types.ts new file mode 100644 index 000000000..01e598d24 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/generate-pda-types.ts @@ -0,0 +1,52 @@ +import { pascalCase, type PdaNode, type RootNode, type VariablePdaSeedNode } from 'codama'; + +import { codamaTypeToTS } from './codama-type-to-ts'; +import { collectPdaNodesFromIdl } from './collect-pda-nodes'; + +/** + * Generate `${Pda}PdaSeeds` types and the aggregate `${Program}Pdas` map type. + * + * Returns the type block with the aggregate map type name. `mapTypeName` is `null` when the program has no PDAs. + */ +export function generatePdaTypes(idl: RootNode): { mapTypeName: string | null; typeBlock: string } { + const programName = pascalCase(idl.program.name); + const definedTypes = idl.program.definedTypes ?? []; + const pdaMap = collectPdaNodesFromIdl(idl); + + if (pdaMap.size === 0) { + return { mapTypeName: null, typeBlock: '' }; + } + + let output = ''; + + for (const [pdaName, pdaNode] of pdaMap) { + const variableSeeds = getVariableSeedNodes(pdaNode); + if (variableSeeds.length === 0) continue; + const typeName = pascalCase(pdaName); + output += `export type ${typeName}PdaSeeds = {\n`; + for (const seed of variableSeeds) { + const tsType = seed.type + ? codamaTypeToTS(seed.type, definedTypes) + : 'unknown/** missing type in variablePdaSeedNode */'; + output += ` ${seed.name}: ${tsType};\n`; + } + output += '};\n\n'; + } + + const mapTypeName = `${programName}Pdas`; + output += `/**\n * Strongly-typed PDAs for ${programName}.\n */\n`; + output += `export type ${mapTypeName} = {\n`; + for (const [pdaName, pdaNode] of pdaMap) { + const typeName = pascalCase(pdaName); + const seedsParam = + getVariableSeedNodes(pdaNode).length > 0 ? `seeds: ${typeName}PdaSeeds` : `seeds?: Record`; + output += ` ${pdaName}: (${seedsParam}) => Promise;\n`; + } + output += '};\n\n'; + + return { mapTypeName, typeBlock: output }; +} + +function getVariableSeedNodes(pdaNode: PdaNode): VariablePdaSeedNode[] { + return (pdaNode.seeds ?? []).filter(s => s.kind === 'variablePdaSeedNode'); +} diff --git a/packages/dynamic-address-resolution/src/codegen/generate-resolution-input-types.ts b/packages/dynamic-address-resolution/src/codegen/generate-resolution-input-types.ts new file mode 100644 index 000000000..19c371e14 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/generate-resolution-input-types.ts @@ -0,0 +1,87 @@ +import { type DefinedTypeNode, type InstructionNode, type RootNode } from 'codama'; + +import { OPTIONAL_NODE_KINDS } from '../shared/nodes'; +import { codamaTypeToTS } from './codama-type-to-ts'; +import { collectResolverNames } from './collect-resolver-names'; +import { getResolutionRefs } from './get-resolution-refs'; +import { isAccountAutoResolvable } from './is-account-auto-resolvable'; + +/** + * Local non-exported declaration of `ResolverFn` emitted into generated files + * that contain at least one `${Name}Resolvers` block. The structural shape + * must remain identical to the runtime `ResolverFn` exported from + * `@codama/dynamic-address-resolution`; a type-level test in this package + * locks the two together. + * + * @internal Exported only for a drift test that pins this string to the + * runtime `ResolverFn` type. Do not consume from downstream codegen — call + * `generateResolutionInputTypes` instead, which inlines this declaration + * into the emitted output. + */ +export const RESOLVER_FN_DECLARATION = + 'type ResolverFn = (argumentsInput: TArgumentsInput, accountsInput: TAccountsInput) => Promise;\n\n'; + +/** + * Emits the input types required for address resolution of each instruction — + * `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`. + * + * These types live in this package because address resolution + * (PDAs, defaults, resolver functions) operates on them. + */ +export function generateResolutionInputTypes(idl: RootNode): string { + const definedTypes = idl.program.definedTypes ?? []; + const hasAnyResolvers = idl.program.instructions.some(ix => getResolutionRefs(ix).hasResolvers); + let output = hasAnyResolvers ? RESOLVER_FN_DECLARATION : ''; + for (const ix of idl.program.instructions) { + output += generateTypeBlockForInstruction(ix, definedTypes); + } + return output; +} + +function generateTypeBlockForInstruction(ix: InstructionNode, definedTypes: DefinedTypeNode[]): string { + const refs = getResolutionRefs(ix); + let output = ''; + + if (refs.argsRef) { + const args = ix.arguments.filter(arg => arg.defaultValueStrategy !== 'omitted'); + const remainingAccountArgs = (ix.remainingAccounts ?? []).filter(ra => ra.value.kind === 'argumentValueNode'); + output += `export type ${refs.argsRef} = {\n`; + for (const arg of args) { + const tsType = codamaTypeToTS(arg.type, definedTypes); + const isOptional = OPTIONAL_NODE_KINDS.includes(arg.type.kind); + const sep = isOptional ? '?:' : ':'; + output += ` ${arg.name}${sep} ${tsType};\n`; + } + for (const ra of remainingAccountArgs) { + const sep = ra.isOptional ? '?:' : ':'; + output += ` ${ra.value.name}${sep} Address[];\n`; + } + output += '};\n\n'; + } + + if (ix.accounts.length > 0) { + output += `export type ${refs.accountsRef} = {\n`; + for (const acc of ix.accounts) { + const omittable = isAccountAutoResolvable(acc) ? '?' : ''; + const type = acc.isOptional ? 'Address | null' : 'Address'; + output += ` ${acc.name}${omittable}: ${type};\n`; + } + output += '};\n\n'; + output += `export type ${refs.accountsWithDataRef} = ${refs.accountsRef} & Record;\n\n`; + } else { + // No IDL-declared accounts: emit the strict and loose forms independently. + output += `export type ${refs.accountsRef} = Record;\n\n`; + output += `export type ${refs.accountsWithDataRef} = Record;\n\n`; + } + + if (refs.resolversRef) { + const resolverArgsRef = refs.argsRef ?? 'Record'; + output += `export type ${refs.resolversRef} = {\n`; + for (const name of collectResolverNames(ix)) { + output += ` ${name}: ResolverFn<${resolverArgsRef}, ${refs.accountsRef}>;\n`; + } + output += '};\n\n'; + } + + return output; +} diff --git a/packages/dynamic-address-resolution/src/codegen/generate-types-from-file.ts b/packages/dynamic-address-resolution/src/codegen/generate-types-from-file.ts new file mode 100644 index 000000000..f897535cf --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/generate-types-from-file.ts @@ -0,0 +1,73 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import { createFromJson, type RootNode } from 'codama'; + +export type GenerateTypesFromFileOptions = { + codamaIdlPath: string; + generate: (idl: RootNode) => string; + outputDirPath: string; + /** + * Filename suffix appended after IDL filename. + * @example 'instruction-types' + */ + outputFileSuffix: string; +}; + +/** + * Shared CLI helper: + * - Reads a Codama IDL JSON. + * - Runs a generator function. + * - Writes the result to `/-.ts` + */ +export function generateTypesFromFile(opts: GenerateTypesFromFileOptions): void { + const { codamaIdlPath, outputDirPath, generate, outputFileSuffix } = opts; + + const idlPath = path.resolve(codamaIdlPath); + const outputDir = path.resolve(outputDirPath); + + if (!existsSync(idlPath)) { + throw new Error(`IDL file not found: ${idlPath}`); + } + + console.log(`Reading IDL from: ${idlPath}`); + + let idlJson: string; + try { + idlJson = readFileSync(idlPath, 'utf-8'); + } catch (err) { + throw new Error(`Cannot read IDL file: ${idlPath}`, { cause: err }); + } + + let idl: RootNode; + try { + idl = createFromJson(idlJson).getRoot(); + } catch (err) { + throw new Error(`${idlPath} is not valid Codama JSON`, { + cause: err, + }); + } + + let types: string; + try { + console.log(`Generating types for program: ${idl.program.name}`); + types = generate(idl); + } catch (err) { + throw new Error(`Cannot generate types for IDL: ${idlPath}`, { + cause: err, + }); + } + + try { + mkdirSync(outputDir, { recursive: true }); + const fileName = path.basename(idlPath); + const outputFile = fileName.replace(/\.json$/, `-${outputFileSuffix}.ts`); + const outputPath = path.join(outputDir, outputFile); + + console.log(`Writing types to: ${outputPath}`); + writeFileSync(outputPath, types, 'utf-8'); + console.log('Done!'); + } catch (err) { + throw new Error(`Cannot write generated types`, { cause: err }); + } +} diff --git a/packages/dynamic-address-resolution/src/codegen/generate-types.ts b/packages/dynamic-address-resolution/src/codegen/generate-types.ts new file mode 100644 index 000000000..8a53e7acc --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/generate-types.ts @@ -0,0 +1,26 @@ +import type { RootNode } from 'codama'; + +import { generatePdaTypes } from './generate-pda-types'; +import { generateResolutionInputTypes } from './generate-resolution-input-types'; + +/** + * Generate a self-contained TypeScript file with all address-resolution types for a Codama IDL: + * - PDA seed types, `${Program}Pdas` map, + * - Per-instruction `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`. + */ +export function generateTypes(idl: RootNode): string { + const { typeBlock: pdaBlock, mapTypeName } = generatePdaTypes(idl); + return getTypesFileHeader(mapTypeName !== null) + pdaBlock + generateResolutionInputTypes(idl); +} + +const getTypesFileHeader = (importPdaTypes: boolean): string => { + const addressImports = importPdaTypes ? 'Address, ProgramDerivedAddress' : 'Address'; + return `/** + * Auto-generated address-resolution types + * DO NOT EDIT - Generated by @codama/dynamic-address-resolution generate-types + */ + +import type { ${addressImports} } from '@solana/addresses'; + +`; +}; diff --git a/packages/dynamic-address-resolution/src/codegen/get-resolution-refs.ts b/packages/dynamic-address-resolution/src/codegen/get-resolution-refs.ts new file mode 100644 index 000000000..9b1a71931 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/get-resolution-refs.ts @@ -0,0 +1,59 @@ +import { type InstructionNode, pascalCase } from 'codama'; + +import { OPTIONAL_NODE_KINDS } from '../shared/nodes'; +import { collectResolverNames } from './collect-resolver-names'; + +/** + * Symbol registry for the resolvable surface of an instruction. + * + * Returns the TypeScript identifier each codegen step in this package + * (and downstream consumers) should reference when emitting types tied + * to address resolution. `null` means "this symbol is not emitted for + * this instruction" — callers pick their own fallback at the use site. + * + * Lives here because resolution rules decide which of these symbols exist. + */ +export type ResolutionRefs = { + /** Identifier of the emitted `${Name}Accounts` type (strict, IDL-named keys only). */ + accountsRef: string; + /** + * Identifier of the emitted `${Name}AccountsWithData` type. + * Widened with `Record`. + */ + accountsWithDataRef: string; + /** Identifier of the emitted `${Name}Args` type, or `null` if no args type is emitted. */ + argsRef: string | null; + /** `true` when the instruction has any arguments (mirrors `argsRef !== null`). */ + hasArgs: boolean; + /** `true` when at least one argument is non-optional. */ + hasRequiredArgs: boolean; + /** `true` when at least one remaining-account argument is non-optional. */ + hasRequiredRemainingAccounts: boolean; + /** `true` when the instruction has any custom resolvers (mirrors `resolversRef !== null`). */ + hasResolvers: boolean; + /** Identifier of the emitted `${Name}Resolvers` type, or `null` if no resolvers type is emitted. */ + resolversRef: string | null; +}; + +export function getResolutionRefs(ix: InstructionNode): ResolutionRefs { + const typeName = pascalCase(ix.name); + + const args = ix.arguments.filter(arg => arg.defaultValueStrategy !== 'omitted'); + const remainingAccountArgs = (ix.remainingAccounts ?? []).filter(ra => ra.value.kind === 'argumentValueNode'); + const hasArgs = args.length > 0 || remainingAccountArgs.length > 0; + const hasRequiredArgs = args.some(arg => !OPTIONAL_NODE_KINDS.includes(arg.type.kind)); + const hasRequiredRemainingAccounts = remainingAccountArgs.some(ra => !ra.isOptional); + + const hasResolvers = collectResolverNames(ix).size > 0; + + return { + accountsRef: `${typeName}Accounts`, + accountsWithDataRef: `${typeName}AccountsWithData`, + argsRef: hasArgs ? `${typeName}Args` : null, + hasArgs, + hasRequiredArgs, + hasRequiredRemainingAccounts, + hasResolvers, + resolversRef: hasResolvers ? `${typeName}Resolvers` : null, + }; +} diff --git a/packages/dynamic-address-resolution/src/codegen/index.ts b/packages/dynamic-address-resolution/src/codegen/index.ts new file mode 100644 index 000000000..ef8ce7a12 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/index.ts @@ -0,0 +1,9 @@ +export { codamaTypeToTS } from './codama-type-to-ts'; +export { collectPdaNodesFromIdl } from './collect-pda-nodes'; +export { collectResolverNames } from './collect-resolver-names'; +export { generatePdaTypes } from './generate-pda-types'; +export { generateResolutionInputTypes } from './generate-resolution-input-types'; +export { generateTypes } from './generate-types'; +export { generateTypesFromFile, type GenerateTypesFromFileOptions } from './generate-types-from-file'; +export { getResolutionRefs, type ResolutionRefs } from './get-resolution-refs'; +export { isAccountAutoResolvable } from './is-account-auto-resolvable'; diff --git a/packages/dynamic-address-resolution/src/codegen/is-account-auto-resolvable.ts b/packages/dynamic-address-resolution/src/codegen/is-account-auto-resolvable.ts new file mode 100644 index 000000000..644f109a3 --- /dev/null +++ b/packages/dynamic-address-resolution/src/codegen/is-account-auto-resolvable.ts @@ -0,0 +1,12 @@ +import type { InstructionAccountNode } from 'codama'; + +// Accounts with these default value nodes always require user input. +const nonResolvableValueNodes = ['payerValueNode', 'identityValueNode']; + +/** + * Determines if an account has an auto-resolvable default value. + */ +export function isAccountAutoResolvable(acc: InstructionAccountNode): boolean { + if (acc.defaultValue == null) return false; + return !nonResolvableValueNodes.includes(acc.defaultValue.kind); +} diff --git a/packages/dynamic-address-resolution/test/cli/generate-types.test.ts b/packages/dynamic-address-resolution/test/cli/generate-types.test.ts new file mode 100644 index 000000000..5c0460820 --- /dev/null +++ b/packages/dynamic-address-resolution/test/cli/generate-types.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterAll, describe, expect, test } from 'vitest'; + +import { makeRoot } from '../test-utils'; + +const CLI_PATH = path.resolve('bin/cli.cjs'); + +function execCli(args: string[]) { + try { + const stdout = execFileSync('node', [CLI_PATH, ...args], { + cwd: path.resolve('.'), + encoding: 'utf-8', + stdio: 'pipe', + }); + return { exitCode: 0, stderr: '', stdout }; + } catch (error: unknown) { + const e = error as { status: number; stderr: string; stdout: string }; + return { exitCode: e.status ?? 1, stderr: e.stderr ?? '', stdout: e.stdout ?? '' }; + } +} + +describe('CLI', () => { + const tmpDirs: string[] = []; + + afterAll(() => { + for (const dir of tmpDirs) { + rmSync(dir, { force: true, recursive: true }); + } + }); + + test('should print help when no arguments are provided', () => { + const { stdout, exitCode } = execCli([]); + expect(stdout).toContain('Usage: dynamic-address-resolution'); + expect(stdout).toContain('generate-types '); + expect(exitCode).toBe(0); + }); + + test('should read IDL and write output file for generate-types', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'dar-cli-')); + tmpDirs.push(tmpDir); + const idlPath = path.join(tmpDir, 'test.json'); + writeFileSync(idlPath, JSON.stringify(makeRoot([])), 'utf-8'); + + const { exitCode } = execCli(['generate-types', idlPath, tmpDir]); + expect(exitCode).toBe(0); + + const outputPath = path.join(tmpDir, 'test-address-resolution-types.ts'); + expect(existsSync(outputPath)).toBe(true); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/codama-type-to-ts.test.ts b/packages/dynamic-address-resolution/test/codegen/codama-type-to-ts.test.ts new file mode 100644 index 000000000..a81e9aa15 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/codama-type-to-ts.test.ts @@ -0,0 +1,118 @@ +import { camelCase, type DefinedTypeNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { codamaTypeToTS } from '../../src/codegen/codama-type-to-ts'; + +const NO_DEFINED: DefinedTypeNode[] = []; + +describe('codamaTypeToTS', () => { + test('should map numeric format to number or bigint', () => { + expect(codamaTypeToTS({ endian: 'le', format: 'u32', kind: 'numberTypeNode' }, NO_DEFINED)).toBe('number'); + expect(codamaTypeToTS({ endian: 'le', format: 'u64', kind: 'numberTypeNode' }, NO_DEFINED)).toBe( + 'number | bigint', + ); + expect(codamaTypeToTS({ endian: 'le', format: 'i128', kind: 'numberTypeNode' }, NO_DEFINED)).toBe( + 'number | bigint', + ); + }); + + test('should map primitive types to TS scalars', () => { + expect(codamaTypeToTS({ kind: 'publicKeyTypeNode' }, NO_DEFINED)).toBe('Address'); + expect(codamaTypeToTS({ encoding: 'utf8', kind: 'stringTypeNode' }, NO_DEFINED)).toBe('string'); + expect( + codamaTypeToTS( + { kind: 'booleanTypeNode', size: { endian: 'le', format: 'u8', kind: 'numberTypeNode' } }, + NO_DEFINED, + ), + ).toBe('boolean'); + expect(codamaTypeToTS({ kind: 'bytesTypeNode' }, NO_DEFINED)).toBe('Uint8Array'); + }); + + test('should append | null for option types', () => { + expect( + codamaTypeToTS( + { + item: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + kind: 'optionTypeNode', + prefix: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }, + NO_DEFINED, + ), + ).toBe('number | null'); + }); + + test('should parenthesize union item types in arrays', () => { + expect( + codamaTypeToTS( + { + count: { kind: 'fixedCountNode', value: 4 }, + item: { + item: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + kind: 'optionTypeNode', + prefix: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }, + kind: 'arrayTypeNode', + }, + NO_DEFINED, + ), + ).toBe('(number | null)[]'); + }); + + test('should emit field unions for struct types', () => { + const result = codamaTypeToTS( + { + fields: [ + { + kind: 'structFieldTypeNode', + name: camelCase('a'), + type: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }, + { + kind: 'structFieldTypeNode', + name: camelCase('b'), + type: { encoding: 'utf8', kind: 'stringTypeNode' }, + }, + ], + kind: 'structTypeNode', + }, + NO_DEFINED, + ); + expect(result).toBe('{ a: number; b: string }'); + }); + + test('should emit a string union for all-empty enums', () => { + expect( + codamaTypeToTS( + { + kind: 'enumTypeNode', + size: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + variants: [ + { kind: 'enumEmptyVariantTypeNode', name: camelCase('one') }, + { kind: 'enumEmptyVariantTypeNode', name: camelCase('two') }, + ], + }, + NO_DEFINED, + ), + ).toBe("'one' | 'two'"); + }); + + test('should resolve definedTypeLinkNode through definedTypes', () => { + const definedTypes: DefinedTypeNode[] = [ + { + docs: [], + kind: 'definedTypeNode', + name: camelCase('amount'), + type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, + }, + ]; + expect(codamaTypeToTS({ kind: 'definedTypeLinkNode', name: camelCase('amount') }, definedTypes)).toBe( + 'number | bigint', + ); + }); + + test('should fall back to unknown for unknown definedTypeLinkNode', () => { + expect(codamaTypeToTS({ kind: 'definedTypeLinkNode', name: camelCase('missing') }, NO_DEFINED)).toBe( + 'unknown /** DefinedTypeNode not found for definedTypeLinkNode */', + ); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/collect-pda-nodes.test.ts b/packages/dynamic-address-resolution/test/codegen/collect-pda-nodes.test.ts new file mode 100644 index 000000000..ae0b16138 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/collect-pda-nodes.test.ts @@ -0,0 +1,77 @@ +import { + constantPdaSeedNodeFromString, + instructionAccountNode, + instructionNode, + pdaNode, + pdaValueNode, + programNode, + publicKeyTypeNode, + rootNode, + variablePdaSeedNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { collectPdaNodesFromIdl } from '../../src/codegen/collect-pda-nodes'; +import { makeRoot } from '../test-utils'; + +describe('collectPdaNodesFromIdl', () => { + test('should collect inline PDA from instruction account default', () => { + const inline = pdaNode({ + name: 'inline', + seeds: [variablePdaSeedNode('mint', publicKeyTypeNode())], + }); + const root = makeRoot([ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode(inline), + isSigner: false, + isWritable: false, + name: 'acc', + }), + ], + name: 'test', + }), + ]); + + const map = collectPdaNodesFromIdl(root); + expect(map.size).toBe(1); + expect(map.get('inline')).toBe(inline); + }); + + test('should prefer program.pdas over inline duplicate of the same name', () => { + const topLevel = pdaNode({ + name: 'shared', + seeds: [constantPdaSeedNodeFromString('utf8', 'top')], + }); + const inline = pdaNode({ + name: 'shared', + seeds: [constantPdaSeedNodeFromString('utf8', 'inline')], + }); + + const root = rootNode( + programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode(inline), + isSigner: false, + isWritable: false, + name: 'acc', + }), + ], + name: 'test', + }), + ], + name: 'program', + pdas: [topLevel], + publicKey: '11111111111111111111111111111111', + }), + ); + + const map = collectPdaNodesFromIdl(root); + expect(map.size).toBe(1); + expect(map.get('shared')).toBe(topLevel); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/collect-resolver-names.test.ts b/packages/dynamic-address-resolution/test/codegen/collect-resolver-names.test.ts new file mode 100644 index 000000000..e7e07ec26 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/collect-resolver-names.test.ts @@ -0,0 +1,58 @@ +import { + conditionalValueNode, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + numberTypeNode, + resolverValueNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { collectResolverNames } from '../../src/codegen/collect-resolver-names'; + +describe('collectResolverNames', () => { + test('should dedupe resolver names across accounts and arguments', () => { + const ix = instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: resolverValueNode('shared'), + isSigner: false, + isWritable: false, + name: 'a', + }), + ], + arguments: [ + instructionArgumentNode({ + defaultValue: resolverValueNode('shared'), + name: 'arg', + type: numberTypeNode('u8'), + }), + ], + name: 'doThing', + }); + + const names = collectResolverNames(ix); + expect(names).toEqual(new Set(['shared'])); + }); + + test('should recurse into conditionalValueNode branches', () => { + const ix = instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: conditionalValueNode({ + condition: resolverValueNode('condition'), + ifFalse: resolverValueNode('no'), + ifTrue: resolverValueNode('yes'), + }), + isSigner: false, + isWritable: false, + name: 'a', + }), + ], + name: 'branch', + }); + + const names = collectResolverNames(ix); + expect(names).toEqual(new Set(['condition', 'yes', 'no'])); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/generate-pda-types.test.ts b/packages/dynamic-address-resolution/test/codegen/generate-pda-types.test.ts new file mode 100644 index 000000000..d63045148 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/generate-pda-types.test.ts @@ -0,0 +1,119 @@ +import { + constantPdaSeedNodeFromString, + instructionAccountNode, + instructionNode, + pdaNode, + pdaValueNode, + programNode, + publicKeyTypeNode, + rootNode, + stringTypeNode, + variablePdaSeedNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { generatePdaTypes } from '../../src/codegen/generate-pda-types'; + +describe('generatePdaTypes', () => { + test('should return null mapTypeName when there are no PDAs', () => { + const root = rootNode( + programNode({ + instructions: [], + name: 'noPdaProgram', + publicKey: '11111111111111111111111111111111', + }), + ); + const { mapTypeName, typeBlock } = generatePdaTypes(root); + expect(mapTypeName).toBeNull(); + expect(typeBlock).toBe(''); + }); + + test('should emit seed types and aggregate map for PDAs', () => { + const pda = pdaNode({ + name: 'config', + seeds: [ + constantPdaSeedNodeFromString('utf8', 'config'), + variablePdaSeedNode('authority', publicKeyTypeNode()), + ], + }); + const root = rootNode( + programNode({ + instructions: [], + name: 'myProgram', + pdas: [pda], + publicKey: '11111111111111111111111111111111', + }), + ); + const { mapTypeName, typeBlock } = generatePdaTypes(root); + expect(mapTypeName).toBe('MyProgramPdas'); + expect(typeBlock).toContain('export type ConfigPdaSeeds'); + expect(typeBlock).toContain('authority: Address;'); + expect(typeBlock).toContain('export type MyProgramPdas'); + expect(typeBlock).toContain('config: (seeds: ConfigPdaSeeds) => Promise;'); + }); + + test('should discover inline PDAs on instruction account defaults', () => { + const inlinePda = pdaNode({ + name: 'inline', + seeds: [variablePdaSeedNode('mint', publicKeyTypeNode())], + }); + const root = rootNode( + programNode({ + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: pdaValueNode(inlinePda), + isSigner: false, + isWritable: true, + name: 'inlineAccount', + }), + ], + name: 'doThing', + }), + ], + name: 'inlineProgram', + publicKey: '11111111111111111111111111111111', + }), + ); + const { mapTypeName, typeBlock } = generatePdaTypes(root); + expect(mapTypeName).toBe('InlineProgramPdas'); + expect(typeBlock).toContain('export type InlinePdaSeeds'); + expect(typeBlock).toContain('inline: (seeds: InlinePdaSeeds) => Promise;'); + }); + + test('should emit seedless variant for PDAs with only constant seeds', () => { + const pda = pdaNode({ + name: 'fixed', + seeds: [constantPdaSeedNodeFromString('utf8', 'fixed')], + }); + const root = rootNode( + programNode({ + instructions: [], + name: 'fixedProgram', + pdas: [pda], + publicKey: '11111111111111111111111111111111', + }), + ); + const { typeBlock } = generatePdaTypes(root); + expect(typeBlock).not.toContain('FixedPdaSeeds'); + expect(typeBlock).toContain('fixed: (seeds?: Record) => Promise;'); + }); + + test('should use string seed type', () => { + const pda = pdaNode({ + name: 'named', + seeds: [variablePdaSeedNode('label', stringTypeNode('utf8'))], + }); + const root = rootNode( + programNode({ + instructions: [], + name: 'namedProgram', + pdas: [pda], + publicKey: '11111111111111111111111111111111', + }), + ); + const { typeBlock } = generatePdaTypes(root); + expect(typeBlock).toContain('label: string;'); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/generate-resolution-input-types.test.ts b/packages/dynamic-address-resolution/test/codegen/generate-resolution-input-types.test.ts new file mode 100644 index 000000000..95b87e350 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/generate-resolution-input-types.test.ts @@ -0,0 +1,195 @@ +import { + camelCase, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + instructionRemainingAccountsNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { generateResolutionInputTypes } from '../../src/codegen/generate-resolution-input-types'; +import { makeRoot } from '../test-utils'; + +describe('generateResolutionInputTypes', () => { + test('should generate Args type with correct TS types', () => { + const root = makeRoot([ + instructionNode({ + arguments: [ + instructionArgumentNode({ + name: 'amount', + type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, + }), + instructionArgumentNode({ + name: 'memo', + type: { encoding: 'utf8', kind: 'stringTypeNode' }, + }), + ], + name: 'transfer', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('export type TransferArgs'); + expect(output).toContain('amount: number | bigint;'); + expect(output).toContain('memo: string;'); + }); + + test('should filter omitted arguments from Args type', () => { + const root = makeRoot([ + instructionNode({ + arguments: [ + instructionArgumentNode({ + name: 'visible', + type: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }), + instructionArgumentNode({ + defaultValue: { kind: 'numberValueNode', number: 0 }, + defaultValueStrategy: 'omitted', + name: 'hidden', + type: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }), + ], + name: 'init', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('visible: number;'); + expect(output).not.toContain('hidden'); + }); + + test('should skip Args block when there are no arguments', () => { + const root = makeRoot([ + instructionNode({ + accounts: [instructionAccountNode({ isSigner: true, isWritable: false, name: 'authority' })], + name: 'noArgs', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).not.toContain('NoArgsArgs'); + }); + + test('should mark auto-resolvable accounts with ? and required ones plain', () => { + const root = makeRoot([ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: { kind: 'payerValueNode' }, + isSigner: true, + isWritable: true, + name: 'payer', + }), + instructionAccountNode({ + defaultValue: { + kind: 'publicKeyValueNode', + publicKey: '11111111111111111111111111111111', + }, + isSigner: false, + isWritable: false, + name: 'systemProgram', + }), + instructionAccountNode({ isSigner: false, isWritable: true, name: 'target' }), + ], + name: 'create', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('export type CreateAccounts'); + expect(output).toContain('payer: Address;'); + expect(output).toContain('systemProgram?: Address;'); + expect(output).toContain('target: Address;'); + }); + + test('should emit | null for optional accounts', () => { + const root = makeRoot([ + instructionNode({ + accounts: [ + instructionAccountNode({ + isOptional: true, + isSigner: false, + isWritable: false, + name: 'closeAuthority', + }), + ], + name: 'maybeClose', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('closeAuthority: Address | null;'); + }); + + test('should emit Resolvers type when resolverValueNode exists', () => { + const root = makeRoot([ + instructionNode({ + accounts: [instructionAccountNode({ isSigner: true, isWritable: false, name: 'authority' })], + arguments: [ + instructionArgumentNode({ + defaultValue: { kind: 'resolverValueNode', name: camelCase('computeValue') }, + name: 'computedValue', + type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, + }), + ], + name: 'customResolve', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('export type CustomResolveResolvers'); + expect(output).toContain('computeValue: ResolverFn;'); + }); + + test('should mark optional type arguments with ?', () => { + const root = makeRoot([ + instructionNode({ + arguments: [ + instructionArgumentNode({ + name: 'maybeValue', + type: { + item: { endian: 'le', format: 'u32', kind: 'numberTypeNode' }, + kind: 'optionTypeNode', + prefix: { endian: 'le', format: 'u8', kind: 'numberTypeNode' }, + }, + }), + ], + name: 'optionalArgs', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('maybeValue?: number | null;'); + }); + + test('should emit remaining account arguments in Args type', () => { + const root = makeRoot([ + instructionNode({ + name: 'multiSig', + remainingAccounts: [ + instructionRemainingAccountsNode( + { kind: 'argumentValueNode', name: camelCase('multiSigners') }, + { isSigner: true, isWritable: false }, + ), + ], + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('export type MultiSigArgs'); + expect(output).toContain('multiSigners: Address[];'); + }); + + test('should emit empty Accounts fallback for instructions without accounts', () => { + const root = makeRoot([instructionNode({ name: 'noAccounts' })]); + const output = generateResolutionInputTypes(root); + expect(output).toContain('export type NoAccountsAccounts = Record;'); + expect(output).toContain( + 'export type NoAccountsAccountsWithData = Record;', + ); + }); + + test('should not emit Signers or InstructionBuilders blocks', () => { + const root = makeRoot([ + instructionNode({ + accounts: [instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' })], + name: 'transfer', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).not.toContain('TransferSigners'); + expect(output).not.toContain('InstructionBuilders'); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/generate-types-from-file.test.ts b/packages/dynamic-address-resolution/test/codegen/generate-types-from-file.test.ts new file mode 100644 index 000000000..dc0475e96 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/generate-types-from-file.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { generateTypesFromFile } from '../../src/codegen/generate-types-from-file'; +import { makeRoot } from '../test-utils'; + +describe('generateTypesFromFile', () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'gen-types-')); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + rmSync(workDir, { force: true, recursive: true }); + vi.restoreAllMocks(); + }); + + test('should read the IDL, run the generator, and write -.ts', () => { + // Given a valid IDL JSON on disk and an output directory that does not yet exist. + const idlPath = join(workDir, 'my-idl.json'); + writeFileSync(idlPath, JSON.stringify(makeRoot([])), 'utf-8'); + const outputDirPath = join(workDir, 'out'); + + // When we run the helper with a trivial generator and a custom suffix. + generateTypesFromFile({ + codamaIdlPath: idlPath, + generate: () => 'export const x = 1;', + outputDirPath, + outputFileSuffix: 'foo', + }); + + // Then the generator output is written to /-.ts. + const content = readFileSync(join(outputDirPath, 'my-idl-foo.ts'), 'utf-8'); + expect(content).toBe('export const x = 1;'); + }); + + test('should throw when the IDL file does not exist', () => { + const missingPath = join(workDir, 'missing.json'); + + expect(() => + generateTypesFromFile({ + codamaIdlPath: missingPath, + generate: () => '', + outputDirPath: workDir, + outputFileSuffix: 'foo', + }), + ).toThrow(/IDL file not found/); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/generate-types.test.ts b/packages/dynamic-address-resolution/test/codegen/generate-types.test.ts new file mode 100644 index 000000000..cb6d42a4a --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/generate-types.test.ts @@ -0,0 +1,29 @@ +import { constantPdaSeedNodeFromString, pdaNode, programNode, rootNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { generateTypes } from '../../src/codegen/generate-types'; +import { makeRoot } from '../test-utils'; + +describe('generateTypes', () => { + test('should omit ProgramDerivedAddress from the header when there are no PDAs', () => { + const root = makeRoot([]); + const output = generateTypes(root); + + expect(output).toContain("import type { Address } from '@solana/addresses';"); + expect(output).not.toContain('ProgramDerivedAddress'); + }); + + test('should include ProgramDerivedAddress in the header when PDAs are present', () => { + const root = rootNode( + programNode({ + instructions: [], + name: 'p', + pdas: [pdaNode({ name: 'cfg', seeds: [constantPdaSeedNodeFromString('utf8', 'cfg')] })], + publicKey: '11111111111111111111111111111111', + }), + ); + + const output = generateTypes(root); + expect(output).toContain("import type { Address, ProgramDerivedAddress } from '@solana/addresses';"); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/get-resolution-refs.test.ts b/packages/dynamic-address-resolution/test/codegen/get-resolution-refs.test.ts new file mode 100644 index 000000000..88c08cdb1 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/get-resolution-refs.test.ts @@ -0,0 +1,119 @@ +import { + argumentValueNode, + booleanTypeNode, + instructionAccountNode, + instructionArgumentNode, + instructionNode, + instructionRemainingAccountsNode, + optionTypeNode, + resolverValueNode, +} from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { getResolutionRefs } from '../../src/codegen/get-resolution-refs'; + +describe('getResolutionRefs', () => { + test('should return null argsRef when there are no non-omitted arguments and no argument-valued remaining accounts', () => { + const ix = instructionNode({ accounts: [], arguments: [], name: 'noop' }); + const refs = getResolutionRefs(ix); + expect(refs.argsRef).toBeNull(); + expect(refs.hasArgs).toBe(false); + expect(refs.accountsRef).toBe('NoopAccounts'); + }); + + test('should return ${Name}Args when an argument exists', () => { + const ix = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'flag', type: booleanTypeNode() })], + name: 'setFlag', + }); + const refs = getResolutionRefs(ix); + expect(refs.argsRef).toBe('SetFlagArgs'); + expect(refs.hasArgs).toBe(true); + }); + + test('should ignore arguments with defaultValueStrategy "omitted"', () => { + const ix = instructionNode({ + accounts: [], + arguments: [ + instructionArgumentNode({ + defaultValueStrategy: 'omitted', + name: 'discriminator', + type: booleanTypeNode(), + }), + ], + name: 'omitted', + }); + const refs = getResolutionRefs(ix); + expect(refs.argsRef).toBeNull(); + }); + + test('should treat argumentValueNode remaining accounts as args', () => { + const ix = instructionNode({ + accounts: [], + arguments: [], + name: 'extra', + remainingAccounts: [instructionRemainingAccountsNode(argumentValueNode('extras'))], + }); + const refs = getResolutionRefs(ix); + expect(refs.argsRef).toBe('ExtraArgs'); + expect(refs.hasArgs).toBe(true); + }); + + test('should report hasRequiredArgs true when at least one non-optional argument exists', () => { + const ix = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'flag', type: booleanTypeNode() })], + name: 'setFlag', + }); + const refs = getResolutionRefs(ix); + expect(refs.hasRequiredArgs).toBe(true); + expect(refs.hasRequiredRemainingAccounts).toBe(false); + }); + + test('should report hasRequiredArgs false when every argument is optional-kinded', () => { + const ix = instructionNode({ + accounts: [], + arguments: [instructionArgumentNode({ name: 'maybeFlag', type: optionTypeNode(booleanTypeNode()) })], + name: 'setMaybeFlag', + }); + const refs = getResolutionRefs(ix); + expect(refs.hasArgs).toBe(true); + expect(refs.hasRequiredArgs).toBe(false); + }); + + test('should report hasRequiredRemainingAccounts based on the remaining-account argument isOptional flag', () => { + const required = instructionNode({ + accounts: [], + arguments: [], + name: 'requiredExtras', + remainingAccounts: [instructionRemainingAccountsNode(argumentValueNode('extras'), { isOptional: false })], + }); + const optional = instructionNode({ + accounts: [], + arguments: [], + name: 'optionalExtras', + remainingAccounts: [instructionRemainingAccountsNode(argumentValueNode('extras'), { isOptional: true })], + }); + expect(getResolutionRefs(required).hasRequiredRemainingAccounts).toBe(true); + expect(getResolutionRefs(optional).hasRequiredRemainingAccounts).toBe(false); + }); + + test('should emit resolversRef when an account default is a resolverValueNode', () => { + const ix = instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: resolverValueNode('resolveOwner'), + isSigner: false, + isWritable: false, + name: 'owner', + }), + ], + arguments: [], + name: 'doStuff', + }); + const refs = getResolutionRefs(ix); + expect(refs.resolversRef).toBe('DoStuffResolvers'); + expect(refs.hasResolvers).toBe(true); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/is-account-auto-resolvable.test.ts b/packages/dynamic-address-resolution/test/codegen/is-account-auto-resolvable.test.ts new file mode 100644 index 000000000..80dfe9002 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/is-account-auto-resolvable.test.ts @@ -0,0 +1,42 @@ +import { instructionAccountNode, pdaNode, pdaValueNode, publicKeyTypeNode, variablePdaSeedNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { isAccountAutoResolvable } from '../../src/codegen/is-account-auto-resolvable'; + +describe('isAccountAutoResolvable', () => { + test('should return false when account has no defaultValue', () => { + const acc = instructionAccountNode({ isSigner: false, isWritable: false, name: 'plain' }); + expect(isAccountAutoResolvable(acc)).toBe(false); + }); + + test.each([ + instructionAccountNode({ + defaultValue: { kind: 'identityValueNode' }, + isSigner: true, + isWritable: false, + name: 'identity', + }), + instructionAccountNode({ + defaultValue: { kind: 'payerValueNode' }, + isSigner: true, + isWritable: false, + name: 'payer', + }), + ])('should return false for identityValueNode and payerValueNode', acc => { + expect(isAccountAutoResolvable(acc)).toBe(false); + }); + + test('should return true for pdaValueNode default', () => { + const pda = pdaNode({ + name: 'thing', + seeds: [variablePdaSeedNode('owner', publicKeyTypeNode())], + }); + const acc = instructionAccountNode({ + defaultValue: pdaValueNode(pda), + isSigner: false, + isWritable: true, + name: 'thing', + }); + expect(isAccountAutoResolvable(acc)).toBe(true); + }); +}); diff --git a/packages/dynamic-address-resolution/test/codegen/resolver-fn-declaration.test.ts b/packages/dynamic-address-resolution/test/codegen/resolver-fn-declaration.test.ts new file mode 100644 index 000000000..5989c4116 --- /dev/null +++ b/packages/dynamic-address-resolution/test/codegen/resolver-fn-declaration.test.ts @@ -0,0 +1,59 @@ +import { instructionAccountNode, instructionNode, resolverValueNode } from 'codama'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { + generateResolutionInputTypes, + RESOLVER_FN_DECLARATION, +} from '../../src/codegen/generate-resolution-input-types'; +import type { ResolverFn } from '../../src/shared/types'; +import { makeRoot } from '../test-utils'; + +describe('emitted ResolverFn declaration', () => { + test('should emit the ResolverFn declaration verbatim', () => { + expect(RESOLVER_FN_DECLARATION).toMatchInlineSnapshot(` + "type ResolverFn = (argumentsInput: TArgumentsInput, accountsInput: TAccountsInput) => Promise; + + " + `); + }); + + test('should be emitted verbatim when at least one instruction has resolvers', () => { + const root = makeRoot([ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: resolverValueNode('resolveOwner'), + isSigner: false, + isWritable: false, + name: 'owner', + }), + ], + arguments: [], + name: 'doStuff', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).toContain(RESOLVER_FN_DECLARATION); + }); + + test('should match the structural shape of the runtime ResolverFn', () => { + // Mirror of RESOLVER_FN_DECLARATION (which is emitted as a string). + // This asserts the runtime ResolverFn keeps the same structural shape. + type EmittedResolverFn = (argumentsInput: TArgs, accountsInput: TAccs) => Promise; + expectTypeOf>().toEqualTypeOf< + EmittedResolverFn<{ x: number }, { a: string }> + >(); + }); + + test('should not be emitted when no instruction has resolvers', () => { + const root = makeRoot([ + instructionNode({ + accounts: [instructionAccountNode({ isSigner: false, isWritable: false, name: 'authority' })], + arguments: [], + name: 'plain', + }), + ]); + const output = generateResolutionInputTypes(root); + expect(output).not.toContain('type ResolverFn'); + }); +}); diff --git a/packages/dynamic-address-resolution/tsconfig.declarations.json b/packages/dynamic-address-resolution/tsconfig.declarations.json index 3a1928a5c..d60e8a4ad 100644 --- a/packages/dynamic-address-resolution/tsconfig.declarations.json +++ b/packages/dynamic-address-resolution/tsconfig.declarations.json @@ -6,5 +6,5 @@ "outDir": "./dist/types" }, "extends": "./tsconfig.json", - "include": ["src/index.ts"] + "include": ["src/index.ts", "src/codegen/index.ts"] } diff --git a/packages/dynamic-address-resolution/tsconfig.json b/packages/dynamic-address-resolution/tsconfig.json index 3c4bd67dd..c41e0eac3 100644 --- a/packages/dynamic-address-resolution/tsconfig.json +++ b/packages/dynamic-address-resolution/tsconfig.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "lib": [] + "lib": ["ES2022.Error"] }, "display": "@codama/dynamic-address-resolution", "extends": "../../tsconfig.json", diff --git a/packages/dynamic-address-resolution/tsup.config.ts b/packages/dynamic-address-resolution/tsup.config.ts index 55e99457f..49d4da6dd 100644 --- a/packages/dynamic-address-resolution/tsup.config.ts +++ b/packages/dynamic-address-resolution/tsup.config.ts @@ -1,5 +1,11 @@ import { defineConfig } from 'tsup'; -import { getPackageBuildConfigs } from '../../tsup.config.base'; +import { getBuildConfig, getCliBuildConfig, getPackageBuildConfigs } from '../../tsup.config.base'; -export default defineConfig(getPackageBuildConfigs()); +const codegenConfigs = (['cjs', 'esm'] as const).map(format => ({ + ...getBuildConfig({ format, platform: 'node' }), + entry: { codegen: './src/codegen/index.ts' }, + outExtension: () => ({ js: format === 'cjs' ? '.node.cjs' : '.node.mjs' }), +})); + +export default defineConfig([...getPackageBuildConfigs(), getCliBuildConfig(), ...codegenConfigs]); diff --git a/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types-from-file.ts b/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types-from-file.ts index 7d6c86672..b86f0956d 100644 --- a/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types-from-file.ts +++ b/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types-from-file.ts @@ -1,59 +1,12 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; - -import { createFromJson, type RootNode } from 'codama'; +import { generateTypesFromFile } from '@codama/dynamic-address-resolution/codegen'; import { generateClientTypes } from './generate-client-types'; -export function generateClientTypesFromFile(codamaIdlPath: string, outputDirPath: string) { - const idlPath = path.resolve(codamaIdlPath); - const outputDir = path.resolve(outputDirPath); - - if (!existsSync(idlPath)) { - console.error(`Error: IDL file not found: ${idlPath}`); - process.exit(1); - } - - console.log(`Reading IDL from: ${idlPath}`); - - let idlJson: string; - try { - idlJson = readFileSync(idlPath, 'utf-8'); - } catch (err) { - console.error(`Error reading IDL file: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - let idl: RootNode; - try { - idl = createFromJson(idlJson).getRoot(); - } catch (err) { - console.error( - `Error: ${idlPath} is not valid Codama JSON: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); - } - - let types: string = ''; - try { - console.log(`Generating types for program: ${idl.program.name}`); - types = generateClientTypes(idl); - } catch (err) { - console.error(`Error generating client types: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - try { - mkdirSync(outputDir, { recursive: true }); - const fileName = path.basename(idlPath); - const outputFile = fileName.replace(/\.json$/, '-types.ts'); - const outputPath = path.join(outputDir, outputFile); - - console.log(`Writing types to: ${outputPath}`); - writeFileSync(outputPath, types, 'utf-8'); - console.log('Done!'); - } catch (err) { - console.error(`Error writing generated types: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } +export function generateClientTypesFromFile(codamaIdlPath: string, outputDirPath: string): void { + generateTypesFromFile({ + codamaIdlPath, + generate: generateClientTypes, + outputDirPath, + outputFileSuffix: 'types', + }); } diff --git a/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types.ts b/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types.ts index d00ee6422..1f5f297cc 100644 --- a/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types.ts +++ b/packages/dynamic-client/src/cli/commands/generate-client-types/generate-client-types.ts @@ -1,24 +1,19 @@ -import { OPTIONAL_NODE_KINDS } from '@codama/dynamic-address-resolution'; -import type { - DefinedTypeNode, - InstructionAccountNode, - InstructionInputValueNode, - InstructionNode, - PdaNode, - RootNode, - TypeNode, -} from 'codama'; +import { + generatePdaTypes, + generateResolutionInputTypes, + getResolutionRefs, +} from '@codama/dynamic-address-resolution/codegen'; +import { generateSignerTypes, getInstructionSignerRef } from '@codama/dynamic-instructions/codegen'; +import { pascalCase, type RootNode } from 'codama'; /** * Generate TypeScript type for program client. */ export function generateClientTypes(idl: RootNode): string { - const programName = toPascalCase(idl.program.name); - const definedTypes = idl.program.definedTypes ?? []; + const programName = pascalCase(idl.program.name); - const pdaMap = collectPdaNodesFromIdl(idl); - - const hasPdas = pdaMap.size > 0; + const { mapTypeName: pdasMapTypeName, typeBlock: pdaTypeBlock } = generatePdaTypes(idl); + const hasPdas = pdasMapTypeName !== null; const addressImports = hasPdas ? 'Address, ProgramDerivedAddress' : 'Address'; let output = `/** @@ -34,11 +29,6 @@ import type { InstructionNode, RootNode } from 'codama'; import type { ${addressImports} } from '@solana/addresses'; import type { Instruction } from '@solana/instructions'; -export type ResolverFn< - TArgumentsInput = Record, - TAccountsInput = Record -> = (argumentsInput: TArgumentsInput, accountsInput: TAccountsInput) => Promise; - /** * Method builder interface. */ @@ -51,80 +41,23 @@ export type MethodBuilder arg.defaultValueStrategy !== 'omitted'); - const remainingAccountArgs = (ix.remainingAccounts ?? []).filter(ra => ra.value.kind === 'argumentValueNode'); - let argsRef = 'void'; - if (args.length > 0 || remainingAccountArgs.length > 0) { - const argsInterfaceName = `${typeName}Args`; - output += `export type ${argsInterfaceName} = {\n`; - for (const arg of args) { - const tsType = codamaTypeToTS(arg.type, definedTypes); - const isOptional = OPTIONAL_NODE_KINDS.includes(arg.type.kind); - const sep = isOptional ? '?:' : ':'; - output += ` ${arg.name}${sep} ${tsType};\n`; - } - for (const ra of remainingAccountArgs) { - const sep = ra.isOptional ? '?:' : ':'; - output += ` ${ra.value.name}${sep} Address[];\n`; - } - output += '};\n\n'; - argsRef = argsInterfaceName; - } - - // Build accounts interface - // these ValueNodes don't have default value and must be provided if required. - const nonResolvableValueNodes = ['payerValueNode', 'identityValueNode']; - function isAccAutoResolvable(acc: InstructionAccountNode): boolean { - if (acc.defaultValue == null) return false; - return !nonResolvableValueNodes.includes(acc.defaultValue.kind); - } - const accountsInterfaceName = `${typeName}Accounts`; - if (ix.accounts.length > 0) { - output += `export type ${accountsInterfaceName} = {\n`; - for (const acc of ix.accounts) { - // Omittable accounts have a defaultValue that can be auto-resolved, so they can be omitted from .accounts(). - // When null: resolved via optionalAccountStrategy. - // When undefined: resolved via defaultValue. - const omittable = isAccAutoResolvable(acc) ? '?' : ''; - const type = acc.isOptional ? 'Address | null' : 'Address'; - output += ` ${acc.name}${omittable}: ${type};\n`; - } - output += '} & Record;\n\n'; - } else { - output += `export type ${accountsInterfaceName} = Record;\n\n`; - } - - // Collect all ambiguous isSigner: "either" account names - const eitherSignerAccounts = ix.accounts.filter(acc => acc.isSigner === 'either').map(acc => `'${acc.name}'`); - if (eitherSignerAccounts.length > 0) { - output += `export type ${typeName}Signers = (${eitherSignerAccounts.join(' | ')})[];\n\n`; - } + for (const ix of idl.program.instructions) { + const typeName = pascalCase(ix.name); + const refs = getResolutionRefs(ix); + const signerRef = getInstructionSignerRef(ix); + const signersGeneric = signerRef.signersRef ?? 'string[]'; + const resolversGeneric = refs.resolversRef ? `, ${refs.resolversRef}` : ''; - // Collect resolver names for this instruction - const resolverNames = collectResolverNames(ix); - let resolversRef = ''; - if (resolverNames.size > 0) { - const resolversTypeName = `${typeName}Resolvers`; - output += `export type ${resolversTypeName} = {\n`; - for (const name of resolverNames) { - output += ` ${name}: ResolverFn<${argsRef === 'void' ? 'Record' : argsRef}, ${accountsInterfaceName}>;\n`; - } - output += '};\n\n'; - resolversRef = resolversTypeName; + let argsParam = ''; + if (refs.argsRef) { + const allArgsOptional = !refs.hasRequiredArgs && !refs.hasRequiredRemainingAccounts; + argsParam = allArgsOptional ? `args?: ${refs.argsRef}` : `args: ${refs.argsRef}`; } - // Generate method type - const hasRequiredArgs = args.some(arg => !OPTIONAL_NODE_KINDS.includes(arg.type.kind)); - const hasRequiredRemainingAccounts = remainingAccountArgs.some(ra => !ra.isOptional); - const allArgsOptional = !hasRequiredArgs && !hasRequiredRemainingAccounts; - const argsParam = argsRef === 'void' ? '' : allArgsOptional ? `args?: ${argsRef}` : `args: ${argsRef}`; - const signersGeneric = eitherSignerAccounts.length > 0 ? `${typeName}Signers` : 'string[]'; - const resolversGeneric = resolversRef ? `, ${resolversRef}` : ''; - const methodSignature = `(${argsParam}) => MethodBuilder<${accountsInterfaceName}, ${signersGeneric}${resolversGeneric}>`; + const methodSignature = `(${argsParam}) => MethodBuilder<${refs.accountsRef}, ${signersGeneric}${resolversGeneric}>`; output += `export type ${typeName}Method = ${methodSignature};\n\n`; } @@ -134,40 +67,15 @@ export type MethodBuilder 0) { - for (const [pdaName, pdaNode] of pdaMap) { - const typeName = toPascalCase(pdaName); - const variableSeeds = (pdaNode.seeds ?? []).filter(s => s.kind === 'variablePdaSeedNode'); - if (variableSeeds.length > 0) { - output += `export type ${typeName}PdaSeeds = {\n`; - for (const seed of variableSeeds) { - const tsType = seed.type ? codamaTypeToTS(seed.type, definedTypes) : 'unknown'; - output += ` ${seed.name}: ${tsType};\n`; - } - output += '};\n\n'; - } - } + output += pdaTypeBlock; - output += `/**\n * Strongly-typed PDAs for ${programName}.\n */\n`; - output += `export type ${programName}Pdas = {\n`; - for (const [pdaName, pdaNode] of pdaMap) { - const typeName = toPascalCase(pdaName); - const variableSeeds = (pdaNode.seeds ?? []).filter(s => s.kind === 'variablePdaSeedNode'); - const seedsParam = - variableSeeds.length > 0 ? `seeds: ${typeName}PdaSeeds` : `seeds?: Record`; - output += ` ${pdaName}: (${seedsParam}) => Promise;\n`; - } - output += '};\n\n'; - } - - const pdasProp = pdaMap.size > 0 ? ` pdas: ${programName}Pdas;\n` : ''; + const pdasProp = pdasMapTypeName ? ` pdas: ${pdasMapTypeName};\n` : ''; output += `/** * Strongly-typed program client for ${programName}. */ @@ -181,152 +89,3 @@ ${pdasProp} programAddress: Address; return output; } - -/** - * Convert Codama type to TypeScript type string. - */ -function codamaTypeToTS(type: TypeNode | undefined, definedTypes: DefinedTypeNode[]): string { - if (!type || typeof type !== 'object') return 'unknown'; - - switch (type.kind) { - case 'numberTypeNode': - return ['u64', 'u128', 'i64', 'i128'].includes(type.format ?? '') ? 'number | bigint' : 'number'; - case 'publicKeyTypeNode': - return 'Address'; - case 'stringTypeNode': - return 'string'; - case 'booleanTypeNode': - return 'boolean'; - case 'optionTypeNode': - return `${codamaTypeToTS(type.item, definedTypes)} | null`; - case 'remainderOptionTypeNode': - case 'zeroableOptionTypeNode': - return `${codamaTypeToTS(type.item, definedTypes)} | null`; - case 'bytesTypeNode': - return 'Uint8Array'; - case 'fixedSizeTypeNode': - case 'sizePrefixTypeNode': - case 'hiddenPrefixTypeNode': - case 'preOffsetTypeNode': - case 'postOffsetTypeNode': - case 'hiddenSuffixTypeNode': - case 'sentinelTypeNode': - return codamaTypeToTS(type.type, definedTypes); - case 'amountTypeNode': - case 'solAmountTypeNode': - return 'number | bigint'; - case 'structTypeNode': { - if (!type.fields || type.fields.length === 0) return '{}'; - const fields = type.fields - .filter(f => f.defaultValueStrategy !== 'omitted') - .map(f => `${f.name}: ${codamaTypeToTS(f.type, definedTypes)}`); - if (fields.length === 0) return '{}'; - return `{ ${fields.join('; ')} }`; - } - case 'enumTypeNode': { - if (!type.variants || type.variants.length === 0) return 'unknown'; - const allEmpty = type.variants.every(v => v.kind === 'enumEmptyVariantTypeNode'); - if (allEmpty) { - return type.variants.map(v => `'${v.name}'`).join(' | '); - } - // Enum with struct/tuple variants — discriminated union - const variantTypes = type.variants.map(v => { - if (v.kind === 'enumEmptyVariantTypeNode') { - return `{ __kind: '${v.name}' }`; - } - if (v.kind === 'enumStructVariantTypeNode' && v.struct) { - const inner = codamaTypeToTS(v.struct, definedTypes); - return `{ __kind: '${v.name}' } & ${inner}`; - } - if (v.kind === 'enumTupleVariantTypeNode' && v.tuple) { - const inner = codamaTypeToTS(v.tuple, definedTypes); - return `{ __kind: '${v.name}'; fields: ${inner} }`; - } - return `{ __kind: '${v.name}' }`; - }); - return variantTypes.join(' | '); - } - case 'tupleTypeNode': { - if (!type.items || type.items.length === 0) return '[]'; - const items = type.items.map(i => codamaTypeToTS(i, definedTypes)); - return `[${items.join(', ')}]`; - } - case 'arrayTypeNode': - case 'setTypeNode': { - const itemType = codamaTypeToTS(type.item, definedTypes); - const needsParens = itemType.includes(' | ') || itemType.includes(' & '); - return needsParens ? `(${itemType})[]` : `${itemType}[]`; - } - case 'mapTypeNode': { - const v = codamaTypeToTS(type.value, definedTypes); - return `Record`; - } - case 'definedTypeLinkNode': { - if (!type.name) return 'unknown'; - const def = definedTypes.find(d => d.name === type.name); - if (!def) return 'unknown'; - return codamaTypeToTS(def.type, definedTypes); - } - case 'dateTimeTypeNode': { - return codamaTypeToTS(type.number, definedTypes); - } - default: - type['kind'] satisfies never; - return 'unknown'; - } -} - -function collectPdaNodesFromIdl(idl: RootNode): Map { - const pdas = new Map(); - - for (const pda of idl.program.pdas ?? []) { - pdas.set(pda.name, pda); - } - - for (const ix of idl.program.instructions) { - for (const acc of ix.accounts) { - if (!acc.defaultValue || acc.defaultValue.kind !== 'pdaValueNode') continue; - const pdaDef = acc.defaultValue.pda; - if (!pdaDef || pdaDef.kind !== 'pdaNode') continue; - if (!pdas.has(pdaDef.name)) { - pdas.set(pdaDef.name, pdaDef); - } - } - } - - return pdas; -} - -/** - * Collects all unique resolverValueNode names from an instruction's accounts and arguments. - */ -function collectResolverNames(ix: InstructionNode): Set { - const names = new Set(); - - function extractResolverNodeName(node: InstructionInputValueNode | undefined): void { - if (!node) return; - if (node.kind === 'resolverValueNode' && node.name) { - names.add(node.name); - } else if (node.kind === 'conditionalValueNode') { - extractResolverNodeName(node.condition); - extractResolverNodeName(node.ifTrue); - extractResolverNodeName(node.ifFalse); - } - } - - for (const acc of ix.accounts) { - extractResolverNodeName(acc.defaultValue); - } - for (const arg of ix.arguments) { - extractResolverNodeName(arg.defaultValue); - } - - return names; -} - -function toPascalCase(str: string): string { - return str - .split(/[-_]/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(''); -} diff --git a/packages/dynamic-client/src/cli/commands/generate-client-types/register-command.ts b/packages/dynamic-client/src/cli/commands/generate-client-types/register-command.ts index 7e6a8d7ae..54d1edc69 100644 --- a/packages/dynamic-client/src/cli/commands/generate-client-types/register-command.ts +++ b/packages/dynamic-client/src/cli/commands/generate-client-types/register-command.ts @@ -9,6 +9,11 @@ export function registerGenerateClientTypesCommand(program: Command): void { .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') .action((idlArg: string, outputDirArg: string) => { - generateClientTypesFromFile(idlArg, outputDirArg); + try { + generateClientTypesFromFile(idlArg, outputDirArg); + } catch (err) { + console.error(err); + process.exit(1); + } }); } diff --git a/packages/dynamic-client/test/unit/cli/program-client-types.test.ts b/packages/dynamic-client/test/unit/cli/program-client-types.test.ts index 08dc7eba1..96579f44e 100644 --- a/packages/dynamic-client/test/unit/cli/program-client-types.test.ts +++ b/packages/dynamic-client/test/unit/cli/program-client-types.test.ts @@ -1,3 +1,4 @@ +import type { ResolverFn } from '@codama/dynamic-address-resolution'; import type { Address, ProgramDerivedAddress } from '@solana/addresses'; import type { Instruction } from '@solana/instructions'; import type { InstructionNode, RootNode } from 'codama'; @@ -7,7 +8,6 @@ import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers, - ResolverFn, } from '../../programs/generated/custom-resolvers-test-idl-types'; import type { AllocateArgs, diff --git a/packages/dynamic-instructions/README.md b/packages/dynamic-instructions/README.md index bb7683e98..8d3872e7e 100644 --- a/packages/dynamic-instructions/README.md +++ b/packages/dynamic-instructions/README.md @@ -20,8 +20,25 @@ pnpm install @codama/dynamic-instructions ## Types generation -> [!NOTE] -> For now, per-instruction types (`*Args`, `*Accounts`, `*Resolvers`) can be produced via [`@codama/dynamic-client`](../dynamic-client/README.md)'s `generate-client-types` command, which emits a `-idl-types.ts` file. A type generation for this package will be added in a follow-up release. +This package can emit TypeScript types per-instruction - `${Name}Args`, `${Name}Accounts`, `${Name}Resolvers`, and `${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. + +### CLI + +```sh +npx @codama/dynamic-instructions generate-types +``` + +Writes `-instruction-types.ts` to the output directory. + +### Programmatic + +```ts +import { generateTypes } from '@codama/dynamic-instructions/codegen'; + +const source = generateTypes(idl); +``` ## Functions @@ -38,10 +55,10 @@ const instruction = await build(args, accounts, signers, resolvers); **Typed:** -> Types are generated via [`generate-client-types`](#types-generation). +> Types are generated via [`generate-types`](#types-generation). ```ts -import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-idl-types'; +import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-instruction-types'; const build = createInstructionsBuilder(root, ixNode); const instruction = await build({ name: 'item' }, { authority }, [], { @@ -61,10 +78,10 @@ const accountMetas = await createAccountMeta(root, ixNode, args, accounts, ['own **Typed:** -> Types are generated via [`generate-client-types`](#types-generation). +> Types are generated via [`generate-types`](#types-generation). ```ts -import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-idl-types'; +import type { CreateItemAccounts, CreateItemArgs, CreateItemResolvers } from './generated/-instruction-types'; const accountMetas = await createAccountMeta( root, @@ -88,10 +105,10 @@ const data = encodeInstructionArguments(root, ixNode, { amount: 1_000_000_000 }) **Typed:** -> Types are generated via [`generate-client-types`](#types-generation). +> Types are generated via [`generate-types`](#types-generation). ```ts -import type { TransferArgs } from './generated/-idl-types'; +import type { TransferArgs } from './generated/-instruction-types'; const data = encodeInstructionArguments(root, ixNode, { amount: 1_000_000_000n }); ``` diff --git a/packages/dynamic-instructions/bin/cli.cjs b/packages/dynamic-instructions/bin/cli.cjs new file mode 100755 index 000000000..0e2d748a0 --- /dev/null +++ b/packages/dynamic-instructions/bin/cli.cjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const run = require('../dist/cli.cjs').run; + +run(process.argv); diff --git a/packages/dynamic-instructions/package.json b/packages/dynamic-instructions/package.json index 7e4b7267d..bca52c861 100644 --- a/packages/dynamic-instructions/package.json +++ b/packages/dynamic-instructions/package.json @@ -2,6 +2,9 @@ "name": "@codama/dynamic-instructions", "version": "0.2.0", "description": "Runtime instruction creation for Codama IDLs", + "bin": { + "dynamic-instructions": "./bin/cli.cjs" + }, "exports": { ".": { "types": "./dist/types/index.d.ts", @@ -14,6 +17,11 @@ "import": "./dist/index.node.mjs", "require": "./dist/index.node.cjs" } + }, + "./codegen": { + "types": "./dist/types/codegen/index.d.ts", + "import": "./dist/codegen.node.mjs", + "require": "./dist/codegen.node.cjs" } }, "browser": { @@ -24,10 +32,20 @@ "module": "./dist/index.node.mjs", "react-native": "./dist/index.react-native.mjs", "types": "./dist/types/index.d.ts", + "typesVersions": { + "*": { + "codegen": [ + "./dist/types/codegen/index.d.ts" + ] + } + }, "type": "commonjs", "files": [ "./dist/types", - "./dist/index.*" + "./dist/index.*", + "./dist/cli.*", + "./dist/codegen.*", + "./bin/*" ], "sideEffects": false, "keywords": [ @@ -55,6 +73,7 @@ "@solana/codecs": "^5.3.0", "@solana/instructions": "^5.3.0", "codama": "workspace:*", + "commander": "^14.0.2", "superstruct": "^2.0.2" }, "devDependencies": { diff --git a/packages/dynamic-instructions/src/cli/commands/generate-types/generate-types-from-file.ts b/packages/dynamic-instructions/src/cli/commands/generate-types/generate-types-from-file.ts new file mode 100644 index 000000000..a807af2e2 --- /dev/null +++ b/packages/dynamic-instructions/src/cli/commands/generate-types/generate-types-from-file.ts @@ -0,0 +1,12 @@ +import { generateTypesFromFile as generateTypesFromFileShared } from '@codama/dynamic-address-resolution/codegen'; + +import { generateTypes } from '../../../codegen/generate-types'; + +export function generateTypesFromFile(codamaIdlPath: string, outputDirPath: string): void { + generateTypesFromFileShared({ + codamaIdlPath, + generate: generateTypes, + outputDirPath, + outputFileSuffix: 'instruction-types', + }); +} 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 new file mode 100644 index 000000000..01a2e662b --- /dev/null +++ b/packages/dynamic-instructions/src/cli/commands/generate-types/register-command.ts @@ -0,0 +1,21 @@ +import type { Command } from 'commander'; + +import { generateTypesFromFile } from './generate-types-from-file'; + +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', + ) + .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') + .action((idlArg: string, outputDirArg: string) => { + try { + generateTypesFromFile(idlArg, outputDirArg); + } catch (err) { + console.error(err); + process.exit(1); + } + }); +} diff --git a/packages/dynamic-instructions/src/cli/commands/index.ts b/packages/dynamic-instructions/src/cli/commands/index.ts new file mode 100644 index 000000000..71f02c1ec --- /dev/null +++ b/packages/dynamic-instructions/src/cli/commands/index.ts @@ -0,0 +1,7 @@ +import type { Command } from 'commander'; + +import { registerGenerateTypesCommand } from './generate-types/register-command'; + +export function registerCommands(program: Command): void { + registerGenerateTypesCommand(program); +} diff --git a/packages/dynamic-instructions/src/cli/index.ts b/packages/dynamic-instructions/src/cli/index.ts new file mode 100644 index 000000000..88a375bd0 --- /dev/null +++ b/packages/dynamic-instructions/src/cli/index.ts @@ -0,0 +1,13 @@ +import { createProgram } from './program'; + +const program = createProgram(); + +export function run(argv: string[]): void { + // Show help when invoked with no arguments. + if (argv.length <= 2) { + program.outputHelp(); + return; + } + + program.parse(argv); +} diff --git a/packages/dynamic-instructions/src/cli/program.ts b/packages/dynamic-instructions/src/cli/program.ts new file mode 100644 index 000000000..8086c41ad --- /dev/null +++ b/packages/dynamic-instructions/src/cli/program.ts @@ -0,0 +1,12 @@ +import { Command } from 'commander'; + +import { registerCommands } from './commands'; + +export function createProgram(): Command { + const program = new Command(); + + program.name('dynamic-instructions').description('CLI for @codama/dynamic-instructions').showHelpAfterError(true); + + registerCommands(program); + return program; +} diff --git a/packages/dynamic-instructions/src/codegen/collect-either-signer-names.ts b/packages/dynamic-instructions/src/codegen/collect-either-signer-names.ts new file mode 100644 index 000000000..ab6b10d36 --- /dev/null +++ b/packages/dynamic-instructions/src/codegen/collect-either-signer-names.ts @@ -0,0 +1,8 @@ +import type { InstructionNode } from 'codama'; + +/** + * Collect the names of accounts on an instruction with `isSigner: 'either'`. + */ +export function collectEitherSignerNames(ix: InstructionNode): string[] { + return ix.accounts.filter(acc => acc.isSigner === 'either').map(acc => acc.name); +} diff --git a/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts b/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts new file mode 100644 index 000000000..485cfc00f --- /dev/null +++ b/packages/dynamic-instructions/src/codegen/generate-instruction-builder.ts @@ -0,0 +1,31 @@ +import { getResolutionRefs } from '@codama/dynamic-address-resolution/codegen'; +import { pascalCase, type RootNode } from 'codama'; + +import { getInstructionSignerRef } from './generate-signer-types'; + +/** + * Generate the `${Program}InstructionBuilders` aggregate map type. + * Keys each instruction name to its `InstructionsBuilderFn` signature. + * + * NOTE: it is intentionally NOT exported as public method. + * Use `generateTypes` instead. + */ +export function generateInstructionBuildersMap(idl: RootNode): string { + const programName = pascalCase(idl.program.name); + let output = `/** + * Strongly-typed instruction builders for ${programName}. + */ +export type ${programName}InstructionBuilders = {\n`; + + for (const ix of idl.program.instructions) { + const refs = getResolutionRefs(ix); + const signerRef = getInstructionSignerRef(ix); + const argsGeneric = refs.argsRef ?? 'Record'; + const signersGeneric = signerRef.signersRef ?? 'string[]'; + const resolversGeneric = refs.resolversRef ? `, ${refs.resolversRef}` : ''; + output += ` ${ix.name}: InstructionsBuilderFn<${argsGeneric}, ${refs.accountsRef}, ${signersGeneric}${resolversGeneric}>;\n`; + } + + output += '};\n'; + return output; +} diff --git a/packages/dynamic-instructions/src/codegen/generate-signer-types.ts b/packages/dynamic-instructions/src/codegen/generate-signer-types.ts new file mode 100644 index 000000000..593cf8e35 --- /dev/null +++ b/packages/dynamic-instructions/src/codegen/generate-signer-types.ts @@ -0,0 +1,38 @@ +import { type InstructionNode, pascalCase, type RootNode } from 'codama'; + +import { collectEitherSignerNames } from './collect-either-signer-names'; + +/** + * Generate the per-instruction `${Name}Signers` type alias for instructions with `isSigner: 'either'` accounts. + */ +export function generateSignerTypes(idl: RootNode): string { + let output = ''; + for (const ix of idl.program.instructions) { + output += generateSignersTypeBlock(ix); + } + return output; +} + +/** + * Symbol registry for the `${Name}Signers` alias. + * Describe which `isSigner: 'either'` accounts are passed as signers. + */ +export type InstructionSignerRef = { + hasEitherSigners: boolean; + signersRef: string | null; +}; + +export function getInstructionSignerRef(ix: InstructionNode): InstructionSignerRef { + const hasEitherSigners = collectEitherSignerNames(ix).length > 0; + return { + hasEitherSigners, + signersRef: hasEitherSigners ? `${pascalCase(ix.name)}Signers` : null, + }; +} + +function generateSignersTypeBlock(ix: InstructionNode): string { + const names = collectEitherSignerNames(ix); + if (names.length === 0) return ''; + const quoted = names.map(name => `'${name}'`); + return `export type ${pascalCase(ix.name)}Signers = (${quoted.join(' | ')})[];\n\n`; +} diff --git a/packages/dynamic-instructions/src/codegen/generate-types.ts b/packages/dynamic-instructions/src/codegen/generate-types.ts new file mode 100644 index 000000000..de23ce45a --- /dev/null +++ b/packages/dynamic-instructions/src/codegen/generate-types.ts @@ -0,0 +1,30 @@ +import { generateResolutionInputTypes } from '@codama/dynamic-address-resolution/codegen'; +import type { RootNode } from 'codama'; + +import { generateInstructionBuildersMap } from './generate-instruction-builder'; +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}Signers` aliases. + * - The aggregate `${Program}InstructionBuilders` map. + */ +export function generateTypes(idl: RootNode): string { + return ( + INSTRUCTION_TYPES_FILE_HEADER + + generateResolutionInputTypes(idl) + + generateSignerTypes(idl) + + generateInstructionBuildersMap(idl) + ); +} + +const INSTRUCTION_TYPES_FILE_HEADER = `/** + * Auto-generated instruction types + * DO NOT EDIT - Generated by @codama/dynamic-instructions generate-types + */ + +import type { Address } from '@solana/addresses'; +import type { InstructionsBuilderFn } from '@codama/dynamic-instructions'; + +`; diff --git a/packages/dynamic-instructions/src/codegen/index.ts b/packages/dynamic-instructions/src/codegen/index.ts new file mode 100644 index 000000000..69c4899d8 --- /dev/null +++ b/packages/dynamic-instructions/src/codegen/index.ts @@ -0,0 +1,3 @@ +export { collectEitherSignerNames } from './collect-either-signer-names'; +export { generateSignerTypes, getInstructionSignerRef, type InstructionSignerRef } from './generate-signer-types'; +export { generateTypes } from './generate-types'; diff --git a/packages/dynamic-instructions/test/cli/generate-types.test.ts b/packages/dynamic-instructions/test/cli/generate-types.test.ts new file mode 100644 index 000000000..92c3056f0 --- /dev/null +++ b/packages/dynamic-instructions/test/cli/generate-types.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterAll, describe, expect, test } from 'vitest'; + +import { makeRoot } from '../test-utils'; + +const CLI_PATH = path.resolve('bin/cli.cjs'); + +function execCli(args: string[]) { + try { + const stdout = execFileSync('node', [CLI_PATH, ...args], { + cwd: path.resolve('.'), + encoding: 'utf-8', + stdio: 'pipe', + }); + return { exitCode: 0, stderr: '', stdout }; + } catch (error: unknown) { + const e = error as { status: number; stderr: string; stdout: string }; + return { exitCode: e.status ?? 1, stderr: e.stderr ?? '', stdout: e.stdout ?? '' }; + } +} + +describe('CLI', () => { + const tmpDirs: string[] = []; + + afterAll(() => { + for (const dir of tmpDirs) { + rmSync(dir, { force: true, recursive: true }); + } + }); + + test('should print help when no arguments are provided', () => { + const { stdout, exitCode } = execCli([]); + expect(stdout).toContain('Usage: dynamic-instructions'); + expect(stdout).toContain('generate-types '); + expect(exitCode).toBe(0); + }); + + test('should read IDL and write output file for generate-types', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'di-cli-')); + tmpDirs.push(tmpDir); + const idlPath = path.join(tmpDir, 'test.json'); + writeFileSync(idlPath, JSON.stringify(makeRoot([])), 'utf-8'); + + const { exitCode } = execCli(['generate-types', idlPath, tmpDir]); + expect(exitCode).toBe(0); + + const outputPath = path.join(tmpDir, 'test-instruction-types.ts'); + expect(existsSync(outputPath)).toBe(true); + }); +}); 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 new file mode 100644 index 000000000..28e9ac02d --- /dev/null +++ b/packages/dynamic-instructions/test/codegen/collect-either-signer-names.test.ts @@ -0,0 +1,28 @@ +import { instructionAccountNode, instructionNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { collectEitherSignerNames } from '../../src/codegen/collect-either-signer-names'; + +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' }), + ], + arguments: [], + name: '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', + }); + 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 new file mode 100644 index 000000000..7e511a25f --- /dev/null +++ b/packages/dynamic-instructions/test/codegen/generate-instruction-builder.test.ts @@ -0,0 +1,30 @@ +import { instructionAccountNode, instructionArgumentNode, instructionNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { generateInstructionBuildersMap } from '../../src/codegen/generate-instruction-builder'; +import { makeRoot } from '../test-utils'; + +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', + }), + instructionNode({ name: 'close' }), + ], + 'token', + ); + const output = generateInstructionBuildersMap(root); + expect(output).toContain('export type TokenInstructionBuilders'); + expect(output).toContain('transfer: InstructionsBuilderFn;'); + expect(output).toContain('close: InstructionsBuilderFn, CloseAccounts, string[]>;'); + }); +}); diff --git a/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts b/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts new file mode 100644 index 000000000..3a5a4d7e6 --- /dev/null +++ b/packages/dynamic-instructions/test/codegen/generate-signer-types.test.ts @@ -0,0 +1,56 @@ +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'; + +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' }), + ], + name: 'transfer', + }), + ]); + const output = generateSignerTypes(root); + expect(output).toContain("export type TransferSigners = ('authority')[];"); + }); + + 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', + }), + ]); + const output = generateSignerTypes(root); + expect(output).not.toContain('NoEitherSigners'); + }); +}); + +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', + }); + const ref = getInstructionSignerRef(ix); + expect(ref.signersRef).toBe('TransferSigners'); + expect(ref.hasEitherSigners).toBe(true); + }); + + 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', + }); + const ref = getInstructionSignerRef(ix); + expect(ref.signersRef).toBeNull(); + expect(ref.hasEitherSigners).toBe(false); + }); +}); diff --git a/packages/dynamic-instructions/test/codegen/generate-types.test.ts b/packages/dynamic-instructions/test/codegen/generate-types.test.ts new file mode 100644 index 000000000..ffd50c1f0 --- /dev/null +++ b/packages/dynamic-instructions/test/codegen/generate-types.test.ts @@ -0,0 +1,37 @@ +import { instructionAccountNode, instructionArgumentNode, instructionNode } from 'codama'; +import { describe, expect, test } from 'vitest'; + +import { generateTypes } from '../../src/codegen/generate-types'; +import { makeRoot } from '../test-utils'; + +describe('generateTypes', () => { + test('should compose header, instruction blocks, signers, and instruction builders map', () => { + const root = makeRoot( + [ + instructionNode({ + accounts: [ + instructionAccountNode({ isSigner: 'either', isWritable: false, name: 'authority' }), + instructionAccountNode({ isSigner: false, isWritable: true, name: 'source' }), + ], + arguments: [ + instructionArgumentNode({ + name: 'amount', + type: { endian: 'le', format: 'u64', kind: 'numberTypeNode' }, + }), + ], + name: 'transfer', + }), + ], + 'token', + ); + const output = generateTypes(root); + // 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 TransferAccounts'); + expect(output).toContain("export type TransferSigners = ('authority')[];"); + expect(output).toContain('export type TokenInstructionBuilders'); + expect(output).toContain('transfer: InstructionsBuilderFn { const signer = await generateKeyPairSigner(); return signer.address; } + +export function makeRoot(instructions: ReturnType[], name = 'testProgram') { + return rootNode( + programNode({ + instructions, + name, + publicKey: '11111111111111111111111111111111', + }), + ); +} diff --git a/packages/dynamic-instructions/tsconfig.declarations.json b/packages/dynamic-instructions/tsconfig.declarations.json index 3a1928a5c..d60e8a4ad 100644 --- a/packages/dynamic-instructions/tsconfig.declarations.json +++ b/packages/dynamic-instructions/tsconfig.declarations.json @@ -6,5 +6,5 @@ "outDir": "./dist/types" }, "extends": "./tsconfig.json", - "include": ["src/index.ts"] + "include": ["src/index.ts", "src/codegen/index.ts"] } diff --git a/packages/dynamic-instructions/tsup.config.ts b/packages/dynamic-instructions/tsup.config.ts index 55e99457f..4c489146b 100644 --- a/packages/dynamic-instructions/tsup.config.ts +++ b/packages/dynamic-instructions/tsup.config.ts @@ -1,5 +1,16 @@ import { defineConfig } from 'tsup'; -import { getPackageBuildConfigs } from '../../tsup.config.base'; +import { getBuildConfig, getCliBuildConfig, getPackageBuildConfigs } from '../../tsup.config.base'; -export default defineConfig(getPackageBuildConfigs()); +const codegenConfigs = (['cjs', 'esm'] as const).map(format => ({ + ...getBuildConfig({ format, platform: 'node' }), + entry: { codegen: './src/codegen/index.ts' }, + outExtension: () => ({ js: format === 'cjs' ? '.node.cjs' : '.node.mjs' }), +})); + +export default defineConfig([ + ...getPackageBuildConfigs(), + getCliBuildConfig(), + // Codegen entry: Node CJS + ESM for programmatic import by other packages. + ...codegenConfigs, +]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 138cde353..7013e5fbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: codama: specifier: workspace:* version: link:../library + commander: + specifier: ^14.0.2 + version: 14.0.3 devDependencies: '@solana/kit': specifier: 6.5.0 @@ -200,6 +203,9 @@ importers: codama: specifier: workspace:* version: link:../library + commander: + specifier: ^14.0.2 + version: 14.0.3 superstruct: specifier: ^2.0.2 version: 2.0.2