From e840018e4369ac9990b693f7a9bc6ed07f06d56b Mon Sep 17 00:00:00 2001 From: Nhan Phan Date: Thu, 2 May 2024 22:18:01 -0700 Subject: [PATCH] oracle test wip (#90) --- clients/js/package.json | 1 + clients/js/pnpm-lock.yaml | 13 + .../instructions/burnCollectionV1.ts | 2 +- .../js/src/generated/instructions/burnV1.ts | 2 +- clients/js/src/helpers/lifecycle.ts | 347 ++++- clients/js/src/hooked/pluginRegistryV1Data.ts | 7 +- clients/js/src/plugins/externalPlugins.ts | 10 +- clients/js/src/plugins/extraAccount.ts | 172 ++- clients/js/src/plugins/index.ts | 1 + clients/js/src/plugins/lifecycleChecks.ts | 28 +- clients/js/src/plugins/oracle.ts | 38 + clients/js/test/_setupRaw.ts | 14 + clients/js/test/burn.test.ts | 47 +- .../js/test/externalPlugins/oracle.test.ts | 1297 +++++++++++++++++ clients/js/test/helps/lifecycle.test.ts | 603 +++++++- clients/js/test/info.test.ts | 2 +- .../test/plugins/asset/permanentBurn.test.ts | 7 +- .../instructions/burn_collection_v1.rs | 8 +- .../src/generated/instructions/burn_v1.rs | 8 +- clients/rust/tests/add_external_plugins.rs | 2 +- .../tests/create_with_external_plugins.rs | 2 +- clients/rust/tests/remove_external_plugins.rs | 2 +- clients/rust/tests/update_external_plugins.rs | 2 +- configs/scripts/program/build.sh | 4 + .../scripts/program/dump_oracle_example.sh | 84 ++ configs/validator.cjs | 5 + idls/mpl_core.json | 4 +- programs/mpl-core/src/instruction.rs | 4 +- programs/mpl-core/src/plugins/lifecycle.rs | 4 + programs/mpl-core/src/plugins/utils.rs | 4 +- 30 files changed, 2527 insertions(+), 197 deletions(-) create mode 100644 clients/js/test/externalPlugins/oracle.test.ts create mode 100755 configs/scripts/program/dump_oracle_example.sh diff --git a/clients/js/package.json b/clients/js/package.json index 8a69fc22..35985f23 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@metaplex-foundation/mpl-toolbox": "^0.8.0", "@ava/typescript": "^3.0.1", + "@metaplex-foundation/mpl-core-oracle-example": "^0.0.1", "@metaplex-foundation/umi": "^0.8.10", "@metaplex-foundation/umi-bundle-tests": "^0.8.10", "@solana/web3.js": "^1.73.0", diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml index 451c5249..df43c491 100644 --- a/clients/js/pnpm-lock.yaml +++ b/clients/js/pnpm-lock.yaml @@ -13,6 +13,9 @@ devDependencies: '@ava/typescript': specifier: ^3.0.1 version: 3.0.1 + '@metaplex-foundation/mpl-core-oracle-example': + specifier: ^0.0.1 + version: 0.0.1(@metaplex-foundation/umi@0.8.10)(@noble/hashes@1.3.1) '@metaplex-foundation/mpl-toolbox': specifier: ^0.8.0 version: 0.8.0(@metaplex-foundation/umi@0.8.10) @@ -1861,6 +1864,16 @@ packages: - supports-color dev: true + /@metaplex-foundation/mpl-core-oracle-example@0.0.1(@metaplex-foundation/umi@0.8.10)(@noble/hashes@1.3.1): + resolution: {integrity: sha512-z2432DCY6eqPSUbMAqHNpZoN2keDu9kLo5tr0d/Kx7GbZ3LHIg7tiyber9gtIUJAJx3c0k3DX/awoA9Fd5kbdA==} + peerDependencies: + '@metaplex-foundation/umi': '>=0.8.2 < 1' + '@noble/hashes': ^1.3.1 + dependencies: + '@metaplex-foundation/umi': 0.8.10 + '@noble/hashes': 1.3.1 + dev: true + /@metaplex-foundation/mpl-toolbox@0.8.0(@metaplex-foundation/umi@0.8.10): resolution: {integrity: sha512-SK1VUPU4hCaL3sozgtoVjjbZxqx2gWiRt0YTFbwEt5LAHWOlCb7J7rcrrA5XwymX4iV2bIWygYs0yz7hYyx2rg==} peerDependencies: diff --git a/clients/js/src/generated/instructions/burnCollectionV1.ts b/clients/js/src/generated/instructions/burnCollectionV1.ts index 1aeff52f..94be0b72 100644 --- a/clients/js/src/generated/instructions/burnCollectionV1.ts +++ b/clients/js/src/generated/instructions/burnCollectionV1.ts @@ -108,7 +108,7 @@ export function burnCollectionV1( }, authority: { index: 2, - isWritable: false as boolean, + isWritable: true as boolean, value: input.authority ?? null, }, logWrapper: { diff --git a/clients/js/src/generated/instructions/burnV1.ts b/clients/js/src/generated/instructions/burnV1.ts index f4c7bec0..32cc9490 100644 --- a/clients/js/src/generated/instructions/burnV1.ts +++ b/clients/js/src/generated/instructions/burnV1.ts @@ -114,7 +114,7 @@ export function burnV1( }, authority: { index: 3, - isWritable: false as boolean, + isWritable: true as boolean, value: input.authority ?? null, }, systemProgram: { diff --git a/clients/js/src/helpers/lifecycle.ts b/clients/js/src/helpers/lifecycle.ts index 3c08224d..f5dc93dd 100644 --- a/clients/js/src/helpers/lifecycle.ts +++ b/clients/js/src/helpers/lifecycle.ts @@ -1,12 +1,30 @@ -import { PublicKey } from '@metaplex-foundation/umi'; -import { AssetV1, CollectionV1, PluginType } from '../generated'; +import { Context, PublicKey } from '@metaplex-foundation/umi'; +import { + AssetV1, + CollectionV1, + ExternalValidationResult, + PluginType, +} from '../generated'; import { deriveAssetPlugins, isFrozen } from './state'; import { checkPluginAuthorities } from './plugin'; import { hasAssetUpdateAuthority } from './authority'; +import { + CheckResult, + deserializeOracleValidation, + findOracleAccount, + getExtraAccountRequiredInputs, +} from '../plugins'; + +export enum LifecycleValidationError { + OracleValidationFailed = 'Oracle validation failed.', + NoAuthority = 'No authority to perform this action.', + AssetFrozen = 'Asset is frozen.', +} /** * Check if the given authority is eligible to transfer the asset. - * This does NOT check if the asset's roylaty rule sets. + * This does NOT check the asset's royalty rule sets or external plugins. Use `validateTransfer` for more comprehensive checks. + * @deprecated since v1.0.0. Use `validateTransfer` instead. * @param {PublicKey | string} authority Pubkey * @param {AssetV1} asset Asset * @param {CollectionV1 | undefined} collection Collection @@ -18,6 +36,8 @@ export function canTransfer( collection?: CollectionV1 ): boolean { const dAsset = deriveAssetPlugins(asset, collection); + + // Permanent plugins have force approve powers const permaTransferDelegate = checkPluginAuthorities({ authority, pluginTypes: [PluginType.PermanentTransferDelegate], @@ -28,23 +48,135 @@ export function canTransfer( return true; } - if (!isFrozen(asset, collection)) { - if (dAsset.owner === authority) { - return true; + if (isFrozen(asset, collection)) { + return false; + } + + if (dAsset.owner === authority) { + return true; + } + const transferDelegates = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.TransferDelegate], + asset: dAsset, + collection, + }); + return transferDelegates.some((d) => d); +} + +export type ValidateTransferInput = { + authority: PublicKey | string; + asset: AssetV1; + collection?: CollectionV1; + recipient?: PublicKey; +}; + +/** + * Check if the given authority is eligible to transfer the asset and receive an error message if not. + * + * @param {Context} context Umi context + * @param {ValidateTransferInput} inputs Inputs to validate transfer + * @returns {null | LifecycleValidationError} null if success or error message + */ +export async function validateTransfer( + context: Pick, + { authority, asset, collection, recipient }: ValidateTransferInput +): Promise { + const dAsset = deriveAssetPlugins(asset, collection); + + // Permanent plugins have force approve powers + const permaTransferDelegate = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.PermanentTransferDelegate], + asset: dAsset, + collection, + }); + if (permaTransferDelegate.some((d) => d)) { + return null; + } + + if (isFrozen(asset, collection)) { + return LifecycleValidationError.AssetFrozen + } + + if (dAsset.oracles?.length) { + const eligibleOracles = dAsset.oracles + .filter((o) => + o.lifecycleChecks?.transfer?.includes(CheckResult.CAN_REJECT) + ) + .filter((o) => { + // there's no PDA to derive, we can check the oracle account + if (!o.pda) { + return true; + } + // If there's a recipient in the inputs, we can try to check the oracle account + if (recipient) { + return true; + } + + if (!getExtraAccountRequiredInputs(o.pda).includes('recipient')) { + return true; + } + // we skip the check if there's a recipient required but no recipient provided + // this is due how UIs generally show the availability of the transfer button before requiring the recipient address + return false; + }); + if (eligibleOracles.length) { + const accountsWithOffset = eligibleOracles.map((o) => { + const account = findOracleAccount(context, o, { + asset: asset.publicKey, + collection: collection?.publicKey, + owner: asset.owner, + recipient, + }); + + return { + pubkey: account, + offset: o.resultsOffset, + }; + }); + + const oracleValidations = ( + await context.rpc.getAccounts(accountsWithOffset.map((a) => a.pubkey)) + ).map((a, index) => { + if (a.exists) { + return deserializeOracleValidation( + a.data, + accountsWithOffset[index].offset + ); + } + return null; + }); + + const oraclePass = oracleValidations.every( + (v) => v?.transfer === ExternalValidationResult.Pass + ); + if (!oraclePass) { + return LifecycleValidationError.OracleValidationFailed; + } } - const transferDelegates = checkPluginAuthorities({ - authority, - pluginTypes: [PluginType.TransferDelegate], - asset: dAsset, - collection, - }); - return transferDelegates.some((d) => d); - } - return false; + } + + if (dAsset.owner === authority) { + return null; + } + const transferDelegates = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.TransferDelegate], + asset: dAsset, + collection, + }); + if (transferDelegates.some((d) => d)) { + return null; + } + + return LifecycleValidationError.NoAuthority; } /** * Check if the given pubkey is eligible to burn the asset. + * This does NOT check external plugins, use `validateBurn` for more comprehensive checks. + * @deprecated since v1.0.0. Use `validateBurn` instead. * @param {PublicKey | string} authority Pubkey * @param {AssetV1} asset Asset * @param {CollectionV1 | undefined} collection Collection @@ -66,23 +198,121 @@ export function canBurn( return true; } - if (!isFrozen(asset, collection)) { - if (dAsset.owner === authority) { - return true; + if (isFrozen(asset, collection)) { + return false; + } + + if (dAsset.owner === authority) { + return true; + } + const burnDelegates = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.BurnDelegate], + asset, + collection, + }); + return burnDelegates.some((d) => d); +} + +export type ValidateBurnInput = { + authority: PublicKey | string; + asset: AssetV1; + collection?: CollectionV1; +}; + +/** + * Check if the given authority is eligible to burn the asset and receive an error message if not. + * + * @param {Context} context Umi context + * @param {ValidateBurnInput} inputs Inputs to validate burn + * @returns {null | LifecycleValidationError} null if success or error message + */ +export async function validateBurn( + context: Pick, + { + authority, + asset, + collection, + }: { + authority: PublicKey | string; + asset: AssetV1; + collection?: CollectionV1; + } +): Promise { + const dAsset = deriveAssetPlugins(asset, collection); + const permaBurnDelegate = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.PermanentBurnDelegate], + asset: dAsset, + collection, + }); + if (permaBurnDelegate.some((d) => d)) { + return null; + } + + if (isFrozen(asset, collection)) { + return LifecycleValidationError.AssetFrozen; + } + + if (dAsset.oracles?.length) { + const eligibleOracles = dAsset.oracles.filter((o) => + o.lifecycleChecks?.burn?.includes(CheckResult.CAN_REJECT) + ); + if (eligibleOracles.length) { + const accountsWithOffset = eligibleOracles.map((o) => { + const account = findOracleAccount(context, o, { + asset: asset.publicKey, + collection: collection?.publicKey, + owner: asset.owner, + }); + + return { + pubkey: account, + offset: o.resultsOffset, + }; + }); + + const oracleValidations = ( + await context.rpc.getAccounts(accountsWithOffset.map((a) => a.pubkey)) + ).map((a, index) => { + if (a.exists) { + return deserializeOracleValidation( + a.data, + accountsWithOffset[index].offset + ); + } + return null; + }); + + const oraclePass = oracleValidations.every( + (v) => v?.burn === ExternalValidationResult.Pass + ); + if (!oraclePass) { + return LifecycleValidationError.OracleValidationFailed; + } } - const burnDelegates = checkPluginAuthorities({ - authority, - pluginTypes: [PluginType.BurnDelegate], - asset, - collection, - }); - return burnDelegates.some((d) => d); - } - return false; + } + + if (dAsset.owner === authority) { + return null; + } + const burnDelegates = checkPluginAuthorities({ + authority, + pluginTypes: [PluginType.BurnDelegate], + asset, + collection, + }); + if (burnDelegates.some((d) => d)) { + return null; + } + + return LifecycleValidationError.NoAuthority; } /** * Check if the given pubkey is eligible to update the asset. + * This does NOT check external plugins. Use `validateUpdate` for more comprehensive checks. + * @deprecated since v1.0.0. Use `validateTransfer` instead. * @param {PublicKey | string} authority Pubkey * @param {AssetV1} asset Asset * @param {CollectionV1 | undefined} collection Collection @@ -95,3 +325,66 @@ export function canUpdate( ): boolean { return hasAssetUpdateAuthority(authority, asset, collection); } + +export type ValidateUpdateInput = { + authority: PublicKey | string; + asset: AssetV1; + collection?: CollectionV1; +}; + +/** + * Check if the given authority is eligible to update the asset and receive an error message if not. + * + * @param {Context} context Umi context + * @param {ValidateUpdateInput} inputs Inputs to validate update + * @returns {null | LifecycleValidationError} null if success or error message + */ +export async function validateUpdate( + context: Pick, + { authority, asset, collection }: ValidateUpdateInput +): Promise { + if (asset.oracles?.length) { + const eligibleOracles = asset.oracles.filter((o) => + o.lifecycleChecks?.update?.includes(CheckResult.CAN_REJECT) + ); + if (eligibleOracles.length) { + const accountsWithOffset = eligibleOracles.map((o) => { + const account = findOracleAccount(context, o, { + asset: asset.publicKey, + collection: collection?.publicKey, + owner: asset.owner, + }); + + return { + pubkey: account, + offset: o.resultsOffset, + }; + }); + + const oracleValidations = ( + await context.rpc.getAccounts(accountsWithOffset.map((a) => a.pubkey)) + ).map((a, index) => { + if (a.exists) { + return deserializeOracleValidation( + a.data, + accountsWithOffset[index].offset + ); + } + return null; + }); + + const oraclePass = oracleValidations.every( + (v) => v?.update === ExternalValidationResult.Pass + ); + if (!oraclePass) { + return LifecycleValidationError.OracleValidationFailed; + } + } + } + + if (!hasAssetUpdateAuthority(authority, asset, collection)) { + return LifecycleValidationError.NoAuthority; + } + + return null; +} diff --git a/clients/js/src/hooked/pluginRegistryV1Data.ts b/clients/js/src/hooked/pluginRegistryV1Data.ts index 56e264b5..62a76836 100644 --- a/clients/js/src/hooked/pluginRegistryV1Data.ts +++ b/clients/js/src/hooked/pluginRegistryV1Data.ts @@ -138,7 +138,10 @@ export function getExternalRegistryRecordSerializer(): Serializer< buffer, pluginOffsetOffset ); - const [dataLen] = option(u64()).deserialize(buffer, dataOffsetOffset); + const [dataLen, dataLenOffset] = option(u64()).deserialize( + buffer, + dataOffsetOffset + ); return [ { pluginType, @@ -149,7 +152,7 @@ export function getExternalRegistryRecordSerializer(): Serializer< dataOffset, dataLen, }, - pluginOffsetOffset, + dataLenOffset, ]; }, }; diff --git a/clients/js/src/plugins/externalPlugins.ts b/clients/js/src/plugins/externalPlugins.ts index 80a1e355..4981dfb9 100644 --- a/clients/js/src/plugins/externalPlugins.ts +++ b/clients/js/src/plugins/externalPlugins.ts @@ -190,17 +190,14 @@ export const findExtraAccounts = ( } ): AccountMeta[] => { const accounts: AccountMeta[] = []; - const { asset, collection, owner, recipient } = inputs; externalPlugins.oracles?.forEach((oracle) => { if (oracle.lifecycleChecks?.[lifecycle]) { if (oracle.pda) { accounts.push( extraAccountToAccountMeta(context, oracle.pda, { + ...inputs, program: oracle.baseAddress, - asset, - collection, - recipient, }) ); } else { @@ -224,11 +221,8 @@ export const findExtraAccounts = ( hook.extraAccounts?.forEach((extra) => { accounts.push( extraAccountToAccountMeta(context, extra, { + ...inputs, program: hook.hookedProgram, - asset, - collection, - recipient, - owner, }) ); }); diff --git a/clients/js/src/plugins/extraAccount.ts b/clients/js/src/plugins/extraAccount.ts index d6b86955..071c0a4c 100644 --- a/clients/js/src/plugins/extraAccount.ts +++ b/clients/js/src/plugins/extraAccount.ts @@ -20,21 +20,27 @@ export const findPreconfiguredPda = ( ]); export type ExtraAccount = - | Exclude< - RenameToType, - { type: 'CustomPda' } | { type: 'Address' } - > + | (Omit< + Exclude< + RenameToType, + { type: 'CustomPda' } | { type: 'Address' } + >, + 'isSigner' | 'isWritable' + > & { + isSigner?: boolean; + isWritable?: boolean; + }) | { type: 'CustomPda'; seeds: Array; - isSigner: boolean; - isWritable: boolean; + isSigner?: boolean; + isWritable?: boolean; } | { type: 'Address'; address: PublicKey; - isSigner: boolean; - isWritable: boolean; + isSigner?: boolean; + isWritable?: boolean; }; export function extraAccountToAccountMeta( @@ -48,94 +54,98 @@ export function extraAccountToAccountMeta( owner?: PublicKey; } ): AccountMeta { + const acccountMeta: Pick = { + isSigner: e.isSigner || false, + isWritable: e.isWritable || false, + }; + + const requiredInputs = getExtraAccountRequiredInputs(e); + const missing: string[] = []; + + requiredInputs.forEach((input) => { + if (!inputs[input]) { + missing.push(input); + } + }); + + if (missing.length) { + throw new Error( + `Missing required inputs to derive account address: ${missing.join(', ')}` + ); + } switch (e.type) { case 'PreconfiguredProgram': - if (!inputs.program) throw new Error('Program address is required'); return { - pubkey: context.eddsa.findPda(inputs.program, [ + ...acccountMeta, + pubkey: context.eddsa.findPda(inputs.program!, [ string({ size: 'variable' }).serialize(PRECONFIGURED_SEED), ])[0], - isSigner: e.isSigner, - isWritable: e.isWritable, }; case 'PreconfiguredCollection': - if (!inputs.program) throw new Error('Program address is required'); - if (!inputs.collection) throw new Error('Collection address is required'); return { + ...acccountMeta, pubkey: findPreconfiguredPda( context, - inputs.program, - inputs.collection + inputs.program!, + inputs.collection! )[0], - isSigner: e.isSigner, - isWritable: e.isWritable, }; case 'PreconfiguredOwner': - if (!inputs.program) throw new Error('Program address is required'); - if (!inputs.owner) throw new Error('Owner address is required'); return { - pubkey: findPreconfiguredPda(context, inputs.program, inputs.owner)[0], - isSigner: e.isSigner, - isWritable: e.isWritable, + ...acccountMeta, + pubkey: findPreconfiguredPda( + context, + inputs.program!, + inputs.owner! + )[0], }; case 'PreconfiguredRecipient': - if (!inputs.program) throw new Error('Program address is required'); - if (!inputs.recipient) throw new Error('Recipient address is required'); return { + ...acccountMeta, pubkey: findPreconfiguredPda( context, - inputs.program, - inputs.recipient + inputs.program!, + inputs.recipient! )[0], - isSigner: e.isSigner, - isWritable: e.isWritable, }; case 'PreconfiguredAsset': - if (!inputs.program) throw new Error('Program address is required'); - if (!inputs.asset) throw new Error('Asset address is required'); return { - pubkey: findPreconfiguredPda(context, inputs.program, inputs.asset)[0], - isSigner: e.isSigner, - isWritable: e.isWritable, + ...acccountMeta, + pubkey: findPreconfiguredPda( + context, + inputs.program!, + inputs.asset! + )[0], }; case 'CustomPda': - if (!inputs.program) throw new Error('Program address is required'); return { pubkey: context.eddsa.findPda( - inputs.program, + inputs.program!, e.seeds.map((seed) => { switch (seed.type) { case 'Collection': - if (!inputs.collection) - throw new Error('Collection address is required'); - return publicKeySerializer().serialize(inputs.collection); + return publicKeySerializer().serialize(inputs.collection!); case 'Owner': - if (!inputs.owner) throw new Error('Owner address is required'); - return publicKeySerializer().serialize(inputs.owner); + return publicKeySerializer().serialize(inputs.owner!); case 'Recipient': - if (!inputs.recipient) - throw new Error('Recipient address is required'); - return publicKeySerializer().serialize(inputs.recipient); + return publicKeySerializer().serialize(inputs.recipient!); case 'Asset': - if (!inputs.asset) throw new Error('Asset address is required'); - return publicKeySerializer().serialize(inputs.asset); + return publicKeySerializer().serialize(inputs.asset!); case 'Address': return publicKeySerializer().serialize(seed.pubkey); case 'Bytes': return seed.bytes; default: - throw new Error(`Unrecognized seed type`); + throw new Error('Unknown seed type'); } }) )[0], - isSigner: e.isSigner, - isWritable: e.isWritable, + ...acccountMeta, }; case 'Address': return { + ...acccountMeta, pubkey: e.address, - isSigner: e.isSigner, - isWritable: e.isWritable, }; default: throw new Error('Unknown extra account type'); @@ -143,27 +153,28 @@ export function extraAccountToAccountMeta( } export function extraAccountToBase(s: ExtraAccount): BaseExtraAccount { + const acccountMeta: Pick = { + isSigner: s.isSigner || false, + isWritable: s.isWritable || false, + }; if (s.type === 'CustomPda') { return { __kind: 'CustomPda', - isSigner: s.isSigner, - isWritable: s.isWritable, + ...acccountMeta, seeds: s.seeds.map(seedToBase), }; } if (s.type === 'Address') { return { __kind: 'Address', - isSigner: s.isSigner, - isWritable: s.isWritable, + ...acccountMeta, address: s.address, }; } return { __kind: s.type, - isSigner: s.isSigner, - isWritable: s.isWritable, + ...acccountMeta, }; } @@ -191,3 +202,50 @@ export function extraAccountFromBase(s: BaseExtraAccount): ExtraAccount { isWritable: s.isWritable, }; } + +export type ExtraAccountInput = + | 'owner' + | 'recipient' + | 'asset' + | 'collection' + | 'program'; + +const EXTRA_ACCOUNT_INPUT_MAP: { + [type in ExtraAccount['type']]?: ExtraAccountInput; +} = { + PreconfiguredOwner: 'owner', + PreconfiguredRecipient: 'recipient', + PreconfiguredAsset: 'asset', + PreconfiguredCollection: 'collection', + PreconfiguredProgram: 'program', +}; + +export function getExtraAccountRequiredInputs( + s: ExtraAccount +): ExtraAccountInput[] { + const preconfigured = EXTRA_ACCOUNT_INPUT_MAP[s.type]; + if (preconfigured) { + return [preconfigured]; + } + + if (s.type === 'CustomPda') { + return s.seeds + .map((seed) => { + switch (seed.type) { + case 'Collection': + return 'collection'; + case 'Owner': + return 'owner'; + case 'Recipient': + return 'recipient'; + case 'Asset': + return 'asset'; + default: + return null; + } + }) + .filter((input) => input) as ExtraAccountInput[]; + } + + return []; +} diff --git a/clients/js/src/plugins/index.ts b/clients/js/src/plugins/index.ts index 83417e70..ba8a63ad 100644 --- a/clients/js/src/plugins/index.ts +++ b/clients/js/src/plugins/index.ts @@ -12,3 +12,4 @@ export * from './externalPlugins'; export * from './updateAuthority'; export * from './seed'; export * from './extraAccount'; +export * from './validationResultsOffset'; diff --git a/clients/js/src/plugins/lifecycleChecks.ts b/clients/js/src/plugins/lifecycleChecks.ts index 7be856a1..d1b5acdb 100644 --- a/clients/js/src/plugins/lifecycleChecks.ts +++ b/clients/js/src/plugins/lifecycleChecks.ts @@ -8,7 +8,7 @@ export type LifecycleEvent = 'create' | 'update' | 'transfer' | 'burn'; export enum CheckResult { CAN_LISTEN, CAN_APPROVE, - CAN_DENY, + CAN_REJECT, } export const externalCheckResultToCheckResults = ( @@ -22,7 +22,7 @@ export const externalCheckResultToCheckResults = ( results.push(CheckResult.CAN_APPROVE); } if (check.flags & 4) { - results.push(CheckResult.CAN_DENY); + results.push(CheckResult.CAN_REJECT); } return results; }; @@ -39,7 +39,7 @@ export const checkResultsToExternalCheckResult = ( case CheckResult.CAN_APPROVE: flags |= 2; break; - case CheckResult.CAN_DENY: + case CheckResult.CAN_REJECT: flags |= 4; break; default: @@ -71,12 +71,22 @@ export function hookableLifecycleEventToLifecycleCheckKey( export function lifecycleChecksToBase( l: LifecycleChecks ): [HookableLifecycleEvent, ExternalCheckResult][] { - return Object(l) - .keys() - .map((key: keyof LifecycleChecks) => [ - lifecycleCheckKeyToEnum(key), - l[key], - ]); + return Object.keys(l) + .map((key) => { + const k = key as keyof LifecycleChecks; + const value = l[k]; + if (value) { + return [ + lifecycleCheckKeyToEnum(k), + checkResultsToExternalCheckResult(value), + ]; + } + return null; + }) + .filter((x) => x !== null) as [ + HookableLifecycleEvent, + ExternalCheckResult + ][]; } export function lifecycleChecksFromBase( diff --git a/clients/js/src/plugins/oracle.ts b/clients/js/src/plugins/oracle.ts index 6bf223b2..9fe8b013 100644 --- a/clients/js/src/plugins/oracle.ts +++ b/clients/js/src/plugins/oracle.ts @@ -1,6 +1,8 @@ +import { Context, PublicKey } from '@metaplex-foundation/umi'; import { ExtraAccount, extraAccountFromBase, + extraAccountToAccountMeta, extraAccountToBase, } from './extraAccount'; import { @@ -8,6 +10,8 @@ import { BaseOracleInitInfoArgs, BaseOracleUpdateInfoArgs, ExternalRegistryRecord, + getOracleValidationSerializer, + OracleValidation, } from '../generated'; import { LifecycleChecks, lifecycleChecksToBase } from './lifecycleChecks'; import { PluginAuthority, pluginAuthorityToBase } from './pluginAuthority'; @@ -96,6 +100,40 @@ export function oracleFromBase( }; } +export function findOracleAccount( + context: Pick, + oracle: Pick, + inputs: { + asset?: PublicKey; + collection?: PublicKey; + recipient?: PublicKey; + owner?: PublicKey; + } +): PublicKey { + if (!oracle.pda) { + return oracle.baseAddress; + } + + return extraAccountToAccountMeta(context, oracle.pda, { + ...inputs, + program: oracle.baseAddress, + }).pubkey; +} + +export function deserializeOracleValidation( + data: Uint8Array, + offset: ValidationResultsOffset +): OracleValidation { + let offs = 0; + if (offset.type === 'Custom') { + offs = Number(offset.offset); + } else if (offset.type === 'Anchor') { + offs = 8; + } + + return getOracleValidationSerializer().deserialize(data, offs)[0]; +} + export const oracleManifest: ExternalPluginManifest< Oracle, BaseOracle, diff --git a/clients/js/test/_setupRaw.ts b/clients/js/test/_setupRaw.ts index 4b6bb075..b0a2f632 100644 --- a/clients/js/test/_setupRaw.ts +++ b/clients/js/test/_setupRaw.ts @@ -5,6 +5,7 @@ import { PublicKey, Signer, Umi, + assertAccountExists, generateSigner, publicKey, } from '@metaplex-foundation/umi'; @@ -204,3 +205,16 @@ export const assertCollection = async ( t.like(collectionWithPlugins, testObj); }; + +export const assertBurned = async ( + t: Assertions, + umi: Umi, + asset: PublicKey +) => { + const account = await umi.rpc.getAccount(asset); + t.true(account.exists); + assertAccountExists(account); + t.is(account.data.length, 1); + t.is(account.data[0], Key.Uninitialized); + return account; +}; diff --git a/clients/js/test/burn.test.ts b/clients/js/test/burn.test.ts index 68614e82..d1c314de 100644 --- a/clients/js/test/burn.test.ts +++ b/clients/js/test/burn.test.ts @@ -1,15 +1,13 @@ -import { - assertAccountExists, - generateSigner, - sol, -} from '@metaplex-foundation/umi'; +import { generateSigner, sol } from '@metaplex-foundation/umi'; import test from 'ava'; -import { burnCollectionV1, burnV1, Key, pluginAuthorityPair } from '../src'; +import { generateSignerWithSol } from '@metaplex-foundation/umi-bundle-tests'; +import { burnCollectionV1, burnV1, pluginAuthorityPair } from '../src'; import { DEFAULT_ASSET, DEFAULT_COLLECTION, assertAsset, + assertBurned, assertCollection, createAsset, createAssetWithCollection, @@ -18,7 +16,6 @@ import { } from './_setupRaw'; test('it can burn an asset as the owner', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const asset = await createAsset(umi); await assertAsset(t, umi, { @@ -33,16 +30,11 @@ test('it can burn an asset as the owner', async (t) => { }).sendAndConfirm(umi); // And the asset address still exists but was resized to 1. - const afterAsset = await umi.rpc.getAccount(asset.publicKey); - t.true(afterAsset.exists); - assertAccountExists(afterAsset); + const afterAsset = await assertBurned(t, umi, asset.publicKey); t.deepEqual(afterAsset.lamports, sol(0.00089784 + 0.0015)); - t.is(afterAsset.data.length, 1); - t.is(afterAsset.data[0], Key.Uninitialized); }); test('it cannot burn an asset if not the owner', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const attacker = generateSigner(umi); @@ -69,7 +61,6 @@ test('it cannot burn an asset if not the owner', async (t) => { }); test('it cannot burn an asset if it is frozen', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const asset = await createAsset(umi, { @@ -114,7 +105,6 @@ test('it cannot burn an asset if it is frozen', async (t) => { }); test('it cannot burn asset in collection if no collection specified', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const { asset, collection } = await createAssetWithCollection(umi, {}); @@ -133,7 +123,6 @@ test('it cannot burn asset in collection if no collection specified', async (t) }); test('it cannot burn an asset if collection permanently frozen', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const { asset, collection } = await createAssetWithCollection( @@ -182,7 +171,6 @@ test('it cannot burn an asset if collection permanently frozen', async (t) => { }); test('it cannot use an invalid system program for assets', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const asset = await createAsset(umi); const fakeSystemProgram = generateSigner(umi); @@ -202,7 +190,6 @@ test('it cannot use an invalid system program for assets', async (t) => { }); test('it cannot use an invalid noop program for assets', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const asset = await createAsset(umi); const fakeLogWrapper = generateSigner(umi); @@ -222,7 +209,6 @@ test('it cannot use an invalid noop program for assets', async (t) => { }); test('it cannot use an invalid noop program for collections', async (t) => { - // Given a Umi instance and a new signer. const umi = await createUmi(); const collection = await createCollection(umi); const fakeLogWrapper = generateSigner(umi); @@ -241,6 +227,29 @@ test('it cannot use an invalid noop program for collections', async (t) => { await t.throwsAsync(result, { name: 'InvalidLogWrapperProgram' }); }); +test('it can burn using owner authority', async (t) => { + const umi = await createUmi(); + const owner = await generateSignerWithSol(umi); + const asset = await createAsset(umi, { + owner: owner.publicKey, + }); + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: owner.publicKey, + updateAuthority: { type: 'Address', address: umi.identity.publicKey }, + }); + + await burnV1(umi, { + asset: asset.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + // And the asset address still exists but was resized to 1. + const afterAsset = await assertBurned(t, umi, asset.publicKey); + t.deepEqual(afterAsset.lamports, sol(0.00089784 + 0.0015)); +}); + test('it cannot burn an asset with the wrong collection specified', async (t) => { // Given a Umi instance and a new signer. const umi = await createUmi(); diff --git a/clients/js/test/externalPlugins/oracle.test.ts b/clients/js/test/externalPlugins/oracle.test.ts new file mode 100644 index 00000000..471d954f --- /dev/null +++ b/clients/js/test/externalPlugins/oracle.test.ts @@ -0,0 +1,1297 @@ +import test from 'ava'; + +import { + mplCoreOracleExample, + fixedAccountInit, + fixedAccountSet, + preconfiguredAssetPdaInit, + MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + preconfiguredAssetPdaSet, + preconfiguredProgramPdaInit, + preconfiguredProgramPdaSet, + preconfiguredOwnerPdaInit, + preconfiguredOwnerPdaSet, + preconfiguredRecipientPdaInit, + preconfiguredRecipientPdaSet, + customPdaAllSeedsInit, + customPdaAllSeedsSet, + customPdaTypicalInit, + customPdaTypicalSet, + preconfiguredAssetPdaCustomOffsetInit, + preconfiguredAssetPdaCustomOffsetSet, + close, +} from '@metaplex-foundation/mpl-core-oracle-example'; +import { generateSigner } from '@metaplex-foundation/umi'; +import { ExternalValidationResult } from '@metaplex-foundation/mpl-core-oracle-example/dist/src/hooked'; +import { generateSignerWithSol } from '@metaplex-foundation/umi-bundle-tests'; +import { + assertAsset, + assertBurned, + createUmi as baseCreateUmi, + DEFAULT_ASSET, +} from '../_setupRaw'; +import { createAsset, createAssetWithCollection } from '../_setupSdk'; +import { + burn, + CheckResult, + create, + findOracleAccount, + OracleInitInfoArgs, + transfer, + update, +} from '../../src'; + +const createUmi = async () => + (await baseCreateUmi()).use(mplCoreOracleExample()); + +test('it can use fixed address oracle to deny update', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const asset = await createAsset(umi, { + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }); + + const result = update(umi, { + asset, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await fixedAccountSet(umi, { + account: account.publicKey, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); + +test('it can use fixed address oracle to deny update via collection', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const { asset, collection } = await createAssetWithCollection( + umi, + {}, + { + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + } + ); + + const result = update(umi, { + asset, + collection, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await fixedAccountSet(umi, { + account: account.publicKey, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + collection, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); + +test('it can use fixed address oracle to deny transfer', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const asset = await createAsset(umi, { + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }); + + const newOwner = generateSigner(umi); + + const result = transfer(umi, { + asset, + newOwner: newOwner.publicKey, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await fixedAccountSet(umi, { + account: account.publicKey, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await transfer(umi, { + asset, + newOwner: newOwner.publicKey, + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: newOwner.publicKey, + }); +}); + +test('it cannot configure oracle to approve', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + const owner = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Approved, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // Validate cannot have Oracle with `CheckResult.CAN_APPROVE` + const result = createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_APPROVE], + }, + baseAddress: account.publicKey, + }, + ], + }); + + await t.throwsAsync(result, { name: 'OracleCanDenyOnly' }); +}); + +test('it cannot configure oracle to listen', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + const owner = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Approved, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // Validate cannot have Oracle with `CheckResult.CAN_LISTEN` + const result = createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_LISTEN], + }, + baseAddress: account.publicKey, + }, + ], + }); + + await t.throwsAsync(result, { name: 'OracleCanDenyOnly' }); +}); + +test('it cannot use fixed address oracle to deny transfer if not registered for lifecycle event', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + const owner = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + create: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }); + + const newOwner = generateSigner(umi); + + await transfer(umi, { + asset, + newOwner: newOwner.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: newOwner.publicKey, + }); +}); + +test('it can use fixed address oracle to deny create', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Rejected, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const assetSigner = generateSigner(umi); + const result = create(umi, { + ...DEFAULT_ASSET, + asset: assetSigner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + create: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + await fixedAccountSet(umi, { + account: account.publicKey, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await create(umi, { + ...DEFAULT_ASSET, + asset: assetSigner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + create: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: assetSigner.publicKey, + owner: umi.identity.publicKey, + }); +}); + +test('it can use fixed address oracle to deny burn', async (t) => { + const umi = await createUmi(); + const account = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Rejected, + }, + }, + }).sendAndConfirm(umi); + + // create asset referencing the oracle account + const asset = await createAsset(umi, { + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + burn: [CheckResult.CAN_REJECT], + }, + baseAddress: account.publicKey, + }, + ], + }); + + const result = burn(umi, { + asset, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await fixedAccountSet(umi, { + account: account.publicKey, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await burn(umi, { + asset, + }).sendAndConfirm(umi); + + await assertBurned(t, umi, asset.publicKey); +}); + +test('it can use preconfigured program pda oracle to deny update', async (t) => { + const umi = await createUmi(); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredProgram', + }, + }; + + const asset = await createAsset(umi, { + plugins: [oraclePlugin], + }); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, {}); + + // Need to close program account from previous test runs on same amman/validator session. + try { + await close(umi, { + account, + signer: umi.identity, + payer: umi.identity, + }).sendAndConfirm(umi); + } catch (error) { + if (error.name === 'ProgramErrorNotRecognizedError') { + // Do nothing. + } else { + throw error; + } + } + + // write to the PDA which corresponds to the asset + await preconfiguredProgramPdaInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = update(umi, { + asset, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await preconfiguredProgramPdaSet(umi, { + account, + signer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); + +test('it can use preconfigured collection pda oracle to deny update', async (t) => { + const umi = await createUmi(); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredCollection', + }, + }; + + const { asset, collection } = await createAssetWithCollection( + umi, + { + plugins: [oraclePlugin], + }, + {} + ); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + collection: collection.publicKey, + }); + + // write to the PDA which corresponds to the asset + await preconfiguredAssetPdaInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + additionalPubkey: collection.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = update(umi, { + asset, + collection, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await preconfiguredAssetPdaSet(umi, { + account, + signer: umi.identity, + args: { + additionalPubkey: collection.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + collection, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + updateAuthority: { type: 'Collection', address: collection.publicKey }, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); + +test('it can use preconfigured owner pda oracle to deny burn', async (t) => { + const umi = await createUmi(); + const owner = await generateSignerWithSol(umi); + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + burn: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredOwner', + }, + }; + + const asset = await createAsset(umi, { + owner, + plugins: [oraclePlugin], + }); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + owner: owner.publicKey, + }); + + // write to the PDA which corresponds to the asset + await preconfiguredOwnerPdaInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + additionalPubkey: owner.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Rejected, + }, + }, + }).sendAndConfirm(umi); + + const result = burn(umi, { + asset, + authority: owner, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner, + }); + + await preconfiguredOwnerPdaSet(umi, { + account, + signer: umi.identity, + args: { + additionalPubkey: owner.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await burn(umi, { + asset, + authority: owner, + }).sendAndConfirm(umi); + + await assertBurned(t, umi, asset.publicKey); +}); + +test('it can use preconfigured recipient pda oracle to deny transfer', async (t) => { + const umi = await createUmi(); + const newOwner = generateSigner(umi); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredRecipient', + }, + }; + + const asset = await createAsset(umi, { + plugins: [oraclePlugin], + }); + + // Find the oracle PDA for the new owner. + const account = findOracleAccount(umi, oraclePlugin, { + recipient: newOwner.publicKey, + }); + + // write to the PDA which corresponds to the new owner. + await preconfiguredRecipientPdaInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + additionalPubkey: newOwner.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = transfer(umi, { + asset, + newOwner: newOwner.publicKey, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await preconfiguredRecipientPdaSet(umi, { + account, + signer: umi.identity, + args: { + additionalPubkey: newOwner.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await transfer(umi, { + asset, + newOwner: newOwner.publicKey, + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: newOwner.publicKey, + }); +}); + +test('it can use preconfigured asset pda oracle to deny update', async (t) => { + const umi = await createUmi(); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredAsset', + }, + }; + + const asset = await createAsset(umi, { + plugins: [oraclePlugin], + }); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + asset: asset.publicKey, + }); + + // write to the PDA which corresponds to the asset + await preconfiguredAssetPdaInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + additionalPubkey: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = update(umi, { + asset, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await preconfiguredAssetPdaSet(umi, { + account, + signer: umi.identity, + args: { + additionalPubkey: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); + +test('it can use custom pda (all seeds) oracle to deny transfer', async (t) => { + const umi = await createUmi(); + const seedPubkey = generateSigner(umi).publicKey; + const owner = generateSigner(umi); + const newOwner = generateSigner(umi); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'CustomPda', + seeds: [ + { type: 'Collection' }, + { type: 'Owner' }, + { type: 'Recipient' }, + { type: 'Asset' }, + { type: 'Address', pubkey: seedPubkey }, + { + type: 'Bytes', + bytes: Buffer.from('example-seed-bytes', 'utf8'), + }, + ], + }, + }; + + const { asset, collection } = await createAssetWithCollection( + umi, + { + owner, + plugins: [oraclePlugin], + }, + {} + ); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + collection: collection.publicKey, + owner: owner.publicKey, + recipient: newOwner.publicKey, + asset: asset.publicKey, + }); + + // write to the PDA which corresponds to the asset + await customPdaAllSeedsInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + collection: collection.publicKey, + owner: owner.publicKey, + recipient: newOwner.publicKey, + asset: asset.publicKey, + address: seedPubkey, + bytes: Buffer.from('example-seed-bytes', 'utf8'), + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = transfer(umi, { + asset, + collection, + newOwner: newOwner.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await customPdaAllSeedsSet(umi, { + account, + signer: umi.identity, + args: { + collection: collection.publicKey, + owner: owner.publicKey, + recipient: newOwner.publicKey, + asset: asset.publicKey, + address: seedPubkey, + bytes: Buffer.from('example-seed-bytes', 'utf8'), + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await transfer(umi, { + asset, + collection, + newOwner: newOwner.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: newOwner.publicKey, + }); +}); + +test('it can use custom pda (typical) oracle to deny transfer', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const newOwner = generateSigner(umi); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'CustomPda', + seeds: [ + { + type: 'Bytes', + bytes: Buffer.from('prefix-seed-bytes', 'utf8'), + }, + { type: 'Collection' }, + { + type: 'Bytes', + bytes: Buffer.from('additional-bytes-seed-bytes', 'utf8'), + }, + ], + }, + }; + + const { asset, collection } = await createAssetWithCollection( + umi, + { + owner, + plugins: [oraclePlugin], + }, + {} + ); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + collection: collection.publicKey, + }); + + // write to the PDA + await customPdaTypicalInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + prefixBytes: Buffer.from('prefix-seed-bytes', 'utf8'), + collection: collection.publicKey, + additionalBytes: Buffer.from('additional-bytes-seed-bytes', 'utf8'), + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const result = transfer(umi, { + asset, + collection, + newOwner: newOwner.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'InvalidAuthority' }); + + await customPdaTypicalSet(umi, { + account, + signer: umi.identity, + args: { + prefixBytes: Buffer.from('prefix-seed-bytes', 'utf8'), + collection: collection.publicKey, + additionalBytes: Buffer.from('additional-bytes-seed-bytes', 'utf8'), + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + await transfer(umi, { + asset, + collection, + newOwner: newOwner.publicKey, + authority: owner, + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: newOwner.publicKey, + }); +}); + +test('it can use preconfigured asset pda custom offset oracle to deny update', async (t) => { + const umi = await createUmi(); + + // This test uses an oracle with the data struct: + // pub struct CustomDataValidation { + // pub authority: Pubkey, + // pub sequence_num: u64, + // pub validation: OracleValidation, + // } + // + // Thus the `resultsOffset` below is set to 48. This is because the anchor discriminator, the + // `authority` `Pubkey`, and the `sequence_num` all precede the `OracleValidation` struct + // within the account: + // + // 8 (anchor discriminator) + 32 (authority) + 8 (sequence number) = 48. + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Custom', + offset: 48n, + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'PreconfiguredAsset', + }, + }; + + const asset = await createAsset(umi, { + plugins: [oraclePlugin], + }); + + // Find the oracle PDA based on the asset we just created + const account = findOracleAccount(umi, oraclePlugin, { + asset: asset.publicKey, + }); + + const dataAuthority = generateSigner(umi); + // write to the PDA which corresponds to the asset + await preconfiguredAssetPdaCustomOffsetInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + authority: dataAuthority.publicKey, + asset: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }).sendAndConfirm(umi); + + const updateResult = update(umi, { + asset, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(updateResult, { name: 'InvalidAuthority' }); + + // Making sure the incorrect authority cannot update the oracle. This is more just a test of the + // example program functionality. + const setResult = preconfiguredAssetPdaCustomOffsetSet(umi, { + account, + authority: umi.identity, + sequenceNum: 2, + asset: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }).sendAndConfirm(umi); + + await t.throwsAsync(setResult, { name: 'ProgramErrorNotRecognizedError' }); + + // Making sure a lower sequence number passes but does not update the oracle. This is also just + // a test of the example program functionality. + await preconfiguredAssetPdaCustomOffsetSet(umi, { + account, + authority: dataAuthority, + sequenceNum: 0, + asset: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }).sendAndConfirm(umi); + + // Validate still cannot update the mpl-core asset because the oracle did not change. + const updateResult2 = update(umi, { + asset, + name: 'new name', + }).sendAndConfirm(umi); + + await t.throwsAsync(updateResult2, { name: 'InvalidAuthority' }); + + // Oracle update that works. + await preconfiguredAssetPdaCustomOffsetSet(umi, { + account, + authority: dataAuthority, + sequenceNum: 2, + asset: asset.publicKey, + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }).sendAndConfirm(umi); + + await update(umi, { + asset, + name: 'new name 2', + }).sendAndConfirm(umi); + + await assertAsset(t, umi, { + ...DEFAULT_ASSET, + asset: asset.publicKey, + owner: umi.identity.publicKey, + name: 'new name 2', + }); +}); diff --git a/clients/js/test/helps/lifecycle.test.ts b/clients/js/test/helps/lifecycle.test.ts index 3b42ed1b..cc956bd2 100644 --- a/clients/js/test/helps/lifecycle.test.ts +++ b/clients/js/test/helps/lifecycle.test.ts @@ -1,16 +1,24 @@ import test from 'ava'; import { generateSigner } from '@metaplex-foundation/umi'; import { - addressPluginAuthority, + customPdaAllSeedsInit, + fixedAccountInit, + MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, +} from '@metaplex-foundation/mpl-core-oracle-example'; +import { canBurn, canTransfer, - pluginAuthorityPair, + CheckResult, + ExternalValidationResult, + findOracleAccount, + LifecycleValidationError, + OracleInitInfoArgs, + validateBurn, + validateTransfer, + validateUpdate, } from '../../src'; -import { - createAsset, - createAssetWithCollection, - createUmi, -} from '../_setupRaw'; +import { createUmi } from '../_setupRaw'; +import { createAsset, createAssetWithCollection } from '../_setupSdk'; test('it can detect transferrable on basic asset', async (t) => { const umi = await createUmi(); @@ -21,6 +29,10 @@ test('it can detect transferrable on basic asset', async (t) => { }); t.assert(canTransfer(owner.publicKey, asset)); + t.is( + await validateTransfer(umi, { authority: owner.publicKey, asset }), + null + ); }); test('it can detect non transferrable from frozen asset', async (t) => { @@ -30,14 +42,18 @@ test('it can detect non transferrable from frozen asset', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }); t.assert(!canTransfer(owner.publicKey, asset)); + t.is( + await validateTransfer(umi, { authority: owner.publicKey, asset }), + LifecycleValidationError.AssetFrozen + ); }); test('it can detect transferrable on asset with transfer delegate', async (t) => { @@ -48,14 +64,21 @@ test('it can detect transferrable on asset with transfer delegate', async (t) => const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'TransferDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], }); t.assert(canTransfer(delegate.publicKey, asset)); + t.is( + await validateTransfer(umi, { authority: delegate.publicKey, asset }), + null + ); }); test('it can detect transferrable from permanent transfer', async (t) => { @@ -66,14 +89,21 @@ test('it can detect transferrable from permanent transfer', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'PermanentTransferDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], }); t.assert(canTransfer(delegate.publicKey, asset)); + t.is( + await validateTransfer(umi, { authority: delegate.publicKey, asset }), + null + ); }); test('it can detect transferrable when frozen with permanent transfer', async (t) => { @@ -84,19 +114,30 @@ test('it can detect transferrable when frozen with permanent transfer', async (t const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'PermanentTransferDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), - pluginAuthorityPair({ + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }); t.assert(!canTransfer(owner.publicKey, asset)); t.assert(canTransfer(delegate.publicKey, asset)); + t.is( + await validateTransfer(umi, { authority: owner.publicKey, asset }), + LifecycleValidationError.AssetFrozen + ); + t.is( + await validateTransfer(umi, { authority: delegate.publicKey, asset }), + null + ); }); test('it can detect transferrable when frozen with permanent collection transfer delegate', async (t) => { @@ -109,24 +150,43 @@ test('it can detect transferrable when frozen with permanent collection transfer { owner, plugins: [ - pluginAuthorityPair({ + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }, { plugins: [ - pluginAuthorityPair({ + { type: 'PermanentTransferDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], } ); t.assert(!canTransfer(owner.publicKey, asset, collection)); t.assert(canTransfer(delegate.publicKey, asset, collection)); + t.is( + await validateTransfer(umi, { + authority: owner.publicKey, + asset, + collection, + }), + LifecycleValidationError.AssetFrozen + ); + t.is( + await validateTransfer(umi, { + authority: delegate.publicKey, + asset, + collection, + }), + null + ); }); test('it can detect burnable on basic asset', async (t) => { @@ -138,6 +198,7 @@ test('it can detect burnable on basic asset', async (t) => { }); t.assert(canBurn(owner.publicKey, asset)); + t.is(await validateBurn(umi, { authority: owner.publicKey, asset }), null); }); test('it can detect non burnable from frozen asset', async (t) => { @@ -147,14 +208,18 @@ test('it can detect non burnable from frozen asset', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }); t.assert(!canBurn(owner.publicKey, asset)); + t.is( + await validateBurn(umi, { authority: owner.publicKey, asset }), + LifecycleValidationError.AssetFrozen + ); }); test('it can detect burnable on asset with burn delegate', async (t) => { @@ -165,14 +230,18 @@ test('it can detect burnable on asset with burn delegate', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'BurnDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], }); t.assert(canBurn(delegate.publicKey, asset)); + t.is(await validateBurn(umi, { authority: delegate.publicKey, asset }), null); }); test('it can detect burnable from permanent burn', async (t) => { @@ -183,14 +252,18 @@ test('it can detect burnable from permanent burn', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'PermanentBurnDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], }); t.assert(canBurn(delegate.publicKey, asset)); + t.is(await validateBurn(umi, { authority: delegate.publicKey, asset }), null); }); test('it can detect burnable when frozen with permanent burn', async (t) => { @@ -201,19 +274,27 @@ test('it can detect burnable when frozen with permanent burn', async (t) => { const asset = await createAsset(umi, { owner, plugins: [ - pluginAuthorityPair({ + { type: 'PermanentBurnDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), - pluginAuthorityPair({ + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }); t.assert(!canBurn(owner.publicKey, asset)); t.assert(canBurn(delegate.publicKey, asset)); + t.is( + await validateBurn(umi, { authority: owner.publicKey, asset }), + LifecycleValidationError.AssetFrozen + ); + t.is(await validateBurn(umi, { authority: delegate.publicKey, asset }), null); }); test('it can detect burnable when frozen with permanent collection burn delegate', async (t) => { @@ -226,22 +307,446 @@ test('it can detect burnable when frozen with permanent collection burn delegate { owner, plugins: [ - pluginAuthorityPair({ + { type: 'FreezeDelegate', - data: { frozen: true }, - }), + frozen: true, + }, ], }, { plugins: [ - pluginAuthorityPair({ + { type: 'PermanentBurnDelegate', - authority: addressPluginAuthority(delegate.publicKey), - }), + authority: { + type: 'Address', + address: delegate.publicKey, + }, + }, ], } ); t.assert(!canBurn(owner.publicKey, asset, collection)); t.assert(canBurn(delegate.publicKey, asset, collection)); + t.is( + await validateBurn(umi, { authority: owner.publicKey, asset, collection }), + LifecycleValidationError.AssetFrozen + ); + t.is( + await validateBurn(umi, { + authority: delegate.publicKey, + asset, + collection, + }), + null + ); +}); + +test('it can validate non-transferrable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateTransfer(umi, { + authority: owner.publicKey, + asset, + }), + LifecycleValidationError.OracleValidationFailed + ); +}); + +test('it can validate transferrable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + const oracle2 = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + }, + { + type: 'Oracle', + baseAddress: oracle2.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateTransfer(umi, { + authority: owner.publicKey, + asset, + }), + null + ); +}); + +test('it can validate non-transferrable asset with oracle with recipient seed', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const seedPubkey = generateSigner(umi).publicKey; + const newOwner = generateSigner(umi); + + const oraclePlugin: OracleInitInfoArgs = { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'CustomPda', + seeds: [ + { type: 'Collection' }, + { type: 'Owner' }, + { type: 'Recipient' }, + { type: 'Asset' }, + { type: 'Address', pubkey: seedPubkey }, + { + type: 'Bytes', + bytes: Buffer.from('example-seed-bytes', 'utf8'), + }, + ], + }, + }; + + const { asset, collection } = await createAssetWithCollection( + umi, + { + owner, + plugins: [oraclePlugin], + }, + {} + ); + + const account = findOracleAccount(umi, oraclePlugin, { + collection: collection.publicKey, + owner: owner.publicKey, + recipient: newOwner.publicKey, + asset: asset.publicKey, + }); + + // write to example program oracle account + await customPdaAllSeedsInit(umi, { + account, + signer: umi.identity, + payer: umi.identity, + args: { + collection: collection.publicKey, + owner: owner.publicKey, + recipient: newOwner.publicKey, + asset: asset.publicKey, + address: seedPubkey, + bytes: Buffer.from('example-seed-bytes', 'utf8'), + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Rejected, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + t.is( + await validateTransfer(umi, { + authority: owner.publicKey, + asset, + recipient: newOwner.publicKey, + collection, + }), + LifecycleValidationError.OracleValidationFailed + ); +}); + +test('it can validate and skip transferrable asset with oracle with recipient seed if missing recipient', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + + const { asset, collection } = await createAssetWithCollection( + umi, + { + owner, + plugins: [ + { + type: 'Oracle', + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + baseAddress: MPL_CORE_ORACLE_EXAMPLE_PROGRAM_ID, + pda: { + type: 'CustomPda', + seeds: [ + { + type: 'Recipient', + }, + ], + }, + }, + ], + }, + {} + ); + + t.is( + await validateTransfer(umi, { + authority: owner.publicKey, + collection, + asset, + }), + null + ); +}); + +test('it can validate non-burnable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Rejected, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + burn: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateBurn(umi, { + authority: owner.publicKey, + asset, + }), + LifecycleValidationError.OracleValidationFailed + ); +}); + +test('it can validate burnable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateBurn(umi, { + authority: owner.publicKey, + asset, + }), + null + ); +}); + +test('it can validate non-updatable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Rejected, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Rejected, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + update: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateUpdate(umi, { + authority: owner.publicKey, + asset, + }), + LifecycleValidationError.OracleValidationFailed + ); +}); + +test('it can validate updatable asset with oracle', async (t) => { + const umi = await createUmi(); + const owner = generateSigner(umi); + const oracle = generateSigner(umi); + + // write to example program oracle account + await fixedAccountInit(umi, { + account: oracle, + signer: umi.identity, + payer: umi.identity, + args: { + oracleData: { + __kind: 'V1', + create: ExternalValidationResult.Pass, + update: ExternalValidationResult.Pass, + transfer: ExternalValidationResult.Pass, + burn: ExternalValidationResult.Pass, + }, + }, + }).sendAndConfirm(umi); + + const asset = await createAsset(umi, { + owner, + plugins: [ + { + type: 'Oracle', + baseAddress: oracle.publicKey, + resultsOffset: { + type: 'Anchor', + }, + lifecycleChecks: { + transfer: [CheckResult.CAN_REJECT], + }, + }, + ], + }); + + t.is( + await validateUpdate(umi, { + authority: umi.identity.publicKey, + asset, + }), + null + ); }); diff --git a/clients/js/test/info.test.ts b/clients/js/test/info.test.ts index 692eaca9..ff051e4f 100644 --- a/clients/js/test/info.test.ts +++ b/clients/js/test/info.test.ts @@ -46,7 +46,7 @@ test.skip('fetch account info for ledger state', async (t) => { // Print the size of the account. const account = await umi.rpc.getAccount(assetAddress.publicKey); if (account.exists) { - console.log(`Account Size ${account.data.length} bytes`); + // console.log(`Account Size ${account.data.length} bytes`); } // Then an account was created with the correct data. diff --git a/clients/js/test/plugins/asset/permanentBurn.test.ts b/clients/js/test/plugins/asset/permanentBurn.test.ts index ec7b0ae4..5270d37b 100644 --- a/clients/js/test/plugins/asset/permanentBurn.test.ts +++ b/clients/js/test/plugins/asset/permanentBurn.test.ts @@ -17,6 +17,7 @@ import { } from '../../../src'; import { assertAsset, + assertBurned, createAsset, createCollection, createUmi, @@ -39,12 +40,8 @@ test('it can burn an assets as an owner', async (t) => { asset: asset.publicKey, }).sendAndConfirm(umi); - const afterAsset = await umi.rpc.getAccount(asset.publicKey); - t.true(afterAsset.exists); - assertAccountExists(afterAsset); + const afterAsset = await assertBurned(t, umi, asset.publicKey); t.deepEqual(afterAsset.lamports, sol(0.00089784 + 0.0015)); - t.is(afterAsset.data.length, 1); - t.is(afterAsset.data[0], Key.Uninitialized); }); test('it can burn an assets as a delegate', async (t) => { diff --git a/clients/rust/src/generated/instructions/burn_collection_v1.rs b/clients/rust/src/generated/instructions/burn_collection_v1.rs index 6bcc5032..65d86f20 100644 --- a/clients/rust/src/generated/instructions/burn_collection_v1.rs +++ b/clients/rust/src/generated/instructions/burn_collection_v1.rs @@ -43,7 +43,7 @@ impl BurnCollectionV1 { self.payer, true, )); if let Some(authority) = self.authority { - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( authority, true, )); } else { @@ -99,7 +99,7 @@ pub struct BurnCollectionV1InstructionArgs { /// /// 0. `[writable]` collection /// 1. `[writable, signer]` payer -/// 2. `[signer, optional]` authority +/// 2. `[writable, signer, optional]` authority /// 3. `[optional]` log_wrapper #[derive(Default)] pub struct BurnCollectionV1Builder { @@ -270,7 +270,7 @@ impl<'a, 'b> BurnCollectionV1Cpi<'a, 'b> { true, )); if let Some(authority) = self.authority { - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( *authority.key, true, )); @@ -335,7 +335,7 @@ impl<'a, 'b> BurnCollectionV1Cpi<'a, 'b> { /// /// 0. `[writable]` collection /// 1. `[writable, signer]` payer -/// 2. `[signer, optional]` authority +/// 2. `[writable, signer, optional]` authority /// 3. `[optional]` log_wrapper pub struct BurnCollectionV1CpiBuilder<'a, 'b> { instruction: Box>, diff --git a/clients/rust/src/generated/instructions/burn_v1.rs b/clients/rust/src/generated/instructions/burn_v1.rs index 803c102e..02d1dcde 100644 --- a/clients/rust/src/generated/instructions/burn_v1.rs +++ b/clients/rust/src/generated/instructions/burn_v1.rs @@ -56,7 +56,7 @@ impl BurnV1 { self.payer, true, )); if let Some(authority) = self.authority { - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( authority, true, )); } else { @@ -124,7 +124,7 @@ pub struct BurnV1InstructionArgs { /// 0. `[writable]` asset /// 1. `[writable, optional]` collection /// 2. `[writable, signer]` payer -/// 3. `[signer, optional]` authority +/// 3. `[writable, signer, optional]` authority /// 4. `[optional]` system_program /// 5. `[optional]` log_wrapper #[derive(Default)] @@ -338,7 +338,7 @@ impl<'a, 'b> BurnV1Cpi<'a, 'b> { true, )); if let Some(authority) = self.authority { - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( *authority.key, true, )); @@ -421,7 +421,7 @@ impl<'a, 'b> BurnV1Cpi<'a, 'b> { /// 0. `[writable]` asset /// 1. `[writable, optional]` collection /// 2. `[writable, signer]` payer -/// 3. `[signer, optional]` authority +/// 3. `[writable, signer, optional]` authority /// 4. `[optional]` system_program /// 5. `[optional]` log_wrapper pub struct BurnV1CpiBuilder<'a, 'b> { diff --git a/clients/rust/tests/add_external_plugins.rs b/clients/rust/tests/add_external_plugins.rs index c3c8e5a9..8e20e36e 100644 --- a/clients/rust/tests/add_external_plugins.rs +++ b/clients/rust/tests/add_external_plugins.rs @@ -150,7 +150,7 @@ async fn test_add_oracle() { init_plugin_authority: Some(PluginAuthority::UpdateAuthority), lifecycle_checks: Some(vec![( HookableLifecycleEvent::Transfer, - ExternalCheckResult { flags: 2 }, + ExternalCheckResult { flags: 4 }, )]), pda: None, results_offset: None, diff --git a/clients/rust/tests/create_with_external_plugins.rs b/clients/rust/tests/create_with_external_plugins.rs index 44b98048..248491d5 100644 --- a/clients/rust/tests/create_with_external_plugins.rs +++ b/clients/rust/tests/create_with_external_plugins.rs @@ -92,7 +92,7 @@ async fn test_create_oracle() { init_plugin_authority: Some(PluginAuthority::UpdateAuthority), lifecycle_checks: Some(vec![( HookableLifecycleEvent::Transfer, - ExternalCheckResult { flags: 2 }, + ExternalCheckResult { flags: 4 }, )]), pda: None, results_offset: None, diff --git a/clients/rust/tests/remove_external_plugins.rs b/clients/rust/tests/remove_external_plugins.rs index 85461cd0..1064f96b 100644 --- a/clients/rust/tests/remove_external_plugins.rs +++ b/clients/rust/tests/remove_external_plugins.rs @@ -127,7 +127,7 @@ async fn test_remove_oracle() { init_plugin_authority: Some(PluginAuthority::UpdateAuthority), lifecycle_checks: Some(vec![( HookableLifecycleEvent::Transfer, - ExternalCheckResult { flags: 2 }, + ExternalCheckResult { flags: 4 }, )]), pda: None, results_offset: None, diff --git a/clients/rust/tests/update_external_plugins.rs b/clients/rust/tests/update_external_plugins.rs index dd546743..5817c5df 100644 --- a/clients/rust/tests/update_external_plugins.rs +++ b/clients/rust/tests/update_external_plugins.rs @@ -140,7 +140,7 @@ async fn test_update_oracle() { init_plugin_authority: Some(PluginAuthority::UpdateAuthority), lifecycle_checks: Some(vec![( HookableLifecycleEvent::Transfer, - ExternalCheckResult { flags: 2 }, + ExternalCheckResult { flags: 4 }, )]), pda: None, results_offset: None, diff --git a/configs/scripts/program/build.sh b/configs/scripts/program/build.sh index c8708bc7..0b324b55 100755 --- a/configs/scripts/program/build.sh +++ b/configs/scripts/program/build.sh @@ -4,6 +4,10 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) OUTPUT="./programs/.bin" # saves external programs binaries to the output directory source ${SCRIPT_DIR}/dump.sh ${OUTPUT} + +# Oracle program used for tests and is only deployed to devnet +source ${SCRIPT_DIR}/dump_oracle_example.sh ${OUTPUT} + # go to parent folder cd $(dirname $(dirname $(dirname ${SCRIPT_DIR}))) diff --git a/configs/scripts/program/dump_oracle_example.sh b/configs/scripts/program/dump_oracle_example.sh new file mode 100755 index 00000000..b815a0b7 --- /dev/null +++ b/configs/scripts/program/dump_oracle_example.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +EXTERNAL_ID=("4RZ7RhXeL4oz4kVX5fpRfkNQ3nz1n4eruqBn2AGPQepo") +EXTERNAL_SO=("mpl_core_oracle_example.so") + +# output colours +RED() { echo $'\e[1;31m'$1$'\e[0m'; } +GRN() { echo $'\e[1;32m'$1$'\e[0m'; } +YLW() { echo $'\e[1;33m'$1$'\e[0m'; } + +CURRENT_DIR=$(pwd) +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +# go to parent folder +cd $(dirname $(dirname $(dirname $SCRIPT_DIR))) + +OUTPUT=$1 + +# oracle example program is only deployed to devnet +RPC="https://api.devnet.solana.com" + +if [ -z "$OUTPUT" ]; then + echo "missing output directory" + exit 1 +fi + +# creates the output directory if it doesn't exist +if [ ! -d ${OUTPUT} ]; then + mkdir ${OUTPUT} +fi + +# only prints this if we have external programs +if [ ${#EXTERNAL_ID[@]} -gt 0 ]; then + echo "Dumping external accounts to '${OUTPUT}':" +fi + +# copy external programs or accounts binaries from the chain +copy_from_chain() { + ACCOUNT_TYPE=`echo $1 | cut -d. -f2` + PREFIX=$2 + + case "$ACCOUNT_TYPE" in + "bin") + solana account -u $RPC ${EXTERNAL_ID[$i]} -o ${OUTPUT}/$2$1 > /dev/null + ;; + "so") + solana program dump -u $RPC ${EXTERNAL_ID[$i]} ${OUTPUT}/$2$1 > /dev/null + ;; + *) + echo $(RED "[ ERROR ] unknown account type for '$1'") + exit 1 + ;; + esac + + if [ -z "$PREFIX" ]; then + echo "Wrote account data to ${OUTPUT}/$2$1" + fi +} + +# dump external programs binaries if needed +for i in ${!EXTERNAL_ID[@]}; do + if [ ! -f "${OUTPUT}/${EXTERNAL_SO[$i]}" ]; then + copy_from_chain "${EXTERNAL_SO[$i]}" + else + copy_from_chain "${EXTERNAL_SO[$i]}" "onchain-" + + ON_CHAIN=`sha256sum -b ${OUTPUT}/onchain-${EXTERNAL_SO[$i]} | cut -d ' ' -f 1` + LOCAL=`sha256sum -b ${OUTPUT}/${EXTERNAL_SO[$i]} | cut -d ' ' -f 1` + + if [ "$ON_CHAIN" != "$LOCAL" ]; then + echo $(YLW "[ WARNING ] on-chain and local binaries are different for '${EXTERNAL_SO[$i]}'") + else + echo "$(GRN "[ SKIPPED ]") on-chain and local binaries are the same for '${EXTERNAL_SO[$i]}'" + fi + + rm ${OUTPUT}/onchain-${EXTERNAL_SO[$i]} + fi +done + +# only prints this if we have external programs +if [ ${#EXTERNAL_ID[@]} -gt 0 ]; then + echo "" +fi + +cd ${CURRENT_DIR} \ No newline at end of file diff --git a/configs/validator.cjs b/configs/validator.cjs index 10be4405..58043348 100755 --- a/configs/validator.cjs +++ b/configs/validator.cjs @@ -15,6 +15,11 @@ module.exports = { programId: "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d", deployPath: getProgram("mpl_core_program.so"), }, + { + label: "Mpl Core Oracle Example", + programId: "4RZ7RhXeL4oz4kVX5fpRfkNQ3nz1n4eruqBn2AGPQepo", + deployPath: getProgram("mpl_core_oracle_example.so"), + }, // Below are external programs that should be included in the local validator. // You may configure which ones to fetch from the cluster when building // programs within the `configs/program-scripts/dump.sh` script. diff --git a/idls/mpl_core.json b/idls/mpl_core.json index 5d5e903b..2fc33f39 100644 --- a/idls/mpl_core.json +++ b/idls/mpl_core.json @@ -803,7 +803,7 @@ }, { "name": "authority", - "isMut": false, + "isMut": true, "isSigner": true, "isOptional": true, "docs": [ @@ -863,7 +863,7 @@ }, { "name": "authority", - "isMut": false, + "isMut": true, "isSigner": true, "isOptional": true, "docs": [ diff --git a/programs/mpl-core/src/instruction.rs b/programs/mpl-core/src/instruction.rs index 4ab37ac1..ca1352cf 100644 --- a/programs/mpl-core/src/instruction.rs +++ b/programs/mpl-core/src/instruction.rs @@ -127,7 +127,7 @@ pub(crate) enum MplAssetInstruction { #[account(0, writable, name="asset", desc = "The address of the asset")] #[account(1, optional, writable, name="collection", desc = "The collection to which the asset belongs")] #[account(2, writable, signer, name="payer", desc = "The account paying for the storage fees")] - #[account(3, optional, signer, name="authority", desc = "The owner or delegate of the asset")] + #[account(3, optional, writable, signer, name="authority", desc = "The owner or delegate of the asset")] #[account(4, optional, name="system_program", desc = "The system program")] #[account(5, optional, name="log_wrapper", desc = "The SPL Noop Program")] BurnV1(BurnV1Args), @@ -135,7 +135,7 @@ pub(crate) enum MplAssetInstruction { /// Burn an mpl-core. #[account(0, writable, name="collection", desc = "The address of the asset")] #[account(1, writable, signer, name="payer", desc = "The account paying for the storage fees")] - #[account(2, optional, signer, name="authority", desc = "The owner or delegate of the asset")] + #[account(2, optional, writable, signer, name="authority", desc = "The owner or delegate of the asset")] #[account(3, optional, name="log_wrapper", desc = "The SPL Noop Program")] BurnCollectionV1(BurnCollectionV1Args), diff --git a/programs/mpl-core/src/plugins/lifecycle.rs b/programs/mpl-core/src/plugins/lifecycle.rs index 9f7b4fc9..02033f7f 100644 --- a/programs/mpl-core/src/plugins/lifecycle.rs +++ b/programs/mpl-core/src/plugins/lifecycle.rs @@ -40,6 +40,10 @@ impl ExternalCheckResult { pub(crate) fn none() -> Self { Self { flags: 0 } } + + pub(crate) fn can_reject_only() -> Self { + Self { flags: 0x4 } + } } /// Bitfield representation of lifecycle permissions for external, third party plugins. diff --git a/programs/mpl-core/src/plugins/utils.rs b/programs/mpl-core/src/plugins/utils.rs index 8f2801ca..7bb7b2e3 100644 --- a/programs/mpl-core/src/plugins/utils.rs +++ b/programs/mpl-core/src/plugins/utils.rs @@ -6,6 +6,7 @@ use solana_program::{ use crate::{ error::MplCoreError, + plugins::ExternalCheckResult, state::{AssetV1, Authority, CoreAsset, DataBlob, Key, SolanaAccount}, utils::resize_or_reallocate_account, }; @@ -333,8 +334,7 @@ pub fn initialize_external_plugin<'a, T: DataBlob + SolanaAccount>( // You cannot configure an Oracle plugin to approve lifecycle events. if let Some(lifecycle_checks) = &init_info.lifecycle_checks { for (_, result) in lifecycle_checks { - // Deny is bit 2. - if result.flags != 0x2 { + if *result != ExternalCheckResult::can_reject_only() { return Err(MplCoreError::OracleCanDenyOnly.into()); } }