diff --git a/clients/js/src/defaultGuards/assetGate.ts b/clients/js/src/defaultGuards/assetGate.ts new file mode 100644 index 0000000..7227dff --- /dev/null +++ b/clients/js/src/defaultGuards/assetGate.ts @@ -0,0 +1,32 @@ +import { PublicKey } from '@metaplex-foundation/umi'; +import { getAssetGateSerializer, AssetGate, AssetGateArgs } from '../generated'; +import { GuardManifest, noopParser } from '../guards'; + +/** + * The assetGate guard restricts minting to holders + * of a specified NFT collection. + * + * This means the mint address of an NFT from this + * collection must be passed when minting. + */ +export const assetGateGuardManifest: GuardManifest< + AssetGateArgs, + AssetGate, + AssetGateMintArgs +> = { + name: 'assetGate', + serializer: getAssetGateSerializer, + mintParser: (context, mintContext, args) => ({ + data: new Uint8Array(), + remainingAccounts: [{ publicKey: args.asset, isWritable: false }], + }), + routeParser: noopParser, +}; + +export type AssetGateMintArgs = { + /** + * The address of an Asset from the required + * collection that belongs to the payer. + */ + asset: PublicKey; +}; diff --git a/clients/js/src/defaultGuards/default.ts b/clients/js/src/defaultGuards/default.ts index c4ed8dd..ce97c13 100644 --- a/clients/js/src/defaultGuards/default.ts +++ b/clients/js/src/defaultGuards/default.ts @@ -10,6 +10,8 @@ import { AssetBurnArgs, AssetBurnMulti, AssetBurnMultiArgs, + AssetGate, + AssetGateArgs, AssetMintLimit, AssetMintLimitArgs, AssetPayment, @@ -93,6 +95,7 @@ import { AssetBurnMintArgs } from './assetBurn'; import { AssetMintLimitMintArgs } from './assetMintLimit'; import { AssetBurnMultiMintArgs } from './assetBurnMulti'; import { AssetPaymentMultiMintArgs } from './assetPaymentMulti'; +import { AssetGateMintArgs } from './assetGate'; /** * The arguments for all default Candy Machine guards. @@ -127,6 +130,7 @@ export type DefaultGuardSetArgs = GuardSetArgs & { assetMintLimit: OptionOrNullable; assetBurnMulti: OptionOrNullable; assetPaymentMulti: OptionOrNullable; + assetGate: OptionOrNullable; }; /** @@ -162,6 +166,7 @@ export type DefaultGuardSet = GuardSet & { assetMintLimit: Option; assetBurnMulti: Option; assetPaymentMulti: Option; + assetGate: Option; }; /** @@ -197,6 +202,7 @@ export type DefaultGuardSetMintArgs = GuardSetMintArgs & { assetMintLimit: OptionOrNullable; assetBurnMulti: OptionOrNullable; assetPaymentMulti: OptionOrNullable; + assetGate: OptionOrNullable; }; /** @@ -257,6 +263,7 @@ export const defaultCandyGuardNames: string[] = [ 'assetMintLimit', 'assetBurnMulti', 'assetPaymentMulti', + 'assetGate', ]; /** @internal */ diff --git a/clients/js/src/defaultGuards/index.ts b/clients/js/src/defaultGuards/index.ts index a7cde5f..12bd9f0 100644 --- a/clients/js/src/defaultGuards/index.ts +++ b/clients/js/src/defaultGuards/index.ts @@ -28,3 +28,4 @@ export * from './assetBurn'; export * from './assetMintLimit'; export * from './assetBurnMulti'; export * from './assetPaymentMulti'; +export * from './assetGate'; diff --git a/clients/js/src/generated/types/assetGate.ts b/clients/js/src/generated/types/assetGate.ts new file mode 100644 index 0000000..934f8a3 --- /dev/null +++ b/clients/js/src/generated/types/assetGate.ts @@ -0,0 +1,32 @@ +/** + * This code was AUTOGENERATED using the kinobi library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun kinobi to update it. + * + * @see https://github.com/metaplex-foundation/kinobi + */ + +import { PublicKey } from '@metaplex-foundation/umi'; +import { + Serializer, + publicKey as publicKeySerializer, + struct, +} from '@metaplex-foundation/umi/serializers'; + +/** + * Guard that restricts the transaction to holders of a specified collection. + * + * List of accounts required: + * + * 0. `[]` Account of the Asset. + */ + +export type AssetGate = { requiredCollection: PublicKey }; + +export type AssetGateArgs = AssetGate; + +export function getAssetGateSerializer(): Serializer { + return struct([['requiredCollection', publicKeySerializer()]], { + description: 'AssetGate', + }) as Serializer; +} diff --git a/clients/js/src/generated/types/guardType.ts b/clients/js/src/generated/types/guardType.ts index 3959032..9db9ba1 100644 --- a/clients/js/src/generated/types/guardType.ts +++ b/clients/js/src/generated/types/guardType.ts @@ -39,6 +39,7 @@ export enum GuardType { AssetMintLimit, AssetBurnMulti, AssetPaymentMulti, + AssetGate, } export type GuardTypeArgs = GuardType; diff --git a/clients/js/src/generated/types/index.ts b/clients/js/src/generated/types/index.ts index 8af71ba..3e94d6c 100644 --- a/clients/js/src/generated/types/index.ts +++ b/clients/js/src/generated/types/index.ts @@ -11,6 +11,7 @@ export * from './allocation'; export * from './allowList'; export * from './assetBurn'; export * from './assetBurnMulti'; +export * from './assetGate'; export * from './assetMintLimit'; export * from './assetPayment'; export * from './assetPaymentMulti'; diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts index d3443b6..a381731 100644 --- a/clients/js/src/plugin.ts +++ b/clients/js/src/plugin.ts @@ -31,6 +31,7 @@ import { assetMintLimitGuardManifest, assetBurnMultiGuardManifest, assetPaymentMultiGuardManifest, + assetGateGuardManifest, } from './defaultGuards'; import { createMplCoreCandyGuardProgram, @@ -93,7 +94,8 @@ export const mplCandyMachine = (): UmiPlugin => ({ assetBurnGuardManifest, assetMintLimitGuardManifest, assetBurnMultiGuardManifest, - assetPaymentMultiGuardManifest + assetPaymentMultiGuardManifest, + assetGateGuardManifest ); }, }); diff --git a/clients/js/test/defaultGuards/assetGate.test.ts b/clients/js/test/defaultGuards/assetGate.test.ts new file mode 100644 index 0000000..2112b0f --- /dev/null +++ b/clients/js/test/defaultGuards/assetGate.test.ts @@ -0,0 +1,286 @@ +import { setComputeUnitLimit } from '@metaplex-foundation/mpl-toolbox'; +import { + generateSigner, + sol, + some, + transactionBuilder, +} from '@metaplex-foundation/umi'; +import test from 'ava'; +import { transferV1 } from '@metaplex-foundation/mpl-core'; +import { mintV1 } from '../../src'; +import { + assertBotTax, + assertSuccessfulMint, + createCollection, + createUmi, + createV2, + createAsset, +} from '../_setup'; + +test('it allows minting when the payer owns an asset from a certain collection', async (t) => { + // Given the identity owns an NFT from a certain collection. + const umi = await createUmi(); + const requiredCollectionAuthority = generateSigner(umi); + const { publicKey: requiredCollection } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthority.publicKey, + }); + const nftToVerify = await createAsset(umi, { + owner: umi.identity.publicKey, + collection: requiredCollection, + authority: requiredCollectionAuthority, + }); + + // And a loaded Candy Machine with an assetGate guard. + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + assetGate: some({ requiredCollection }), + }, + }); + + // When we mint from it. + const mint = generateSigner(umi); + await transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + collection, + mintArgs: { + assetGate: some({ asset: nftToVerify.publicKey }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then minting was successful. + await assertSuccessfulMint(t, umi, { mint, owner: umi.identity }); +}); + +test('it allows minting even when the payer is different from the minter', async (t) => { + // Given a separate minter that owns an NFT from a certain collection. + const umi = await createUmi(); + const minter = generateSigner(umi); + const requiredCollectionAuthority = generateSigner(umi); + const { publicKey: requiredCollection } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthority.publicKey, + }); + const nftToVerify = await createAsset(umi, { + owner: minter.publicKey, + collection: requiredCollection, + authority: requiredCollectionAuthority, + }); + + // And a loaded Candy Machine with an assetGate guard. + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + assetGate: some({ requiredCollection }), + }, + }); + + // When we mint from it. + const mint = generateSigner(umi); + await transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + minter, + collection, + mintArgs: { + assetGate: some({ asset: nftToVerify.publicKey }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then minting was successful. + await assertSuccessfulMint(t, umi, { mint, owner: minter }); +}); + +test('it forbids minting when the payer does not own an NFT from a certain collection', async (t) => { + // Given the identity owns an NFT from a certain collection. + const umi = await createUmi(); + const requiredCollectionAuthority = generateSigner(umi); + const { publicKey: requiredCollection } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthority.publicKey, + }); + const { publicKey: nftToVerify } = await createAsset(umi, { + owner: umi.identity.publicKey, + collection: requiredCollection, + authority: requiredCollectionAuthority, + }); + + // But sent their NFT to another wallet. + const destination = generateSigner(umi).publicKey; + await transactionBuilder() + .add( + transferV1(umi, { + authority: umi.identity, + newOwner: destination, + asset: nftToVerify, + collection: requiredCollection, + }) + ) + .sendAndConfirm(umi); + + // And a loaded Candy Machine with an assetGate guard on that collection. + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + assetGate: some({ requiredCollection }), + }, + }); + + // When the payer tries to mint from it. + const mint = generateSigner(umi); + const promise = transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + collection, + mintArgs: { + assetGate: some({ asset: nftToVerify }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then we expect an error. + await t.throwsAsync(promise, { message: /MissingNft/ }); +}); + +test('it forbids minting when the payer tries to provide an NFT from the wrong collection', async (t) => { + // Given the identity owns an NFT from a collection A. + const umi = await createUmi(); + const requiredCollectionAuthorityA = generateSigner(umi); + const { publicKey: requiredCollectionA } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthorityA.publicKey, + }); + const { publicKey: nftToVerify } = await createAsset(umi, { + owner: umi.identity.publicKey, + collection: requiredCollectionA, + authority: requiredCollectionAuthorityA, + }); + + // And a loaded Candy Machine with an assetGate guard on a Collection B. + const requiredCollectionAuthorityB = generateSigner(umi); + const { publicKey: requiredCollectionB } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthorityB.publicKey, + }); + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + assetGate: some({ requiredCollection: requiredCollectionB }), + }, + }); + + // When the identity tries to mint from it using its collection A NFT. + const mint = generateSigner(umi); + const promise = transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + collection, + mintArgs: { + assetGate: some({ asset: nftToVerify }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then we expect an error. + // console.log(await umi.rpc.getTransaction((await promise).signature)); + await t.throwsAsync(promise, { message: /InvalidNftCollection/ }); +}); + +test('it forbids minting when the payer tries to provide an NFT from an unverified collection', async (t) => { + // Given a payer that owns an unverified NFT from a certain collection. + const umi = await createUmi(); + const requiredCollectionAuthority = generateSigner(umi); + const { publicKey: requiredCollection } = await createCollection(umi, { + updateAuthority: requiredCollectionAuthority.publicKey, + }); + const { publicKey: nftToVerify } = await createAsset(umi, { + owner: umi.identity.publicKey, + }); + + // And a loaded Candy Machine with an assetGate guard. + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + assetGate: some({ requiredCollection }), + }, + }); + + // When the payer tries to mint from it using its unverified NFT. + const mint = generateSigner(umi); + const promise = transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + collection, + mintArgs: { + assetGate: some({ asset: nftToVerify }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then we expect an error. + await t.throwsAsync(promise, { message: /InvalidNftCollection/ }); +}); + +test('it charges a bot tax when trying to mint without owning the right NFT', async (t) => { + // Given a loaded Candy Machine with an assetGate guard and a bot tax guard. + const umi = await createUmi(); + const { publicKey: requiredCollection } = await createCollection(umi); + const collection = (await createCollection(umi)).publicKey; + const { publicKey: candyMachine } = await createV2(umi, { + collection, + configLines: [{ name: 'Degen #1', uri: 'https://example.com/degen/1' }], + guards: { + botTax: some({ lamports: sol(0.1), lastInstruction: true }), + assetGate: some({ requiredCollection }), + }, + }); + + // When we try to mint from it using any NFT that's not from the required collection. + const wrongNft = await createAsset(umi, {}); + const mint = generateSigner(umi); + const { signature } = await transactionBuilder() + .add(setComputeUnitLimit(umi, { units: 600_000 })) + .add( + mintV1(umi, { + candyMachine, + asset: mint, + collection, + mintArgs: { + assetGate: some({ asset: wrongNft.publicKey }), + }, + }) + ) + .sendAndConfirm(umi); + + // Then we expect a bot tax error. + await assertBotTax(t, umi, mint, signature, /InvalidNftCollection/); +}); diff --git a/idls/candy_guard.json b/idls/candy_guard.json index 37726f3..6920b2c 100644 --- a/idls/candy_guard.json +++ b/idls/candy_guard.json @@ -601,6 +601,25 @@ ] } }, + { + "name": "AssetGate", + "docs": [ + "Guard that restricts the transaction to holders of a specified collection.", + "", + "List of accounts required:", + "", + "0. `[]` Account of the Asset." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "requiredCollection", + "type": "publicKey" + } + ] + } + }, { "name": "AssetMintLimit", "docs": [ @@ -1660,6 +1679,17 @@ "defined": "AssetPaymentMulti" } } + }, + { + "name": "assetGate", + "docs": [ + "Asset Gate (restrict access to holders of a specific asset)." + ], + "type": { + "option": { + "defined": "AssetGate" + } + } } ] } @@ -1775,6 +1805,9 @@ }, { "name": "AssetPaymentMulti" + }, + { + "name": "AssetGate" } ] } diff --git a/programs/candy-guard/program/src/guards/asset_gate.rs b/programs/candy-guard/program/src/guards/asset_gate.rs new file mode 100644 index 0000000..bb0e5c6 --- /dev/null +++ b/programs/candy-guard/program/src/guards/asset_gate.rs @@ -0,0 +1,66 @@ +use mpl_core::accounts::BaseAssetV1; + +use super::*; +use crate::{errors::CandyGuardError, state::GuardType, utils::assert_keys_equal}; + +/// Guard that restricts the transaction to holders of a specified collection. +/// +/// List of accounts required: +/// +/// 0. `[]` Account of the Asset. +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] +pub struct AssetGate { + pub required_collection: Pubkey, +} + +impl Guard for AssetGate { + fn size() -> usize { + 32 // required_collection + } + + fn mask() -> u64 { + GuardType::as_mask(GuardType::AssetGate) + } +} + +impl Condition for AssetGate { + fn validate<'info>( + &self, + ctx: &mut EvaluationContext, + _guard_set: &GuardSet, + _mint_args: &[u8], + ) -> Result<()> { + let index = ctx.account_cursor; + // validates that we received all required accounts + let asset_account = try_get_account_info(ctx.accounts.remaining, index)?; + ctx.account_cursor += 1; + + Self::verify_collection( + asset_account, + &self.required_collection, + ctx.accounts.minter.key, + ) + } +} + +impl AssetGate { + pub fn verify_collection( + asset_account: &AccountInfo, + collection: &Pubkey, + owner: &Pubkey, + ) -> Result<()> { + // validates the metadata information + assert_keys_equal(asset_account.owner, &mpl_core::ID)?; + + let asset: BaseAssetV1 = BaseAssetV1::try_from(asset_account)?; + if asset.update_authority != UpdateAuthority::Collection(*collection) { + return Err(CandyGuardError::InvalidNftCollection.into()); + } + + if asset.owner != *owner { + return Err(CandyGuardError::MissingNft.into()); + } + + Ok(()) + } +} diff --git a/programs/candy-guard/program/src/guards/mod.rs b/programs/candy-guard/program/src/guards/mod.rs index b6a81d0..2986da3 100644 --- a/programs/candy-guard/program/src/guards/mod.rs +++ b/programs/candy-guard/program/src/guards/mod.rs @@ -18,6 +18,7 @@ pub use allocation::Allocation; pub use allow_list::AllowList; pub use asset_burn::AssetBurn; pub use asset_burn_multi::AssetBurnMulti; +pub use asset_gate::AssetGate; pub use asset_mint_limit::AssetMintLimit; pub use asset_payment::AssetPayment; pub use asset_payment_multi::AssetPaymentMulti; @@ -48,6 +49,7 @@ mod allocation; mod allow_list; mod asset_burn; mod asset_burn_multi; +mod asset_gate; mod asset_mint_limit; mod asset_payment; mod asset_payment_multi; diff --git a/programs/candy-guard/program/src/state/candy_guard.rs b/programs/candy-guard/program/src/state/candy_guard.rs index 23ee9f5..38fe5d5 100644 --- a/programs/candy-guard/program/src/state/candy_guard.rs +++ b/programs/candy-guard/program/src/state/candy_guard.rs @@ -140,6 +140,8 @@ pub struct GuardSet { pub asset_burn_multi: Option, /// Asset Payment Multi (multi pay Assets). pub asset_payment_multi: Option, + /// Asset Gate (restrict access to holders of a specific asset). + pub asset_gate: Option, } /// Available guard types. @@ -174,6 +176,7 @@ pub enum GuardType { AssetMintLimit, AssetBurnMulti, AssetPaymentMulti, + AssetGate, } impl GuardType {