Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class ReputationJobType1777986717078 implements MigrationInterface {
name = 'ReputationJobType1777986717078';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "hmt"."IDX_5012dff596f037415a1370a0cb"`,
);
await queryRunner.query(
`ALTER TABLE "hmt"."reputation" ADD "job_request_type" character varying NOT NULL DEFAULT 'image_skeletons_from_boxes'`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_e2589f31dc15f8cadca6c560ff" ON "hmt"."reputation" ("chain_id", "address", "type", "job_request_type") `,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "hmt"."IDX_e2589f31dc15f8cadca6c560ff"`,
);
await queryRunner.query(
`ALTER TABLE "hmt"."reputation" DROP COLUMN "job_request_type"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_5012dff596f037415a1370a0cb" ON "hmt"."reputation" ("chain_id", "address", "type") `,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import _ from 'lodash';
import { CvatJobType, FortuneJobType, MarketingJobType } from '@/common/enums';
import { ServerConfigService, Web3ConfigService } from '@/config';
import { ReputationService } from '@/modules/reputation';
import { ReputationEntityType } from '@/modules/reputation/constants';
import { StorageService } from '@/modules/storage';
import { WalletWithProvider, Web3Service } from '@/modules/web3';
import {
Expand Down Expand Up @@ -708,6 +709,7 @@ describe('EscrowCompletionService', () => {
let mockedSigner: SignerMock;
const mockedCreateBulkPayoutTransaction = jest.fn();
let mockedRawTransaction: { nonce: number };
let jobRequestType: FortuneJobType;

beforeEach(() => {
mockedSigner = createSignerMock();
Expand All @@ -725,13 +727,30 @@ describe('EscrowCompletionService', () => {
mockedCreateBulkPayoutTransaction.mockResolvedValueOnce(
mockedRawTransaction,
);

const manifest = generateFortuneManifest();
jobRequestType = manifest.requestType;
mockedEscrowUtils.getEscrow.mockResolvedValue({
manifest: faker.internet.url(),
} as unknown as IEscrow);
mockStorageService.downloadJsonLikeData.mockResolvedValue(manifest);
});

it('should succesfully process payouts batch', async () => {
const awaitingPayoutsRecord = generateEscrowCompletion(
EscrowCompletionStatus.AWAITING_PAYOUTS,
);
const payoutsBatch = generateEscrowPayoutsBatch();
payoutsBatch.payouts = [
{
address: faker.finance.ethereumAddress(),
amount: faker.number.bigInt({ min: 1n }).toString(),
},
{
address: faker.finance.ethereumAddress(),
amount: faker.number.bigInt({ min: 1n }).toString(),
},
];

await service['processPayoutsBatch'](awaitingPayoutsRecord, {
...payoutsBatch,
Expand Down Expand Up @@ -759,6 +778,21 @@ describe('EscrowCompletionService', () => {
id: payoutsBatch.id,
}),
);

expect(mockReputationService.increaseReputation).toHaveBeenCalledTimes(
payoutsBatch.payouts.length,
);
for (const payout of payoutsBatch.payouts) {
expect(mockReputationService.increaseReputation).toHaveBeenCalledWith(
{
chainId: awaitingPayoutsRecord.chainId,
address: payout.address,
type: ReputationEntityType.WORKER,
jobRequestType,
},
1,
);
}
});

it('should reset nonce if expired', async () => {
Expand Down Expand Up @@ -859,6 +893,9 @@ describe('EscrowCompletionService', () => {
launcherAddress = faker.finance.ethereumAddress();
exchangeOracleAddress = faker.finance.ethereumAddress();
recordingOracleAddress = faker.finance.ethereumAddress();
mockStorageService.downloadJsonLikeData.mockResolvedValue(
generateFortuneManifest(),
);
});

describe('handle failures', () => {
Expand Down Expand Up @@ -1075,6 +1112,7 @@ describe('EscrowCompletionService', () => {
launcherAddress,
exchangeOracleAddress,
recordingOracleAddress,
FortuneJobType.FORTUNE,
);

const expectedWebhookData = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ServerConfigService, Web3ConfigService } from '@/config';
import { isDuplicatedError } from '@/database';
import logger from '@/logger';
import { ReputationService } from '@/modules/reputation';
import { ReputationEntityType } from '@/modules/reputation/constants';
import { StorageService } from '@/modules/storage';
import { Web3Service } from '@/modules/web3';
/**
Expand Down Expand Up @@ -264,11 +265,14 @@ export class EscrowCompletionService {
/**
* This operation can fail and lost, so it's "at most once"
*/
const jobRequestType =
await this.getJobRequestTypeFromEscrowData(escrowData);
await this.reputationService.assessEscrowParties(
chainId,
escrowData.launcher,
escrowData.exchangeOracle!,
escrowData.recordingOracle!,
jobRequestType,
);
}

Expand Down Expand Up @@ -474,6 +478,15 @@ export class EscrowCompletionService {
);

await this.escrowPayoutsBatchRepository.deleteOne(payoutsBatch);
const jobRequestType = await this.getJobRequestTypeForEscrow(
escrowCompletionEntity.chainId,
escrowCompletionEntity.escrowAddress,
);
await this.increasePayoutRecipientsReputation(
escrowCompletionEntity.chainId,
Array.from(recipientToAmountMap.keys()),
jobRequestType,
);
} catch (error) {
if (ethers.isError(error, 'NONCE_EXPIRED')) {
payoutsBatch.txNonce = null;
Expand All @@ -484,6 +497,60 @@ export class EscrowCompletionService {
}
}

private async increasePayoutRecipientsReputation(
chainId: ChainId,
recipients: string[],
jobRequestType: JobRequestType,
): Promise<void> {
for (const address of recipients) {
try {
await this.reputationService.increaseReputation(
{
chainId,
address,
type: ReputationEntityType.WORKER,
jobRequestType,
},
1,
);
} catch (error) {
this.logger.error('Failed to increase payout recipient reputation', {
error,
address,
chainId,
jobRequestType,
});
}
}
}

private async getJobRequestTypeForEscrow(
chainId: ChainId,
escrowAddress: string,
): Promise<JobRequestType> {
const escrowData = await EscrowUtils.getEscrow(chainId, escrowAddress);
if (!escrowData) {
throw new Error('Escrow data is missing');
}

return this.getJobRequestTypeFromEscrowData(escrowData);
}

private async getJobRequestTypeFromEscrowData(
escrowData: Awaited<ReturnType<typeof EscrowUtils.getEscrow>>,
): Promise<JobRequestType> {
if (!escrowData) {
throw new Error('Escrow data is missing');
}

const manifest =
await this.storageService.downloadJsonLikeData<JobManifest>(
escrowData.manifest as string,
);

return manifestUtils.getJobRequestType(manifest);
}

private getEscrowResultsProcessor(
jobRequestType: JobRequestType,
): EscrowResultsProcessor {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { faker } from '@faker-js/faker';

import { CvatJobType } from '@/common/enums';
import { generateTestnetChainId } from '@/modules/web3/fixtures';

import { ReputationEntityType } from '../constants';
Expand All @@ -20,6 +21,7 @@ export function generateReputationEntity(score?: number): ReputationEntity {
chainId: generateTestnetChainId(),
address: faker.finance.ethereumAddress(),
type: generateReputationEntityType(),
jobRequestType: CvatJobType.IMAGE_BOXES,
reputationPoints: score || generateRandomScorePoints(),
createdAt: faker.date.recent(),
updatedAt: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class ReputationController {
const {
chainId,
address,
jobRequestTypes,
roles,
orderBy = GetReputationQueryOrderBy.CREATED_AT,
orderDirection = SortDirection.DESC,
Expand All @@ -58,6 +59,7 @@ export class ReputationController {
{
chainId,
address,
jobRequestTypes,
types: roles,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEthereumAddress, IsOptional, Max, Min } from 'class-validator';

import { SortDirection } from '@/common/enums';
import {
CvatJobType,
FortuneJobType,
MarketingJobType,
SortDirection,
} from '@/common/enums';
import type { JobRequestType } from '@/common/types';
import { IsChainId, IsLowercasedEnum } from '@/common/validators';

import {
Expand Down Expand Up @@ -47,6 +53,29 @@ export class GetReputationsQueryDto {
@IsOptional()
roles?: ReputationEntityType[];

@ApiPropertyOptional({
enum: [
...Object.values(FortuneJobType),
...Object.values(MarketingJobType),
...Object.values(CvatJobType),
],
name: 'job_request_types',
isArray: true,
})
/**
* NOTE: Order of decorators here matters
*
* Query param is parsed as string if single value passed
* and as array if multiple
*/
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
@IsLowercasedEnum(
{ ...FortuneJobType, ...MarketingJobType, ...CvatJobType },
{ each: true },
)
@IsOptional()
jobRequestTypes?: JobRequestType[];

@ApiPropertyOptional({
name: 'order_by',
enum: GetReputationQueryOrderBy,
Expand Down Expand Up @@ -94,4 +123,14 @@ export class ReputationResponseDto {

@ApiProperty({ enum: ReputationEntityType })
role: ReputationEntityType;

@ApiProperty({
enum: [
...Object.values(FortuneJobType),
...Object.values(MarketingJobType),
...Object.values(CvatJobType),
],
name: 'job_request_type',
})
jobRequestType: JobRequestType;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Column, Entity, Index } from 'typeorm';

import { DATABASE_SCHEMA_NAME } from '@/common/constants';
import { CvatJobType } from '@/common/enums';
import type { JobRequestType } from '@/common/types';
import { BaseEntity } from '@/database';

import { ReputationEntityType } from './constants';

@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'reputation' })
@Index(['chainId', 'address', 'type'], { unique: true })
@Index(['chainId', 'address', 'type', 'jobRequestType'], { unique: true })
export class ReputationEntity extends BaseEntity {
@Column({ type: 'int' })
chainId: number;
Expand All @@ -20,6 +22,9 @@ export class ReputationEntity extends BaseEntity {
})
type: ReputationEntityType;

@Column({ type: 'varchar', default: CvatJobType.IMAGE_BOXES })
jobRequestType: JobRequestType;

@Column({ type: 'int' })
reputationPoints: number;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { ChainId } from '@human-protocol/sdk';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, In } from 'typeorm';
import { DataSource, FindManyOptions, In, Raw } from 'typeorm';

import { SortDirection } from '@/common/enums';
import { JobRequestType } from '@/common/types';
import { BaseRepository } from '@/database';

import { ReputationEntityType, ReputationOrderBy } from './constants';
import { ReputationEntity } from './reputation.entity';
import type { ExclusiveReputationCriteria } from './types';

function caseInsensitiveAddress(address: string) {
return Raw((addressAlias) => `LOWER(${addressAlias}) = LOWER(:address)`, {
address,
});
}

@Injectable()
export class ReputationRepository extends BaseRepository<ReputationEntity> {
constructor(dataSource: DataSource) {
Expand All @@ -19,12 +26,14 @@ export class ReputationRepository extends BaseRepository<ReputationEntity> {
chainId,
address,
type,
jobRequestType,
}: ExclusiveReputationCriteria): Promise<ReputationEntity | null> {
return this.findOne({
where: {
chainId,
address,
address: caseInsensitiveAddress(address),
type,
jobRequestType,
},
});
}
Expand All @@ -33,6 +42,7 @@ export class ReputationRepository extends BaseRepository<ReputationEntity> {
filters: {
address?: string;
chainId?: ChainId;
jobRequestTypes?: JobRequestType[];
types?: ReputationEntityType[];
},
options?: {
Expand All @@ -49,8 +59,11 @@ export class ReputationRepository extends BaseRepository<ReputationEntity> {
if (filters.types) {
query.type = In(filters.types);
}
if (filters.jobRequestTypes) {
query.jobRequestType = In(filters.jobRequestTypes);
}
if (filters.address) {
query.address = filters.address;
query.address = caseInsensitiveAddress(filters.address);
}

return this.find({
Expand Down
Loading
Loading