diff --git a/AccountsService b/AccountsService new file mode 100644 index 00000000..e69de29b diff --git a/package.json b/package.json index 9f6e6168..a4ed7c66 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@polymeshassociation/fireblocks-signing-manager": "^2.5.0", "@polymeshassociation/hashicorp-vault-signing-manager": "^3.4.0", "@polymeshassociation/local-signing-manager": "^3.3.0", - "@polymeshassociation/polymesh-sdk": "24.7.0-alpha.11", + "@polymeshassociation/polymesh-sdk": "24.7.0-alpha.13", "@polymeshassociation/signing-manager-types": "^3.2.0", "class-transformer": "0.5.1", "class-validator": "^0.14.0", diff --git a/src/accounts/accounts.controller.spec.ts b/src/accounts/accounts.controller.spec.ts index acd9b01d..f71aa8d5 100644 --- a/src/accounts/accounts.controller.spec.ts +++ b/src/accounts/accounts.controller.spec.ts @@ -296,10 +296,13 @@ describe('AccountsController', () => { }); describe('getDetails', () => { - it('should call the service and return AccountDetailsModel', async () => { - const fakeIdentityModel = 'fakeIdentityModel' as unknown as IdentityModel; + const fakeIdentityModel = 'fakeIdentityModel' as unknown as IdentityModel; + + beforeEach(() => { jest.spyOn(identityUtil, 'createIdentityModel').mockResolvedValue(fakeIdentityModel); + }); + it('should call the service and return AccountDetailsModel', async () => { const mockResponse: AccountDetails = { identity: new MockIdentity() as unknown as Identity, multiSigDetails: null, @@ -311,10 +314,27 @@ describe('AccountsController', () => { expect(result).toEqual({ identity: fakeIdentityModel }); }); + + it('should handle MultiSig details', async () => { + const mockResponse: AccountDetails = { + identity: new MockIdentity() as unknown as Identity, + multiSigDetails: { signers: [], requiredSignatures: new BigNumber(1) }, + }; + + mockAccountsService.getDetails.mockReturnValue(mockResponse); + + const result = await controller.getAccountDetails({ account: '5xdd' }); + + expect(result).toEqual( + expect.objectContaining({ + multiSig: expect.objectContaining({ signers: [], requiredSignatures: new BigNumber(1) }), + }) + ); + }); }); describe('getOffChainReceipts', () => { - it('should call the service and return AccountDetailsModel', async () => { + it('should call the service and return off chain receipts', async () => { const mockResponse = [new BigNumber(1), new BigNumber(2)]; mockAccountsService.fetchOffChainReceipts.mockReturnValue(mockResponse); diff --git a/src/accounts/accounts.controller.ts b/src/accounts/accounts.controller.ts index cc6eac14..f4a647ee 100644 --- a/src/accounts/accounts.controller.ts +++ b/src/accounts/accounts.controller.ts @@ -324,6 +324,7 @@ export class AccountsController { @ApiNotFoundResponse({ description: 'No Account found for the given address', }) + @ApiTags('multi-sigs') @Get(':account') async getAccountDetails(@Param() { account }: AccountParamsDto): Promise { const { identity, multiSigDetails } = await this.accountsService.getDetails(account); diff --git a/src/accounts/accounts.service.ts b/src/accounts/accounts.service.ts index 474719fa..d9d1b506 100644 --- a/src/accounts/accounts.service.ts +++ b/src/accounts/accounts.service.ts @@ -33,7 +33,7 @@ export class AccountsService { private readonly transactionsService: TransactionsService ) {} - public async findOne(address: string): Promise { + public async findOne(address: string): Promise { const { polymeshService: { polymeshApi }, } = this; diff --git a/src/accounts/models/multi-sig-details.model.ts b/src/accounts/models/multi-sig-details.model.ts index 02972c3b..4d4b9c4e 100644 --- a/src/accounts/models/multi-sig-details.model.ts +++ b/src/accounts/models/multi-sig-details.model.ts @@ -17,7 +17,7 @@ export class MultiSigDetailsModel { readonly signers: SignerModel[]; @ApiProperty({ - description: 'Required signers', + description: 'The required number of signers needed to approve a proposal', type: 'string', example: '2', }) diff --git a/src/app.module.ts b/src/app.module.ts index 0840161d..b27147f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { DeveloperTestingModule } from '~/developer-testing/developer-testing.mo import { EventsModule } from '~/events/events.module'; import { IdentitiesModule } from '~/identities/identities.module'; import { MetadataModule } from '~/metadata/metadata.module'; +import { MultiSigsModule } from '~/multi-sigs/multi-sigs.module'; import { NetworkModule } from '~/network/network.module'; import { NftsModule } from '~/nfts/nfts.module'; import { NotificationsModule } from '~/notifications/notifications.module'; @@ -105,6 +106,7 @@ import { UsersModule } from '~/users/users.module'; OfflineSubmitterModule, OfflineStarterModule, OfflineRecorderModule, + MultiSigsModule, ], }) export class AppModule {} diff --git a/src/multi-sigs/dto/create-multi-sig.dto.ts b/src/multi-sigs/dto/create-multi-sig.dto.ts new file mode 100644 index 00000000..9a6696ed --- /dev/null +++ b/src/multi-sigs/dto/create-multi-sig.dto.ts @@ -0,0 +1,27 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { IsString } from 'class-validator'; + +import { IsBigNumber, ToBigNumber } from '~/common/decorators'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; + +export class CreateMultiSigDto extends TransactionBaseDto { + @ApiProperty({ + description: 'The number of approvals required in order for a proposal to be accepted', + example: '1', + }) + @IsBigNumber() + @ToBigNumber() + readonly requiredSignatures: BigNumber; + + @ApiProperty({ + description: 'The signers for the MultiSig', + type: 'string', + isArray: true, + example: ['5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV'], + }) + @IsString({ each: true }) + readonly signers: string[]; +} diff --git a/src/multi-sigs/dto/join-creator.dto.ts b/src/multi-sigs/dto/join-creator.dto.ts new file mode 100644 index 00000000..4494673f --- /dev/null +++ b/src/multi-sigs/dto/join-creator.dto.ts @@ -0,0 +1,29 @@ +/* istanbul ignore file */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; + +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; +import { IsPermissionsLike } from '~/identities/decorators/validation'; +import { PermissionsLikeDto } from '~/identities/dto/permissions-like.dto'; + +export class JoinCreatorDto extends TransactionBaseDto { + @ApiPropertyOptional({ + description: 'Whether or not to join the creator as the new primary key', + type: 'boolean', + }) + @IsOptional() + @IsBoolean() + readonly asPrimary?: boolean; + + @ApiPropertyOptional({ + description: 'Permissions to be granted to the multiSig if joining as a `secondaryAccount`', + type: PermissionsLikeDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => PermissionsLikeDto) + @IsPermissionsLike() + readonly permissions?: PermissionsLikeDto; +} diff --git a/src/multi-sigs/dto/modify-multi-sig.dto.ts b/src/multi-sigs/dto/modify-multi-sig.dto.ts new file mode 100644 index 00000000..acf26585 --- /dev/null +++ b/src/multi-sigs/dto/modify-multi-sig.dto.ts @@ -0,0 +1,28 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { IsString } from 'class-validator'; + +import { IsBigNumber, ToBigNumber } from '~/common/decorators'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; + +export class ModifyMultiSigDto extends TransactionBaseDto { + @ApiProperty({ + description: 'The number of approvals required in order for a proposal to be accepted', + example: '2', + type: 'string', + }) + @IsBigNumber() + @ToBigNumber() + readonly requiredSignatures: BigNumber; + + @ApiProperty({ + description: 'The signers for the MultiSig', + type: 'string', + isArray: true, + example: ['5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV'], + }) + @IsString({ each: true }) + readonly signers: string[]; +} diff --git a/src/multi-sigs/dto/multi-sig-params.dto.ts b/src/multi-sigs/dto/multi-sig-params.dto.ts new file mode 100644 index 00000000..54218b5e --- /dev/null +++ b/src/multi-sigs/dto/multi-sig-params.dto.ts @@ -0,0 +1,14 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class MultiSigParamsDto { + @ApiProperty({ + description: 'The address of the MultiSig', + example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb', + type: 'string', + }) + @IsString() + readonly multiSigAddress: string; +} diff --git a/src/multi-sigs/dto/multisig-proposal-params.dto.ts b/src/multi-sigs/dto/multisig-proposal-params.dto.ts new file mode 100644 index 00000000..4a813718 --- /dev/null +++ b/src/multi-sigs/dto/multisig-proposal-params.dto.ts @@ -0,0 +1,26 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { IsString } from 'class-validator'; + +import { IsBigNumber, ToBigNumber } from '~/common/decorators'; + +export class MultiSigProposalParamsDto { + @ApiProperty({ + description: 'The MultiSig address', + type: 'string', + example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb', + }) + @IsString() + readonly multiSigAddress: string; + + @ApiProperty({ + description: 'The proposal ID', + type: 'string', + example: '7', + }) + @IsBigNumber() + @ToBigNumber() + readonly proposalId: BigNumber; +} diff --git a/src/multi-sigs/models/multi-sig-created.model.ts b/src/multi-sigs/models/multi-sig-created.model.ts new file mode 100644 index 00000000..59085bd1 --- /dev/null +++ b/src/multi-sigs/models/multi-sig-created.model.ts @@ -0,0 +1,22 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; + +import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; + +export class MultiSigCreatedModel extends TransactionQueueModel { + @ApiProperty({ + description: 'The address of the multiSig', + type: 'string', + example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb', + }) + readonly multiSigAddress: string; + + constructor(model: MultiSigCreatedModel) { + const { transactions, details, ...rest } = model; + + super({ transactions, details }); + + Object.assign(this, rest); + } +} diff --git a/src/multi-sigs/models/multi-sig-proposal-details.model.ts b/src/multi-sigs/models/multi-sig-proposal-details.model.ts new file mode 100644 index 00000000..6a741b0b --- /dev/null +++ b/src/multi-sigs/models/multi-sig-proposal-details.model.ts @@ -0,0 +1,87 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { + Account, + AnyJson, + ProposalStatus, + TxTag, + TxTags, +} from '@polymeshassociation/polymesh-sdk/types'; + +import { FromBigNumber, FromEntityObject } from '~/common/decorators'; +import { getTxTags } from '~/common/utils'; + +export class MultiSigProposalDetailsModel { + @ApiProperty({ + description: 'The number of approvals this proposal has received', + type: 'string', + example: '1', + }) + @FromBigNumber() + approvalAmount: BigNumber; + + @ApiProperty({ + description: 'The number of rejections this proposal has received', + type: 'string', + example: '0', + }) + @FromBigNumber() + rejectionAmount: BigNumber; + + @ApiProperty({ + description: 'The current status of the proposal', + enum: ProposalStatus, + type: 'string', + example: ProposalStatus.Active, + }) + readonly status: string; + + @ApiProperty({ + description: + "An optional time in which this proposal will expire if a decision isn't reached by then", + example: null, + }) + readonly expiry: Date | null; + + @ApiProperty({ + description: + 'Determines if the proposal will automatically be closed once a threshold of reject votes has been reached', + type: 'boolean', + example: true, + }) + readonly autoClose: boolean; + + @ApiProperty({ + description: 'The tag for the transaction being proposed for the MultiSig to execute', + type: 'string', + enum: getTxTags(), + example: TxTags.asset.Issue, + }) + readonly txTag: TxTag; + + @ApiProperty({ + description: 'The arguments to be passed to the transaction for this proposal', + type: 'string', + example: { + ticker: '0x5449434b4552000000000000', + amount: 1000000000, + portfolio_kind: { + default: null, + }, + }, + }) + readonly args: AnyJson; + + @ApiProperty({ + description: 'Accounts of signing keys that have already voted on this proposal', + isArray: true, + type: 'string', + example: ['5EyGPbr94Hw2r5kYR4eW21U9CuNwW87pk2bpkR5WGE2STK2r'], + }) + @FromEntityObject() + voted: Account[]; + + constructor(model: MultiSigProposalDetailsModel) { + Object.assign(this, model); + } +} diff --git a/src/multi-sigs/models/multi-sig-proposal.model.ts b/src/multi-sigs/models/multi-sig-proposal.model.ts new file mode 100644 index 00000000..01238041 --- /dev/null +++ b/src/multi-sigs/models/multi-sig-proposal.model.ts @@ -0,0 +1,51 @@ +/* istanbul ignore file */ + +import { ApiProperty } from '@nestjs/swagger'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; + +import { FromBigNumber } from '~/common/decorators'; +import { MultiSigProposalDetailsModel } from '~/multi-sigs/models/multi-sig-proposal-details.model'; + +export class MultiSigProposalModel { + @ApiProperty({ + description: 'The multiSig for which the proposal if for', + type: 'string', + example: '5EjsqfmY4JqMSrt7YQCe3if5DK4FrG98uUwZsaXmNW7aKdNM', + }) + readonly multiSigAddress: string; + + @ApiProperty({ + description: 'The ID of the proposal', + example: '1', + }) + @FromBigNumber() + readonly proposalId: BigNumber; + + @ApiProperty({ + description: 'Proposal details', + example: { + approvalAmount: '1', + rejectionAmount: '0', + status: 'Active', + expiry: null, + autoClose: true, + args: { + ticker: '0x5449434b4552000000000000', + amount: 1000000000, + portfolio_kind: { + default: null, + }, + }, + txTag: 'asset.issue', + voted: ['5EyGPbr94Hw2r5kYR4eW21U9CuNwW87pk2bpkR5WGE2STK2r'], + }, + }) + readonly details: MultiSigProposalDetailsModel; + + constructor(model: MultiSigProposalModel) { + const { details: rawDetails, ...rest } = model; + const details = new MultiSigProposalDetailsModel(rawDetails); + + Object.assign(this, { ...rest, details }); + } +} diff --git a/src/multi-sigs/multi-sigs.controller.spec.ts b/src/multi-sigs/multi-sigs.controller.spec.ts new file mode 100644 index 00000000..fbb78919 --- /dev/null +++ b/src/multi-sigs/multi-sigs.controller.spec.ts @@ -0,0 +1,129 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BigNumber } from '@polymeshassociation/polymesh-sdk'; +import { + MultiSig, + MultiSigProposal, + MultiSigProposalDetails, + TxTags, +} from '@polymeshassociation/polymesh-sdk/types'; +import { when } from 'jest-when'; + +import { MultiSigsController } from '~/multi-sigs/multi-sigs.controller'; +import { MultiSigsService } from '~/multi-sigs/multi-sigs.service'; +import { processedTxResult, txResult } from '~/test-utils/consts'; + +describe('MultiSigsController', () => { + const multiSigAddress = 'someMultiAddress'; + let controller: MultiSigsController; + let service: DeepMocked; + let mockMultiSig: DeepMocked; + + beforeEach(async () => { + service = createMock(); + mockMultiSig = createMock({ address: multiSigAddress }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [{ provide: MultiSigsService, useValue: service }], + controllers: [MultiSigsController], + }).compile(); + + controller = module.get(MultiSigsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('create', () => { + it('should call the service and return the result', async () => { + const params = { signers: ['someAddress'], requiredSignatures: new BigNumber(1) }; + + when(service.create) + .calledWith(params) + .mockResolvedValue({ ...txResult, result: mockMultiSig }); + + const result = await controller.create(params); + + expect(result).toEqual({ ...processedTxResult, multiSigAddress }); + }); + }); + + describe('joinCreator', () => { + it('should call the service and return the result', async () => { + const params = { asPrimary: true }; + + when(service.joinCreator).calledWith(multiSigAddress, params).mockResolvedValue(txResult); + + const result = await controller.joinCreator({ multiSigAddress }, params); + + expect(result).toEqual(processedTxResult); + }); + }); + + describe('modify', () => { + it('should call the service and return the result', async () => { + const params = { + requiredSignatures: new BigNumber(3), + signers: [], + }; + + when(service.modify).calledWith(multiSigAddress, params).mockResolvedValue(txResult); + + const result = await controller.modify({ multiSigAddress }, params); + + expect(result).toEqual(processedTxResult); + }); + }); + + describe('getProposal', () => { + it('should return details about the proposal', async () => { + const mockProposal = createMock({ + multiSig: mockMultiSig, + id: new BigNumber(2), + }); + const mockDetails = createMock({ txTag: TxTags.asset.Issue }); + + mockProposal.details.mockResolvedValue(mockDetails); + + service.findProposal.mockResolvedValue(mockProposal); + + const result = await controller.getProposal({ + multiSigAddress, + proposalId: new BigNumber(2), + }); + + expect(result).toEqual( + expect.objectContaining({ + multiSigAddress, + proposalId: new BigNumber(2), + details: expect.objectContaining({ txTag: TxTags.asset.Issue }), + }) + ); + }); + }); + + describe('approveProposal', () => { + it('should call the service and return the result', async () => { + const params = { multiSigAddress, proposalId: new BigNumber(3) }; + + when(service.approve).calledWith(params, {}).mockResolvedValue(txResult); + + const result = await controller.approveProposal(params, {}); + + expect(result).toEqual(processedTxResult); + }); + }); + + describe('rejectProposal', () => { + it('should call the service and return the result', async () => { + const params = { multiSigAddress, proposalId: new BigNumber(3) }; + + when(service.reject).calledWith(params, {}).mockResolvedValue(txResult); + + const result = await controller.rejectProposal(params, {}); + + expect(result).toEqual(processedTxResult); + }); + }); +}); diff --git a/src/multi-sigs/multi-sigs.controller.ts b/src/multi-sigs/multi-sigs.controller.ts new file mode 100644 index 00000000..b6dd759d --- /dev/null +++ b/src/multi-sigs/multi-sigs.controller.ts @@ -0,0 +1,179 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiOperation, + ApiTags, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; +import { MultiSig } from '@polymeshassociation/polymesh-sdk/types'; + +import { ApiTransactionResponse } from '~/common/decorators'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; +import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; +import { handleServiceResult, TransactionResolver, TransactionResponseModel } from '~/common/utils'; +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 { 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'; + +@ApiTags('multi-sigs') +@Controller('multi-sigs') +export class MultiSigsController { + constructor(private readonly multiSigService: MultiSigsService) {} + + @ApiOperation({ + summary: 'Create a MultiSig account', + description: + "This endpoint creates a multiSig account. The signer's identity will be the multiSig's creator. The creator is granted admin privileges over the multiSig and their primary key will be charged any POLYX fee on behalf of the multiSig account.", + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: MultiSigCreatedModel, + }) + @ApiBadRequestResponse({ + description: + '
    ' + + '
  • The number of required signatures should not exceed the number of signers
  • ' + + '
  • An address is not valid a SS58 address
  • ' + + '
', + }) + @Post('create') + async create(@Body() params: CreateMultiSigDto): Promise { + const serviceResult = await this.multiSigService.create(params); + + const resolver: TransactionResolver = async ({ transactions, details, result }) => { + const { address } = result; + + return new MultiSigCreatedModel({ + multiSigAddress: address, + details, + transactions, + }); + }; + + return handleServiceResult(serviceResult, resolver); + } + + @ApiOperation({ + summary: "Join the creator's identity as a signing key", + description: + "This endpoint joins a MultiSig to its creator's identity. For the multiSig to join a DID not belonging to the creator then a join identity auth needs to be made and accepted by the MultiSig", + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiBadRequestResponse({ + description: '
    ' + '
  • The multiSig is already attached to an identity
  • ' + '
', + }) + @Post(':multiSigAddress/join-creator') + async joinCreator( + @Param() { multiSigAddress }: MultiSigParamsDto, + @Body() params: JoinCreatorDto + ): Promise { + const serviceResult = await this.multiSigService.joinCreator(multiSigAddress, params); + + return handleServiceResult(serviceResult); + } + + @ApiOperation({ + summary: 'Modify a MultiSig', + description: 'This endpoint allows for a multiSig to be modified by its creator', + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiBadRequestResponse({ + description: + '
    ' + + '
  • The account is not a multiSig account
  • ' + + '
  • requiredSignatures cannot exceed the number of signers
  • ' + + "
  • The signing account must belong to the multiSig creator's identity
  • " + + '
', + }) + @Post(':multiSigAddress/modify') + async modify( + @Param() { multiSigAddress }: MultiSigParamsDto, + @Body() params: ModifyMultiSigDto + ): Promise { + const serviceResult = await this.multiSigService.modify(multiSigAddress, params); + + return handleServiceResult(serviceResult); + } + + @ApiOperation({ + summary: 'Get proposal details', + description: 'This endpoint returns details for a multiSig proposal', + }) + @Get(':multiSigAddress/proposals/:proposalId') + async getProposal(@Param() params: MultiSigProposalParamsDto): Promise { + const proposal = await this.multiSigService.findProposal(params); + + const details = await proposal.details(); + + return new MultiSigProposalModel({ + multiSigAddress: proposal.multiSig.address, + proposalId: proposal.id, + details, + }); + } + + @ApiOperation({ + summary: 'Accept a proposal', + description: 'This endpoint allows for a MultiSig to accept a proposal', + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiBadRequestResponse({ + description: 'The account is not a multiSig', + }) + @ApiNotFoundResponse({ + description: 'The multiSig proposal was not found', + }) + @ApiUnprocessableEntityResponse({ + description: + '
    ' + '
  • The signing account has already voted for the multiSig proposal
  • ' + '
', + }) + @Post(':multiSigAddress/proposals/:proposalId/approve') + async approveProposal( + @Param() params: MultiSigProposalParamsDto, + @Body() body: TransactionBaseDto + ): Promise { + const serviceResult = await this.multiSigService.approve(params, body); + + return handleServiceResult(serviceResult); + } + + @ApiOperation({ + summary: 'Reject a proposal', + description: 'This endpoint allows for a MultiSig signer to reject a proposal', + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiNotFoundResponse({ + description: 'The multiSig proposal was not found', + }) + @ApiUnprocessableEntityResponse({ + description: + '
    ' + '
  • The signing account has already voted for the MultiSig proposal
  • ' + '
', + }) + @Post(':multiSigAddress/proposals/:proposalId/reject') + async rejectProposal( + @Param() params: MultiSigProposalParamsDto, + @Body() body: TransactionBaseDto + ): Promise { + const serviceResult = await this.multiSigService.reject(params, body); + + return handleServiceResult(serviceResult); + } +} diff --git a/src/multi-sigs/multi-sigs.module.ts b/src/multi-sigs/multi-sigs.module.ts new file mode 100644 index 00000000..9f98d703 --- /dev/null +++ b/src/multi-sigs/multi-sigs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { AccountsModule } from '~/accounts/accounts.module'; +import { MultiSigsController } from '~/multi-sigs/multi-sigs.controller'; +import { MultiSigsService } from '~/multi-sigs/multi-sigs.service'; +import { PolymeshModule } from '~/polymesh/polymesh.module'; +import { TransactionsModule } from '~/transactions/transactions.module'; + +@Module({ + imports: [AccountsModule, PolymeshModule, TransactionsModule], + providers: [MultiSigsService], + controllers: [MultiSigsController], +}) +export class MultiSigsModule {} diff --git a/src/multi-sigs/multi-sigs.service.spec.ts b/src/multi-sigs/multi-sigs.service.spec.ts new file mode 100644 index 00000000..4e333bb7 --- /dev/null +++ b/src/multi-sigs/multi-sigs.service.spec.ts @@ -0,0 +1,171 @@ +/* eslint-disable import/first */ +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 { when } from 'jest-when'; + +import { AccountsService } from '~/accounts/accounts.service'; +import { AppInternalError, AppValidationError } from '~/common/errors'; +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 { MockTransactionsService } from '~/test-utils/service-mocks'; +import { TransactionsService } from '~/transactions/transactions.service'; + +const { options } = testValues; + +jest.mock('@polymeshassociation/polymesh-sdk/utils', () => ({ + ...jest.requireActual('@polymeshassociation/polymesh-sdk/utils'), + isMultiSigAccount: mockIsMultiSigAccount, +})); + +describe('MultiSigsService', () => { + const multiSigAddress = 'someAddress'; + const proposalId = new BigNumber(1); + const proposalParams = { multiSigAddress, proposalId }; + + let service: MultiSigsService; + let mockAccountService: DeepMocked; + let mockPolymeshApi: MockPolymesh; + let mockTransactionsService: MockTransactionsService; + + let multiSig: DeepMocked; + let proposal: DeepMocked; + + beforeEach(async () => { + mockAccountService = createMock(); + mockTransactionsService = new MockTransactionsService(); + + multiSig = createMock({ address: multiSigAddress }); + proposal = createMock({ id: proposalId }); + mockPolymeshApi = new MockPolymesh(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [PolymeshModule], + providers: [ + { provide: AccountsService, useValue: mockAccountService }, + { provide: TransactionsService, useValue: mockTransactionsService }, + MultiSigsService, + ], + }) + .overrideProvider(POLYMESH_API) + .useValue(mockPolymeshApi) + .compile(); + + when(mockAccountService.findOne).calledWith(multiSigAddress).mockResolvedValue(multiSig); + when(multiSig.getProposal).calledWith({ id: proposalId }).mockResolvedValue(proposal); + when(mockIsMultiSigAccount).calledWith(multiSig).mockReturnValue(true); + when(mockTransactionsService.submit) + .calledWith(expect.any(Function), expect.anything(), expect.anything()) + .mockResolvedValue(txResult); + + service = module.get(MultiSigsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findOne', () => { + it('should return the multiSig', async () => { + mockIsMultiSigAccount.mockReturnValue(true); + + const result = await service.findOne(multiSigAddress); + + expect(result).toEqual(multiSig); + }); + + it('should throw an error if the account is not a multiSig', () => { + mockAccountService.findOne.mockResolvedValue(multiSig); + mockIsMultiSigAccount.mockReturnValue(false); + + return expect(service.findOne(multiSigAddress)).rejects.toThrow(AppValidationError); + }); + }); + + describe('findProposal', () => { + it('should return the proposal', async () => { + const result = await service.findProposal(proposalParams); + + expect(result).toEqual(proposal); + }); + + it('should handle an error when finding a multiSig', () => { + const error = new Error('some find multi sig error'); + mockAccountService.findOne.mockRejectedValue(error); + + return expect(service.findProposal(proposalParams)).rejects.toThrow(AppInternalError); + }); + + it('should handle an error when finding the proposal', () => { + const error = new Error('some get proposal error'); + multiSig.getProposal.mockRejectedValue(error); + + return expect(service.findProposal(proposalParams)).rejects.toThrow(AppInternalError); + }); + }); + + describe('create', () => { + it('should create a multiSig', async () => { + const multiSignerAddress = 'multiSignerAddress'; + when(mockAccountService.findOne) + .calledWith(multiSignerAddress) + .mockResolvedValue(createMock()); + + const result = await service.create({ + requiredSignatures: new BigNumber(1), + signers: [multiSignerAddress], + options, + }); + + expect(result).toEqual(txResult); + }); + }); + + describe('modify', () => { + it('should modify the multiSig', async () => { + const multiSignerAddress = 'multiSignerAddress'; + + when(mockAccountService.findOne) + .calledWith(multiSignerAddress) + .mockResolvedValue(createMock()); + + const result = await service.modify(multiSigAddress, { + requiredSignatures: new BigNumber(1), + signers: [multiSignerAddress], + options, + }); + + expect(result).toEqual(txResult); + }); + }); + + describe('joinCreator', () => { + it('should join the multiSig to the creator', async () => { + const result = await service.joinCreator(multiSigAddress, { options }); + + expect(result).toEqual(txResult); + }); + }); + + describe('approve', () => { + it('should approve the proposal', async () => { + const result = await service.approve(proposalParams, { options }); + + expect(result).toEqual(txResult); + }); + }); + + describe('reject', () => { + it('should reject the proposal', async () => { + const result = await service.reject(proposalParams, { options }); + + expect(result).toEqual(txResult); + }); + }); +}); diff --git a/src/multi-sigs/multi-sigs.service.ts b/src/multi-sigs/multi-sigs.service.ts new file mode 100644 index 00000000..f61e76c8 --- /dev/null +++ b/src/multi-sigs/multi-sigs.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { + JoinCreatorParams, + MultiSig, + MultiSigProposal, +} from '@polymeshassociation/polymesh-sdk/types'; +import { isMultiSigAccount } from '@polymeshassociation/polymesh-sdk/utils'; + +import { AccountsService } from '~/accounts/accounts.service'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; +import { AppValidationError } from '~/common/errors'; +import { extractTxOptions, ServiceReturn } from '~/common/utils'; +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 { PolymeshService } from '~/polymesh/polymesh.service'; +import { TransactionsService } from '~/transactions/transactions.service'; +import { handleSdkError } from '~/transactions/transactions.util'; + +@Injectable() +export class MultiSigsService { + constructor( + private readonly accountService: AccountsService, + private readonly polymeshService: PolymeshService, + private readonly transactionsService: TransactionsService + ) {} + + public async findOne(multiSigAddress: string): Promise { + const multiSig = await this.accountService.findOne(multiSigAddress).catch(error => { + throw handleSdkError(error); + }); + + if (!isMultiSigAccount(multiSig)) { + throw new AppValidationError(`account is not a multi sig: "${multiSigAddress}"`); + } + + return multiSig; + } + + public async findProposal(params: MultiSigProposalParamsDto): Promise { + const { multiSigAddress, proposalId: id } = params; + + const multiSig = await this.findOne(multiSigAddress); + + return multiSig.getProposal({ id }).catch(error => { + throw handleSdkError(error); + }); + } + + public async create(params: CreateMultiSigDto): ServiceReturn { + const { + options, + args: { requiredSignatures, signers }, + } = extractTxOptions(params); + + const signerAccounts = await Promise.all( + signers.map(address => this.accountService.findOne(address)) + ); + + const createMultiSig = this.polymeshService.polymeshApi.accountManagement.createMultiSigAccount; + + return this.transactionsService.submit( + createMultiSig, + { signers: signerAccounts, requiredSignatures }, + options + ); + } + + public async modify(multiSigAddress: string, params: ModifyMultiSigDto): ServiceReturn { + const { + options, + args: { signers }, + } = extractTxOptions(params); + + const signerAccounts = await Promise.all( + signers.map(address => + this.polymeshService.polymeshApi.accountManagement.getAccount({ address }) + ) + ); + + const multiSig = await this.findOne(multiSigAddress); + + return this.transactionsService.submit(multiSig.modify, { signers: signerAccounts }, options); + } + + public async joinCreator(multiSigAddress: string, params: JoinCreatorDto): ServiceReturn { + const { options, args } = extractTxOptions(params); + + const multi = await this.findOne(multiSigAddress); + + return this.transactionsService.submit(multi.joinCreator, args as JoinCreatorParams, options); + } + + public async approve( + proposalParams: MultiSigProposalParamsDto, + txParams: TransactionBaseDto + ): ServiceReturn { + const { options } = extractTxOptions(txParams); + + const proposal = await this.findProposal(proposalParams); + + return this.transactionsService.submit(proposal.approve, {}, options); + } + + public async reject( + proposalParams: MultiSigProposalParamsDto, + txParams: TransactionBaseDto + ): ServiceReturn { + const { options } = extractTxOptions(txParams); + + const proposal = await this.findProposal(proposalParams); + + return this.transactionsService.submit(proposal.reject, {}, options); + } +} diff --git a/src/polymesh/polymesh.service.ts b/src/polymesh/polymesh.service.ts index 25231b69..1b589be1 100644 --- a/src/polymesh/polymesh.service.ts +++ b/src/polymesh/polymesh.service.ts @@ -26,6 +26,7 @@ export class PolymeshService { /* istanbul ignore next: remove when this is replaced by a real service */ } + /* istanbul ignore next: not worth the trouble */ /** * @hidden * Allows for the execution of a transaction defined in the polkadot.js instance, bypassing the SDK. diff --git a/src/test-utils/consts.ts b/src/test-utils/consts.ts index 5cfd4947..20340722 100644 --- a/src/test-utils/consts.ts +++ b/src/test-utils/consts.ts @@ -7,13 +7,14 @@ import { TxTags, } from '@polymeshassociation/polymesh-sdk/types'; -import { TransactionType } from '~/common/types'; +import { ProcessMode, TransactionType } from '~/common/types'; import { OfflineTxModel, OfflineTxStatus } from '~/offline-submitter/models/offline-tx.model'; import { DirectTransactionResult } from '~/transactions/transactions.util'; import { ResultType } from '~/transactions/types'; import { UserModel } from '~/users/model/user.model'; const signer = 'alice'; +const options = { signer, processMode: ProcessMode.Submit }; const did = '0x01'.padEnd(66, '0'); const dryRun = false; const ticker = 'TICKER'; @@ -97,6 +98,7 @@ export const processedTxResult = processedResult; export const testValues = { signer, + options, did, user, offlineTx, diff --git a/src/test-utils/mocks.ts b/src/test-utils/mocks.ts index 5ec57f8e..d594ff00 100644 --- a/src/test-utils/mocks.ts +++ b/src/test-utils/mocks.ts @@ -107,6 +107,7 @@ export class MockPolymesh { subsidizeAccount: jest.fn(), getSubsidy: jest.fn(), isValidAddress: jest.fn(), + createMultiSigAccount: jest.fn(), }; public identities = { diff --git a/yarn.lock b/yarn.lock index 0cbc097d..fc64cb5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1954,10 +1954,10 @@ dependencies: "@polymeshassociation/signing-manager-types" "^3.3.0" -"@polymeshassociation/polymesh-sdk@24.7.0-alpha.11": - version "24.7.0-alpha.11" - resolved "https://registry.yarnpkg.com/@polymeshassociation/polymesh-sdk/-/polymesh-sdk-24.7.0-alpha.11.tgz#a54e28ec13658a8d28a82458e0c1e6462b0f3d0e" - integrity sha512-eY3uuY3Q9JWTM1gWze2wUouzRa9A+om8x7JTZkY63Whxvv9BrbsadHjzq7kMCO5SdYZ94MGV2cA1rKd+x789eA== +"@polymeshassociation/polymesh-sdk@24.7.0-alpha.13": + version "24.7.0-alpha.13" + resolved "https://registry.yarnpkg.com/@polymeshassociation/polymesh-sdk/-/polymesh-sdk-24.7.0-alpha.13.tgz#3244fc5d24f1e25b5712beb9fa2c6b5dbaafa651" + integrity sha512-mPwRLQhn5t4soX3kxSAIqNyJ7JWSv7UHUadaLZcT3YN2NyxXnrkvkLf51pXRZCpvpwGZGASAm/RAbM5KsTXzOw== dependencies: "@apollo/client" "^3.8.1" "@polkadot/api" "11.2.1"