Skip to content
Open
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
110 changes: 109 additions & 1 deletion src/renderers/js/getRenderMapVisitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { format as formatCodeUsingPrettier } from '@prettier/sync';
import { ConfigureOptions } from 'nunjucks';
import { Options as PrettierOptions } from 'prettier';
import {
AccountNode,
FieldDiscriminatorNode,
getAllAccounts,
getAllDefinedTypes,
Expand Down Expand Up @@ -214,8 +215,26 @@ export function getRenderMapVisitor(
.add('programs/index.ts', render('programsIndex.njk', ctx))
.add('errors/index.ts', render('errorsIndex.njk', ctx));
}
const programsWithAccountDiscriminators = programsToExport
.filter((p) =>
p.accounts.some(
(a) => (a.discriminators ?? []).length > 0
)
)
.map((p) => camelCase(p.name));
if (accountsToExport.length > 0) {
map.add('accounts/index.ts', render('accountsIndex.njk', ctx));
map
.add(
'accounts/index.ts',
render('accountsIndex.njk', {
...ctx,
programsWithAccountDiscriminators,
})
)
.add(
'accounts/fetchHelpers.ts',
render('accountsFetchHelpers.njk')
);
}
if (instructionsToExport.length > 0) {
map.add(
Expand Down Expand Up @@ -283,6 +302,95 @@ export function getRenderMapVisitor(
program: node,
})
);
// Generate per-program account helpers with discriminator-based identification.
const accountsWithDisc = node.accounts.filter(
(a) => (a.discriminators ?? []).length > 0
);
if (accountsWithDisc.length > 0) {
const helperImports = new JavaScriptImportMap()
.add('umi', [
'assertAccountExists',
'Context',
'Pda',
'PublicKey',
'RpcAccount',
'RpcGetAccountsOptions',
'publicKey',
])
.addAlias('umi', 'publicKey', 'toPublicKey');
const resolvedAccounts = accountsWithDisc.map(
(account: AccountNode) => {
const conditions: string[] = [];
for (const disc of account.discriminators ?? []) {
if (isNode(disc, 'byteDiscriminatorNode')) {
conditions.push(
`accountDataMatches(data, new Uint8Array([${disc.bytes.join(', ')}]), ${disc.offset})`
);
} else if (isNode(disc, 'fieldDiscriminatorNode')) {
const field = account.data.fields.find(
(f) => f.name === disc.name
);
if (field && field.defaultValue) {
// Anchor-style u8 array discriminator — use raw bytes.
if (
isNode(field.type, 'arrayTypeNode') &&
isNode(field.type.item, 'numberTypeNode') &&
field.type.item.format === 'u8' &&
isNode(field.type.size, 'fixedSizeNode') &&
isNode(field.defaultValue, 'arrayValueNode') &&
field.defaultValue.items.every(
isNodeFilter('numberValueNode')
)
) {
const bytes = field.defaultValue.items.map(
(n) => (n as { number: number }).number
);
conditions.push(
`accountDataMatches(data, new Uint8Array([${bytes.join(', ')}]), ${disc.offset})`
);
} else {
const fieldManifest = visit(
field.type,
typeManifestVisitor
);
const fieldValue = visit(
field.defaultValue,
valueNodeVisitor
);
helperImports.mergeWith(
fieldManifest.serializerImports,
fieldValue.imports
);
conditions.push(
`accountDataMatches(data, ${fieldManifest.serializer}.serialize(${fieldValue.render}), ${disc.offset})`
);
}
}
} else if (isNode(disc, 'sizeDiscriminatorNode')) {
conditions.push(`data.length === ${disc.size}`);
}
}
return {
name: account.name,
pascalName: pascalCase(account.name),
camelName: camelCase(account.name),
condition: conditions.join(' && '),
};
}
);

renderMap.add(
`accounts/${camelCase(node.name)}Helpers.ts`,
render('accountsProgramHelpers.njk', {
imports: helperImports.toString(dependencyMap),
program: node,
programPascalName: pascalCaseName,
programCamelName: camelCase(node.name),
accounts: resolvedAccounts,
})
);
}

program = null;
return renderMap;
},
Expand Down
107 changes: 107 additions & 0 deletions src/renderers/js/templates/accountsFetchHelpers.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{% extends "layout.njk" %}

{% block main %}
import {
Account,
assertAccountExists,
Context,
Pda,
PublicKey,
RpcAccount,
RpcGetAccountsOptions,
publicKey as toPublicKey,
} from '@metaplex-foundation/umi';

/**
* Defines the input for a single account to be fetched and deserialized
* as part of a mixed-account batch fetch.
*
* @typeParam T - The deserialized account type.
*/
export type FetchAccountInput<T extends Account<any>> = {
publicKey: PublicKey | Pda;
deserialize: (rawAccount: RpcAccount) => T;
};

/** @internal */
type DeserializedAccounts<T extends FetchAccountInput<any>[]> = {
[K in keyof T]: T[K] extends FetchAccountInput<infer U> ? U : never;
};

/** @internal */
type MaybeDeserializedAccounts<T extends FetchAccountInput<any>[]> = {
[K in keyof T]: T[K] extends FetchAccountInput<infer U> ? U | null : never;
};

