Skip to content

Commit 60bfce9

Browse files
committed
Resolve nested seed paths in v01 PDA extraction
1 parent 35516d4 commit 60bfce9

8 files changed

Lines changed: 560 additions & 98 deletions

File tree

.changeset/nested-pda-seeds.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@codama/nodes-from-anchor': minor
3+
---
4+
5+
Resolve nested seed paths in v01 PDA extraction instead of silently skipping them

packages/nodes-from-anchor/src/v01/InstructionAccountNode.ts

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logWarn } from '@codama/errors';
12
import {
23
AccountValueNode,
34
ArgumentValueNode,
@@ -7,16 +8,15 @@ import {
78
InstructionArgumentNode,
89
isNode,
910
pdaNode,
10-
PdaSeedNode,
11-
PdaSeedValueNode,
1211
PdaValueNode,
1312
pdaValueNode,
1413
PublicKeyValueNode,
1514
publicKeyValueNode,
1615
} from '@codama/nodes';
1716

18-
import { IdlV01InstructionAccount, IdlV01InstructionAccountItem, IdlV01Seed } from './idl';
17+
import { IdlV01InstructionAccount, IdlV01InstructionAccountItem, IdlV01TypeDef } from './idl';
1918
import { pdaSeedNodeFromAnchorV01 } from './PdaSeedNode';
19+
import type { GenericsV01 } from './unwrapGenerics';
2020

2121
function hasDuplicateAccountNames(idl: IdlV01InstructionAccountItem[]): boolean {
2222
const seenNames = new Set<string>();
@@ -45,6 +45,8 @@ export function instructionAccountNodesFromAnchorV01(
4545
idl: IdlV01InstructionAccountItem[],
4646
instructionArguments: InstructionArgumentNode[],
4747
prefix?: string,
48+
idlTypes: IdlV01TypeDef[] = [],
49+
generics: GenericsV01 = { constArgs: {}, typeArgs: {}, types: {} },
4850
): InstructionAccountNode[] {
4951
const shouldPrefix = prefix !== undefined || hasDuplicateAccountNames(idl);
5052

@@ -54,15 +56,27 @@ export function instructionAccountNodesFromAnchorV01(
5456
account.accounts,
5557
instructionArguments,
5658
shouldPrefix ? (prefix ? `${prefix}_${account.name}` : account.name) : undefined,
59+
idlTypes,
60+
generics,
5761
)
58-
: [instructionAccountNodeFromAnchorV01(account, instructionArguments, shouldPrefix ? prefix : undefined)],
62+
: [
63+
instructionAccountNodeFromAnchorV01(
64+
account,
65+
instructionArguments,
66+
shouldPrefix ? prefix : undefined,
67+
idlTypes,
68+
generics,
69+
),
70+
],
5971
);
6072
}
6173

6274
export function instructionAccountNodeFromAnchorV01(
6375
idl: IdlV01InstructionAccount,
6476
instructionArguments: InstructionArgumentNode[],
6577
prefix?: string,
78+
idlTypes: IdlV01TypeDef[] = [],
79+
generics: GenericsV01 = { constArgs: {}, typeArgs: {}, types: {} },
6680
): InstructionAccountNode {
6781
const isOptional = idl.optional ?? false;
6882
const docs = idl.docs ?? [];
@@ -74,38 +88,61 @@ export function instructionAccountNodeFromAnchorV01(
7488
if (idl.address) {
7589
defaultValue = publicKeyValueNode(idl.address, name);
7690
} else if (idl.pda) {
77-
// TODO: Handle seeds with nested paths.
78-
// Currently, we gracefully ignore PDA default values if we encounter seeds with nested paths.
79-
const seedsWithNestedPaths = idl.pda.seeds.some(seed => 'path' in seed && seed.path.includes('.'));
80-
if (!seedsWithNestedPaths) {
81-
const [seedDefinitions, seedValues] = idl.pda.seeds.reduce(
82-
([seeds, lookups], seed: IdlV01Seed) => {
83-
const { definition, value } = pdaSeedNodeFromAnchorV01(seed, instructionArguments, prefix);
84-
return [[...seeds, definition], value ? [...lookups, value] : lookups];
85-
},
86-
<[PdaSeedNode[], PdaSeedValueNode[]]>[[], []],
91+
const hasNestedProgramPath =
92+
idl.pda.program != null && 'path' in idl.pda.program && idl.pda.program.path.includes('.');
93+
if (hasNestedProgramPath) {
94+
logWarn(`Skipping PDA for account "${name}": program seed uses a nested path that cannot be resolved.`);
95+
} else {
96+
const seedResults = idl.pda.seeds.map(seed =>
97+
pdaSeedNodeFromAnchorV01(seed, instructionArguments, prefix, idlTypes, generics),
8798
);
8899

89-
let programId: string | undefined;
90-
let programIdValue: AccountValueNode | ArgumentValueNode | undefined;
91-
if (idl.pda.program !== undefined) {
92-
const { definition, value } = pdaSeedNodeFromAnchorV01(idl.pda.program, instructionArguments, prefix);
93-
if (
94-
isNode(definition, 'constantPdaSeedNode') &&
95-
isNode(definition.value, 'bytesValueNode') &&
96-
definition.value.encoding === 'base58'
97-
) {
98-
programId = definition.value.data;
99-
} else if (value && isNode(value.value, ['accountValueNode', 'argumentValueNode'])) {
100-
programIdValue = value.value;
100+
if (seedResults.every((r): r is NonNullable<typeof r> => r != null)) {
101+
const seedDefinitions = seedResults.map(r => r.definition);
102+
const seedValues = seedResults.flatMap(r => (r.value ? [r.value] : []));
103+
104+
let programId: string | undefined;
105+
let programIdValue: AccountValueNode | ArgumentValueNode | undefined;
106+
if (idl.pda.program !== undefined) {
107+
const result = pdaSeedNodeFromAnchorV01(
108+
idl.pda.program,
109+
instructionArguments,
110+
prefix,
111+
idlTypes,
112+
generics,
113+
);
114+
if (!result) {
115+
logWarn(`Skipping PDA for account "${name}": program seed could not be resolved.`);
116+
return instructionAccountNode({ defaultValue, docs, isOptional, isSigner, isWritable, name });
117+
}
118+
if (
119+
isNode(result.definition, 'constantPdaSeedNode') &&
120+
isNode(result.definition.value, 'bytesValueNode') &&
121+
result.definition.value.encoding === 'base58'
122+
) {
123+
programId = result.definition.value.data;
124+
} else if (result.value && isNode(result.value.value, ['accountValueNode', 'argumentValueNode'])) {
125+
programIdValue = result.value.value;
126+
}
101127
}
102-
}
103128

104-
defaultValue = pdaValueNode(
105-
pdaNode({ name, programId, seeds: seedDefinitions }),
106-
seedValues,
107-
programIdValue,
108-
);
129+
const camelName = camelCase(name);
130+
const isSelfReferential =
131+
seedValues.some(sv => isNode(sv.value, 'accountValueNode') && sv.value.name === camelName) ||
132+
(programIdValue != null &&
133+
isNode(programIdValue, 'accountValueNode') &&
134+
programIdValue.name === camelName);
135+
if (isSelfReferential) {
136+
logWarn(`Skipping PDA for account "${name}": a seed references the account itself.`);
137+
}
138+
if (!isSelfReferential) {
139+
defaultValue = pdaValueNode(
140+
pdaNode({ name, programId, seeds: seedDefinitions }),
141+
seedValues,
142+
programIdValue,
143+
);
144+
}
145+
}
109146
}
110147
}
111148

packages/nodes-from-anchor/src/v01/InstructionNode.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import {
99
} from '@codama/nodes';
1010

1111
import { getAnchorDiscriminatorV01 } from '../discriminators';
12-
import type { IdlV01Instruction } from './idl';
12+
import type { IdlV01Instruction, IdlV01TypeDef } from './idl';
1313
import { instructionAccountNodesFromAnchorV01 } from './InstructionAccountNode';
1414
import { instructionArgumentNodeFromAnchorV01 } from './InstructionArgumentNode';
1515
import type { GenericsV01 } from './unwrapGenerics';
1616

17-
export function instructionNodeFromAnchorV01(idl: IdlV01Instruction, generics: GenericsV01): InstructionNode {
17+
export function instructionNodeFromAnchorV01(
18+
idl: IdlV01Instruction,
19+
generics: GenericsV01,
20+
idlTypes: IdlV01TypeDef[] = [],
21+
): InstructionNode {
1822
const name = idl.name;
1923
let dataArguments = idl.args.map(arg => instructionArgumentNodeFromAnchorV01(arg, generics));
2024

@@ -28,7 +32,13 @@ export function instructionNodeFromAnchorV01(idl: IdlV01Instruction, generics: G
2832
const discriminators = [fieldDiscriminatorNode('discriminator')];
2933

3034
return instructionNode({
31-
accounts: instructionAccountNodesFromAnchorV01(idl.accounts ?? [], dataArguments),
35+
accounts: instructionAccountNodesFromAnchorV01(
36+
idl.accounts ?? [],
37+
dataArguments,
38+
undefined,
39+
idlTypes,
40+
generics,
41+
),
3242
arguments: dataArguments,
3343
discriminators,
3444
docs: idl.docs ?? [],

packages/nodes-from-anchor/src/v01/PdaSeedNode.ts

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CODAMA_ERROR__ANCHOR__ARGUMENT_TYPE_MISSING,
33
CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED,
44
CodamaError,
5+
logWarn,
56
} from '@codama/errors';
67
import {
78
accountValueNode,
@@ -15,17 +16,22 @@ import {
1516
pdaSeedValueNode,
1617
publicKeyTypeNode,
1718
stringTypeNode,
19+
TypeNode,
1820
variablePdaSeedNode,
1921
} from '@codama/nodes';
2022
import { getBase58Codec } from '@solana/codecs';
2123

22-
import { IdlV01Seed } from './idl';
24+
import { IdlV01Field, IdlV01Seed, IdlV01TypeDef } from './idl';
25+
import { typeNodeFromAnchorV01 } from './typeNodes';
26+
import type { GenericsV01 } from './unwrapGenerics';
2327

2428
export function pdaSeedNodeFromAnchorV01(
2529
seed: IdlV01Seed,
2630
instructionArguments: InstructionArgumentNode[],
2731
prefix?: string,
28-
): Readonly<{ definition: PdaSeedNode; value?: PdaSeedValueNode }> {
32+
idlTypes: IdlV01TypeDef[] = [],
33+
generics: GenericsV01 = { constArgs: {}, typeArgs: {}, types: {} },
34+
): Readonly<{ definition: PdaSeedNode; value?: PdaSeedValueNode }> | undefined {
2935
const kind = seed.kind;
3036

3137
switch (kind) {
@@ -34,40 +40,115 @@ export function pdaSeedNodeFromAnchorV01(
3440
definition: constantPdaSeedNodeFromBytes('base58', getBase58Codec().decode(new Uint8Array(seed.value))),
3541
};
3642
case 'account': {
37-
// Ignore nested paths.
38-
const [accountName] = seed.path.split('.');
43+
const pathParts = seed.path.split('.');
44+
const [accountName] = pathParts;
3945
const prefixedAccountName = prefix ? `${prefix}_${accountName}` : accountName;
46+
47+
if (pathParts.length > 1) {
48+
const accountTypeName = seed.account ?? accountName;
49+
const rootType = typeNodeFromAnchorV01({ defined: { name: accountTypeName } }, generics);
50+
const resolved = resolveNestedFieldType(rootType, pathParts.slice(1), idlTypes, generics);
51+
if (!resolved) {
52+
logWarn(`Could not resolve nested account path "${seed.path}" for PDA seed.`);
53+
return undefined;
54+
}
55+
const combinedName = camelCase(`${prefixedAccountName}_${pathParts[pathParts.length - 1]}`);
56+
return {
57+
definition: variablePdaSeedNode(combinedName, resolved),
58+
value: pdaSeedValueNode(combinedName, accountValueNode(prefixedAccountName)),
59+
};
60+
}
61+
4062
return {
4163
definition: variablePdaSeedNode(prefixedAccountName, publicKeyTypeNode()),
4264
value: pdaSeedValueNode(prefixedAccountName, accountValueNode(prefixedAccountName)),
4365
};
4466
}
4567
case 'arg': {
46-
// Ignore nested paths.
47-
const [originalArgumentName] = seed.path.split('.');
48-
const argumentName = camelCase(originalArgumentName);
49-
const argumentNode = instructionArguments.find(({ name }) => name === argumentName);
50-
if (!argumentNode) {
51-
throw new CodamaError(CODAMA_ERROR__ANCHOR__ARGUMENT_TYPE_MISSING, { name: originalArgumentName });
68+
const pathParts = seed.path.split('.');
69+
const argumentName = camelCase(pathParts.length > 1 ? pathParts[pathParts.length - 1] : pathParts[0]);
70+
71+
let argumentType: TypeNode;
72+
if (pathParts.length > 1) {
73+
const rootArgName = camelCase(pathParts[0]);
74+
const rootArgNode = instructionArguments.find(({ name }) => name === rootArgName);
75+
if (!rootArgNode) {
76+
throw new CodamaError(CODAMA_ERROR__ANCHOR__ARGUMENT_TYPE_MISSING, { name: pathParts[0] });
77+
}
78+
const resolved = resolveNestedFieldType(rootArgNode.type, pathParts.slice(1), idlTypes, generics);
79+
if (!resolved) {
80+
logWarn(`Could not resolve nested arg path "${seed.path}" for PDA seed.`);
81+
return undefined;
82+
}
83+
argumentType = resolved;
84+
} else {
85+
const argumentNode = instructionArguments.find(({ name }) => name === argumentName);
86+
if (!argumentNode) {
87+
throw new CodamaError(CODAMA_ERROR__ANCHOR__ARGUMENT_TYPE_MISSING, { name: pathParts[0] });
88+
}
89+
argumentType = argumentNode.type;
5290
}
5391

54-
// Anchor uses unprefixed strings for PDA seeds even though the
55-
// argument itself uses a Borsh size-prefixed string. Thus, we
56-
// must recognize this case and convert the type accordingly.
57-
const isBorshString =
58-
isNode(argumentNode.type, 'sizePrefixTypeNode') &&
59-
isNode(argumentNode.type.type, 'stringTypeNode') &&
60-
argumentNode.type.type.encoding === 'utf8' &&
61-
isNode(argumentNode.type.prefix, 'numberTypeNode') &&
62-
argumentNode.type.prefix.format === 'u32';
63-
const argumentType = isBorshString ? stringTypeNode('utf8') : argumentNode.type;
92+
// Anchor uses unprefixed strings for PDA seeds.
93+
if (
94+
isNode(argumentType, 'sizePrefixTypeNode') &&
95+
isNode(argumentType.type, 'stringTypeNode') &&
96+
argumentType.type.encoding === 'utf8' &&
97+
isNode(argumentType.prefix, 'numberTypeNode') &&
98+
argumentType.prefix.format === 'u32'
99+
) {
100+
argumentType = stringTypeNode('utf8');
101+
}
102+
103+
if (pathParts.length > 1) {
104+
return {
105+
definition: variablePdaSeedNode(argumentName, argumentType),
106+
value: pdaSeedValueNode(argumentName, argumentValueNode(argumentName)),
107+
};
108+
}
64109

65110
return {
66-
definition: variablePdaSeedNode(argumentNode.name, argumentType),
67-
value: pdaSeedValueNode(argumentNode.name, argumentValueNode(argumentNode.name)),
111+
definition: variablePdaSeedNode(argumentName, argumentType),
112+
value: pdaSeedValueNode(argumentName, argumentValueNode(argumentName)),
68113
};
69114
}
70115
default:
71116
throw new CodamaError(CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED, { kind });
72117
}
73118
}
119+
120+
function resolveNestedFieldType(
121+
rootType: TypeNode,
122+
fieldPath: string[],
123+
idlTypes: IdlV01TypeDef[],
124+
generics: GenericsV01,
125+
): TypeNode | undefined {
126+
let currentType = rootType;
127+
128+
for (const fieldName of fieldPath) {
129+
const target = camelCase(fieldName);
130+
131+
if (isNode(currentType, 'structTypeNode')) {
132+
const field = currentType.fields.find(f => f.name === target);
133+
if (!field) return undefined;
134+
currentType = field.type;
135+
continue;
136+
}
137+
138+
if (isNode(currentType, 'definedTypeLinkNode')) {
139+
const linkName = currentType.name;
140+
const typeDef = idlTypes.find(t => camelCase(t.name) === linkName);
141+
if (!typeDef || typeDef.type.kind !== 'struct' || !typeDef.type.fields) return undefined;
142+
const { fields } = typeDef.type;
143+
if (!fields.length || typeof fields[0] !== 'object' || !('name' in fields[0])) return undefined;
144+
const field = (fields as IdlV01Field[]).find(f => camelCase(f.name) === target);
145+
if (!field) return undefined;
146+
currentType = typeNodeFromAnchorV01(field.type, generics);
147+
continue;
148+
}
149+
150+
return undefined;
151+
}
152+
153+
return currentType;
154+
}

packages/nodes-from-anchor/src/v01/ProgramNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function programNodeFromAnchorV01(idl: IdlV01): ProgramNode {
2727
definedTypes,
2828
errors: errors.map(errorNodeFromAnchorV01),
2929
events: events.map(event => eventNodeFromAnchorV01(event, types, generics)),
30-
instructions: instructions.map(instruction => instructionNodeFromAnchorV01(instruction, generics)),
30+
instructions: instructions.map(instruction => instructionNodeFromAnchorV01(instruction, generics, types)),
3131
name: idl.metadata.name,
3232
origin: 'anchor',
3333
publicKey: idl.address,

0 commit comments

Comments
 (0)