Skip to content

Commit fb345b7

Browse files
committed
feat: add signTransferData endpoint
This endpoint allows a third party (or the issuer themselves) to sign KYC data to be considered in transfer functions
1 parent 12e58bf commit fb345b7

File tree

5 files changed

+243
-0
lines changed

5 files changed

+243
-0
lines changed

src/entities/SecurityToken/Transfers/Transfers.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { SubModule } from '../SubModule';
22
import { Restrictions } from './Restrictions';
33
import { SecurityToken } from '../SecurityToken';
44
import { Context } from '../../../Context';
5+
import { SignTransferData } from '../../../procedures';
6+
import { ShareholderDataEntry, Omit } from '../../../types';
57

68
export class Transfers extends SubModule {
79
public restrictions: Restrictions;
@@ -11,4 +13,39 @@ export class Transfers extends SubModule {
1113

1214
this.restrictions = new Restrictions(securityToken, context);
1315
}
16+
17+
/**
18+
* Generate a signature string based on dynamic KYC data. This data can be used to:
19+
* - Check if a transfer can be made (using `canTransfer`) with different KYC data than is currently present
20+
* - Actually make a transfer (using `transfer`) with different KYC data than is currently present (in this case, the existing KYC data will be overwritten)
21+
*
22+
* The signature can be generated by a third party other than the issuer. The signing wallet should have permission to modify KYC data (via the Shareholders Administrator role).
23+
* Otherwise, the new data will be disregarded
24+
*
25+
* Note that, when supplying KYC data for signing, ALL investor entries should be supplied (even those that remain the same)
26+
*
27+
* @param kycData new KYC data array to sign
28+
* @param kycData[].address shareholder wallet address
29+
* @param kycData[].canSendAfter date after which the shareholder can transfer tokens (sell lockup)
30+
* @param kycData[].canReceiveAfter date after which the shareholder can receive tokens (buy lockup)
31+
* @param kycData[].kycExpiry date at which the shareholder's KYC expires
32+
* @param validFrom date from which this signature is valid
33+
* @param validTo date until which this signature is valid
34+
*/
35+
public signTransferData = async (args: {
36+
kycData: Omit<Omit<ShareholderDataEntry, 'canBuyFromSto'>, 'isAccredited'>[];
37+
validFrom: Date;
38+
validTo: Date;
39+
}) => {
40+
const { context, securityToken } = this;
41+
const { symbol } = securityToken;
42+
const procedure = new SignTransferData(
43+
{
44+
symbol,
45+
...args,
46+
},
47+
context
48+
);
49+
return procedure.prepare();
50+
};
1451
}