/**
* Fetches multiple accounts of potentially different types in a single RPC
* call and deserializes each one using its provided deserializer.
*
* This is useful when you need to fetch accounts of different types (possibly
* from different programs) in a single batch. The underlying RPC
* `getMultipleAccounts` call has a limit of 100 accounts per request; this
* helper does not perform client-side chunking, so callers must ensure the
* input array does not exceed that limit.
*
* All accounts must exist, otherwise an error is thrown. Use
* {@link safeFetchAllMixedAccounts} if some accounts may not exist.
*
* @example
* ```ts
* const [metadata, edition] = await fetchAllMixedAccounts(context, [
* { publicKey: metadataAddr, deserialize: deserializeMetadata },
* { publicKey: editionAddr, deserialize: deserializeEdition },
* ]);
* // metadata: Metadata, edition: Edition — fully typed!
* ```
*/
export async function fetchAllMixedAccounts<
T extends FetchAccountInput<any>[]
>(
context: Pick<Context, 'rpc'>,
inputs: [...T],
options?: RpcGetAccountsOptions
): Promise<DeserializedAccounts<T>> {
const publicKeys = inputs.map((input) =>
toPublicKey(input.publicKey, false)
);
const maybeAccounts = await context.rpc.getAccounts(publicKeys, options);
return maybeAccounts.map((maybeAccount, index) => {
assertAccountExists(maybeAccount);
return inputs[index].deserialize(maybeAccount);
}) as DeserializedAccounts<T>;
}

/**
* Fetches multiple accounts of potentially different types in a single RPC
* call and deserializes each one using its provided deserializer.
*
* Accounts that do not exist are returned as `null` at the corresponding
* position in the output tuple.
*
* @example
* ```ts
* const [metadata, edition] = await safeFetchAllMixedAccounts(context, [
* { publicKey: metadataAddr, deserialize: deserializeMetadata },
* { publicKey: editionAddr, deserialize: deserializeEdition },
* ]);
* // metadata: Metadata | null, edition: Edition | null
* ```
*/
export async function safeFetchAllMixedAccounts<
T extends FetchAccountInput<any>[]
>(
context: Pick<Context, 'rpc'>,
inputs: [...T],
options?: RpcGetAccountsOptions
): Promise<MaybeDeserializedAccounts<T>> {
const publicKeys = inputs.map((input) =>
toPublicKey(input.publicKey, false)
);
const maybeAccounts = await context.rpc.getAccounts(publicKeys, options);
return maybeAccounts.map((maybeAccount, index) => {
if (!maybeAccount.exists) return null;
return inputs[index].deserialize(maybeAccount);
}) as MaybeDeserializedAccounts<T>;
}
{% endblock %}
4 changes: 4 additions & 0 deletions src/renderers/js/templates/accountsIndex.njk
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
{% extends "layout.njk" %}

{% block main %}
export * from './fetchHelpers';
{% for account in accountsToExport | sort(false, false, 'name') %}
export * from './{{ account.name | camelCase }}';
{% else %}
export default {};
{% endfor %}
{% for programName in programsWithAccountDiscriminators %}
export * from './{{ programName }}Helpers';
{% endfor %}
{% endblock %}
69 changes: 69 additions & 0 deletions src/renderers/js/templates/accountsProgramHelpers.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{% extends "layout.njk" %}

{% block main %}
{{ imports }}
{% for account in accounts %}
import { {{ account.pascalName }}, deserialize{{ account.pascalName }} } from './{{ account.camelName }}';
{% endfor %}

export type {{ programPascalName }}Account =
{% for account in accounts %}
| {{ account.pascalName }}
{% endfor %};

function accountDataMatches(
data: Uint8Array,
expected: Uint8Array,
offset: number
): boolean {
if (data.length < offset + expected.length) return false;
for (let i = 0; i < expected.length; i++) {
if (data[offset + i] !== expected[i]) return false;
}
return true;
}

export function deserialize{{ programPascalName }}Account(
rawAccount: RpcAccount
): {{ programPascalName }}Account {
const data = rawAccount.data;
{% for account in accounts %}
if ({{ account.condition }}) {
return deserialize{{ account.pascalName }}(rawAccount);
}
{% endfor %}
throw new Error(
'The provided account could not be identified as a {{ program.name }} account.'
);
}

export async function fetchAll{{ programPascalName }}Accounts(
context: Pick<Context, 'rpc'>,
publicKeys: Array<PublicKey | Pda>,
options?: RpcGetAccountsOptions,
): Promise<{{ programPascalName }}Account[]> {
const maybeAccounts = await context.rpc.getAccounts(
publicKeys.map((key) => toPublicKey(key, false)),
options,
);
return maybeAccounts.map((maybeAccount) => {
assertAccountExists(maybeAccount);
return deserialize{{ programPascalName }}Account(maybeAccount);
});
}

export async function safeFetchAll{{ programPascalName }}Accounts(
context: Pick<Context, 'rpc'>,
publicKeys: Array<PublicKey | Pda>,
options?: RpcGetAccountsOptions,
): Promise<({{ programPascalName }}Account | null)[]> {
const maybeAccounts = await context.rpc.getAccounts(
publicKeys.map((key) => toPublicKey(key, false)),
options,
);
return maybeAccounts.map((maybeAccount) => {
if (!maybeAccount.exists) return null;
return deserialize{{ programPascalName }}Account(maybeAccount);
});
}
{% endblock %}
4 changes: 2 additions & 2 deletions src/visitors/getUniqueHashStringVisitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function getUniqueHashStringVisitor(
): Visitor<string> {
const removeDocs = options.removeDocs ?? false;
if (!removeDocs) {
return staticVisitor((node) => stringify(node));
return staticVisitor((node) => stringify(node) ?? '');
}
return mapVisitor(removeDocsVisitor(), (node) => stringify(node));
return mapVisitor(removeDocsVisitor(), (node) => stringify(node) ?? '');
}
Loading