Skip to content

Commit aca2adc

Browse files
committed
Add relations, nested argument seeds and legacy PDAs to nodes-from-anchor
1 parent 822c4db commit aca2adc

16 files changed

Lines changed: 841 additions & 175 deletions

‎packages/nodes-from-anchor/src/utils.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { camelCase } from '@codama/fragments/casing';
12
import {
23
booleanValueNode,
34
BytesTypeNode,
@@ -8,11 +9,14 @@ import {
89
integerTypeNode,
910
integerValueNode,
1011
isNode,
12+
PluginNode,
13+
pluginNode,
1114
publicKeyValueNode,
1215
sizePrefixTransformNode,
1316
StringTypeNode,
1417
stringTypeNode,
1518
stringValueNode,
19+
StructFieldTypeNode,
1620
TypeNode,
1721
ValueNode,
1822
} from '@codama/nodes';
@@ -110,3 +114,70 @@ export function parseConstantValue(valueString: string, type: TypeNode): { type:
110114

111115
return { type, value: stringValueNode(valueString) };
112116
}
117+
118+
/** Defined types of a program, keyed by identifier, used to follow `definedTypeLinkNode`s. */
119+
export type DefinedTypeMap = ReadonlyMap<string, TypeNode>;
120+
121+
/** The result of resolving a field path within struct fields (see `resolveFieldPath`). */
122+
export type FieldPathResolution =
123+
/** The path was found: `path` holds the matched field identifiers and `type` the type of the last field. */
124+
| { kind: 'found'; path: string[]; type: TypeNode }
125+
/** A segment of the path matches no field of the struct it goes through. */
126+
| { kind: 'missing'; segment: string }
127+
/** The path goes through a type that is not a struct (or cannot be followed), so it cannot be addressed. */
128+
| { kind: 'unreachable' };
129+
130+
/**
131+
* Resolve a field path (e.g. `["params", "seed"]`) within struct fields,
132+
* following links to the given defined types.
133+
*
134+
* Segments are matched against field identifiers using `matches`, which
135+
* defaults to exact equality. Legacy IDLs, for instance, camelCase field
136+
* names but keep the Rust casing in seed paths.
137+
*/
138+
export function resolveFieldPath(
139+
fields: StructFieldTypeNode[],
140+
path: string[],
141+
definedTypes: DefinedTypeMap,
142+
matches: (identifier: string, segment: string) => boolean = (identifier, segment) => identifier === segment,
143+
): FieldPathResolution {
144+
const [segment, ...rest] = path;
145+
const field = fields.find(candidate => matches(candidate.identifier, segment));
146+
if (!field) return { kind: 'missing', segment };
147+
if (rest.length === 0) return { kind: 'found', path: [field.identifier], type: field.type };
148+
const struct = resolveDefinedTypeLinks(field.type, definedTypes);
149+
if (!struct || !isNode(struct, 'structTypeNode')) return { kind: 'unreachable' };
150+
const resolution = resolveFieldPath(struct.fields ?? [], rest, definedTypes, matches);
151+
return resolution.kind === 'found' ? { ...resolution, path: [field.identifier, ...resolution.path] } : resolution;
152+
}
153+
154+
/** Whether two identifiers share a camelCase form, e.g. `seed_a` and `seedA`. */
155+
export function haveSameCamelCase(left: string, right: string): boolean {
156+
return camelCase(left) === camelCase(right);
157+
}
158+
159+
/** A type guard excluding `undefined`, e.g. to narrow arrays with `every`. */
160+
export function isDefined<T>(value: T | undefined): value is T {
161+
return value !== undefined;
162+
}
163+
164+
function resolveDefinedTypeLinks(type: TypeNode, definedTypes: DefinedTypeMap): TypeNode | undefined {
165+
const visited = new Set<string>();
166+
let resolved: TypeNode | undefined = type;
167+
while (resolved && isNode(resolved, 'definedTypeLinkNode')) {
168+
// Links to other programs cannot be followed, and cycles cannot be walked.
169+
if (resolved.program || visited.has(resolved.identifier)) return undefined;
170+
visited.add(resolved.identifier);
171+
resolved = definedTypes.get(resolved.identifier);
172+
}
173+
return resolved;
174+
}
175+
176+
/**
177+
* The `anchor.relations` plugin of an instruction account, listing the
178+
* identifiers of the instruction accounts that must hold its address in
179+
* their data (i.e. Anchor's `has_one` constraints targeting it).
180+
*/
181+
export function anchorRelationsPluginNode(relations: string[]): PluginNode[] | undefined {
182+
return relations.length > 0 ? [pluginNode('anchor.relations', relations)] : undefined;
183+
}

‎packages/nodes-from-anchor/src/v00/InstructionAccountNode.ts‎

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,42 @@
1+
import { CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED, CodamaError } from '@codama/errors';
12
import { camelCase } from '@codama/fragments/casing';
2-
import { InstructionAccountNode, instructionAccountNode } from '@codama/nodes';
3+
import {
4+
AccountValueNode,
5+
accountValueNode,
6+
constantPdaSeedNode,
7+
constantPdaSeedNodeFromBytes,
8+
constantPdaSeedNodeFromString,
9+
DataValueNode,
10+
dataValueNode,
11+
InstructionAccountNode,
12+
instructionAccountNode,
13+
integerTypeNode,
14+
integerValueNode,
15+
isNode,
16+
pdaNode,
17+
PdaSeedNode,
18+
PdaSeedValueNode,
19+
pdaSeedValueNode,
20+
PdaValueNode,
21+
pdaValueNode,
22+
publicKeyTypeNode,
23+
publicKeyValueNode,
24+
StructFieldTypeNode,
25+
variablePdaSeedNode,
26+
} from '@codama/nodes';
27+
import { getBase58Codec } from '@solana/codecs';
328

4-
import { docsFromAnchor } from '../utils';
5-
import { IdlV00Account, IdlV00AccountItem } from './idl';
29+
import {
30+
anchorRelationsPluginNode,
31+
DefinedTypeMap,
32+
docsFromAnchor,
33+
haveSameCamelCase,
34+
hex,
35+
isDefined,
36+
resolveFieldPath,
37+
} from '../utils';
38+
import { IdlV00Account, IdlV00AccountItem, IdlV00Pda, IdlV00Seed } from './idl';
39+
import { pdaSeedTypeNodeFromAnchorV00 } from './PdaNode';
640

741
/**
842
* Whether flattening nested account groups would produce accounts whose
@@ -32,29 +66,210 @@ function hasDuplicateAccountNames(idl: IdlV00AccountItem[]): boolean {
3266
return checkDuplicates(idl);
3367
}
3468

69+
export type InstructionAccountNodeFromAnchorV00Options = {
70+
/**
71+
* The names of the accounts of the surrounding account group, as they
72+
* appear in the IDL, used to resolve account seeds. When absent, account
73+
* seed paths are used as is.
74+
*/
75+
accountNames?: string[];
76+
/** The program's defined types, used to follow links within nested argument paths. */
77+
definedTypes?: DefinedTypeMap;
78+
/** The prefix of the accounts of the surrounding account group, if any. */
79+
prefix?: string;
80+
/**
81+
* The accounts of the same group that must hold this account's address
82+
* in their data, i.e. the `relations` of the IDL flipped to target it.
83+
*/
84+
relations?: string[];
85+
};
86+
3587
export function instructionAccountNodesFromAnchorV00(
3688
idl: IdlV00AccountItem[],
37-
prefix?: string,
89+
dataFields: StructFieldTypeNode[] = [],
90+
options: Pick<InstructionAccountNodeFromAnchorV00Options, 'definedTypes' | 'prefix'> = {},
3891
): InstructionAccountNode[] {
92+
const { prefix } = options;
3993
const shouldPrefix = prefix !== undefined || hasDuplicateAccountNames(idl);
94+
const accountNames = idl.filter(item => !('accounts' in item)).map(item => item.name);
95+
const relationsByTarget = getRelationsByTarget(idl);
4096

4197
return idl.flatMap(account =>
4298
'accounts' in account
43-
? instructionAccountNodesFromAnchorV00(
44-
account.accounts,
45-
shouldPrefix ? (prefix ? `${prefix}_${account.name}` : account.name) : undefined,
46-
)
47-
: [instructionAccountNodeFromAnchorV00(account, shouldPrefix ? prefix : undefined)],
99+
? instructionAccountNodesFromAnchorV00(account.accounts, dataFields, {
100+
...options,
101+
prefix: shouldPrefix ? (prefix ? `${prefix}_${account.name}` : account.name) : undefined,
102+
})
103+
: [
104+
instructionAccountNodeFromAnchorV00(account, dataFields, {
105+
...options,
106+
accountNames,
107+
prefix: shouldPrefix ? prefix : undefined,
108+
relations: relationsByTarget.get(account.name) ?? [],
109+
}),
110+
],
48111
);
49112
}
50113

