Skip to content
Merged
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
10 changes: 5 additions & 5 deletions packages/nodes-from-anchor/src/extractPdasVisitor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { logWarn } from '@codama/errors';
import { camelCase } from '@codama/fragments/casing';
import {
assertIsNode,
identifierString,
Expand All @@ -18,17 +17,18 @@ import { bottomUpTransformerVisitor, getUniqueHashStringVisitor, visit, type Vis

type Fingerprint = string;

/** Hash a PDA regardless of its identifier, so identical PDAs from different accounts are deduplicated. */
function pdaFingerprint(pda: PdaNode, hashVisitor: Visitor<string>): Fingerprint {
return visit(pdaNode({ ...pda, identifier: '' }), hashVisitor);
return visit(pdaNode({ ...pda, identifier: '_' }), hashVisitor);
}

function getUniquePdaName(name: IdentifierString, usedNames: Set<IdentifierString>): IdentifierString {
if (!usedNames.has(name)) return name;
let suffix = 2;
let candidate = identifierString(camelCase(`${name}${suffix}`));
let candidate = identifierString(`${name}${suffix}`);
while (usedNames.has(candidate)) {
suffix++;
candidate = identifierString(camelCase(`${name}${suffix}`));
candidate = identifierString(`${name}${suffix}`);
}
return candidate;
}
Expand Down Expand Up @@ -71,7 +71,7 @@ export function extractPdasFromProgram(program: ProgramNode): ProgramNode {
const existingFingerprint = nameToFingerprint.get(resolvedName);

if (existingFingerprint !== undefined && existingFingerprint !== fingerprint) {
resolvedName = identifierString(camelCase(`${instruction.identifier}_${pda.identifier}`));
resolvedName = identifierString(`${instruction.identifier}_${pda.identifier}`);
logWarn(
`PDA name collision: "${pda.identifier}" has different seeds across instructions. ` +
`Renaming to "${resolvedName}".`,
Expand Down
80 changes: 69 additions & 11 deletions packages/nodes-from-anchor/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import {
booleanValueNode,
BytesTypeNode,
bytesTypeNode,
bytesValueNode,
fixedSizeTransformNode,
floatValueNode,
integerTypeNode,
integerValueNode,
isNode,
numberValueNode,
publicKeyValueNode,
sizePrefixTransformNode,
StringTypeNode,
stringTypeNode,
stringValueNode,
TypeNode,
Expand All @@ -14,36 +21,87 @@ export function hex(bytes: number[] | Uint8Array): string {
return Array.from(bytes).reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
}

/** Join the lines of Anchor docs into a single Markdown string, if any. */
export function docsFromAnchor(docs: string[] | undefined): string | undefined {
return docs && docs.length > 0 ? docs.join('\n') : undefined;
}

/** A fixed-size byte array, e.g. for Anchor's 8-byte discriminators. */
export function fixedSizeBytesTypeNode(size: number): BytesTypeNode {
return bytesTypeNode({ transforms: [fixedSizeTransformNode(size)] });
}

/** A Borsh string or byte array, prefixed by its `u32` size. */
export function borshSizePrefixedTypeNode(type: 'bytes' | 'string'): BytesTypeNode | StringTypeNode {
const transforms = [sizePrefixTransformNode(integerTypeNode('u32'))];
return type === 'string' ? stringTypeNode('utf8', { transforms }) : bytesTypeNode({ transforms });
}

/**
* Remove the Borsh `u32` size prefix of a string or byte array, if any.
*
* Anchor uses unprefixed strings and byte arrays for PDA seeds even though
* the corresponding arguments are Borsh size-prefixed.
*/
export function removeBorshSizePrefix(type: TypeNode): TypeNode {
const isBorshSizePrefixed =
(isNode(type, 'bytesTypeNode') || (isNode(type, 'stringTypeNode') && type.encoding === 'utf8')) &&
type.transforms?.length === 1 &&
isNode(type.transforms[0], 'sizePrefixTransformNode') &&
type.transforms[0].prefix.format === 'u32';
if (!isBorshSizePrefixed) return type;
return isNode(type, 'stringTypeNode') ? stringTypeNode('utf8') : bytesTypeNode();
}

function isByteArray(value: unknown): value is number[] {
return Array.isArray(value) && value.every(n => typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 255);
}

/**
* The canonical spelling of a decimal float string, e.g. `007.50` → `7.5`.
*
* Canonicalisation is purely textual so that no precision is lost: the
* value is not rounded to the float it is parsed into.
*/
function canonicalFloat(valueString: string): string | undefined {
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(valueString);
if (!match) return undefined;
const [, sign, integer, fraction = ''] = match;
const integerPart = integer.replace(/^0+(?=\d)/, '');
const fractionPart = fraction.replace(/0+$/, '');
return `${sign}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`;
}

export function parseConstantValue(valueString: string, type: TypeNode): { type: TypeNode; value: ValueNode } {
const stringFallback = { type: stringTypeNode('utf8'), value: stringValueNode(valueString) };

if (isNode(type, 'bytesTypeNode')) {
try {
const parsed: unknown = JSON.parse(valueString);
if (isByteArray(parsed)) {
return { type, value: bytesValueNode('base16', hex(new Uint8Array(parsed))) };
}
} catch {
// fall through to the string fallback below
// Not JSON: fall through to the string fallback below.
}
return { type: stringTypeNode('utf8'), value: stringValueNode(valueString) };
return stringFallback;
}

if (isNode(type, 'numberTypeNode')) {
const isFloat = type.format === 'f32' || type.format === 'f64';
const numberRegex = isFloat ? /^-?\d+(\.\d+)?$/ : /^-?\d+$/;
if (numberRegex.test(valueString)) {
return { type, value: numberValueNode(Number(valueString)) };
}
return { type: stringTypeNode('utf8'), value: stringValueNode(valueString) };
if (isNode(type, 'integerTypeNode')) {
// Integers are kept as strings, so 64- and 128-bit values stay lossless.
if (!/^-?\d+$/.test(valueString)) return stringFallback;
return { type, value: integerValueNode(BigInt(valueString).toString()) };
}

if (isNode(type, 'floatTypeNode')) {
const canonical = canonicalFloat(valueString);
return canonical === undefined ? stringFallback : { type, value: floatValueNode(canonical) };
}

if (isNode(type, 'booleanTypeNode')) {
if (valueString === 'true') return { type, value: booleanValueNode(true) };
if (valueString === 'false') return { type, value: booleanValueNode(false) };
return { type: stringTypeNode('utf8'), value: stringValueNode(valueString) };
return stringFallback;
}

if (isNode(type, 'publicKeyTypeNode')) {
Expand Down
19 changes: 6 additions & 13 deletions packages/nodes-from-anchor/src/v00/AccountNode.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
import { camelCase } from '@codama/fragments/casing';
import {
AccountNode,
accountNode,
assertIsNode,
bytesTypeNode,
DiscriminatorNode,
fieldDiscriminatorNode,
fixedSizeTypeNode,
pdaLinkNode,
structFieldTypeNode,
StructTypeNode,
structTypeNode,
} from '@codama/nodes';

import { getAnchorAccountDiscriminatorV00 } from '../discriminators';
import { docsFromAnchor, fixedSizeBytesTypeNode } from '../utils';
import { IdlV00AccountDef } from './idl';
import { structTypeNodeFromAnchorV00 } from './typeNodes';

export function accountNodeFromAnchorV00(
idl: IdlV00AccountDef,
origin?: 'anchor' | 'shank',
): AccountNode<StructTypeNode> {
const idlName = idl.name ?? '';
const name = camelCase(idlName);
export function accountNodeFromAnchorV00(idl: IdlV00AccountDef, origin?: 'anchor' | 'shank'): AccountNode {
const name = idl.name ?? '';
const idlStruct = idl.type ?? { fields: [], kind: 'struct' };
let data = structTypeNodeFromAnchorV00(idlStruct);
assertIsNode(data, 'structTypeNode');
Expand All @@ -32,10 +25,10 @@ export function accountNodeFromAnchorV00(
let discriminators: DiscriminatorNode[] | undefined;
if (origin === 'anchor') {
const discriminator = structFieldTypeNode({
defaultValue: getAnchorAccountDiscriminatorV00(idlName),
defaultValue: getAnchorAccountDiscriminatorV00(name),
defaultValueStrategy: 'omitted',
identifier: 'discriminator',
type: fixedSizeTypeNode(bytesTypeNode(), 8),
type: fixedSizeBytesTypeNode(8),
});
data = structTypeNode([discriminator, ...(data.fields ?? [])]);
discriminators = [fieldDiscriminatorNode('discriminator')];
Expand All @@ -44,7 +37,7 @@ export function accountNodeFromAnchorV00(
return accountNode({
data,
discriminators,
docs: idl.docs ?? [],
docs: docsFromAnchor(idl.docs),
identifier: name,
pda: hasSeeds ? pdaLinkNode(name) : undefined,
size: idl.size,
Expand Down
3 changes: 2 additions & 1 deletion packages/nodes-from-anchor/src/v00/DefinedTypeNode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { DefinedTypeNode, definedTypeNode } from '@codama/nodes';

import { docsFromAnchor } from '../utils';
import { IdlV00TypeDef } from './idl';
import { typeNodeFromAnchorV00 } from './typeNodes';

export function definedTypeNodeFromAnchorV00(idl: Partial<IdlV00TypeDef>): DefinedTypeNode {
const name = idl.name ?? '';
const idlType = idl.type ?? { fields: [], kind: 'struct' };
const type = typeNodeFromAnchorV00(idlType);
return definedTypeNode({ docs: idl.docs, identifier: name, type });
return definedTypeNode({ docs: docsFromAnchor(idl.docs), identifier: name, type });
}
5 changes: 3 additions & 2 deletions packages/nodes-from-anchor/src/v00/ErrorNode.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ErrorNode, errorNode } from '@codama/nodes';

import { docsFromAnchor } from '../utils';
import { IdlV00ErrorCode } from './idl';

export function errorNodeFromAnchorV00(idl: Partial<IdlV00ErrorCode>): ErrorNode {
const name = idl.name ?? '';
const msg = idl.msg ?? '';
return errorNode({
code: idl.code ?? -1,
docs: idl.docs ?? [msg ? `${name}: ${msg}` : `${name}`],
message: msg,
docs: docsFromAnchor(idl.docs) ?? (msg ? `${name}: ${msg}` : name),
identifier: name,
message: msg,
});
}
15 changes: 6 additions & 9 deletions packages/nodes-from-anchor/src/v00/EventNode.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import { camelCase } from '@codama/fragments/casing';
import {
bytesTypeNode,
addTypeNodeTransforms,
constantDiscriminatorNode,
constantValueNode,
EventNode,
eventNode,
fixedSizeTypeNode,
hiddenPrefixTypeNode,
hiddenPrefixTransformNode,
} from '@codama/nodes';

import { getAnchorEventDiscriminatorV00 } from '../discriminators';
import { fixedSizeBytesTypeNode } from '../utils';
import { IdlV00Event } from './idl';
import { structTypeNodeFromAnchorV00 } from './typeNodes';

export function eventNodeFromAnchorV00(idl: IdlV00Event): EventNode {
const idlName = idl.name ?? '';
const name = camelCase(idlName);
const name = idl.name ?? '';
const data = structTypeNodeFromAnchorV00({ fields: idl.fields ?? [], kind: 'struct' });
const discriminator = getAnchorEventDiscriminatorV00(idlName);
const discriminatorConstant = constantValueNode(fixedSizeTypeNode(bytesTypeNode(), 8), discriminator);
const discriminatorConstant = constantValueNode(fixedSizeBytesTypeNode(8), getAnchorEventDiscriminatorV00(name));

return eventNode({
data: hiddenPrefixTypeNode(data, [discriminatorConstant]),
data: addTypeNodeTransforms(data, [hiddenPrefixTransformNode([discriminatorConstant])]),
discriminators: [constantDiscriminatorNode(discriminatorConstant)],
identifier: name,
});
Expand Down
9 changes: 7 additions & 2 deletions packages/nodes-from-anchor/src/v00/InstructionAccountNode.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { camelCase } from '@codama/fragments/casing';
import { InstructionAccountNode, instructionAccountNode } from '@codama/nodes';

import { docsFromAnchor } from '../utils';
import { IdlV00Account, IdlV00AccountItem } from './idl';

/**
* Whether flattening nested account groups would produce accounts whose
* identifiers collide under the casing-collision rule, in which case
* nested accounts are prefixed by their group identifiers.
*/
function hasDuplicateAccountNames(idl: IdlV00AccountItem[]): boolean {
const seenNames = new Set<string>();

Expand Down Expand Up @@ -44,9 +50,8 @@ export function instructionAccountNodesFromAnchorV00(

export function instructionAccountNodeFromAnchorV00(idl: IdlV00Account, prefix?: string): InstructionAccountNode {
const isOptional = idl.optional ?? idl.isOptional ?? false;
const desc = idl.desc ? [idl.desc] : undefined;
return instructionAccountNode({
docs: idl.docs ?? desc ?? [],
docs: docsFromAnchor(idl.docs) ?? (idl.desc || undefined),
identifier: prefix ? `${prefix}_${idl.name ?? ''}` : (idl.name ?? ''),
isOptional,
isSigner: idl.isOptionalSigner ? 'either' : (idl.isSigner ?? false),
Expand Down
12 changes: 0 additions & 12 deletions packages/nodes-from-anchor/src/v00/InstructionArgumentNode.ts

This file was deleted.

Loading
Loading