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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-val
import { resolveConditionalValueNodeCondition } from '../resolvers/resolve-conditional';
import { resolvePDAAddress } from '../resolvers/resolve-pda-address';
import type { BaseResolutionContext } from '../resolvers/types';
import { formatArgumentPathSuffix, resolveArgumentPathValue } from './resolve-argument-path';

type AccountDefaultValueVisitorContext = BaseResolutionContext & {
accountAddressInput: AddressInput | null | undefined;
Expand Down Expand Up @@ -94,10 +95,15 @@ export function createAccountDefaultValueVisitor(
},

visitArgumentValue: async (node: ArgumentValueNode) => {
const argValue = argumentsInput?.[node.name];
const rootArg = argumentsInput?.[node.name];
const argValue =
node.path && node.path.length > 0
? resolveArgumentPathValue(rootArg, node.path, node.name, ixNode.name)
: rootArg;
if (argValue === undefined || argValue === null) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, {
argumentName: node.name,
argumentPath: formatArgumentPathSuffix(node.path ?? []),
instructionName: ixNode.name,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AccountValueNode, ArgumentValueNode, ResolverValueNode } from 'cod

import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address';
import type { BaseResolutionContext } from '../resolvers/types';
import { resolveArgumentPathValue } from './resolve-argument-path';

export const CONDITION_NODE_SUPPORTED_NODE_KINDS = [
'accountValueNode',
Expand Down Expand Up @@ -42,7 +43,15 @@ export function createConditionNodeValueVisitor(
},

visitArgumentValue: async (node: ArgumentValueNode) => {
const argInput = argumentsInput?.[node.name];
const rootArg = argumentsInput?.[node.name];
// Conditions compare runtime values, not encoded bytes — we deliberately do not
// recompute the leaf field's TypeNode here. If conditional comparison ever grows a
// wire-typed dimension (e.g. comparing encoded discriminators), this branch must also
// walk ixArgumentNode.type via resolveArgumentPathType to pick the right type.
const argInput =
node.path && node.path.length > 0
? resolveArgumentPathValue(rootArg, node.path, node.name, ixNode.name)
: rootArg;
return await Promise.resolve(argInput);
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { getMemoizedAddressEncoder, getMemoizedBooleanEncoder, getMemoizedUtf8Co
import { resolveAccountValueNodeAddress } from '../resolvers/resolve-account-value-node-address';
import type { BaseResolutionContext } from '../resolvers/types';
import { createInputValueTransformer } from './input-value-transformer';
import { formatArgumentPathSuffix, resolveArgumentPathType, resolveArgumentPathValue } from './resolve-argument-path';

export const PDA_SEED_VALUE_SUPPORTED_NODE_KINDS = [
'accountValueNode',
Expand Down Expand Up @@ -96,12 +97,20 @@ export function createPdaSeedValueVisitor(
referencedName: node.name,
});
}
const argInput = argumentsInput[node.name];

const argFieldType =
node.path && node.path.length > 0
? resolveArgumentPathType(ixArgumentNode.type, node.path, root, node.name)
: ixArgumentNode.type;
const argInput =
node.path && node.path.length > 0
? resolveArgumentPathValue(argumentsInput[node.name], node.path, node.name, ixNode.name)
: argumentsInput[node.name];

// Use the PDA seed's declared type (e.g. plain stringTypeNode) rather than
// the instruction argument's type (e.g. sizePrefixTypeNode) so the seed
// bytes match what the on-chain program derives.
const typeNode = seedTypeNode ?? ixArgumentNode.type;
// the (nested) instruction argument's type (e.g. sizePrefixTypeNode) so the
// seed bytes match what the on-chain program derives.
const typeNode = seedTypeNode ?? argFieldType;

if (argInput === undefined || argInput === null) {
// optional remainderOptionTypeNode seeds encodes to zero bytes.
Expand All @@ -110,6 +119,7 @@ export function createPdaSeedValueVisitor(
}
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, {
argumentName: node.name,
argumentPath: formatArgumentPathSuffix(node.path ?? []),
instructionName: ixNode.name,
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING,
CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION,
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
CodamaError,
} from '@codama/errors';
import type { ArgumentValueNode, CamelCaseString, RootNode, TypeNode } from 'codama';
import { isNode } from 'codama';

import { isObjectRecord } from '../../shared/util';

/**
* Format an ArgumentValueNode reference as a dotted display string.
* Example: `{ name: "planData", path: ["planId"] }` → `"planData.planId"`.
*/
export function formatArgumentReference(node: ArgumentValueNode): string {
return node.path && node.path.length > 0 ? `${node.name}.${node.path.join('.')}` : node.name;
}

/**
* Format a path array as the `argumentPath` suffix expected by ARGUMENT_MISSING error context.
* Empty/missing path → "" (so the error message renders just the argument name).
*/
function pathSuffix(path: readonly CamelCaseString[]): string {
return path.length > 0 ? `.${path.join('.')}` : '';
}

/**
* Walks `path` through a top-level instruction-arg type to the leaf field's typeNode.
* Descends through structTypeNode fields and resolves definedTypeLinkNode along the way.
* Throws INVARIANT_VIOLATION if the path doesn't resolve through a struct field.
*/
export function resolveArgumentPathType(
rootType: TypeNode,
path: readonly CamelCaseString[],
root: RootNode,
argumentName: CamelCaseString,
): TypeNode {
let current = rootType;
const visited: CamelCaseString[] = [];
for (const segment of path) {
current = unwrapDefinedTypeLink(current, root);
if (!isNode(current, 'structTypeNode')) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, {
message: `Cannot walk argument path "${argumentName}${pathSuffix([...visited, segment])}": expected structTypeNode at "${argumentName}${pathSuffix(visited)}", got ${current.kind}.`,
});
}
const field = current.fields.find(f => f.name === segment);
if (!field) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, {
message: `Argument path "${argumentName}${pathSuffix([...visited, segment])}" does not exist: struct has no field "${segment}".`,
});
}
current = field.type;
visited.push(segment);
}
return current;
}

/**
* Walks `path` through a top-level argument input value to the leaf value.
* Each step requires the intermediate value to be a non-null object.
* Throws ARGUMENT_MISSING if any intermediate (or the leaf) is undefined/null.
*/
export function resolveArgumentPathValue(
rootValue: unknown,
path: readonly CamelCaseString[],
argumentName: CamelCaseString,
instructionName: CamelCaseString,
): unknown {
let current = rootValue;
const visited: CamelCaseString[] = [];
for (const segment of path) {
if (current === undefined || current === null) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING, {
argumentName,
argumentPath: pathSuffix(visited),
instructionName,
});
}
if (!isObjectRecord(current)) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, {
message: `Cannot read "${segment}" from argument "${argumentName}${pathSuffix(visited)}": value is not an object.`,
});
}
current = current[segment];
visited.push(segment);
}
return current;
}

