Skip to content

Commit

Permalink
Feat: programId replacement style optional accounts (#94)
Browse files Browse the repository at this point in the history
* fix prettier command

* optional support

* fix unit test

* remove accidental code addition

* use explicit style instead of coercion

* chore: rename defaultOptionalsToProgramId to defaultOptionalAccounts to match shank

- also put the relevant test into `.only` mode

* fix stuff a bit before refactor

* refactored optional stuff

* added optional integration test

* made renderAccountMetaArray a bit more generic

* Revert "made renderAccountMetaArray a bit more generic"

This reverts commit 884eedf.

That last commit made things more complex and harder to understand. The
previous version is much cleaner.

* chore: first step at sorting things out and renaming for clarity

- separated utility functions that are used in different places and
  don't access the InstruciontRenderer instance
- separated account key rendering into sections depending on optional
  account strategy used

* chore: splitting methods for clarity

* chore: prep isOptional adaption

* test: adding integration test covering both optional account strategies

* chore: include info about optional accounts in instruction doc

Co-authored-by: Thorsten Lorenz <[email protected]>
  • Loading branch information
stegaBOB and thlorenz authored Oct 18, 2022
1 parent 38ca6e6 commit d099dd9
Show file tree
Hide file tree
Showing 9 changed files with 515 additions and 47 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test:anchor:basic3": "cd ./test/anchor-examples/basic-3 && yarn test",
"test:anchor:basic4": "cd ./test/anchor-examples/basic-4 && yarn test",
"lint": "prettier -c ./src/",
"lint:fix": "prettier --format ./src",
"lint:fix": "prettier --write ./src",
"doc": "rimraf ./docs && typedoc",
"doc:update": "./sh/update-docs",
"doctoc": "doctoc README.md"
Expand Down
209 changes: 165 additions & 44 deletions src/render-instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class InstructionRenderer {
readonly accountsTypename: string
readonly instructionDiscriminatorName: string
readonly structArgName: string
private readonly defaultOptionalAccounts: boolean
private readonly instructionDiscriminator: InstructionDiscriminator
private readonly programIdPubkey: string

Expand Down Expand Up @@ -65,6 +66,7 @@ class InstructionRenderer {
typeMapper
)
this.programIdPubkey = `new ${SOLANA_WEB3_EXPORT_NAME}.PublicKey('${this.programId}')`
this.defaultOptionalAccounts = ix.defaultOptionalAccounts ?? false
}

// -----------------
Expand Down Expand Up @@ -122,7 +124,7 @@ ${typeMapperImports.join('\n')}`.trim()
if (isAccountsCollection(acc)) {
for (const ac of acc.accounts) {
// Make collection items easy to identify and avoid name clashes
ac.name = this.deriveCollectionAccountsName(ac.name, acc.name)
ac.name = deriveCollectionAccountsName(ac.name, acc.name)
const knownPubkey = resolveKnownPubkey(ac.name)
const optional = ac.optional ?? false
if (knownPubkey == null) {
Expand All @@ -144,28 +146,28 @@ ${typeMapperImports.join('\n')}`.trim()
return processedAccountsKey
}

private deriveCollectionAccountsName(
accountName: string,
collectionName: string
) {
const camelAccount = accountName
.charAt(0)
.toUpperCase()
.concat(accountName.slice(1))

return `${collectionName}Item${camelAccount}`
}
// -----------------
// AccountKeys
// -----------------

/*
* Main entry to render account metadata for provided account keys.
* The `defaultOptionalAccounts` strategy determines how optional accounts
* are rendered.
*
* a) If the defaultOptionalAccounts strategy is set all accounts will be
* added to the accounts array, but default to the program id when they weren't
* provided by the user.
*
* b) If the defaultOptionalAccounts strategy is not enabled optional accounts
* that are not provided will be omitted from the accounts array.
*
* @private
*/
private renderIxAccountKeys(processedKeys: ProcessedAccountKey[]) {
const requireds = processedKeys.filter((x) => !x.optional)
const optionals = processedKeys.filter((x, idx) => {
if (!x.optional) return false
assert(
idx >= requireds.length,
`All optional accounts need to follow required accounts, ${x.name} is not`
)
return true
})
const fixedAccountKeys = this.defaultOptionalAccounts
? this.renderAccountKeysDefaultingOptionals(processedKeys)
: this.renderAccountKeysNotDefaultingOptionals(processedKeys)

const anchorRemainingAccounts =
this.renderAnchorRemainingAccounts && processedKeys.length > 0
Expand All @@ -178,23 +180,26 @@ ${typeMapperImports.join('\n')}`.trim()
`
: ''

const requiredKeys = requireds
.map(({ name, isMut, isSigner, knownPubkey }) => {
const pubkey =
knownPubkey == null
? `accounts.${name}`
: `accounts.${name} ?? ${renderKnownPubkeyAccess(
knownPubkey,
this.programIdPubkey
)}`
return `{
pubkey: ${pubkey},
isWritable: ${isMut.toString()},
isSigner: ${isSigner.toString()},
}`
})
.join(',\n ')
return `${fixedAccountKeys}\n${anchorRemainingAccounts}\n`
}

