diff --git a/clients/js/src/generated/errors/mplHybrid.ts b/clients/js/src/generated/errors/mplHybrid.ts index 20b2e3c..04daf6a 100644 --- a/clients/js/src/generated/errors/mplHybrid.ts +++ b/clients/js/src/generated/errors/mplHybrid.ts @@ -239,6 +239,32 @@ export class InvalidAuthorityError extends ProgramError { codeToErrorMap.set(0x1780, InvalidAuthorityError); nameToErrorMap.set('InvalidAuthority', InvalidAuthorityError); +/** CaptureBlocked: Capture is blocked for this recipe */ +export class CaptureBlockedError extends ProgramError { + override readonly name: string = 'CaptureBlocked'; + + readonly code: number = 0x1781; // 6017 + + constructor(program: Program, cause?: Error) { + super('Capture is blocked for this recipe', program, cause); + } +} +codeToErrorMap.set(0x1781, CaptureBlockedError); +nameToErrorMap.set('CaptureBlocked', CaptureBlockedError); + +/** ReleaseBlocked: Release is blocked for this recipe */ +export class ReleaseBlockedError extends ProgramError { + override readonly name: string = 'ReleaseBlocked'; + + readonly code: number = 0x1782; // 6018 + + constructor(program: Program, cause?: Error) { + super('Release is blocked for this recipe', program, cause); + } +} +codeToErrorMap.set(0x1782, ReleaseBlockedError); +nameToErrorMap.set('ReleaseBlocked', ReleaseBlockedError); + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/clients/js/src/generated/instructions/captureV2.ts b/clients/js/src/generated/instructions/captureV2.ts index 7740b16..abe5949 100644 --- a/clients/js/src/generated/instructions/captureV2.ts +++ b/clients/js/src/generated/instructions/captureV2.ts @@ -131,7 +131,7 @@ export function captureV2( }, token: { index: 8, - isWritable: false as boolean, + isWritable: true as boolean, value: input.token ?? null, }, feeTokenAccount: { diff --git a/clients/js/src/generated/types/index.ts b/clients/js/src/generated/types/index.ts index 1482421..48a3af8 100644 --- a/clients/js/src/generated/types/index.ts +++ b/clients/js/src/generated/types/index.ts @@ -6,4 +6,4 @@ * @see https://github.com/metaplex-foundation/kinobi */ -export * from './path'; +export * from './internalPath'; diff --git a/clients/js/src/generated/types/internalPath.ts b/clients/js/src/generated/types/internalPath.ts new file mode 100644 index 0000000..300af47 --- /dev/null +++ b/clients/js/src/generated/types/internalPath.ts @@ -0,0 +1,28 @@ +/** + * 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 { Serializer, scalarEnum } from '@metaplex-foundation/umi/serializers'; + +export enum InternalPath { + NoRerollMetadata, + BlockCapture, + BlockRelease, + BurnOnCapture, + BurnOnRelease, +} + +export type InternalPathArgs = InternalPath; + +export function getInternalPathSerializer(): Serializer< + InternalPathArgs, + InternalPath +> { + return scalarEnum(InternalPath, { + description: 'InternalPath', + }) as Serializer; +} diff --git a/clients/js/src/generated/types/path.ts b/clients/js/src/generated/types/path.ts deleted file mode 100644 index 26607ec..0000000 --- a/clients/js/src/generated/types/path.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 { Serializer, scalarEnum } from '@metaplex-foundation/umi/serializers'; - -export enum Path { - RerollMetadata, -} - -export type PathArgs = Path; - -export function getPathSerializer(): Serializer { - return scalarEnum(Path, { description: 'Path' }) as Serializer< - PathArgs, - Path - >; -} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 7dda878..854930e 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,2 +1,3 @@ export * from './generated'; export * from './plugin'; +export * from './path'; diff --git a/clients/js/src/path.ts b/clients/js/src/path.ts new file mode 100644 index 0000000..4fa1257 --- /dev/null +++ b/clients/js/src/path.ts @@ -0,0 +1,27 @@ +import { InternalPath } from './generated'; + +enum CustomPath { + RerollMetadata = 16, +} + +export const Path = { + ...InternalPath, + ...CustomPath, +} as const; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export type Path = InternalPath | CustomPath; + +export function buildPath(features: Path[]) { + let path = 0; + + // eslint-disable-next-line no-restricted-syntax + for (const feature of features) { + if (feature !== Path.RerollMetadata) { + // eslint-disable-next-line no-bitwise + path |= 1 << feature; + } + } + + return path; +} diff --git a/clients/js/test/v1/capture.test.ts b/clients/js/test/v1/capture.test.ts index c5ff5a2..23ac95b 100644 --- a/clients/js/test/v1/capture.test.ts +++ b/clients/js/test/v1/capture.test.ts @@ -16,6 +16,7 @@ import { transfer, } from '@metaplex-foundation/mpl-core'; import { + buildPath, captureV1, EscrowV1, fetchEscrowV1, @@ -76,7 +77,7 @@ test('it can swap tokens for an asset with reroll', async (t) => { min: 0, amount: 5, feeAmount: 1, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), solFeeAmount: 1000000n, }).sendAndConfirm(umi); @@ -94,7 +95,7 @@ test('it can swap tokens for an asset with reroll', async (t) => { amount: 5n, feeAmount: 1n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: escrow[1], solFeeAmount: 1_000_000n, }); @@ -154,6 +155,134 @@ test('it can swap tokens for an asset with reroll', async (t) => { t.regex(assetAfter.uri, uriRegex); }); +test('it can swap tokens for an asset without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + await initEscrowV1(umi, { + escrow, + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmount: 1, + path: buildPath([Path.NoRerollMetadata]), + solFeeAmount: 1000000n, + }).sendAndConfirm(umi); + + const escrowData = await fetchEscrowV1(umi, escrow); + + t.like(escrowData, { + publicKey: publicKey(escrow), + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmount: 1n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: escrow[1], + solFeeAmount: 1_000_000n, + }); + + const userTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.fail('Escrow token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + const assetBefore = await fetchAsset(umi, assets[0].publicKey); + t.is(assetBefore.owner, publicKey(escrow)); + + await captureV1(umi, { + owner: umi.identity, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: escrowData.feeLocation, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 5n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 994n); + const feeTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + escrowData.feeLocation + ); + t.deepEqual(feeTokenAfter.token.amount, 1n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, umi.identity.publicKey); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); + test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) => { // Given a Umi instance using the project's plugin. const umi = await createUmi(); @@ -205,7 +334,7 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) min: 0, amount: 5, feeAmount: 1, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), solFeeAmount: 1000000n, }).sendAndConfirm(umi); @@ -232,7 +361,7 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) amount: 5n, feeAmount: 1n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: escrow[1], solFeeAmount: 1_000_000n, }); @@ -272,3 +401,121 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) const uriRegex = new RegExp(`${escrowData.uri}\\d+\\.json`); t.regex(assetAfter.uri, uriRegex); }); + +test('it can swap tokens for an asset as UpdateDelegate without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + await initEscrowV1(umi, { + escrow, + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmount: 1, + path: buildPath([Path.NoRerollMetadata]), + solFeeAmount: 1000000n, + }).sendAndConfirm(umi); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(escrow) }, + }, + }).sendAndConfirm(umi); + + const escrowData = await fetchEscrowV1(umi, escrow); + + t.like(escrowData, { + publicKey: publicKey(escrow), + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmount: 1n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: escrow[1], + solFeeAmount: 1_000_000n, + }); + + await captureV1(umi, { + owner: umi.identity, + authority: escrow, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: escrowData.feeLocation, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 5n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 994n); + const feeTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + escrowData.feeLocation + ); + t.deepEqual(feeTokenAfter.token.amount, 1n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, umi.identity.publicKey); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); diff --git a/clients/js/test/v1/release.test.ts b/clients/js/test/v1/release.test.ts index cf7c77a..e9d3bd6 100644 --- a/clients/js/test/v1/release.test.ts +++ b/clients/js/test/v1/release.test.ts @@ -12,6 +12,7 @@ import { } from '@metaplex-foundation/umi/serializers'; import { addCollectionPlugin, fetchAsset } from '@metaplex-foundation/mpl-core'; import { + buildPath, EscrowV1, fetchEscrowV1, initEscrowV1, @@ -61,7 +62,7 @@ test('it can swap an asset for tokens with reroll', async (t) => { min: 0, amount: 5, feeAmount: 1, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), solFeeAmount: 1000000n, }).sendAndConfirm(umi); @@ -79,7 +80,7 @@ test('it can swap an asset for tokens with reroll', async (t) => { amount: 5n, feeAmount: 1n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: escrow[1], solFeeAmount: 1_000_000n, }); @@ -131,6 +132,116 @@ test('it can swap an asset for tokens with reroll', async (t) => { t.is(assetAfter.uri, `${escrowData.uri}captured.json`); }); +test('it can swap an asset for tokens without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV1(umi, { + escrow, + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmount: 1, + path: buildPath([Path.NoRerollMetadata]), + solFeeAmount: 1000000n, + }).sendAndConfirm(umi); + + const escrowData = await fetchEscrowV1(umi, escrow); + + t.like(escrowData, { + publicKey: publicKey(escrow), + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmount: 1n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: escrow[1], + solFeeAmount: 1_000_000n, + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + await releaseV1(umi, { + owner: umi.identity, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: escrowData.feeLocation, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 995n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 5n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, publicKey(escrow)); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); + test('it can swap an asset for tokens as UpdateDelegate with reroll', async (t) => { // Given a Umi instance using the project's plugin. const umi = await createUmi(); @@ -171,7 +282,7 @@ test('it can swap an asset for tokens as UpdateDelegate with reroll', async (t) min: 0, amount: 5, feeAmount: 1, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), solFeeAmount: 1000000n, }).sendAndConfirm(umi); @@ -198,7 +309,7 @@ test('it can swap an asset for tokens as UpdateDelegate with reroll', async (t) amount: 5n, feeAmount: 1n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: escrow[1], solFeeAmount: 1_000_000n, }); @@ -250,3 +361,123 @@ test('it can swap an asset for tokens as UpdateDelegate with reroll', async (t) // Confirm that an asset in the escrow has the correct URI t.is(assetAfter.uri, `${escrowData.uri}captured.json`); }); + +test('it can swap an asset for tokens as UpdateDelegate without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV1(umi, { + escrow, + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmount: 1, + path: buildPath([Path.NoRerollMetadata]), + solFeeAmount: 1000000n, + }).sendAndConfirm(umi); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(escrow) }, + }, + }).sendAndConfirm(umi); + + const escrowData = await fetchEscrowV1(umi, escrow); + + t.like(escrowData, { + publicKey: publicKey(escrow), + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmount: 1n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: escrow[1], + solFeeAmount: 1_000_000n, + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + await releaseV1(umi, { + owner: umi.identity, + authority: escrow, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: escrowData.feeLocation, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 995n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 5n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, publicKey(escrow)); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); diff --git a/clients/js/test/v2/captureV2.test.ts b/clients/js/test/v2/captureV2.test.ts index a89b67d..a934146 100644 --- a/clients/js/test/v2/captureV2.test.ts +++ b/clients/js/test/v2/captureV2.test.ts @@ -16,6 +16,7 @@ import { transfer, } from '@metaplex-foundation/mpl-core'; import { + buildPath, captureV2, EscrowV2, fetchEscrowV2, @@ -92,7 +93,7 @@ test('it can swap tokens for an asset with reroll', async (t) => { feeAmountRelease: 1, solFeeAmountCapture: 890_880n, solFeeAmountRelease: 100_000n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), }).sendAndConfirm(umi); const recipeData = await fetchRecipeV1(umi, recipe); @@ -112,7 +113,7 @@ test('it can swap tokens for an asset with reroll', async (t) => { solFeeAmountCapture: 890_880n, solFeeAmountRelease: 100_000n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: recipe[1], }); @@ -173,6 +174,151 @@ test('it can swap tokens for an asset with reroll', async (t) => { t.regex(assetAfter.uri, uriRegex); }); +test('it can swap tokens for an asset without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 1, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + path: buildPath([Path.NoRerollMetadata]), + }).sendAndConfirm(umi); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 1n, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: recipe[1], + }); + + const userTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.fail('Escrow token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + const assetBefore = await fetchAsset(umi, assets[0].publicKey); + t.is(assetBefore.owner, publicKey(escrow)); + + await captureV2(umi, { + owner: umi.identity, + authority: umi.identity, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 5n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 994n); + const feeTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + feeLocation.publicKey + ); + t.deepEqual(feeTokenAfter.token.amount, 1n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, umi.identity.publicKey); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); + test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) => { // Given a Umi instance using the project's plugin. const umi = await createUmi(); @@ -238,7 +384,7 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) feeAmountRelease: 1, solFeeAmountCapture: 890_880n, solFeeAmountRelease: 100_000n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), }).sendAndConfirm(umi); await addCollectionPlugin(umi, { @@ -267,7 +413,7 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) solFeeAmountCapture: 890_880n, solFeeAmountRelease: 100_000n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), bump: recipe[1], }); @@ -307,3 +453,384 @@ test('it can swap tokens for an asset as UpdateDelegate with reroll', async (t) const uriRegex = new RegExp(`${recipeData.uri}\\d+\\.json`); t.regex(assetAfter.uri, uriRegex); }); + +test('it can swap tokens for an asset as UpdateDelegate without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 1, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + path: buildPath([Path.NoRerollMetadata]), + }).sendAndConfirm(umi); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 1n, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + count: 1n, + path: buildPath([Path.NoRerollMetadata]), + bump: recipe[1], + }); + + await captureV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 5n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 994n); + const feeTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + feeLocation.publicKey + ); + t.deepEqual(feeTokenAfter.token.amount, 1n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, umi.identity.publicKey); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); + +test('it cannot swap tokens for an asset with BlockCapture', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 1, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + path: buildPath([Path.NoRerollMetadata, Path.BlockCapture]), + }).sendAndConfirm(umi); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 1n, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + count: 1n, + path: buildPath([Path.NoRerollMetadata, Path.BlockCapture]), + bump: recipe[1], + }); + + const result = captureV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'CaptureBlocked' }); +}); + +test('it can burn tokens for an asset with BurnOnCapture', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: umi.identity.publicKey, + amount: 1000, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + // Transfer the assets to the escrow. + // eslint-disable-next-line no-restricted-syntax + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await transfer(umi, { + asset, + collection, + newOwner: escrow, + }).sendAndConfirm(umi); + } + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 1, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + path: buildPath([Path.NoRerollMetadata, Path.BurnOnCapture]), + }).sendAndConfirm(umi); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 1n, + solFeeAmountCapture: 890_880n, + solFeeAmountRelease: 100_000n, + count: 1n, + path: buildPath([Path.NoRerollMetadata, Path.BurnOnCapture]), + bump: recipe[1], + }); + + await captureV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi, { send: { skipPreflight: true } }); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + // The tokens are burned so the amount should still be 0. + t.deepEqual(escrowTokenAfter.token.amount, 0n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 994n); + const feeTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + feeLocation.publicKey + ); + t.deepEqual(feeTokenAfter.token.amount, 1n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, umi.identity.publicKey); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); diff --git a/clients/js/test/v2/releaseV2.test.ts b/clients/js/test/v2/releaseV2.test.ts index 0f23f5d..3734d16 100644 --- a/clients/js/test/v2/releaseV2.test.ts +++ b/clients/js/test/v2/releaseV2.test.ts @@ -12,6 +12,7 @@ import { } from '@metaplex-foundation/umi/serializers'; import { addCollectionPlugin, fetchAsset } from '@metaplex-foundation/mpl-core'; import { + buildPath, EscrowV2, fetchEscrowV2, fetchRecipeV1, @@ -23,7 +24,7 @@ import { } from '../../src'; import { createCoreCollection, createUmi } from '../_setup'; -test('it can swap an asset for tokens', async (t) => { +test('it can swap an asset for tokens with reroll', async (t) => { // Given a Umi instance using the project's plugin. const umi = await createUmi(); const feeLocation = generateSigner(umi); @@ -72,7 +73,133 @@ test('it can swap an asset for tokens', async (t) => { feeAmountRelease: 0, solFeeAmountCapture: 100_000n, solFeeAmountRelease: 890_880n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), + }).sendAndConfirm(umi); + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 0n, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + count: 1n, + path: buildPath([Path.RerollMetadata]), + bump: recipe[1], + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + await releaseV2(umi, { + owner: umi.identity, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 995n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 5n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, publicKey(escrow)); + + // Confirm that an asset in the escrow has the correct URI + t.is(assetAfter.uri, `${recipeData.uri}captured.json`); +}); + +test('it can swap an asset for tokens without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 0, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + path: buildPath([Path.NoRerollMetadata]), }).sendAndConfirm(umi); const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ @@ -96,7 +223,7 @@ test('it can swap an asset for tokens', async (t) => { solFeeAmountCapture: 100_000n, solFeeAmountRelease: 890_880n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.NoRerollMetadata]), bump: recipe[1], }); @@ -143,9 +270,12 @@ test('it can swap an asset for tokens', async (t) => { t.deepEqual(userTokenAfter.token.amount, 5n); const assetAfter = await fetchAsset(umi, assets[0].publicKey); t.is(assetAfter.owner, publicKey(escrow)); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); }); -test('it can swap an asset for tokens as UpdateDelegate', async (t) => { +test('it can swap an asset for tokens as UpdateDelegate with reroll', async (t) => { // Given a Umi instance using the project's plugin. const umi = await createUmi(); const feeLocation = generateSigner(umi); @@ -194,7 +324,143 @@ test('it can swap an asset for tokens as UpdateDelegate', async (t) => { feeAmountRelease: 0, solFeeAmountCapture: 100_000n, solFeeAmountRelease: 890_880n, - path: Path.RerollMetadata, + path: buildPath([Path.RerollMetadata]), + }).sendAndConfirm(umi); + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + const recipeData = await fetchRecipeV1(umi, recipe); + t.like(recipeData, { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 0n, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + count: 1n, + path: buildPath([Path.RerollMetadata]), + bump: recipe[1], + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + await releaseV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 995n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 5n); + const assetAfter = await fetchAsset(umi, assets[0].publicKey); + t.is(assetAfter.owner, publicKey(escrow)); + + // Confirm that an asset in the escrow has the correct URI + t.is(assetAfter.uri, `${recipeData.uri}captured.json`); +}); + +test('it can swap an asset for tokens as UpdateDelegate without reroll', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 0, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + path: buildPath([Path.NoRerollMetadata]), }).sendAndConfirm(umi); const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ @@ -227,7 +493,7 @@ test('it can swap an asset for tokens as UpdateDelegate', async (t) => { solFeeAmountCapture: 100_000n, solFeeAmountRelease: 890_880n, count: 1n, - path: Path.RerollMetadata, + path: buildPath([Path.NoRerollMetadata]), bump: recipe[1], }); @@ -275,4 +541,264 @@ test('it can swap an asset for tokens as UpdateDelegate', async (t) => { t.deepEqual(userTokenAfter.token.amount, 5n); const assetAfter = await fetchAsset(umi, assets[0].publicKey); t.is(assetAfter.owner, publicKey(escrow)); + + // Make sure the URI has not changed. + t.is(assetAfter.uri, 'https://example.com/asset'); +}); + +test('it cannot swap an asset for tokens with BlockRelease', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 0, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + path: buildPath([Path.NoRerollMetadata, Path.BlockRelease]), + }).sendAndConfirm(umi); + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + t.like(await fetchRecipeV1(umi, recipe), { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 0n, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + count: 1n, + path: buildPath([Path.NoRerollMetadata, Path.BlockRelease]), + bump: recipe[1], + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + const result = releaseV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + await t.throwsAsync(result, { name: 'ReleaseBlocked' }); +}); + +test('it can burn an asset for tokens with BurnOnRelease', async (t) => { + // Given a Umi instance using the project's plugin. + const umi = await createUmi(); + const feeLocation = generateSigner(umi); + const { assets, collection } = await createCoreCollection(umi); + const tokenMint = generateSigner(umi); + await createFungible(umi, { + name: 'Test Token', + uri: 'www.fungible.com', + sellerFeeBasisPoints: { + basisPoints: 0n, + identifier: '%', + decimals: 2, + }, + mint: tokenMint, + }).sendAndConfirm(umi); + + await initEscrowV2(umi, {}).sendAndConfirm(umi); + + const escrow = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('escrow'), + publicKeySerializer().serialize(umi.identity.publicKey), + ]); + + t.like(await fetchEscrowV2(umi, escrow), { + authority: umi.identity.publicKey, + bump: escrow[1], + }); + + await mintV1(umi, { + mint: tokenMint.publicKey, + tokenStandard: TokenStandard.Fungible, + tokenOwner: escrow, + amount: 1000, + }).sendAndConfirm(umi); + + await initRecipeV1(umi, { + collection: collection.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9, + min: 0, + amount: 5, + feeAmountCapture: 1, + feeAmountRelease: 0, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + path: buildPath([Path.NoRerollMetadata, Path.BurnOnRelease]), + }).sendAndConfirm(umi); + + const recipe = umi.eddsa.findPda(MPL_HYBRID_PROGRAM_ID, [ + string({ size: 'variable' }).serialize('recipe'), + publicKeySerializer().serialize(collection.publicKey), + ]); + + await addCollectionPlugin(umi, { + collection: collection.publicKey, + plugin: { + type: 'UpdateDelegate', + additionalDelegates: [], + authority: { type: 'Address', address: publicKey(recipe) }, + }, + }).sendAndConfirm(umi); + + t.like(await fetchRecipeV1(umi, recipe), { + publicKey: publicKey(recipe), + collection: collection.publicKey, + authority: umi.identity.publicKey, + token: tokenMint.publicKey, + feeLocation: feeLocation.publicKey, + name: 'Test Escrow', + uri: 'www.test.com/', + max: 9n, + min: 0n, + amount: 5n, + feeAmountCapture: 1n, + feeAmountRelease: 0n, + solFeeAmountCapture: 100_000n, + solFeeAmountRelease: 890_880n, + count: 1n, + path: buildPath([Path.NoRerollMetadata, Path.BurnOnRelease]), + bump: recipe[1], + }); + + const escrowTokenBefore = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenBefore.token.amount, 1000n); + try { + await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.fail('User token account should not exist'); + } catch (e) { + t.is(e.name, 'AccountNotFoundError'); + } + + t.is(assets[0].owner, umi.identity.publicKey); + + await releaseV2(umi, { + owner: umi.identity, + authority: recipe, + recipe, + escrow, + asset: assets[0].publicKey, + collection: collection.publicKey, + feeProjectAccount: feeLocation.publicKey, + token: tokenMint.publicKey, + }).sendAndConfirm(umi); + + const escrowTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + publicKey(escrow) + ); + t.deepEqual(escrowTokenAfter.token.amount, 995n); + const userTokenAfter = await fetchDigitalAssetWithAssociatedToken( + umi, + tokenMint.publicKey, + umi.identity.publicKey + ); + t.deepEqual(userTokenAfter.token.amount, 5n); + + // The asset should be burned. + const assetAfter = await umi.rpc.getAccount(assets[0].publicKey); + if (assetAfter.exists) { + t.deepEqual(assetAfter.data, Uint8Array.from([0])); + } else { + t.fail('Asset should exist as burned'); + } }); diff --git a/clients/rust/src/generated/errors/mpl_hybrid.rs b/clients/rust/src/generated/errors/mpl_hybrid.rs index 8e85fae..d8e01da 100644 --- a/clients/rust/src/generated/errors/mpl_hybrid.rs +++ b/clients/rust/src/generated/errors/mpl_hybrid.rs @@ -61,6 +61,12 @@ pub enum MplHybridError { /// 6016 (0x1780) - Invalid Authorities #[error("Invalid Authorities")] InvalidAuthority, + /// 6017 (0x1781) - Capture is blocked for this recipe + #[error("Capture is blocked for this recipe")] + CaptureBlocked, + /// 6018 (0x1782) - Release is blocked for this recipe + #[error("Release is blocked for this recipe")] + ReleaseBlocked, } impl solana_program::program_error::PrintProgramError for MplHybridError { diff --git a/clients/rust/src/generated/instructions/capture_v2.rs b/clients/rust/src/generated/instructions/capture_v2.rs index 41ffbfe..a40a7dc 100644 --- a/clients/rust/src/generated/instructions/capture_v2.rs +++ b/clients/rust/src/generated/instructions/capture_v2.rs @@ -87,7 +87,7 @@ impl CaptureV2 { self.escrow_token_account, false, )); - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( self.token, false, )); accounts.push(solana_program::instruction::AccountMeta::new( @@ -159,7 +159,7 @@ impl CaptureV2InstructionData { /// 5. `[writable]` collection /// 6. `[writable]` user_token_account /// 7. `[writable]` escrow_token_account -/// 8. `[]` token +/// 8. `[writable]` token /// 9. `[writable]` fee_token_account /// 10. `[writable, optional]` fee_sol_account (default to `GjF4LqmEhV33riVyAwHwiEeAHx4XXFn2yMY3fmMigoP3`) /// 11. `[writable]` fee_project_account @@ -543,7 +543,7 @@ impl<'a, 'b> CaptureV2Cpi<'a, 'b> { *self.escrow_token_account.key, false, )); - accounts.push(solana_program::instruction::AccountMeta::new_readonly( + accounts.push(solana_program::instruction::AccountMeta::new( *self.token.key, false, )); @@ -636,7 +636,7 @@ impl<'a, 'b> CaptureV2Cpi<'a, 'b> { /// 5. `[writable]` collection /// 6. `[writable]` user_token_account /// 7. `[writable]` escrow_token_account -/// 8. `[]` token +/// 8. `[writable]` token /// 9. `[writable]` fee_token_account /// 10. `[writable]` fee_sol_account /// 11. `[writable]` fee_project_account diff --git a/clients/rust/src/generated/types/path.rs b/clients/rust/src/generated/types/internal_path.rs similarity index 85% rename from clients/rust/src/generated/types/path.rs rename to clients/rust/src/generated/types/internal_path.rs index a170110..b092750 100644 --- a/clients/rust/src/generated/types/path.rs +++ b/clients/rust/src/generated/types/internal_path.rs @@ -15,6 +15,10 @@ use num_derive::FromPrimitive; #[cfg_attr(not(feature = "anchor"), derive(BorshSerialize, BorshDeserialize))] #[cfg_attr(feature = "anchor", derive(AnchorSerialize, AnchorDeserialize))] #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash, FromPrimitive)] -pub enum Path { - RerollMetadata, +pub enum InternalPath { + NoRerollMetadata, + BlockCapture, + BlockRelease, + BurnOnCapture, + BurnOnRelease, } diff --git a/clients/rust/src/generated/types/mod.rs b/clients/rust/src/generated/types/mod.rs index dd978f4..ea1047f 100644 --- a/clients/rust/src/generated/types/mod.rs +++ b/clients/rust/src/generated/types/mod.rs @@ -5,6 +5,6 @@ //! [https://github.com/metaplex-foundation/kinobi] //! -pub(crate) mod r#path; +pub(crate) mod r#internal_path; -pub use self::r#path::*; +pub use self::r#internal_path::*; diff --git a/configs/kinobi.cjs b/configs/kinobi.cjs index eaa713b..765f506 100644 --- a/configs/kinobi.cjs +++ b/configs/kinobi.cjs @@ -17,6 +17,14 @@ kinobi.update( }) ); +kinobi.update( + k.updateDefinedTypesVisitor({ + Path: { + name: "InternalPath", + }, + }) +); + // Update Accounts. kinobi.update( k.updateAccountsVisitor({ diff --git a/idls/mpl_hybrid.json b/idls/mpl_hybrid.json index 868c807..b4e7957 100644 --- a/idls/mpl_hybrid.json +++ b/idls/mpl_hybrid.json @@ -324,7 +324,7 @@ }, { "name": "token", - "isMut": false, + "isMut": true, "isSigner": false }, { @@ -1327,7 +1327,19 @@ "kind": "enum", "variants": [ { - "name": "RerollMetadata" + "name": "NoRerollMetadata" + }, + { + "name": "BlockCapture" + }, + { + "name": "BlockRelease" + }, + { + "name": "BurnOnCapture" + }, + { + "name": "BurnOnRelease" } ] } @@ -1418,6 +1430,16 @@ "code": 6016, "name": "InvalidAuthority", "msg": "Invalid Authorities" + }, + { + "code": 6017, + "name": "CaptureBlocked", + "msg": "Capture is blocked for this recipe" + }, + { + "code": 6018, + "name": "ReleaseBlocked", + "msg": "Release is blocked for this recipe" } ], "metadata": { diff --git a/programs/mpl-hybrid/src/error.rs b/programs/mpl-hybrid/src/error.rs index 82c5a97..57f40c7 100644 --- a/programs/mpl-hybrid/src/error.rs +++ b/programs/mpl-hybrid/src/error.rs @@ -69,4 +69,12 @@ pub enum MplHybridError { /// 6016 (0x1780) - Invalid Token Account Mint #[msg("Invalid Authorities")] InvalidAuthority, + + /// 6017 (0x1781) - Capture is blocked for this recipe + #[msg("Capture is blocked for this recipe")] + CaptureBlocked, + + /// 6018 (0x1782) - Release is blocked for this recipe + #[msg("Release is blocked for this recipe")] + ReleaseBlocked, } diff --git a/programs/mpl-hybrid/src/instructions/capture.rs b/programs/mpl-hybrid/src/instructions/capture.rs index a2e206b..91d4c0e 100644 --- a/programs/mpl-hybrid/src/instructions/capture.rs +++ b/programs/mpl-hybrid/src/instructions/capture.rs @@ -164,8 +164,8 @@ pub fn handler_capture_v1(ctx: Context) -> Result<()> { assert_signer(&ctx.accounts.authority)?; } - //If the path has bit 0 set, we need to update the metadata onchain - if Path::RerollMetadata.check(escrow.path) { + //If the path has bit 0 unset, we need to update the metadata onchain + if !Path::NoRerollMetadata.check(escrow.path) { let clock = Clock::get()?; // seed for the random number is a combination of the slot_hash - timestamp let recent_slothashes = &ctx.accounts.recent_blockhashes; diff --git a/programs/mpl-hybrid/src/instructions/capture_v2.rs b/programs/mpl-hybrid/src/instructions/capture_v2.rs index 32b1e2f..43ffa30 100644 --- a/programs/mpl-hybrid/src/instructions/capture_v2.rs +++ b/programs/mpl-hybrid/src/instructions/capture_v2.rs @@ -8,8 +8,8 @@ use anchor_lang::{ }; use anchor_lang::{prelude::*, system_program}; use anchor_spl::associated_token::AssociatedToken; -use anchor_spl::token; use anchor_spl::token::Mint; +use anchor_spl::token::{self, Burn}; use anchor_spl::token::{Token, Transfer}; use arrayref::array_ref; use mpl_core::accounts::BaseAssetV1; @@ -69,6 +69,7 @@ pub struct CaptureV2Ctx<'info> { /// CHECK: This is a user defined account #[account( + mut, address = recipe.token @MplHybridError::InvalidMintAccount )] token: Account<'info, Mint>, @@ -131,6 +132,10 @@ pub fn handler_capture_v2(ctx: Context) -> Result<()> { return Err(MplHybridError::InvalidAuthority.into()); } + if Path::BlockCapture.check(recipe.path) { + return Err(MplHybridError::CaptureBlocked.into()); + } + // The user token account should already exist. validate_token_account(user_token_account, &owner.key(), &ctx.accounts.token.key())?; @@ -179,8 +184,8 @@ pub fn handler_capture_v2(ctx: Context) -> Result<()> { assert_signer(authority)?; } - //If the path has bit 0 set, we need to update the metadata onchain - if Path::RerollMetadata.check(recipe.path) { + //If the path has bit 0 unset, we need to update the metadata onchain + if !Path::NoRerollMetadata.check(recipe.path) { let clock = Clock::get()?; // seed for the random number is a combination of the slot_hash - timestamp let recent_slothashes = &ctx.accounts.recent_blockhashes; @@ -252,16 +257,32 @@ pub fn handler_capture_v2(ctx: Context) -> Result<()> { let cpi_program = token_program.to_account_info(); - //create transfer token instruction - let cpi_accounts_transfer = Transfer { - from: user_token_account.to_account_info(), - to: escrow_token_account.to_account_info(), - authority: owner.to_account_info(), - }; + // If the path has burn on capture, we burn the token + if Path::BurnOnCapture.check(recipe.path) { + //create burn instruction + let cpi_accounts_burn = Burn { + mint: ctx.accounts.token.to_account_info(), + from: user_token_account.to_account_info(), + authority: owner.to_account_info(), + }; + + let burn_cpi_ctx = CpiContext::new(cpi_program.clone(), cpi_accounts_burn); + + token::burn(burn_cpi_ctx, recipe.amount)?; + } + // Otherwise, we transfer the token to the escrow + else { + //create transfer token instruction + let cpi_accounts_transfer = Transfer { + from: user_token_account.to_account_info(), + to: escrow_token_account.to_account_info(), + authority: owner.to_account_info(), + }; - let transfer_cpi_ctx = CpiContext::new(cpi_program.clone(), cpi_accounts_transfer); + let transfer_cpi_ctx = CpiContext::new(cpi_program.clone(), cpi_accounts_transfer); - token::transfer(transfer_cpi_ctx, recipe.amount)?; + token::transfer(transfer_cpi_ctx, recipe.amount)?; + } //create transfer fee token instruction let cpi_accounts_fee_transfer = Transfer { diff --git a/programs/mpl-hybrid/src/instructions/release.rs b/programs/mpl-hybrid/src/instructions/release.rs index 5a9641c..d755ed7 100644 --- a/programs/mpl-hybrid/src/instructions/release.rs +++ b/programs/mpl-hybrid/src/instructions/release.rs @@ -167,8 +167,8 @@ pub fn handler_release_v1(ctx: Context) -> Result<()> { assert_signer(&ctx.accounts.authority)?; } - //If the path has bit 0 set, we need to update the metadata onchain - if Path::RerollMetadata.check(escrow.path) { + //If the path has bit 0 unset, we need to update the metadata onchain + if !Path::NoRerollMetadata.check(escrow.path) { //construct the captured uri let mut uri = escrow.uri.clone(); let name = "Captured".to_string(); diff --git a/programs/mpl-hybrid/src/instructions/release_v2.rs b/programs/mpl-hybrid/src/instructions/release_v2.rs index bd2e2cf..19b659a 100644 --- a/programs/mpl-hybrid/src/instructions/release_v2.rs +++ b/programs/mpl-hybrid/src/instructions/release_v2.rs @@ -13,7 +13,8 @@ use anchor_spl::token::Mint; use anchor_spl::token::{Token, Transfer}; use mpl_core::accounts::BaseAssetV1; use mpl_core::instructions::{ - TransferV1Cpi, TransferV1InstructionArgs, UpdateV1Cpi, UpdateV1InstructionArgs, + BurnV1Cpi, BurnV1InstructionArgs, TransferV1Cpi, TransferV1InstructionArgs, UpdateV1Cpi, + UpdateV1InstructionArgs, }; use mpl_core::types::UpdateAuthority; use mpl_utils::assert_signer; @@ -132,6 +133,10 @@ pub fn handler_release_v2(ctx: Context) -> Result<()> { return Err(MplHybridError::InvalidAuthority.into()); } + if Path::BlockRelease.check(recipe.path) { + return Err(MplHybridError::ReleaseBlocked.into()); + } + // Create idempotent if user_token_account.owner == &system_program::ID { solana_program::msg!("Creating user token account"); @@ -182,60 +187,86 @@ pub fn handler_release_v2(ctx: Context) -> Result<()> { assert_signer(authority)?; } - //If the path has bit 0 set, we need to update the metadata onchain - if Path::RerollMetadata.check(recipe.path) { - //construct the captured uri - let mut uri = recipe.uri.clone(); - let name = "Captured".to_string(); - let json_extension = ".json"; - - uri.push_str("captured"); - uri.push_str(json_extension); - - //create update instruction - let update_ix = UpdateV1Cpi { + // If the path has burn on release, we burn the Asset + if Path::BurnOnRelease.check(recipe.path) { + //create burn instruction + let burn_nft_ix = BurnV1Cpi { __program: &mpl_core.to_account_info(), asset: &asset.to_account_info(), collection: Some(collection_info), payer: &owner.to_account_info(), - authority: Some(authority_info), - system_program: &system_program.to_account_info(), + authority: Some(owner_info), + system_program: Some(system_info), log_wrapper: None, - __args: UpdateV1InstructionArgs { - new_name: Some(name), - new_uri: Some(uri), - new_update_authority: None, + __args: BurnV1InstructionArgs { + compression_proof: None, }, }; - if authority_info.key == &recipe.authority { - //invoke the update instruction - update_ix.invoke()?; - } else if authority_info.key == &recipe.key() { - // The auth has been delegated as the UpdateDelegate on the asset. - update_ix.invoke_signed(&[&[b"recipe", collection.key.as_ref(), &[recipe.bump]]])?; - } else { - return Err(MplHybridError::InvalidUpdateAuthority.into()); - } + //invoke the burn instruction + burn_nft_ix.invoke()?; } + // Otherwise, we transfer the Asset to the escrow + else { + //If the path has bit 0 unset, we need to update the metadata onchain + if !Path::NoRerollMetadata.check(recipe.path) { + //construct the captured uri + let mut uri = recipe.uri.clone(); + let name = "Captured".to_string(); + let json_extension = ".json"; + + uri.push_str("captured"); + uri.push_str(json_extension); + + //create update instruction + let update_ix = UpdateV1Cpi { + __program: &mpl_core.to_account_info(), + asset: &asset.to_account_info(), + collection: Some(collection_info), + payer: &owner.to_account_info(), + authority: Some(authority_info), + system_program: &system_program.to_account_info(), + log_wrapper: None, + __args: UpdateV1InstructionArgs { + new_name: Some(name), + new_uri: Some(uri), + new_update_authority: None, + }, + }; + + if authority_info.key == &recipe.authority { + //invoke the update instruction + update_ix.invoke()?; + } else if authority_info.key == &recipe.key() { + // The auth has been delegated as the UpdateDelegate on the asset. + update_ix.invoke_signed(&[&[ + b"recipe", + collection.key.as_ref(), + &[recipe.bump], + ]])?; + } else { + return Err(MplHybridError::InvalidUpdateAuthority.into()); + } + } - //create transfer instruction - let transfer_nft_ix = TransferV1Cpi { - __program: &mpl_core.to_account_info(), - asset: &asset.to_account_info(), - collection: Some(collection_info), - payer: &owner.to_account_info(), - authority: Some(owner_info), - new_owner: &escrow.to_account_info(), - system_program: Some(system_info), - log_wrapper: None, - __args: TransferV1InstructionArgs { - compression_proof: None, - }, - }; + //create transfer instruction + let transfer_nft_ix = TransferV1Cpi { + __program: &mpl_core.to_account_info(), + asset: &asset.to_account_info(), + collection: Some(collection_info), + payer: &owner.to_account_info(), + authority: Some(owner_info), + new_owner: &escrow.to_account_info(), + system_program: Some(system_info), + log_wrapper: None, + __args: TransferV1InstructionArgs { + compression_proof: None, + }, + }; - //invoke the transfer instruction - transfer_nft_ix.invoke()?; + //invoke the transfer instruction + transfer_nft_ix.invoke()?; + } //create transfer token instruction let cpi_program = token_program.to_account_info(); diff --git a/programs/mpl-hybrid/src/state/path.rs b/programs/mpl-hybrid/src/state/path.rs index 70ad63c..adbee3b 100644 --- a/programs/mpl-hybrid/src/state/path.rs +++ b/programs/mpl-hybrid/src/state/path.rs @@ -2,11 +2,15 @@ use anchor_lang::prelude::*; #[derive(Debug, Clone, PartialEq, Eq, AnchorSerialize, AnchorDeserialize)] pub enum Path { - RerollMetadata, + NoRerollMetadata, + BlockCapture, + BlockRelease, + BurnOnCapture, + BurnOnRelease, } impl Path { pub fn check(self, bits: u16) -> bool { - bits & (1 << (self as u16)) == 0 + bits & (1 << (self as u16)) != 0 } } diff --git a/programs/mpl-hybrid/src/state/recipe.rs b/programs/mpl-hybrid/src/state/recipe.rs index 1878c13..5b23217 100644 --- a/programs/mpl-hybrid/src/state/recipe.rs +++ b/programs/mpl-hybrid/src/state/recipe.rs @@ -32,7 +32,7 @@ pub struct RecipeV1 { pub count: u64, //1 onchain/offchain metadata update path pub path: u16, - //1 escrow bump + //1 recipe bump pub bump: u8, }