diff --git a/scripts/compareApiSnapshot.ts b/scripts/compareApiSnapshot.ts index c0f2b17c28..ce826f3357 100644 --- a/scripts/compareApiSnapshot.ts +++ b/scripts/compareApiSnapshot.ts @@ -103,7 +103,7 @@ function compareSnapshots(base: APISnapshot, current: APISnapshot) { const currMethods = new Map(currExport.map(m => [m.name, m])); - for (const baseMethod of baseExport) { + for (const baseMethod of baseExport || []) { const currMethod = currMethods.get(baseMethod.name); if (!currMethod) { diff --git a/src/api/entities/Asset/NonFungible/NftCollection.ts b/src/api/entities/Asset/NonFungible/NftCollection.ts index 7d46013ca6..cb7c64ee44 100644 --- a/src/api/entities/Asset/NonFungible/NftCollection.ts +++ b/src/api/entities/Asset/NonFungible/NftCollection.ts @@ -10,6 +10,7 @@ import { assetQuery, assetTransactionQuery } from '~/middleware/queries/assets'; import { Query } from '~/middleware/types'; import { AssetDetails, + BatchIssueNftParams, CollectionKey, ErrorCode, EventIdentifier, @@ -54,6 +55,19 @@ const sumNftIssuance = ( return numberIssued; }; +/** + * @hidden + */ +export function issueNftTransformer([nft]: Nft[]): Nft { + if (!nft) { + throw new PolymeshError({ + code: ErrorCode.DataUnavailable, + message: 'Expected at least one Nft', + }); + } + return nft; +} + /** * Class used to manage NFT functionality */ @@ -65,7 +79,14 @@ export class NftCollection extends BaseAsset { * * @note Each NFT requires metadata for each value returned by `collectionKeys`. The SDK and chain only validate the presence of these fields. Additional validation may be needed to ensure each value complies with the specification. */ - public issue: ProcedureMethod; + public issue: ProcedureMethod; + + /** + * Issues mulitple NFTs for the collection + * + * @note Each NFT requires metadata for each value returned by `collectionKeys`. The SDK and chain only validate the presence of these fields. Additional validation may be needed to ensure each value complies with the specification. + */ + public batchIssue: ProcedureMethod; /** * Force a transfer from the origin portfolio to one of the caller's portfolios @@ -89,6 +110,17 @@ export class NftCollection extends BaseAsset { this.settlements = new NonFungibleSettlements(this, context); this.issue = createProcedureMethod( + { + getProcedureAndArgs: ({ metadata, portfolioId }) => [ + issueNft, + { collection: this, metadataList: [metadata], portfolioId }, + ], + transformer: issueNftTransformer, + }, + context + ); + + this.batchIssue = createProcedureMethod( { getProcedureAndArgs: args => [issueNft, { collection: this, ...args }], }, diff --git a/src/api/entities/Asset/__tests__/NonFungible/NftCollection.ts b/src/api/entities/Asset/__tests__/NonFungible/NftCollection.ts index a88b0bd490..8214dfb64c 100644 --- a/src/api/entities/Asset/__tests__/NonFungible/NftCollection.ts +++ b/src/api/entities/Asset/__tests__/NonFungible/NftCollection.ts @@ -8,6 +8,7 @@ import { Context, DefaultPortfolio, Entity, + issueNftTransformer, NftCollection, PolymeshError, PolymeshTransaction, @@ -513,7 +514,14 @@ describe('NftCollection class', () => { 'someTransaction' as unknown as PolymeshTransaction; when(procedureMockUtils.getPrepareMock()) - .calledWith({ args: { collection, ...args }, transformer: undefined }, context, {}) + .calledWith( + { + args: { collection, metadataList: [args.metadata], portfolioId: args.portfolioId }, + transformer: issueNftTransformer, + }, + context, + {} + ) .mockResolvedValue(expectedTransaction); const tx = await collection.issue(args); @@ -522,6 +530,37 @@ describe('NftCollection class', () => { }); }); + describe('method: batchIssue', () => { + it('should prepare the procedure with the correct arguments and context, and return the resulting transaction', async () => { + const assetId = '12341234-1234-1234-1234-123412341234'; + const context = dsMockUtils.getContextInstance(); + const collection = new NftCollection({ assetId }, context); + + const args = { + metadataList: [[]], + portfolioId: new BigNumber(1), + }; + + const expectedTransaction = + 'someTransaction' as unknown as PolymeshTransaction; + + when(procedureMockUtils.getPrepareMock()) + .calledWith( + { + args: { collection, ...args }, + transformer: undefined, + }, + context, + {} + ) + .mockResolvedValue(expectedTransaction); + + const tx = await collection.batchIssue(args); + + expect(tx).toBe(expectedTransaction); + }); + }); + describe('method: controllerTransfer', () => { it('should prepare the procedure with the correct arguments and context, and return the resulting transaction', async () => { const assetId = '12341234-1234-1234-1234-123412341234'; @@ -702,4 +741,20 @@ describe('NftCollection class', () => { expect(nftCollection.toHuman()).toBe('12341234-1234-1234-1234123412341234'); }); }); + + describe('issueNftTransformer', () => { + it('should return a single Nft', () => { + const id = new BigNumber(1); + const assetId = '12341234-1234-1234-1234-123412341234'; + + const result = issueNftTransformer([entityMockUtils.getNftInstance({ id, assetId })]); + + expect(result.id).toEqual(id); + expect(result.collection.id).toBe(assetId); + }); + + it('should throw if no Nft is provided', () => { + expect(() => issueNftTransformer([])).toThrow('Expected at least one Nft'); + }); + }); }); diff --git a/src/api/procedures/__tests__/issueNft.ts b/src/api/procedures/__tests__/issueNft.ts index 4b6467813d..cc4584788c 100644 --- a/src/api/procedures/__tests__/issueNft.ts +++ b/src/api/procedures/__tests__/issueNft.ts @@ -6,7 +6,7 @@ import { when } from 'jest-when'; import { Nft } from '~/api/entities/Asset/NonFungible/Nft'; import { getAuthorization, - issueNftResolver, + issuedNftsResolver, Params, prepareIssueNft, } from '~/api/procedures/issueNft'; @@ -105,26 +105,30 @@ describe('issueNft procedure', () => { it('should return an issueNft transaction spec', async () => { const args = { - metadata: [], + metadataList: [[]], collection, }; nftInputToMetadataValueSpy.mockReturnValue([]); mockContext.getSigningIdentity.mockResolvedValue(entityMockUtils.getIdentityInstance()); const transaction = dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const result = await prepareIssueNft.call(proc, args); expect(result).toEqual({ - transaction, - args: [rawAssetId, [], defaultPortfolioKind], + transactions: [ + { + transaction, + args: [rawAssetId, [], defaultPortfolioKind], + }, + ], resolver: expect.any(Function), }); }); it('should issue tokens to Default portfolio if portfolioId is not specified', async () => { const args = { - metadata: [], + metadataList: [[]], collection, }; mockContext.getSigningIdentity.mockResolvedValue( @@ -132,19 +136,23 @@ describe('issueNft procedure', () => { ); nftInputToMetadataValueSpy.mockReturnValue([]); const transaction = dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const result = await prepareIssueNft.call(proc, args); expect(result).toEqual({ - transaction, - args: [rawAssetId, [], defaultPortfolioKind], + transactions: [ + { + transaction, + args: [rawAssetId, [], defaultPortfolioKind], + }, + ], resolver: expect.any(Function), }); }); it('should issue tokens to Default portfolio if default portfolioId is provided', async () => { const args = { - metadata: [], + metadataList: [[]], collection, portfolioId: defaultPortfolioId, }; @@ -153,19 +161,23 @@ describe('issueNft procedure', () => { ); nftInputToMetadataValueSpy.mockReturnValue([]); const transaction = dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const result = await prepareIssueNft.call(proc, args); expect(result).toEqual({ - transaction, - args: [rawAssetId, [], defaultPortfolioKind], + transactions: [ + { + transaction, + args: [rawAssetId, [], defaultPortfolioKind], + }, + ], resolver: expect.any(Function), }); }); it('should issue the Nft to the Numbered portfolio that is specified', async () => { const args = { - metadata: [], + metadataList: [[]], collection, portfolioId: numberedPortfolioId, }; @@ -176,19 +188,23 @@ describe('issueNft procedure', () => { nftInputToMetadataValueSpy.mockReturnValue([]); const transaction = dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const result = await prepareIssueNft.call(proc, args); expect(result).toEqual({ - transaction, - args: [rawAssetId, [], numberedPortfolioKind], + transactions: [ + { + transaction, + args: [rawAssetId, [], numberedPortfolioKind], + }, + ], resolver: expect.any(Function), }); }); it('should throw if unneeded metadata is provided', () => { const args = { - metadata: [{ type: MetadataType.Local, id: new BigNumber(1), value: 'test' }], + metadataList: [[{ type: MetadataType.Local, id: new BigNumber(1), value: 'test' }]], collection, }; nftInputToMetadataValueSpy.mockReturnValue([]); @@ -196,7 +212,7 @@ describe('issueNft procedure', () => { dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const expectedError = new PolymeshError({ code: ErrorCode.ValidationError, @@ -208,7 +224,7 @@ describe('issueNft procedure', () => { it('should throw if not all needed metadata is given', () => { const args = { - metadata: [], + metadataList: [[]], collection: entityMockUtils.getNftCollectionInstance({ collectionKeys: [ { type: MetadataType.Global, id: new BigNumber(1), specs: {}, name: 'Example Global' }, @@ -221,7 +237,7 @@ describe('issueNft procedure', () => { dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const expectedError = new PolymeshError({ code: ErrorCode.ValidationError, @@ -233,9 +249,11 @@ describe('issueNft procedure', () => { it('should not throw when all required metadata is provided', () => { const args = { - metadata: [ - { type: MetadataType.Local, id: new BigNumber(1), value: 'local' }, - { type: MetadataType.Global, id: new BigNumber(2), value: 'global' }, + metadataList: [ + [ + { type: MetadataType.Local, id: new BigNumber(1), value: 'local' }, + { type: MetadataType.Global, id: new BigNumber(2), value: 'global' }, + ], ], collection: entityMockUtils.getNftCollectionInstance({ collectionKeys: [ @@ -256,7 +274,7 @@ describe('issueNft procedure', () => { dsMockUtils.createTxMock('nft', 'issueNft'); - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); return expect(prepareIssueNft.call(proc, args)).resolves.not.toThrow(); }); @@ -264,7 +282,7 @@ describe('issueNft procedure', () => { describe('getAuthorization', () => { it('should return the appropriate roles and permissions', () => { - const proc = procedureMockUtils.getInstance(mockContext); + const proc = procedureMockUtils.getInstance(mockContext); const boundFunc = getAuthorization.bind(proc); expect(boundFunc({ collection } as unknown as Params)).toEqual({ @@ -295,10 +313,10 @@ describe('issueNft procedure', () => { it('should create an NFT entity', () => { const context = dsMockUtils.getContextInstance(); - const result = issueNftResolver(context)({} as ISubmittableResult); + const result = issuedNftsResolver(context)({} as ISubmittableResult); - expect(result.collection).toEqual(expect.objectContaining({ id: assetId })); - expect(result.id).toEqual(id); + expect(result[0]!.collection).toEqual(expect.objectContaining({ id: assetId })); + expect(result[0]!.id).toEqual(id); }); }); }); diff --git a/src/api/procedures/issueNft.ts b/src/api/procedures/issueNft.ts index 62de331b2c..ea14e1bcf4 100644 --- a/src/api/procedures/issueNft.ts +++ b/src/api/procedures/issueNft.ts @@ -1,57 +1,52 @@ import { ISubmittableResult } from '@polkadot/types/types'; +import BigNumber from 'bignumber.js'; import { Nft } from '~/api/entities/Asset/NonFungible/Nft'; import { Context, NftCollection, PolymeshError, Procedure } from '~/internal'; -import { ErrorCode, IssueNftParams, TxTags } from '~/types'; -import { ExtrinsicParams, ProcedureAuthorization, TransactionSpec } from '~/types/internal'; +import { CollectionKey, ErrorCode, NftMetadataInput, TxTags } from '~/types'; +import { BatchTransactionSpec, ProcedureAuthorization } from '~/types/internal'; import { assetToMeshAssetId, meshNftToNftId, nftInputToNftMetadataVec, portfolioToPortfolioKind, } from '~/utils/conversion'; -import { filterEventRecords } from '~/utils/internal'; +import { checkTxType, filterEventRecords } from '~/utils/internal'; -export type Params = IssueNftParams & { +export type Params = { + metadataList: NftMetadataInput[][]; + portfolioId?: BigNumber | undefined; collection: NftCollection; }; /** * @hidden */ -export const issueNftResolver = +export const issuedNftsResolver = (context: Context) => - (receipt: ISubmittableResult): Nft => { - const [record] = filterEventRecords(receipt, 'nft', 'NFTPortfolioUpdated'); + (receipt: ISubmittableResult): Nft[] => { + const records = filterEventRecords(receipt, 'nft', 'NFTPortfolioUpdated'); - const { data } = record!; - const { assetId, ids } = meshNftToNftId(data[1]); + return records.map(({ data }) => { + const { assetId, ids } = meshNftToNftId(data[1]); + const id = ids[0]!; - const id = ids[0]!; - - return new Nft({ id, assetId }, context); + return new Nft({ id, assetId }, context); + }); }; /** - * @hidden + * + * @param nftParams + * @param neededMetadata */ -export async function prepareIssueNft( - this: Procedure, - args: Params -): Promise>> { - const { - context: { - polymeshApi: { tx }, - }, - context, - } = this; - const { portfolioId, metadata, collection } = args; - const rawMetadataValues = nftInputToNftMetadataVec(metadata, context); - - const neededMetadata = await collection.collectionKeys(); - +function validateNftParams( + metadata: NftMetadataInput[], + neededMetadata: CollectionKey[], + assetId: string +): void { // for each input, find and remove a value from needed - args.metadata.forEach(value => { + metadata.forEach(value => { const matchedIndex = neededMetadata.findIndex( requiredValue => value.type === requiredValue.type && value.id.eq(requiredValue.id) ); @@ -60,7 +55,7 @@ export async function prepareIssueNft( throw new PolymeshError({ code: ErrorCode.ValidationError, message: 'A metadata value was given that is not required for this collection', - data: { assetId: collection.id, type: value.type, id: value.id }, + data: { assetId, type: value.type, id: value.id }, }); } @@ -74,6 +69,28 @@ export async function prepareIssueNft( data: { missingMetadata: JSON.stringify(neededMetadata) }, }); } +} + +/** + * @hidden + */ +export async function prepareIssueNft( + this: Procedure, + args: Params +): Promise> { + const { + context: { + polymeshApi: { tx }, + }, + context, + } = this; + const { portfolioId, metadataList: nftParams, collection } = args; + + const rawMetadataValues = nftParams.map(metadata => nftInputToNftMetadataVec(metadata, context)); + + const neededMetadata = await collection.collectionKeys(); + + nftParams.forEach(metadata => validateNftParams(metadata, [...neededMetadata], collection.id)); const signingIdentity = await context.getSigningIdentity(); @@ -84,10 +101,16 @@ export async function prepareIssueNft( const rawAssetId = assetToMeshAssetId(collection, context); const rawPortfolio = portfolioToPortfolioKind(portfolio, context); + const transactions = rawMetadataValues.map(metadata => + checkTxType({ + transaction: tx.nft.issueNft, + args: [rawAssetId, metadata, rawPortfolio], + }) + ); + return { - transaction: tx.nft.issueNft, - args: [rawAssetId, rawMetadataValues, rawPortfolio], - resolver: issueNftResolver(context), + transactions, + resolver: issuedNftsResolver(context), }; } @@ -95,7 +118,7 @@ export async function prepareIssueNft( * @hidden */ export function getAuthorization( - this: Procedure, + this: Procedure, { collection }: Params ): ProcedureAuthorization { return { @@ -110,5 +133,5 @@ export function getAuthorization( /** * @hidden */ -export const issueNft = (): Procedure => +export const issueNft = (): Procedure => new Procedure(prepareIssueNft, getAuthorization); diff --git a/src/api/procedures/types.ts b/src/api/procedures/types.ts index 30fb35f42a..45fb819130 100644 --- a/src/api/procedures/types.ts +++ b/src/api/procedures/types.ts @@ -1197,6 +1197,17 @@ export type IssueNftParams = { portfolioId?: BigNumber; }; +export type BatchIssueNftParams = { + /** + * List of metadata for each NFT to be issued + */ + metadataList: NftMetadataInput[][]; + /** + * portfolio to which the NFTCollection will be issued (optional, default is the default portfolio) + */ + portfolioId?: BigNumber; +}; + export interface ModifyPrimaryIssuanceAgentParams { /** * Identity to be set as primary issuance agent diff --git a/src/internal.ts b/src/internal.ts index 57e3b834ea..cf4a9176da 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -107,7 +107,13 @@ export { Account } from '~/api/entities/Account'; export { MultiSig } from '~/api/entities/Account/MultiSig'; export { MultiSigProposal } from '~/api/entities/MultiSigProposal'; export { TickerReservation } from '~/api/entities/TickerReservation'; -export { BaseAsset, FungibleAsset, NftCollection, Nft } from '~/api/entities/Asset'; +export { + BaseAsset, + FungibleAsset, + NftCollection, + Nft, + issueNftTransformer, +} from '~/api/entities/Asset'; export { MetadataEntry } from '~/api/entities/MetadataEntry'; export { registerMetadata } from '~/api/procedures/registerMetadata'; export { setMetadata } from '~/api/procedures/setMetadata';