// -----------------
// AccountKeys: with strategy to not defaultOptionalAccounts
// -----------------
private renderAccountKeysNotDefaultingOptionals(
processedKeys: ProcessedAccountKey[]
) {
const requireds = processedKeys.filter((x) => !x.optional)
const optionals = processedKeys.filter((x, idx) => {
if (!x.optional) return false
assert(
idx >= requireds.length,
`All optional accounts need to follow required accounts, ${x.name} is not`
)
return true
})

const requiredKeys = this.renderAccountKeysRequired(requireds)
const optionalKeys =
optionals.length > 0
? optionals
Expand All @@ -210,24 +215,63 @@ ${typeMapperImports.join('\n')}`.trim()
.map((x) => `\\'accounts.${x.name}\\'`)
.join(', ')} need(s) to be provided as well.') }`
: ''
const pubkey = `accounts.${name}`
const accountMeta = renderAccountMeta(
pubkey,
isMut.toString(),
isSigner.toString()
)
// NOTE: we purposely don't add the default resolution here since the intent is to
// only pass that account when it is provided
return `
if (accounts.${name} != null) {
${checkRequireds}
keys.push({
pubkey: accounts.${name},
isWritable: ${isMut.toString()},
isSigner: ${isSigner.toString()},
})
keys.push(${accountMeta})
}`
})
.join('\n') + '\n'
: ''

return `[\n ${requiredKeys}\n ]\n${optionalKeys}\n${anchorRemainingAccounts}\n`
return `${requiredKeys}\n${optionalKeys}`
}

private renderAccountKeysRequired(processedKeys: ProcessedAccountKey[]) {
const metaElements = processedKeys
.map((processedKey) =>
renderRequiredAccountMeta(processedKey, this.programIdPubkey)
)
.join(',\n ')
return `[\n ${metaElements}\n ]`
}

// -----------------
// AccountKeys: with strategy to defaultOptionalAccounts
// -----------------

/*
* This renders optional accounts when the defaultOptionalAccounts strategy is
* enabled.
* This means that all accounts will be added to the accounts array, but default
* to the program id when they weren't provided by the user.
* @category private
*/
private renderAccountKeysDefaultingOptionals(
processedKeys: ProcessedAccountKey[]
) {
const metaElements = processedKeys
.map((processedKey) => {
return processedKey.optional
? renderOptionalAccountMetaDefaultingToProgramId(processedKey)
: renderRequiredAccountMeta(processedKey, this.programIdPubkey)
})
.join(',\n ')
return `[\n ${metaElements}\n ]`
}

// -----------------
// AccountsType
// -----------------

