Skip to content

Commit

Permalink
feat: 🎸 add set admin method to multi instance
Browse files Browse the repository at this point in the history
allow for mangement of a multiSig admin for v7 chains. Signers can
create a proposal to set an admin and an admin can relinquish the
privilege
  • Loading branch information
polymath-eric authored and sansan committed Oct 4, 2024
1 parent e884334 commit db2c542
Show file tree
Hide file tree
Showing 10 changed files with 499 additions and 42 deletions.
1 change: 1 addition & 0 deletions src/api/client/__tests__/Network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ describe('Network Class', () => {
.mockResolvedValue(
dsMockUtils.createMockRuntimeDispatchInfo({
partialFee: rawGasFees,
weight: dsMockUtils.createMockWeight(),
})
);

Expand Down
12 changes: 12 additions & 0 deletions src/api/entities/Account/MultiSig/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js';

import { UniqueIdentifiers } from '~/api/entities/Account';
import { MultiSigProposal } from '~/api/entities/MultiSigProposal';
import { setMultiSigAdmin } from '~/api/procedures/setMultiSigAdmin';
import { Account, Context, Identity, modifyMultiSig, PolymeshError } from '~/internal';
import { multiSigProposalsQuery } from '~/middleware/queries/multisigs';
import { Query } from '~/middleware/types';
Expand All @@ -12,6 +13,7 @@ import {
ProcedureMethod,
ProposalStatus,
ResultSet,
SetMultiSigAdminParams,
} from '~/types';
import { Ensured } from '~/types/utils';
import {
Expand Down Expand Up @@ -43,6 +45,10 @@ export class MultiSig extends Account {
},
context
);
this.setAdmin = createProcedureMethod(
{ getProcedureAndArgs: adminArgs => [setMultiSigAdmin, { multiSig: this, ...adminArgs }] },
context
);
}

/**
Expand Down Expand Up @@ -324,4 +330,10 @@ export class MultiSig extends Account {
Pick<ModifyMultiSigParams, 'signers' | 'requiredSignatures'>,
void
>;

/**
* Set an admin for the MultiSig. When setting an admin it must be signed by one of the MultiSig signers and ran
* as a proposal. When removing an admin it must be called by account belonging to the admin's identity
*/
public setAdmin: ProcedureMethod<SetMultiSigAdminParams, void>;
}
20 changes: 20 additions & 0 deletions src/api/entities/Account/__tests__/MultiSig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,4 +349,24 @@ describe('MultiSig class', () => {
expect(procedure).toBe(expectedTransaction);
});
});