src/procedures/SignTransferData.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Procedure } from './Procedure';
2+
import { ProcedureType, ErrorCode, SignTransferDataProcedureArgs } from '../types';
3+
import { PolymathError } from '../PolymathError';
4+
5+
export class SignTransferData extends Procedure<SignTransferDataProcedureArgs> {
6+
public type = ProcedureType.SignTransferData;
7+
8+
public async prepareTransactions() {
9+
const { kycData, validFrom, validTo, symbol } = this.args;
10+
const { contractWrappers } = this.context;
11+
12+
let securityToken;
13+
14+
try {
15+
securityToken = await contractWrappers.tokenFactory.getSecurityTokenInstanceFromTicker(
16+
symbol
17+
);
18+
} catch (err) {
19+
throw new PolymathError({
20+
code: ErrorCode.ProcedureValidationError,
21+
message: `There is no Security Token with symbol ${symbol}`,
22+
});
23+
}
24+
25+
if (validFrom >= validTo) {
26+
throw new PolymathError({
27+
code: ErrorCode.ProcedureValidationError,
28+
message: 'Signature validity lower bound must be at an earlier date than the upper bound',
29+
});
30+
}
31+
32+
if (validTo < new Date()) {
33+
throw new PolymathError({
34+
code: ErrorCode.ProcedureValidationError,
35+
message: "Signature validity upper bound can't be in the past",
36+
});
37+
}
38+
39+
const investorsData = kycData.map(({ kycExpiry, address, ...rest }) => ({
40+
expiryTime: kycExpiry,
41+
investorAddress: address,
42+
...rest,
43+
}));
44+
45+
await this.addSignatureRequest(securityToken.signTransferData)({
46+
validFrom,
47+
validTo,
48+
investorsData,
49+
});
50+
}
51+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ImportMock, MockManager } from 'ts-mock-imports';
2+
import { spy, restore } from 'sinon';
3+
import * as contractWrappersModule from '@polymathnetwork/contract-wrappers';
4+
import * as contextModule from '../../Context';
5+
import { Factories } from '../../Context';
6+
import * as wrappersModule from '../../PolymathBase';
7+
import * as tokenFactoryModule from '../../testUtils/MockedTokenFactoryModule';
8+
import { SignTransferData } from '../../procedures/SignTransferData';
9+
import { Procedure } from '../../procedures/Procedure';
10+
import { ProcedureType, ErrorCode, PolyTransactionTag } from '../../types';
11+
import { PolymathError } from '../../PolymathError';
12+
import { mockFactories } from '../../testUtils/mockFactories';
13+
14+
const params = {
15+
symbol: 'TEST1',
16+
whitelistData: [
17+
{
18+
address: '0x01',
19+
canSendAfter: new Date(),
20+
canReceiveAfter: new Date(),
21+
kycExpiry: new Date(),
22+
},
23+
{
24+
address: '0x02',
25+
canSendAfter: new Date(),
26+
canReceiveAfter: new Date(),
27+
kycExpiry: new Date(),
28+
},
29+
],
30+
validFrom: new Date(0),
31+
validTo: new Date(new Date().getTime() + 10000),
32+
};
33+
34+
describe('SignTransferData', () => {
35+
let target: SignTransferData;
36+
let contextMock: MockManager<contextModule.Context>;
37+
let wrappersMock: MockManager<wrappersModule.PolymathBase>;
38+
let tokenFactoryMock: MockManager<tokenFactoryModule.MockedTokenFactoryModule>;
39+
let securityTokenMock: MockManager<contractWrappersModule.SecurityToken_3_0_0>;
40+
let factoriesMockedSetup: Factories;
41+
42+
beforeEach(() => {
43+
// Mock the context, wrappers, tokenFactory and securityToken to test SignTransferData
44+
contextMock = ImportMock.mockClass(contextModule, 'Context');
45+
wrappersMock = ImportMock.mockClass(wrappersModule, 'PolymathBase');
46+
47+
tokenFactoryMock = ImportMock.mockClass(tokenFactoryModule, 'MockedTokenFactoryModule');
48+
securityTokenMock = ImportMock.mockClass(contractWrappersModule, 'SecurityToken_3_0_0');
49+
50+
tokenFactoryMock.mock(
51+
'getSecurityTokenInstanceFromTicker',
52+
securityTokenMock.getMockInstance()
53+
);
54+
55+
contextMock.set('contractWrappers', wrappersMock.getMockInstance());
56+
wrappersMock.set('tokenFactory', tokenFactoryMock.getMockInstance());
57+
58+
factoriesMockedSetup = mockFactories();
59+
contextMock.set('factories', factoriesMockedSetup);
60+
61+
target = new SignTransferData(params, contextMock.getMockInstance());
62+
});
63+
64+
afterEach(() => {
65+
restore();
66+
});
67+
68+
describe('Types', () => {
69+
test('should extend procedure and have SignTransferData type', async () => {
70+
expect(target instanceof Procedure).toBe(true);
71+
expect(target.type).toBe(ProcedureType.SignTransferData);
72+
});
73+
});
74+
75+
describe('SignTransferData', () => {
76+
test('should throw if there is no valid security token being provided', async () => {
77+
tokenFactoryMock
78+
.mock('getSecurityTokenInstanceFromTicker')
79+
.withArgs(params.symbol)
80+
.throws();
81+
82+
await expect(target.prepareTransactions()).rejects.toThrow(
83+
new PolymathError({
84+
code: ErrorCode.ProcedureValidationError,
85+
message: `There is no Security Token with symbol ${params.symbol}`,
86+
})
87+
);
88+
});
89+
90+
test('should throw if the signature validity lower bound is not an earlier date than the upper bound', async () => {
91+
const now = new Date();
92+
target = new SignTransferData(
93+
{
94+
...params,
95+
validTo: now,
96+
validFrom: now,
97+
},
98+
contextMock.getMockInstance()
99+
);
100+
101+
// Real call
102+
await expect(target.prepareTransactions()).rejects.toThrowError(
103+
new PolymathError({
104+
code: ErrorCode.ProcedureValidationError,
105+
message: 'Signature validity lower bound must be at an earlier date than the upper bound',
106+
})
107+
);
108+
});
109+
110+
test('should throw if the signature validity upper bound is in the past', async () => {
111+
const now = new Date();
112+
target = new SignTransferData(
113+
{
114+
...params,
115+
validTo: new Date(now.getTime() - 10000),
116+
},
117+
contextMock.getMockInstance()
118+
);
119+
120+
// Real call
121+
await expect(target.prepareTransactions()).rejects.toThrowError(
122+
new PolymathError({
123+
code: ErrorCode.ProcedureValidationError,
124+
message: "Signature validity upper bound can't be in the past",
125+
})
126+
);
127+
});
128+
129+
test('should add a signature request to the queue to sign whitelist data', async () => {
130+
const addSignatureRequestSpy = spy(target, 'addSignatureRequest');
131+
securityTokenMock.mock('signTransferData', Promise.resolve('SignTransferData'));
132+
133+
// Real call
134+
await target.prepareTransactions();
135+
136+
// Verifications
137+
expect(
138+
addSignatureRequestSpy
139+
.getCall(0)
140+
.calledWith(securityTokenMock.getMockInstance().signTransferData)
141+
).toEqual(true);
142+
expect(addSignatureRequestSpy.callCount).toEqual(1);
143+
});
144+
});
145+
});

