Skip to content

Commit 07d38f5

Browse files
committed
feat: 🎸 add endpoints to manage multi sig accounts
add endpoints to create and modify multiSigs, get proposal details and approve/reject them
1 parent aac249b commit 07d38f5

24 files changed

+931
-11
lines changed

‎AccountsService

Whitespace-only changes.

‎package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"@polymeshassociation/fireblocks-signing-manager": "^2.5.0",
5050
"@polymeshassociation/hashicorp-vault-signing-manager": "^3.4.0",
5151
"@polymeshassociation/local-signing-manager": "^3.3.0",
52-
"@polymeshassociation/polymesh-sdk": "24.7.0-alpha.11",
52+
"@polymeshassociation/polymesh-sdk": "24.7.0-alpha.13",
5353
"@polymeshassociation/signing-manager-types": "^3.2.0",
5454
"class-transformer": "0.5.1",
5555
"class-validator": "^0.14.0",

‎src/accounts/accounts.controller.spec.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,13 @@ describe('AccountsController', () => {
296296
});
297297

298298
describe('getDetails', () => {
299-
it('should call the service and return AccountDetailsModel', async () => {
300-
const fakeIdentityModel = 'fakeIdentityModel' as unknown as IdentityModel;
299+
const fakeIdentityModel = 'fakeIdentityModel' as unknown as IdentityModel;
300+
301+
beforeEach(() => {
301302
jest.spyOn(identityUtil, 'createIdentityModel').mockResolvedValue(fakeIdentityModel);
303+
});
302304

305+
it('should call the service and return AccountDetailsModel', async () => {
303306
const mockResponse: AccountDetails = {
304307
identity: new MockIdentity() as unknown as Identity,
305308
multiSigDetails: null,
@@ -311,10 +314,27 @@ describe('AccountsController', () => {
311314

312315
expect(result).toEqual({ identity: fakeIdentityModel });
313316
});
317+
318+
it('should handle MultiSig details', async () => {
319+
const mockResponse: AccountDetails = {
320+
identity: new MockIdentity() as unknown as Identity,
321+
multiSigDetails: { signers: [], requiredSignatures: new BigNumber(1) },
322+
};
323+
324+
mockAccountsService.getDetails.mockReturnValue(mockResponse);
325+
326+
const result = await controller.getAccountDetails({ account: '5xdd' });
327+
328+
expect(result).toEqual(
329+
expect.objectContaining({
330+
multiSig: expect.objectContaining({ signers: [], requiredSignatures: new BigNumber(1) }),
331+
})
332+
);
333+
});
314334
});
315335

316336
describe('getOffChainReceipts', () => {
317-
it('should call the service and return AccountDetailsModel', async () => {
337+
it('should call the service and return off chain receipts', async () => {
318338
const mockResponse = [new BigNumber(1), new BigNumber(2)];
319339
mockAccountsService.fetchOffChainReceipts.mockReturnValue(mockResponse);
320340

‎src/accounts/accounts.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export class AccountsController {
324324
@ApiNotFoundResponse({
325325
description: 'No Account found for the given address',
326326
})
327+
@ApiTags('multi-sigs')
327328
@Get(':account')
328329
async getAccountDetails(@Param() { account }: AccountParamsDto): Promise<AccountDetailsModel> {
329330
const { identity, multiSigDetails } = await this.accountsService.getDetails(account);

‎src/accounts/accounts.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class AccountsService {
3333
private readonly transactionsService: TransactionsService
3434
) {}
3535

36-
public async findOne(address: string): Promise<Account> {
36+
public async findOne(address: string): Promise<Account | MultiSig> {
3737
const {
3838
polymeshService: { polymeshApi },
3939
} = this;

‎src/accounts/models/multi-sig-details.model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export class MultiSigDetailsModel {
1717
readonly signers: SignerModel[];
1818

1919
@ApiProperty({
20-
description: 'Required signers',
20+
description: 'The required number of signers needed to approve a proposal',
2121
type: 'string',
2222
example: '2',
2323
})

‎src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DeveloperTestingModule } from '~/developer-testing/developer-testing.mo
1818
import { EventsModule } from '~/events/events.module';
1919
import { IdentitiesModule } from '~/identities/identities.module';
2020
import { MetadataModule } from '~/metadata/metadata.module';
21+
import { MultiSigsModule } from '~/multi-sigs/multi-sigs.module';
2122
import { NetworkModule } from '~/network/network.module';
2223
import { NftsModule } from '~/nfts/nfts.module';
2324
import { NotificationsModule } from '~/notifications/notifications.module';
@@ -105,6 +106,7 @@ import { UsersModule } from '~/users/users.module';
105106
OfflineSubmitterModule,
106107
OfflineStarterModule,
107108
OfflineRecorderModule,
109+
MultiSigsModule,
108110
],
109111
})
110112
export class AppModule {}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { BigNumber } from '@polymeshassociation/polymesh-sdk';
5+
import { IsString } from 'class-validator';
6+
7+
import { IsBigNumber, ToBigNumber } from '~/common/decorators';
8+
import { TransactionBaseDto } from '~/common/dto/transaction-base-dto';
9+
10+
export class CreateMultiSigDto extends TransactionBaseDto {
11+
@ApiProperty({
12+
description: 'The number of approvals required in order for a proposal to be accepted',
13+
example: '1',
14+
})
15+
@IsBigNumber()
16+
@ToBigNumber()
17+
readonly requiredSignatures: BigNumber;
18+
19+
@ApiProperty({
20+
description: 'The signers for the MultiSig',
21+
type: 'string',
22+
isArray: true,
23+
example: ['5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV'],
24+
})
25+
@IsString({ each: true })
26+
readonly signers: string[];
27+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiPropertyOptional } from '@nestjs/swagger';
4+
import { Type } from 'class-transformer';
5+
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
6+
7+
import { TransactionBaseDto } from '~/common/dto/transaction-base-dto';
8+
import { IsPermissionsLike } from '~/identities/decorators/validation';
9+
import { PermissionsLikeDto } from '~/identities/dto/permissions-like.dto';
10+
11+
export class JoinCreatorDto extends TransactionBaseDto {
12+
@ApiPropertyOptional({
13+
description: 'Whether or not to join the creator as the new primary key',
14+
type: 'boolean',
15+
})
16+
@IsOptional()
17+
@IsBoolean()
18+
readonly asPrimary?: boolean;
19+
20+
@ApiPropertyOptional({
21+
description: 'Permissions to be granted to the multiSig if joining as a `secondaryAccount`',
22+
type: PermissionsLikeDto,
23+
})
24+
@IsOptional()
25+
@ValidateNested()
26+
@Type(() => PermissionsLikeDto)
27+
@IsPermissionsLike()
28+
readonly permissions?: PermissionsLikeDto;
29+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { BigNumber } from '@polymeshassociation/polymesh-sdk';
5+
import { IsString } from 'class-validator';
6+
7+
import { IsBigNumber, ToBigNumber } from '~/common/decorators';
8+
import { TransactionBaseDto } from '~/common/dto/transaction-base-dto';
9+
10+
export class ModifyMultiSigDto extends TransactionBaseDto {
11+
@ApiProperty({
12+
description: 'The number of approvals required in order for a proposal to be accepted',
13+
example: '2',
14+
type: 'string',
15+
})
16+
@IsBigNumber()
17+
@ToBigNumber()
18+
readonly requiredSignatures: BigNumber;
19+
20+
@ApiProperty({
21+
description: 'The signers for the MultiSig',
22+
type: 'string',
23+
isArray: true,
24+
example: ['5GwwYnwCYcJ1Rkop35y7SDHAzbxrCkNUDD4YuCUJRPPXbvyV'],
25+
})
26+
@IsString({ each: true })
27+
readonly signers: string[];
28+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { IsString } from 'class-validator';
5+
6+
export class MultiSigParamsDto {
7+
@ApiProperty({
8+
description: 'The address of the MultiSig',
9+
example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb',
10+
type: 'string',
11+
})
12+
@IsString()
13+
readonly multiSigAddress: string;
14+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { BigNumber } from '@polymeshassociation/polymesh-sdk';
5+
import { IsString } from 'class-validator';
6+
7+
import { IsBigNumber, ToBigNumber } from '~/common/decorators';
8+
9+
export class MultiSigProposalParamsDto {
10+
@ApiProperty({
11+
description: 'The MultiSig address',
12+
type: 'string',
13+
example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb',
14+
})
15+
@IsString()
16+
readonly multiSigAddress: string;
17+
18+
@ApiProperty({
19+
description: 'The proposal ID',
20+
type: 'string',
21+
example: '7',
22+
})
23+
@IsBigNumber()
24+
@ToBigNumber()
25+
readonly proposalId: BigNumber;
26+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
5+
import { TransactionQueueModel } from '~/common/models/transaction-queue.model';
6+
7+
export class MultiSigCreatedModel extends TransactionQueueModel {
8+
@ApiProperty({
9+
description: 'The address of the multiSig',
10+
type: 'string',
11+
example: '5HCKs1tNprs5S1pHHmsHXaQacSQbYDhLUCyoMZiM7KT8JkNb',
12+
})
13+
readonly multiSigAddress: string;
14+
15+
constructor(model: MultiSigCreatedModel) {
16+
const { transactions, details, ...rest } = model;
17+
18+
super({ transactions, details });
19+
20+
Object.assign(this, rest);
21+
}
22+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { BigNumber } from '@polymeshassociation/polymesh-sdk';
3+
import {
4+
Account,
5+
AnyJson,
6+
ProposalStatus,
7+
TxTag,
8+
TxTags,
9+
} from '@polymeshassociation/polymesh-sdk/types';
10+
11+
import { FromBigNumber, FromEntityObject } from '~/common/decorators';
12+
import { getTxTags } from '~/common/utils';
13+
14+
export class MultiSigProposalDetailsModel {
15+
@ApiProperty({
16+
description: 'The number of approvals this proposal has received',
17+
type: 'string',
18+
example: '1',
19+
})
20+
@FromBigNumber()
21+
approvalAmount: BigNumber;
22+
23+
@ApiProperty({
24+
description: 'The number of rejections this proposal has received',
25+
type: 'string',
26+
example: '0',
27+
})
28+
@FromBigNumber()
29+
rejectionAmount: BigNumber;
30+
31+
@ApiProperty({
32+
description: 'The current status of the proposal',
33+
enum: ProposalStatus,
34+
type: 'string',
35+
example: ProposalStatus.Active,
36+
})
37+
readonly status: string;
38+
39+
@ApiProperty({
40+
description:
41+
"An optional time in which this proposal will expire if a decision isn't reached by then",
42+
example: null,
43+
})
44+
readonly expiry: Date | null;
45+
46+
@ApiProperty({
47+
description:
48+
'Determines if the proposal will automatically be closed once a threshold of reject votes has been reached',
49+
type: 'boolean',
50+
example: true,
51+
})
52+
readonly autoClose: boolean;
53+
54+
@ApiProperty({
55+
description: 'The tag for the transaction being proposed for the MultiSig to execute',
56+
type: 'string',
57+
enum: getTxTags(),
58+
example: TxTags.asset.Issue,
59+
})
60+
readonly txTag: TxTag;
61+
62+
@ApiProperty({
63+
description: 'The arguments to be passed to the transaction for this proposal',
64+
type: 'string',
65+
example: {
66+
ticker: '0x5449434b4552000000000000',
67+
amount: 1000000000,
68+
portfolio_kind: {
69+
default: null,
70+
},
71+
},
72+
})
73+
readonly args: AnyJson;
74+
75+
@ApiProperty({
76+
description: 'Accounts of signing keys that have already voted on this proposal',
77+
isArray: true,
78+
type: 'string',
79+
example: ['5EyGPbr94Hw2r5kYR4eW21U9CuNwW87pk2bpkR5WGE2STK2r'],
80+
})
81+
@FromEntityObject()
82+
voted: Account[];
83+
84+
constructor(model: MultiSigProposalDetailsModel) {
85+
Object.assign(this, model);
86+
}
87+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* istanbul ignore file */
2+
3+
import { ApiProperty } from '@nestjs/swagger';
4+
import { BigNumber } from '@polymeshassociation/polymesh-sdk';
5+
6+
import { FromBigNumber } from '~/common/decorators';
7+
import { MultiSigProposalDetailsModel } from '~/multi-sigs/models/multi-sig-proposal-details.model';
8+
9+
export class MultiSigProposalModel {
10+
@ApiProperty({
11+
description: 'The multiSig for which the proposal if for',
12+
type: 'string',
13+
example: '5EjsqfmY4JqMSrt7YQCe3if5DK4FrG98uUwZsaXmNW7aKdNM',
14+
})
15+
readonly multiSigAddress: string;
16+
17+
@ApiProperty({
18+
description: 'The ID of the proposal',
19+
example: '1',
20+
})
21+
@FromBigNumber()
22+
readonly proposalId: BigNumber;
23+
24+
@ApiProperty({
25+
description: 'Proposal details',
26+
example: {
27+
approvalAmount: '1',
28+
rejectionAmount: '0',
29+
status: 'Active',
30+
expiry: null,
31+
autoClose: true,
32+
args: {
33+
ticker: '0x5449434b4552000000000000',
34+
amount: 1000000000,
35+
portfolio_kind: {
36+
default: null,
37+
},
38+
},
39+
txTag: 'asset.issue',
40+
voted: ['5EyGPbr94Hw2r5kYR4eW21U9CuNwW87pk2bpkR5WGE2STK2r'],
41+
},
42+
})
43+
readonly details: MultiSigProposalDetailsModel;
44+
45+
constructor(model: MultiSigProposalModel) {
46+
const { details: rawDetails, ...rest } = model;
47+
const details = new MultiSigProposalDetailsModel(rawDetails);
48+
49+
Object.assign(this, { ...rest, details });
50+
}
51+
}

0 commit comments

Comments
 (0)