From bc1944328553114c7185625f1dc71cf2d50c5025 Mon Sep 17 00:00:00 2001 From: Toms Date: Thu, 17 Oct 2024 13:32:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20multiSig=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/multi-sigs/dto/set-multi-sig-admin.dto.ts | 15 ++ src/multi-sigs/multi-sigs.controller.spec.ts | 128 +++++++++++++++ src/multi-sigs/multi-sigs.controller.ts | 151 +++++++++++++++++- src/multi-sigs/multi-sigs.service.spec.ts | 100 +++++++++++- src/multi-sigs/multi-sigs.service.ts | 60 +++++++ 5 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 src/multi-sigs/dto/set-multi-sig-admin.dto.ts diff --git a/src/multi-sigs/dto/set-multi-sig-admin.dto.ts b/src/multi-sigs/dto/set-multi-sig-admin.dto.ts new file mode 100644 index 00000000..26851968 --- /dev/null +++ b/src/multi-sigs/dto/set-multi-sig-admin.dto.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; + +import { IsDid } from '~/common/decorators'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; + +export class SetMultiSigAdminDto extends TransactionBaseDto { + @ApiProperty({ + description: 'The DID of the MultiSig Admin', + example: '0x0600000000000000000000000000000000000000000000000000000000000000', + }) + @IsDid() + readonly admin: string; +} diff --git a/src/multi-sigs/multi-sigs.controller.spec.ts b/src/multi-sigs/multi-sigs.controller.spec.ts index fbb78919..894a1525 100644 --- a/src/multi-sigs/multi-sigs.controller.spec.ts +++ b/src/multi-sigs/multi-sigs.controller.spec.ts @@ -1,14 +1,22 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { + Identity, MultiSig, MultiSigProposal, MultiSigProposalDetails, + ResultSet, TxTags, } from '@polymeshassociation/polymesh-sdk/types'; import { when } from 'jest-when'; +import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; +import { IdentitySignerModel } from '~/identities/models/identity-signer.model'; +import { SetMultiSigAdminDto } from '~/multi-sigs/dto/set-multi-sig-admin.dto'; +import { MultiSigProposalModel } from '~/multi-sigs/models/multi-sig-proposal.model'; +import { MultiSigProposalDetailsModel } from '~/multi-sigs/models/multi-sig-proposal-details.model'; import { MultiSigsController } from '~/multi-sigs/multi-sigs.controller'; import { MultiSigsService } from '~/multi-sigs/multi-sigs.service'; import { processedTxResult, txResult } from '~/test-utils/consts'; @@ -126,4 +134,124 @@ describe('MultiSigsController', () => { expect(result).toEqual(processedTxResult); }); }); + + describe('getAdmin', () => { + it('should return the admin identity', async () => { + const mockIdentity = createMock({ did: 'ADMIN_DID' }); + when(service.getAdmin).calledWith(multiSigAddress).mockResolvedValue(mockIdentity); + + const result = await controller.getAdmin({ multiSigAddress }); + + expect(result).toEqual(new IdentitySignerModel({ did: 'ADMIN_DID' })); + }); + + it('should throw NotFoundException if no identity is associated', async () => { + when(service.getAdmin).calledWith(multiSigAddress).mockResolvedValue(null); + + await expect(controller.getAdmin({ multiSigAddress })).rejects.toThrow(NotFoundException); + }); + }); + + describe('setAdmin', () => { + it('should call the service and return the result', async () => { + const params = { + signers: ['someAddress'], + requiredSignatures: new BigNumber(1), + admin: 'NEW_ADMIN_DID', + }; + when(service.setAdmin).calledWith(multiSigAddress, params).mockResolvedValue(txResult); + + const result = await controller.setAdmin({ multiSigAddress }, params); + + expect(result).toEqual(processedTxResult); + }); + }); + + describe('getHistoricalProposals', () => { + it('should return paginated historical proposals', async () => { + const mockDetails = createMock({ txTag: TxTags.asset.Issue }); + + const mockProposal1 = createMock({ + multiSig: mockMultiSig, + details: jest.fn().mockResolvedValue(mockDetails), + id: new BigNumber(1), + }); + const mockProposal2 = createMock({ + multiSig: mockMultiSig, + details: jest.fn().mockResolvedValue(mockDetails), + id: new BigNumber(2), + }); + const mockPaginatedResult = createMock>({ + data: [mockProposal1, mockProposal2], + next: new BigNumber(2), + count: new BigNumber(2), + }); + + when(service.getHistoricalProposals) + .calledWith(multiSigAddress) + .mockResolvedValue(mockPaginatedResult); + + const result = await controller.getHistoricalProposals({ multiSigAddress }); + + expect(result).toEqual( + new PaginatedResultsModel({ + results: [ + new MultiSigProposalModel({ + multiSigAddress, + proposalId: mockProposal1.id, + details: new MultiSigProposalDetailsModel(mockDetails), + }), + new MultiSigProposalModel({ + multiSigAddress, + proposalId: mockProposal2.id, + details: new MultiSigProposalDetailsModel(mockDetails), + }), + ], + total: new BigNumber(2), + next: new BigNumber(2), + }) + ); + }); + }); + + describe('getProposals', () => { + it('should return active proposals', async () => { + const mockProposal1 = createMock({ + multiSig: mockMultiSig, + id: new BigNumber(1), + }); + const mockProposal2 = createMock({ + multiSig: mockMultiSig, + id: new BigNumber(2), + }); + + when(service.getProposals) + .calledWith(multiSigAddress) + .mockResolvedValue([mockProposal1, mockProposal2]); + + const result = await controller.getProposals({ multiSigAddress }); + + expect(result).toEqual([ + expect.objectContaining({ multiSigAddress, proposalId: new BigNumber(1) }), + expect.objectContaining({ multiSigAddress, proposalId: new BigNumber(2) }), + ]); + }); + }); + + describe('getPayer', () => { + it('should return the payer identity', async () => { + const mockIdentity = createMock({ did: 'PAYER_DID' }); + when(service.getPayer).calledWith(multiSigAddress).mockResolvedValue(mockIdentity); + + const result = await controller.getPayer({ multiSigAddress }); + + expect(result).toEqual(new IdentitySignerModel({ did: 'PAYER_DID' })); + }); + + it('should throw NotFoundException if no identity is associated', async () => { + when(service.getPayer).calledWith(multiSigAddress).mockResolvedValue(null); + + await expect(controller.getPayer({ multiSigAddress })).rejects.toThrow(NotFoundException); + }); + }); }); diff --git a/src/multi-sigs/multi-sigs.controller.ts b/src/multi-sigs/multi-sigs.controller.ts index 5f9565f8..f81695ea 100644 --- a/src/multi-sigs/multi-sigs.controller.ts +++ b/src/multi-sigs/multi-sigs.controller.ts @@ -1,22 +1,31 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, HttpStatus, NotFoundException, Param, Post } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, ApiTags, ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; import { MultiSig } from '@polymeshassociation/polymesh-sdk/types'; -import { ApiTransactionResponse } from '~/common/decorators'; +import { + ApiArrayResponse, + ApiTransactionFailedResponse, + ApiTransactionResponse, +} from '~/common/decorators'; import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; +import { PaginatedResultsModel } from '~/common/models/paginated-results.model'; import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; import { handleServiceResult, TransactionResolver, TransactionResponseModel } from '~/common/utils'; +import { IdentityModel } from '~/identities/models/identity.model'; +import { IdentitySignerModel } from '~/identities/models/identity-signer.model'; import { CreateMultiSigDto } from '~/multi-sigs/dto/create-multi-sig.dto'; import { JoinCreatorDto } from '~/multi-sigs/dto/join-creator.dto'; import { ModifyMultiSigDto } from '~/multi-sigs/dto/modify-multi-sig.dto'; import { MultiSigParamsDto } from '~/multi-sigs/dto/multi-sig-params.dto'; import { MultiSigProposalParamsDto } from '~/multi-sigs/dto/multisig-proposal-params.dto'; +import { SetMultiSigAdminDto } from '~/multi-sigs/dto/set-multi-sig-admin.dto'; import { MultiSigCreatedModel } from '~/multi-sigs/models/multi-sig-created.model'; import { MultiSigProposalModel } from '~/multi-sigs/models/multi-sig-proposal.model'; import { MultiSigsService } from '~/multi-sigs/multi-sigs.service'; @@ -112,6 +121,10 @@ export class MultiSigsController { summary: 'Get proposal details', description: 'This endpoint returns details for a multiSig proposal', }) + @ApiOkResponse({ + description: 'Details about the proposal', + type: MultiSigProposalModel, + }) @Get(':multiSigAddress/proposals/:proposalId') async getProposal(@Param() params: MultiSigProposalParamsDto): Promise { const proposal = await this.multiSigService.findProposal(params); @@ -177,4 +190,138 @@ export class MultiSigsController { return handleServiceResult(serviceResult); } + + @ApiOperation({ + summary: "Get the Identity of the MultiSig's admin", + }) + @ApiOkResponse({ + description: 'Returns basic details of the admin Identity for the MultiSig', + type: IdentityModel, + }) + @ApiNotFoundResponse({ + description: 'MultiSig account not found', + }) + @ApiNotFoundResponse({ + description: 'No DID is associated with the given account', + }) + @Get(':multiSigAddress/admin') + async getAdmin(@Param() { multiSigAddress }: MultiSigParamsDto): Promise { + const identity = await this.multiSigService.getAdmin(multiSigAddress); + + if (!identity) { + throw new NotFoundException('No DID is associated with the given account'); + } + return new IdentitySignerModel({ did: identity.did }); + } + + @ApiOperation({ + summary: "Get the Identity covering transaction fees for the MultiSig's ", + }) + @ApiOkResponse({ + description: 'Returns basic details of the Identity covering fees for the MultiSig', + type: IdentityModel, + }) + @ApiNotFoundResponse({ + description: 'MultiSig account not found', + }) + @ApiNotFoundResponse({ + description: 'No DID is associated with the given account', + }) + @Get(':multiSigAddress/payer') + async getPayer(@Param() { multiSigAddress }: MultiSigParamsDto): Promise { + const identity = await this.multiSigService.getPayer(multiSigAddress); + + if (!identity) { + throw new NotFoundException('No DID is associated with the given account'); + } + return new IdentitySignerModel({ did: identity.did }); + } + + @ApiOperation({ + summary: 'Set admin for the MultiSig account', + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiTransactionFailedResponse({ + [HttpStatus.NOT_FOUND]: ['MultiSig account not found'], + [HttpStatus.BAD_REQUEST]: ['MultiSig admins are not supported on v6 chains'], + [HttpStatus.UNPROCESSABLE_ENTITY]: [ + 'The identity is already the admin of the MultiSig', + 'The signing account is not part of the MultiSig', + ], + }) + @Post(':multiSigAddress/admin') + async setAdmin( + @Param() { multiSigAddress }: MultiSigParamsDto, + @Body() body: SetMultiSigAdminDto + ): Promise { + const serviceResult = await this.multiSigService.setAdmin(multiSigAddress, body); + + return handleServiceResult(serviceResult); + } + + @ApiOperation({ + summary: "Get the active proposals for the MultiSig's account", + }) + @ApiArrayResponse(MultiSigProposalModel, { + description: 'Returns a list of active proposals for the MultiSig', + paginated: false, + }) + @ApiNotFoundResponse({ + description: 'MultiSig account not found', + }) + @Get(':multiSigAddress/active-proposals') + async getProposals( + @Param() { multiSigAddress }: MultiSigParamsDto + ): Promise { + const proposals = await this.multiSigService.getProposals(multiSigAddress); + + const detailsPromises = proposals.map(proposal => proposal.details()); + const details = await Promise.all(detailsPromises); + + return proposals.map((proposal, i) => { + return new MultiSigProposalModel({ + multiSigAddress, + proposalId: proposal.id, + details: details[i], + }); + }); + } + + @ApiOperation({ + summary: 'Get the historical proposals for the MultiSig account', + }) + @ApiNotFoundResponse({ + description: 'MultiSig account not found', + }) + @ApiArrayResponse(MultiSigProposalModel, { + description: 'List of historical proposals for the MultiSig', + paginated: true, + }) + @Get(':multiSigAddress/historical-proposals') + async getHistoricalProposals( + @Param() { multiSigAddress }: MultiSigParamsDto + ): Promise> { + const { data, next, count } = await this.multiSigService.getHistoricalProposals( + multiSigAddress + ); + + const detailsPromises = data.map(proposal => proposal.details()); + const details = await Promise.all(detailsPromises); + + return new PaginatedResultsModel({ + results: data.map( + (proposal, i) => + new MultiSigProposalModel({ + multiSigAddress, + proposalId: proposal.id, + details: details[i], + }) + ), + total: count, + next, + }); + } } diff --git a/src/multi-sigs/multi-sigs.service.spec.ts b/src/multi-sigs/multi-sigs.service.spec.ts index 4e333bb7..a7e8336d 100644 --- a/src/multi-sigs/multi-sigs.service.spec.ts +++ b/src/multi-sigs/multi-sigs.service.spec.ts @@ -4,7 +4,12 @@ const mockIsMultiSigAccount = jest.fn(); import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; -import { Account, MultiSig, MultiSigProposal } from '@polymeshassociation/polymesh-sdk/types'; +import { + Account, + Identity, + MultiSig, + MultiSigProposal, +} from '@polymeshassociation/polymesh-sdk/types'; import { when } from 'jest-when'; import { AccountsService } from '~/accounts/accounts.service'; @@ -13,7 +18,7 @@ import { MultiSigsService } from '~/multi-sigs/multi-sigs.service'; import { POLYMESH_API } from '~/polymesh/polymesh.consts'; import { PolymeshModule } from '~/polymesh/polymesh.module'; import { testValues, txResult } from '~/test-utils/consts'; -import { MockPolymesh } from '~/test-utils/mocks'; +import { MockIdentity, MockPolymesh } from '~/test-utils/mocks'; import { MockTransactionsService } from '~/test-utils/service-mocks'; import { TransactionsService } from '~/transactions/transactions.service'; @@ -168,4 +173,95 @@ describe('MultiSigsService', () => { expect(result).toEqual(txResult); }); }); + + describe('getHistoricalProposals', () => { + it('should return historical proposals', async () => { + const mockResultSet = { + data: [proposal], + next: new BigNumber(2), + count: new BigNumber(1), + }; + when(multiSig.getHistoricalProposals).mockResolvedValue(mockResultSet); + const result = await service.getHistoricalProposals(multiSigAddress); + + expect(result).toEqual(mockResultSet); + }); + }); + + describe('getProposals', () => { + it('should return active proposals', async () => { + when(multiSig.getProposals).mockResolvedValue([proposal]); + const result = await service.getProposals(multiSigAddress); + + expect(result).toEqual([proposal]); + }); + + it('should handle errors', () => { + when(multiSig.getProposals).mockRejectedValue(new Error('Some error')); + + return expect(service.getProposals(multiSigAddress)).rejects.toThrow(AppInternalError); + }); + }); + + describe('removePayer', () => { + it('should remove the payer', async () => { + const result = await service.removePayer(multiSigAddress, { options }); + + expect(result).toEqual(txResult); + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + multiSig.removePayer, + {}, + options + ); + }); + }); + + describe('setAdmin', () => { + it('should set the admin', async () => { + const params = { admin: 'NEW_ADMIN_DID' }; + const result = await service.setAdmin(multiSigAddress, { ...params, options }); + + expect(result).toEqual(txResult); + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + multiSig.setAdmin, + params, + options + ); + }); + }); + + describe('getPayer', () => { + it('should return the payer identity', async () => { + const mockIdentity = new MockIdentity() as unknown as Identity; + + multiSig.getPayer = jest.fn().mockResolvedValue(mockIdentity); + + const result = await service.getPayer(multiSigAddress); + + expect(result).toEqual(mockIdentity); + }); + + it('should handle errors', () => { + multiSig.getPayer = jest.fn().mockRejectedValue(new Error('Some error')); + + return expect(service.getPayer(multiSigAddress)).rejects.toThrow(AppInternalError); + }); + }); + + describe('getAdmin', () => { + it('should return the admin identity', async () => { + const mockIdentity = new MockIdentity() as unknown as Identity; + multiSig.getAdmin = jest.fn().mockResolvedValue(mockIdentity); + + const result = await service.getAdmin(multiSigAddress); + + expect(result).toEqual(mockIdentity); + }); + + it('should handle errors', () => { + multiSig.getAdmin = jest.fn().mockRejectedValue(new Error('Some error')); + + return expect(service.getAdmin(multiSigAddress)).rejects.toThrow(AppInternalError); + }); + }); }); diff --git a/src/multi-sigs/multi-sigs.service.ts b/src/multi-sigs/multi-sigs.service.ts index f61e76c8..6ceac0b2 100644 --- a/src/multi-sigs/multi-sigs.service.ts +++ b/src/multi-sigs/multi-sigs.service.ts @@ -1,8 +1,11 @@ import { Injectable } from '@nestjs/common'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { + Identity, JoinCreatorParams, MultiSig, MultiSigProposal, + ResultSet, } from '@polymeshassociation/polymesh-sdk/types'; import { isMultiSigAccount } from '@polymeshassociation/polymesh-sdk/utils'; @@ -14,6 +17,7 @@ import { CreateMultiSigDto } from '~/multi-sigs/dto/create-multi-sig.dto'; import { JoinCreatorDto } from '~/multi-sigs/dto/join-creator.dto'; import { ModifyMultiSigDto } from '~/multi-sigs/dto/modify-multi-sig.dto'; import { MultiSigProposalParamsDto } from '~/multi-sigs/dto/multisig-proposal-params.dto'; +import { SetMultiSigAdminDto } from '~/multi-sigs/dto/set-multi-sig-admin.dto'; import { PolymeshService } from '~/polymesh/polymesh.service'; import { TransactionsService } from '~/transactions/transactions.service'; import { handleSdkError } from '~/transactions/transactions.util'; @@ -113,4 +117,60 @@ export class MultiSigsService { return this.transactionsService.submit(proposal.reject, {}, options); } + + public async getAdmin(multiSigAddress: string): Promise { + const multiSig = await this.findOne(multiSigAddress); + + return multiSig.getAdmin().catch(error => { + throw handleSdkError(error); + }); + } + + public async getPayer(multiSigAddress: string): Promise { + const multiSig = await this.findOne(multiSigAddress); + + return multiSig.getPayer().catch(error => { + throw handleSdkError(error); + }); + } + + public async setAdmin( + multiSigAddress: string, + txParams: SetMultiSigAdminDto + ): ServiceReturn { + const { options, args } = extractTxOptions(txParams); + + const multiSig = await this.findOne(multiSigAddress); + + return this.transactionsService.submit(multiSig.setAdmin, args, options); + } + + public async removePayer( + multiSigAddress: string, + txParams: TransactionBaseDto + ): ServiceReturn { + const { options } = extractTxOptions(txParams); + + const multiSig = await this.findOne(multiSigAddress); + + return this.transactionsService.submit(multiSig.removePayer, {}, options); + } + + public async getProposals(multiSigAddress: string): Promise { + const multiSig = await this.findOne(multiSigAddress); + + return multiSig.getProposals().catch(error => { + throw handleSdkError(error); + }); + } + + public async getHistoricalProposals( + multiSigAddress: string, + size?: BigNumber, + start?: BigNumber + ): Promise> { + const multiSig = await this.findOne(multiSigAddress); + + return multiSig.getHistoricalProposals({ size, start }); + } }