Skip to content

Commit

Permalink
feat: set an account as custodian
Browse files Browse the repository at this point in the history
  • Loading branch information
shuffledex committed Nov 9, 2020
1 parent 088b63f commit 56fc3f4
Show file tree
Hide file tree
Showing 8 changed files with 429 additions and 18 deletions.
22 changes: 21 additions & 1 deletion src/api/entities/Portfolio/__tests__/index.ts
Expand Up @@ -4,7 +4,8 @@ import { PortfolioId, Ticker } from 'polymesh-types/types';
import sinon from 'sinon';

import { Entity, Identity, Portfolio, SecurityToken } from '~/api/entities';
import { Context } from '~/base';
import { setCustodian } from '~/api/procedures';
import { Context, TransactionQueue } from '~/base';
import { dsMockUtils } from '~/testUtils/mocks';
import { tuple } from '~/types/utils';
import * as utilsModule from '~/utils';
Expand Down Expand Up @@ -166,4 +167,23 @@ describe('Portfolio class', () => {
expect(result[1].locked).toEqual(new BigNumber(0));
});
});

describe('method: setCustodian', () => {
test('should prepare the procedure and return the resulting transaction queue', async () => {
const id = new BigNumber(1);
const did = 'someDid';
const portfolio = new Portfolio({ id, did }, context);
const targetAccount = 'someTarget';
const expectedQueue = ('someQueue' as unknown) as TransactionQueue<void>;

sinon
.stub(setCustodian, 'prepare')
.withArgs({ id, did, targetAccount }, context)
.resolves(expectedQueue);

const queue = await portfolio.setCustodian({ targetAccount });

expect(queue).toBe(expectedQueue);
});
});
});
20 changes: 19 additions & 1 deletion src/api/entities/Portfolio/index.ts
Expand Up @@ -3,7 +3,8 @@ import { values } from 'lodash';
import { Ticker } from 'polymesh-types/types';

import { Entity, Identity, SecurityToken } from '~/api/entities';
import { Context } from '~/base';
import { setCustodian, SetCustodianParams } from '~/api/procedures';
import { Context, TransactionQueue } from '~/base';
import { balanceToBigNumber, portfolioIdToMeshPortfolioId, tickerToString } from '~/utils';

import { PortfolioBalance } from './types';
Expand Down Expand Up @@ -130,4 +131,21 @@ export class Portfolio extends Entity<UniqueIdentifiers> {

return values(assetBalances);
}

/**
* Send an invitation to an Account to assign it as custodian for this portfolio
*
* @note this may create AuthorizationRequest which have to be accepted by
* the corresponding Account. An Account or Identity can
* fetch its pending Authorization Requests by calling `authorizations.getReceived`
*/
public setCustodian(args: SetCustodianParams): Promise<TransactionQueue<void>> {
const {
owner: { did },
_id: id,
context,
} = this;

return setCustodian.prepare({ ...args, did, id }, context);
}
}
188 changes: 188 additions & 0 deletions src/api/procedures/__tests__/setCustodian.ts
@@ -0,0 +1,188 @@
import { Moment } from '@polkadot/types/interfaces';
import BigNumber from 'bignumber.js';
import { AuthorizationData, Signatory } from 'polymesh-types/types';
import sinon from 'sinon';

import { Account, AuthorizationRequest, DefaultPortfolio } from '~/api/entities';
import { Params, prepareSetCustodian } from '~/api/procedures/setCustodian';
import { Context } from '~/base';
import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks';
import { Mocked } from '~/testUtils/types';
import { Authorization, AuthorizationType, Identity, ResultSet } from '~/types';
import { SignerType, SignerValue } from '~/types/internal';
import * as utilsModule from '~/utils';

describe('setCustodian procedure', () => {
let mockContext: Mocked<Context>;
let addTransactionStub: sinon.SinonStub;
let authorizationToAuthorizationDataStub: sinon.SinonStub<
[Authorization, Context],
AuthorizationData
>;
let dateToMomentStub: sinon.SinonStub<[Date, Context], Moment>;
let signerToStringStub: sinon.SinonStub<[string | Identity | Account], string>;
let signerValueToSignatoryStub: sinon.SinonStub<[SignerValue, Context], Signatory>;

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

authorizationToAuthorizationDataStub = sinon.stub(
utilsModule,
'authorizationToAuthorizationData'
);
dateToMomentStub = sinon.stub(utilsModule, 'dateToMoment');
signerToStringStub = sinon.stub(utilsModule, 'signerToString');
signerValueToSignatoryStub = sinon.stub(utilsModule, 'signerValueToSignatory');
});