private renderAccountsType(processedKeys: ProcessedAccountKey[]) {
if (processedKeys.length === 0) return ''
const web3 = SOLANA_WEB3_EXPORT_NAME
Expand Down Expand Up @@ -357,6 +401,10 @@ ${struct} `.trim()
]
const programIdArg = `${comma}programId = ${this.programIdPubkey}`

const optionalAccountsComment = optionalAccountsStrategyDocComment(
this.defaultOptionalAccounts,
processedKeys.some((x) => x.optional)
)
return `${imports}
${enums}
Expand All @@ -367,7 +415,7 @@ ${accountsType}
/**
* Creates a _${this.upperCamelIxName}_ instruction.
${accountsParamDoc}${createInstructionArgsComment}
${optionalAccountsComment}${accountsParamDoc}${createInstructionArgsComment}
* @category Instructions
* @category ${this.upperCamelIxName}
* @category generated
Expand Down Expand Up @@ -416,3 +464,76 @@ export function renderInstruction(
)
return renderer.render()
}

// -----------------
// Utility Functions
// -----------------

function renderAccountMeta(
pubkey: string,
isWritable: string,
isSigner: string
): string {
return `{
pubkey: ${pubkey},
isWritable: ${isWritable},
isSigner: ${isSigner},
}`
}

function deriveCollectionAccountsName(
accountName: string,
collectionName: string
) {
const camelAccount = accountName
.charAt(0)
.toUpperCase()
.concat(accountName.slice(1))

return `${collectionName}Item${camelAccount}`
}

function renderOptionalAccountMetaDefaultingToProgramId(
processedKey: ProcessedAccountKey
): string {
const { name, isMut, isSigner } = processedKey
const pubkey = `accounts.${name} ?? programId`
const mut = isMut ? `accounts.${name} != null` : 'false'
const signer = isSigner ? `accounts.${name} != null` : 'false'
return renderAccountMeta(pubkey, mut, signer)
}

function renderRequiredAccountMeta(
processedKey: ProcessedAccountKey,
programIdPubkey: string
): string {
const { name, isMut, isSigner, knownPubkey } = processedKey
const pubkey =
knownPubkey == null
? `accounts.${name}`
: `accounts.${name} ?? ${renderKnownPubkeyAccess(
knownPubkey,
programIdPubkey
)}`
return renderAccountMeta(pubkey, isMut.toString(), isSigner.toString())
}

function optionalAccountsStrategyDocComment(
defaultOptionalAccounts: boolean,
someAccountIsOptional: boolean
) {
if (!someAccountIsOptional) return ''

if (defaultOptionalAccounts) {
return ` *
* Optional accounts that are not provided default to the program ID since
* this was indicated in the IDL from which this instruction was generated.
`
}
return ` *
* Optional accounts that are not provided will be omitted from the accounts
* array passed with the instruction.
* An optional account that is set cannot follow an optional account that is unset.
* Otherwise an Error is raised.
`
}
7 changes: 7 additions & 0 deletions src/transform-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export function adaptIdl(idl: Idl) {
}
}

// -----------------
// Types
// -----------------
function transformDefinition(def: IdlDefinedTypeDefinition) {
const ty = def.type
if (isFieldsType(ty)) {
Expand Down Expand Up @@ -97,3 +100,7 @@ function transformFields(ty: IdlFieldsType) {
}
return ty
}

// -----------------
// Instruction
// -----------------
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export type IdlInstructionArg = {

export type IdlInstruction = {
name: string
defaultOptionalAccounts?: boolean
accounts: IdlInstructionAccount[] | IdlAccountsCollection[]
args: IdlInstructionArg[]
}
Expand Down
21 changes: 19 additions & 2 deletions test/integration/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Idl } from '../../src/solita'
import test from 'tape'
import { checkIdl } from '../utils/check-idl'

import anchorOptional from './fixtures/anchor-optional.json'
import auctionHouseAnchor24Json from './fixtures/auction_house-1.1.4-anchor-0.24.2.json'
import auctionHouseJson from './fixtures/auction_house.json'
import fanoutJson from './fixtures/fanout.json'
Expand All @@ -15,6 +16,21 @@ import shankTicTacToeJson from './fixtures/shank_tictactoe.json'
import shankTokenMetadataJson from './fixtures/shank_token_metadata.json'
import shankTokenVaultJson from './fixtures/shank_token_vault.json'


// -----------------
// anchor-optional
// -----------------
{
const label = 'anchor-optional'

test('renders type correct SDK for ' + label, async (t) => {
const idl = anchorOptional as Idl
idl.instructions.map(ix => {
ix.defaultOptionalAccounts = true
})
await checkIdl(t, idl, label)
})
}
// -----------------
// ah-1.1.4-anchor-0.24.2
// -----------------
Expand Down Expand Up @@ -138,13 +154,14 @@ import shankTokenVaultJson from './fixtures/shank_token_vault.json'
const code = await fs.readFile(fullPath, 'utf8')
t.match(code, rx, `Code inside ${relPath} matches ${rx.toString()}`)
}

await verifyCodeMatches(
'types/Data.ts',
/FixableBeetArgsStruct<\s*Data\s*>/
/FixableBeetArgsStruct<\s*Data\s*>/,
)
await verifyCodeMatches(
'instructions/CreateMetadataAccount.ts',
/FixableBeetArgsStruct<\s*CreateMetadataAccountInstructionArgs/
/FixableBeetArgsStruct<\s*CreateMetadataAccountInstructionArgs/,
)
})
}
Expand Down
12 changes: 12 additions & 0 deletions test/integration/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import mixedEnumsJson from './fixtures/feat-mixed-enums.json'
import tuplesJson from './fixtures/feat-tuples.json'
import setsJson from './fixtures/feat-sets.json'
import collectionAccountsJson from './fixtures/feat-collection-accounts.json'
import optionalAccountsJson from './fixtures/feat-optional-accounts.json'

// -----------------
// feat-account-padding
Expand Down Expand Up @@ -147,3 +148,14 @@ import collectionAccountsJson from './fixtures/feat-collection-accounts.json'
await checkIdl(t, idl, label)
})
}
// -----------------
// feat-optional-accounts
// -----------------
{
const label = 'feat-optional-accounts'

test('renders type correct SDK for ' + label, async (t) => {
const idl = optionalAccountsJson as Idl
await checkIdl(t, idl, label)
})
}
Loading

0 comments on commit d099dd9

Please sign in to comment.