function unwrapDefinedTypeLink(node: TypeNode, root: RootNode, seen: Set<CamelCaseString> = new Set()): TypeNode {
if (!isNode(node, 'definedTypeLinkNode')) return node;
if (seen.has(node.name)) {
throw new CodamaError(CODAMA_ERROR__DYNAMIC_CLIENT__INVARIANT_VIOLATION, {
message: `Circular definedTypeLinkNode chain encountered while resolving argument path through "${node.name}".`,
});
}
seen.add(node.name);
const definedType = root.program.definedTypes.find(dt => dt.name === node.name);
if (!definedType) {
throw new CodamaError(CODAMA_ERROR__LINKED_NODE_NOT_FOUND, {
kind: 'definedTypeLinkNode',
linkNode: node,
name: node.name,
path: [],
});
}
return unwrapDefinedTypeLink(definedType.type, root, seen);
}

export { pathSuffix as formatArgumentPathSuffix };
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { argumentValueNode } from 'codama';
import {
argumentValueNode,
instructionArgumentNode,
instructionNode,
publicKeyTypeNode,
structFieldTypeNode,
structTypeNode,
} from 'codama';
import { describe, expect, test } from 'vitest';

import { SvmTestContext } from '../../../svm-test-context';
Expand Down Expand Up @@ -26,6 +33,48 @@ describe('account-default-value: visitArgumentValue', () => {
);
});

describe('nested struct argument paths', () => {
const ixNodeWithStructArg = instructionNode({
arguments: [
instructionArgumentNode({
name: 'config',
type: structTypeNode([structFieldTypeNode({ name: 'authority', type: publicKeyTypeNode() })]),
}),
],
name: 'testInstruction',
});

test('should resolve nested address from struct field', async () => {
const addr = await SvmTestContext.generateAddress();
const visitor = makeVisitor({
argumentsInput: { config: { authority: addr } },
ixNode: ixNodeWithStructArg,
});
const result = await visitor.visitArgumentValue(argumentValueNode('config', ['authority']));
expect(result).toBe(addr);
});

test('should throw when intermediate struct arg is missing', async () => {
const visitor = makeVisitor({
argumentsInput: {},
ixNode: ixNodeWithStructArg,
});
await expect(visitor.visitArgumentValue(argumentValueNode('config', ['authority']))).rejects.toThrow(
/Missing argument \[config\] in \[testInstruction\]/,
);
});

test('should throw when leaf field is missing on provided struct', async () => {
const visitor = makeVisitor({
argumentsInput: { config: {} },
ixNode: ixNodeWithStructArg,
});
await expect(visitor.visitArgumentValue(argumentValueNode('config', ['authority']))).rejects.toThrow(
/Missing argument \[config\.authority\] in \[testInstruction\]/,
);
});
});

