Skip to content

Commit 463b693

Browse files
feat: 🎸 add unlockInstruction to complete the lock/relock cycle
`Instruction.unlockForExecution` moves a `LockedForExecution` instruction back to `Pending`, and `Instruction.getRelockStatus` exposes the mediator's last unlock timestamp, relock count, and the on-chain relock cooldown window, so callers can implement the full lock/relock flow that `lockForExecution` alone couldn't complete.
1 parent d6ec0ca commit 463b693

7 files changed

Lines changed: 463 additions & 0 deletions

File tree

src/api/entities/Instruction/__tests__/index.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,73 @@ describe('Instruction class', () => {
303303
});
304304
});
305305

306+
describe('method: getRelockStatus', () => {
307+
afterAll(() => {
308+
jest.restoreAllMocks();
309+
});
310+
311+
let bigNumberToU64Spy: jest.SpyInstance;
312+
const unlockedTime = new Date('2025-07-01');
313+
const relockCooldown = new BigNumber(86400000);
314+
const maxRelockCount = new BigNumber(3);
315+
const relockCount = new BigNumber(1);
316+
317+
beforeAll(() => {
318+
bigNumberToU64Spy = jest.spyOn(utilsConversionModule, 'bigNumberToU64');
319+
dsMockUtils.createQueryMock('settlement', 'unlockedTimestamp');
320+
dsMockUtils.createQueryMock('settlement', 'instructionRelockCount');
321+
});
322+
323+
beforeEach(() => {
324+
when(bigNumberToU64Spy).calledWith(id, context).mockReturnValue(rawId);
325+
326+
dsMockUtils.setConstMock('settlement', 'relockCooldown', {
327+
returnValue: dsMockUtils.createMockU64(relockCooldown),
328+
});
329+
dsMockUtils.setConstMock('settlement', 'maxRelockCount', {
330+
returnValue: dsMockUtils.createMockU32(maxRelockCount),
331+
});
332+
});
333+
334+
it('should return all the details for an instruction that has been unlocked', async () => {
335+
dsMockUtils
336+
.getQueryMultiMock()
337+
.mockResolvedValue([
338+
dsMockUtils.createMockOption(
339+
dsMockUtils.createMockU64(new BigNumber(unlockedTime.getTime()))
340+
),
341+
dsMockUtils.createMockU32(relockCount),
342+
]);
343+
344+
const result = await instruction.getRelockStatus();
345+
346+
expect(result).toEqual({
347+
unlockedAt: unlockedTime,
348+
relockCount,
349+
maxRelockCount,
350+
cooldownEndsAt: new Date(unlockedTime.getTime() + relockCooldown.toNumber()),
351+
});
352+
});
353+
354+
it('should return all the details for an instruction that has never been unlocked', async () => {
355+
dsMockUtils
356+
.getQueryMultiMock()
357+
.mockResolvedValue([
358+
dsMockUtils.createMockOption(),
359+
dsMockUtils.createMockU32(new BigNumber(0)),
360+
]);
361+
362+
const result = await instruction.getRelockStatus();
363+
364+
expect(result).toEqual({
365+
unlockedAt: null,
366+
relockCount: new BigNumber(0),
367+
maxRelockCount,
368+
cooldownEndsAt: null,
369+
});
370+
});
371+
});
372+
306373
describe('method: onStatusChange', () => {
307374
let bigNumberToU64Spy: jest.SpyInstance;
308375
let instructionStatusesMock: jest.Mock;
@@ -1540,6 +1607,31 @@ describe('Instruction class', () => {
15401607
});
15411608
});
15421609

