Skip to content

Commit ee743ed

Browse files
committed
add direct cancellation when remaining balance is 0 and fixed some bugs
1 parent 9490cf8 commit ee743ed

File tree

17 files changed

+382
-41
lines changed

17 files changed

+382
-41
lines changed

packages/apps/fortune/exchange-oracle/server/src/common/enums/webhook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum EventType {
77
SUBMISSION_IN_REVIEW = 'submission_in_review',
88
ABUSE_DETECTED = 'abuse_detected',
99
ABUSE_DISMISSED = 'abuse_dismissed',
10+
CANCELLATION_REQUESTED = 'cancellation_requested',
1011
}
1112

1213
export enum WebhookStatus {

packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,12 @@ describe('WebhookService', () => {
142142
expect(jobService.createJob).toHaveBeenCalledWith(webhook);
143143
});
144144

145-
it('should handle an incoming escrow canceled webhook', async () => {
145+
it('should handle an incoming cancellation request webhook', async () => {
146146
jest.spyOn(jobService, 'cancelJob').mockResolvedValue();
147147
const webhook: WebhookDto = {
148148
chainId,
149149
escrowAddress,
150-
eventType: EventType.ESCROW_CANCELED,
150+
eventType: EventType.CANCELLATION_REQUESTED,
151151
};
152152
expect(await webhookService.handleWebhook(webhook)).toBe(undefined);
153153
expect(jobService.cancelJob).toHaveBeenCalledWith(webhook);

packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class WebhookService {
4343
await this.jobService.completeJob(webhook);
4444
break;
4545

46-
case EventType.ESCROW_CANCELED:
46+
case EventType.CANCELLATION_REQUESTED:
4747
await this.jobService.cancelJob(webhook);
4848
break;
4949

@@ -59,6 +59,9 @@ export class WebhookService {
5959
await this.jobService.resumeJob(webhook);
6060
break;
6161

62+
case EventType.ESCROW_CANCELED:
63+
return;
64+
6265
default:
6366
throw new ValidationError(
6467
`Invalid webhook event type: ${webhook.eventType}`,

packages/apps/fortune/recording-oracle/src/common/enums/webhook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export enum EventType {
22
ESCROW_COMPLETED = 'escrow_completed',
3-
ESCROW_CANCELED = 'escrow_canceled',
3+
CANCELLATION_REQUESTED = 'cancellation_requested',
44
JOB_COMPLETED = 'job_completed',
55
JOB_CANCELED = 'job_canceled',
66
SUBMISSION_REJECTED = 'submission_rejected',

packages/apps/fortune/recording-oracle/src/modules/webhook/webhook.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export class WebhookService {
1818
await this.jobService.processJobSolution(wehbook);
1919
break;
2020

21-
case EventType.ESCROW_CANCELED:
21+
case EventType.CANCELLATION_REQUESTED:
2222
await this.jobService.cancelJob(wehbook);
2323
break;
2424

packages/apps/job-launcher/server/src/common/enums/webhook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum EventType {
44
ESCROW_COMPLETED = 'escrow_completed',
55
ESCROW_FAILED = 'escrow_failed',
66
ABUSE_DETECTED = 'abuse_detected',
7+
CANCELLATION_REQUESTED = 'cancellation_requested',
78
}
89

910
export enum OracleType {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddCancellationRequestEventType1749483713555
4+
implements MigrationInterface
5+
{
6+
name = 'AddCancellationRequestEventType1749483713555';
7+
8+
public async up(queryRunner: QueryRunner): Promise<void> {
9+
await queryRunner.query(`
10+
DROP INDEX "hmt"."IDX_e834f9a1d4dc20775e2cb2319e"
11+
`);
12+
await queryRunner.query(`
13+
ALTER TYPE "hmt"."webhook_event_type_enum"
14+
RENAME TO "webhook_event_type_enum_old"
15+
`);
16+
await queryRunner.query(`
17+
CREATE TYPE "hmt"."webhook_event_type_enum" AS ENUM(
18+
'escrow_created',
19+
'escrow_canceled',
20+
'escrow_completed',
21+
'escrow_failed',
22+
'abuse_detected',
23+
'cancellation_requested'
24+
)
25+
`);
26+
await queryRunner.query(`
27+
ALTER TABLE "hmt"."webhook"
28+
ALTER COLUMN "event_type" TYPE "hmt"."webhook_event_type_enum" USING "event_type"::"text"::"hmt"."webhook_event_type_enum"
29+
`);
30+
await queryRunner.query(`
31+
DROP TYPE "hmt"."webhook_event_type_enum_old"
32+
`);
33+
await queryRunner.query(`
34+
CREATE UNIQUE INDEX "IDX_e834f9a1d4dc20775e2cb2319e" ON "hmt"."webhook" (
35+
"chain_id",
36+
"escrow_address",
37+
"event_type",
38+
"oracle_address"
39+
)
40+
`);
41+
}
42+
43+
public async down(queryRunner: QueryRunner): Promise<void> {
44+
await queryRunner.query(`
45+
DROP INDEX "hmt"."IDX_e834f9a1d4dc20775e2cb2319e"
46+
`);
47+
await queryRunner.query(`
48+
CREATE TYPE "hmt"."webhook_event_type_enum_old" AS ENUM(
49+
'abuse_detected',
50+
'escrow_canceled',
51+
'escrow_completed',
52+
'escrow_created',
53+
'escrow_failed'
54+
)
55+
`);
56+
await queryRunner.query(`
57+
ALTER TABLE "hmt"."webhook"
58+
ALTER COLUMN "event_type" TYPE "hmt"."webhook_event_type_enum_old" USING "event_type"::"text"::"hmt"."webhook_event_type_enum_old"
59+
`);
60+
await queryRunner.query(`
61+
DROP TYPE "hmt"."webhook_event_type_enum"
62+
`);
63+
await queryRunner.query(`
64+
ALTER TYPE "hmt"."webhook_event_type_enum_old"
65+
RENAME TO "webhook_event_type_enum"
66+
`);
67+
await queryRunner.query(`
68+
CREATE UNIQUE INDEX "IDX_e834f9a1d4dc20775e2cb2319e" ON "hmt"."webhook" (
69+
"chain_id",
70+
"escrow_address",
71+
"event_type",
72+
"oracle_address"
73+
)
74+
`);
75+
}
76+
}

packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export class CronJobService {
290290
const baseWebhook = {
291291
escrowAddress: jobEntity.escrowAddress,
292292
chainId: jobEntity.chainId,
293-
eventType: EventType.ESCROW_CANCELED,
293+
eventType: EventType.CANCELLATION_REQUESTED,
294294
oracleType,
295295
hasSignature: true,
296296
};
@@ -346,7 +346,7 @@ export class CronJobService {
346346
try {
347347
const webhookEntities = await this.webhookRepository.findByStatusAndType(
348348
WebhookStatus.PENDING,
349-
[EventType.ESCROW_CREATED, EventType.ESCROW_CANCELED],
349+
[EventType.ESCROW_CREATED, EventType.CANCELLATION_REQUESTED],
350350
);
351351

352352
for (const webhookEntity of webhookEntities) {

packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts

Lines changed: 143 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createMock } from '@golevelup/ts-jest';
66
import {
77
ChainId,
88
EscrowClient,
9+
EscrowStatus,
910
EscrowUtils,
1011
KVStoreUtils,
1112
NETWORKS,
@@ -924,6 +925,7 @@ describe('JobService', () => {
924925
chainId: jobEntity.chainId,
925926
eventType: EventType.ESCROW_CREATED,
926927
oracleType: jobEntity.requestType,
928+
oracleAddress: jobEntity.exchangeOracle,
927929
hasSignature: true,
928930
status: WebhookStatus.PENDING,
929931
retriesCount: 0,
@@ -1409,6 +1411,52 @@ describe('JobService', () => {
14091411
const result = await jobService.processEscrowCancellation(jobEntity);
14101412
expect(result).toHaveProperty('txHash');
14111413
});
1414+
1415+
it('should throw if escrow status is not Active', async () => {
1416+
const jobEntity = createJobEntity();
1417+
mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n);
1418+
mockedEscrowClient.build.mockResolvedValue({
1419+
getStatus: jest.fn().mockResolvedValue(EscrowStatus.Complete),
1420+
getBalance: jest.fn().mockResolvedValue(100n),
1421+
cancel: jest.fn(),
1422+
} as unknown as EscrowClient);
1423+
1424+
await expect(
1425+
jobService.processEscrowCancellation(jobEntity),
1426+
).rejects.toThrow(
1427+
new ConflictError(ErrorEscrow.InvalidStatusCancellation),
1428+
);
1429+
});
1430+
1431+
it('should throw if escrow balance is zero', async () => {
1432+
const jobEntity = createJobEntity();
1433+
mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n);
1434+
mockedEscrowClient.build.mockResolvedValue({
1435+
getStatus: jest.fn().mockResolvedValue(EscrowStatus.Pending),
1436+
getBalance: jest.fn().mockResolvedValue(0n),
1437+
cancel: jest.fn(),
1438+
} as unknown as EscrowClient);
1439+
1440+
await expect(
1441+
jobService.processEscrowCancellation(jobEntity),
1442+
).rejects.toThrow(
1443+
new ConflictError(ErrorEscrow.InvalidBalanceCancellation),
1444+
);
1445+
});
1446+
1447+
it('should throw if cancel throws an error', async () => {
1448+
const jobEntity = createJobEntity();
1449+
mockWeb3Service.calculateGasPrice.mockResolvedValueOnce(1n);
1450+
mockedEscrowClient.build.mockResolvedValue({
1451+
getStatus: jest.fn().mockResolvedValue(EscrowStatus.Pending),
1452+
getBalance: jest.fn().mockResolvedValue(100n),
1453+
cancel: jest.fn().mockRejectedValue(new Error('Network error')),
1454+
} as unknown as EscrowClient);
1455+
1456+
await expect(
1457+
jobService.processEscrowCancellation(jobEntity),
1458+
).rejects.toThrow('Network error');
1459+
});
14121460
});
14131461

14141462
describe('escrowFailedWebhook', () => {
@@ -1612,7 +1660,7 @@ describe('JobService', () => {
16121660
});
16131661
});
16141662

1615-
describe('completeJob', () => {
1663+
describe('finalizeJob', () => {
16161664
it('should complete a job', async () => {
16171665
const jobEntity = createJobEntity({
16181666
status: JobStatus.LAUNCHED,
@@ -1621,7 +1669,7 @@ describe('JobService', () => {
16211669
jobEntity,
16221670
);
16231671
await expect(
1624-
jobService.completeJob({
1672+
jobService.finalizeJob({
16251673
chainId: ChainId.POLYGON_AMOY,
16261674
escrowAddress: faker.finance.ethereumAddress(),
16271675
eventType: EventType.ESCROW_COMPLETED,
@@ -1633,33 +1681,64 @@ describe('JobService', () => {
16331681
});
16341682
});
16351683

1636-
it('should throw an error if job not found', async () => {
1684+
it('should do nothing if job is already COMPLETED', async () => {
1685+
const jobEntity = createJobEntity({ status: JobStatus.COMPLETED });
16371686
mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce(
1638-
null,
1687+
jobEntity,
16391688
);
16401689
await expect(
1641-
jobService.completeJob({
1690+
jobService.finalizeJob({
16421691
chainId: ChainId.POLYGON_AMOY,
16431692
escrowAddress: faker.finance.ethereumAddress(),
16441693
eventType: EventType.ESCROW_COMPLETED,
16451694
}),
1646-
).rejects.toThrow(new NotFoundError(ErrorJob.NotFound));
1695+
).resolves.toBeUndefined();
1696+
expect(mockJobRepository.updateOne).not.toHaveBeenCalled();
16471697
});
16481698

1649-
it('should throw an error if job is not in LAUNCHED or PARTIAL status', async () => {
1650-
const jobEntity = createJobEntity({
1651-
status: JobStatus.CANCELED,
1652-
});
1699+
it('should do nothing if job is already CANCELED', async () => {
1700+
const jobEntity = createJobEntity({ status: JobStatus.CANCELED });
16531701
mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce(
16541702
jobEntity,
16551703
);
16561704
await expect(
1657-
jobService.completeJob({
1705+
jobService.finalizeJob({
16581706
chainId: ChainId.POLYGON_AMOY,
16591707
escrowAddress: faker.finance.ethereumAddress(),
16601708
eventType: EventType.ESCROW_COMPLETED,
16611709
}),
1662-
).rejects.toThrow(new ValidationError(ErrorJob.InvalidStatusCompletion));
1710+
).resolves.toBeUndefined();
1711+
expect(mockJobRepository.updateOne).not.toHaveBeenCalled();
1712+
});
1713+
1714+
it('should call cancelJob if eventType is ESCROW_CANCELED and status is LAUNCHED', async () => {
1715+
const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED });
1716+
mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce(
1717+
jobEntity,
1718+
);
1719+
const cancelJobSpy = jest
1720+
.spyOn(jobService, 'cancelJob')
1721+
.mockResolvedValueOnce();
1722+
await jobService.finalizeJob({
1723+
chainId: ChainId.POLYGON_AMOY,
1724+
escrowAddress: faker.finance.ethereumAddress(),
1725+
eventType: EventType.ESCROW_CANCELED,
1726+
});
1727+
expect(cancelJobSpy).toHaveBeenCalledWith(jobEntity);
1728+
});
1729+
1730+
it('should throw ConflictError if eventType is not ESCROW_COMPLETED or ESCROW_CANCELED', async () => {
1731+
const jobEntity = createJobEntity({ status: JobStatus.LAUNCHED });
1732+
mockJobRepository.findOneByChainIdAndEscrowAddress.mockResolvedValueOnce(
1733+
jobEntity,
1734+
);
1735+
await expect(
1736+
jobService.finalizeJob({
1737+
chainId: ChainId.POLYGON_AMOY,
1738+
escrowAddress: faker.finance.ethereumAddress(),
1739+
eventType: EventType.ESCROW_FAILED,
1740+
}),
1741+
).rejects.toThrow(new ConflictError(ErrorJob.InvalidStatusCompletion));
16631742
});
16641743
});
16651744

@@ -1693,4 +1772,56 @@ describe('JobService', () => {
16931772
expect(result).toBe(false);
16941773
});
16951774
});
1775+
1776+
describe('cancelJob', () => {
1777+
it('should create a refund payment and set status to CANCELED', async () => {
1778+
const jobEntity = createJobEntity();
1779+
const refundAmount = faker.number.float({ min: 1, max: 10 });
1780+
1781+
mockedEscrowUtils.getCancellationRefund.mockResolvedValueOnce({
1782+
amount: ethers.parseUnits(refundAmount.toString(), 18),
1783+
escrowAddress: jobEntity.escrowAddress!,
1784+
} as any);
1785+
mockPaymentService.createRefundPayment.mockResolvedValueOnce(undefined);
1786+
mockJobRepository.updateOne.mockResolvedValueOnce(jobEntity);
1787+
1788+
await jobService.cancelJob(jobEntity);
1789+
1790+
expect(EscrowUtils.getCancellationRefund).toHaveBeenCalledWith(
1791+
jobEntity.chainId,
1792+
jobEntity.escrowAddress,
1793+
);
1794+
expect(mockPaymentService.createRefundPayment).toHaveBeenCalledWith({
1795+
refundAmount: refundAmount,
1796+
refundCurrency: jobEntity.token,
1797+
userId: jobEntity.userId,
1798+
jobId: jobEntity.id,
1799+
});
1800+
expect(jobEntity.status).toBe(JobStatus.CANCELED);
1801+
expect(mockJobRepository.updateOne).toHaveBeenCalledWith(jobEntity);
1802+
});
1803+
1804+
it('should throw ConflictError if no refund is found', async () => {
1805+
const jobEntity = createJobEntity();
1806+
mockedEscrowUtils.getCancellationRefund.mockResolvedValueOnce(
1807+
null as any,
1808+
);
1809+
1810+
await expect(jobService.cancelJob(jobEntity)).rejects.toThrow(
1811+
new ConflictError(ErrorJob.NoRefundFound),
1812+
);
1813+
});
1814+
1815+
it('should throw ConflictError if refund.amount is empty', async () => {
1816+
const jobEntity = createJobEntity();
1817+
mockedEscrowUtils.getCancellationRefund.mockResolvedValueOnce({
1818+
amount: 0,
1819+
escrowAddress: jobEntity.escrowAddress!,
1820+
} as any);
1821+
1822+
await expect(jobService.cancelJob(jobEntity)).rejects.toThrow(
1823+
new ConflictError(ErrorJob.NoRefundFound),
1824+
);
1825+
});
1826+
});
16961827
});

packages/apps/job-launcher/server/src/modules/job/job.service.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -905,12 +905,10 @@ export class JobService {
905905
}
906906

907907
public async cancelJob(jobEntity: JobEntity): Promise<void> {
908-
console.log('Cancelling job');
909908
const refund = await EscrowUtils.getCancellationRefund(
910909
jobEntity.chainId,
911-
jobEntity.escrowAddress,
910+
jobEntity.escrowAddress!,
912911
);
913-
console.log(refund);
914912

915913
if (!refund || !refund.amount) {
916914
throw new ConflictError(ErrorJob.NoRefundFound);

0 commit comments

Comments
 (0)