beforeEach(() => {
addTransactionStub = procedureMockUtils.getAddTransactionStub();
mockContext = dsMockUtils.getContextInstance();
});

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

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

test('should throw an error if the passed account has a pending authorization to accept', async () => {
const did = 'someDid';
const args = { targetAccount: 'targetAccount', did };

const target = new Account({ address: args.targetAccount }, mockContext);
const signer = entityMockUtils.getAccountInstance({ address: 'someFakeAccount' });
const fakePortfolio = new DefaultPortfolio({ did }, mockContext);
const sentAuthorizations: ResultSet<AuthorizationRequest> = {
data: [
new AuthorizationRequest(
{
target,
issuer: entityMockUtils.getIdentityInstance(),
authId: new BigNumber(1),
expiry: null,
data: { type: AuthorizationType.PortfolioCustody, value: fakePortfolio },
},
mockContext
),
],
next: 1,
count: 1,
};

dsMockUtils.configureMocks({
contextOptions: {
sentAuthorizations,
},
});

mockContext.getSecondaryKeys.resolves([
{
signer,
permissions: [],
},
]);

signerToStringStub.withArgs(signer).returns(signer.address);
signerToStringStub.withArgs(args.targetAccount).returns(args.targetAccount);
signerToStringStub.withArgs(target).returns(args.targetAccount);

const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

await expect(prepareSetCustodian.call(proc, args)).rejects.toThrow(
'The target Account already has a pending invitation to be the custodian for the portfolio'
);
});

test('should add an add authorization transaction to the queue', async () => {
const did = 'someDid';
const id = new BigNumber(1);
const expiry = new Date('1/1/2040');
const args = { targetAccount: 'targetAccount', did };
const target = new Account({ address: args.targetAccount }, mockContext);
const signer = entityMockUtils.getAccountInstance({ address: 'someFakeAccount' });
const rawSignatory = dsMockUtils.createMockSignatory({
Account: dsMockUtils.createMockAccountId('someAccountId'),
});
const rawDid = dsMockUtils.createMockIdentityId(did);
const rawPortfolioKind = dsMockUtils.createMockPortfolioKind({
User: dsMockUtils.createMockU64(id.toNumber()),
});
const rawAuthorizationData = dsMockUtils.createMockAuthorizationData({
PortfolioCustody: dsMockUtils.createMockPortfolioId({ did: rawDid, kind: rawPortfolioKind }),
});
const rawExpiry = dsMockUtils.createMockMoment(expiry.getTime());
const sentAuthorizations: ResultSet<AuthorizationRequest> = {
data: [
new AuthorizationRequest(
{
target,
issuer: entityMockUtils.getIdentityInstance(),
authId: new BigNumber(1),
expiry: null,
data: { type: AuthorizationType.JoinIdentity, value: [] },
},
mockContext
),
],
next: 1,
count: 1,
};

dsMockUtils.configureMocks({
contextOptions: {
sentAuthorizations,
},
});

mockContext.getSecondaryKeys.resolves([
{
signer,
permissions: [],
},
]);

signerToStringStub.withArgs(signer).returns(signer.address);
signerToStringStub.withArgs(args.targetAccount).returns(args.targetAccount);
signerToStringStub.withArgs(target).returns('someValue');
signerValueToSignatoryStub
.withArgs({ type: SignerType.Account, value: args.targetAccount }, mockContext)
.returns(rawSignatory);
authorizationToAuthorizationDataStub.returns(rawAuthorizationData);
dateToMomentStub.withArgs(expiry, mockContext).returns(rawExpiry);

const proc = procedureMockUtils.getInstance<Params, void>(mockContext);

const transaction = dsMockUtils.createTxStub('identity', 'addAuthorization');

await prepareSetCustodian.call(proc, args);

sinon.assert.calledWith(
addTransactionStub,
transaction,
{},
rawSignatory,
rawAuthorizationData,
null
);

await prepareSetCustodian.call(proc, { ...args, id, expiry });

sinon.assert.calledWith(
addTransactionStub,
transaction,
{},
rawSignatory,
rawAuthorizationData,
rawExpiry
);
});
});
1 change: 1 addition & 0 deletions src/api/procedures/index.ts
Expand Up @@ -41,3 +41,4 @@ export { transferTokenOwnership, TransferTokenOwnershipParams } from './transfer
export { removePrimaryIssuanceAgent } from './removePrimaryIssuanceAgent';
export { deletePortfolio } from './deletePortfolio';
export { renamePortfolio, RenamePortfolioParams } from './renamePortfolio';
export { setCustodian, SetCustodianParams } from './setCustodian';
99 changes: 99 additions & 0 deletions src/api/procedures/setCustodian.ts
@@ -0,0 +1,99 @@
import BigNumber from 'bignumber.js';