51-
export function instructionAccountNodeFromAnchorV00(idl: IdlV00Account, prefix?: string): InstructionAccountNode {
52-
const isOptional = idl.optional ?? idl.isOptional ?? false;
114+
/**
115+
* Legacy IDLs list relations on the account declaring the `has_one`
116+
* constraint, whereas the `anchor.relations` plugin follows current IDLs
117+
* and lists them on the target account. Flip them within an account
118+
* group, ignoring targets that are not accounts of that group.
119+
*
120+
* Relations keep the Rust casing (e.g. `my_account`) whereas account names
121+
* are camelCased (e.g. `myAccount`), so they are matched by camelCase form.
122+
*/
123+
function getRelationsByTarget(idl: IdlV00AccountItem[]): Map<string, string[]> {
124+
const relationsByTarget = new Map<string, string[]>();
125+
const accounts = idl.filter((item): item is IdlV00Account => !('accounts' in item));
126+
accounts.forEach(account => {
127+
(account.relations ?? []).forEach(relation => {
128+
const target = accounts.find(candidate => haveSameCamelCase(candidate.name, relation));
129+
if (!target) return;
130+
const relations = relationsByTarget.get(target.name) ?? [];
131+
if (!relations.includes(account.name)) relationsByTarget.set(target.name, [...relations, account.name]);
132+
});
133+
});
134+
return relationsByTarget;
135+
}
136+
137+
/**
138+
* Convert a legacy Anchor instruction account, including its PDA default
139+
* value and its relations as an `anchor.relations` plugin.
140+
*
141+
* Legacy IDLs camelCase argument and account names but keep the Rust
142+
* casing in seed paths (e.g. `seed_a` for the `seedA` argument), so seeds
143+
* are matched by camelCase form. A PDA default value is only set when all
144+
* of its seeds can be expressed statically and resolved: nested account
145+
* paths (e.g. `mint.authority`) require fetching the account.
146+
*/
147+
export function instructionAccountNodeFromAnchorV00(
148+
idl: IdlV00Account,
149+
dataFields: StructFieldTypeNode[] = [],
150+
options: InstructionAccountNodeFromAnchorV00Options = {},
151+
): InstructionAccountNode {
152+
const { prefix, relations = [] } = options;
153+
const withPrefix = (accountName: string) => (prefix ? `${prefix}_${accountName}` : accountName);
154+
const name = withPrefix(idl.name ?? '');
155+
const context: SeedContext = {
156+
dataFields,
157+
definedTypes: options.definedTypes ?? new Map(),
158+
resolveAccount: seedPath => {
159+
if (!options.accountNames) return withPrefix(seedPath);
160+
const account = options.accountNames.find(candidate => haveSameCamelCase(candidate, seedPath));
161+
return account === undefined ? undefined : withPrefix(account);
162+
},
163+
};
164+
53165
return instructionAccountNode({
166+
defaultValue: idl.pda ? pdaDefaultValueFromAnchorV00(name, idl.pda, context) : undefined,
54167
docs: docsFromAnchor(idl.docs) ?? (idl.desc || undefined),
55-
identifier: prefix ? `${prefix}_${idl.name ?? ''}` : (idl.name ?? ''),
56-
isOptional,
168+
identifier: name,
169+
isOptional: idl.optional ?? idl.isOptional ?? false,
57170
isSigner: idl.isOptionalSigner ? 'either' : (idl.isSigner ?? false),
58171
isWritable: idl.isMut ?? false,
172+
plugins: anchorRelationsPluginNode(relations.map(withPrefix)),
173+
});
174+
}
175+
176+
type SeedContext = {
177+
dataFields: StructFieldTypeNode[];
178+
definedTypes: DefinedTypeMap;
179+
/** The identifier of the instruction account a seed path refers to, if any. */
180+
resolveAccount: (seedPath: string) => string | undefined;
181+
};
182+
183+
type SeedConversion = { definition: PdaSeedNode; value?: PdaSeedValueNode };
184+
185+
function pdaDefaultValueFromAnchorV00(name: string, pda: IdlV00Pda, context: SeedContext): PdaValueNode | undefined {
186+
const seeds = pda.seeds.map(seed => pdaSeedNodeFromAnchorV00(seed, context));
187+
if (!seeds.every(isDefined)) return undefined;
188+
189+
let programId: string | undefined;
190+
let programIdValue: AccountValueNode | DataValueNode | undefined;
191+
if (pda.programId !== undefined) {
192+
const seed = pda.programId;
193+
if (seed.kind === 'const') {
194+
programId = constantProgramIdFromAnchorV00(seed.type, seed.value);
195+
if (programId === undefined) return undefined;
196+
} else {
197+
const conversion = pdaSeedNodeFromAnchorV00(seed, context);
198+
const programIdNode = conversion?.value?.value;
199+
if (!programIdNode || !isNode(programIdNode, ['accountValueNode', 'dataValueNode'])) return undefined;
200+
programIdValue = programIdNode;
201+
}
202+
}
203+
204+
return pdaValueNode(pdaNode({ identifier: name, programId, seeds: seeds.map(seed => seed.definition) }), {
205+
programId: programIdValue,
206+
seeds: seeds.flatMap(seed => (seed.value ? [seed.value] : [])),
59207
});
60208
}
209+
210+
/** A constant program ID, given as an address or as its 32 bytes. */
211+
function constantProgramIdFromAnchorV00(type: IdlV00Seed['type'], value: unknown): string | undefined {
212+
if (type === 'publicKey' && typeof value === 'string') return value;
213+
if (isByteArray(value) && value.length === 32) return getBase58Codec().decode(new Uint8Array(value));
214+
return undefined;
215+
}
216+
217+
function pdaSeedNodeFromAnchorV00(seed: IdlV00Seed, context: SeedContext): SeedConversion | undefined {
218+
const kind = seed.kind;
219+
switch (kind) {
220+
case 'const':
221+
return constantPdaSeedNodeFromAnchorV00(seed.type, seed.value);
222+
case 'account': {
223+
if (seed.path.includes('.')) return undefined;
224+
const accountName = context.resolveAccount(seed.path);
225+
if (accountName === undefined) return undefined;
226+
return {
227+
definition: variablePdaSeedNode(accountName, publicKeyTypeNode()),
228+
value: pdaSeedValueNode(accountName, accountValueNode(accountName)),
229+
};
230+
}
231+
case 'arg': {
232+
// Legacy seeds carry their type, so only the identifiers of the path are resolved.
233+
const resolution = resolveFieldPath(
234+
context.dataFields,
235+
seed.path.split('.'),
236+
context.definedTypes,
237+
haveSameCamelCase,
238+
);
239+
if (resolution.kind !== 'found') return undefined;
240+
const seedName = resolution.path.join('_');
241+
return {
242+
definition: variablePdaSeedNode(seedName, pdaSeedTypeNodeFromAnchorV00(seed.type)),
243+
value: pdaSeedValueNode(seedName, dataValueNode(resolution.path.join('.'))),
244+
};
245+
}
246+
default:
247+
throw new CodamaError(CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED, { kind });
248+
}
249+
}
250+
251+
const INTEGER_SEED_TYPES = ['u8', 'u16', 'u32', 'u64', 'u128', 'i8', 'i16', 'i32', 'i64', 'i128'] as const;
252+
253+
/** Convert a legacy constant seed, whose raw JSON value depends on its type. */
254+
function constantPdaSeedNodeFromAnchorV00(type: IdlV00Seed['type'], value: unknown): SeedConversion | undefined {
255+
if (type === 'string' && typeof value === 'string') {
256+
return { definition: constantPdaSeedNodeFromString('utf8', value) };
257+
}
258+
if (type === 'publicKey' && typeof value === 'string') {
259+
return { definition: constantPdaSeedNode(publicKeyTypeNode(), publicKeyValueNode(value)) };
260+
}
261+
if (isByteArray(value)) {
262+
return { definition: constantPdaSeedNodeFromBytes('base16', hex(value)) };
263+
}
264+
const integerType = INTEGER_SEED_TYPES.find(format => format === type);
265+
if (integerType && (typeof value === 'number' || typeof value === 'string') && /^-?\d+$/.test(String(value))) {
266+
return {
267+
definition: constantPdaSeedNode(integerTypeNode(integerType), integerValueNode(BigInt(value).toString())),
268+
};
269+
}
270+
return undefined;
271+
}
272+
273+
function isByteArray(value: unknown): value is number[] {
274+
return Array.isArray(value) && value.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255);
275+
}