1610+
describe('method: unlockForExecution', () => {
1611+
afterAll(() => {
1612+
jest.restoreAllMocks();
1613+
});
1614+
1615+
it('should prepare the procedure and return the resulting transaction', async () => {
1616+
const expectedTransaction = 'someTransaction' as unknown as PolymeshTransaction<Instruction>;
1617+
1618+
when(procedureMockUtils.getPrepareMock())
1619+
.calledWith(
1620+
{
1621+
args: { id },
1622+
transformer: undefined,
1623+
},
1624+
context,
1625+
{}
1626+
)
1627+
.mockResolvedValue(expectedTransaction);
1628+
1629+
const tx = await instruction.unlockForExecution();
1630+
1631+
expect(tx).toBe(expectedTransaction);
1632+
});
1633+
});
1634+
15431635
describe('method: rejectAsMediator', () => {
15441636
afterAll(() => {
15451637
jest.restoreAllMocks();

src/api/entities/Instruction/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
AffirmationStatus,
1010
InstructionAffirmation,
1111
InstructionDetails,
12+
InstructionRelockStatus,
1213
InstructionStatus,
1314
InstructionStatusResult,
1415
Leg,
@@ -17,6 +18,7 @@ import {
1718
} from '~/api/entities/Instruction/types';
1819
import { executeManualInstruction } from '~/api/procedures/executeManualInstruction';
1920
import { lockInstructionForExecution } from '~/api/procedures/lockInstructionForExecution';
21+
import { unlockInstructionForExecution } from '~/api/procedures/unlockInstructionForExecution';
2022
import {
2123
Account,
2224
Context,
@@ -90,6 +92,7 @@ import {
9092
momentToDate,
9193
stringToBytes,
9294
tickerToString,
95+
u32ToBigNumber,
9396
u64ToBigNumber,
9497
} from '~/utils/conversion';
9598
import {
@@ -221,6 +224,14 @@ export class Instruction extends Entity<UniqueIdentifiers, string> {
221224
},
222225
context
223226
);
227+
228+
this.unlockForExecution = createProcedureMethod(
229+
{
230+
getProcedureAndArgs: () => [unlockInstructionForExecution, { id }],
231+
voidArgs: true,
232+
},
233+
context
234+
);
224235
}
225236

226237
/**
@@ -347,6 +358,59 @@ export class Instruction extends Entity<UniqueIdentifiers, string> {
347358
};
348359
}
349360

361+
/**
362+
* Retrieve the relock cooldown status of the Instruction
363+
*
364+
* @note After a mediator unlocks an Instruction, they must wait for the relock cooldown period to
365+
* end before locking it again. `maxRelockCount` limits the total number of times an Instruction can be relocked.
366+
*/
367+
public async getRelockStatus(): Promise<InstructionRelockStatus> {
368+
const {
369+
context: {
370+
polymeshApi: {
371+
query: { settlement },
372+
consts: {
373+
settlement: { relockCooldown, maxRelockCount },
374+
},
375+
},
376+
},
377+
id,
378+
context,
379+
} = this;
380+
381+
const rawId = bigNumberToU64(id, context);
382+
383+
const [rawUnlockedTimestamp, rawRelockCount] = await requestMulti<
384+
[typeof settlement.unlockedTimestamp, typeof settlement.instructionRelockCount]
385+
>(context, [
386+
[settlement.unlockedTimestamp, rawId],
387+
[settlement.instructionRelockCount, rawId],
388+
]);
389+
390+
const relockCount = u32ToBigNumber(rawRelockCount);
391+
const maxRelockCountValue = u32ToBigNumber(maxRelockCount);
392+
393+
if (rawUnlockedTimestamp.isSome) {
394+
const unlockedAt = momentToDate(rawUnlockedTimestamp.unwrap());
395+
const cooldown = u64ToBigNumber(relockCooldown);
396+
const cooldownEndsAt = new Date(unlockedAt.getTime() + cooldown.toNumber());
397+
398+
return {
399+
unlockedAt,
400+
relockCount,
401+
maxRelockCount: maxRelockCountValue,
402+
cooldownEndsAt,
403+
};
404+
}
405+
406+
return {
407+
unlockedAt: null,
408+
relockCount,
409+
maxRelockCount: maxRelockCountValue,
410+
cooldownEndsAt: null,
411+
};
412+
}
413+
350414
/**
351415
* Retrieve current status of the Instruction. This can be subscribed to know if instruction fails
352416
*
@@ -960,6 +1024,13 @@ export class Instruction extends Entity<UniqueIdentifiers, string> {
9601024
*/
9611025
public lockForExecution: NoArgsProcedureMethod<Instruction>;
9621026

1027+
/**
1028+
* Unlocks an Instruction that is currently `LockedForExecution`, moving it back to `Pending`. Only a mediator of the instruction can unlock it.
1029+
*
1030+
* @note After unlocking, the mediator must wait for the relock cooldown period (see {@link getRelockStatus}) before locking the instruction again. This gives other parties time to reject the instruction if they wish to back out.
1031+
*/
1032+
public unlockForExecution: NoArgsProcedureMethod<Instruction>;
1033+
9631034
/**
9641035
* @hidden
9651036
* Retrieve Instruction status event from middleware V2

src/api/entities/Instruction/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,25 @@ export interface GroupedInvolvedInstructions {
168168
owned: Omit<GroupedInstructions, 'affirmed'>;
169169
}
170170

171+
export interface InstructionRelockStatus {
172+
/**
173+
* The date and time when the instruction was last unlocked by a mediator, `null` if it has never been unlocked
174+
*/
175+
unlockedAt: Date | null;
176+
/**
177+
* The number of times the instruction has been relocked
178+
*/
179+
relockCount: BigNumber;
180+
/**
181+
* The maximum number of times the instruction can be relocked
182+
*/
183+
maxRelockCount: BigNumber;
184+
/**
185+
* The date and time after which the instruction can be locked again, `null` if it has never been unlocked
186+
*/
187+
cooldownEndsAt: Date | null;
188+
}
189+
171190
export interface InstructionLockedInfo {
172191
/**
173192
* Whether the instruction is locked for execution
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { u64 } from '@polkadot/types';
2+
import BigNumber from 'bignumber.js';
3+
import { when } from 'jest-when';
4+
5+
import {
6+
getAuthorization,
7+
Params,
8+
prepareUnlockInstructionForExecution,
9+
} from '~/api/procedures/unlockInstructionForExecution';
10+
import * as procedureUtilsModule from '~/api/procedures/utils';
11+
import { Context, Instruction } from '~/internal';
12+
import { dsMockUtils, entityMockUtils, procedureMockUtils } from '~/testUtils/mocks';
13+
import { Mocked } from '~/testUtils/types';
14+
import { AffirmationStatus, TxTags } from '~/types';
15+
import * as utilsConversionModule from '~/utils/conversion';
16+
17+
jest.mock(
18+
'~/api/entities/Instruction',
19+
require('~/testUtils/mocks/entities').mockInstructionModule('~/api/entities/Instruction')
20+
);
21+
22+
describe('unlockInstructionForExecution procedure', () => {
23+
const id = new BigNumber(1);
24+
const rawInstructionId = dsMockUtils.createMockU64(id);
25+
26+
let mockContext: Mocked<Context>;
27+
let bigNumberToU64Spy: jest.SpyInstance<u64, [BigNumber, Context]>;
28+
29+
let unlockInstructionTxMock: jest.Mock;
30+
31+
beforeAll(() => {
32+
dsMockUtils.initMocks();
33+
procedureMockUtils.initMocks();
34+
entityMockUtils.initMocks();
35+
36+
bigNumberToU64Spy = jest.spyOn(utilsConversionModule, 'bigNumberToU64');
37+
38+
jest.spyOn(procedureUtilsModule, 'assertInstructionValidForUnlocking').mockImplementation();
39+
});
40+
41+
beforeEach(() => {
42+
entityMockUtils.configureMocks({
43+
instructionOptions: {
44+
getMediators: [
45+
{ identity: entityMockUtils.getIdentityInstance(), status: AffirmationStatus.Affirmed },
46+
],
47+
},
48+
});
49+
50+
unlockInstructionTxMock = dsMockUtils.createTxMock('settlement', 'unlockInstruction');
51+
52+
mockContext = dsMockUtils.getContextInstance();
53+
when(bigNumberToU64Spy).calledWith(id, mockContext).mockReturnValue(rawInstructionId);
54+
});
55+
56+
afterEach(() => {
57+
entityMockUtils.reset();
58+
procedureMockUtils.reset();
59+
dsMockUtils.reset();
60+
});
61+
62+
afterAll(() => {
63+
procedureMockUtils.cleanup();
64+
dsMockUtils.cleanup();
65+
});
66+
67+
it('should throw an error if signer is not a mediator', () => {
68+
entityMockUtils.configureMocks({
69+
instructionOptions: {
70+
getMediators: [
71+
{
72+
identity: entityMockUtils.getIdentityInstance({ did: 'randomDid' }),
73+
status: AffirmationStatus.Affirmed,
74+
},
75+
],
76+
},
77+
});
78+
79+
const proc = procedureMockUtils.getInstance<Params, Instruction>(mockContext);
80+
81+
return expect(
82+
prepareUnlockInstructionForExecution.call(proc, {
83+
id,
84+
})
85+
).rejects.toThrow('Only mediators can unlock instructions for execution');
86+
});
87+
88+
it('should throw an error if mediator affirmation has expired', () => {
89+
entityMockUtils.configureMocks({
90+
instructionOptions: {
91+
getMediators: [
92+
{
93+
identity: entityMockUtils.getIdentityInstance(),
94+
status: AffirmationStatus.Affirmed,
95+
expiry: new Date('2022/01/01'),
96+
},
97+
],
98+
},
99+
});
100+
101+
const proc = procedureMockUtils.getInstance<Params, Instruction>(mockContext);
102+
103+
return expect(
104+
prepareUnlockInstructionForExecution.call(proc, {
105+
id,
106+
})
107+
).rejects.toThrow('Mediator affirmation has expired');
108+
});
109+
110+
it('should return a transaction spec on successful unlocking', async () => {
111+
const proc = procedureMockUtils.getInstance<Params, Instruction>(mockContext);
112+
113+
const result = await prepareUnlockInstructionForExecution.call(proc, {
114+
id,
115+
});
116+
117+
expect(result).toEqual({
118+
transaction: unlockInstructionTxMock,
119+
args: [rawInstructionId],
120+
resolver: expect.objectContaining({ id }),
121+
});
122+
});
123+
124+
describe('getAuthorization', () => {
125+
it('should return the appropriate roles and permissions', () => {
126+
const proc = procedureMockUtils.getInstance<Params, Instruction>(mockContext, { id });
127+
const boundFunc = getAuthorization.bind(proc);
128+
129+
const result = boundFunc();
130+
131+
expect(result).toEqual({
132+
permissions: {
133+
assets: [],
134+
portfolios: [],
135+
transactions: [TxTags.settlement.UnlockInstruction],
136+
},
137+
});
138+
});
139+
});
140+
});

0 commit comments

Comments
 (0)