src/procedures/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ export { TransferSecurityTokens } from './TransferSecurityTokens';
4141
export { ToggleFreezeTransfers } from './ToggleFreezeTransfers';
4242
export { ModifyDividendsDefaultExclusionList } from './ModifyDividendsDefaultExclusionList';
4343
export { ModifyPreIssuing } from './ModifyPreIssuing';
44+
export { SignTransferData } from './SignTransferData';

src/types/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export enum ProcedureType {
159159
ModifyPercentageExemptions = 'ModifyPercentageExemptions',
160160
TransferSecurityTokens = 'TransferSecurityTokens',
161161
ToggleFreezeTransfers = 'ToggleFreezeTransfers',
162+
SignTransferData = 'SignTransferData',
162163
}
163164

164165
export enum PolyTransactionTag {
@@ -598,6 +599,13 @@ export interface ToggleFreezeTransfersProcedureArgs {
598599
freeze: boolean;
599600
}
600601

602+
export interface SignTransferDataProcedureArgs {
603+
symbol: string;
604+
kycData: Omit<Omit<ShareholderDataEntry, 'isAccredited'>, 'canBuyFromSto'>[];
605+
validFrom: Date;
606+
validTo: Date;
607+
}
608+
601609
export interface ProcedureArguments {
602610
[ProcedureType.ApproveErc20]: ApproveErc20ProcedureArgs;
603611
[ProcedureType.TransferErc20]: TransferErc20ProcedureArgs;
@@ -641,6 +649,7 @@ export interface ProcedureArguments {
641649
[ProcedureType.ModifyPercentageExemptions]: ModifyPercentageExemptionsProcedureArgs;
642650
[ProcedureType.TransferSecurityTokens]: TransferSecurityTokensProcedureArgs;
643651
[ProcedureType.ToggleFreezeTransfers]: ToggleFreezeTransfersProcedureArgs;
652+
[ProcedureType.SignTransferData]: SignTransferDataProcedureArgs;
644653
[ProcedureType.UnnamedProcedure]: {};
645654
}
646655

0 commit comments

Comments
 (0)