+ ? TypedInstruction
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ TEntry extends Record>
+ ? {
+ [K in keyof TEntry]: TEntry[K] extends InstructionHandler
+ ? TypedInstruction
+ : TypedInstruction, unknown>;
+ }
+ : TypedInstruction, unknown>;
+
+export type InstructionsInterface<
+ TInstructions extends Record | undefined,
+> =
+ TInstructions extends Record
+ ? { [K in keyof TInstructions]: TypedInstructionFor }
+ : Record;
-export type InstructionsInterface | undefined> =
- TInstructions extends Record
- ? { [K in keyof TInstructions]: InstructionExecutor }
- : {};
+/** @deprecated Retained for backward compatibility; prefer {@link TypedInstruction}. */
+export type InstructionExecutor = TypedInstruction, unknown>;
+
+/**
+ * Distinguishes a handler from a per-program map of handlers in a stack
+ * definition's `instructions` block. Handlers are the only entries with a
+ * `build` function.
+ */
+export function isInstructionHandler(
+ entry: import('./types').StackInstructionEntry
+): entry is InstructionHandler {
+ return typeof (entry as InstructionHandler).build === 'function';
+}
export class Arete {
private readonly connection: ConnectionManager;
@@ -63,12 +121,15 @@ export class Arete {
private readonly _views: TypedViews;
private readonly stack: TStack;
private readonly _instructions: InstructionsInterface;
+ private _wallet?: WalletAdapter;
+ private _aggregatedErrors?: ErrorMetadata[];
private constructor(
url: string,
options: AreteOptionsWithStorage
) {
this.stack = options.stack;
+ this._wallet = options.wallet;
this.storage = new SortedStorageDecorator(options.storage ?? new MemoryAdapter());
this.processor = new FrameProcessor(this.storage, {
maxEntriesPerView: options.maxEntriesPerView,
@@ -92,19 +153,56 @@ export class Arete {
}
private buildInstructions(): InstructionsInterface {
- const instructions = {} as Record;
-
+ const instructions = {} as Record<
+ string,
+ | TypedInstruction, unknown>
+ | Record, unknown>>
+ >;
+
if (this.stack.instructions) {
- for (const [name, handler] of Object.entries(this.stack.instructions)) {
- instructions[name] = (args: Record, options: InstructionExecutorOptions) => {
- return executeInstruction(handler as InstructionHandler, args, options);
- };
+ for (const [name, entry] of Object.entries(this.stack.instructions)) {
+ if (isInstructionHandler(entry)) {
+ instructions[name] = this.createTypedInstruction(entry);
+ } else {
+ // Multi-program stacks namespace handlers one level deep.
+ const nested: Record, unknown>> = {};
+ for (const [instructionName, handler] of Object.entries(entry)) {
+ nested[instructionName] = this.createTypedInstruction(handler as InstructionHandler);
+ }
+ instructions[name] = nested;
+ }
}
}
-
+
return instructions as InstructionsInterface;
}
+ private createTypedInstruction(
+ handler: InstructionHandler
+ ): TypedInstruction, unknown> {
+ const fn = ((
+ params: Record,
+ options?: InstructionExecutorOptions
+ ) => {
+ return executeInstruction(handler, params, this.withWallet(options));
+ }) as TypedInstruction, unknown>;
+
+ fn.build = (params: Record, options?: BuildOptions) => {
+ return buildInstruction(handler, params, this.withWallet(options));
+ };
+
+ return fn;
+ }
+
+ /** Merge the client's default wallet into call options (call options win). */
+ private withWallet(options?: T): T {
+ const merged = { ...(options ?? {}) } as T;
+ if (!merged.wallet && this._wallet) {
+ merged.wallet = this._wallet;
+ }
+ return merged;
+ }
+
static async connect(
stack: T,
options?: ConnectOptions
@@ -125,6 +223,7 @@ export class Arete {
maxReconnectAttempts: options?.maxReconnectAttempts,
validateFrames: options?.validateFrames,
auth: options?.auth,
+ wallet: options?.wallet,
};
const client = new Arete(url, internalOptions);
@@ -144,6 +243,82 @@ export class Arete {
return this._instructions;
}
+ /** The default wallet adapter, if one was configured. */
+ get wallet(): WalletAdapter | undefined {
+ return this._wallet;
+ }
+
+ /**
+ * Set (or clear) the default wallet adapter used for instruction execution.
+ * Useful for connecting/disconnecting a wallet after the client is created.
+ */
+ setWallet(wallet: WalletAdapter | undefined): void {
+ this._wallet = wallet;
+ }
+
+ /**
+ * Sign and send a batch of pre-built instructions as a single transaction.
+ *
+ * Build instructions with `client.instructions..build(params)` and
+ * compose them here. RPC/compilation/confirmation are owned by the adapter.
+ *
+ * On failure, the error is parsed against `options.errors` when given,
+ * otherwise against error metadata aggregated from all the stack's handlers
+ * (deduped by code, first-wins — if the stack bundles programs with
+ * overlapping error codes, pass `options.errors` or use the per-instruction
+ * call path for precise attribution).
+ */
+ async transaction(
+ instructions: BuiltInstruction[],
+ options?: { wallet?: WalletAdapter; send?: SendOptions; errors?: ErrorMetadata[] }
+ ): Promise {
+ const wallet = options?.wallet ?? this._wallet;
+ if (!wallet) {
+ throw new Error('Wallet required to sign and send transaction');
+ }
+ try {
+ const result = await wallet.signAndSend(instructions, options?.send ?? {});
+ return { signature: result.signature, slot: result.slot };
+ } catch (err) {
+ const programError = parseInstructionError(err, options?.errors ?? this.aggregateErrors());
+ if (programError) {
+ throw new InstructionError(
+ `${programError.name} (${programError.code}): ${programError.message}`,
+ programError,
+ err
+ );
+ }
+ throw err;
+ }
+ }
+
+ /** Error metadata from every handler in the stack, deduped by code. */
+ private aggregateErrors(): ErrorMetadata[] {
+ if (!this._aggregatedErrors) {
+ const all: ErrorMetadata[] = [];
+ const seen = new Set();
+ const collect = (handler: InstructionHandler) => {
+ for (const error of handler.errors ?? []) {
+ if (!seen.has(error.code)) {
+ seen.add(error.code);
+ all.push(error);
+ }
+ }
+ };
+ for (const entry of Object.values(this.stack.instructions ?? {})) {
+ if (isInstructionHandler(entry)) {
+ collect(entry);
+ } else {
+ for (const handler of Object.values(entry)) {
+ collect(handler as InstructionHandler);
+ }
+ }
+ }
+ this._aggregatedErrors = all;
+ }
+ return this._aggregatedErrors;
+ }
+
get connectionState(): ConnectionState {
return this.connection.getState();
}
diff --git a/typescript/core/src/index.ts b/typescript/core/src/index.ts
index 8219c1a1..6ac0eb6d 100644
--- a/typescript/core/src/index.ts
+++ b/typescript/core/src/index.ts
@@ -1,5 +1,12 @@
export { Arete } from './client';
-export type { AreteOptionsWithStorage, InstructionExecutorOptions, InstructionExecutor } from './client';
+export type {
+ ConnectOptions,
+ AreteOptionsWithStorage,
+ InstructionExecutorOptions,
+ InstructionExecutor,
+ TypedInstruction,
+ InstructionsInterface,
+} from './client';
export { ConnectionManager } from './connection';
export { SubscriptionRegistry } from './subscription';
@@ -29,6 +36,7 @@ export type {
RichUpdate,
ViewDef,
StackDefinition,
+ StackInstructionEntry,
ViewGroup,
Subscription,
Schema,
@@ -53,7 +61,16 @@ export type {
export { DEFAULT_CONFIG, DEFAULT_MAX_ENTRIES_PER_VIEW, AreteError } from './types';
// Wallet types
-export type { WalletAdapter, WalletState, WalletConnectOptions } from './wallet/types';
+export type {
+ WalletAdapter,
+ WalletState,
+ WalletConnectOptions,
+ BuiltInstruction,
+ BuiltAccountMeta,
+ ConfirmationLevel,
+ SendOptions,
+ SendResult,
+} from './wallet/types';
// Instruction execution
export type {
@@ -67,14 +84,13 @@ export type {
AccountResolutionOptions,
ArgSchema,
ArgType,
- ConfirmationLevel,
- ExecuteOptions,
- ExecutionResult,
ProgramError,
ErrorMetadata,
InstructionHandler,
- InstructionDefinition,
- BuiltInstruction,
+ InstructionHandlerConfig,
+ BuildOptions,
+ ExecuteOptions,
+ ExecutionResult,
SeedDef,
PdaDeriveContext,
PdaFactory,
@@ -92,10 +108,12 @@ export {
decodeBase58,
encodeBase58,
serializeInstructionData,
- waitForConfirmation,
parseInstructionError,
formatProgramError,
+ InstructionError,
+ buildInstruction,
executeInstruction,
+ createInstructionHandler,
createInstructionExecutor,
literal,
account,
diff --git a/typescript/core/src/instructions/account-resolver.ts b/typescript/core/src/instructions/account-resolver.ts
index 7da4e9b3..4175c545 100644
--- a/typescript/core/src/instructions/account-resolver.ts
+++ b/typescript/core/src/instructions/account-resolver.ts
@@ -1,5 +1,6 @@
import type { WalletAdapter } from '../wallet/types';
import { findProgramAddressSync, decodeBase58, createSeed } from './pda';
+import { serializeSeedValue } from './seed-serializer';
/**
* Categories of accounts in an instruction.
@@ -45,7 +46,8 @@ export interface PdaConfig {
*/
export type PdaSeed =
| { type: 'literal'; value: string }
- | { type: 'argRef'; argName: string }
+ | { type: 'bytes'; value: number[] }
+ | { type: 'argRef'; argName: string; argType?: string }
| { type: 'accountRef'; accountName: string };
/**
@@ -185,12 +187,36 @@ export function resolveAccounts(
}
}
- // Return accounts in original order (as defined in accountMetas)
+ // Return accounts in original order (as defined in accountMetas).
+ //
+ // Omitted optional accounts that precede a resolved account cannot simply
+ // be dropped — that would shift every later account into the wrong slot.
+ // Anchor's convention is to pass the program ID as a placeholder; trailing
+ // omitted optionals are dropped as usual.
+ const lastResolvedIndex = accountMetas.reduce(
+ (last, meta, index) => (resolvedMap[meta.name] ? index : last),
+ -1
+ );
+
const orderedAccounts: ResolvedAccount[] = [];
- for (const meta of accountMetas) {
+ for (let i = 0; i < accountMetas.length; i++) {
+ const meta = accountMetas[i]!;
const resolved = resolvedMap[meta.name];
if (resolved) {
orderedAccounts.push(resolved);
+ } else if (meta.isOptional && i < lastResolvedIndex) {
+ if (!options.programId) {
+ throw new Error(
+ 'Omitted optional account "' + meta.name + '" precedes other accounts and needs ' +
+ 'the program ID as a placeholder, but no programId was provided in options.'
+ );
+ }
+ orderedAccounts.push({
+ name: meta.name,
+ address: options.programId,
+ isSigner: false,
+ isWritable: false,
+ });
}
}
@@ -276,16 +302,20 @@ function resolvePdaAccount(
case 'literal':
seeds.push(createSeed(seed.value));
break;
-
+
+ case 'bytes':
+ seeds.push(Uint8Array.from(seed.value));
+ break;
+
case 'argRef': {
const argValue = args[seed.argName];
if (argValue === undefined) {
throw new Error(
- 'PDA seed references missing argument: ' + seed.argName +
+ 'PDA seed references missing argument: ' + seed.argName +
' (for account "' + meta.name + '")'
);
}
- seeds.push(createSeed(argValue as string | bigint | number));
+ seeds.push(serializeSeedValue(argValue, seed.argType));
break;
}
diff --git a/typescript/core/src/instructions/confirmation.ts b/typescript/core/src/instructions/confirmation.ts
deleted file mode 100644
index 1a5474a2..00000000
--- a/typescript/core/src/instructions/confirmation.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { WalletAdapter } from '../wallet/types';
-
-/**
- * Confirmation level for transaction processing.
- * - `processed`: Transaction processed but not confirmed
- * - `confirmed`: Transaction confirmed by cluster
- * - `finalized`: Transaction finalized (recommended for production)
- */
-export type ConfirmationLevel = 'processed' | 'confirmed' | 'finalized';
-
-/**
- * Options for executing an instruction.
- */
-export interface ExecuteOptions {
- /** Wallet adapter for signing */
- wallet?: WalletAdapter;
- /** User-provided account addresses */
- accounts?: Record;
- /** Confirmation level to wait for */
- confirmationLevel?: ConfirmationLevel;
- /** Maximum time to wait for confirmation (ms) */
- timeout?: number;
- /** Refresh view after transaction completes */
- refresh?: {
- view: string;
- key?: string;
- }[];
-}
-
-/**
- * Result of a successful instruction execution.
- */
-export interface ExecutionResult {
- /** Transaction signature */
- signature: string;
- /** Confirmation level achieved */
- confirmationLevel: ConfirmationLevel;
- /** Slot when transaction was processed */
- slot: number;
- /** Error code if transaction failed */
- error?: string;
-}
-
-/**
- * Waits for transaction confirmation.
- *
- * @param signature - Transaction signature
- * @param level - Desired confirmation level
- * @param timeout - Maximum wait time in milliseconds
- * @returns Confirmation result
- */
-export async function waitForConfirmation(
- signature: string,
- level: ConfirmationLevel = 'confirmed',
- timeout: number = 60000
-): Promise<{ level: ConfirmationLevel; slot: number }> {
- const startTime = Date.now();
-
- while (Date.now() - startTime < timeout) {
- const status = await checkTransactionStatus(signature);
-
- if (status.err) {
- throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
- }
-
- if (isConfirmationLevelSufficient(status.confirmations, level)) {
- return {
- level,
- slot: status.slot,
- };
- }
-
- await sleep(1000);
- }
-
- throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
-}
-
-async function checkTransactionStatus(_signature: string): Promise<{
- err: unknown;
- confirmations: number | null;
- slot: number;
-}> {
- // In production, query the Solana RPC
- return {
- err: null,
- confirmations: 32,
- slot: 123456789,
- };
-}
-
-function isConfirmationLevelSufficient(
- confirmations: number | null,
- level: ConfirmationLevel
-): boolean {
- if (confirmations === null) {
- return false;
- }
-
- switch (level) {
- case 'processed':
- return confirmations >= 0;
- case 'confirmed':
- return confirmations >= 1;
- case 'finalized':
- return confirmations >= 32;
- default:
- return false;
- }
-}
-
-function sleep(ms: number): Promise {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
diff --git a/typescript/core/src/instructions/error-parser.ts b/typescript/core/src/instructions/error-parser.ts
index 3ec97372..9e895ca5 100644
--- a/typescript/core/src/instructions/error-parser.ts
+++ b/typescript/core/src/instructions/error-parser.ts
@@ -93,3 +93,21 @@ function extractErrorCode(error: unknown): number | null {
export function formatProgramError(error: ProgramError): string {
return `${error.name} (${error.code}): ${error.message}`;
}
+
+/**
+ * Error thrown when an instruction fails to send and the underlying failure
+ * could be parsed against the handler's IDL error definitions.
+ */
+export class InstructionError extends Error {
+ /** Parsed program error, if the failure matched a known error code. */
+ readonly programError: ProgramError | null;
+ /** The original underlying error from the wallet adapter / RPC. */
+ readonly cause: unknown;
+
+ constructor(message: string, programError: ProgramError | null, cause: unknown) {
+ super(message);
+ this.name = 'InstructionError';
+ this.programError = programError;
+ this.cause = cause;
+ }
+}
diff --git a/typescript/core/src/instructions/executor.ts b/typescript/core/src/instructions/executor.ts
index 3cf3ba68..9200c12c 100644
--- a/typescript/core/src/instructions/executor.ts
+++ b/typescript/core/src/instructions/executor.ts
@@ -1,4 +1,10 @@
-import type { WalletAdapter } from '../wallet/types';
+import type {
+ WalletAdapter,
+ BuiltInstruction,
+ BuiltAccountMeta,
+ SendOptions,
+ ConfirmationLevel,
+} from '../wallet/types';
import {
resolveAccounts,
validateAccountResolution,
@@ -6,185 +12,282 @@ import {
type AccountResolutionOptions,
type ResolvedAccount,
} from './account-resolver';
-import { waitForConfirmation, type ExecuteOptions, type ExecutionResult } from './confirmation';
-import type { ErrorMetadata } from './error-parser';
+import { serializeInstructionData, type ArgSchema } from './serializer';
+import {
+ parseInstructionError,
+ InstructionError,
+ type ErrorMetadata,
+} from './error-parser';
/**
- * Resolved accounts map passed to the instruction builder.
+ * Resolved accounts map passed to a handler's build function.
* Keys are account names, values are base58 addresses.
*/
export type ResolvedAccounts = Record;
+// Re-export the boundary instruction type for convenience.
+export type { BuiltInstruction } from '../wallet/types';
+
/**
- * The instruction object returned by the handler's build function.
- * This is a framework-agnostic representation that can be converted
- * to @solana/web3.js TransactionInstruction.
+ * Instruction handler consumed by the core executor.
+ *
+ * Handlers are normally produced by {@link createInstructionHandler} (either
+ * hand-written or code-generated), so `build` is implemented generically and
+ * callers never deal with serialization directly.
+ *
+ * The phantom `_params` / `_error` fields carry compile-time type information
+ * for the typed client surface; they are never populated at runtime.
*/
-export interface BuiltInstruction {
- /** Program ID (base58) */
+export interface InstructionHandler<
+ TParams = Record,
+ TError = unknown,
+> {
+ /** Program ID for this instruction (base58). Used for PDA derivation. */
+ programId?: string;
+ /** Ordered account metadata used by the core SDK for resolution. */
+ accounts: AccountMeta[];
+ /** Error definitions used for error parsing. */
+ errors: ErrorMetadata[];
+ /**
+ * Names of the instruction's serialized arguments. Everything in the merged
+ * params object that is NOT in this list is treated as a user-provided
+ * account address override.
+ */
+ argNames: string[];
+ /**
+ * Build the instruction from already-resolved, ordered accounts.
+ * Implemented by {@link createInstructionHandler}.
+ */
+ build(args: Record, resolved: ResolvedAccount[]): BuiltInstruction;
+ /** Phantom: merged params type (args + user-provided accounts). */
+ readonly _params?: TParams;
+ /** Phantom: typed error union. */
+ readonly _error?: TError;
+}
+
+/**
+ * Configuration accepted by {@link createInstructionHandler}.
+ */
+export interface InstructionHandlerConfig {
+ /** Program ID (base58). */
programId: string;
- /** Account keys in order */
- keys: Array<{
- pubkey: string;
- isSigner: boolean;
- isWritable: boolean;
- }>;
- /** Serialized instruction data */
- data: Uint8Array;
+ /** Instruction discriminator bytes (8 for Anchor, 1 for Steel, etc.). */
+ discriminator: Uint8Array | number[];
+ /** Ordered account metadata. */
+ accounts: AccountMeta[];
+ /** Ordered argument schema for Borsh serialization. */
+ args: ArgSchema[];
+ /** Error definitions from the IDL. */
+ errors?: ErrorMetadata[];
}
/**
- * Instruction handler from the generated stack SDK.
- * The build() function is generated code that handles serialization.
+ * Creates a data-driven instruction handler.
+ *
+ * The returned handler implements `build()` generically: it serializes args
+ * via the schema-driven serializer and constructs the account key list from
+ * the resolved, ordered accounts. No imperative per-instruction code is
+ * required, which keeps generated SDKs tiny and puts all serialization logic
+ * in one tested place.
*/
-export interface InstructionHandler {
- /**
- * Build the instruction with resolved accounts.
- * This is generated code - serialization logic lives here.
+export function createInstructionHandler<
+ TParams = Record,
+ TError = unknown,
+>(config: InstructionHandlerConfig): InstructionHandler {
+ const discriminator =
+ config.discriminator instanceof Uint8Array
+ ? config.discriminator
+ : Uint8Array.from(config.discriminator);
+ const argNames = config.args.map((a) => a.name);
+ const errors = config.errors ?? [];
+
+ return {
+ programId: config.programId,
+ accounts: config.accounts,
+ errors,
+ argNames,
+ build(args: Record, resolved: ResolvedAccount[]): BuiltInstruction {
+ const data = serializeInstructionData(discriminator, args, config.args);
+ return {
+ programId: config.programId,
+ keys: resolved.map((r) => ({
+ pubkey: r.address,
+ isSigner: r.isSigner,
+ isWritable: r.isWritable,
+ })),
+ data,
+ };
+ },
+ };
+}
+
+/**
+ * Options for building an instruction (no network access).
+ */
+export interface BuildOptions {
+ /** Wallet, used only for `publicKey` to resolve signer accounts. */
+ wallet?: WalletAdapter;
+ /** Extra/override user-provided account addresses. */
+ accounts?: Record;
+ /**
+ * Extra account metas appended after the instruction's declared accounts
+ * (Anchor's `remainingAccounts`) — for routers, transfer hooks, and other
+ * composition patterns the IDL cannot express.
*/
- build(args: Record, accounts: ResolvedAccounts): BuiltInstruction;
-
- /** Account metadata - used by core SDK for resolution */
- accounts: AccountMeta[];
-
- /** Error definitions - used by core SDK for error parsing */
- errors: ErrorMetadata[];
-
- /** Program ID for this instruction (used for PDA derivation) */
- programId?: string;
+ remainingAccounts?: BuiltAccountMeta[];
}
/**
- * @deprecated Use InstructionHandler instead. Will be removed in next major version.
- * Legacy instruction definition for backwards compatibility.
+ * Options for executing (building + sending) an instruction.
*/
-export interface InstructionDefinition {
- /** Instruction name */
- name: string;
- /** Program ID (base58) */
- programId: string;
- /** 8-byte discriminator */
- discriminator: Uint8Array;
- /** Account metadata */
- accounts: AccountMeta[];
- /** Argument schema for serialization */
- argsSchema: import('./serializer').ArgSchema[];
- /** Error definitions */
- errors: ErrorMetadata[];
+export interface ExecuteOptions extends BuildOptions {
+ /** Wallet adapter that signs and broadcasts the transaction. */
+ wallet?: WalletAdapter;
+ /** Confirmation level forwarded to the adapter. */
+ confirmationLevel?: ConfirmationLevel;
+ /** Additional options forwarded verbatim to the wallet adapter. */
+ send?: SendOptions;
}
/**
- * Converts resolved account array to a map for the builder.
+ * Result of a successful instruction execution.
*/
-function toResolvedAccountsMap(accounts: ResolvedAccount[]): ResolvedAccounts {
- const map: ResolvedAccounts = {};
- for (const account of accounts) {
- map[account.name] = account.address;
+export interface ExecutionResult {
+ /** Transaction signature. */
+ signature: string;
+ /** Slot in which the transaction landed, if the adapter reports it. */
+ slot?: number;
+}
+
+/**
+ * Splits a merged params object into serialized args and account overrides.
+ *
+ * Keys matching a declared argument name are args; keys matching a declared
+ * account name (with a string value) are account address overrides. Anything
+ * else throws — a typo'd key silently dropped here would otherwise change the
+ * built instruction. `options.accounts` on {@link BuildOptions} remains an
+ * unvalidated escape hatch for advanced callers.
+ */
+function splitParams(
+ handler: InstructionHandler,
+ params: Record
+): { args: Record; accountOverrides: Record } {
+ const argNameSet = new Set(handler.argNames);
+ const accountNameSet = new Set(handler.accounts.map((a) => a.name));
+ const args: Record = {};
+ const accountOverrides: Record = {};
+
+ for (const [key, value] of Object.entries(params)) {
+ if (argNameSet.has(key)) {
+ args[key] = value;
+ } else if (accountNameSet.has(key)) {
+ if (typeof value !== 'string') {
+ // Non-string values are not valid account addresses.
+ throw new Error(
+ `Parameter "${key}" is not a known argument and is not a base58 account address`
+ );
+ }
+ accountOverrides[key] = value;
+ } else {
+ throw new Error(
+ `Unknown parameter "${key}". Expected one of args [${[...argNameSet].join(', ')}] ` +
+ `or accounts [${[...accountNameSet].join(', ')}]`
+ );
+ }
}
- return map;
+
+ return { args, accountOverrides };
}
/**
- * Executes an instruction handler with the given arguments and options.
- *
- * This is the main function for executing Solana instructions. It handles:
- * 1. Account resolution (signer, PDA, user-provided)
- * 2. Calling the generated build() function
- * 3. Transaction signing and sending
- * 4. Confirmation waiting
- *
- * @param handler - Instruction handler from generated SDK
- * @param args - Instruction arguments
- * @param options - Execution options
- * @returns Execution result with signature
+ * Builds a {@link BuiltInstruction} from a handler and a merged params object.
+ *
+ * This is a pure function: it performs no network access. It is the unit of
+ * composition for batching (`wallet.signAndSend([a, b, c])`).
*/
-export async function executeInstruction(
+export function buildInstruction(
handler: InstructionHandler,
- args: Record,
- options: ExecuteOptions = {}
-): Promise {
- // Step 1: Resolve accounts using handler's account metadata
+ params: Record,
+ options: BuildOptions = {}
+): BuiltInstruction {
+ const { args, accountOverrides } = splitParams(handler, params);
+
const resolutionOptions: AccountResolutionOptions = {
- accounts: options.accounts,
+ accounts: { ...accountOverrides, ...options.accounts },
wallet: options.wallet,
- programId: handler.programId, // Pass programId for PDA derivation
+ programId: handler.programId,
};
-
- const resolution = resolveAccounts(
- handler.accounts,
- args,
- resolutionOptions
- );
-
+
+ const resolution = resolveAccounts(handler.accounts, args, resolutionOptions);
validateAccountResolution(resolution);
-
- // Step 2: Call generated build() function
- const resolvedAccountsMap = toResolvedAccountsMap(resolution.accounts);
- const instruction = handler.build(args, resolvedAccountsMap);
-
- // Step 3: Build transaction from the built instruction
- const transaction = buildTransaction(instruction);
-
- // Step 4: Sign and send
- if (!options.wallet) {
- throw new Error('Wallet required to sign transaction');
+
+ const instruction = handler.build(args, resolution.accounts);
+ if (options.remainingAccounts?.length) {
+ instruction.keys.push(...options.remainingAccounts);
}
-
- const signature = await options.wallet.signAndSend(transaction);
-
- // Step 5: Wait for confirmation
- const confirmationLevel = options.confirmationLevel ?? 'confirmed';
- const timeout = options.timeout ?? 60000;
-
- const confirmation = await waitForConfirmation(
- signature,
- confirmationLevel,
- timeout
- );
-
- return {
- signature,
- confirmationLevel: confirmation.level,
- slot: confirmation.slot,
- };
+ return instruction;
}
/**
- * Creates a transaction object from a built instruction.
- *
- * @param instruction - Built instruction from handler
- * @returns Transaction object ready for signing
+ * Builds, signs, and sends an instruction via the wallet adapter.
+ *
+ * The core SDK does not touch RPC: the adapter owns blockhash, compilation,
+ * signing, sending, and confirmation. On failure, program errors are parsed
+ * against the handler's IDL error definitions and surfaced as an
+ * {@link InstructionError}.
*/
-function buildTransaction(instruction: BuiltInstruction): unknown {
- // This returns a framework-agnostic transaction representation.
- // The wallet adapter is responsible for converting this to the
- // appropriate format (@solana/web3.js Transaction, etc.)
- return {
- instructions: [{
- programId: instruction.programId,
- keys: instruction.keys,
- data: Array.from(instruction.data),
- }],
+export async function executeInstruction(
+ handler: InstructionHandler,
+ params: Record,
+ options: ExecuteOptions = {}
+): Promise {
+ const instruction = buildInstruction(handler, params, options);
+
+ if (!options.wallet) {
+ throw new Error('Wallet required to sign and send transaction');
+ }
+
+ const sendOptions: SendOptions = {
+ ...options.send,
};
+ if (options.confirmationLevel !== undefined) {
+ sendOptions.confirmationLevel = options.confirmationLevel;
+ }
+
+ try {
+ const result = await options.wallet.signAndSend([instruction], sendOptions);
+ return { signature: result.signature, slot: result.slot };
+ } catch (err) {
+ const programError = parseInstructionError(err, handler.errors);
+ if (programError) {
+ throw new InstructionError(
+ `${programError.name} (${programError.code}): ${programError.message}`,
+ programError,
+ err
+ );
+ }
+ throw err;
+ }
}
/**
* Creates an instruction executor bound to a specific wallet.
- *
- * @param wallet - Wallet adapter
- * @returns Bound executor function
*/
export function createInstructionExecutor(wallet: WalletAdapter) {
return {
execute: async (
handler: InstructionHandler,
- args: Record,
+ params: Record,
options?: Omit
) => {
- return executeInstruction(handler, args, {
- ...options,
- wallet,
- });
+ return executeInstruction(handler, params, { ...options, wallet });
+ },
+ build: (
+ handler: InstructionHandler,
+ params: Record,
+ options?: Omit
+ ) => {
+ return buildInstruction(handler, params, { ...options, wallet });
},
};
}
diff --git a/typescript/core/src/instructions/index.ts b/typescript/core/src/instructions/index.ts
index 8aeca098..98321b58 100644
--- a/typescript/core/src/instructions/index.ts
+++ b/typescript/core/src/instructions/index.ts
@@ -1,4 +1,13 @@
-export type { WalletAdapter, WalletState, WalletConnectOptions } from '../wallet/types';
+export type {
+ WalletAdapter,
+ WalletState,
+ WalletConnectOptions,
+ BuiltInstruction,
+ BuiltAccountMeta,
+ ConfirmationLevel,
+ SendOptions,
+ SendResult,
+} from '../wallet/types';
export type {
AccountCategory,
AccountMeta,
@@ -18,18 +27,25 @@ export {
decodeBase58,
encodeBase58,
} from './pda';
-export type { ArgSchema, ArgType } from './serializer';
+export type { ArgSchema, ArgType, ArgStructField, EnumVariant } from './serializer';
export { serializeInstructionData } from './serializer';
-export type { ConfirmationLevel, ExecuteOptions, ExecutionResult } from './confirmation';
-export { waitForConfirmation } from './confirmation';
+export type { CanonicalSeedType } from './seed-serializer';
+export { normalizeSeedType, serializeSeedValue } from './seed-serializer';
export type { ProgramError, ErrorMetadata } from './error-parser';
-export { parseInstructionError, formatProgramError } from './error-parser';
+export { parseInstructionError, formatProgramError, InstructionError } from './error-parser';
export type {
InstructionHandler,
- InstructionDefinition,
- BuiltInstruction,
+ InstructionHandlerConfig,
+ BuildOptions,
+ ExecuteOptions,
+ ExecutionResult,
ResolvedAccounts,
} from './executor';
-export { executeInstruction, createInstructionExecutor } from './executor';
+export {
+ buildInstruction,
+ executeInstruction,
+ createInstructionHandler,
+ createInstructionExecutor,
+} from './executor';
export type { SeedDef, PdaDeriveContext, PdaFactory, ProgramPdas } from './pda-dsl';
export { literal, account, arg, bytes, pda, createProgramPdas } from './pda-dsl';
diff --git a/typescript/core/src/instructions/instructions.test.ts b/typescript/core/src/instructions/instructions.test.ts
new file mode 100644
index 00000000..45f5ce0f
--- /dev/null
+++ b/typescript/core/src/instructions/instructions.test.ts
@@ -0,0 +1,586 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ findProgramAddress,
+ findProgramAddressSync,
+ decodeBase58,
+ encodeBase58,
+ createSeed,
+} from './pda';
+import { serializeInstructionData, type ArgSchema } from './serializer';
+import {
+ resolveAccounts,
+ validateAccountResolution,
+ type AccountMeta,
+} from './account-resolver';
+import { parseInstructionError, formatProgramError } from './error-parser';
+import {
+ createInstructionHandler,
+ buildInstruction,
+ executeInstruction,
+} from './executor';
+import type { WalletAdapter, BuiltInstruction, SendResult } from '../wallet/types';
+
+// Well-known, valid 32-byte base58 program/account addresses.
+const SYSTEM_PROGRAM = '11111111111111111111111111111111';
+const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
+const WSOL_MINT = 'So11111111111111111111111111111111111111112';
+
+describe('base58', () => {
+ it('decodes the system program to 32 zero bytes', () => {
+ const bytes = decodeBase58(SYSTEM_PROGRAM);
+ expect(bytes.length).toBe(32);
+ expect([...bytes].every((b) => b === 0)).toBe(true);
+ });
+
+ it('round-trips known program addresses', () => {
+ for (const addr of [TOKEN_PROGRAM, WSOL_MINT, SYSTEM_PROGRAM]) {
+ const decoded = decodeBase58(addr);
+ expect(decoded.length).toBe(32);
+ expect(encodeBase58(decoded)).toBe(addr);
+ }
+ });
+
+ it('rejects invalid base58 characters', () => {
+ expect(() => decodeBase58('0OIl')).toThrow(/Invalid base58/);
+ });
+});
+
+describe('createSeed', () => {
+ it('encodes a u64 number/bigint as 8 little-endian bytes', () => {
+ expect([...createSeed(1n)]).toEqual([1, 0, 0, 0, 0, 0, 0, 0]);
+ expect([...createSeed(256)]).toEqual([0, 1, 0, 0, 0, 0, 0, 0]);
+ });
+
+ it('encodes strings as utf-8 bytes', () => {
+ expect([...createSeed('abc')]).toEqual([0x61, 0x62, 0x63]);
+ });
+});
+
+describe('findProgramAddress', () => {
+ it('is deterministic and off-curve (sync)', () => {
+ const seeds = [createSeed('treasury')];
+ const [addr1, bump1] = findProgramAddressSync(seeds, TOKEN_PROGRAM);
+ const [addr2, bump2] = findProgramAddressSync(seeds, TOKEN_PROGRAM);
+ expect(addr1).toBe(addr2);
+ expect(bump1).toBe(bump2);
+ // Canonical bump is the highest valid one, typically near 255.
+ expect(bump1).toBeGreaterThanOrEqual(0);
+ expect(bump1).toBeLessThanOrEqual(255);
+ expect(decodeBase58(addr1).length).toBe(32);
+ });
+
+ it('matches between sync and async implementations', async () => {
+ const seeds = [createSeed('miner'), decodeBase58(WSOL_MINT)];
+ const [syncAddr, syncBump] = findProgramAddressSync(seeds, TOKEN_PROGRAM);
+ const [asyncAddr, asyncBump] = await findProgramAddress(seeds, TOKEN_PROGRAM);
+ expect(asyncAddr).toBe(syncAddr);
+ expect(asyncBump).toBe(syncBump);
+ });
+
+ it('produces different addresses for different seeds', () => {
+ const [a] = findProgramAddressSync([createSeed('a')], TOKEN_PROGRAM);
+ const [b] = findProgramAddressSync([createSeed('b')], TOKEN_PROGRAM);
+ expect(a).not.toBe(b);
+ });
+
+ it('rejects more than 16 seeds and oversized seeds', () => {
+ const tooMany = Array.from({ length: 17 }, () => createSeed('x'));
+ expect(() => findProgramAddressSync(tooMany, TOKEN_PROGRAM)).toThrow(/16 seeds/);
+ expect(() => findProgramAddressSync([new Uint8Array(33)], TOKEN_PROGRAM)).toThrow(
+ /maximum length/
+ );
+ });
+});
+
+describe('serializeInstructionData', () => {
+ it('prefixes the discriminator and serializes primitives', () => {
+ const schema: ArgSchema[] = [
+ { name: 'amount', type: 'u64' },
+ { name: 'flag', type: 'bool' },
+ { name: 'count', type: 'u8' },
+ ];
+ const data = serializeInstructionData(
+ Uint8Array.from([0xaa, 0xbb]),
+ { amount: 1n, flag: true, count: 7 },
+ schema
+ );
+ expect([...data]).toEqual([
+ 0xaa, 0xbb, // discriminator
+ 1, 0, 0, 0, 0, 0, 0, 0, // u64 = 1
+ 1, // bool = true
+ 7, // u8 = 7
+ ]);
+ });
+
+ it('serializes a pubkey from a base58 string into 32 bytes', () => {
+ const schema: ArgSchema[] = [{ name: 'mint', type: 'pubkey' }];
+ const data = serializeInstructionData(new Uint8Array(0), { mint: SYSTEM_PROGRAM }, schema);
+ expect(data.length).toBe(32);
+ expect([...data].every((b) => b === 0)).toBe(true);
+ });
+
+ it('rejects pubkeys that do not decode to 32 bytes', () => {
+ const schema: ArgSchema[] = [{ name: 'mint', type: 'pubkey' }];
+ expect(() =>
+ serializeInstructionData(new Uint8Array(0), { mint: 'abc' }, schema)
+ ).toThrow(/Invalid pubkey/);
+ });
+
+ it('serializes strings with a length prefix', () => {
+ const schema: ArgSchema[] = [{ name: 's', type: 'string' }];
+ const data = serializeInstructionData(new Uint8Array(0), { s: 'hi' }, schema);
+ expect([...data]).toEqual([2, 0, 0, 0, 0x68, 0x69]);
+ });
+
+ it('serializes option None and Some', () => {
+ const schema: ArgSchema[] = [{ name: 'maybe', type: { option: 'u8' } }];
+ expect([...serializeInstructionData(new Uint8Array(0), { maybe: null }, schema)]).toEqual([0]);
+ expect([...serializeInstructionData(new Uint8Array(0), { maybe: 9 }, schema)]).toEqual([1, 9]);
+ });
+
+ it('serializes negative i64/i128 values in two\'s complement', () => {
+ const schema: ArgSchema[] = [
+ { name: 'small', type: 'i64' },
+ { name: 'big', type: 'i128' },
+ ];
+ const data = serializeInstructionData(
+ new Uint8Array(0),
+ { small: -1n, big: -1n },
+ schema
+ );
+ // -1 is all 0xff in two's complement at any width.
+ expect(data.length).toBe(24);
+ expect([...data].every((b) => b === 0xff)).toBe(true);
+
+ const min = serializeInstructionData(
+ new Uint8Array(0),
+ { small: -(2n ** 63n), big: -(2n ** 127n) },
+ schema
+ );
+ expect([...min.subarray(0, 8)]).toEqual([0, 0, 0, 0, 0, 0, 0, 0x80]);
+ expect([...min.subarray(8, 24)]).toEqual([
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x80,
+ ]);
+ });
+
+ it('serializes f32/f64 little-endian and bytes with a length prefix', () => {
+ const schema: ArgSchema[] = [
+ { name: 'ratio', type: 'f32' },
+ { name: 'price', type: 'f64' },
+ { name: 'blob', type: 'bytes' },
+ ];
+ const data = serializeInstructionData(
+ new Uint8Array(0),
+ { ratio: 1.5, price: 2.5, blob: Uint8Array.from([9, 8]) },
+ schema
+ );
+ const expectedF32 = Buffer.alloc(4);
+ expectedF32.writeFloatLE(1.5);
+ const expectedF64 = Buffer.alloc(8);
+ expectedF64.writeDoubleLE(2.5);
+ expect([...data.subarray(0, 4)]).toEqual([...expectedF32]);
+ expect([...data.subarray(4, 12)]).toEqual([...expectedF64]);
+ expect([...data.subarray(12)]).toEqual([2, 0, 0, 0, 9, 8]); // u32 len + raw
+
+ // bytes accepts plain number arrays too.
+ const fromArray = serializeInstructionData(
+ new Uint8Array(0),
+ { ratio: 0, price: 0, blob: [1] },
+ schema
+ );
+ expect([...fromArray.subarray(12)]).toEqual([1, 0, 0, 0, 1]);
+ });
+
+ it('serializes structs in field order, including nesting', () => {
+ const schema: ArgSchema[] = [
+ {
+ name: 'data',
+ type: {
+ struct: [
+ { name: 'amount', type: 'u64' },
+ { name: 'inner', type: { struct: [{ name: 'flag', type: 'bool' }] } },
+ ],
+ },
+ },
+ ];
+ const data = serializeInstructionData(
+ new Uint8Array(0),
+ { data: { inner: { flag: true }, amount: 3n } }, // intentionally reordered keys
+ schema
+ );
+ expect([...data]).toEqual([3, 0, 0, 0, 0, 0, 0, 0, 1]);
+ });
+
+ it('rejects structs with missing required fields', () => {
+ const schema: ArgSchema[] = [
+ { name: 'data', type: { struct: [{ name: 'amount', type: 'u64' }] } },
+ ];
+ expect(() =>
+ serializeInstructionData(new Uint8Array(0), { data: {} }, schema)
+ ).toThrow(/Missing required struct field "amount"/);
+ });
+
+ it('serializes fieldless enums by name or index', () => {
+ const schema: ArgSchema[] = [{ name: 'status', type: { enum: ['active', 'sunset'] } }];
+ expect([
+ ...serializeInstructionData(new Uint8Array(0), { status: 'sunset' }, schema),
+ ]).toEqual([1]);
+ expect([
+ ...serializeInstructionData(new Uint8Array(0), { status: 0 }, schema),
+ ]).toEqual([0]);
+ expect(() =>
+ serializeInstructionData(new Uint8Array(0), { status: 'paused' }, schema)
+ ).toThrow(/Unknown enum variant "paused"/);
+ });
+
+ it('serializes data-carrying enum variants (struct and tuple)', () => {
+ const schema: ArgSchema[] = [
+ {
+ name: 'op',
+ type: {
+ enum: [
+ 'noop',
+ { name: 'transfer', fields: [{ name: 'amount', type: 'u64' }] },
+ { name: 'pair', tuple: ['u8', 'u16'] },
+ ],
+ },
+ },
+ ];
+ expect([
+ ...serializeInstructionData(
+ new Uint8Array(0),
+ { op: { transfer: { amount: 7n } } },
+ schema
+ ),
+ ]).toEqual([1, 7, 0, 0, 0, 0, 0, 0, 0]);
+ expect([
+ ...serializeInstructionData(new Uint8Array(0), { op: { pair: [5, 0x0102] } }, schema),
+ ]).toEqual([2, 5, 2, 1]);
+ // Data-carrying variants cannot be passed as bare names.
+ expect(() =>
+ serializeInstructionData(new Uint8Array(0), { op: 'transfer' }, schema)
+ ).toThrow(/carries data/);
+ });
+
+ it('serializes vec with a length prefix and fixed arrays without one', () => {
+ const vecSchema: ArgSchema[] = [{ name: 'v', type: { vec: 'u8' } }];
+ expect([...serializeInstructionData(new Uint8Array(0), { v: [1, 2] }, vecSchema)]).toEqual([
+ 2, 0, 0, 0, 1, 2,
+ ]);
+
+ const arrSchema: ArgSchema[] = [{ name: 'a', type: { array: ['u8', 3] } }];
+ expect([...serializeInstructionData(new Uint8Array(0), { a: [4, 5, 6] }, arrSchema)]).toEqual([
+ 4, 5, 6,
+ ]);
+ expect(() =>
+ serializeInstructionData(new Uint8Array(0), { a: [1] }, arrSchema)
+ ).toThrow(/length mismatch/);
+ });
+});
+
+describe('resolveAccounts', () => {
+ const wallet = { publicKey: WSOL_MINT } as WalletAdapter;
+
+ it('resolves signer, known, and userProvided categories', () => {
+ const metas: AccountMeta[] = [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ {
+ name: 'systemProgram',
+ isSigner: false,
+ isWritable: false,
+ category: 'known',
+ knownAddress: SYSTEM_PROGRAM,
+ },
+ { name: 'mint', isSigner: false, isWritable: false, category: 'userProvided' },
+ ];
+ const result = resolveAccounts(metas, {}, {
+ wallet,
+ accounts: { mint: TOKEN_PROGRAM },
+ });
+ validateAccountResolution(result);
+ expect(result.accounts.map((a) => a.name)).toEqual(['authority', 'systemProgram', 'mint']);
+ expect(result.accounts[0]!.address).toBe(WSOL_MINT);
+ expect(result.accounts[1]!.address).toBe(SYSTEM_PROGRAM);
+ expect(result.accounts[2]!.address).toBe(TOKEN_PROGRAM);
+ });
+
+ it('derives a PDA referencing a signer account and keeps original order', () => {
+ const metas: AccountMeta[] = [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ {
+ name: 'state',
+ isSigner: false,
+ isWritable: true,
+ category: 'pda',
+ pdaConfig: {
+ programId: TOKEN_PROGRAM,
+ seeds: [
+ { type: 'literal', value: 'state' },
+ { type: 'accountRef', accountName: 'authority' },
+ ],
+ },
+ },
+ ];
+ const result = resolveAccounts(metas, {}, { wallet });
+ validateAccountResolution(result);
+ // Original (instruction) order is preserved even though PDAs resolve later.
+ expect(result.accounts.map((a) => a.name)).toEqual(['authority', 'state']);
+
+ const expected = findProgramAddressSync(
+ [createSeed('state'), decodeBase58(WSOL_MINT)],
+ TOKEN_PROGRAM
+ )[0];
+ expect(result.accounts[1]!.address).toBe(expected);
+ });
+
+ it('derives a PDA from raw byte seeds', () => {
+ const raw = [1, 2, 255];
+ const metas: AccountMeta[] = [
+ {
+ name: 'config',
+ isSigner: false,
+ isWritable: false,
+ category: 'pda',
+ pdaConfig: {
+ programId: TOKEN_PROGRAM,
+ seeds: [{ type: 'bytes', value: raw }],
+ },
+ },
+ ];
+ const result = resolveAccounts(metas, {}, {});
+ validateAccountResolution(result);
+
+ const expected = findProgramAddressSync([Uint8Array.from(raw)], TOKEN_PROGRAM)[0];
+ expect(result.accounts[0]!.address).toBe(expected);
+ });
+
+ it('reports missing required user-provided accounts', () => {
+ const metas: AccountMeta[] = [
+ { name: 'mint', isSigner: false, isWritable: false, category: 'userProvided' },
+ ];
+ const result = resolveAccounts(metas, {}, {});
+ expect(result.missingUserAccounts).toEqual(['mint']);
+ expect(() => validateAccountResolution(result)).toThrow(/Missing required accounts/);
+ });
+
+ it('substitutes the program id for omitted non-trailing optional accounts', () => {
+ const metas: AccountMeta[] = [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ {
+ name: 'referrer',
+ isSigner: false,
+ isWritable: false,
+ category: 'userProvided',
+ isOptional: true,
+ },
+ { name: 'mint', isSigner: false, isWritable: false, category: 'userProvided' },
+ ];
+ const result = resolveAccounts(metas, {}, {
+ wallet,
+ accounts: { mint: TOKEN_PROGRAM },
+ programId: SYSTEM_PROGRAM,
+ });
+ validateAccountResolution(result);
+ expect(result.accounts.map((a) => a.name)).toEqual(['authority', 'referrer', 'mint']);
+ // Anchor convention: omitted optional in a non-trailing slot = program id.
+ expect(result.accounts[1]!.address).toBe(SYSTEM_PROGRAM);
+ expect(result.accounts[1]!.isSigner).toBe(false);
+ expect(result.accounts[1]!.isWritable).toBe(false);
+ });
+
+ it('drops omitted trailing optional accounts', () => {
+ const metas: AccountMeta[] = [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ {
+ name: 'referrer',
+ isSigner: false,
+ isWritable: false,
+ category: 'userProvided',
+ isOptional: true,
+ },
+ ];
+ const result = resolveAccounts(metas, {}, { wallet, programId: SYSTEM_PROGRAM });
+ validateAccountResolution(result);
+ expect(result.accounts.map((a) => a.name)).toEqual(['authority']);
+ });
+
+ it('resolves provided optional accounts normally', () => {
+ const metas: AccountMeta[] = [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ {
+ name: 'referrer',
+ isSigner: false,
+ isWritable: false,
+ category: 'userProvided',
+ isOptional: true,
+ },
+ { name: 'mint', isSigner: false, isWritable: false, category: 'userProvided' },
+ ];
+ const result = resolveAccounts(metas, {}, {
+ wallet,
+ accounts: { referrer: WSOL_MINT, mint: TOKEN_PROGRAM },
+ programId: SYSTEM_PROGRAM,
+ });
+ expect(result.accounts.map((a) => a.address)).toEqual([
+ WSOL_MINT,
+ WSOL_MINT,
+ TOKEN_PROGRAM,
+ ]);
+ });
+});
+
+describe('parseInstructionError', () => {
+ const errors = [{ code: 6000, name: 'SlippageExceeded', msg: 'Slippage tolerance exceeded' }];
+
+ it('maps an InstructionError Custom code to IDL metadata', () => {
+ const parsed = parseInstructionError(
+ { InstructionError: [0, { Custom: 6000 }] },
+ errors
+ );
+ expect(parsed).toEqual({
+ code: 6000,
+ name: 'SlippageExceeded',
+ message: 'Slippage tolerance exceeded',
+ });
+ expect(formatProgramError(parsed!)).toBe(
+ 'SlippageExceeded (6000): Slippage tolerance exceeded'
+ );
+ });
+
+ it('falls back to a synthetic error for unknown codes', () => {
+ const parsed = parseInstructionError({ code: 12345 }, errors);
+ expect(parsed).toEqual({
+ code: 12345,
+ name: 'CustomError12345',
+ message: 'Unknown error with code 12345',
+ });
+ });
+
+ it('returns null for non-program errors', () => {
+ expect(parseInstructionError(null, errors)).toBeNull();
+ expect(parseInstructionError(new Error('network down'), errors)).toBeNull();
+ });
+});
+
+describe('createInstructionHandler + buildInstruction', () => {
+ function makeHandler() {
+ return createInstructionHandler({
+ programId: TOKEN_PROGRAM,
+ discriminator: [1],
+ accounts: [
+ { name: 'authority', isSigner: true, isWritable: true, category: 'signer' },
+ { name: 'mint', isSigner: false, isWritable: false, category: 'userProvided' },
+ {
+ name: 'state',
+ isSigner: false,
+ isWritable: true,
+ category: 'pda',
+ pdaConfig: {
+ seeds: [
+ { type: 'literal', value: 'state' },
+ { type: 'accountRef', accountName: 'authority' },
+ ],
+ },
+ },
+ ],
+ args: [{ name: 'amount', type: 'u64' }],
+ errors: [{ code: 6000, name: 'Boom', msg: 'boom' }],
+ });
+ }
+
+ const wallet = { publicKey: WSOL_MINT } as WalletAdapter;
+
+ it('splits merged params into args and account overrides', () => {
+ const handler = makeHandler();
+ const built = buildInstruction(
+ handler,
+ { amount: 100n, mint: SYSTEM_PROGRAM },
+ { wallet }
+ );
+
+ expect(built.programId).toBe(TOKEN_PROGRAM);
+ expect(built.keys.map((k) => k.pubkey)).toEqual([
+ WSOL_MINT, // authority (signer)
+ SYSTEM_PROGRAM, // mint (user-provided)
+ findProgramAddressSync(
+ [createSeed('state'), decodeBase58(WSOL_MINT)],
+ TOKEN_PROGRAM
+ )[0], // state (PDA derived from authority)
+ ]);
+ // discriminator [1] + u64 100 little-endian.
+ expect([...built.data]).toEqual([1, 100, 0, 0, 0, 0, 0, 0, 0]);
+ expect(built.keys[0]!.isSigner).toBe(true);
+ });
+
+ it('throws when a non-arg param is not a string address', () => {
+ const handler = makeHandler();
+ expect(() =>
+ buildInstruction(handler, { amount: 1n, mint: 42 as unknown as string }, { wallet })
+ ).toThrow(/not a known argument/);
+ });
+
+ it('rejects unknown parameter names instead of silently dropping them', () => {
+ const handler = makeHandler();
+ expect(() =>
+ buildInstruction(
+ handler,
+ { amount: 1n, mint: SYSTEM_PROGRAM, mnit: TOKEN_PROGRAM },
+ { wallet }
+ )
+ ).toThrow(/Unknown parameter "mnit"/);
+ });
+
+ it('rejects missing required args instead of encoding zeros', () => {
+ const handler = makeHandler();
+ expect(() =>
+ buildInstruction(handler, { mint: SYSTEM_PROGRAM }, { wallet })
+ ).toThrow(/Missing required argument "amount"/);
+ });
+
+ it('still treats an omitted option arg as None', () => {
+ const schema: ArgSchema[] = [{ name: 'maybe', type: { option: 'u8' } }];
+ expect([...serializeInstructionData(new Uint8Array(0), {}, schema)]).toEqual([0]);
+ });
+
+ it('appends remainingAccounts after the declared accounts', () => {
+ const handler = makeHandler();
+ const extra = { pubkey: TOKEN_PROGRAM, isSigner: false, isWritable: true };
+ const built = buildInstruction(
+ handler,
+ { amount: 1n, mint: SYSTEM_PROGRAM },
+ { wallet, remainingAccounts: [extra] }
+ );
+ expect(built.keys[built.keys.length - 1]).toEqual(extra);
+ expect(built.keys).toHaveLength(4); // 3 declared + 1 remaining
+ });
+
+ it('executes via the wallet and parses program errors', async () => {
+ const handler = makeHandler();
+ let sent: BuiltInstruction[] | null = null;
+ const okWallet: WalletAdapter = {
+ publicKey: WSOL_MINT,
+ async signAndSend(ixs): Promise {
+ sent = ixs;
+ return { signature: 'sig123', slot: 99 };
+ },
+ };
+ const result = await executeInstruction(
+ handler,
+ { amount: 1n, mint: SYSTEM_PROGRAM },
+ { wallet: okWallet }
+ );
+ expect(result).toEqual({ signature: 'sig123', slot: 99 });
+ expect(sent!).toHaveLength(1);
+
+ const failWallet: WalletAdapter = {
+ publicKey: WSOL_MINT,
+ async signAndSend(): Promise {
+ throw { InstructionError: [0, { Custom: 6000 }] };
+ },
+ };
+ await expect(
+ executeInstruction(handler, { amount: 1n, mint: SYSTEM_PROGRAM }, { wallet: failWallet })
+ ).rejects.toMatchObject({ name: 'InstructionError', programError: { code: 6000, name: 'Boom' } });
+ });
+});
diff --git a/typescript/core/src/instructions/pda-dsl.ts b/typescript/core/src/instructions/pda-dsl.ts
index 2e461eb5..9cc9405a 100644
--- a/typescript/core/src/instructions/pda-dsl.ts
+++ b/typescript/core/src/instructions/pda-dsl.ts
@@ -1,4 +1,5 @@
import { findProgramAddress, findProgramAddressSync, decodeBase58 } from './pda';
+import { serializeSeedValue } from './seed-serializer';
export type SeedDef =
| { type: 'literal'; value: string }
@@ -48,7 +49,7 @@ function resolveSeeds(seeds: readonly SeedDef[], context: PdaDeriveContext): Uin
if (value === undefined) {
throw new Error(`Missing arg for PDA seed: ${seed.argName}`);
}
- return serializeArgForSeed(value, seed.argType);
+ return serializeSeedValue(value, seed.argType);
}
case 'accountRef': {
const address = context.accounts?.[seed.accountName];
@@ -61,50 +62,6 @@ function resolveSeeds(seeds: readonly SeedDef[], context: PdaDeriveContext): Uin
});
}
-function serializeArgForSeed(value: unknown, argType?: string): Uint8Array {
- if (value instanceof Uint8Array) {
- return value;
- }
-
- if (typeof value === 'string') {
- if (value.length === 43 || value.length === 44) {
- try {
- return decodeBase58(value);
- } catch {
- return new TextEncoder().encode(value);
- }
- }
- return new TextEncoder().encode(value);
- }
-
- if (typeof value === 'bigint' || typeof value === 'number') {
- const size = getArgSize(argType);
- return serializeNumber(value, size);
- }
-
- throw new Error(`Cannot serialize value for PDA seed: ${typeof value}`);
-}
-
-function getArgSize(argType?: string): number {
- if (!argType) return 8;
- const match = argType.match(/^[ui](\d+)$/);
- if (match && match[1]) {
- return parseInt(match[1], 10) / 8;
- }
- if (argType === 'pubkey') return 32;
- return 8;
-}
-
-function serializeNumber(value: bigint | number, size: number): Uint8Array {
- const buffer = new Uint8Array(size);
- let n = typeof value === 'bigint' ? value : BigInt(value);
- for (let i = 0; i < size; i++) {
- buffer[i] = Number(n & BigInt(0xff));
- n >>= BigInt(8);
- }
- return buffer;
-}
-
export function pda(programId: string, ...seeds: SeedDef[]): PdaFactory {
return {
seeds,
diff --git a/typescript/core/src/instructions/pda.ts b/typescript/core/src/instructions/pda.ts
index ff4f81d9..5fe7c872 100644
--- a/typescript/core/src/instructions/pda.ts
+++ b/typescript/core/src/instructions/pda.ts
@@ -4,6 +4,8 @@
* Implements Solana's PDA derivation algorithm without depending on @solana/web3.js.
*/
+import { Point } from '@noble/ed25519';
+
// Base58 alphabet (Bitcoin/Solana style)
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
@@ -15,7 +17,10 @@ export function decodeBase58(str: string): Uint8Array {
return new Uint8Array(0);
}
- const bytes: number[] = [0];
+ // Big-endian byte accumulator (stored little-endian here, reversed at the
+ // end). Must start empty: a leading `[0]` produces a spurious extra byte for
+ // all-zero values such as the System Program ("111...1").
+ const bytes: number[] = [];
for (const char of str) {
const value = BASE58_ALPHABET.indexOf(char);
@@ -52,7 +57,9 @@ export function encodeBase58(bytes: Uint8Array): string {
return '';
}
- const digits: number[] = [0];
+ // Must start empty for the same reason as `decodeBase58`: a leading `[0]`
+ // yields an extra '1' character when encoding all-zero inputs.
+ const digits: number[] = [];
for (const byte of bytes) {
let carry = byte;
@@ -76,42 +83,129 @@ export function encodeBase58(bytes: Uint8Array): string {
return digits.reverse().map(d => BASE58_ALPHABET[d]).join('');
}
+// SHA-256 round constants (first 32 bits of the fractional parts of the cube
+// roots of the first 64 primes).
+const SHA256_K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
/**
- * SHA-256 hash function (synchronous, Node.js).
+ * Dependency-free, synchronous SHA-256.
+ *
+ * Used so PDA derivation works identically in browsers, Node (both CJS and
+ * ESM), and bundlers without relying on `require('crypto')` or async WebCrypto.
+ */
+function sha256Pure(data: Uint8Array): Uint8Array {
+ const h = new Uint32Array([
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
+ ]);
+
+ const bitLen = data.length * 8;
+ // Pad: append 0x80, then zeros, until length ≡ 56 (mod 64), then 8-byte length.
+ const paddedLen = ((data.length + 8) >> 6) * 64 + 64;
+ const msg = new Uint8Array(paddedLen);
+ msg.set(data);
+ msg[data.length] = 0x80;
+ // 64-bit big-endian bit length (high 32 bits assumed 0 for our seed sizes).
+ const dv = new DataView(msg.buffer);
+ dv.setUint32(paddedLen - 4, bitLen >>> 0, false);
+ dv.setUint32(paddedLen - 8, Math.floor(bitLen / 0x100000000), false);
+
+ const w = new Uint32Array(64);
+ for (let offset = 0; offset < paddedLen; offset += 64) {
+ for (let i = 0; i < 16; i++) {
+ w[i] = dv.getUint32(offset + i * 4, false);
+ }
+ for (let i = 16; i < 64; i++) {
+ const w15 = w[i - 15]!;
+ const w2 = w[i - 2]!;
+ const s0 = ((w15 >>> 7) | (w15 << 25)) ^ ((w15 >>> 18) | (w15 << 14)) ^ (w15 >>> 3);
+ const s1 = ((w2 >>> 17) | (w2 << 15)) ^ ((w2 >>> 19) | (w2 << 13)) ^ (w2 >>> 10);
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
+ }
+
+ let a = h[0]!, b = h[1]!, c = h[2]!, d = h[3]!;
+ let e = h[4]!, f = h[5]!, g = h[6]!, hh = h[7]!;
+
+ for (let i = 0; i < 64; i++) {
+ const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
+ const ch = (e & f) ^ (~e & g);
+ const t1 = (hh + S1 + ch + SHA256_K[i]! + w[i]!) >>> 0;
+ const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
+ const maj = (a & b) ^ (a & c) ^ (b & c);
+ const t2 = (S0 + maj) >>> 0;
+
+ hh = g; g = f; f = e; e = (d + t1) >>> 0;
+ d = c; c = b; b = a; a = (t1 + t2) >>> 0;
+ }
+
+ h[0] = (h[0]! + a) >>> 0;
+ h[1] = (h[1]! + b) >>> 0;
+ h[2] = (h[2]! + c) >>> 0;
+ h[3] = (h[3]! + d) >>> 0;
+ h[4] = (h[4]! + e) >>> 0;
+ h[5] = (h[5]! + f) >>> 0;
+ h[6] = (h[6]! + g) >>> 0;
+ h[7] = (h[7]! + hh) >>> 0;
+ }
+
+ const out = new Uint8Array(32);
+ const outView = new DataView(out.buffer);
+ for (let i = 0; i < 8; i++) {
+ outView.setUint32(i * 4, h[i]!, false);
+ }
+ return out;
+}
+
+/**
+ * SHA-256 hash function (synchronous).
*/
function sha256Sync(data: Uint8Array): Uint8Array {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- const { createHash } = require('crypto');
- return new Uint8Array(createHash('sha256').update(Buffer.from(data)).digest());
+ return sha256Pure(data);
}
/**
- * SHA-256 hash function (async, works in browser and Node.js).
+ * SHA-256 hash function (async). Uses WebCrypto when available for speed,
+ * otherwise falls back to the pure implementation.
*/
async function sha256Async(data: Uint8Array): Promise {
if (typeof globalThis !== 'undefined' && globalThis.crypto && globalThis.crypto.subtle) {
- // Create a copy of the data to ensure we have an ArrayBuffer
const copy = new Uint8Array(data);
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', copy);
return new Uint8Array(hashBuffer);
}
- return sha256Sync(data);
+ return sha256Pure(data);
}
/**
- * Check if a point is on the ed25519 curve.
- * A valid PDA must be OFF the curve.
- *
- * This is a simplified implementation.
- * In practice, most PDAs are valid on first try with bump=255.
+ * Check if a 32-byte value is a valid point on the ed25519 curve.
+ *
+ * A valid PDA must be OFF the curve (it must NOT correspond to a real
+ * ed25519 public key, so that no private key can ever sign for it).
+ *
+ * We determine on-curve status by attempting to decompress the candidate
+ * as a compressed Edwards point. If decompression succeeds the value lies
+ * on the curve; if it throws, the value is off-curve and is a valid PDA.
+ * This matches the behaviour of `PublicKey.isOnCurve` in @solana/web3.js.
*/
-function isOnCurve(_publicKey: Uint8Array): boolean {
- // Simplified heuristic: actual curve check requires ed25519 math
- // For Solana PDAs, we try bumps from 255 down
- // The first bump (255) almost always produces a valid off-curve point
- // We return false here to accept the first result
- // In production with @solana/web3.js, use PublicKey.isOnCurve()
- return false;
+function isOnCurve(publicKey: Uint8Array): boolean {
+ if (publicKey.length !== 32) {
+ return false;
+ }
+ try {
+ Point.fromHex(publicKey);
+ return true;
+ } catch {
+ return false;
+ }
}
/**
diff --git a/typescript/core/src/instructions/seed-serializer.test.ts b/typescript/core/src/instructions/seed-serializer.test.ts
new file mode 100644
index 00000000..7e21dd81
--- /dev/null
+++ b/typescript/core/src/instructions/seed-serializer.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest';
+
+import { normalizeSeedType, serializeSeedValue } from './seed-serializer';
+import { decodeBase58 } from './pda';
+
+const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
+
+describe('normalizeSeedType', () => {
+ it('canonicalizes pubkey and string spellings', () => {
+ for (const t of ['pubkey', 'Pubkey', 'publicKey', 'PublicKey', 'solana_pubkey::Pubkey']) {
+ expect(normalizeSeedType(t)).toBe('pubkey');
+ }
+ for (const t of ['string', 'String', 'str']) {
+ expect(normalizeSeedType(t)).toBe('string');
+ }
+ });
+
+ it('passes integer widths through and rejects everything else', () => {
+ expect(normalizeSeedType('u32')).toBe('u32');
+ expect(normalizeSeedType('i64')).toBe('i64');
+ expect(normalizeSeedType('u24')).toBeUndefined();
+ expect(normalizeSeedType('Vec')).toBeUndefined();
+ expect(normalizeSeedType(undefined)).toBeUndefined();
+ });
+});
+
+describe('serializeSeedValue (typed)', () => {
+ it('encodes integers little-endian at the declared width', () => {
+ expect([...serializeSeedValue(1, 'u8')]).toEqual([1]);
+ expect([...serializeSeedValue(0x0102, 'u16')]).toEqual([2, 1]);
+ expect([...serializeSeedValue(7, 'u32')]).toEqual([7, 0, 0, 0]);
+ expect([...serializeSeedValue(42n, 'u64')]).toEqual([42, 0, 0, 0, 0, 0, 0, 0]);
+ });
+
+ it('encodes negative signed integers in two\'s complement', () => {
+ expect([...serializeSeedValue(-1n, 'i64')]).toEqual([
+ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+ ]);
+ });
+
+ it('rejects values that overflow the declared width', () => {
+ expect(() => serializeSeedValue(256, 'u8')).toThrow(/does not fit/);
+ expect(() => serializeSeedValue(-1, 'u32')).toThrow(/does not fit/);
+ });
+
+ it('decodes pubkey seeds from base58 to 32 bytes', () => {
+ const bytes = serializeSeedValue(TOKEN_PROGRAM, 'pubkey');
+ expect(bytes.length).toBe(32);
+ expect([...bytes]).toEqual([...decodeBase58(TOKEN_PROGRAM)]);
+ // Path-qualified Rust spelling works too.
+ expect([...serializeSeedValue(TOKEN_PROGRAM, 'solana_pubkey::Pubkey')]).toEqual([...bytes]);
+ });
+
+ it('rejects non-pubkey strings for pubkey seeds', () => {
+ expect(() => serializeSeedValue('abc', 'pubkey')).toThrow(/expected 32/);
+ expect(() => serializeSeedValue(42, 'pubkey')).toThrow(/base58 string/);
+ });
+
+ it('utf8-encodes typed string seeds without base58 guessing', () => {
+ // 44 chars: heuristic path would base58-decode this; typed must not.
+ const fortyFour = 'a'.repeat(44);
+ expect([...serializeSeedValue(fortyFour, 'string')]).toEqual(
+ [...new TextEncoder().encode(fortyFour)]
+ );
+ });
+});
+
+describe('serializeSeedValue (untyped heuristics)', () => {
+ it('passes Uint8Array through', () => {
+ const raw = Uint8Array.from([1, 2, 3]);
+ expect(serializeSeedValue(raw)).toBe(raw);
+ });
+
+ it('tries base58 for 43/44-char strings and utf8 otherwise', () => {
+ expect(serializeSeedValue(TOKEN_PROGRAM).length).toBe(32);
+ expect([...serializeSeedValue('treasury')]).toEqual([
+ ...new TextEncoder().encode('treasury'),
+ ]);
+ });
+
+ it('encodes numbers as 8-byte little-endian', () => {
+ expect([...serializeSeedValue(256)]).toEqual([0, 1, 0, 0, 0, 0, 0, 0]);
+ });
+});
diff --git a/typescript/core/src/instructions/seed-serializer.ts b/typescript/core/src/instructions/seed-serializer.ts
new file mode 100644
index 00000000..ebb9fd2f
--- /dev/null
+++ b/typescript/core/src/instructions/seed-serializer.ts
@@ -0,0 +1,128 @@
+/**
+ * Typed serialization of PDA seed values.
+ *
+ * Shared by the standalone PDA DSL (`pda-dsl.ts`) and instruction account
+ * resolution (`account-resolver.ts`). When a seed carries a declared type
+ * (from the IDL or the `pdas!` registry), encoding is exact: pubkeys are
+ * base58-decoded to 32 bytes, integers are little-endian at the declared
+ * width. Without a type, legacy heuristics apply for backward compatibility.
+ */
+
+import { decodeBase58 } from './pda';
+
+/**
+ * Canonical seed type names: 'pubkey', 'string', or 'u8'..'u128'/'i8'..'i128'.
+ */
+export type CanonicalSeedType = 'pubkey' | 'string' | `u${number}` | `i${number}`;
+
+/**
+ * Normalizes the type-name variants that IDLs and codegen produce
+ * ("Pubkey", "publicKey", "solana_pubkey::Pubkey", "String", ...) to a
+ * canonical seed type. Returns undefined for types that cannot be a seed.
+ */
+export function normalizeSeedType(argType?: string): CanonicalSeedType | undefined {
+ if (!argType) return undefined;
+ // Strip any path qualifier (e.g. solana_pubkey::Pubkey).
+ const parts = argType.split('::');
+ const t = (parts[parts.length - 1] ?? argType).trim();
+
+ if (/^[ui](8|16|32|64|128)$/.test(t)) {
+ return t as CanonicalSeedType;
+ }
+ switch (t) {
+ case 'pubkey':
+ case 'Pubkey':
+ case 'publicKey':
+ case 'PublicKey':
+ return 'pubkey';
+ case 'string':
+ case 'String':
+ case 'str':
+ return 'string';
+ default:
+ return undefined;
+ }
+}
+
+/**
+ * Serializes a PDA seed value.
+ *
+ * With a recognized `argType`, encoding is strict and width-exact; an
+ * incompatible value throws rather than deriving a wrong address. Without
+ * one, the legacy heuristics apply: raw bytes pass through, 43/44-character
+ * strings are tried as base58, other strings are utf-8, numbers are 8-byte
+ * little-endian u64.
+ */
+export function serializeSeedValue(value: unknown, argType?: string): Uint8Array {
+ if (value instanceof Uint8Array) {
+ return value;
+ }
+
+ const t = normalizeSeedType(argType);
+
+ if (t === 'pubkey') {
+ if (typeof value !== 'string') {
+ throw new Error(`Pubkey seed requires a base58 string, got ${typeof value}`);
+ }
+ const decoded = decodeBase58(value);
+ if (decoded.length !== 32) {
+ throw new Error(
+ `Pubkey seed '${value}' decoded to ${decoded.length} bytes, expected 32`
+ );
+ }
+ return decoded;
+ }
+
+ if (t === 'string') {
+ if (typeof value !== 'string') {
+ throw new Error(`String seed requires a string value, got ${typeof value}`);
+ }
+ return new TextEncoder().encode(value);
+ }
+
+ if (t !== undefined) {
+ // Numeric type at a declared width.
+ if (typeof value !== 'bigint' && typeof value !== 'number') {
+ throw new Error(`Numeric seed of type ${t} requires a number/bigint, got ${typeof value}`);
+ }
+ const bits = parseInt(t.slice(1), 10);
+ return serializeNumber(value, bits / 8, t.startsWith('i'));
+ }
+
+ // --- Untyped: legacy heuristics. ---
+ if (typeof value === 'string') {
+ if (value.length === 43 || value.length === 44) {
+ try {
+ return decodeBase58(value);
+ } catch {
+ return new TextEncoder().encode(value);
+ }
+ }
+ return new TextEncoder().encode(value);
+ }
+
+ if (typeof value === 'bigint' || typeof value === 'number') {
+ return serializeNumber(value, 8, true);
+ }
+
+ throw new Error(`Cannot serialize value for PDA seed: ${typeof value}`);
+}
+
+/**
+ * Little-endian two's-complement encoding at a fixed byte width, with an
+ * overflow check so out-of-range values fail instead of silently truncating.
+ */
+function serializeNumber(value: bigint | number, size: number, signed: boolean): Uint8Array {
+ const buffer = new Uint8Array(size);
+ let n = typeof value === 'bigint' ? value : BigInt(value);
+ const original = n;
+ for (let i = 0; i < size; i++) {
+ buffer[i] = Number(n & BigInt(0xff));
+ n >>= BigInt(8);
+ }
+ const fits = signed ? n === BigInt(0) || n === BigInt(-1) : n === BigInt(0);
+ if (!fits) {
+ throw new Error(`Seed value ${original} does not fit in ${size * 8} bits`);
+ }
+ return buffer;
+}
diff --git a/typescript/core/src/instructions/serializer.ts b/typescript/core/src/instructions/serializer.ts
index 69539d44..f8373dc3 100644
--- a/typescript/core/src/instructions/serializer.ts
+++ b/typescript/core/src/instructions/serializer.ts
@@ -5,6 +5,8 @@
* expected by Solana programs using Borsh serialization.
*/
+import { decodeBase58 } from './pda';
+
/**
* Instruction argument schema for serialization.
*/
@@ -17,16 +19,42 @@ export interface ArgSchema {
/**
* Supported argument types for Borsh serialization.
+ *
+ * Struct and enum schemas are fully inlined (field names and types travel
+ * with the schema), so the serializer needs no runtime type registry.
*/
export type ArgType =
| 'u8' | 'u16' | 'u32' | 'u64' | 'u128'
| 'i8' | 'i16' | 'i32' | 'i64' | 'i128'
+ | 'f32' | 'f64'
| 'bool'
| 'string'
| 'pubkey'
+ | 'bytes'
| { vec: ArgType }
| { option: ArgType }
- | { array: [ArgType, number] };
+ | { array: [ArgType, number] }
+ | { struct: ArgStructField[] }
+ | { enum: EnumVariant[] };
+
+/** One field of a struct schema, in declaration (serialization) order. */
+export interface ArgStructField {
+ name: string;
+ type: ArgType;
+}
+
+/**
+ * One enum variant: a bare string for fieldless variants, or a named variant
+ * carrying struct fields / tuple elements.
+ *
+ * Values: fieldless variants are passed as the variant name (or its index);
+ * data-carrying variants as a single-key object, e.g. `{ transfer: { amount } }`
+ * or `{ pair: [1, 2] }`.
+ */
+export type EnumVariant =
+ | string
+ | { name: string; fields: ArgStructField[] }
+ | { name: string; tuple: ArgType[] };
/**
* Serializes instruction arguments into a Buffer using Borsh encoding.
@@ -42,13 +70,20 @@ export function serializeInstructionData(
schema: ArgSchema[]
): Buffer {
const buffers: Buffer[] = [Buffer.from(discriminator)];
-
+
for (const field of schema) {
const value = args[field.name];
+ // Option fields treat undefined as None; everything else must be present
+ // (silently encoding zeros for a missing arg corrupts instruction data).
+ if (value === undefined && !(typeof field.type === 'object' && 'option' in field.type)) {
+ throw new Error(
+ `Missing required argument "${field.name}" (type ${JSON.stringify(field.type)})`
+ );
+ }
const serialized = serializeValue(value, field.type);
buffers.push(serialized);
}
-
+
return Buffer.concat(buffers);
}
@@ -68,10 +103,110 @@ function serializeValue(value: unknown, type: ArgType): Buffer {
if ('array' in type) {
return serializeArray(value as unknown[], type.array[0], type.array[1]);
}
-
+
+ if ('struct' in type) {
+ return serializeStruct(value, type.struct);
+ }
+
+ if ('enum' in type) {
+ return serializeEnum(value, type.enum);
+ }
+
throw new Error(`Unknown type: ${JSON.stringify(type)}`);
}
+function serializeStruct(value: unknown, fields: ArgStructField[]): Buffer {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error(`Struct value must be a plain object, got ${typeof value}`);
+ }
+ const obj = value as Record;
+ const buffers: Buffer[] = [];
+ for (const field of fields) {
+ const fieldValue = obj[field.name];
+ if (
+ fieldValue === undefined &&
+ !(typeof field.type === 'object' && 'option' in field.type)
+ ) {
+ throw new Error(`Missing required struct field "${field.name}"`);
+ }
+ buffers.push(serializeValue(fieldValue, field.type));
+ }
+ return Buffer.concat(buffers);
+}
+
+function variantName(variant: EnumVariant): string {
+ return typeof variant === 'string' ? variant : variant.name;
+}
+
+function serializeEnum(value: unknown, variants: EnumVariant[]): Buffer {
+ // Numeric value: a bare variant index (fieldless variants only).
+ if (typeof value === 'number') {
+ if (!Number.isInteger(value) || value < 0 || value >= variants.length) {
+ throw new Error(`Enum variant index ${value} out of range (0..${variants.length - 1})`);
+ }
+ const variant = variants[value]!;
+ if (typeof variant !== 'string') {
+ throw new Error(
+ `Enum variant "${variantName(variant)}" carries data; pass { ${variantName(variant)}: ... } instead of an index`
+ );
+ }
+ return Buffer.from([value]);
+ }
+
+ // String value: a fieldless variant by name.
+ if (typeof value === 'string') {
+ const index = variants.findIndex((v) => variantName(v) === value);
+ if (index === -1) {
+ throw new Error(
+ `Unknown enum variant "${value}". Expected one of: ${variants.map(variantName).join(', ')}`
+ );
+ }
+ const variant = variants[index]!;
+ if (typeof variant !== 'string') {
+ throw new Error(
+ `Enum variant "${value}" carries data; pass { ${value}: ... } instead of a bare name`
+ );
+ }
+ return Buffer.from([index]);
+ }
+
+ // Object value: { variantName: payload } for data-carrying variants.
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ const keys = Object.keys(value);
+ if (keys.length !== 1) {
+ throw new Error(
+ `Enum value must be a single-key object ({ variantName: payload }), got keys [${keys.join(', ')}]`
+ );
+ }
+ const key = keys[0]!;
+ const payload = (value as Record)[key];
+ const index = variants.findIndex((v) => variantName(v) === key);
+ if (index === -1) {
+ throw new Error(
+ `Unknown enum variant "${key}". Expected one of: ${variants.map(variantName).join(', ')}`
+ );
+ }
+ const variant = variants[index]!;
+ if (typeof variant === 'string') {
+ throw new Error(`Enum variant "${key}" is fieldless; pass '${key}' instead of an object`);
+ }
+ const prefix = Buffer.from([index]);
+ if ('fields' in variant) {
+ return Buffer.concat([prefix, serializeStruct(payload, variant.fields)]);
+ }
+ const tuple = payload as unknown[];
+ if (!Array.isArray(tuple) || tuple.length !== variant.tuple.length) {
+ throw new Error(
+ `Enum variant "${key}" expects a tuple of length ${variant.tuple.length}`
+ );
+ }
+ const elements = variant.tuple.map((elementType, i) => serializeValue(tuple[i], elementType));
+ return Buffer.concat([prefix, ...elements]);
+ }
+
+ throw new Error(`Cannot serialize enum from value of type ${typeof value}`);
+}
+
function serializePrimitive(value: unknown, type: string): Buffer {
switch (type) {
case 'u8':
@@ -112,21 +247,61 @@ function serializePrimitive(value: unknown, type: string): Buffer {
case 'i128':
const i128 = Buffer.alloc(16);
const bigI128 = BigInt(value as string | number | bigint);
- i128.writeBigInt64LE(bigI128 & BigInt('0xFFFFFFFFFFFFFFFF'), 0);
+ // The masked low limb is always non-negative, so it must be written
+ // unsigned; the arithmetic-shifted high limb carries the sign.
+ i128.writeBigUInt64LE(bigI128 & BigInt('0xFFFFFFFFFFFFFFFF'), 0);
i128.writeBigInt64LE(bigI128 >> BigInt(64), 8);
return i128;
+ case 'f32': {
+ const f32 = Buffer.alloc(4);
+ f32.writeFloatLE(value as number, 0);
+ return f32;
+ }
+ case 'f64': {
+ const f64 = Buffer.alloc(8);
+ f64.writeDoubleLE(value as number, 0);
+ return f64;
+ }
case 'bool':
return Buffer.from([value as boolean ? 1 : 0]);
+ case 'bytes': {
+ // Borsh bytes: u32 LE length prefix + raw bytes.
+ const raw =
+ value instanceof Uint8Array
+ ? value
+ : Array.isArray(value)
+ ? Uint8Array.from(value as number[])
+ : null;
+ if (raw === null) {
+ throw new Error(`Cannot serialize bytes from value of type ${typeof value}`);
+ }
+ const lenPrefix = Buffer.alloc(4);
+ lenPrefix.writeUInt32LE(raw.length, 0);
+ return Buffer.concat([lenPrefix, Buffer.from(raw)]);
+ }
case 'string':
const str = value as string;
const strBytes = Buffer.from(str, 'utf-8');
const strLen = Buffer.alloc(4);
strLen.writeUInt32LE(strBytes.length, 0);
return Buffer.concat([strLen, strBytes]);
- case 'pubkey':
- // Public key is 32 bytes
- // In production, decode base58 to 32 bytes
- return Buffer.alloc(32, 0);
+ case 'pubkey': {
+ // Public key is 32 bytes. Accept base58 strings or raw 32-byte buffers.
+ if (value instanceof Uint8Array) {
+ if (value.length !== 32) {
+ throw new Error(`Invalid pubkey byte length: expected 32, got ${value.length}`);
+ }
+ return Buffer.from(value);
+ }
+ if (typeof value === 'string') {
+ const decoded = decodeBase58(value);
+ if (decoded.length !== 32) {
+ throw new Error(`Invalid pubkey: '${value}' decoded to ${decoded.length} bytes, expected 32`);
+ }
+ return Buffer.from(decoded);
+ }
+ throw new Error(`Cannot serialize pubkey from value of type ${typeof value}`);
+ }
default:
throw new Error(`Unknown primitive type: ${type}`);
}
diff --git a/typescript/core/src/types.ts b/typescript/core/src/types.ts
index ef94ebb5..646e57cd 100644
--- a/typescript/core/src/types.ts
+++ b/typescript/core/src/types.ts
@@ -26,9 +26,22 @@ export interface StackDefinition {
readonly url: string;
readonly views: Record;
readonly schemas?: Record>;
- instructions?: Record;
+ // Handlers carry specific phantom param/error types; accept any of them
+ // here. Multi-program stacks nest handlers one level deep per program
+ // (e.g. instructions.ore.close); single-program stacks stay flat.
+ instructions?: Record;
}
+/**
+ * One entry in a stack definition's `instructions` block: either a handler
+ * (single-program stacks) or a per-program map of handlers (multi-program).
+ */
+export type StackInstructionEntry =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ | import('./instructions').InstructionHandler
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ | Record>;
+
export interface ViewGroup {
state?: ViewDef;
list?: ViewDef;
diff --git a/typescript/core/src/wallet/types.ts b/typescript/core/src/wallet/types.ts
index b4e22c77..3b08a92a 100644
--- a/typescript/core/src/wallet/types.ts
+++ b/typescript/core/src/wallet/types.ts
@@ -1,17 +1,97 @@
+/**
+ * Wallet adapter boundary for the Arete SDK.
+ *
+ * The core SDK is intentionally RPC-free: it only constructs `BuiltInstruction`
+ * objects. Everything network-related (recent blockhash, message compilation,
+ * signing, sending, and confirmation) lives behind the `WalletAdapter`
+ * boundary, implemented by adapters that wrap the Solana library of your choice
+ * (@solana/web3.js, @solana/kit, a raw Keypair signer for scripts, etc.).
+ */
+
+/**
+ * A single account reference within a built instruction.
+ */
+export interface BuiltAccountMeta {
+ /** Account address as a base58-encoded string */
+ pubkey: string;
+ /** Whether this account must sign the transaction */
+ isSigner: boolean;
+ /** Whether this account is writable */
+ isWritable: boolean;
+}
+
+/**
+ * A framework-agnostic representation of a Solana instruction.
+ *
+ * This is the boundary type between the core SDK (which builds instructions)
+ * and wallet adapters (which broadcast them). It maps 1:1 onto a
+ * @solana/web3.js `TransactionInstruction` or a @solana/kit `Instruction`.
+ */
+export interface BuiltInstruction {
+ /** Program ID (base58) */
+ programId: string;
+ /** Account keys, in the exact order required by the program */
+ keys: BuiltAccountMeta[];
+ /** Serialized instruction data (discriminator + Borsh-encoded args) */
+ data: Uint8Array;
+}
+
+/**
+ * Confirmation level for transaction processing.
+ * - `processed`: Transaction processed but not confirmed
+ * - `confirmed`: Transaction confirmed by cluster
+ * - `finalized`: Transaction finalized (recommended for production)
+ */
+export type ConfirmationLevel = 'processed' | 'confirmed' | 'finalized';
+
+/**
+ * Options forwarded to the wallet adapter when sending a transaction.
+ *
+ * The core SDK does not interpret these; it passes them straight through to
+ * the adapter, which owns all RPC semantics.
+ */
+export interface SendOptions {
+ /** Confirmation level the adapter should wait for */
+ confirmationLevel?: ConfirmationLevel;
+ /** Skip the RPC preflight simulation */
+ skipPreflight?: boolean;
+ /** Adapter-specific passthrough options (priority fees, lookup tables, etc.) */
+ [key: string]: unknown;
+}
+
+/**
+ * Result returned by a wallet adapter after broadcasting a transaction.
+ */
+export interface SendResult {
+ /** Transaction signature (base58) */
+ signature: string;
+ /** Slot in which the transaction landed, if the adapter reports it */
+ slot?: number;
+}
+
/**
* Wallet adapter interface for signing and sending transactions.
- * This is framework-agnostic and can be implemented by any wallet provider.
+ *
+ * Implementations own blockhash fetching, message compilation (legacy or v0),
+ * signing, sending, and confirmation. The core SDK only needs `publicKey` for
+ * signer-account resolution and `signAndSend` to broadcast built instructions.
*/
export interface WalletAdapter {
/** The wallet's public key as a base58-encoded string */
publicKey: string;
/**
- * Sign and send a transaction.
- * @param transaction - The transaction to sign and send (can be raw bytes, a Transaction object, or an array of instructions)
- * @returns The transaction signature
+ * Compile, sign, and broadcast one or more built instructions as a single
+ * transaction.
+ *
+ * Accepting an array (rather than a single instruction) makes batching and
+ * composition fall out for free.
+ *
+ * @param instructions - Instructions to include in the transaction, in order
+ * @param options - Adapter-specific send/confirmation options
+ * @returns The transaction signature (and slot, if known)
*/
- signAndSend: (transaction: unknown) => Promise;
+ signAndSend(instructions: BuiltInstruction[], options?: SendOptions): Promise;
}
/**
diff --git a/typescript/react/src/hooks/use-mutation.ts b/typescript/react/src/hooks/use-mutation.ts
index 39f5a7e3..dab862ae 100644
--- a/typescript/react/src/hooks/use-mutation.ts
+++ b/typescript/react/src/hooks/use-mutation.ts
@@ -1,10 +1,10 @@
import { useState, useCallback } from 'react';
-import type {
- InstructionExecutor,
- InstructionExecutorOptions,
+import type {
+ TypedInstruction,
+ InstructionExecutorOptions,
ExecutionResult,
} from '@usearete/sdk';
-import { parseInstructionError } from '@usearete/sdk';
+import { InstructionError } from '@usearete/sdk';
export type MutationStatus = 'idle' | 'pending' | 'success' | 'error';
@@ -13,8 +13,15 @@ export interface UseMutationOptions extends InstructionExecutorOptions {
onError?: (error: Error) => void;
}
-export interface UseMutationResult {
- submit: (args: Record, options?: Partial) => Promise;
+/**
+ * Result of {@link useInstructionMutation}.
+ *
+ * `TParams` is the merged params object accepted by the instruction (IDL args
+ * plus any user-provided account addresses), inferred from the generated
+ * handler. `TError` is the handler's typed program-error union.
+ */
+export interface UseMutationResult, _TError = unknown> {
+ submit: (args: TParams, options?: Partial) => Promise;
status: MutationStatus;
error: string | null;
signature: string | null;
@@ -22,15 +29,15 @@ export interface UseMutationResult {
reset: () => void;
}
-export function useInstructionMutation(
- execute: InstructionExecutor
-): UseMutationResult {
+export function useInstructionMutation, TError = unknown>(
+ execute: TypedInstruction
+): UseMutationResult {
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
const [signature, setSignature] = useState(null);
const submit = useCallback(async (
- args: Record,
+ args: TParams,
options?: Partial
): Promise => {
setStatus('pending');
@@ -49,12 +56,14 @@ export function useInstructionMutation(
return result;
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : String(err);
-
- const programError = parseInstructionError(err, []);
- const displayError = programError
- ? `${programError.name}: ${programError.message}`
- : errorMessage;
+ // The core executor already parses program errors against the handler's
+ // IDL error definitions and throws an InstructionError.
+ const displayError =
+ err instanceof InstructionError && err.programError
+ ? `${err.programError.name}: ${err.programError.message}`
+ : err instanceof Error
+ ? err.message
+ : String(err);
setStatus('error');
setError(displayError);
diff --git a/typescript/react/src/index.ts b/typescript/react/src/index.ts
index e931da37..8488e186 100644
--- a/typescript/react/src/index.ts
+++ b/typescript/react/src/index.ts
@@ -25,10 +25,12 @@ export {
createSeed,
createPublicKeySeed,
serializeInstructionData,
- waitForConfirmation,
parseInstructionError,
formatProgramError,
+ InstructionError,
+ buildInstruction,
executeInstruction,
+ createInstructionHandler,
createInstructionExecutor,
} from '@usearete/sdk';
@@ -66,13 +68,18 @@ export type {
ArgSchema,
ArgType,
ConfirmationLevel,
+ SendOptions,
+ SendResult,
+ BuildOptions,
ExecuteOptions,
ExecutionResult,
ProgramError,
ErrorMetadata,
InstructionHandler,
- InstructionDefinition,
+ InstructionHandlerConfig,
+ TypedInstruction,
BuiltInstruction,
+ BuiltAccountMeta,
} from '@usearete/sdk';
export type {
diff --git a/typescript/react/src/provider.tsx b/typescript/react/src/provider.tsx
index def53394..cf412791 100644
--- a/typescript/react/src/provider.tsx
+++ b/typescript/react/src/provider.tsx
@@ -64,6 +64,7 @@ export function AreteProvider({
maxEntriesPerView: config.maxEntriesPerView,
flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
auth: config.auth,
+ wallet: config.wallet,
}).then((client) => {
client.onConnectionStateChange((state, error) => {
adapter.setConnectionState(state, error);
@@ -81,7 +82,7 @@ export function AreteProvider({
connectingRef.current.set(cacheKey, connectionPromise);
return connectionPromise as Promise>;
- }, [config.autoConnect, config.reconnectIntervals, config.maxReconnectAttempts, config.maxEntriesPerView, config.flushIntervalMs, config.auth, notifyClientChange]);
+ }, [config.autoConnect, config.reconnectIntervals, config.maxReconnectAttempts, config.maxEntriesPerView, config.flushIntervalMs, config.auth, config.wallet, notifyClientChange]);
const getClient = useCallback((stack: TStack | undefined): Arete | null => {
if (!stack) {
diff --git a/typescript/react/src/stack.ts b/typescript/react/src/stack.ts
index 35177873..04aa0ca7 100644
--- a/typescript/react/src/stack.ts
+++ b/typescript/react/src/stack.ts
@@ -3,7 +3,7 @@ import type { ConnectionState } from '@usearete/sdk';
import type { StoreApi, UseBoundStore } from 'zustand';
import { useAreteContext } from './provider';
import { createStateViewHook, createListViewHook } from './view-hooks';
-import { useInstructionMutation, UseMutationResult } from './hooks';
+import { useInstructionMutation, type UseMutationResult } from './hooks';
import type {
StackDefinition,
ViewDef,
@@ -17,7 +17,7 @@ import type {
UseAreteOptions
} from './types';
import { ZustandAdapter, type AreteStore } from './zustand-adapter';
-import type { InstructionHandler, InstructionExecutor } from '@usearete/sdk';
+import type { InstructionHandler, TypedInstruction, StackInstructionEntry } from '@usearete/sdk';
import type { Arete } from '@usearete/sdk';
type ViewHookForDef = TDef extends ViewDef
@@ -57,15 +57,38 @@ type BuildViewInterface> = {
};
};
-type InstructionHook = {
- useMutation: () => UseMutationResult;
- execute: InstructionExecutor;
-};
-
-type BuildInstructionInterface | undefined> =
- TInstructions extends Record
- ? { [K in keyof TInstructions]: InstructionHook }
- : {};
+/**
+ * Per-instruction hook surface, with params/error types inferred from the
+ * generated handler's phantom types.
+ */
+type InstructionHookFor = THandler extends InstructionHandler
+ ? {
+ useMutation: () => UseMutationResult;
+ execute: TypedInstruction
;
+ }
+ : {
+ useMutation: () => UseMutationResult;
+ execute: TypedInstruction, unknown>;
+ };
+
+/**
+ * Maps one stack-definition instruction entry to its hook surface. Handlers
+ * map directly; per-program maps (multi-program stacks) nest one level.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type InstructionEntryHooks = TEntry extends InstructionHandler
+ ? InstructionHookFor
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ TEntry extends Record>
+ ? { [K in keyof TEntry]: InstructionHookFor }
+ : InstructionHookFor;
+
+type BuildInstructionInterface<
+ TInstructions extends Record | undefined,
+> =
+ TInstructions extends Record
+ ? { [K in keyof TInstructions]: InstructionEntryHooks }
+ : Record;
type StackClient = {
views: BuildViewInterface;
@@ -150,14 +173,34 @@ export function useArete(
}, [stack, client]);
const instructions = useMemo(() => {
- const result: Record = {};
+ type Hook = {
+ execute: TypedInstruction, unknown>;
+ useMutation: () => UseMutationResult;
+ };
+ const toHook = (executeFn: unknown): Hook => {
+ const execute = executeFn as TypedInstruction, unknown>;
+ return {
+ execute,
+ useMutation: () => useInstructionMutation(execute),
+ };
+ };
+
+ const result: Record> = {};
if (client?.instructions) {
- for (const [instructionName, executeFn] of Object.entries(client.instructions)) {
- result[instructionName] = {
- execute: executeFn as InstructionExecutor,
- useMutation: () => useInstructionMutation(executeFn as InstructionExecutor)
- };
+ for (const [name, entry] of Object.entries(client.instructions)) {
+ if (typeof entry === 'function') {
+ result[name] = toHook(entry);
+ } else {
+ // Multi-program stacks: one nested hook map per program.
+ const nested: Record = {};
+ for (const [instructionName, executeFn] of Object.entries(
+ entry as Record
+ )) {
+ nested[instructionName] = toHook(executeFn);
+ }
+ result[name] = nested;
+ }
}
}