test('should throw when argument cannot be converted to Address', async () => {
const visitors: ReturnType<typeof makeVisitor>[] = [
makeVisitor({ argumentsInput: { myArg: 'not-a-valid-base58' } }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ describe('condition-node-value: visitArgumentValue', () => {
const result = await visitor.visitArgumentValue(argumentValueNode('amount'));
expect(result).toBeUndefined();
});

test('should resolve nested struct field value via path', async () => {
const visitor = makeVisitor({ argumentsInput: { config: { threshold: 7 } } });
const result = await visitor.visitArgumentValue(argumentValueNode('config', ['threshold']));
expect(result).toBe(7);
});

test('should throw when intermediate struct arg is missing for nested path', async () => {
const visitor = makeVisitor({ argumentsInput: {} });
await expect(visitor.visitArgumentValue(argumentValueNode('config', ['threshold']))).rejects.toThrow(
/Missing argument \[config\] in/,
);
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { getUtf8Codec } from '@solana/codecs';
import { getU64Encoder, getUtf8Codec } from '@solana/codecs';
import {
argumentValueNode,
definedTypeLinkNode,
definedTypeNode,
instructionArgumentNode,
instructionNode,
numberTypeNode,
programNode,
publicKeyTypeNode,
remainderOptionTypeNode,
rootNode,
sizePrefixTypeNode,
stringTypeNode,
structFieldTypeNode,
structTypeNode,
} from 'codama';
import { describe, expect, test } from 'vitest';

Expand Down Expand Up @@ -80,6 +86,77 @@ describe('pda-seed-value: visitArgumentValue', () => {
);
});

describe('nested struct argument paths', () => {
const planDataStruct = structTypeNode([
structFieldTypeNode({ name: 'planId', type: numberTypeNode('u64') }),
structFieldTypeNode({ name: 'label', type: stringTypeNode('utf8') }),
]);
const ixNodeWithStructArg = instructionNode({
arguments: [instructionArgumentNode({ name: 'planData', type: planDataStruct })],
name: 'createPlan',
});

test('should encode nested numeric field via path', async () => {
const visitor = makeVisitor({
argumentsInput: { planData: { label: 'x', planId: 42n } },
ixNode: ixNodeWithStructArg,
});
const result = await visitor.visitArgumentValue(argumentValueNode('planData', ['planId']));
expect(result).toEqual(getU64Encoder().encode(42n));
});

test('should throw when nested path does not exist on struct', async () => {
const visitor = makeVisitor({
argumentsInput: { planData: { label: 'x', planId: 42n } },
ixNode: ixNodeWithStructArg,
});
await expect(visitor.visitArgumentValue(argumentValueNode('planData', ['missing']))).rejects.toThrow(
/struct has no field "missing"/,
);
});

test('should throw when intermediate value is missing', async () => {
const visitor = makeVisitor({
argumentsInput: {},
ixNode: ixNodeWithStructArg,
});
await expect(visitor.visitArgumentValue(argumentValueNode('planData', ['planId']))).rejects.toThrow(
/Missing argument \[planData\]/,
);
});

test('should throw when path traverses a non-struct type', async () => {
const visitor = makeVisitor({
argumentsInput: { title: 'hi' },
ixNode: ixNodeWithArg, // title is stringTypeNode
});
await expect(visitor.visitArgumentValue(argumentValueNode('title', ['bogus']))).rejects.toThrow(
/expected structTypeNode/,
);
});

test('should walk through definedTypeLinkNode to nested struct field', async () => {
const planDataLink = definedTypeLinkNode('planData');
const localProgram = programNode({
definedTypes: [definedTypeNode({ name: 'planData', type: planDataStruct })],
name: 'test',
publicKey: '11111111111111111111111111111111',
});
const localRoot = rootNode(localProgram);
const ixNode = instructionNode({
arguments: [instructionArgumentNode({ name: 'planData', type: planDataLink })],
name: 'createPlan',
});
const visitor = makeVisitor({
argumentsInput: { planData: { label: 'x', planId: 7n } },
ixNode,
root: localRoot,
});
const result = await visitor.visitArgumentValue(argumentValueNode('planData', ['planId']));
expect(result).toEqual(getU64Encoder().encode(7n));
});
});

describe('remainderOptionTypeNode seeds', () => {
// Mirrors the pmp IDL's metadata PDA:
// the "authority" seed is remainderOptionTypeNode(publicKeyTypeNode) — null is canonical.
Expand Down
4 changes: 4 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
};
[CODAMA_ERROR__DYNAMIC_CLIENT__ARGUMENT_MISSING]: {
argumentName: CamelCaseString;
// Pre-formatted dotted suffix (e.g. ".planId" or "") for nested-field references.
// Stored as a string rather than CamelCaseString[] because the message formatter is a flat
// ${value} substitution; structured data lives on ArgumentValueNode.path instead.
argumentPath?: string;
instructionName: CamelCaseString;
};
[CODAMA_ERROR__DYNAMIC_CLIENT__CANNOT_CONVERT_TO_ADDRESS]: {
Expand Down
Loading
Loading