describe('method: setAdmin', () => {
it('should prepare the procedure and return the resulting procedure', async () => {
const did = 'someDid';
const admin = entityMockUtils.getIdentityInstance({ did });

const expectedTransaction = 'someQueue' as unknown as PolymeshTransaction<void>;
const args = {
admin,
};

when(procedureMockUtils.getPrepareMock())
.calledWith({ args: { multiSig, ...args }, transformer: undefined }, context, {})
.mockResolvedValue(expectedTransaction);

const procedure = await multiSig.setAdmin(args);

expect(procedure).toBe(expectedTransaction);
});
});
});
34 changes: 31 additions & 3 deletions src/api/procedures/__tests__/evaluateMultiSigProposal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { u64 } from '@polkadot/types';
import { AccountId } from '@polkadot/types/interfaces';
import { AccountId, RuntimeDispatchInfo } from '@polkadot/types/interfaces';
import { PolymeshPrimitivesSecondaryKeySignatory } from '@polkadot/types/lookup';
import BigNumber from 'bignumber.js';
import { when } from 'jest-when';
Expand Down Expand Up @@ -43,11 +43,14 @@ describe('evaluateMultiSigProposal', () => {
let rawSignerAccount: AccountId;
let rawOtherAccount: AccountId;
let rawProposalId: u64;
let rawDispatchInfo: RuntimeDispatchInfo;
let proposal: MultiSigProposal;
let rawSigner: PolymeshPrimitivesSecondaryKeySignatory;
let creator: Identity;
let proposalDetails: MultiSigProposalDetails;
let votesQuery: jest.Mock;
let proposalQuery: jest.Mock;
let callInfoCall: jest.Mock;

beforeAll(() => {
dsMockUtils.initMocks();
Expand All @@ -57,6 +60,10 @@ describe('evaluateMultiSigProposal', () => {
stringToAccountId = jest.spyOn(utilsConversionModule, 'stringToAccountId');
bigNumberToU64Spy = jest.spyOn(utilsConversionModule, 'bigNumberToU64');
signerToSignatorySpy = jest.spyOn(utilsConversionModule, 'signerToSignatory');
rawDispatchInfo = dsMockUtils.createMockRuntimeDispatchInfo({
weight: dsMockUtils.createMockWeight(),
partialFee: dsMockUtils.createMockBalance(),
});

multiSigAddress = 'multiSigAddress';
signerAddress = 'someAddress';
Expand All @@ -70,6 +77,8 @@ describe('evaluateMultiSigProposal', () => {
});

votesQuery = dsMockUtils.createQueryMock('multiSig', 'votes');
proposalQuery = dsMockUtils.createQueryMock('multiSig', 'proposals');
callInfoCall = dsMockUtils.createCallMock('transactionPaymentCallApi', 'queryCallInfo');

rawMultiSigAccount = dsMockUtils.createMockAccountId(multiSigAddress);
rawSignerAccount = dsMockUtils.createMockAccountId(signerAddress);
Expand Down Expand Up @@ -118,6 +127,8 @@ describe('evaluateMultiSigProposal', () => {
});

votesQuery.mockResolvedValue(dsMockUtils.createMockBool(false));
proposalQuery.mockResolvedValue(dsMockUtils.createMockOption(dsMockUtils.createMockCall()));
callInfoCall.mockResolvedValue(rawDispatchInfo);
});

afterEach(() => {
Expand Down Expand Up @@ -271,7 +282,24 @@ describe('evaluateMultiSigProposal', () => {
}
});

it('should return a approveAsKey transaction spec', async () => {
it('should throw an error if proposal information is not found', () => {
const proc = procedureMockUtils.getInstance<MultiSigProposalVoteParams, void>(mockContext);
proposalQuery.mockResolvedValue(dsMockUtils.createMockOption());

const expectedError = new PolymeshError({
code: ErrorCode.DataUnavailable,
message: 'The proposal data was not found on chain',
});

return expect(
prepareMultiSigProposalEvaluation.call(proc, {
proposal,
action: MultiSigProposalAction.Approve,
})
).rejects.toThrow(expectedError);
});

it('should return a approve transaction spec', async () => {
const proc = procedureMockUtils.getInstance<MultiSigProposalVoteParams, void>(mockContext);

const transaction = dsMockUtils.createTxMock('multiSig', 'approve');
Expand All @@ -284,7 +312,7 @@ describe('evaluateMultiSigProposal', () => {
expect(result).toEqual({
transaction,
paidForBy: creator,
args: [rawMultiSigAccount, rawProposalId],
args: [rawMultiSigAccount, rawProposalId, rawDispatchInfo.weight],
});
});

Expand Down
228 changes: 228 additions & 0 deletions src/api/procedures/__tests__/setMultiSigAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { AccountId } from '@polkadot/types/interfaces';
import { PolymeshPrimitivesIdentityId } from '@polkadot/types/lookup';
import BigNumber from 'bignumber.js';
import { when } from 'jest-when';

import {
getAuthorization,
Params,
prepareSetMultiSigAdmin,
} from '~/api/procedures/setMultiSigAdmin';
import { Context, Identity, MultiSig, PolymeshError } from '~/internal';
import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks';
import { Mocked } from '~/testUtils/types';
import { ErrorCode, TxTags } from '~/types';
import { PolymeshTx } from '~/types/internal';
import * as utilsConversionModule from '~/utils/conversion';

jest.mock(
'~/api/entities/Asset/Base',
require('~/testUtils/mocks/entities').mockBaseAssetModule('~/api/entities/Asset/Base')
);

describe('setMultiSigAdmin procedure', () => {
const adminDid = 'adminDid';
const multiSigAddress = 'multiSigAddress';

let mockContext: Mocked<Context>;
let multiSig: MultiSig;
let adminIdentity: Identity;
let rawAdminDid: PolymeshPrimitivesIdentityId;
let rawMultiSigAddress: AccountId;
let stringToAccountIdSpy: jest.SpyInstance;
let stringToIdentityIdSpy: jest.SpyInstance;

beforeAll(() => {
dsMockUtils.initMocks();
procedureMockUtils.initMocks();
entityMockUtils.initMocks();
});

let addAdminTransaction: PolymeshTx<[PolymeshPrimitivesIdentityId]>;
let removeAdminTransaction: PolymeshTx<[AccountId]>;

beforeEach(() => {
mockContext = dsMockUtils.getContextInstance();

adminIdentity = entityMockUtils.getIdentityInstance({ did: adminDid });
rawAdminDid = dsMockUtils.createMockIdentityId(adminDid);
multiSig = entityMockUtils.getMultiSigInstance({ address: multiSigAddress });
rawMultiSigAddress = dsMockUtils.createMockAccountId(multiSigAddress);
stringToAccountIdSpy = jest.spyOn(utilsConversionModule, 'stringToAccountId');
stringToIdentityIdSpy = jest.spyOn(utilsConversionModule, 'stringToIdentityId');

when(stringToAccountIdSpy)
.calledWith(multiSigAddress, mockContext)
.mockReturnValue(rawMultiSigAddress);
when(stringToIdentityIdSpy).calledWith(adminDid, mockContext).mockReturnValue(rawAdminDid);

addAdminTransaction = dsMockUtils.createTxMock('multiSig', 'addAdmin');
removeAdminTransaction = dsMockUtils.createTxMock('multiSig', 'removeAdminViaAdmin');
});

afterEach(() => {
entityMockUtils.reset();
procedureMockUtils.reset();
dsMockUtils.reset();
});

afterAll(() => {
procedureMockUtils.cleanup();
dsMockUtils.cleanup();
});

it('should throw an error if the chain is on v6', () => {
mockContext.isV6 = true;
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const expectedError = new PolymeshError({
code: ErrorCode.General,
message: 'MultiSig admins are not supported on v6 chains',
});

return expect(
prepareSetMultiSigAdmin.call(proc, { multiSig, admin: adminIdentity })
).rejects.toThrow(expectedError);
});

it('should throw an error if the supplied identity is already an admin for the MultiSig', () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const expectedError = new PolymeshError({
code: ErrorCode.ValidationError,
message: 'The identity is already the admin of the MultiSig',
});

return expect(
prepareSetMultiSigAdmin.call(proc, { multiSig, admin: adminIdentity })
).rejects.toThrow(expectedError);
});

it('should return an add admin transaction when given an admin', async () => {
adminIdentity = entityMockUtils.getIdentityInstance({ did: adminDid, isEqual: false });
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const expectedError = new PolymeshError({
code: ErrorCode.ValidationError,
message: 'The signing account is not part of the MultiSig',
});

return expect(
prepareSetMultiSigAdmin.call(proc, {
multiSig,
admin: adminIdentity,
})
).rejects.toThrow(expectedError);
});

it('should return an add admin transaction when given an admin', async () => {
adminIdentity = entityMockUtils.getIdentityInstance({ did: adminDid, isEqual: false });
multiSig = entityMockUtils.getMultiSigInstance({
details: { requiredSignatures: new BigNumber(1), signers: [mockContext.getSigningAccount()] },
});
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const result = await prepareSetMultiSigAdmin.call(proc, {
multiSig,
admin: adminIdentity,
});

expect(result).toEqual({
transaction: addAdminTransaction,
args: [rawAdminDid],
resolver: undefined,
});
});

it('should throw an error if trying to remove an admin from a MultiSig without one', () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

multiSig = entityMockUtils.getMultiSigInstance({
getAdmin: null,
});

const expectedError = new PolymeshError({
code: ErrorCode.NoDataChange,
message: 'The multiSig does not have an admin set currently',
data: { multiSig: multiSig.address },
});

return expect(
prepareSetMultiSigAdmin.call(proc, {
multiSig,
admin: null,
})
).rejects.toThrow(expectedError);
});

it('should throw an error if an identity that is not the admin is calling for remove', () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

mockContext = dsMockUtils.getContextInstance({
signingIdentityIsEqual: false,
});

multiSig = entityMockUtils.getMultiSigInstance({
getAdmin: entityMockUtils.getIdentityInstance({ did: 'someOtherDid' }),
});

const expectedError = new PolymeshError({
code: ErrorCode.NoDataChange,
message: "Only the current admin's identity can remove themselves",
});

return expect(
prepareSetMultiSigAdmin.call(proc, {
multiSig,
admin: null,
})
).rejects.toThrow(expectedError);
});

it('should return an remove admin transaction when given null for an admin', async () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const result = await prepareSetMultiSigAdmin.call(proc, {
multiSig,
admin: null,
});

expect(result).toEqual({
transaction: removeAdminTransaction,
args: [rawMultiSigAddress],
resolver: undefined,
});
});

describe('getAuthorization', () => {
it('should return AddAdmin transaction when adding an admin', () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);
const boundFunc = getAuthorization.bind(proc);
const params = {
admin: adminIdentity,
multiSig,
};

expect(boundFunc(params)).toEqual({
permissions: {
transactions: [TxTags.multiSig.AddAdmin],
},
});
});

it('should return RemoveAdminViaAdmin transaction when removing an admin', () => {
const proc = procedureMockUtils.getInstance<Params, void>(mockContext);
const boundFunc = getAuthorization.bind(proc);
const params = {
admin: null,
multiSig,
};

expect(boundFunc(params)).toEqual({
permissions: {
transactions: [TxTags.multiSig.RemoveAdminViaAdmin],
},
});
});
});
});
Loading

0 comments on commit db2c542

Please sign in to comment.