|
| 1 | +import { CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED, CodamaError } from '@codama/errors'; |
1 | 2 | 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'; |
3 | 28 |
|
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'; |
6 | 40 |
|
7 | 41 | /** |
8 | 42 | * Whether flattening nested account groups would produce accounts whose |
@@ -32,29 +66,210 @@ function hasDuplicateAccountNames(idl: IdlV00AccountItem[]): boolean { |
32 | 66 | return checkDuplicates(idl); |
33 | 67 | } |
34 | 68 |
|
| 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 | + |
35 | 87 | export function instructionAccountNodesFromAnchorV00( |
36 | 88 | idl: IdlV00AccountItem[], |
37 | | - prefix?: string, |
| 89 | + dataFields: StructFieldTypeNode[] = [], |
| 90 | + options: Pick<InstructionAccountNodeFromAnchorV00Options, 'definedTypes' | 'prefix'> = {}, |
38 | 91 | ): InstructionAccountNode[] { |
| 92 | + const { prefix } = options; |
39 | 93 | const shouldPrefix = prefix !== undefined || hasDuplicateAccountNames(idl); |
| 94 | + const accountNames = idl.filter(item => !('accounts' in item)).map(item => item.name); |
| 95 | + const relationsByTarget = getRelationsByTarget(idl); |
40 | 96 |
|
41 | 97 | return idl.flatMap(account => |
42 | 98 | '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 | + ], |
48 | 111 | ); |
49 | 112 | } |
50 | 113 |
|
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 | + |
53 | 165 | return instructionAccountNode({ |
| 166 | + defaultValue: idl.pda ? pdaDefaultValueFromAnchorV00(name, idl.pda, context) : undefined, |
54 | 167 | 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, |
57 | 170 | isSigner: idl.isOptionalSigner ? 'either' : (idl.isSigner ?? false), |
58 | 171 | 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] : [])), |
59 | 207 | }); |
60 | 208 | } |
| 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 | +} |
0 commit comments