‎packages/nodes-from-anchor/src/v00/InstructionNode.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@codama/nodes';
1414

1515
import { getAnchorInstructionDiscriminatorV00 } from '../discriminators';
16-
import { docsFromAnchor, fixedSizeBytesTypeNode } from '../utils';
16+
import { DefinedTypeMap, docsFromAnchor, fixedSizeBytesTypeNode } from '../utils';
1717
import { IdlV00Instruction } from './idl';
1818
import { instructionAccountNodesFromAnchorV00 } from './InstructionAccountNode';
1919
import { structFieldTypeNodeFromAnchorV00, typeNodeFromAnchorV00 } from './typeNodes';
@@ -22,6 +22,7 @@ export function instructionNodeFromAnchorV00(
2222
idl: IdlV00Instruction,
2323
ixIndex: number,
2424
origin?: 'anchor' | 'shank',
25+
definedTypes: DefinedTypeMap = new Map(),
2526
): InstructionNode {
2627
const name = idl.name ?? '';
2728
let dataFields = (idl.args ?? []).map(structFieldTypeNodeFromAnchorV00);
@@ -35,7 +36,7 @@ export function instructionNodeFromAnchorV00(
3536
}
3637

3738
return instructionNode({
38-
accounts: instructionAccountNodesFromAnchorV00(idl.accounts ?? []),
39+
accounts: instructionAccountNodesFromAnchorV00(idl.accounts ?? [], dataFields, { definedTypes }),
3940
data: structTypeNode(dataFields),
4041
discriminators,
4142
docs: docsFromAnchor(idl.docs),

‎packages/nodes-from-anchor/src/v00/PdaNode.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export function pdaNodeFromAnchorV00(idl: IdlV00PdaDef): PdaNode {
3737
return pdaNode({ identifier: name, seeds });
3838
}
3939

40-
function pdaSeedTypeNodeFromAnchorV00(type: IdlV00Type): TypeNode {
40+
/** The type of a PDA seed, i.e. strings and byte arrays without their Borsh size prefix. */
41+
export function pdaSeedTypeNodeFromAnchorV00(type: IdlV00Type): TypeNode {
4142
// Anchor derives PDA seeds from raw bytes, so strings and byte
4243
// arrays lose their Borsh size prefix when used as seeds.
4344
if (type === 'string') return stringTypeNode('utf8');

0 commit comments

Comments
 (0)