Skip to content

Commit

Permalink
Add support for remaining account signers in JS experimental (#183)
Browse files Browse the repository at this point in the history
  • Loading branch information
lorisleiva authored Mar 21, 2024
1 parent 5929361 commit 0873088
Show file tree
Hide file tree
Showing 15 changed files with 313 additions and 143 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-camels-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@metaplex-foundation/kinobi": patch
---

Add support for remaining account signers in JS experimental
9 changes: 6 additions & 3 deletions src/nodes/InstructionRemainingAccountsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ export type InstructionRemainingAccountsNode = {
readonly value: ArgumentValueNode | ResolverValueNode;

// Data.
readonly isWritable?: boolean;
readonly isOptional?: boolean;
readonly isSigner?: boolean | 'either';
readonly isWritable?: boolean;
};

export type InstructionRemainingAccountsNodeInput = Omit<
Expand All @@ -19,14 +20,16 @@ export type InstructionRemainingAccountsNodeInput = Omit<
export function instructionRemainingAccountsNode(
value: ArgumentValueNode | ResolverValueNode,
options: {
isWritable?: boolean;
isOptional?: boolean;
isSigner?: boolean | 'either';
isWritable?: boolean;
} = {}
): InstructionRemainingAccountsNode {
return {
kind: 'instructionRemainingAccountsNode',
value,
isWritable: options.isWritable,
isOptional: options.isOptional,
isSigner: options.isSigner,
isWritable: options.isWritable,
};
}
24 changes: 8 additions & 16 deletions src/renderers/js-experimental/fragments/instructionInputType.njk
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
{% import "templates/macros.njk" as macros %}

export type {{ instructionInputType }}
{%- if accounts.length > 0 -%}
{%- if instruction.accounts.length > 0 -%}
<
{%- for account in accounts -%}
{{ account.typeParam }} extends string = string,
{%- for account in instruction.accounts -%}
TAccount{{ account.name | pascalCase }} extends string = string,
{% endfor %}
>
{% endif %}
= {
{% for account in accounts %}
{% if account.docs.length > 0 %}
{{ macros.docblock(account.docs) }}
{% endif %}
{{ account.name | camelCase }}{{ account.optionalSign }}: {{ account.type }},
{% endfor %}
{% for dataArg in dataArgs %}
{{ dataArg.renamedName | camelCase }}{{ dataArg.optionalSign }}: {{ dataArgsType }}["{{ dataArg.name | camelCase }}"],
{% endfor %}
{% for extraArg in extraArgs %}
{{ extraArg.renamedName | camelCase }}{{ extraArg.optionalSign }}: {{ extraArgsType }}["{{ extraArg.name | camelCase }}"],
{% endfor %}
= {{ customDataArgumentsFragment }} {
{{ accountsFragment -}}
{{ dataArgumentsFragment -}}
{{ extraArgumentsFragment -}}
{{ remainingAccountsFragment }}
}
263 changes: 179 additions & 84 deletions src/renderers/js-experimental/fragments/instructionInputType.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import {
InstructionArgumentNode,
InstructionNode,
ProgramNode,
getAllInstructionArguments,
isNode,
} from '../../../nodes';
import { pascalCase } from '../../../shared';
import { camelCase, jsDocblock, pascalCase } from '../../../shared';
import {
ResolvedInstructionAccount,
ResolvedInstructionArgument,
ResolvedInstructionInput,
} from '../../../visitors';
import { ImportMap } from '../ImportMap';
import { TypeManifest } from '../TypeManifest';
import { isAsyncDefaultValue } from '../asyncHelpers';
import type { GlobalFragmentScope } from '../getRenderMapVisitor';
import { Fragment, fragment, fragmentFromTemplate } from './common';
import {
Fragment,
fragment,
fragmentFromTemplate,
mergeFragments,
} from './common';

export function getInstructionInputTypeFragment(
scope: Pick<
Expand All @@ -25,26 +29,53 @@ export function getInstructionInputTypeFragment(
resolvedInputs: ResolvedInstructionInput[];
renamedArgs: Map<string, string>;
dataArgsManifest: TypeManifest;
programNode: ProgramNode;
useAsync: boolean;
}
): Fragment {
const {
instructionNode,
resolvedInputs,
renamedArgs,
dataArgsManifest,
programNode,
asyncResolvers,
useAsync,
nameApi,
customInstructionData,
} = scope;

// Accounts.
const accountImports = new ImportMap();
const accounts = instructionNode.accounts.map((account) => {
const typeParam = `TAccount${pascalCase(account.name)}`;
const { instructionNode, useAsync, nameApi } = scope;

const instructionInputType = useAsync
? nameApi.instructionAsyncInputType(instructionNode.name)
: nameApi.instructionSyncInputType(instructionNode.name);
const accountsFragment = getAccountsFragment(scope);
const [dataArgumentsFragment, customDataArgumentsFragment] =
getDataArgumentsFragments(scope);
const extraArgumentsFragment = getExtraArgumentsFragment(scope);
const remainingAccountsFragment =
getRemainingAccountsFragment(instructionNode);

return fragmentFromTemplate('instructionInputType.njk', {
instruction: instructionNode,
instructionInputType,
accountsFragment,
dataArgumentsFragment,
customDataArgumentsFragment,
extraArgumentsFragment,
remainingAccountsFragment,
})
.mergeImportsWith(
accountsFragment,
dataArgumentsFragment,
customDataArgumentsFragment,
extraArgumentsFragment,
remainingAccountsFragment
)
.addImports('solanaAddresses', ['Address']);
}

function getAccountsFragment(
scope: Pick<
GlobalFragmentScope,
'nameApi' | 'asyncResolvers' | 'customInstructionData'
> & {
instructionNode: InstructionNode;
resolvedInputs: ResolvedInstructionInput[];
useAsync: boolean;
}
): Fragment {
const { instructionNode, resolvedInputs, useAsync, asyncResolvers } = scope;

const fragments = instructionNode.accounts.map((account) => {
const resolvedAccount = resolvedInputs.find(
(input) =>
input.kind === 'instructionAccountNode' && input.name === account.name
Expand All @@ -57,73 +88,20 @@ export function getInstructionInputTypeFragment(
]) &&
(useAsync ||
!isAsyncDefaultValue(resolvedAccount.defaultValue, asyncResolvers));
const type = getAccountType(resolvedAccount);
accountImports.mergeWith(type);
return {
...resolvedAccount,
typeParam,
optionalSign: hasDefaultValue || resolvedAccount.isOptional ? '?' : '',
type: type.render,
};
});

// Arg link imports.
const customData = customInstructionData.get(instructionNode.name);
const argLinkImports = new ImportMap();
if (customData) {
argLinkImports.mergeWith(dataArgsManifest.looseType);
}

// Arguments.
const resolveArg = (arg: InstructionArgumentNode) => {
const resolvedArg = resolvedInputs.find(
(input) =>
isNode(input, 'instructionArgumentNode') && input.name === arg.name
) as ResolvedInstructionArgument | undefined;
if (arg.defaultValue && arg.defaultValueStrategy === 'omitted') return [];
const renamedName = renamedArgs.get(arg.name) ?? arg.name;
const docblock = account.docs.length > 0 ? jsDocblock(account.docs) : '';
const optionalSign =
arg.defaultValue || resolvedArg?.defaultValue ? '?' : '';
return [
{
...arg,
...resolvedArg,
renamedName,
optionalSign,
},
];
};
const instructionDataName = nameApi.instructionDataType(instructionNode.name);
const instructionExtraName = nameApi.instructionExtraType(
instructionNode.name
);
const dataArgsType = customData
? nameApi.dataArgsType(customData.importAs)
: nameApi.dataArgsType(instructionDataName);
const dataArgs = customData
? []
: instructionNode.arguments.flatMap(resolveArg);
const extraArgsType = nameApi.dataArgsType(instructionExtraName);
const extraArgs = (instructionNode.extraArguments ?? []).flatMap(resolveArg);
const instructionInputType = useAsync
? nameApi.instructionAsyncInputType(instructionNode.name)
: nameApi.instructionSyncInputType(instructionNode.name);
hasDefaultValue || resolvedAccount.isOptional ? '?' : '';
return getAccountTypeFragment(resolvedAccount).mapRender(
(r) => `${docblock}${camelCase(account.name)}${optionalSign}: ${r};`
);
});

return fragmentFromTemplate('instructionInputType.njk', {
instruction: instructionNode,
program: programNode,
instructionInputType,
accounts,
dataArgs,
dataArgsType,
extraArgs,
extraArgsType,
})
.mergeImportsWith(accountImports, argLinkImports)
.addImports('solanaAddresses', ['Address']);
return mergeFragments(fragments, (r) => r.join('\n'));
}

function getAccountType(account: ResolvedInstructionAccount): Fragment {
function getAccountTypeFragment(
account: Pick<ResolvedInstructionAccount, 'name' | 'isPda' | 'isSigner'>
): Fragment {
const typeParam = `TAccount${pascalCase(account.name)}`;

if (account.isPda && account.isSigner === false) {
Expand Down Expand Up @@ -158,3 +136,120 @@ function getAccountType(account: ResolvedInstructionAccount): Fragment {
'Address',
]);
}

function getDataArgumentsFragments(
scope: Pick<GlobalFragmentScope, 'nameApi' | 'customInstructionData'> & {
instructionNode: InstructionNode;
resolvedInputs: ResolvedInstructionInput[];
renamedArgs: Map<string, string>;
dataArgsManifest: TypeManifest;
}
): [Fragment, Fragment] {
const { instructionNode, nameApi } = scope;

const customData = scope.customInstructionData.get(instructionNode.name);
if (customData) {
return [
fragment(''),
fragment(nameApi.dataArgsType(customData.importAs))
.mergeImportsWith(scope.dataArgsManifest.looseType)
.mapRender((r) => `${r} & `),
];
}

const instructionDataName = nameApi.instructionDataType(instructionNode.name);
const dataArgsType = nameApi.dataArgsType(instructionDataName);

const fragments = instructionNode.arguments.flatMap((arg) => {
const argFragment = getArgumentFragment(
arg,
fragment(dataArgsType),
scope.resolvedInputs,
scope.renamedArgs
);
return argFragment ? [argFragment] : [];
});

return [mergeFragments(fragments, (r) => r.join('\n')), fragment('')];
}

function getExtraArgumentsFragment(
scope: Pick<GlobalFragmentScope, 'nameApi'> & {
instructionNode: InstructionNode;
resolvedInputs: ResolvedInstructionInput[];
renamedArgs: Map<string, string>;
}
): Fragment {
const { instructionNode, nameApi } = scope;
const instructionExtraName = nameApi.instructionExtraType(
instructionNode.name
);
const extraArgsType = nameApi.dataArgsType(instructionExtraName);

const fragments = (instructionNode.extraArguments ?? []).flatMap((arg) => {
const argFragment = getArgumentFragment(
arg,
fragment(extraArgsType),
scope.resolvedInputs,
scope.renamedArgs
);
return argFragment ? [argFragment] : [];
});

return mergeFragments(fragments, (r) => r.join('\n'));
}

function getArgumentFragment(
arg: InstructionArgumentNode,
argsType: Fragment,
resolvedInputs: ResolvedInstructionInput[],
renamedArgs: Map<string, string>
): Fragment | null {
const resolvedArg = resolvedInputs.find(
(input) =>
isNode(input, 'instructionArgumentNode') && input.name === arg.name
) as ResolvedInstructionArgument | undefined;
if (arg.defaultValue && arg.defaultValueStrategy === 'omitted') return null;
const renamedName = renamedArgs.get(arg.name) ?? arg.name;
const optionalSign = arg.defaultValue || resolvedArg?.defaultValue ? '?' : '';
return argsType.mapRender(
(r) =>
`${camelCase(renamedName)}${optionalSign}: ${r}["${camelCase(arg.name)}"];`
);
}

function getRemainingAccountsFragment(
instructionNode: InstructionNode
): Fragment {
const fragments = (instructionNode.remainingAccounts ?? []).flatMap(
(remainingAccountsNode) => {
if (isNode(remainingAccountsNode.value, 'resolverValueNode')) return [];

const { name } = remainingAccountsNode.value;
const allArguments = getAllInstructionArguments(instructionNode);
const argumentExists = allArguments.some((arg) => arg.name === name);
if (argumentExists) return [];

const isSigner = remainingAccountsNode.isSigner ?? false;
const optionalSign = remainingAccountsNode.isOptional ?? false ? '?' : '';
const signerFragment = fragment(`TransactionSigner`).addImports(
'solanaSigners',
['TransactionSigner']
);
const addressFragment = fragment(`Address`).addImports(
'solanaAddresses',
['Address']
);
return (() => {
if (isSigner === 'either') {
return mergeFragments([signerFragment, addressFragment], (r) =>
r.join(' | ')
);
}
return isSigner ? signerFragment : addressFragment;
})().mapRender((r) => `${camelCase(name)}${optionalSign}: Array<${r}>;`);
}
);

return mergeFragments(fragments, (r) => r.join('\n'));
}
Loading

0 comments on commit 0873088

Please sign in to comment.