import { Account, DefaultPortfolio, NumberedPortfolio } from '~/api/entities';
import { PolymeshError, Procedure } from '~/base';
import { AuthorizationType, ErrorCode } from '~/types';
import { SignerType } from '~/types/internal';
import {
authorizationToAuthorizationData,
dateToMoment,
signerToString,
signerValueToSignatory,
} from '~/utils';

export interface SetCustodianParams {
targetAccount: string | Account;
expiry?: Date;
}

/**
* @hidden
*/
export type Params = { did: string; id?: BigNumber } & SetCustodianParams;

/**
* @hidden
*/
export async function prepareSetCustodian(
this: Procedure<Params, void>,
args: Params
): Promise<void> {
const {
context: {
polymeshApi: {
tx: { identity },
},
},
context,
} = this;

const { targetAccount, expiry, did, id } = args;

const portfolio = id
? new NumberedPortfolio({ did, id }, context)
: new DefaultPortfolio({ did }, context);

const address = signerToString(targetAccount);
const currentIdentity = await context.getCurrentIdentity();
const authorizationRequests = await currentIdentity.authorizations.getSent();

const hasPendingAuth = !!authorizationRequests.data.find(authorizationRequest => {
const {
target,
data,
data: { type },
} = authorizationRequest;

if (type === AuthorizationType.PortfolioCustody) {
const authorizationData = data as { value: NumberedPortfolio | DefaultPortfolio };

return (
signerToString(target) === address &&
!authorizationRequest.isExpired() &&
type === AuthorizationType.PortfolioCustody &&
authorizationData.value.uuid === portfolio.uuid
);
}

return false;
});

if (hasPendingAuth) {
throw new PolymeshError({
code: ErrorCode.ValidationError,
message:
'The target Account already has a pending invitation to be the custodian for the portfolio',
});
}

// TODO @shuffledex: validate owner and custodian properties

const rawSignatory = signerValueToSignatory(
{ type: SignerType.Account, value: address },
context
);

const rawAuthorizationData = authorizationToAuthorizationData(
{ type: AuthorizationType.PortfolioCustody, value: portfolio },
context
);

const rawExpiry = expiry ? dateToMoment(expiry, context) : null;

this.addTransaction(identity.addAuthorization, {}, rawSignatory, rawAuthorizationData, rawExpiry);
}

/**
* @hidden
*/
export const setCustodian = new Procedure(prepareSetCustodian);
4 changes: 2 additions & 2 deletions src/types/index.ts
Expand Up @@ -9,7 +9,7 @@ import {
DefaultPortfolio,
Identity,
NumberedPortfolio,
Portfolio /*, Proposal */,
/*, Proposal */
} from '~/api/entities';
// import { ProposalDetails } from '~/api/entities/Proposal/types';
import { CountryCode } from '~/generated/types';
Expand Down Expand Up @@ -194,7 +194,7 @@ export enum AuthorizationType {
export type Authorization =
| { type: AuthorizationType.NoData }
| { type: AuthorizationType.JoinIdentity; value: Permission[] }
| { type: AuthorizationType.PortfolioCustody; value: Portfolio }
| { type: AuthorizationType.PortfolioCustody; value: NumberedPortfolio | DefaultPortfolio }
| {
type: Exclude<
AuthorizationType,
Expand Down

0 comments on commit 56fc3f4

Please sign in to comment.