diff --git a/packages/apps/dashboard/client/package.json b/packages/apps/dashboard/client/package.json index 082facf182..4b8a5f8f45 100644 --- a/packages/apps/dashboard/client/package.json +++ b/packages/apps/dashboard/client/package.json @@ -16,7 +16,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@human-protocol/sdk": "*", - "@mui/icons-material": "^6.4.6", + "@mui/icons-material": "^7.0.1", "@mui/material": "^5.15.18", "@mui/styled-engine-sc": "6.4.0", "@mui/x-data-grid": "^7.23.2", @@ -53,7 +53,7 @@ "sass": "^1.85.0", "stylelint-prettier": "^5.0.0", "typescript": "^5.6.3", - "vite": "^6.2.0", + "vite": "^6.2.4", "vite-plugin-svgr": "^4.2.0" } } diff --git a/packages/apps/dashboard/server/package.json b/packages/apps/dashboard/server/package.json index 1b3bf5252c..e1f7d7b352 100644 --- a/packages/apps/dashboard/server/package.json +++ b/packages/apps/dashboard/server/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.13", "@types/jest": "29.5.1", diff --git a/packages/apps/faucet/client/package.json b/packages/apps/faucet/client/package.json index 006c225494..76e4e52491 100644 --- a/packages/apps/faucet/client/package.json +++ b/packages/apps/faucet/client/package.json @@ -6,7 +6,7 @@ "license": "MIT", "dependencies": { "@human-protocol/sdk": "*", - "@mui/icons-material": "^6.4.6", + "@mui/icons-material": "^7.0.1", "@mui/material": "^5.16.7", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -22,7 +22,7 @@ "eslint-plugin-import": "^2.29.0", "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^5.1.0", - "vite": "^6.2.0", + "vite": "^6.2.4", "vite-plugin-node-polyfills": "^0.22.0" }, "scripts": { diff --git a/packages/apps/fortune/exchange-oracle/client/package.json b/packages/apps/fortune/exchange-oracle/client/package.json index 35712e7cee..c8c868e164 100644 --- a/packages/apps/fortune/exchange-oracle/client/package.json +++ b/packages/apps/fortune/exchange-oracle/client/package.json @@ -50,7 +50,7 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.11", "typescript": "^5.6.3", - "vite": "^6.2.0" + "vite": "^6.2.4" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/packages/apps/fortune/exchange-oracle/server/package.json b/packages/apps/fortune/exchange-oracle/server/package.json index 99a18ac957..2b977ef3af 100644 --- a/packages/apps/fortune/exchange-oracle/server/package.json +++ b/packages/apps/fortune/exchange-oracle/server/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@golevelup/ts-jest": "^0.6.1", "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.13", "@types/jest": "29.5.12", diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/constant/errors.ts b/packages/apps/fortune/exchange-oracle/server/src/common/constant/errors.ts index dd40aa3443..4ebc670517 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/constant/errors.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/constant/errors.ts @@ -45,6 +45,7 @@ export enum ErrorAssignment { export enum ErrorJob { AlreadyExists = 'Job already exists', InvalidAddress = 'Invalid address', + InvalidStatus = 'Invalid job status', NotAssigned = 'User is not assigned to the job', SolutionAlreadySubmitted = 'User has already submitted a solution', JobCompleted = 'This job has already been completed', diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts b/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts index 68659c8162..052a3754ad 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/enums/job.ts @@ -1,5 +1,6 @@ export enum JobStatus { ACTIVE = 'active', + PAUSED = 'paused', COMPLETED = 'completed', CANCELED = 'canceled', } diff --git a/packages/apps/fortune/exchange-oracle/server/src/common/enums/webhook.ts b/packages/apps/fortune/exchange-oracle/server/src/common/enums/webhook.ts index da81b02ebb..b87d0e0475 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/common/enums/webhook.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/common/enums/webhook.ts @@ -5,6 +5,8 @@ export enum EventType { ESCROW_FAILED = 'escrow_failed', SUBMISSION_REJECTED = 'submission_rejected', SUBMISSION_IN_REVIEW = 'submission_in_review', + ABUSE_DETECTED = 'abuse_detected', + ABUSE_DISMISSED = 'abuse_dismissed', } export enum WebhookStatus { diff --git a/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1743412274647-handleAbuse.ts b/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1743412274647-handleAbuse.ts new file mode 100644 index 0000000000..85ea65b4d9 --- /dev/null +++ b/packages/apps/fortune/exchange-oracle/server/src/database/migrations/1743412274647-handleAbuse.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class HandleAbuse1743412274647 implements MigrationInterface { + name = 'HandleAbuse1743412274647'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "hmt"."webhooks_event_type_enum" + RENAME TO "webhooks_event_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."webhooks_event_type_enum" AS ENUM( + 'escrow_created', + 'escrow_completed', + 'escrow_canceled', + 'escrow_failed', + 'submission_rejected', + 'submission_in_review', + 'abuse_detected', + 'abuse_dismissed' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhooks" + ALTER COLUMN "event_type" TYPE "hmt"."webhooks_event_type_enum" USING "event_type"::"text"::"hmt"."webhooks_event_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhooks_event_type_enum_old" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum" + RENAME TO "jobs_status_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum" AS ENUM('active', 'paused', 'completed', 'canceled') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum" USING "status"::"text"::"hmt"."jobs_status_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum_old" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_status_enum_old" AS ENUM('active', 'completed', 'canceled') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "status" TYPE "hmt"."jobs_status_enum_old" USING "status"::"text"::"hmt"."jobs_status_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_status_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_status_enum_old" + RENAME TO "jobs_status_enum" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."webhooks_event_type_enum_old" AS ENUM( + 'escrow_created', + 'escrow_completed', + 'escrow_canceled', + 'escrow_failed', + 'submission_rejected', + 'submission_in_review' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhooks" + ALTER COLUMN "event_type" TYPE "hmt"."webhooks_event_type_enum_old" USING "event_type"::"text"::"hmt"."webhooks_event_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhooks_event_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."webhooks_event_type_enum_old" + RENAME TO "webhooks_event_type_enum" + `); + } +} diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts index 051466ba82..76fec45ecf 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.spec.ts @@ -6,7 +6,7 @@ import { MOCK_MANIFEST_URL, MOCK_PRIVATE_KEY, } from '../../../test/constants'; -import { AssignmentStatus, JobType } from '../../common/enums/job'; +import { AssignmentStatus, JobStatus, JobType } from '../../common/enums/job'; import { AssignmentRepository } from '../assignment/assignment.repository'; import { AssignmentService } from '../assignment/assignment.service'; import { ManifestDto } from '../job/job.dto'; @@ -18,7 +18,7 @@ import { Escrow__factory } from '@human-protocol/core/typechain-types'; import { AssignmentSortField } from '../../common/enums/job'; import { SortDirection } from '../../common/enums/collection'; import { AssignmentEntity } from './assignment.entity'; -import { ErrorAssignment } from '../../common/constant/errors'; +import { ErrorAssignment, ErrorJob } from '../../common/constant/errors'; import { BadRequestException } from '@nestjs/common'; import { ServerConfigService } from '../../common/config/server-config.service'; @@ -117,6 +117,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') @@ -140,6 +141,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, }, workerAddress: workerAddress, status: AssignmentStatus.ACTIVE, @@ -160,6 +162,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') @@ -194,6 +197,7 @@ describe('AssignmentService', () => { assignmentService.createAssignment(createAssignmentDto, { address: workerAddress, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any), ).rejects.toThrow('Job not found'); }); @@ -207,6 +211,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: differentReputationNetwork, + status: JobStatus.ACTIVE, } as any); await expect( @@ -217,6 +222,24 @@ describe('AssignmentService', () => { ).rejects.toThrow('Requested job is not in your reputation network'); }); + it('should fail if job is not active', async () => { + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue({ + id: 1, + manifestUrl: MOCK_MANIFEST_URL, + reputationNetwork: reputationNetwork, + status: JobStatus.PAUSED, + } as any); + + await expect( + assignmentService.createAssignment(createAssignmentDto, { + address: workerAddress, + reputationNetwork: reputationNetwork, + } as any), + ).rejects.toThrow(ErrorJob.InvalidStatus); + }); + it('should fail if user already assigned', async () => { jest .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') @@ -224,6 +247,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') @@ -244,6 +268,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') @@ -255,6 +280,7 @@ describe('AssignmentService', () => { assignmentService.createAssignment(createAssignmentDto, { address: workerAddress, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any), ).rejects.toThrow('Fully assigned job'); }); @@ -267,6 +293,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') @@ -291,6 +318,7 @@ describe('AssignmentService', () => { id: 1, manifestUrl: MOCK_MANIFEST_URL, reputationNetwork: reputationNetwork, + status: JobStatus.ACTIVE, } as any); jest .spyOn(assignmentRepository, 'findOneByJobIdAndWorker') diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts index 922a5e8dcd..ccb12c602f 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/assignment/assignment.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import { AssignmentStatus, JobType } from '../../common/enums/job'; +import { AssignmentStatus, JobStatus, JobType } from '../../common/enums/job'; import { JwtUser } from '../../common/types/jwt'; import { JobRepository } from '../job/job.repository'; import { @@ -13,7 +13,7 @@ import { PageDto } from '../../common/pagination/pagination.dto'; import { JobService } from '../job/job.service'; import { Escrow__factory } from '@human-protocol/core/typechain-types'; import { Web3Service } from '../web3/web3.service'; -import { ErrorAssignment } from '../../common/constant/errors'; +import { ErrorAssignment, ErrorJob } from '../../common/constant/errors'; import { ServerConfigService } from '../../common/config/server-config.service'; @Injectable() @@ -40,6 +40,9 @@ export class AssignmentService { if (!jobEntity) { this.logger.log(ErrorAssignment.JobNotFound, AssignmentService.name); throw new BadRequestException(ErrorAssignment.JobNotFound); + } else if (jobEntity.status !== JobStatus.ACTIVE) { + this.logger.log(ErrorJob.InvalidStatus, AssignmentService.name); + throw new BadRequestException(ErrorJob.InvalidStatus); } else if (jobEntity.reputationNetwork !== jwtUser.reputationNetwork) { this.logger.log( ErrorAssignment.ReputationNetworkMismatch, diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts index 2ffb5640db..4a07ab3dd1 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.spec.ts @@ -450,6 +450,7 @@ describe('JobService', () => { job: { escrowAddress, chainId, + status: JobStatus.ACTIVE, }, } as AssignmentEntity; @@ -666,4 +667,54 @@ describe('JobService', () => { ).rejects.toThrow(`Solution not found in Escrow: ${escrowAddress}`); }); }); + + describe('pauseJob', () => { + const webhook: WebhookDto = { + chainId, + escrowAddress, + eventType: EventType.ABUSE_DETECTED, + }; + + it('should create a new job in the database', async () => { + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue({ + chainId: chainId, + escrowAddress: escrowAddress, + status: JobStatus.ACTIVE, + } as JobEntity); + const result = await jobService.pauseJob(webhook); + + expect(result).toEqual(undefined); + expect(jobRepository.updateOne).toHaveBeenCalledWith({ + chainId: chainId, + escrowAddress: escrowAddress, + status: JobStatus.PAUSED, + }); + }); + + it('should fail if job not exists', async () => { + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue(null); + + await expect(jobService.pauseJob(webhook)).rejects.toThrow( + ErrorJob.NotFound, + ); + }); + + it('should fail if job is not in Active status', async () => { + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue({ + chainId: chainId, + escrowAddress: escrowAddress, + status: JobStatus.CANCELED, + } as JobEntity); + + await expect(jobService.pauseJob(webhook)).rejects.toThrow( + ErrorJob.InvalidStatus, + ); + }); + }); }); diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts index c718a6bcd4..caf475da50 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/job/job.service.ts @@ -225,6 +225,8 @@ export class JobService { if (assignment.status !== AssignmentStatus.ACTIVE) { throw new BadRequestException(ErrorAssignment.InvalidStatus); + } else if (assignment.job.status !== JobStatus.ACTIVE) { + throw new BadRequestException(ErrorJob.InvalidStatus); } await this.addSolution( @@ -382,4 +384,34 @@ export class JobService { return manifest; } + + public async pauseJob(webhook: WebhookDto): Promise { + const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhook.chainId, + webhook.escrowAddress, + ); + if (!jobEntity) { + throw new NotFoundException(ErrorJob.NotFound); + } + if (jobEntity.status !== JobStatus.ACTIVE) { + throw new BadRequestException(ErrorJob.InvalidStatus); + } + jobEntity.status = JobStatus.PAUSED; + await this.jobRepository.updateOne(jobEntity); + } + + public async resumeJob(webhook: WebhookDto): Promise { + const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhook.chainId, + webhook.escrowAddress, + ); + if (!jobEntity) { + throw new NotFoundException(ErrorJob.NotFound); + } + if (jobEntity.status !== JobStatus.PAUSED) { + throw new BadRequestException(ErrorJob.InvalidStatus); + } + jobEntity.status = JobStatus.ACTIVE; + await this.jobRepository.updateOne(jobEntity); + } } diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.controller.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.controller.ts index 8cbbf66e2b..4ff1e27ef0 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.controller.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; import { ApiBody, ApiResponse, @@ -41,7 +41,7 @@ export class WebhookController { type: WebhookDto, }) @ApiResponse({ - status: 200, + status: 201, description: 'Webhook event processed successfully.', }) @ApiResponse({ @@ -56,6 +56,7 @@ export class WebhookController { status: 404, description: 'Not Found. Could not find the requested content.', }) + @HttpCode(201) public async processWebhook(@Body() body: WebhookDto): Promise { return this.webhookService.handleWebhook(body); } diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts index 4d1c3c4090..7fffd318ef 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.spec.ts @@ -167,6 +167,26 @@ describe('WebhookService', () => { ); }); + it('should handle an incoming escrow abuse webhook', async () => { + jest.spyOn(jobService, 'pauseJob').mockResolvedValue(); + const webhook: WebhookDto = { + chainId, + escrowAddress, + eventType: EventType.ABUSE_DETECTED, + }; + expect(await webhookService.handleWebhook(webhook)).toBe(undefined); + }); + + it('should handle an incoming escrow resume webhook', async () => { + jest.spyOn(jobService, 'resumeJob').mockResolvedValue(); + const webhook: WebhookDto = { + chainId, + escrowAddress, + eventType: EventType.ABUSE_DISMISSED, + }; + expect(await webhookService.handleWebhook(webhook)).toBe(undefined); + }); + it('should return an error when the event type is invalid', async () => { const webhook: WebhookDto = { chainId, diff --git a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts index 0c1d6772a6..86b2381607 100644 --- a/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts +++ b/packages/apps/fortune/exchange-oracle/server/src/modules/webhook/webhook.service.ts @@ -55,6 +55,14 @@ export class WebhookService { await this.jobService.processInvalidJobSolution(webhook); break; + case EventType.ABUSE_DETECTED: + await this.jobService.pauseJob(webhook); + break; + + case EventType.ABUSE_DISMISSED: + await this.jobService.resumeJob(webhook); + break; + default: throw new BadRequestException( `Invalid webhook event type: ${webhook.eventType}`, diff --git a/packages/apps/fortune/recording-oracle/package.json b/packages/apps/fortune/recording-oracle/package.json index 2dd1dc7c58..44796022b1 100644 --- a/packages/apps/fortune/recording-oracle/package.json +++ b/packages/apps/fortune/recording-oracle/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.13" } diff --git a/packages/apps/human-app/frontend/package.json b/packages/apps/human-app/frontend/package.json index a1209b1519..737c1349c2 100644 --- a/packages/apps/human-app/frontend/package.json +++ b/packages/apps/human-app/frontend/package.json @@ -20,11 +20,11 @@ "@hcaptcha/react-hcaptcha": "^0.3.6", "@hookform/resolvers": "^3.3.4", "@human-protocol/sdk": "*", - "@mui/icons-material": "^6.4.6", + "@mui/icons-material": "^7.0.1", "@mui/material": "^5.16.7", "@mui/x-date-pickers": "^7.23.6", - "@reown/appkit": "1.3.2", - "@reown/appkit-adapter-wagmi": "1.3.2", + "@reown/appkit": "^1.7.2", + "@reown/appkit-adapter-wagmi": "^1.7.2", "@synaps-io/verify-sdk": "^4.0.45", "@tanstack/react-query": "^5.67.2", "date-fns": "^4.1.0", @@ -70,6 +70,6 @@ "lint-staged": "^15.4.3", "prettier": "^3.4.2", "typescript": "^5.6.3", - "vite": "^6.2.0" + "vite": "^6.2.4" } } diff --git a/packages/apps/human-app/frontend/src/api/api-paths.ts b/packages/apps/human-app/frontend/src/api/api-paths.ts index d925116678..87596f3a44 100644 --- a/packages/apps/human-app/frontend/src/api/api-paths.ts +++ b/packages/apps/human-app/frontend/src/api/api-paths.ts @@ -94,5 +94,9 @@ export const apiPaths = { path: '/disable-operator', withAuthRetry: true, }, + enableOperator: { + path: '/enable-operator', + withAuthRetry: true, + }, }, } as const; diff --git a/packages/apps/human-app/frontend/src/api/hooks/use-prepare-signature.ts b/packages/apps/human-app/frontend/src/api/hooks/use-prepare-signature.ts index 4034114681..506850b033 100644 --- a/packages/apps/human-app/frontend/src/api/hooks/use-prepare-signature.ts +++ b/packages/apps/human-app/frontend/src/api/hooks/use-prepare-signature.ts @@ -7,6 +7,7 @@ export enum PrepareSignatureType { SIGN_UP = 'signup', SIGN_IN = 'signin', DISABLE_OPERATOR = 'disable_operator', + ENABLE_OPERATOR = 'enable_operator', REGISTER_ADDRESS = 'register_address', } diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index 855bc34e58..dccc2c8cd3 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -18,7 +18,6 @@ import { Web3AuthProvider } from '@/modules/auth-web3/context/web3-auth-context' import { JWTExpirationCheck } from '@/shared/contexts/jwt-expiration-check'; import { ColorModeProvider } from '@/shared/contexts/color-mode'; import { HomePageStateProvider } from '@/shared/contexts/homepage-state'; -import { RegisteredOraclesProvider } from '@/shared/contexts/registered-oracles'; import { NotificationProvider } from '@/shared/providers/notifications-provider'; const root = document.getElementById('root'); @@ -44,9 +43,7 @@ createRoot(root).render( - - - + diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx index a3332c8a09..6c54fff874 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/context/web3-auth-context.tsx @@ -10,12 +10,18 @@ import { } from '@/shared/components/ui/modal/modal.store'; import { type AuthTokensSuccessResponse } from '@/shared/schemas'; +export enum OperatorStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + const web3userDataSchema = z.object({ - userId: z.number(), + user_id: z.number(), wallet_address: z.string(), reputation_network: z.string(), + operator_status: z.nativeEnum(OperatorStatus), exp: z.number(), - status: z.string().nullable().optional(), + status: z.literal('active'), }); export type Web3UserData = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx b/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx index bc8d8cfb76..0f5f5b746f 100644 --- a/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth-web3/providers/require-web3-auth.tsx @@ -8,7 +8,9 @@ import { useWeb3Auth } from '@/modules/auth-web3/hooks/use-web3-auth'; export const Web3AuthenticatedUserContext = createContext(null); -export function RequireWeb3Auth({ children }: { children: JSX.Element }) { +export function RequireWeb3Auth({ + children, +}: Readonly<{ children: JSX.Element }>) { const web3Auth = useWeb3Auth(); const location = useLocation(); @@ -22,6 +24,16 @@ export function RequireWeb3Auth({ children }: { children: JSX.Element }) { ); } + if (web3Auth.status !== 'success') { + return ( + + ); + } + return ( {children} diff --git a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx index ed300db26e..4a36360c6d 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/context/auth-context.tsx @@ -14,13 +14,13 @@ const extendableUserDataSchema = z.object({ site_key: z.string().optional().nullable(), kyc_status: z.string().optional().nullable(), wallet_address: z.string().optional().nullable(), - status: z.string().optional().nullable(), + status: z.enum(['active', 'pending']), }); const userDataSchema = z .object({ email: z.string(), - userId: z.number(), + user_id: z.number(), reputation_network: z.string(), email_notifications: z.boolean().optional(), // TODO that should be verified when email notifications feature is done exp: z.number(), diff --git a/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx b/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx index d47ac1b61e..dbc6a13b5b 100644 --- a/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx +++ b/packages/apps/human-app/frontend/src/modules/auth/providers/require-auth.tsx @@ -8,7 +8,7 @@ import { PageCardLoader } from '@/shared/components/ui/page-card'; export const AuthenticatedUserContext = createContext(null); -export function RequireAuth({ children }: { children: JSX.Element }) { +export function RequireAuth({ children }: Readonly<{ children: JSX.Element }>) { const auth = useAuth(); const location = useLocation(); @@ -22,6 +22,16 @@ export function RequireAuth({ children }: { children: JSX.Element }) { ); } + if (auth.user.status === 'pending') { + return ( + + ); + } + return ( {children} diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx index ef8c77352a..36dfb88519 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/operator-info.tsx @@ -1,5 +1,6 @@ import { Paper, Typography, Stack, List, Grid } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { OperatorStatus } from '@/modules/auth-web3/context/web3-auth-context'; import { useWeb3AuthenticatedUser } from '@/modules/auth-web3/hooks/use-web3-authenticated-user'; import { CheckmarkIcon, LockerIcon } from '@/shared/components/ui/icons'; import { ProfileListItem } from '@/shared/components/ui/profile'; @@ -17,7 +18,7 @@ export function OperatorInfo({ const { user } = useWeb3AuthenticatedUser(); const isMobile = useIsMobile('lg'); - const isOperatorActive = user.status === 'active'; + const isOperatorActive = user.operator_status === OperatorStatus.ACTIVE; return ( { diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-enable-button.tsx b/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-enable-button.tsx index b8ed244ac5..91193ce7d3 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-enable-button.tsx +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/components/profile-enable-button.tsx @@ -1,11 +1,53 @@ import { t } from 'i18next'; +import { Typography } from '@mui/material'; +import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; import { Button } from '@/shared/components/ui/button'; +import type { SignatureData } from '@/api/hooks/use-prepare-signature'; +import { + PrepareSignatureType, + usePrepareSignature, +} from '@/api/hooks/use-prepare-signature'; +import { useEnableWeb3Operator } from '../hooks'; export function ProfileEnableButton() { - // TODO add operator activation + const { address, signMessage } = useConnectedWallet(); + const { + data: signatureData, + isError: isSignatureDataError, + isPending: isSignatureDataPending, + } = usePrepareSignature({ + address, + type: PrepareSignatureType.ENABLE_OPERATOR, + }); + + const { + mutate: enableOperatorMutation, + isError: isEnableOperatorError, + isPending: isEnableOperatorPending, + } = useEnableWeb3Operator(); + + const enableOperator = async (signaturePayload: SignatureData) => { + const signature = await signMessage(JSON.stringify(signaturePayload)); + enableOperatorMutation({ signature: signature ?? '' }); + }; + + if (isSignatureDataError || isEnableOperatorError) { + return ( + {t('operator.profile.activate.cannotActivate')} + ); + } + return ( - ); } diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts index 033ed47173..b95a7fdb88 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-get-stats'; export * from './use-disable-operator'; +export * from './use-enable-operator'; diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts index a316284888..fa407468dd 100644 --- a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-disable-operator.ts @@ -3,6 +3,7 @@ import { apiClient } from '@/api/api-client'; import { apiPaths } from '@/api/api-paths'; import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; +import { OperatorStatus } from '@/modules/auth-web3/context/web3-auth-context'; import { useWeb3AuthenticatedUser } from '@/modules/auth-web3/hooks/use-web3-authenticated-user'; export function useDisableWeb3Operator() { @@ -11,7 +12,7 @@ export function useDisableWeb3Operator() { const { updateUserData } = useWeb3AuthenticatedUser(); return useMutation({ mutationFn: async ({ signature }: { signature: string }) => { - const result = apiClient(apiPaths.operator.disableOperator.path, { + const result = await apiClient(apiPaths.operator.disableOperator.path, { skipValidation: true, authenticated: true, withAuthRetry: apiPaths.operator.disableOperator.withAuthRetry, @@ -22,7 +23,8 @@ export function useDisableWeb3Operator() { }); await refreshAccessTokenAsync({ authType: 'web3' }); - updateUserData({ status: 'inactive' }); + // eslint-disable-next-line camelcase + updateUserData({ operator_status: OperatorStatus.INACTIVE }); return result; }, diff --git a/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-enable-operator.ts b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-enable-operator.ts new file mode 100644 index 0000000000..220f1664e8 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/operator/profile/hooks/use-enable-operator.ts @@ -0,0 +1,33 @@ +import { useMutation } from '@tanstack/react-query'; +import { apiClient } from '@/api/api-client'; +import { apiPaths } from '@/api/api-paths'; +import { useConnectedWallet } from '@/shared/contexts/wallet-connect'; +import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; +import { useWeb3AuthenticatedUser } from '@/modules/auth-web3/hooks/use-web3-authenticated-user'; +import { OperatorStatus } from '@/modules/auth-web3/context/web3-auth-context'; + +export function useEnableWeb3Operator() { + const { address, chainId } = useConnectedWallet(); + const { refreshAccessTokenAsync } = useAccessTokenRefresh(); + const { updateUserData } = useWeb3AuthenticatedUser(); + return useMutation({ + mutationFn: async ({ signature }: { signature: string }) => { + const result = await apiClient(apiPaths.operator.enableOperator.path, { + skipValidation: true, + authenticated: true, + withAuthRetry: apiPaths.operator.enableOperator.withAuthRetry, + options: { + method: 'POST', + body: JSON.stringify({ signature }), + }, + }); + + await refreshAccessTokenAsync({ authType: 'web3' }); + // eslint-disable-next-line camelcase + updateUserData({ operator_status: OperatorStatus.ACTIVE }); + + return result; + }, + mutationKey: ['enableOperator', address, chainId], + }); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/email-verification-form-container.tsx b/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/email-verification-form-container.tsx index d1bac7f655..96a31a9294 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/email-verification-form-container.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/email-verification/components/email-verification-form-container.tsx @@ -16,9 +16,7 @@ export function EmailVerificationFormContainer() { const { user, signOut } = useAuth(); const navigate = useNavigate(); const routerState = useResendEmailRouterParams(); - const { methods, handleResend, isError, error, isSuccess } = useResendEmail( - routerState?.email ?? '' - ); + const { methods, handleResend, isError, error, isSuccess } = useResendEmail(); const { showNotification } = useNotification(); const { t } = useTranslation(); const isAuthenticated = Boolean(user); diff --git a/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts b/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts index 14df70a6ff..efb764e5a6 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/email-verification/hooks/use-resend-email.ts @@ -8,7 +8,7 @@ import { } from '../schemas'; import { useResendEmailVerificationWorkerMutation } from './resend-email-verification'; -export function useResendEmail(email: string) { +export function useResendEmail() { const { isError, error, @@ -28,12 +28,7 @@ export function useResendEmail(email: string) { const handleResend = ( data: Pick ) => { - if (!email) { - return; - } - resendEmailVerificationMutation({ - email, h_captcha_token: data.h_captcha_token, }); }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/email-verification/schemas.ts b/packages/apps/human-app/frontend/src/modules/worker/email-verification/schemas.ts index 931c2f46a8..a0f3070e67 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/email-verification/schemas.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/email-verification/schemas.ts @@ -6,17 +6,6 @@ export const resendEmailVerificationHcaptchaSchema = z.object({ h_captcha_token: z.string().min(1, t('validation.captcha')).default('token'), }); -type ResendEmailVerificationHcaptchaDto = z.infer< +export type ResendEmailVerificationDto = z.infer< typeof resendEmailVerificationHcaptchaSchema >; - -const resendEmailVerificationEmailSchema = z.object({ - email: z.string().email(), -}); - -type ResendEmailVerificationEmailDto = z.infer< - typeof resendEmailVerificationEmailSchema ->; - -export type ResendEmailVerificationDto = ResendEmailVerificationHcaptchaDto & - ResendEmailVerificationEmailDto; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx index 38455484ef..2974a58fe4 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-job-type-filter.tsx @@ -1,11 +1,14 @@ /* eslint-disable camelcase --- ... */ import { useTranslation } from 'react-i18next'; import { useMemo } from 'react'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { JOB_TYPES } from '@/shared/consts'; import { useJobsFilterStore } from '../../hooks'; -export function AvailableJobsJobTypeFilter({ isMobile = false }) { +export function AvailableJobsJobTypeFilter({ + showClearButton = false, + showTitle = false, +}) { const { t } = useTranslation(); const { setFilterParams, filterParams } = useJobsFilterStore(); @@ -31,8 +34,9 @@ export function AvailableJobsJobTypeFilter({ isMobile = false }) { clear={handleClear} filteringOptions={filteringOptions} isChecked={(option) => option === filterParams.job_type} - isMobile={isMobile} setFiltering={handleFilterChange} + showClearButton={showClearButton} + showTitle={showTitle} /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx index 47f5dce0ae..18d6e250b7 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-network-filter.tsx @@ -1,15 +1,17 @@ /* eslint-disable camelcase --- ... */ -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { useGetAllNetworks, useJobsFilterStore } from '../../hooks'; interface AvailableJobsNetworkFilterProps { chainIdsEnabled: number[]; - isMobile?: boolean; + showClearButton?: boolean; + showTitle?: boolean; } export function AvailableJobsNetworkFilter({ chainIdsEnabled, - isMobile = false, + showClearButton = false, + showTitle = false, }: Readonly) { const { setFilterParams, filterParams } = useJobsFilterStore(); const { allNetworks } = useGetAllNetworks(chainIdsEnabled); @@ -27,8 +29,9 @@ export function AvailableJobsNetworkFilter({ clear={handleClear} filteringOptions={allNetworks} isChecked={(option) => option === filterParams.chain_id} - isMobile={isMobile} setFiltering={handleFilterChange} + showClearButton={showClearButton} + showTitle={showTitle} /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx index 8f65c5f9fe..69cf8417bd 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/components/available-jobs-reward-amount-sort.tsx @@ -1,6 +1,6 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; -import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; +import { Sorting } from '@/shared/components/ui/table/table-header-menu/sorting'; import { useJobsFilterStore } from '../../hooks'; import { SortDirection, SortField } from '../../types'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx index 3bba5a3acb..a47869a1a9 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-get-available-jobs-columns.tsx @@ -49,23 +49,19 @@ export const useGetAvailableJobsColumns = ( Cell: (props) => { return getNetworkName(props.row.original.chain_id); }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - } + Header: ( + - ); - }, - }), + } + /> + ), }, { accessorKey: 'reward_amount', @@ -81,16 +77,13 @@ export const useGetAvailableJobsColumns = ( /> ); }, - muiTableHeadCellProps: () => ({ - component: (props) => ( - } - /> - ), - }), + Header: ( + } + /> + ), }, { accessorKey: 'job_type', @@ -101,18 +94,15 @@ export const useGetAvailableJobsColumns = ( const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); return ; }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), + Header: ( + + } + /> + ), }, { accessorKey: 'escrow_address', diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx index 0ae8322952..a76d57871c 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/columns.tsx @@ -47,20 +47,15 @@ export const getColumnsDefinition = ({ Cell: (props) => { return getNetworkName(props.row.original.chain_id); }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - } - /> - ); - }, - }), + Header: ( + + } + /> + ), }, { accessorKey: 'reward_amount', @@ -76,16 +71,13 @@ export const getColumnsDefinition = ({ /> ); }, - muiTableHeadCellProps: () => ({ - component: (props) => ( - } - /> - ), - }), + Header: ( + } + /> + ), }, { accessorKey: 'job_type', @@ -96,18 +88,13 @@ export const getColumnsDefinition = ({ const label = t(`jobTypeLabels.${row.original.job_type as JobType}`); return ; }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), + Header: ( + } + /> + ), }, { accessorKey: 'expires_at', @@ -117,18 +104,13 @@ export const getColumnsDefinition = ({ Cell: (props) => { return formatDate(props.row.original.expires_at); }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), + Header: ( + } + /> + ), }, { accessorKey: 'status', @@ -139,18 +121,13 @@ export const getColumnsDefinition = ({ const status = props.row.original.status; return ; }, - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - } - /> - ); - }, - }), + Header: ( + } + /> + ), }, { accessorKey: 'assignment_id', @@ -162,37 +139,31 @@ export const getColumnsDefinition = ({ ), - muiTableHeadCellProps: () => ({ - component: (props) => { - return ( - - - - - - ); - }, - }), + Header: ( + + + + ), }, ]; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx index dc48b16472..bfcce08fd6 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-expires-at-sort.tsx @@ -1,6 +1,6 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; -import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; +import { Sorting } from '@/shared/components/ui/table/table-header-menu/sorting'; import { useMyJobsFilterStore } from '../../../hooks'; import { SortDirection, SortField } from '../../../types'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx index 1992596ac0..b04e690def 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-job-type-filter.tsx @@ -1,7 +1,7 @@ /* eslint-disable camelcase --- ... */ import { useTranslation } from 'react-i18next'; import { useMemo } from 'react'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { JOB_TYPES } from '@/shared/consts'; import { useMyJobsFilterStore } from '../../../hooks'; @@ -16,7 +16,6 @@ export function MyJobsJobTypeFilter() { })), [t] ); - return ( { @@ -31,6 +30,8 @@ export function MyJobsJobTypeFilter() { job_type: jobType, }); }} + showClearButton + showTitle /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx index 71e099b10f..cee99313dd 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-network-filter.tsx @@ -1,5 +1,5 @@ /* eslint-disable camelcase --- ... */ -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { useMyJobsFilterStore, useGetAllNetworks } from '../../../hooks'; interface MyJobsNetworkFilterProps { @@ -26,6 +26,8 @@ export function MyJobsNetworkFilter({ chain_id: chainId, }); }} + showClearButton + showTitle /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx index 293d32abae..b623b2eb70 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-reward-amount-sort.tsx @@ -1,6 +1,6 @@ /* eslint-disable camelcase --- ... */ import { t } from 'i18next'; -import { Sorting } from '@/shared/components/ui/table/table-header-menu.tsx/sorting'; +import { Sorting } from '@/shared/components/ui/table/table-header-menu/sorting'; import { useMyJobsFilterStore } from '../../../hooks'; import { SortDirection, SortField } from '../../../types'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx index 285f1e334e..4c17f3f157 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/desktop/my-jobs-status-filter.tsx @@ -1,5 +1,5 @@ import capitalize from 'lodash/capitalize'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { useMyJobsFilterStore } from '../../../hooks'; import { MyJobStatus } from '../../../types'; @@ -23,6 +23,8 @@ export function MyJobsStatusFilter() { status, }); }} + showClearButton + showTitle /> ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx index 6c570f07f5..694eda5feb 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-job-type-filter-mobile.tsx @@ -1,6 +1,6 @@ /* eslint-disable camelcase --- ... */ import { useTranslation } from 'react-i18next'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { JOB_TYPES } from '@/shared/consts'; import { useMyJobsFilterStore } from '../../../hooks'; @@ -21,7 +21,6 @@ export function MyJobsJobTypeFilterMobile() { option: jobType, }))} isChecked={(option) => option === filterParams.job_type} - isMobile={false} setFiltering={(jobType) => { setFilterParams({ job_type: jobType, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx index 9bfbc7e5ba..18818190f8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-network-filter-mobile.tsx @@ -1,5 +1,5 @@ /* eslint-disable camelcase --- ... */ -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { useMyJobsFilterStore, useGetAllNetworks } from '../../../hooks'; interface MyJobsNetworkFilterMobileProps { @@ -22,7 +22,6 @@ export function MyJobsNetworkFilterMobile({ }} filteringOptions={allNetworks} isChecked={(option) => option === filterParams.chain_id} - isMobile={false} setFiltering={(chainId) => { setFilterParams({ chain_id: chainId, diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx index ce4f90c46a..d1199971d8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/components/mobile/my-jobs-status-filter-mobile.tsx @@ -1,5 +1,5 @@ import capitalize from 'lodash/capitalize'; -import { Filtering } from '@/shared/components/ui/table/table-header-menu.tsx/filtering'; +import { Filtering } from '@/shared/components/ui/table/table-header-menu/filtering'; import { useMyJobsFilterStore } from '../../../hooks'; import { MyJobStatus } from '../../../types'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/index.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/index.ts index 23a606a15a..8281d0c42e 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/index.ts @@ -1,2 +1 @@ -export * from './use-oracle-registration-flow'; export * from './use-is-already-registered'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts index a31b44c22c..3d47ea8eaf 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-is-already-registered.ts @@ -1,7 +1,11 @@ -import { useRegisteredOracles } from '@/shared/contexts/registered-oracles'; +import { useGetRegistrationDataInOracles } from '../../jobs-discovery'; export const useIsAlreadyRegistered = (address: string | undefined) => { - const { registeredOracles } = useRegisteredOracles(); + const { data } = useGetRegistrationDataInOracles(); - return Boolean(address && registeredOracles?.includes(address)); + if (!address) { + return false; + } + + return (data?.oracle_addresses ?? []).includes(address); }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.ts deleted file mode 100644 index 31a1173fcf..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration-flow.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useOracleInstructions } from './use-oracle-instructions'; -import { useOracleRegistration } from './use-oracle-registration'; - -export function useOracleRegistrationFlow( - address: string, - oracleInstructions?: string | URL | null | undefined -) { - const { handleRegistration, isRegistrationPending, registrationError } = - useOracleRegistration(address); - - const { hasViewedInstructions, handleInstructionsView } = - useOracleInstructions(oracleInstructions); - - return { - hasViewedInstructions, - handleInstructionsView, - handleRegistration, - isRegistrationPending, - registrationError, - }; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts deleted file mode 100644 index 40b716d31a..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/hooks/use-oracle-registration.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useCallback } from 'react'; -import { useRegisteredOracles } from '@/shared/contexts/registered-oracles'; -import { type RegistrationInExchangeOracleDto } from '../schema'; -import { useExchangeOracleRegistrationMutation } from './use-exchange-oracle-registration-mutation'; - -export function useOracleRegistration(oracleAddress: string | undefined) { - const { setRegisteredOracles } = useRegisteredOracles(); - const { - mutate: registerInOracle, - isPending: isRegistrationPending, - error: registrationError, - } = useExchangeOracleRegistrationMutation(); - - const handleRegistration = useCallback( - (data: RegistrationInExchangeOracleDto) => { - registerInOracle(data, { - onSuccess() { - if (oracleAddress) { - setRegisteredOracles((prev) => - prev ? [...prev, oracleAddress] : [oracleAddress] - ); - } - }, - }); - }, - [oracleAddress, registerInOracle, setRegisteredOracles] - ); - - return { - handleRegistration, - isRegistrationPending, - registrationError, - }; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx index 4a3e3005d6..e78e075f73 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/oracle-registration/registration-form.tsx @@ -1,27 +1,17 @@ +/* eslint-disable camelcase */ import { Box, Stack } from '@mui/material'; import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { zodResolver } from '@hookform/resolvers/zod'; import { Button } from '@/shared/components/ui/button'; import { HCaptchaForm } from '@/shared/components/hcaptcha'; -import { useOracleRegistrationFlow } from './hooks'; +import { useOracleInstructions } from './hooks/use-oracle-instructions'; +import { useExchangeOracleRegistrationMutation } from './hooks/use-exchange-oracle-registration-mutation'; import { - type RegistrationInExchangeOracleDto, - registrationInExchangeOracleDtoSchema, + oracleRegistrationFormSchema, + type OracleRegistrationFormValues, } from './schema'; -function useRegistrationForm(address: string) { - return useForm({ - defaultValues: { - // eslint-disable-next-line camelcase - oracle_address: address, - // eslint-disable-next-line camelcase - h_captcha_token: '', - }, - resolver: zodResolver(registrationInExchangeOracleDtoSchema), - }); -} - export function RegistrationForm({ address, oracleInstructions, @@ -30,19 +20,34 @@ export function RegistrationForm({ oracleInstructions: string | URL | null | undefined; }>) { const { t } = useTranslation(); - const methods = useRegistrationForm(address); + + const { hasViewedInstructions, handleInstructionsView } = + useOracleInstructions(oracleInstructions); + const { - hasViewedInstructions, - handleInstructionsView, - handleRegistration, - isRegistrationPending: isLoading, - registrationError: error, - } = useOracleRegistrationFlow(address, oracleInstructions); + mutate: registerInOracle, + isPending: isLoading, + error, + } = useExchangeOracleRegistrationMutation(); + + const methods = useForm({ + defaultValues: { + h_captcha_token: '', + }, + resolver: zodResolver(oracleRegistrationFormSchema), + }); const handleSubmit = (event: React.FormEvent) => { - void methods.handleSubmit(handleRegistration)(event); + void methods.handleSubmit((formData: OracleRegistrationFormValues) => { + registerInOracle({ + h_captcha_token: formData.h_captcha_token, + oracle_address: address, + }); + })(event); }; + const disabled = !hasViewedInstructions || isLoading; + return ( <> + + + ); +} diff --git a/packages/apps/human-app/frontend/src/router/components/layout/protected/index.ts b/packages/apps/human-app/frontend/src/router/components/layout/protected/index.ts new file mode 100644 index 0000000000..8643262e0f --- /dev/null +++ b/packages/apps/human-app/frontend/src/router/components/layout/protected/index.ts @@ -0,0 +1,2 @@ +export * from './layout'; +export * from './drawer-navigation'; diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/protected/layout.tsx b/packages/apps/human-app/frontend/src/router/components/layout/protected/layout.tsx similarity index 94% rename from packages/apps/human-app/frontend/src/shared/components/layout/protected/layout.tsx rename to packages/apps/human-app/frontend/src/router/components/layout/protected/layout.tsx index 9080d7296b..e9e800abfa 100644 --- a/packages/apps/human-app/frontend/src/shared/components/layout/protected/layout.tsx +++ b/packages/apps/human-app/frontend/src/router/components/layout/protected/layout.tsx @@ -4,13 +4,12 @@ import { useEffect, useRef, useState } from 'react'; import { Outlet } from 'react-router-dom'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { useBackgroundContext } from '@/shared/contexts/background'; -import type { PageHeaderProps } from '@/shared/components/layout/protected/page-header'; -import { PageHeader } from '@/shared/components/layout/protected/page-header'; import { breakpoints } from '@/shared/styles/breakpoints'; import { useIsHCaptchaLabelingPage } from '@/shared/hooks/use-is-hcaptcha-labeling-page'; import { GovernanceBanner } from '@/modules/governance-banner/components/governance-banner'; -import { Footer } from '../footer'; +import { Footer } from '../../footer'; import { Navbar } from './navbar'; +import { type PageHeaderProps, PageHeader } from './page-header'; const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' && prop !== 'isMobile', @@ -34,12 +33,12 @@ const Main = styled('main', { }), })); -export function Layout({ +export function ProtectedLayout({ pageHeaderProps, renderDrawer, renderHCaptchaStatisticsDrawer, renderGovernanceBanner, -}: { +}: Readonly<{ pageHeaderProps: PageHeaderProps; renderDrawer: ( open: boolean, @@ -47,7 +46,7 @@ export function Layout({ ) => JSX.Element; renderHCaptchaStatisticsDrawer?: (isOpen: boolean) => JSX.Element; renderGovernanceBanner?: boolean; -}) { +}>) { const layoutElementRef = useRef(); const isHCaptchaLabelingPage = useIsHCaptchaLabelingPage(); const isMobile = useIsMobile(); diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/protected/navbar.tsx b/packages/apps/human-app/frontend/src/router/components/layout/protected/navbar.tsx similarity index 99% rename from packages/apps/human-app/frontend/src/shared/components/layout/protected/navbar.tsx rename to packages/apps/human-app/frontend/src/router/components/layout/protected/navbar.tsx index 4b71b17bc4..39650f1ebf 100644 --- a/packages/apps/human-app/frontend/src/shared/components/layout/protected/navbar.tsx +++ b/packages/apps/human-app/frontend/src/router/components/layout/protected/navbar.tsx @@ -23,7 +23,7 @@ export function Navbar({ open, userStatsDrawerOpen, toggleUserStatsDrawer, -}: NavbarProps) { +}: Readonly) { const handleMainNavIconClick = useHandleMainNavIconClick(); const { colorPalette } = useColorMode(); const isMobile = useIsMobile(); diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/protected/page-header.tsx b/packages/apps/human-app/frontend/src/router/components/layout/protected/page-header.tsx similarity index 98% rename from packages/apps/human-app/frontend/src/shared/components/layout/protected/page-header.tsx rename to packages/apps/human-app/frontend/src/router/components/layout/protected/page-header.tsx index d2609a1248..466c169333 100644 --- a/packages/apps/human-app/frontend/src/shared/components/layout/protected/page-header.tsx +++ b/packages/apps/human-app/frontend/src/router/components/layout/protected/page-header.tsx @@ -13,7 +13,7 @@ export function PageHeader({ headerIcon, headerText, headerItem, -}: PageHeaderProps) { +}: Readonly) { const isMobile = useIsMobile(); return ( void; + items?: MenuItem[]; +} + +export function TopMenuItemsList({ + handleItemClick, + items, +}: Readonly) { + const { isDarkMode } = useColorMode(); + const isMobile = useIsMobile(); + const location = useLocation(); + + return ( + + {items?.map((item, index) => { + if (!isDrawerItem(item)) { + return ( + + + {item} + + + ); + } + + const { link, label, disabled, icon } = item; + const isActive = location.pathname === link; + + return ( + + { + handleItemClick(item); + }} + selected={isActive} + sx={{ + borderRadius: '4px', + p: 0, + '&.Mui-selected': { + backgroundColor: isDarkMode + ? onlyDarkModeColor.listItemColor + : colorPalette.primary.shades, + }, + }} + > + + {icon} + + {label} + + } + sx={{ + marginLeft: index === 0 ? '10px' : '0px', + }} + /> + + + + ); + })} + + ); +} diff --git a/packages/apps/human-app/frontend/src/router/components/layout/unprotected/index.ts b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/index.ts new file mode 100644 index 0000000000..5d15fe1b3c --- /dev/null +++ b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/index.ts @@ -0,0 +1 @@ +export * from './layout'; diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/unprotected/layout.tsx b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/layout.tsx similarity index 79% rename from packages/apps/human-app/frontend/src/shared/components/layout/unprotected/layout.tsx rename to packages/apps/human-app/frontend/src/router/components/layout/unprotected/layout.tsx index 1af00794ea..e1804f91a8 100644 --- a/packages/apps/human-app/frontend/src/shared/components/layout/unprotected/layout.tsx +++ b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/layout.tsx @@ -1,25 +1,29 @@ import { Container, Grid } from '@mui/material'; import { Outlet } from 'react-router-dom'; -import { useBackgroundContext } from '@/shared/contexts/background'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { breakpoints } from '@/shared/styles/breakpoints'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { Footer } from '../footer'; +import { useHomePageState } from '@/shared/contexts/homepage-state'; +import { Footer } from '../../footer'; import { Navbar } from './navbar'; interface LayoutProps { withNavigation?: boolean; } -export function Layout({ withNavigation = true }: LayoutProps) { +export function UnprotectedLayout({ + withNavigation = true, +}: Readonly) { const { colorPalette, isDarkMode } = useColorMode(); - const { backgroundColor } = useBackgroundContext(); + const { isMainPage } = useHomePageState(); + const isMobile = useIsMobile(); const layoutBackgroundColor = (() => { - if (isDarkMode) { + if (isDarkMode || isMobile || isMainPage) { return colorPalette.backgroundColor; } - return isMobile ? colorPalette.white : backgroundColor; + + return colorPalette.paper.main; })(); return ( diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/unprotected/navbar.tsx b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/navbar.tsx similarity index 98% rename from packages/apps/human-app/frontend/src/shared/components/layout/unprotected/navbar.tsx rename to packages/apps/human-app/frontend/src/router/components/layout/unprotected/navbar.tsx index fa241c0ddb..00c3d0ff79 100644 --- a/packages/apps/human-app/frontend/src/shared/components/layout/unprotected/navbar.tsx +++ b/packages/apps/human-app/frontend/src/router/components/layout/unprotected/navbar.tsx @@ -19,7 +19,7 @@ interface NavbarProps { withNavigation: boolean; } -export function Navbar({ withNavigation }: NavbarProps) { +export function Navbar({ withNavigation }: Readonly) { const { isMainPage } = useHomePageState(); const { t } = useTranslation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/packages/apps/human-app/frontend/src/router/router.tsx b/packages/apps/human-app/frontend/src/router/router.tsx index ace123b038..b22c8052d8 100644 --- a/packages/apps/human-app/frontend/src/router/router.tsx +++ b/packages/apps/human-app/frontend/src/router/router.tsx @@ -1,6 +1,4 @@ import { Routes, Route, Navigate } from 'react-router-dom'; -import { Layout as LayoutProtected } from '@/shared/components/layout/protected/layout'; -import { Layout as LayoutUnprotected } from '@/shared/components/layout/unprotected/layout'; import { protectedRoutes, walletConnectRoutes, @@ -10,28 +8,38 @@ import { import { RequireAuth } from '@/modules/auth/providers/require-auth'; import { RequireWalletConnect } from '@/shared/contexts/wallet-connect'; import { RequireWeb3Auth } from '@/modules/auth-web3/providers/require-web3-auth'; -import { DrawerNavigation } from '@/shared/components/layout/protected/drawer-navigation'; -import { - workerDrawerTopMenuItems, - workerDrawerBottomMenuItems, -} from '@/shared/components/layout/drawer-menu-items/drawer-menu-items-worker'; -import { operatorDrawerBottomMenuItems } from '@/shared/components/layout/drawer-menu-items/drawer-menu-items-operator'; +import { DrawerNavigation } from '@/router/components/layout/protected/drawer-navigation'; +import { operatorDrawerBottomMenuItems } from '@/router/components/drawer-menu-items/drawer-menu-items-operator'; import { browserAuthProvider } from '@/shared/contexts/browser-auth-provider'; import { useAuth } from '@/modules/auth/hooks/use-auth'; import { UserStatsDrawer } from '@/modules/worker/hcaptcha-labeling'; import { routerPaths } from './router-paths'; +import { + ProtectedLayout, + UnprotectedLayout, + workerDrawerBottomMenuItems, + workerDrawerTopMenuItems, +} from './components'; export function Router() { const { user } = useAuth(); + const handleSignOut = () => { + browserAuthProvider.signOut({ + callback: () => { + window.location.reload(); + }, + }); + }; + return ( - }> + }> {unprotectedRoutes.map((route) => ( ))} - }> + }> {walletConnectRoutes.map((route) => ( - ( { - browserAuthProvider.signOut({ - callback: () => { - window.location.reload(); - }, - }); - }} + signOut={handleSignOut} topMenuItems={workerDrawerTopMenuItems(user)} /> )} @@ -85,20 +87,14 @@ export function Router() { element={ - ( { - browserAuthProvider.signOut({ - callback: () => { - window.location.reload(); - }, - }); - }} + signOut={handleSignOut} /> )} /> diff --git a/packages/apps/human-app/frontend/src/router/routes.tsx b/packages/apps/human-app/frontend/src/router/routes.tsx index d6d7f85ee9..46bff008ab 100644 --- a/packages/apps/human-app/frontend/src/router/routes.tsx +++ b/packages/apps/human-app/frontend/src/router/routes.tsx @@ -8,7 +8,7 @@ import { ProfileIcon, WorkHeaderIcon, } from '@/shared/components/ui/icons'; -import type { PageHeaderProps } from '@/shared/components/layout/protected/page-header'; +import type { PageHeaderProps } from '@/router/components/layout/protected/page-header'; import { HcaptchaLabelingPage, UserStatsAccordion, diff --git a/packages/apps/human-app/frontend/src/shared/components/layout/protected/drawer-navigation.tsx b/packages/apps/human-app/frontend/src/shared/components/layout/protected/drawer-navigation.tsx deleted file mode 100644 index 2bc1732f39..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/layout/protected/drawer-navigation.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import Box from '@mui/material/Box'; -import Drawer from '@mui/material/Drawer'; -import CssBaseline from '@mui/material/CssBaseline'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemText from '@mui/material/ListItemText'; -import { Stack, Typography } from '@mui/material'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { t } from 'i18next'; -import type { Dispatch, SetStateAction } from 'react'; -import { HumanLogoNavbarIcon } from '@/shared/components/ui/icons'; -import { Button } from '@/shared/components/ui/button'; -import { useIsMobile } from '@/shared/hooks/use-is-mobile'; -import { NAVBAR_PADDING } from '@/shared/components/layout/protected/navbar'; -import { colorPalette } from '@/shared/styles/color-palette'; -import { useColorMode } from '@/shared/contexts/color-mode'; -import { onlyDarkModeColor } from '@/shared/styles/dark-color-palette'; -import { useHandleMainNavIconClick } from '@/shared/hooks/use-handle-main-nav-icon-click'; - -const drawerWidth = 240; - -export interface DrawerItem { - label: string; - link?: string; - href?: string; - icon?: JSX.Element; - disabled?: boolean; - onClick?: () => void; -} - -export type TopMenuItem = DrawerItem | JSX.Element; -export type BottomMenuItem = DrawerItem | JSX.Element; -interface DrawerNavigationProps { - open: boolean; - setDrawerOpen: Dispatch>; - topMenuItems?: TopMenuItem[]; - bottomMenuItems?: BottomMenuItem[]; - signOut: () => void; -} - -export function DrawerNavigation({ - open, - setDrawerOpen, - topMenuItems, - bottomMenuItems, - signOut, -}: DrawerNavigationProps) { - const { isDarkMode } = useColorMode(); - const navigate = useNavigate(); - const isMobile = useIsMobile(); - const location = useLocation(); - const handleMainNavIconClick = useHandleMainNavIconClick(); - - return ( - - - - {!isMobile && ( - { - handleMainNavIconClick(); - }} - > - - - )} - - - {topMenuItems?.map((item, index) => { - if (!('label' in item)) { - return ( - - - {item} - - - ); - } - - const { link, label, disabled, href, onClick, icon } = item; - const isActive = Boolean(link && location.pathname === link); - - return ( - - { - if (onClick) { - onClick(); - return; - } - if (disabled) return; - if (isMobile) setDrawerOpen(false); - if (href) { - const element = document.createElement('a'); - element.href = href; - element.target = '_blank'; - document.body.appendChild(element); - element.click(); - return; - } - if (link && !href) { - navigate(link); - } - }} - selected={isActive} - sx={{ - borderRadius: '4px', - p: 0, - '&.Mui-selected': { - backgroundColor: isDarkMode - ? onlyDarkModeColor.listItemColor - : colorPalette.primary.shades, - }, - }} - > - - {icon} - - {label} - - } - sx={{ - marginLeft: index === 0 ? '10px' : '0px', - }} - /> - - - - ); - })} - - - {bottomMenuItems?.map((item) => { - if (!('label' in item)) { - return ( - - - {item} - - - ); - } - - const { label, link, icon, href, onClick } = item; - const isActive = location.pathname === link; - - return ( - - { - if (onClick) { - onClick(); - return; - } - if (isMobile) setDrawerOpen(false); - if (href) { - const element = document.createElement('a'); - element.href = href; - element.target = '_blank'; - document.body.appendChild(element); - element.click(); - return; - } - if (link && !href) { - navigate(link); - } - }} - selected={isActive} - sx={{ - borderRadius: '4px', - p: 0, - '&.Mui-selected': { - backgroundColor: isDarkMode - ? onlyDarkModeColor.listItemColor - : colorPalette.primary.shades, - }, - }} - > - - {icon} - - {label} - - } - sx={{ - textAlign: 'center', - marginLeft: '10px', - }} - /> - - - - ); - })} - - - - - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx index fb61df67d0..81d2c45ee7 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-cell.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useState } from 'react'; +import React, { useState } from 'react'; import Popover from '@mui/material/Popover'; import type { TableCellBaseProps } from '@mui/material/TableCell/TableCell'; import { type IconType, TextHeaderWithIcon } from '../text-header-with-icon'; @@ -18,18 +18,18 @@ type PropsWithoutIcon = CommonProps & { type HeaderCellProps = PropsWithoutIcon | PropsWithIcon; -export const TableHeaderCell = forwardRef< - HTMLTableDataCellElement, - HeaderCellProps ->(function TableHeaderCell( - { popoverContent, headerText, iconType, ...rest }, - ref -) { - const [anchorEl, setAnchorEl] = useState( - null - ); +export function TableHeaderCell({ + popoverContent, + headerText, + iconType, +}: HeaderCellProps) { + const [anchorEl, setAnchorEl] = useState(null); - const handleClick = (event: React.MouseEvent) => { + const handleClick = ( + event: + | React.MouseEvent + | React.KeyboardEvent + ) => { setAnchorEl(event.currentTarget); }; @@ -42,12 +42,15 @@ export const TableHeaderCell = forwardRef< const getHeader = () => { if (!iconType) { - return ; + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + return
{headerText}
; } + return ( - + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
- +
); }; @@ -63,12 +66,11 @@ export const TableHeaderCell = forwardRef< id={id} onClose={handleClose} open={open} - ref={ref} > {popoverContent} ); -}); +} TableHeaderCell.displayName = 'TableHeaderCell'; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu.tsx/filtering.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu/filtering.tsx similarity index 92% rename from packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu.tsx/filtering.tsx rename to packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu/filtering.tsx index 1dd92c9ecd..32023a5d48 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu.tsx/filtering.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu/filtering.tsx @@ -16,7 +16,8 @@ interface FilteringProps { isChecked: (option: T) => boolean; setFiltering: (option: T) => void; clear: () => void; - isMobile?: boolean; + showTitle?: boolean; + showClearButton?: boolean; } export function Filtering({ @@ -24,13 +25,14 @@ export function Filtering({ isChecked, setFiltering, clear, - isMobile = true, -}: FilteringProps) { + showTitle = false, + showClearButton = false, +}: Readonly>) { const { colorPalette } = useColorMode(); return ( - {isMobile ? ( + {showTitle ? ( ({ ); })} - {isMobile ? ( + {showClearButton ? ( <> diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu.tsx/sorting.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu/sorting.tsx similarity index 100% rename from packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu.tsx/sorting.tsx rename to packages/apps/human-app/frontend/src/shared/components/ui/table/table-header-menu/sorting.tsx diff --git a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/index.ts b/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/index.ts deleted file mode 100644 index 8c5fb5fd81..0000000000 --- a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './registered-oracles'; -export * from './use-registered-oracles'; diff --git a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/registered-oracles.tsx b/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/registered-oracles.tsx deleted file mode 100644 index ad1affa7ed..0000000000 --- a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/registered-oracles.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { ReactNode } from 'react'; -import { createContext, useMemo, useState } from 'react'; - -interface RegisteredOraclesContextProps { - registeredOracles: string[] | undefined; - setRegisteredOracles: React.Dispatch< - React.SetStateAction - >; -} - -export const RegisteredOraclesContext = createContext< - RegisteredOraclesContextProps | undefined ->(undefined); - -export function RegisteredOraclesProvider({ - children, -}: Readonly<{ - children: ReactNode; -}>) { - const [registeredOracles, setRegisteredOracles] = useState< - string[] | undefined - >(undefined); - - const oraclesContextValue = useMemo( - () => ({ registeredOracles, setRegisteredOracles }), - [registeredOracles, setRegisteredOracles] - ); - - return ( - - {children} - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/use-registered-oracles.tsx b/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/use-registered-oracles.tsx deleted file mode 100644 index 76907c3efc..0000000000 --- a/packages/apps/human-app/frontend/src/shared/contexts/registered-oracles/use-registered-oracles.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from 'react'; -import { RegisteredOraclesContext } from './registered-oracles'; - -export const useRegisteredOracles = () => { - const context = useContext(RegisteredOraclesContext); - if (!context) { - throw new Error( - 'useRegisteredOracles must be used within a RegisteredOraclesProvider' - ); - } - return context; -}; diff --git a/packages/apps/human-app/frontend/src/shared/i18n/en.json b/packages/apps/human-app/frontend/src/shared/i18n/en.json index cb936198bf..70ee9859b2 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -324,7 +324,10 @@ "jobTypes": "Job Types", "publicKey": "Public Key" }, - "activateBtn": "Activate", + "activate": { + "activateBtn": "Activate", + "cannotActivate": "Cannot activate operator" + }, "disable": { "disableBtn": "Deactivate", "cannotDisable": "Cannot deactivate operator" diff --git a/packages/apps/human-app/server/package.json b/packages/apps/human-app/server/package.json index b24c0329b1..4e92d6182e 100644 --- a/packages/apps/human-app/server/package.json +++ b/packages/apps/human-app/server/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/express": "^4.17.13", "@types/jest": "29.5.12", diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index b220b001f5..0044cf15a0 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -24,7 +24,6 @@ import { ExchangeOracleModule } from './integrations/exchange-oracle/exchange-or import { KvStoreModule } from './integrations/kv-store/kv-store.module'; import { EmailConfirmationModule } from './modules/email-confirmation/email-confirmation.module'; import { PasswordResetModule } from './modules/password-reset/password-reset.module'; -import { DisableOperatorModule } from './modules/disable-operator/disable-operator.module'; import { KycProcedureModule } from './modules/kyc-procedure/kyc-procedure.module'; import { PrepareSignatureModule } from './modules/prepare-signature/prepare-signature.module'; import { HCaptchaModule } from './modules/h-captcha/h-captcha.module'; @@ -115,7 +114,6 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); KvStoreModule, EmailConfirmationModule, PasswordResetModule, - DisableOperatorModule, KycProcedureModule, PrepareSignatureModule, HCaptchaModule, diff --git a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts index e07bf927e6..ff3518bc88 100644 --- a/packages/apps/human-app/server/src/common/config/gateway-config.service.ts +++ b/packages/apps/human-app/server/src/common/config/gateway-config.service.ts @@ -76,6 +76,11 @@ export class GatewayConfigService { method: HttpMethod.POST, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.ENABLE_OPERATOR]: { + endpoint: '/user/enable-operator', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, [ReputationOracleEndpoints.KYC_PROCEDURE_START]: { endpoint: '/kyc/start', method: HttpMethod.POST, diff --git a/packages/apps/human-app/server/src/common/constants/hmt.ts b/packages/apps/human-app/server/src/common/constants/hmt.ts new file mode 100644 index 0000000000..7551f452ae --- /dev/null +++ b/packages/apps/human-app/server/src/common/constants/hmt.ts @@ -0,0 +1 @@ +export const HMT_TOKEN_SYMBOL = 'HMT'; diff --git a/packages/apps/human-app/server/src/common/enums/global-common.ts b/packages/apps/human-app/server/src/common/enums/global-common.ts index bd05903368..386300c11c 100644 --- a/packages/apps/human-app/server/src/common/enums/global-common.ts +++ b/packages/apps/human-app/server/src/common/enums/global-common.ts @@ -3,7 +3,7 @@ export enum JobDiscoveryFieldName { RewardAmount = 'reward_amount', RewardToken = 'reward_token', CreatedAt = 'created_at', - Qualifications = 'qualifications', + UpdatedAt = 'updated_at', } export enum JobStatus { diff --git a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts index c929a56aa6..149368a3f2 100644 --- a/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts +++ b/packages/apps/human-app/server/src/common/enums/reputation-oracle-endpoints.ts @@ -9,6 +9,7 @@ export enum ReputationOracleEndpoints { RESTORE_PASSWORD = 'restore_password', PREPARE_SIGNATURE = 'prepare_signature', DISABLE_OPERATOR = 'disable_operator', + ENABLE_OPERATOR = 'enable_operator', KYC_PROCEDURE_START = 'kyc_procedure_start', ENABLE_LABELING = 'enable_labeling', REGISTER_ADDRESS = 'register_address', diff --git a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts index e35778440d..c330d54fd6 100644 --- a/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts +++ b/packages/apps/human-app/server/src/common/utils/jwt-token.model.ts @@ -2,7 +2,7 @@ import { AutoMap } from '@automapper/classes'; export class JwtUserData { @AutoMap() - userId: string; + user_id: string; @AutoMap() wallet_address: string; @AutoMap() diff --git a/packages/apps/human-app/server/src/common/utils/pagination.utils.ts b/packages/apps/human-app/server/src/common/utils/pagination.utils.ts index be817a8c99..38069230d3 100644 --- a/packages/apps/human-app/server/src/common/utils/pagination.utils.ts +++ b/packages/apps/human-app/server/src/common/utils/pagination.utils.ts @@ -1,10 +1,14 @@ +import _ from 'lodash'; + import { SortOrder } from '../../common/enums/global-common'; +export type Iteratee = keyof T | ((value: T) => any); + export function paginateAndSortResults( data: T[], page = 0, pageSize = 10, - sortField: keyof T | undefined = 'created_at' as keyof T, + iteratee: Iteratee, sortOrder = SortOrder.DESC, ): { results: T[]; @@ -13,19 +17,7 @@ export function paginateAndSortResults( total_pages: number; total_results: number; } { - let results = data; - - // Sorting - if (!sortField) { - sortField = 'created_at' as keyof T; - } - results = results.sort((a, b) => { - if (sortOrder === SortOrder.DESC) { - return a[sortField] < b[sortField] ? 1 : -1; - } else { - return a[sortField] > b[sortField] ? 1 : -1; - } - }); + const orderedData = _.orderBy(data, iteratee, sortOrder); // Pagination const start = page * pageSize; @@ -36,6 +28,6 @@ export function paginateAndSortResults( page_size: pageSize, total_pages: Math.ceil(data.length / pageSize), total_results: data.length, - results: results.slice(start, end), + results: orderedData.slice(start, end), }; } diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts index 8e5e3fa593..6819b220e6 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.gateway.ts @@ -55,7 +55,12 @@ import { DisableOperatorCommand, DisableOperatorData, DisableOperatorParams, -} from '../../modules/disable-operator/model/disable-operator.model'; +} from '../../modules/user-operator/model/disable-operator.model'; +import { + EnableOperatorCommand, + EnableOperatorData, + EnableOperatorParams, +} from '../../modules/user-operator/model/enable-operator.model'; import { KycProcedureStartResponse } from '../../modules/kyc-procedure/model/kyc-start.model'; import { EnableLabelingCommand, @@ -283,6 +288,20 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } + async sendEnableOperator(enableOperatorCommand: EnableOperatorCommand) { + const enableOperatorData = this.mapper.map( + enableOperatorCommand.data, + EnableOperatorParams, + EnableOperatorData, + ); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.ENABLE_OPERATOR, + enableOperatorData, + enableOperatorCommand.token, + ); + return this.handleRequestToReputationOracle(options); + } + async sendKycProcedureStart(token: string) { const options = this.getEndpointOptions( ReputationOracleEndpoints.KYC_PROCEDURE_START, diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts index 87ad21dfb9..f235bd320a 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.interface.ts @@ -4,7 +4,7 @@ import { SigninWorkerData } from '../../modules/user-worker/model/worker-signin. import { EmailVerificationData } from '../../modules/email-confirmation/model/email-verification.model'; import { ResendEmailVerificationData } from '../../modules/email-confirmation/model/resend-email-verification.model'; import { PrepareSignatureData } from '../../modules/prepare-signature/model/prepare-signature.model'; -import { DisableOperatorData } from '../../modules/disable-operator/model/disable-operator.model'; +import { DisableOperatorData } from '../../modules/user-operator/model/disable-operator.model'; import { RegisterAddressData } from '../../modules/register-address/model/register-address.model'; import { RestorePasswordData } from '../../modules/password-reset/model/restore-password.model'; import { TokenRefreshData } from '../../modules/token-refresh/model/token-refresh.model'; diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts index 9e551d17ca..6021483d8c 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/reputation-oracle.mapper.profile.ts @@ -28,7 +28,7 @@ import { import { DisableOperatorData, DisableOperatorParams, -} from '../../modules/disable-operator/model/disable-operator.model'; +} from '../../modules/user-operator/model/disable-operator.model'; import { RestorePasswordCommand, RestorePasswordData, diff --git a/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts b/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts index 6c4f214a4f..704699c472 100644 --- a/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts +++ b/packages/apps/human-app/server/src/integrations/reputation-oracle/spec/reputation-oracle.gateway.spec.ts @@ -54,11 +54,11 @@ import { import { disableOperatorCommandFixture, disableOperatorDataFixture, -} from '../../../modules/disable-operator/spec/disable-operator.fixtures'; +} from '../../../modules/user-operator/spec/disable-operator.fixtures'; import { DisableOperatorCommand, DisableOperatorData, -} from '../../../modules/disable-operator/model/disable-operator.model'; +} from '../../../modules/user-operator/model/disable-operator.model'; import { EnableLabelingCommand } from '../../../modules/h-captcha/model/enable-labeling.model'; import { enableLabelingCommandFixture, diff --git a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts index e3a86ee28a..5487014efb 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/cron-job.service.ts @@ -2,18 +2,43 @@ import { Injectable, Logger } from '@nestjs/common'; import { CronJob } from 'cron'; import { ExchangeOracleGateway } from '../../integrations/exchange-oracle/exchange-oracle.gateway'; import { + DiscoveredJob, JobsDiscoveryParams, JobsDiscoveryParamsCommand, - JobsDiscoveryResponseItem, + JobsDiscoveryResponse, } from '../jobs-discovery/model/jobs-discovery.model'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { OracleDiscoveryService } from '../oracle-discovery/oracle-discovery.service'; import { DiscoveredOracle } from '../oracle-discovery/model/oracle-discovery.model'; import { WorkerService } from '../user-worker/worker.service'; -import { JobDiscoveryFieldName } from '../../common/enums/global-common'; +import { + JobDiscoveryFieldName, + JobStatus, +} from '../../common/enums/global-common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { JobsDiscoveryService } from '../jobs-discovery/jobs-discovery.service'; +function assertJobsDiscoveryResponseItemsFormat( + items: JobsDiscoveryResponse['results'], +): asserts items is DiscoveredJob[] { + if (items.length === 0) { + return; + } + + const item = items[0]; + if ( + [ + item.job_description, + item.reward_amount, + item.reward_token, + item.created_at, + item.updated_at, + ].includes(undefined) + ) { + throw new Error('Job discovery response items missing expected fields'); + } +} + @Injectable() export class CronJobService { private readonly logger = new Logger(CronJobService.name); @@ -81,7 +106,7 @@ export class CronJobService { async updateJobsListCache(oracle: DiscoveredOracle, token: string) { try { - let allResults: JobsDiscoveryResponseItem[] = []; + let allResults: DiscoveredJob[] = []; // Initial fetch to determine the total number of pages const command = new JobsDiscoveryParamsCommand(); @@ -91,14 +116,18 @@ export class CronJobService { command.data.page = 0; command.data.pageSize = command.data.pageSize || 10; // Max value for Exchange Oracle command.data.fields = [ - JobDiscoveryFieldName.CreatedAt, JobDiscoveryFieldName.JobDescription, JobDiscoveryFieldName.RewardAmount, JobDiscoveryFieldName.RewardToken, - JobDiscoveryFieldName.Qualifications, + JobDiscoveryFieldName.CreatedAt, + JobDiscoveryFieldName.UpdatedAt, ]; + command.data.status = JobStatus.ACTIVE; const initialResponse = await this.exchangeOracleGateway.fetchJobs(command); + + assertJobsDiscoveryResponseItemsFormat(initialResponse.results); + allResults = this.mergeJobs(allResults, initialResponse.results); const totalPages = initialResponse.total_pages; @@ -112,6 +141,7 @@ export class CronJobService { const remainingResponses = await Promise.all(pageFetches); for (const response of remainingResponses) { + assertJobsDiscoveryResponseItemsFormat(response.results); allResults = this.mergeJobs(allResults, response.results); } @@ -143,10 +173,10 @@ export class CronJobService { } private mergeJobs( - cachedJobs: JobsDiscoveryResponseItem[], - newJobs: JobsDiscoveryResponseItem[], - ): JobsDiscoveryResponseItem[] { - const jobsMap = new Map(); + cachedJobs: DiscoveredJob[], + newJobs: DiscoveredJob[], + ): DiscoveredJob[] { + const jobsMap = new Map(); for (const job of cachedJobs) { jobsMap.set(job.escrow_address + '-' + job.chain_id, job); diff --git a/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts b/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts index f6e1375dd5..06e86f90f9 100644 --- a/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/cron-job/spec/cron-job.service.spec.ts @@ -1,3 +1,5 @@ +import { ChainId } from '@human-protocol/sdk'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { CronJobService } from '../cron-job.service'; import { ExchangeOracleGateway } from '../../../integrations/exchange-oracle/exchange-oracle.gateway'; @@ -5,14 +7,16 @@ import { OracleDiscoveryService } from '../../../modules/oracle-discovery/oracle import { WorkerService } from '../../../modules/user-worker/worker.service'; import { EnvironmentConfigService } from '../../../common/config/environment-config.service'; import { + DiscoveredJob, JobsDiscoveryParamsCommand, + JobsDiscoveryResponse, JobsDiscoveryResponseItem, } from '../../../modules/jobs-discovery/model/jobs-discovery.model'; import { JobStatus } from '../../../common/enums/global-common'; import { JobsDiscoveryService } from '../../../modules/jobs-discovery/jobs-discovery.service'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { generateOracleDiscoveryResponseBody } from '../../../modules/oracle-discovery/spec/oracle-discovery.fixture'; -import { ChainId } from '@human-protocol/sdk'; + +import { HMT_TOKEN_SYMBOL } from '../../../common/constants/hmt'; jest.mock('cron', () => { return { @@ -166,9 +170,26 @@ describe('CronJobService', () => { const token = 'Bearer token'; it('should fetch all jobs and update the cache', async () => { - const initialResponse = { - results: [{ escrow_address: '0xabc', chain_id: '1' }], + const now = new Date(); + const initialResponse: JobsDiscoveryResponse = { + results: [ + { + escrow_address: '0xabc', + chain_id: 1, + job_type: '', + status: JobStatus.ACTIVE, + job_description: 'Best job ever', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: now.toISOString(), + qualifications: [], + }, + ], total_pages: 1, + page: 0, + page_size: 5, + total_results: 1, }; (exchangeOracleGatewayMock.fetchJobs as jest.Mock).mockResolvedValue( initialResponse, @@ -204,9 +225,26 @@ describe('CronJobService', () => { }); it('should reset retries count after successful job fetch', async () => { - const initialResponse = { - results: [{ escrow_address: '0xabc', chain_id: '1' }], + const now = new Date(); + const initialResponse: JobsDiscoveryResponse = { + results: [ + { + escrow_address: '0xabc', + chain_id: 1, + job_type: 'test', + status: JobStatus.ACTIVE, + job_description: 'Best job ever', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: now.toISOString(), + qualifications: [], + }, + ], total_pages: 1, + page: 0, + page_size: 5, + total_results: 1, }; (exchangeOracleGatewayMock.fetchJobs as jest.Mock).mockResolvedValue( initialResponse, @@ -226,48 +264,59 @@ describe('CronJobService', () => { describe('mergeJobs', () => { it('should merge jobs correctly', () => { - const cachedJobs: JobsDiscoveryResponseItem[] = [ + const now = new Date(); + const cachedJobs: DiscoveredJob[] = [ { escrow_address: '0xabc', chain_id: 1, job_type: 'type1', status: JobStatus.ACTIVE, + job_description: 'Best job ever', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: now.toISOString(), + qualifications: [], }, ]; + const newJobs: JobsDiscoveryResponseItem[] = [ { escrow_address: '0xdef', chain_id: 1, job_type: 'type2', status: JobStatus.CANCELED, + job_description: 'Greatest job', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: now.toISOString(), + qualifications: [], }, ]; - const result = service['mergeJobs'](cachedJobs, newJobs); + const result = service['mergeJobs']( + cachedJobs, + newJobs as DiscoveredJob[], + ); - expect(result).toEqual([ - { - escrow_address: '0xabc', - chain_id: 1, - job_type: 'type1', - status: JobStatus.ACTIVE, - }, - { - escrow_address: '0xdef', - chain_id: 1, - job_type: 'type2', - status: JobStatus.CANCELED, - }, - ]); + expect(result).toEqual([cachedJobs[0], newJobs[0]]); }); it('should update existing jobs with new data', () => { - const cachedJobs: JobsDiscoveryResponseItem[] = [ + const now = new Date(); + const cachedJobs: DiscoveredJob[] = [ { escrow_address: '0xabc', chain_id: 1, job_type: 'type1', status: JobStatus.ACTIVE, + job_description: 'Best job ever', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: now.toISOString(), + qualifications: [], }, ]; const newJobs: JobsDiscoveryResponseItem[] = [ @@ -276,19 +325,21 @@ describe('CronJobService', () => { chain_id: 1, job_type: 'type1', status: JobStatus.COMPLETED, + job_description: 'Nice job', + reward_amount: '42', + reward_token: HMT_TOKEN_SYMBOL, + created_at: now.toISOString(), + updated_at: new Date().toISOString(), + qualifications: [], }, ]; - const result = service['mergeJobs'](cachedJobs, newJobs); + const result = service['mergeJobs']( + cachedJobs, + newJobs as DiscoveredJob[], + ); - expect(result).toEqual([ - { - escrow_address: '0xabc', - chain_id: 1, - job_type: 'type1', - status: JobStatus.COMPLETED, - }, - ]); + expect(result).toEqual(newJobs); }); }); diff --git a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.controller.ts b/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.controller.ts deleted file mode 100644 index 0b41f34cea..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - Body, - Controller, - Post, - UsePipes, - ValidationPipe, -} from '@nestjs/common'; -import { DisableOperatorService } from './disable-operator.service'; -import { InjectMapper } from '@automapper/nestjs'; -import { Mapper } from '@automapper/core'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Authorization } from '../../common/config/params-decorators'; -import { - DisableOperatorCommand, - DisableOperatorDto, -} from './model/disable-operator.model'; - -@Controller('/disable-operator') -export class DisableOperatorController { - constructor( - private readonly service: DisableOperatorService, - @InjectMapper() private readonly mapper: Mapper, - ) {} - - @ApiTags('Disable-Operator') - @Post('/') - @ApiOperation({ - summary: 'Endpoint to disable an operator', - }) - @ApiBearerAuth() - @UsePipes(new ValidationPipe()) - public async disableOperator( - @Body() disableOperatorDto: DisableOperatorDto, - @Authorization() token: string, - ): Promise { - const disableOperatorCommand = this.mapper.map( - disableOperatorDto, - DisableOperatorDto, - DisableOperatorCommand, - ); - disableOperatorCommand.token = token; - return this.service.processDisableOperator(disableOperatorCommand); - } -} diff --git a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.module.ts b/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.module.ts deleted file mode 100644 index 42371f202c..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DisableOperatorController } from './disable-operator.controller'; -import { DisableOperatorService } from './disable-operator.service'; -import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; -import { DisableOperatorProfile } from './disable-operator.mapper.profile'; - -@Module({ - imports: [ReputationOracleModule], - controllers: [DisableOperatorController], - providers: [DisableOperatorService, DisableOperatorProfile], -}) -export class DisableOperatorModule {} diff --git a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.service.ts b/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.service.ts deleted file mode 100644 index 2d3d88cc83..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { DisableOperatorCommand } from './model/disable-operator.model'; - -@Injectable() -export class DisableOperatorService { - constructor(private gateway: ReputationOracleGateway) {} - - async processDisableOperator(command: DisableOperatorCommand): Promise { - return this.gateway.sendDisableOperator(command); - } -} diff --git a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.controller.spec.ts b/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.controller.spec.ts deleted file mode 100644 index a23d2e7e48..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.controller.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { DisableOperatorController } from '../disable-operator.controller'; -import { DisableOperatorService } from '../disable-operator.service'; -import { Test, TestingModule } from '@nestjs/testing'; -import { AutomapperModule } from '@automapper/nestjs'; -import { classes } from '@automapper/classes'; -import { expect, it } from '@jest/globals'; -import { DisableOperatorProfile } from '../disable-operator.mapper.profile'; -import { - disableOperatorCommandFixture, - disableOperatorDtoFixture, - disableOperatorTokenFixture, -} from './disable-operator.fixtures'; -import { serviceMock } from './disable-operator.service.mock'; - -describe('DisableOperatorController', () => { - let controller: DisableOperatorController; - let service: DisableOperatorService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [DisableOperatorController], - imports: [ - AutomapperModule.forRoot({ - strategyInitializer: classes(), - }), - ], - providers: [DisableOperatorService, DisableOperatorProfile], - }) - .overrideProvider(DisableOperatorService) - .useValue(serviceMock) - .compile(); - - controller = module.get( - DisableOperatorController, - ); - service = module.get(DisableOperatorService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('disableOperator', () => { - it('should call the processDisableOperator method of the service with the correct arguments', async () => { - const dto = disableOperatorDtoFixture; - const command = disableOperatorCommandFixture; - await controller.disableOperator(dto, disableOperatorTokenFixture); - expect(service.processDisableOperator).toHaveBeenCalledWith(command); - }); - }); -}); diff --git a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.mock.ts b/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.mock.ts deleted file mode 100644 index 0d5703a44b..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.mock.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const serviceMock = { - processDisableOperator: jest.fn(), -}; diff --git a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.spec.ts b/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.spec.ts deleted file mode 100644 index d67a1bf194..0000000000 --- a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.service.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { DisableOperatorService } from '../disable-operator.service'; -import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { Test, TestingModule } from '@nestjs/testing'; -import { reputationOracleGatewayMock } from '../../../integrations/reputation-oracle/spec/reputation-oracle.gateway.mock'; -import { expect, it } from '@jest/globals'; -import { disableOperatorCommandFixture } from './disable-operator.fixtures'; -import { DisableOperatorCommand } from '../model/disable-operator.model'; - -describe('DisableOperatorService', () => { - let service: DisableOperatorService; - let reputationOracleGateway: ReputationOracleGateway; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [DisableOperatorService, ReputationOracleGateway], - }) - .overrideProvider(ReputationOracleGateway) - .useValue(reputationOracleGatewayMock) - .compile(); - - service = module.get(DisableOperatorService); - reputationOracleGateway = module.get( - ReputationOracleGateway, - ); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('processDisableOperator', () => { - it('should call sendDisableOperator method of reputationOracleGateway', async () => { - const command: DisableOperatorCommand = disableOperatorCommandFixture; - await service.processDisableOperator(command); - expect(reputationOracleGateway.sendDisableOperator).toHaveBeenCalledWith( - command, - ); - }); - }); -}); diff --git a/packages/apps/human-app/server/src/modules/email-confirmation/model/resend-email-verification.model.ts b/packages/apps/human-app/server/src/modules/email-confirmation/model/resend-email-verification.model.ts index 545427ed2e..b3baf456c8 100644 --- a/packages/apps/human-app/server/src/modules/email-confirmation/model/resend-email-verification.model.ts +++ b/packages/apps/human-app/server/src/modules/email-confirmation/model/resend-email-verification.model.ts @@ -1,12 +1,8 @@ import { AutoMap } from '@automapper/classes'; -import { IsEmail, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class ResendEmailVerificationDto { - @AutoMap() - @IsEmail() - @ApiProperty({ example: 'string' }) - email: string; @AutoMap() @IsString() @ApiProperty({ example: 'string' }) @@ -14,8 +10,6 @@ export class ResendEmailVerificationDto { } export class ResendEmailVerificationParams { - @AutoMap() - email: string; @AutoMap() hCaptchaToken: string; } @@ -26,8 +20,6 @@ export class ResendEmailVerificationCommand { } export class ResendEmailVerificationData { - @AutoMap() - email: string; @AutoMap() h_captcha_token: string; } diff --git a/packages/apps/human-app/server/src/modules/email-confirmation/spec/email-verification.fixtures.ts b/packages/apps/human-app/server/src/modules/email-confirmation/spec/email-verification.fixtures.ts index 6516de698e..4f54bc4c12 100644 --- a/packages/apps/human-app/server/src/modules/email-confirmation/spec/email-verification.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/email-confirmation/spec/email-verification.fixtures.ts @@ -11,7 +11,6 @@ import { } from '../model/email-verification.model'; const TOKEN = 'test_user_token'; -const EMAIL = 'test_user@email.com'; export const emailVerificationDtoFixture: EmailVerificationDto = { token: TOKEN, @@ -26,12 +25,10 @@ export const emailVerificationDataFixture: EmailVerificationData = { export const emailVerificationToken = TOKEN; export const resendEmailVerificationDtoFixture: ResendEmailVerificationDto = { - email: EMAIL, h_captcha_token: TOKEN, }; export const resendEmailVerificationParamsFixture: ResendEmailVerificationParams = { - email: EMAIL, hCaptchaToken: TOKEN, }; export const resendEmailVerificationCommandFixture: ResendEmailVerificationCommand = @@ -40,6 +37,5 @@ export const resendEmailVerificationCommandFixture: ResendEmailVerificationComma token: TOKEN, }; export const resendEmailVerificationDataFixture: ResendEmailVerificationData = { - email: EMAIL, h_captcha_token: TOKEN, }; diff --git a/packages/apps/human-app/server/src/modules/h-captcha/spec/h-captcha.fixtures.ts b/packages/apps/human-app/server/src/modules/h-captcha/spec/h-captcha.fixtures.ts index 1a253b3b6d..5476814b38 100644 --- a/packages/apps/human-app/server/src/modules/h-captcha/spec/h-captcha.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/h-captcha/spec/h-captcha.fixtures.ts @@ -70,7 +70,7 @@ const EARNINGS_DATA_2 = { }; const SUCCESSFULLY_ENABLED = 'Enabled labeling for this account successfully'; export const jwtUserDataFixture: JwtUserData = { - userId: ID, + user_id: ID, wallet_address: POLYGON_WALLET_ADDR, email: EMAIL, kyc_status: 'approved', diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts index 42852294e1..7dd491cb9e 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.service.ts @@ -1,5 +1,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ethers } from 'ethers'; import { Cache } from 'cache-manager'; import { decode } from 'jsonwebtoken'; import { EscrowUtilsGateway } from '../../integrations/escrow/escrow-utils-gateway.service'; @@ -14,7 +15,11 @@ import { ResignJobCommand, } from './model/job-assignment.model'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; -import { paginateAndSortResults } from '../../common/utils/pagination.utils'; +import { AssignmentSortField } from '../../common/enums/global-common'; +import { + Iteratee, + paginateAndSortResults, +} from '../../common/utils/pagination.utils'; import { JOB_ASSIGNMENT_CACHE_KEY } from '../../common/constants/cache'; @Injectable() @@ -98,6 +103,16 @@ export class JobAssignmentService { command.oracleAddress, ); + const sortField = command.data.sortField || AssignmentSortField.CREATED_AT; + + let iteratee: AssignmentSortField | Iteratee; + if (sortField === AssignmentSortField.REWARD_AMOUNT) { + iteratee = (job: JobsFetchResponseItem) => + ethers.parseUnits(job[sortField], 18); + } else { + iteratee = sortField; + } + const cachedData = await this.cacheManager.get(cacheKey); if (cachedData && cachedData.length > 0) { @@ -105,7 +120,7 @@ export class JobAssignmentService { this.applyFilters(cachedData, command.data), command.data.page, command.data.pageSize, - command.data.sortField as keyof JobsFetchResponseItem, + iteratee, command.data.sort, ); } @@ -117,7 +132,7 @@ export class JobAssignmentService { this.applyFilters(allJobsData, command.data), command.data.page, command.data.pageSize, - command.data.sortField as keyof JobsFetchResponseItem, + iteratee, command.data.sort, ); } diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts index 52a8584f17..8e590812d6 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.service.ts @@ -1,15 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; -import { paginateAndSortResults } from '../../common/utils/pagination.utils'; import { + Iteratee, + paginateAndSortResults, +} from '../../common/utils/pagination.utils'; +import { + DiscoveredJob, JobsDiscoveryParamsCommand, JobsDiscoveryResponse, - JobsDiscoveryResponseItem, } from './model/jobs-discovery.model'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { JOB_DISCOVERY_CACHE_KEY } from '../../common/constants/cache'; -import { JobDiscoveryFieldName } from '../../common/enums/global-common'; +import { + JobDiscoveryFieldName, + JobDiscoverySortField, +} from '../../common/enums/global-common'; +import { ethers } from 'ethers'; @Injectable() export class JobsDiscoveryService { @@ -27,19 +34,29 @@ export class JobsDiscoveryService { this.configService.chainIdsEnabled.includes(job.chain_id), ); + const sortField = + command.data.sortField || JobDiscoverySortField.CREATED_AT; + + let iteratee: JobDiscoverySortField | Iteratee; + if (sortField === JobDiscoverySortField.REWARD_AMOUNT) { + iteratee = (job: DiscoveredJob) => ethers.parseUnits(job[sortField], 18); + } else { + iteratee = sortField; + } + return paginateAndSortResults( filteredJobs, command.data.page, command.data.pageSize, - command.data.sortField as keyof JobsDiscoveryResponseItem, + iteratee, command.data.sort, ); } private applyFilters( - jobs: JobsDiscoveryResponseItem[], + jobs: DiscoveredJob[], filters: JobsDiscoveryParamsCommand['data'], - ): JobsDiscoveryResponseItem[] { + ): DiscoveredJob[] { const difference = Object.values(JobDiscoveryFieldName).filter( (value) => !filters.fields?.includes(value), ); @@ -92,21 +109,19 @@ export class JobsDiscoveryService { return `${JOB_DISCOVERY_CACHE_KEY}:${oracleAddress}`; } - async getCachedJobs( - oracleAddress: string, - ): Promise { + async getCachedJobs(oracleAddress: string): Promise { const cacheKey = JobsDiscoveryService.makeCacheKeyForOracle(oracleAddress); - const cachedJobs = await this.cacheManager.get< - JobsDiscoveryResponseItem[] | undefined - >(cacheKey); + const cachedJobs = await this.cacheManager.get( + cacheKey, + ); return cachedJobs || []; } async setCachedJobs( oracleAddress: string, - jobs: JobsDiscoveryResponseItem[], + jobs: DiscoveredJob[], ): Promise { const cacheKey = JobsDiscoveryService.makeCacheKeyForOracle(oracleAddress); await this.cacheManager.set(cacheKey, jobs); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts index 9a2363502f..cc63a65da0 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/model/jobs-discovery.model.ts @@ -95,18 +95,21 @@ export class JobsDiscoveryParamsCommand { data: JobsDiscoveryParams; } -export class JobsDiscoveryResponseItem { +export type JobsDiscoveryResponseItem = { escrow_address: string; chain_id: number; job_type: string; status: JobStatus; - created_at?: string; job_description?: string; - reward_amount?: number; + reward_amount?: string; reward_token?: string; - qualifications?: string[]; -} + created_at?: string; + updated_at?: string; + qualifications: string[]; +}; export class JobsDiscoveryResponse extends PageableResponse { results: JobsDiscoveryResponseItem[]; } + +export type DiscoveredJob = Required; diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts index eb898c410a..f86b8bcd3f 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.fixtures.ts @@ -77,18 +77,24 @@ export const responseItemFixture1: JobsDiscoveryResponseItem = { chain_id: CHAIN_ID, job_type: JOB_TYPE, status: JobStatus.ACTIVE, + created_at: '2025-03-18T03:00:00.000Z', + qualifications: [], }; export const responseItemFixture2: JobsDiscoveryResponseItem = { escrow_address: ESCROW_ADDRESS2, chain_id: CHAIN_ID, job_type: JOB_TYPE, status: JobStatus.COMPLETED, + created_at: '2025-03-18T02:00:00.000Z', + qualifications: [], }; export const responseItemFixture3: JobsDiscoveryResponseItem = { escrow_address: ESCROW_ADDRESS3, chain_id: CHAIN_ID, job_type: JOB_TYPE, status: JobStatus.ACTIVE, + created_at: '2025-03-18T01:00:00.000Z', + qualifications: [], }; export const responseItemsFixture: JobsDiscoveryResponseItem[] = [ responseItemFixture1, diff --git a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.mapper.profile.ts b/packages/apps/human-app/server/src/modules/user-operator/disable-operator.mapper.profile.ts similarity index 94% rename from packages/apps/human-app/server/src/modules/disable-operator/disable-operator.mapper.profile.ts rename to packages/apps/human-app/server/src/modules/user-operator/disable-operator.mapper.profile.ts index 593e791fe6..1fdf480164 100644 --- a/packages/apps/human-app/server/src/modules/disable-operator/disable-operator.mapper.profile.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/disable-operator.mapper.profile.ts @@ -6,7 +6,7 @@ import { DisableOperatorData, DisableOperatorDto, DisableOperatorParams, -} from './model/disable-operator.model'; +} from '../user-operator/model/disable-operator.model'; @Injectable() export class DisableOperatorProfile extends AutomapperProfile { diff --git a/packages/apps/human-app/server/src/modules/user-operator/enable-operator.mapper.profile.ts b/packages/apps/human-app/server/src/modules/user-operator/enable-operator.mapper.profile.ts new file mode 100644 index 0000000000..231a8df27c --- /dev/null +++ b/packages/apps/human-app/server/src/modules/user-operator/enable-operator.mapper.profile.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { createMap, forMember, Mapper, mapWith } from '@automapper/core'; +import { + EnableOperatorCommand, + EnableOperatorData, + EnableOperatorDto, + EnableOperatorParams, +} from '../user-operator/model/enable-operator.model'; + +@Injectable() +export class EnableOperatorProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap( + mapper, + EnableOperatorDto, + EnableOperatorCommand, + forMember( + (destination) => destination.data, + mapWith(EnableOperatorParams, EnableOperatorDto, (source) => source), + ), + ); + createMap(mapper, EnableOperatorDto, EnableOperatorParams); + createMap(mapper, EnableOperatorParams, EnableOperatorData); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/disable-operator/model/disable-operator.model.ts b/packages/apps/human-app/server/src/modules/user-operator/model/disable-operator.model.ts similarity index 100% rename from packages/apps/human-app/server/src/modules/disable-operator/model/disable-operator.model.ts rename to packages/apps/human-app/server/src/modules/user-operator/model/disable-operator.model.ts diff --git a/packages/apps/human-app/server/src/modules/user-operator/model/enable-operator.model.ts b/packages/apps/human-app/server/src/modules/user-operator/model/enable-operator.model.ts new file mode 100644 index 0000000000..aff624e161 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/user-operator/model/enable-operator.model.ts @@ -0,0 +1,25 @@ +import { AutoMap } from '@automapper/classes'; +import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class EnableOperatorDto { + @AutoMap() + @IsString() + @ApiProperty({ example: 'string' }) + signature: string; +} + +export class EnableOperatorParams { + @AutoMap() + signature: string; +} +export class EnableOperatorCommand { + @AutoMap() + data: EnableOperatorParams; + token: string; +} + +export class EnableOperatorData { + @AutoMap() + signature: string; +} diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts index 07c0d5c111..563b3842ce 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.controller.ts @@ -1,14 +1,18 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; import { Body, Controller, + HttpCode, Post, UsePipes, ValidationPipe, } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { Authorization } from '../../common/config/params-decorators'; + import { OperatorService } from './operator.service'; -import { Mapper } from '@automapper/core'; -import { InjectMapper } from '@automapper/nestjs'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { SignupOperatorCommand, SignupOperatorDto, @@ -18,6 +22,14 @@ import { SigninOperatorDto, SigninOperatorResponse, } from './model/operator-signin.model'; +import { + DisableOperatorCommand, + DisableOperatorDto, +} from './model/disable-operator.model'; +import { + EnableOperatorCommand, + EnableOperatorDto, +} from './model/enable-operator.model'; @Controller() export class OperatorController { @@ -27,9 +39,10 @@ export class OperatorController { ) {} @ApiTags('User-Operator') @Post('/auth/web3/signup') + @HttpCode(200) @ApiOperation({ summary: 'Operator signup' }) @UsePipes(new ValidationPipe()) - public signupOperator( + async signupOperator( @Body() signupOperatorDto: SignupOperatorDto, ): Promise { const signupOperatorCommand = this.mapper.map( @@ -37,14 +50,15 @@ export class OperatorController { SignupOperatorDto, SignupOperatorCommand, ); - return this.service.signupOperator(signupOperatorCommand); + await this.service.signupOperator(signupOperatorCommand); } @ApiTags('User-Operator') @Post('/auth/web3/signin') + @HttpCode(200) @ApiOperation({ summary: 'Operator signin' }) @UsePipes(new ValidationPipe()) - public signinOperator( + async signinOperator( @Body() dto: SigninOperatorDto, ): Promise { const command = this.mapper.map( @@ -54,4 +68,46 @@ export class OperatorController { ); return this.service.signinOperator(command); } + + @ApiTags('User-Operator') + @Post('/disable-operator') + @HttpCode(200) + @ApiOperation({ + summary: 'Endpoint to disable an operator', + }) + @ApiBearerAuth() + @UsePipes(new ValidationPipe()) + async disableOperator( + @Body() disableOperatorDto: DisableOperatorDto, + @Authorization() token: string, + ): Promise { + const disableOperatorCommand = this.mapper.map( + disableOperatorDto, + DisableOperatorDto, + DisableOperatorCommand, + ); + disableOperatorCommand.token = token; + await this.service.disableOperator(disableOperatorCommand); + } + + @ApiTags('User-Operator') + @Post('/enable-operator') + @HttpCode(200) + @ApiOperation({ + summary: 'Endpoint to enable an operator', + }) + @ApiBearerAuth() + @UsePipes(new ValidationPipe()) + async enable( + @Body() enableOperatorDto: EnableOperatorDto, + @Authorization() token: string, + ): Promise { + const enableOperatorCommand = this.mapper.map( + enableOperatorDto, + EnableOperatorDto, + EnableOperatorCommand, + ); + enableOperatorCommand.token = token; + await this.service.enableOperator(enableOperatorCommand); + } } diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts index 45c0630efe..6e60c4fa21 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.module.ts @@ -1,11 +1,20 @@ -import { OperatorService } from './operator.service'; +import { Module } from '@nestjs/common'; + import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; + +import { DisableOperatorProfile } from './disable-operator.mapper.profile'; +import { EnableOperatorProfile } from './enable-operator.mapper.profile'; +import { OperatorService } from './operator.service'; import { OperatorProfile } from './operator.mapper.profile'; -import { Module } from '@nestjs/common'; @Module({ imports: [ReputationOracleModule], - providers: [OperatorService, OperatorProfile], + providers: [ + OperatorService, + OperatorProfile, + DisableOperatorProfile, + EnableOperatorProfile, + ], exports: [OperatorService], }) export class OperatorModule {} diff --git a/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts b/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts index 38d64ab1fd..5e41cad8c1 100644 --- a/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/operator.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { SignupOperatorCommand } from './model/operator-registration.model'; + import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; + import { SigninOperatorCommand } from './model/operator-signin.model'; +import { SignupOperatorCommand } from './model/operator-registration.model'; +import { DisableOperatorCommand } from './model/disable-operator.model'; +import { EnableOperatorCommand } from './model/enable-operator.model'; @Injectable() export class OperatorService { @@ -14,4 +18,12 @@ export class OperatorService { signinOperator(command: SigninOperatorCommand) { return this.gateway.sendOperatorSignin(command); } + + async disableOperator(command: DisableOperatorCommand): Promise { + await this.gateway.sendDisableOperator(command); + } + + async enableOperator(command: EnableOperatorCommand): Promise { + await this.gateway.sendEnableOperator(command); + } } diff --git a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.fixtures.ts b/packages/apps/human-app/server/src/modules/user-operator/spec/disable-operator.fixtures.ts similarity index 83% rename from packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.fixtures.ts rename to packages/apps/human-app/server/src/modules/user-operator/spec/disable-operator.fixtures.ts index 4ea7616f00..82255e3ea2 100644 --- a/packages/apps/human-app/server/src/modules/disable-operator/spec/disable-operator.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/user-operator/spec/disable-operator.fixtures.ts @@ -1,7 +1,6 @@ import { DisableOperatorCommand, DisableOperatorData, - DisableOperatorDto, DisableOperatorParams, } from '../model/disable-operator.model'; @@ -10,10 +9,6 @@ const TOKEN = 'test_user_token'; export const disableOperatorTokenFixture = TOKEN; -export const disableOperatorDtoFixture: DisableOperatorDto = { - signature: SIGNATURE, -}; - export const disableOperatorParamsFixture: DisableOperatorParams = { signature: SIGNATURE, }; diff --git a/packages/apps/job-launcher/client/package.json b/packages/apps/job-launcher/client/package.json index 0782ecf949..149b62c18e 100644 --- a/packages/apps/job-launcher/client/package.json +++ b/packages/apps/job-launcher/client/package.json @@ -7,7 +7,7 @@ "@emotion/styled": "^11.10.5", "@hcaptcha/react-hcaptcha": "^1.10.1", "@human-protocol/sdk": "*", - "@mui/icons-material": "^6.4.6", + "@mui/icons-material": "^7.0.1", "@mui/lab": "^5.0.0-alpha.141", "@mui/material": "^5.16.7", "@mui/x-date-pickers": "^7.23.6", @@ -73,7 +73,7 @@ "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^5.1.0", "resize-observer-polyfill": "^1.5.1", - "vite": "^6.2.0", + "vite": "^6.2.4", "vite-plugin-node-polyfills": "^0.22.0" }, "lint-staged": { diff --git a/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx b/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx index 883fb28ebd..2f37342cc2 100644 --- a/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx +++ b/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx @@ -84,11 +84,11 @@ const BillingDetailsModal = ({ } }); - if (!formData?.vat) { - newErrors.vat = 'Tax ID required'; + if (formData?.vat && !formData?.vatType) { + newErrors.vatType = 'Tax ID Type is required'; } - if (!formData?.vatType) { - newErrors.vatType = 'Tax ID type required'; + if (formData?.vatType && !formData?.vat) { + newErrors.vat = 'Tax ID is required'; } setErrors(newErrors); @@ -100,8 +100,16 @@ const BillingDetailsModal = ({ if (validateForm()) { setIsLoading(true); try { + if (formData.vat === '') { + delete formData.vat; + } + if (formData.vatType === '') { + delete formData.vatType; + } + const email = formData?.email; delete formData?.email; + await editUserBillingInfo(formData); setBillingInfo({ ...formData, email }); } catch (err: any) { @@ -217,6 +225,7 @@ const BillingDetailsModal = ({ error={!!errors.vatType} helperText={errors.vatType || ''} > + None {Object.entries(vatTypeOptions).map(([key, label]) => ( {label} diff --git a/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx b/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx index c6db6b328b..55fce1c117 100644 --- a/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx +++ b/packages/apps/job-launcher/client/src/components/Headers/AuthHeader.tsx @@ -106,7 +106,7 @@ export const AuthHeader = () => { variant="contained" sx={{ mr: 1 }} onClick={() => { - reset?.(); + reset(); navigate('/jobs/create'); }} > diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/AudinoJobRequestForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/AudinoJobRequestForm.tsx new file mode 100644 index 0000000000..f726f6357a --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/AudinoJobRequestForm.tsx @@ -0,0 +1,700 @@ +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { + Autocomplete, + Box, + Button, + Chip, + FormControl, + FormHelperText, + Grid, + InputAdornment, + InputLabel, + MenuItem, + Select, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useFormik } from 'formik'; +import React, { useEffect, useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, +} from '../../../components/Accordion'; +import { CollectionsFilledIcon } from '../../../components/Icons/CollectionsFilledIcon'; +import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; +import { getQualifications } from '../../../services/qualification'; +import { + AudinoJobType, + AWSRegions, + GCSRegions, + Qualification, + StorageProviders, +} from '../../../types'; +import { mapAudinoFormValues } from './helpers'; +import { AudinoJobRequestValidationSchema } from './schema'; + +export const AudinoJobRequestForm = () => { + const { jobRequest, updateJobRequest, goToPrevStep, goToNextStep } = + useCreateJobPageUI(); + const [expanded, setExpanded] = useState(['panel1']); + const [qualificationsOptions, setQualificationsOptions] = useState< + Qualification[] + >([]); + + const handleChange = + (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => { + if (newExpanded) { + setExpanded([...expanded, panel]); + } else { + setExpanded(expanded.filter((item) => item !== panel)); + } + }; + + const persistedFormValues = mapAudinoFormValues( + jobRequest, + qualificationsOptions, + ); + + const initialValues = { ...persistedFormValues }; + + useEffect(() => { + const fetchData = async () => { + if (jobRequest.chainId !== undefined) { + try { + setQualificationsOptions(await getQualifications(jobRequest.chainId)); + } catch (error) { + console.error('Error fetching data:', error); + } + } + }; + + fetchData(); + }, [jobRequest.chainId]); + + const handleNext = ({ + type, + labels, + description, + qualifications, + dataProvider, + dataRegion, + dataBucketName, + dataPath, + gtProvider, + gtRegion, + gtBucketName, + gtPath, + userGuide, + accuracyTarget, + audioDuration, + segmentDuration, + }: ReturnType) => { + updateJobRequest({ + ...jobRequest, + audinoRequest: { + labels: labels.map((name: string) => ({ name })), + type, + description, + qualifications: (qualifications as Qualification[]).map( + (qualification) => qualification.reference, + ), + data: { + dataset: { + provider: dataProvider, + region: dataRegion, + bucketName: dataBucketName, + path: dataPath, + }, + }, + groundTruth: { + provider: gtProvider, + region: gtRegion, + bucketName: gtBucketName, + path: gtPath, + }, + userGuide, + accuracyTarget, + audioDuration, + segmentDuration, + }, + }); + goToNextStep(); + }; + + const { + errors, + touched, + values, + dirty, + isValid, + handleSubmit, + handleBlur, + setFieldValue, + } = useFormik({ + initialValues, + validationSchema: AudinoJobRequestValidationSchema, + onSubmit: handleNext, + validateOnChange: true, + validateOnMount: true, + }); + + const dataRegions = + values.dataProvider === StorageProviders.AWS ? AWSRegions : GCSRegions; + + const gtRegions = + values.gtProvider === StorageProviders.AWS ? AWSRegions : GCSRegions; + + return ( + +
+ + + + + General + + + + + + + + Type of job + + + + + + + { + const updatedLabels = (newValues as string[]).map( + (label: string) => + label.startsWith('Add: ') + ? label.replace('Add: ', '') + : label, + ); + setFieldValue('labels', updatedLabels); + }} + filterOptions={(options: any, params) => { + const filtered = options; + const { inputValue } = params; + if (inputValue !== '' && !options.includes(inputValue)) { + filtered.push('Add: ' + inputValue); + } + return filtered; + }} + selectOnFocus + onBlur={handleBlur} + handleHomeEndKeys + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + + + )} + /> + {errors.labels && ( + + {errors.labels} + + )} + + + + + + setFieldValue('description', e.target.value) + } + onBlur={handleBlur} + placeholder="Description" + label="Description" + error={touched.description && Boolean(errors.description)} + helperText={errors.description} + multiline + rows={4} + /> + + + + + + + Qualifications + + + + + + + + + option.title} + value={values.qualifications} + onChange={(event, newValues) => { + setFieldValue('qualifications', newValues); + }} + selectOnFocus + onBlur={handleBlur} + handleHomeEndKeys + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + + + )} + /> + + + + + + + + + + + Job annotation details + + + + + + + + Datasets + + + + + + Storage Provider + + + + + + + + Region + + + {errors.dataRegion && ( + + {errors.dataRegion} + + )} + + + + + + setFieldValue('dataBucketName', e.target.value) + } + error={ + touched.dataBucketName && Boolean(errors.dataBucketName) + } + helperText={errors.dataBucketName} + /> + + + + + + setFieldValue('dataPath', e.target.value) + } + error={touched.dataPath && Boolean(errors.dataPath)} + helperText={errors.dataPath} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + + + Ground truth + + + + + + + + + + Storage Provider + + + + + + + + Region + + + {errors.gtRegion && ( + + {errors.gtRegion} + + )} + + + + + + setFieldValue('gtBucketName', e.target.value) + } + error={ + touched.gtBucketName && Boolean(errors.gtBucketName) + } + helperText={errors.gtBucketName} + /> + + + + + setFieldValue('gtPath', e.target.value)} + onBlur={handleBlur} + error={touched.gtPath && Boolean(errors.gtPath)} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + + + setFieldValue('userGuide', e.target.value) + } + onBlur={handleBlur} + placeholder="Annotator's guideline for data labeling" + label="User Guide URL" + error={touched.userGuide && Boolean(errors.userGuide)} + helperText={errors.userGuide} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + setFieldValue('accuracyTarget', e.target.value) + } + onBlur={handleBlur} + error={ + touched.accuracyTarget && Boolean(errors.accuracyTarget) + } + helperText={errors.accuracyTarget} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + + + setFieldValue('audioDuration', e.target.value) + } + onBlur={handleBlur} + placeholder="Audio duration" + label="Audio duration (seconds)" + error={ + touched.audioDuration && Boolean(errors.audioDuration) + } + helperText={errors.audioDuration} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + setFieldValue('segmentDuration', e.target.value) + } + onBlur={handleBlur} + error={ + touched.segmentDuration && + Boolean(errors.segmentDuration) + } + helperText={errors.segmentDuration} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + + + + + + + + + +
+
+ ); +}; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx index 36b4732a7a..a019830c0b 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CreateJob.tsx @@ -6,6 +6,7 @@ import { NetworkSelect } from '../../../components/NetworkSelect'; import { IS_MAINNET } from '../../../constants/chains'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { JobType, PayMethod } from '../../../types'; +import { AudinoJobRequestForm } from './AudinoJobRequestForm'; import { CvatJobRequestForm } from './CvatJobRequestForm'; import { FortuneJobRequestForm } from './FortuneJobRequestForm'; import { HCaptchaJobRequestForm } from './HCaptchaJobRequestForm'; @@ -55,35 +56,37 @@ export const CreateJob = () => { }} value={jobRequest.jobType} onChange={(e) => - updateJobRequest?.({ + updateJobRequest({ ...jobRequest, jobType: e.target.value as JobType, }) } > {!IS_MAINNET && ( - Fortune + Fortune )} CVAT {/* {!IS_MAINNET && ( hCaptcha )} */} + {!IS_MAINNET && Audino} - updateJobRequest?.({ + updateJobRequest({ ...jobRequest, chainId: e.target.value as ChainId, }) } /> - {jobRequest.jobType === JobType.Fortune && } + {jobRequest.jobType === JobType.FORTUNE && } {jobRequest.jobType === JobType.CVAT && } {jobRequest.jobType === JobType.HCAPTCHA && } + {jobRequest.jobType === JobType.AUDINO && } ); }; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx index 0777ddf7e4..15af1384f6 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CryptoPayForm.tsx @@ -205,6 +205,8 @@ export const CryptoPayForm = ({ hash, confirmations: Number(import.meta.env.VITE_APP_MIN_CONFIRMATIONS) ?? 1, + retryCount: 10, + retryDelay: ({ count }) => Math.min(1000 * 2 ** count, 30000), }); // create crypto payment record @@ -221,8 +223,9 @@ export const CryptoPayForm = ({ fortuneRequest, cvatRequest, hCaptchaRequest, + audinoRequest, } = jobRequest; - if (jobType === JobType.Fortune && fortuneRequest) { + if (jobType === JobType.FORTUNE && fortuneRequest) { await jobService.createFortuneJob( chainId, fortuneRequest, @@ -240,6 +243,14 @@ export const CryptoPayForm = ({ ); } else if (jobType === JobType.HCAPTCHA && hCaptchaRequest) { await jobService.createHCaptchaJob(chainId, hCaptchaRequest); + } else if (jobType === JobType.AUDINO && audinoRequest) { + await jobService.createAudinoJob( + chainId, + audinoRequest, + paymentTokenSymbol, + Number(amount), + fundTokenSymbol, + ); } onFinish(); } catch (err) { @@ -256,7 +267,7 @@ export const CryptoPayForm = ({ You are on wrong network, please switch to{' '} {NETWORKS[jobRequest.chainId!]?.title}.
- @@ -492,7 +503,7 @@ export const CryptoPayForm = ({ variant="outlined" sx={{ width: '240px', ml: 4 }} size="large" - onClick={() => goToPrevStep?.()} + onClick={goToPrevStep} > Cancel diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx index e8ae3643e7..10c649ee37 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/CvatJobRequestForm.tsx @@ -34,6 +34,7 @@ import { Qualification, StorageProviders, } from '../../../types'; +import { mapCvatFormValues } from './helpers'; import { CvatJobRequestValidationSchema, dataValidationSchema } from './schema'; export const CvatJobRequestForm = () => { @@ -44,28 +45,16 @@ export const CvatJobRequestForm = () => { const [qualificationsOptions, setQualificationsOptions] = useState< Qualification[] >([]); + const [updatedValidationSchema, setUpdatedValidationSchema] = useState( + CvatJobRequestValidationSchema, + ); - const initialValues = { - labels: [], - nodes: [], - type: CvatJobType.IMAGE_BOXES, - description: '', - qualifications: [], - userGuide: '', - accuracyTarget: 80, - dataProvider: StorageProviders.AWS, - dataRegion: '', - dataBucketName: '', - dataPath: '', - bpProvider: StorageProviders.AWS, - bpRegion: '', - bpBucketName: '', - bpPath: '', - gtProvider: StorageProviders.AWS, - gtRegion: '', - gtBucketName: '', - gtPath: '', - }; + const persistedFormValues = mapCvatFormValues( + jobRequest, + qualificationsOptions, + ); + + const initialValues = { ...persistedFormValues }; useEffect(() => { const fetchData = async () => { @@ -73,7 +62,6 @@ export const CvatJobRequestForm = () => { try { setQualificationsOptions(await getQualifications(jobRequest.chainId)); } catch (error) { - // eslint-disable-next-line no-console console.error('Error fetching data:', error); } } @@ -111,9 +99,9 @@ export const CvatJobRequestForm = () => { gtPath, userGuide, accuracyTarget, - }: any) => { + }: ReturnType) => { let bp = undefined; - if (type === CvatJobType.IMAGE_BOXES_FROM_POINTS) + if (type === CvatJobType.IMAGE_BOXES_FROM_POINTS) { bp = { points: { provider: bpProvider, @@ -122,7 +110,7 @@ export const CvatJobRequestForm = () => { path: bpPath, }, }; - else if (type === CvatJobType.IMAGE_SKELETONS_FROM_BOXES) + } else if (type === CvatJobType.IMAGE_SKELETONS_FROM_BOXES) { bp = { boxes: { provider: bpProvider, @@ -131,12 +119,17 @@ export const CvatJobRequestForm = () => { path: bpPath, }, }; + } + const labelArray: Label[] = labels.map((name: string) => { - if (type === CvatJobType.IMAGE_SKELETONS_FROM_BOXES) - return { name: name, nodes: nodes }; - else return { name: name }; + if (type === CvatJobType.IMAGE_SKELETONS_FROM_BOXES) { + return { name, nodes }; + } + + return { name }; }); - updateJobRequest?.({ + + updateJobRequest({ ...jobRequest, cvatRequest: { labels: labelArray, @@ -164,13 +157,9 @@ export const CvatJobRequestForm = () => { accuracyTarget, }, }); - goToNextStep?.(); + goToNextStep(); }; - const [updatedValidationSchema, setUpdatedValidationSchema] = useState( - CvatJobRequestValidationSchema, - ); - const { errors, touched, @@ -729,9 +718,9 @@ export const CvatJobRequestForm = () => { ))} - {errors.dataRegion && ( + {errors.gtRegion && ( - {errors.dataRegion} + {errors.gtRegion} )} @@ -855,8 +844,8 @@ export const CvatJobRequestForm = () => { diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FortuneJobRequestForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FortuneJobRequestForm.tsx index 1ade5f91fb..896d1f27ae 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FortuneJobRequestForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FortuneJobRequestForm.tsx @@ -11,7 +11,7 @@ import { Typography, } from '@mui/material'; import { Formik } from 'formik'; -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Accordion, AccordionDetails, @@ -21,22 +21,21 @@ import { CollectionsFilledIcon } from '../../../components/Icons/CollectionsFill import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { getQualifications } from '../../../services/qualification'; import { Qualification } from '../../../types'; +import { mapFortuneFormValues } from './helpers'; import { FortuneJobRequestValidationSchema } from './schema'; export const FortuneJobRequestForm = () => { const { jobRequest, updateJobRequest, goToPrevStep, goToNextStep } = useCreateJobPageUI(); - const [expanded, setExpanded] = useState('panel1'); + const [isExpanded, setIsExpanded] = useState(true); const [qualificationsOptions, setQualificationsOptions] = useState< Qualification[] >([]); - const initialValues = { - title: '', - fortunesRequested: undefined, - description: '', - qualifications: [], - }; + const persistedFormValues = mapFortuneFormValues( + jobRequest, + qualificationsOptions, + ); useEffect(() => { const fetchData = async () => { @@ -44,7 +43,6 @@ export const FortuneJobRequestForm = () => { try { setQualificationsOptions(await getQualifications(jobRequest.chainId)); } catch (error) { - // eslint-disable-next-line no-console console.error('Error fetching data:', error); } } @@ -53,18 +51,13 @@ export const FortuneJobRequestForm = () => { fetchData(); }, [jobRequest.chainId]); - const handleChange = - (panel: string) => (event: React.SyntheticEvent, newExpanded: boolean) => { - setExpanded(newExpanded ? panel : false); - }; - const handleNext = ({ title, fortunesRequested, description, qualifications, - }: any) => { - updateJobRequest?.({ + }: ReturnType) => { + updateJobRequest({ ...jobRequest, fortuneRequest: { title, @@ -75,13 +68,13 @@ export const FortuneJobRequestForm = () => { ), }, }); - goToNextStep?.(); + goToNextStep(); }; return ( @@ -97,8 +90,8 @@ export const FortuneJobRequestForm = () => { }) => (
setIsExpanded((prevState) => !prevState)} > { + @@ -729,8 +723,8 @@ export const HCaptchaJobRequestForm = () => { ); }; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/LaunchSuccess.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/LaunchSuccess.tsx index c909585cea..0b6f419c54 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/LaunchSuccess.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/LaunchSuccess.tsx @@ -34,7 +34,7 @@ export const LaunchSuccess = () => { variant="contained" color="primary" sx={{ width: 240 }} - onClick={() => reset?.()} + onClick={reset} > Add New Job diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx index e6833868df..a7a4dfc09e 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx @@ -4,27 +4,33 @@ import { StyledTab, StyledTabs } from '../../../components/Tabs'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { useSnackbar } from '../../../providers/SnackProvider'; import { useAppSelector } from '../../../state'; -import { PayMethod } from '../../../types'; +import { PayingStatus, PayMethod } from '../../../types'; import { CryptoPayForm } from './CryptoPayForm'; import { FiatPayForm } from './FiatPayForm'; import { LaunchJobProgress } from './LaunchJobProgress'; export const PayJob = () => { - const { payMethod, changePayMethod, goToNextStep } = useCreateJobPageUI(); - const [isPaying, setIsPaying] = useState(false); + const { payMethod, changePayMethod, goToNextStep, goToPrevStep } = + useCreateJobPageUI(); + const [payingStatus, setPayingStatus] = useState( + PayingStatus.Idle, + ); const { showError } = useSnackbar(); const { user } = useAppSelector((state) => state.auth); + const isIdle = payingStatus === PayingStatus.Idle; + const handleStart = () => { - setIsPaying(true); + setPayingStatus(PayingStatus.Pending); }; const handleFinish = () => { - setIsPaying(false); - goToNextStep?.(); + setPayingStatus(PayingStatus.Success); + goToNextStep(); }; const handleError = (err: any) => { + setPayingStatus(PayingStatus.Error); if (err.code === 'UNPREDICTABLE_GAS_LIMIT') { showError('Insufficient token amount or the gas limit is too low'); } else if (err.code === 'ACTION_REJECTED') { @@ -34,7 +40,7 @@ export const PayJob = () => { } }; - return !isPaying ? ( + return isIdle ? ( { > changePayMethod?.(newValue)} + onChange={(e, newValue) => changePayMethod(newValue)} sx={{ '& .MuiTabs-indicator': { display: 'none', @@ -99,6 +105,9 @@ export const PayJob = () => { ) : ( - + ); }; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts b/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts new file mode 100644 index 0000000000..ef5ba26270 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/helpers.ts @@ -0,0 +1,107 @@ +import { + JobRequest, + Qualification, + CvatJobType, + StorageProviders, + GCSRegions, + AWSRegions, + AudinoJobType, +} from '../../../types'; + +export const mapCvatFormValues = ( + jobRequest: JobRequest, + qualificationsOptions: Qualification[], +) => { + const { cvatRequest } = jobRequest; + return { + labels: cvatRequest?.labels?.map((label) => label.name) || [], + nodes: cvatRequest?.labels?.[0]?.nodes || [], + type: cvatRequest?.type || CvatJobType.IMAGE_BOXES, + description: cvatRequest?.description || '', + qualifications: cvatRequest?.qualifications + ? qualificationsOptions.filter((q: Qualification) => + cvatRequest?.qualifications?.includes(q.reference), + ) + : [], + userGuide: cvatRequest?.userGuide || '', + accuracyTarget: cvatRequest?.accuracyTarget || 80, + dataProvider: cvatRequest?.data?.dataset?.provider || StorageProviders.AWS, + dataRegion: + (cvatRequest?.data?.dataset?.region as AWSRegions | GCSRegions) || '', + dataBucketName: cvatRequest?.data?.dataset?.bucketName || '', + dataPath: cvatRequest?.data?.dataset?.path || '', + bpProvider: + cvatRequest?.data?.points?.provider || + cvatRequest?.data?.boxes?.provider || + StorageProviders.AWS, + bpRegion: + cvatRequest?.data?.points?.region || + (cvatRequest?.data?.boxes?.region as AWSRegions | GCSRegions) || + '', + bpBucketName: + cvatRequest?.data?.points?.bucketName || + cvatRequest?.data?.boxes?.bucketName || + '', + bpPath: + cvatRequest?.data?.points?.path || cvatRequest?.data?.boxes?.path || '', + gtProvider: cvatRequest?.groundTruth?.provider || StorageProviders.AWS, + gtRegion: + (cvatRequest?.groundTruth?.region as AWSRegions | GCSRegions) || '', + gtBucketName: cvatRequest?.groundTruth?.bucketName || '', + gtPath: cvatRequest?.groundTruth?.path || '', + }; +}; + +export const mapFortuneFormValues = ( + jobRequest: JobRequest, + qualificationsOptions: Qualification[], +) => { + const { fortuneRequest } = jobRequest; + return { + title: fortuneRequest?.title || '', + fortunesRequested: fortuneRequest?.fortunesRequested || 0, + description: fortuneRequest?.description || '', + qualifications: fortuneRequest?.qualifications + ? qualificationsOptions.filter((q: Qualification) => + fortuneRequest?.qualifications?.includes(q.reference), + ) + : [], + }; +}; + +export const mapAudinoFormValues = ( + jobRequest: JobRequest, + qualificationsOptions: Qualification[], +) => { + const { audinoRequest } = jobRequest; + + return { + type: audinoRequest?.type || AudinoJobType.AUDIO_TRANSCRIPTION, + labels: audinoRequest?.labels?.map((label) => label.name) || [], + description: audinoRequest?.description || '', + qualifications: audinoRequest?.qualifications + ? qualificationsOptions.filter((q: Qualification) => + audinoRequest?.qualifications?.includes(q.reference), + ) + : [], + + dataProvider: + audinoRequest?.data?.dataset?.provider || StorageProviders.AWS, + dataRegion: + (audinoRequest?.data?.dataset?.region as AWSRegions | GCSRegions) || '', + dataBucketName: audinoRequest?.data?.dataset?.bucketName || '', + dataPath: audinoRequest?.data?.dataset?.path || '', + + gtProvider: audinoRequest?.groundTruth?.provider || StorageProviders.AWS, + gtRegion: + (audinoRequest?.groundTruth?.region as AWSRegions | GCSRegions) || '', + gtBucketName: audinoRequest?.groundTruth?.bucketName || '', + gtPath: audinoRequest?.groundTruth?.path || '', + + userGuide: audinoRequest?.userGuide || '', + accuracyTarget: audinoRequest?.accuracyTarget || 50, + + audioDuration: audinoRequest?.audioDuration || 0, + segmentDuration: audinoRequest?.segmentDuration || 0, + }; +}; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/index.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/index.tsx index ebb6db6df2..ed198d9630 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/index.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/index.tsx @@ -8,27 +8,25 @@ import { FundingMethod } from './FundingMethod'; import { LaunchSuccess } from './LaunchSuccess'; import { PayJob } from './PayJob'; +const supportedJobTypes = Object.values(JobType); + +function isSupportedJobType(jobType: string): jobType is JobType { + return supportedJobTypes.includes(jobType as JobType); +} + export const CreateJobView = () => { const { step, changePayMethod, setStep, updateJobRequest } = useCreateJobPageUI(); const [searchParams] = useSearchParams(); useEffect(() => { - const jobType = searchParams.get('jobType'); - const supportedJobTypes = ['fortune', 'cvat', 'hcaptcha']; + const jobType = searchParams.get('jobType') || ''; - if (jobType && supportedJobTypes.includes(jobType.toLowerCase())) { - changePayMethod?.(PayMethod.Fiat); + if (isSupportedJobType(jobType)) { + changePayMethod(PayMethod.Fiat); setStep(CreateJobStep.CreateJob); - updateJobRequest?.({ - jobType: - jobType === 'fortune' - ? JobType.Fortune - : jobType === 'cvat' - ? JobType.CVAT - : JobType.HCAPTCHA, - }); + updateJobRequest({ jobType }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts b/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts index 827358218a..64ac3b1e97 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/schema.ts @@ -89,3 +89,38 @@ export const HCaptchaJobRequesteValidationSchema = Yup.object().shape({ images: Yup.array().of(Yup.string().url('Invalid Image URL')), qualifications: Yup.array().of(Yup.object()), }); + +export const AudinoJobRequestValidationSchema = Yup.object().shape({ + labels: Yup.array().of(Yup.string()).min(1, 'At least one label is required'), + description: Yup.string().required('Description is required'), + dataProvider: Yup.string().required('Provider is required'), + dataRegion: Yup.string().required('Region is required'), + dataBucketName: Yup.string().required('Bucket name is required'), + dataPath: Yup.string().optional(), + gtProvider: Yup.string().required('Provider is required'), + gtRegion: Yup.string().required('Region is required'), + gtBucketName: Yup.string().required('Bucket name is required'), + gtPath: Yup.string().optional(), + userGuide: Yup.string() + .required('User Guide URL is required') + .url('Invalid URL'), + accuracyTarget: Yup.number() + .required('Accuracy target is required') + .moreThan(0, 'Accuracy target must be greater than 0') + .max(100, 'Accuracy target must be less than or equal to 100'), + qualifications: Yup.array().of(Yup.object()), + + audioDuration: Yup.number() + .required('Audio duration is required') + .moreThan(0, 'Audio duration must be greater than 0') + .max(31536000, 'Audio duration must be less than or equal to 31536000'), // one year in seconds + segmentDuration: Yup.number() + .required('Segment duration is required') + .moreThan(0, 'Segment duration must be greater than 0') + .when('$audioDuration', ([audioDuration], schema) => { + return schema.max( + audioDuration * 1000, + 'Segment duration should not exceed audio duration', + ); + }), +}); diff --git a/packages/apps/job-launcher/client/src/components/Payment/PaymentTable.tsx b/packages/apps/job-launcher/client/src/components/Payment/PaymentTable.tsx index ae784fc6a7..abceef1b5c 100644 --- a/packages/apps/job-launcher/client/src/components/Payment/PaymentTable.tsx +++ b/packages/apps/job-launcher/client/src/components/Payment/PaymentTable.tsx @@ -3,11 +3,9 @@ import CurrencyExchangeOutlinedIcon from '@mui/icons-material/CurrencyExchangeOu import DownloadIcon from '@mui/icons-material/Download'; import MoneyOffCsredOutlinedIcon from '@mui/icons-material/MoneyOffCsredOutlined'; import SaveAltOutlinedIcon from '@mui/icons-material/SaveAltOutlined'; -import { Box, Chip, IconButton, Typography } from '@mui/material'; -import copy from 'copy-to-clipboard'; +import { Box, Chip, IconButton, Link, Typography } from '@mui/material'; import { useState } from 'react'; import CreditCardFilledIcon from '../../assets/CreditCardFilled.svg'; -import { CopyLinkIcon } from '../../components/Icons/CopyLinkIcon'; import { Table } from '../../components/Table'; import { paymentSource, paymentType } from '../../constants/payment'; import { usePayments } from '../../hooks/usePayments'; @@ -93,10 +91,15 @@ export const PaymentTable = ({ rows = 10 }: { rows?: number }) => { }, { id: 'amount', - label: 'Amount USD', + label: 'Amount', sortable: true, - render: ({ amount, rate }) => - `$${Number(amount) > 0 ? Number(amount * rate).toFixed(2) : (Number(amount * rate) * -1).toFixed(2)}`, + render: ({ amount, rate, currency }) => ( + 0 ? 'green' : 'red'}> + {currency.toUpperCase() !== 'USD' + ? `${Number(amount) > 0 ? '+' : ''}${Number(amount).toFixed(2)} ${currency.toUpperCase()} (${(Number(amount) * rate).toFixed(2)} USD)` + : `${Number(amount) > 0 ? '+' : ''}${Number(amount).toFixed(2)} ${currency.toUpperCase()}`} + + ), }, { id: 'type', @@ -171,27 +174,24 @@ export const PaymentTable = ({ rows = 10 }: { rows?: number }) => { ), }, { - id: 'escrowAddress', - label: 'Escrow Address', + id: 'jobId', + label: 'Job ID', sortable: false, - render: ({ escrowAddress }) => - escrowAddress ? ( - - {escrowAddress} - ( + + {jobId ? ( + copy(escrowAddress)} + sx={{ textDecoration: 'underline' }} > - - - - ) : ( - - {escrowAddress} - - - - ), + {jobId} + + ) : ( + '-' + )} + + ), }, { id: 'status', diff --git a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx index 3333630221..b7b5a2148c 100644 --- a/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx +++ b/packages/apps/job-launcher/client/src/components/TopUpAccount/CryptoTopUpForm.tsx @@ -89,6 +89,8 @@ export const CryptoTopUpForm = () => { await publicClient?.waitForTransactionReceipt({ hash: transactionHash, confirmations: Number(import.meta.env.VITE_APP_MIN_CONFIRMATIONS) ?? 1, + retryCount: 10, + retryDelay: ({ count }) => Math.min(1000 * 2 ** count, 30000), }); // create crypto payment record diff --git a/packages/apps/job-launcher/client/src/providers/CreateJobPageUIProvider.tsx b/packages/apps/job-launcher/client/src/providers/CreateJobPageUIProvider.tsx index 16048ee5c8..7af09b5cae 100644 --- a/packages/apps/job-launcher/client/src/providers/CreateJobPageUIProvider.tsx +++ b/packages/apps/job-launcher/client/src/providers/CreateJobPageUIProvider.tsx @@ -8,25 +8,27 @@ export type CreateJobPageUIType = { step: CreateJobStep; payMethod: PayMethod; jobRequest: JobRequest; - reset?: () => void; - changePayMethod?: (method: PayMethod) => void; - updateJobRequest?: (jobRequest: JobRequest) => void; - goToPrevStep?: () => void; - goToNextStep?: () => void; + reset: () => void; + changePayMethod: (method: PayMethod) => void; + updateJobRequest: (jobRequest: JobRequest) => void; + goToPrevStep: () => void; + goToNextStep: () => void; setStep: (step: CreateJobStep) => void; }; -const initialData: Omit< - CreateJobPageUIType, - 'changePayMethod' | 'goToNextStep' -> = { +const initialData: CreateJobPageUIType = { step: CreateJobStep.FundingMethod, payMethod: PayMethod.Crypto, jobRequest: { - jobType: JobType.Fortune, + jobType: JobType.FORTUNE, chainId: undefined, }, - setStep: () => {}, + reset: () => {}, + changePayMethod: (_) => {}, + updateJobRequest: (_) => {}, + goToPrevStep: () => {}, + goToNextStep: () => {}, + setStep: (_) => {}, }; export const CreateJobPageUIContext = @@ -43,7 +45,7 @@ export const CreateJobPageUIProvider = ({ const [step, setStep] = useState(CreateJobStep.FundingMethod); const [payMethod, setPayMethod] = useState(PayMethod.Crypto); const [jobRequest, setJobRequest] = useState({ - jobType: IS_MAINNET ? JobType.CVAT : JobType.Fortune, + jobType: IS_MAINNET ? JobType.CVAT : JobType.FORTUNE, chainId: chain?.id && SUPPORTED_CHAIN_IDS.includes(chain?.id) ? chain?.id @@ -55,11 +57,15 @@ export const CreateJobPageUIProvider = ({ }); const goToPrevStep = () => { - setStep((prev) => prev - 1); + if (step > CreateJobStep.FundingMethod) { + setStep((prev) => prev - 1); + } }; const goToNextStep = () => { - setStep((prev) => prev + 1); + if (step < CreateJobStep.Launch) { + setStep((prev) => prev + 1); + } }; const changePayMethod = (method: PayMethod) => setPayMethod(method); @@ -71,7 +77,7 @@ export const CreateJobPageUIProvider = ({ setStep(CreateJobStep.FundingMethod); setPayMethod(PayMethod.Crypto); setJobRequest({ - jobType: JobType.Fortune, + jobType: JobType.FORTUNE, }); }; diff --git a/packages/apps/job-launcher/client/src/providers/WagmiProvider.tsx b/packages/apps/job-launcher/client/src/providers/WagmiProvider.tsx index a0d72e5061..ad9308ed9e 100644 --- a/packages/apps/job-launcher/client/src/providers/WagmiProvider.tsx +++ b/packages/apps/job-launcher/client/src/providers/WagmiProvider.tsx @@ -1,7 +1,13 @@ import { FC, PropsWithChildren } from 'react'; -import { createConfig, http, WagmiProvider as WWagmiProvider } from 'wagmi'; +import { + createConfig, + WagmiProvider as WWagmiProvider, + fallback, + http, + unstable_connector, +} from 'wagmi'; import * as wagmiChains from 'wagmi/chains'; -import { coinbaseWallet, walletConnect } from 'wagmi/connectors'; +import { coinbaseWallet, walletConnect, metaMask } from 'wagmi/connectors'; import { LOCALHOST } from '../constants/chains'; @@ -31,21 +37,43 @@ export const wagmiConfig = createConfig({ coinbaseWallet({ appName: 'human-job-launcher', }), + metaMask(), ], transports: { - [wagmiChains.mainnet.id]: http(), - [wagmiChains.sepolia.id]: http(), - [wagmiChains.bsc.id]: http(), - [wagmiChains.bscTestnet.id]: http(), - [wagmiChains.polygon.id]: http(), - [wagmiChains.polygonAmoy.id]: http(), - [wagmiChains.moonbeam.id]: http(), - [wagmiChains.moonbaseAlpha.id]: http(), - [wagmiChains.avalanche.id]: http(), - [wagmiChains.avalancheFuji.id]: http(), - [wagmiChains.xLayer.id]: http(), - [wagmiChains.xLayerTestnet.id]: http(), - [LOCALHOST.id]: http(LOCALHOST.rpcUrls.default.http[0]), + [wagmiChains.mainnet.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.sepolia.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.bsc.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.bscTestnet.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [wagmiChains.polygon.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.polygonAmoy.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [wagmiChains.moonbeam.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.moonbaseAlpha.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [wagmiChains.avalanche.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [wagmiChains.avalancheFuji.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [wagmiChains.xLayer.id]: fallback([unstable_connector(metaMask), http()]), + [wagmiChains.xLayerTestnet.id]: fallback([ + unstable_connector(metaMask), + http(), + ]), + [LOCALHOST.id]: fallback([ + unstable_connector(metaMask), + http(LOCALHOST.rpcUrls.default.http[0]), + ]), }, }); diff --git a/packages/apps/job-launcher/client/src/services/job.ts b/packages/apps/job-launcher/client/src/services/job.ts index fae6ea60d4..44025fa127 100644 --- a/packages/apps/job-launcher/client/src/services/job.ts +++ b/packages/apps/job-launcher/client/src/services/job.ts @@ -8,6 +8,8 @@ import { JobDetailsResponse, HCaptchaRequest, FortuneFinalResult, + AudinoRequest, + CreateAudinoJobRequest, } from '../types'; import api from '../utils/api'; import { getFilenameFromContentDisposition } from '../utils/string'; @@ -66,6 +68,33 @@ export const createHCaptchaJob = async ( }); }; +export const createAudinoJob = async ( + chainId: number, + data: AudinoRequest, + paymentCurrency: string, + paymentAmount: number | string, + escrowFundToken: string, +) => { + const body: CreateAudinoJobRequest = { + chainId, + requesterDescription: data.description, + paymentCurrency, + paymentAmount: Number(paymentAmount), + escrowFundToken, + data: data.data, + labels: data.labels, + minQuality: Number(data.accuracyTarget) / 100, + groundTruth: data.groundTruth, + userGuide: data.userGuide, + type: data.type, + qualifications: data.qualifications, + audioDuration: Number(data.audioDuration), + segmentDuration: Number(data.segmentDuration), + }; + + await api.post('/job/audino', body); +}; + export const getJobList = async ({ chainId = ChainId.ALL, status, diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 8a03ce900f..f8ba948d89 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -52,14 +52,31 @@ export type CreateCvatJobRequest = { paymentCurrency: string; paymentAmount: number; escrowFundToken: string; - data: CvatDataSource; - labels: string[]; + data: CvatData; + labels: Label[]; minQuality: number; groundTruth: CvatDataSource; userGuide: string; type: CvatJobType; }; +export type CreateAudinoJobRequest = { + chainId: number; + requesterDescription: string; + qualifications?: string[]; + paymentCurrency: string; + paymentAmount: number; + escrowFundToken: string; + data: AudinoData; + labels: Array<{ name: string }>; + minQuality: number; + groundTruth: AudinoDataSource; + userGuide: string; + type: AudinoJobType; + audioDuration: number; + segmentDuration: number; +}; + export enum CreateJobStep { FundingMethod, CreateJob, @@ -72,10 +89,18 @@ export enum PayMethod { Fiat, } +export enum PayingStatus { + Idle, + Pending, + Success, + Error, +} + export enum JobType { - Fortune, - CVAT, - HCAPTCHA, + FORTUNE = 'fortune', + CVAT = 'cvat', + HCAPTCHA = 'hcaptcha', + AUDINO = 'audino', } export enum CvatJobType { @@ -94,6 +119,10 @@ export enum HCaptchaJobType { COMPARISON = 'comparison', } +export enum AudinoJobType { + AUDIO_TRANSCRIPTION = 'audio_transcription', +} + export type FortuneRequest = { title: string; fortunesRequested: number; @@ -235,12 +264,37 @@ export type HCaptchaRequest = { }; }; +type AudinoDataSource = { + provider: StorageProviders; + region: AWSRegions | GCSRegions; + bucketName: string; + path: string; +}; + +type AudinoData = { + dataset: AudinoDataSource; +}; + +export type AudinoRequest = { + labels: Array<{ name: string }>; + type: AudinoJobType; + description: string; + qualifications?: string[]; + data: AudinoData; + groundTruth: AudinoDataSource; + userGuide: string; + accuracyTarget: number; + audioDuration: number; + segmentDuration: number; +}; + export type JobRequest = { jobType: JobType; chainId?: ChainId; fortuneRequest?: FortuneRequest; cvatRequest?: CvatRequest; hCaptchaRequest?: HCaptchaRequest; + audinoRequest?: AudinoRequest; }; export enum JobStatus { @@ -317,8 +371,8 @@ export type BillingInfo = { name: string; email?: string; address: Address; - vat: string; - vatType: string; + vat?: string; + vatType?: string; }; type Address = { diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index fee89736ac..08b4c7fac6 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -24,6 +24,8 @@ FORTUNE_EXCHANGE_ORACLE_ADDRESS= FORTUNE_RECORDING_ORACLE_ADDRESS= CVAT_EXCHANGE_ORACLE_ADDRESS= CVAT_RECORDING_ORACLE_ADDRESS= +AUDINO_EXCHANGE_ORACLE_ADDRESS= +AUDINO_RECORDING_ORACLE_ADDRESS= REPUTATION_ORACLE_ADDRESS= HCAPTCHA_RECORDING_ORACLE_URI= HCAPTCHA_REPUTATION_ORACLE_URI= @@ -74,7 +76,7 @@ GOOGLE_PROJECT_ID= GOOGLE_PRIVATE_KEY= GOOGLE_CLIENT_EMAIL= GCV_MODERATION_RESULTS_FILES_PATH= -GCS_MODERATION_RESULTS_BUCKET= +GCV_MODERATION_RESULTS_BUCKET= # Slack SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL= \ No newline at end of file diff --git a/packages/apps/job-launcher/server/package.json b/packages/apps/job-launcher/server/package.json index 27e6410504..47cfeacb3f 100644 --- a/packages/apps/job-launcher/server/package.json +++ b/packages/apps/job-launcher/server/package.json @@ -62,7 +62,7 @@ "pg": "8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.2.0", - "stripe": "^17.4.0", + "stripe": "^17.7.0", "typeorm": "^0.3.17", "typeorm-naming-strategies": "^4.1.0", "zxcvbn": "^4.4.2" @@ -71,7 +71,7 @@ "@faker-js/faker": "^9.5.0", "@golevelup/ts-jest": "^0.6.1", "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.13", diff --git a/packages/apps/job-launcher/server/src/common/config/env-schema.ts b/packages/apps/job-launcher/server/src/common/config/env-schema.ts index 25f4f7b4b0..32f15f9d83 100644 --- a/packages/apps/job-launcher/server/src/common/config/env-schema.ts +++ b/packages/apps/job-launcher/server/src/common/config/env-schema.ts @@ -8,7 +8,7 @@ export const envValidator = Joi.object({ FE_URL: Joi.string(), MAX_RETRY_COUNT: Joi.number(), MINIMUM_FEE_USD: Joi.number(), - // ABUSE_AMOUNT: Joi.number(), + ABUSE_AMOUNT: Joi.number(), // Auth JWT_PRIVATE_KEY: Joi.string().required(), JWT_PUBLIC_KEY: Joi.string().required(), @@ -34,6 +34,8 @@ export const envValidator = Joi.object({ FORTUNE_RECORDING_ORACLE_ADDRESS: Joi.string().required(), CVAT_EXCHANGE_ORACLE_ADDRESS: Joi.string().required(), CVAT_RECORDING_ORACLE_ADDRESS: Joi.string().required(), + AUDINO_EXCHANGE_ORACLE_ADDRESS: Joi.string(), + AUDINO_RECORDING_ORACLE_ADDRESS: Joi.string(), HCAPTCHA_RECORDING_ORACLE_URI: Joi.string().required(), HCAPTCHA_REPUTATION_ORACLE_URI: Joi.string().required(), HCAPTCHA_ORACLE_ADDRESS: Joi.string().required(), diff --git a/packages/apps/job-launcher/server/src/common/config/server-config.service.ts b/packages/apps/job-launcher/server/src/common/config/server-config.service.ts index dbd00254f4..50ff98a9c3 100644 --- a/packages/apps/job-launcher/server/src/common/config/server-config.service.ts +++ b/packages/apps/job-launcher/server/src/common/config/server-config.service.ts @@ -70,7 +70,7 @@ export class ServerConfigService { /** * The amount to charge abusive users. */ - // get abuseAmount(): number { - // return +this.configService.get('ABUSE_AMOUNT', 10000); - // } + get abuseAmount(): number { + return +this.configService.get('ABUSE_AMOUNT', 10000); + } } diff --git a/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts b/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts index 3155185641..3104f2a006 100644 --- a/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts +++ b/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts @@ -112,4 +112,22 @@ export class Web3ConfigService { get hCaptchaOracleAddress(): string { return this.configService.getOrThrow('HCAPTCHA_ORACLE_ADDRESS'); } + + /** + * Address of the Audino exchange oracle. + */ + get audinoExchangeOracleAddress(): string { + return this.configService.getOrThrow( + 'AUDINO_EXCHANGE_ORACLE_ADDRESS', + ); + } + + /** + * Address of the Audino recording oracle. + */ + get audinoRecordingOracleAddress(): string { + return this.configService.getOrThrow( + 'AUDINO_RECORDING_ORACLE_ADDRESS', + ); + } } diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 9c3d0fc72d..68ce51748f 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -13,6 +13,7 @@ export enum ErrorJob { JobParamsValidationFailed = 'Job parameters validation failed', InvalidEventType = 'Invalid event type', InvalidStatusCancellation = 'Job has an invalid status for cancellation', + InvalidStatusCompletion = 'Job has an invalid status for completion', NotLaunched = 'Not launched', TaskDataNotFound = 'Task data not found', HCaptchaInvalidJobType = 'hCaptcha invalid job type', @@ -107,6 +108,7 @@ export enum ErrorPayment { InvoiceNotFound = 'Invoice not found', NotSuccess = 'Unsuccessful payment', NotEnoughFunds = 'Not enough funds', + NotDefaultPaymentMethod = 'Default payment method not found', IntentNotCreated = 'Payment intent not created', CardNotAssigned = 'Card not assigned', SetupNotFound = 'Setup not found', diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 81db79f4d8..9f3e0bf9ae 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -35,6 +35,7 @@ export enum JobRequestType { IMAGE_SKELETONS_FROM_BOXES = 'image_skeletons_from_boxes', HCAPTCHA = 'hcaptcha', FORTUNE = 'fortune', + AUDIO_TRANSCRIPTION = 'audio_transcription', } export enum JobCaptchaMode { diff --git a/packages/apps/job-launcher/server/src/common/enums/webhook.ts b/packages/apps/job-launcher/server/src/common/enums/webhook.ts index e8875ac446..df966a7dff 100644 --- a/packages/apps/job-launcher/server/src/common/enums/webhook.ts +++ b/packages/apps/job-launcher/server/src/common/enums/webhook.ts @@ -9,6 +9,7 @@ export enum EventType { export enum OracleType { FORTUNE = 'fortune', CVAT = 'cvat', + AUDINO = 'audino', HCAPTCHA = 'hcaptcha', } diff --git a/packages/apps/job-launcher/server/src/common/utils/storage.ts b/packages/apps/job-launcher/server/src/common/utils/storage.ts index 3efe07783d..91b4aef4c3 100644 --- a/packages/apps/job-launcher/server/src/common/utils/storage.ts +++ b/packages/apps/job-launcher/server/src/common/utils/storage.ts @@ -16,11 +16,14 @@ export function generateBucketUrl( jobType: JobRequestType, ): URL { if ( - (jobType === JobRequestType.IMAGE_POLYGONS || - jobType === JobRequestType.IMAGE_BOXES || - jobType === JobRequestType.IMAGE_POINTS || - jobType === JobRequestType.IMAGE_BOXES_FROM_POINTS || - jobType === JobRequestType.IMAGE_SKELETONS_FROM_BOXES) && + [ + JobRequestType.IMAGE_POLYGONS, + JobRequestType.IMAGE_BOXES, + JobRequestType.IMAGE_POINTS, + JobRequestType.IMAGE_BOXES_FROM_POINTS, + JobRequestType.IMAGE_SKELETONS_FROM_BOXES, + JobRequestType.AUDIO_TRANSCRIPTION, + ].includes(jobType) && storageData.provider != StorageProviders.AWS && storageData.provider != StorageProviders.GCS && storageData.provider != StorageProviders.LOCAL diff --git a/packages/apps/job-launcher/server/src/database/migrations/1743594533813-AudinoJobType.ts b/packages/apps/job-launcher/server/src/database/migrations/1743594533813-AudinoJobType.ts new file mode 100644 index 0000000000..965c19bf35 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1743594533813-AudinoJobType.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AudinoJobType1743594533813 implements MigrationInterface { + name = 'AudinoJobType1743594533813'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_request_type_enum" + RENAME TO "jobs_request_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_request_type_enum" AS ENUM( + 'image_points', + 'image_polygons', + 'image_boxes', + 'image_boxes_from_points', + 'image_skeletons_from_boxes', + 'hcaptcha', + 'fortune', + 'audio_transcription' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "request_type" TYPE "hmt"."jobs_request_type_enum" USING "request_type"::"text"::"hmt"."jobs_request_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_request_type_enum_old" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "hmt"."jobs_request_type_enum_old" AS ENUM( + 'image_points', + 'image_polygons', + 'image_boxes', + 'image_boxes_from_points', + 'image_skeletons_from_boxes', + 'hcaptcha', + 'fortune' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."jobs" + ALTER COLUMN "request_type" TYPE "hmt"."jobs_request_type_enum_old" USING "request_type"::"text"::"hmt"."jobs_request_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."jobs_request_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."jobs_request_type_enum_old" + RENAME TO "jobs_request_type_enum" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/database/migrations/1743611706650-AudinoOracleType.ts b/packages/apps/job-launcher/server/src/database/migrations/1743611706650-AudinoOracleType.ts new file mode 100644 index 0000000000..2f9a0d6c02 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1743611706650-AudinoOracleType.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AudinoOracleType1743611706650 implements MigrationInterface { + name = 'AudinoOracleType1743611706650'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "hmt"."webhook_oracle_type_enum" + RENAME TO "webhook_oracle_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."webhook_oracle_type_enum" AS ENUM('fortune', 'cvat', 'audino', 'hcaptcha') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhook" + ALTER COLUMN "oracle_type" TYPE "hmt"."webhook_oracle_type_enum" USING "oracle_type"::"text"::"hmt"."webhook_oracle_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhook_oracle_type_enum_old" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "hmt"."webhook_oracle_type_enum_old" AS ENUM('fortune', 'cvat', 'hcaptcha') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhook" + ALTER COLUMN "oracle_type" TYPE "hmt"."webhook_oracle_type_enum_old" USING "oracle_type"::"text"::"hmt"."webhook_oracle_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhook_oracle_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."webhook_oracle_type_enum_old" + RENAME TO "webhook_oracle_type_enum" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts index ef3d5615de..7f77c883be 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts @@ -62,6 +62,7 @@ import { CronJobEntity } from './cron-job.entity'; import { CronJobRepository } from './cron-job.repository'; import { CronJobService } from './cron-job.service'; import { RoutingProtocolService } from '../routing-protocol/routing-protocol.service'; +import { faker } from '@faker-js/faker'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -87,6 +88,7 @@ describe('CronJobService', () => { webhookRepository: WebhookRepository, storageService: StorageService, jobService: JobService, + paymentService: PaymentService, contentModerationService: GCVContentModerationService, jobRepository: JobRepository; @@ -184,6 +186,7 @@ describe('CronJobService', () => { ); jobService = module.get(JobService); jobRepository = module.get(JobRepository); + paymentService = module.get(PaymentService); repository = module.get(CronJobRepository); webhookService = module.get(WebhookService); webhookRepository = module.get(WebhookRepository); @@ -1193,147 +1196,274 @@ describe('CronJobService', () => { }); }); - // describe('processAbuseCronJob', () => { - // let sendWebhookMock: any; - // let cronJobEntityMock: Partial; - // let webhookEntity: Partial, jobEntity: Partial; - - // beforeEach(() => { - // cronJobEntityMock = { - // cronJobType: CronJobType.Abuse, - // startedAt: new Date(), - // }; - - // webhookEntity = { - // id: 1, - // chainId: ChainId.LOCALHOST, - // escrowAddress: MOCK_ADDRESS, - // status: WebhookStatus.PENDING, - // waitUntil: new Date(), - // retriesCount: 0, - // }; - - // jobEntity = { - // id: 1, - // chainId: ChainId.LOCALHOST, - // escrowAddress: MOCK_ADDRESS, - // status: JobStatus.PENDING, - // }; - - // jest - // .spyOn(webhookRepository, 'findByStatusAndType') - // .mockResolvedValue([webhookEntity as any]); - - // sendWebhookMock = jest.spyOn(webhookService as any, 'sendWebhook'); - // sendWebhookMock.mockResolvedValue(true); - - // jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); - - // jest.spyOn(repository, 'findOneByType').mockResolvedValue(null); - // jest - // .spyOn(repository, 'createUnique') - // .mockResolvedValue(cronJobEntityMock as any); - // jest - // .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') - // .mockResolvedValue(jobEntity as any); - // jest - // .spyOn(jobService, 'processEscrowCancellation') - // .mockResolvedValue(null as any); - // jest.spyOn(paymentService, 'createSlash').mockResolvedValue(null as any); - // }); - - // afterEach(() => { - // jest.restoreAllMocks(); - // }); - - // it('should not run if cron job is already running', async () => { - // jest.spyOn(service, 'isCronJobRunning').mockResolvedValueOnce(true); - - // const startCronJobMock = jest.spyOn(service, 'startCronJob'); - - // await service.processAbuse(); - - // expect(startCronJobMock).not.toHaveBeenCalled(); - // }); - - // it('should create cron job entity to lock the process', async () => { - // jest - // .spyOn(service, 'startCronJob') - // .mockResolvedValueOnce(cronJobEntityMock as any); - - // await service.processAbuse(); - - // expect(service.startCronJob).toHaveBeenCalledWith(CronJobType.Abuse); - // }); - - // it('should slash for all of the pending webhooks', async () => { - // await service.processAbuse(); - - // expect(jobRepository.updateOne).toHaveBeenCalled(); - // expect(jobEntity.status).toBe(JobStatus.CANCELED); - // expect(webhookRepository.updateOne).toHaveBeenCalled(); - // expect(webhookEntity.status).toBe(WebhookStatus.COMPLETED); - // }); - - // it('should increase retriesCount by 1 if no job is found', async () => { - // jest - // .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') - // .mockResolvedValue(null); - // await service.processAbuse(); - - // expect(webhookRepository.updateOne).toHaveBeenCalled(); - // expect(webhookEntity.status).toBe(WebhookStatus.PENDING); - // expect(webhookEntity.retriesCount).toBe(1); - // expect(webhookEntity.waitUntil).toBeInstanceOf(Date); - // }); - - // it('should increase retriesCount by 1 if processEscrowCancellation fails', async () => { - // jest - // .spyOn(jobService, 'processEscrowCancellation') - // .mockRejectedValueOnce(new Error()); - // await service.processAbuse(); - - // expect(webhookRepository.updateOne).toHaveBeenCalled(); - // expect(webhookEntity.status).toBe(WebhookStatus.PENDING); - // expect(webhookEntity.retriesCount).toBe(1); - // expect(webhookEntity.waitUntil).toBeInstanceOf(Date); - // }); - - // it('should increase retriesCount by 1 if createSlash fails', async () => { - // jest - // .spyOn(paymentService, 'createSlash') - // .mockRejectedValueOnce(new Error()); - // await service.processAbuse(); - - // expect(webhookRepository.updateOne).toHaveBeenCalled(); - // expect(webhookEntity.status).toBe(WebhookStatus.PENDING); - // expect(webhookEntity.retriesCount).toBe(1); - // expect(webhookEntity.waitUntil).toBeInstanceOf(Date); - // }); - - // it('should mark webhook as failed if retriesCount exceeds threshold', async () => { - // jest - // .spyOn(jobService, 'processEscrowCancellation') - // .mockRejectedValueOnce(new Error()); - - // webhookEntity.retriesCount = MOCK_MAX_RETRY_COUNT; - - // await service.processAbuse(); - - // expect(webhookRepository.updateOne).toHaveBeenCalled(); - // expect(webhookEntity.status).toBe(WebhookStatus.FAILED); - // }); - - // it('should complete the cron job entity to unlock', async () => { - // jest - // .spyOn(service, 'completeCronJob') - // .mockResolvedValueOnce(cronJobEntityMock as any); - - // await service.processAbuse(); - - // expect(service.completeCronJob).toHaveBeenCalledWith( - // cronJobEntityMock as any, - // ); - // }); - // }); + describe('processAbuseCronJob', () => { + it('should not run if cron job is already running', async () => { + jest.spyOn(repository, 'findOneByType').mockResolvedValueOnce({} as any); + + const startCronJobMock = jest.spyOn(service, 'startCronJob'); + + await service.processAbuse(); + + expect(startCronJobMock).not.toHaveBeenCalled(); + }); + + it('should create cron job entity to lock the process', async () => { + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + jest + .spyOn(repository, 'createUnique') + .mockResolvedValueOnce(cronJobEntityMock as any); + + await service.processAbuse(); + + expect(repository.createUnique).toHaveBeenCalledWith({ + cronJobType: CronJobType.Abuse, + }); + }); + + it('should slash for all of the pending webhooks', async () => { + const webhookEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + status: WebhookStatus.PENDING, + waitUntil: new Date(), + retriesCount: 0, + }; + + const jobEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: webhookEntity.escrowAddress, + status: JobStatus.PAID, + }; + + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + completedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(cronJobEntityMock as any) + .mockResolvedValueOnce(cronJobEntityMock as any); + jest.spyOn(repository, 'updateOne').mockResolvedValueOnce({ + ...cronJobEntityMock, + completedAt: null, + } as any); + + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValueOnce([webhookEntity as any]); + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce(jobEntity as any); + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockResolvedValueOnce(null as any); + jest + .spyOn(paymentService, 'createSlash') + .mockResolvedValueOnce(null as any); + jest + .spyOn(webhookRepository, 'updateOne') + .mockResolvedValueOnce(null as any); + jest.spyOn(jobRepository, 'updateOne').mockResolvedValueOnce(null as any); + + await service.processAbuse(); + + expect(jobRepository.updateOne).toHaveBeenCalledWith({ + ...jobEntity, + status: JobStatus.CANCELED, + }); + expect(webhookRepository.updateOne).toHaveBeenCalledWith({ + ...webhookEntity, + status: WebhookStatus.COMPLETED, + }); + }); + + it('should increase retriesCount by 1 if no job is found', async () => { + const webhookEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + status: WebhookStatus.PENDING, + waitUntil: new Date(), + retriesCount: 0, + }; + + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + completedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(cronJobEntityMock as any) + .mockResolvedValueOnce(cronJobEntityMock as any); + jest.spyOn(repository, 'updateOne').mockResolvedValueOnce({ + ...cronJobEntityMock, + completedAt: null, + } as any); + + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValueOnce([webhookEntity as any]); + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce(null as any); + jest + .spyOn(webhookRepository, 'updateOne') + .mockResolvedValueOnce(null as any); + + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalledWith({ + ...webhookEntity, + retriesCount: 1, + waitUntil: expect.any(Date), + status: WebhookStatus.PENDING, + }); + }); + + it('should increase retriesCount by 1 if createSlash fails', async () => { + const webhookEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + status: WebhookStatus.PENDING, + waitUntil: new Date(), + retriesCount: 0, + }; + + const jobEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: webhookEntity.escrowAddress, + status: JobStatus.PAID, + }; + + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + completedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(cronJobEntityMock as any) + .mockResolvedValueOnce(cronJobEntityMock as any); + jest.spyOn(repository, 'updateOne').mockResolvedValueOnce({ + ...cronJobEntityMock, + completedAt: null, + } as any); + + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValueOnce([webhookEntity as any]); + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce(jobEntity as any); + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockResolvedValueOnce(null as any); + jest + .spyOn(paymentService, 'createSlash') + .mockRejectedValueOnce(new Error()); + jest + .spyOn(webhookRepository, 'updateOne') + .mockResolvedValueOnce(null as any); + + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalledWith({ + ...webhookEntity, + retriesCount: 1, + waitUntil: expect.any(Date), + status: WebhookStatus.PENDING, + }); + }); + + it('should mark webhook as failed if retriesCount exceeds threshold', async () => { + const webhookEntity = { + id: faker.number.int(), + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + status: WebhookStatus.PENDING, + waitUntil: new Date(), + retriesCount: MOCK_MAX_RETRY_COUNT, + }; + + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + completedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(cronJobEntityMock as any) + .mockResolvedValueOnce(cronJobEntityMock as any); + jest.spyOn(repository, 'updateOne').mockResolvedValueOnce({ + ...cronJobEntityMock, + completedAt: null, + } as any); + + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValueOnce([webhookEntity as any]); + jest + .spyOn(webhookRepository, 'updateOne') + .mockResolvedValueOnce(null as any); + + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalledWith({ + ...webhookEntity, + status: WebhookStatus.FAILED, + }); + }); + + it('should complete the cron job entity to unlock', async () => { + const cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + completedAt: new Date(), + }; + + jest + .spyOn(repository, 'findOneByType') + .mockResolvedValueOnce(cronJobEntityMock as any) + .mockResolvedValueOnce(cronJobEntityMock as any); + jest.spyOn(repository, 'updateOne').mockResolvedValueOnce({ + ...cronJobEntityMock, + completedAt: null, + } as any); + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValueOnce(null as any); + + await service.processAbuse(); + + expect(repository.updateOne).toHaveBeenCalledTimes(2); + expect(repository.updateOne).toHaveBeenCalledWith({ + cronJobType: CronJobType.Abuse, + startedAt: expect.any(Date), + completedAt: null, + }); + expect(repository.updateOne).toHaveBeenCalledWith({ + cronJobType: CronJobType.Abuse, + startedAt: expect.any(Date), + completedAt: expect.any(Date), + }); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index 81314a90c5..b0c6a756ed 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -4,6 +4,7 @@ import { ErrorContentModeration, ErrorCronJob, ErrorEscrow, + ErrorJob, } from '../../common/constants/errors'; import { CronJobType } from '../../common/enums/cron-job'; @@ -342,7 +343,7 @@ export class CronJobService { try { const webhookEntities = await this.webhookRepository.findByStatusAndType( WebhookStatus.PENDING, - EventType.ESCROW_CREATED, + [EventType.ESCROW_CREATED, EventType.ESCROW_CANCELED], ); for (const webhookEntity of webhookEntities) { @@ -364,67 +365,70 @@ export class CronJobService { await this.completeCronJob(cronJob); } - // @Cron('*/5 * * * *') + @Cron('*/5 * * * *') /** * Process an abuse webhook. * @returns {Promise} - Returns a promise that resolves when the operation is complete. */ - // public async processAbuse(): Promise { - // const isCronJobRunning = await this.isCronJobRunning(CronJobType.Abuse); - - // if (isCronJobRunning) { - // return; - // } - - // this.logger.log('Abuse START'); - // const cronJob = await this.startCronJob(CronJobType.Abuse); - - // try { - // const webhookEntities = await this.webhookRepository.findByStatusAndType( - // WebhookStatus.PENDING, - // EventType.ABUSE_DETECTED, - // ); - - // for (const webhookEntity of webhookEntities) { - // try { - // const jobEntity = - // await this.jobRepository.findOneByChainIdAndEscrowAddress( - // webhookEntity.chainId, - // webhookEntity.escrowAddress, - // ); - // if (!jobEntity) { - // this.logger.log(ErrorJob.NotFound, JobService.name); - // throw new ControlledError( - // ErrorJob.NotFound, - // HttpStatus.BAD_REQUEST, - // ); - // } - // if ( - // jobEntity.escrowAddress && - // jobEntity.status !== JobStatus.CANCELED - // ) { - // await this.jobService.processEscrowCancellation(jobEntity); - // jobEntity.status = JobStatus.CANCELED; - // await this.jobRepository.updateOne(jobEntity); - // } - // await this.paymentService.createSlash(jobEntity); - // } catch (err) { - // this.logger.error( - // `Error slashing escrow (address: ${webhookEntity.escrowAddress}, chainId: ${webhookEntity.chainId}: ${err.message}`, - // ); - // await this.webhookService.handleWebhookError(webhookEntity); - // continue; - // } - // webhookEntity.status = WebhookStatus.COMPLETED; - // await this.webhookRepository.updateOne(webhookEntity); - // } - // } catch (e) { - // this.logger.error(e); - // } - - // this.logger.log('Abuse STOP'); - // await this.completeCronJob(cronJob); - // } + public async processAbuse(): Promise { + const isCronJobRunning = await this.isCronJobRunning(CronJobType.Abuse); + + if (isCronJobRunning) { + return; + } + + this.logger.log('Abuse START'); + const cronJob = await this.startCronJob(CronJobType.Abuse); + + try { + const webhookEntities = await this.webhookRepository.findByStatusAndType( + WebhookStatus.PENDING, + EventType.ABUSE_DETECTED, + ); + + for (const webhookEntity of webhookEntities) { + try { + const jobEntity = + await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhookEntity.chainId, + webhookEntity.escrowAddress, + ); + if (!jobEntity) { + this.logger.log(ErrorJob.NotFound, JobService.name); + throw new ControlledError( + ErrorJob.NotFound, + HttpStatus.BAD_REQUEST, + ); + } + if ( + jobEntity.escrowAddress && + jobEntity.status !== JobStatus.CANCELED + ) { + await this.jobService.processEscrowCancellation(jobEntity); + } + + if (jobEntity.status !== JobStatus.CANCELED) { + jobEntity.status = JobStatus.CANCELED; + await this.jobRepository.updateOne(jobEntity); + } + await this.paymentService.createSlash(jobEntity); + } catch (err) { + this.logger.error( + `Error slashing escrow (address: ${webhookEntity.escrowAddress}, chainId: ${webhookEntity.chainId}: ${err.message}`, + ); + await this.webhookService.handleWebhookError(webhookEntity); + continue; + } + webhookEntity.status = WebhookStatus.COMPLETED; + await this.webhookRepository.updateOne(webhookEntity); + } + } catch (e) { + this.logger.error(e); + } + + this.logger.log('Abuse STOP'); + await this.completeCronJob(cronJob); + } /** * Process a job that syncs job statuses. diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index 7435683f71..78f65ccec6 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -31,6 +31,7 @@ import { JobQuickLaunchDto, JobCancelDto, GetJobsDto, + JobAudinoDto, } from './job.dto'; import { JobService } from './job.service'; import { JobRequestType } from '../../common/enums/job'; @@ -176,6 +177,46 @@ export class JobController { ); } + @ApiOperation({ + summary: 'Create an Audino job', + description: 'Endpoint to create a new Audino job.', + }) + @ApiBody({ type: JobAudinoDto }) + @ApiResponse({ + status: 201, + description: 'ID of the created Audino job.', + type: Number, + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid input parameters.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 409, + description: 'Conflict. Conflict with the current state of the server.', + }) + @Post('/audino') + public async createAudinoJob( + @Body() data: JobAudinoDto, + @Request() req: RequestWithUser, + ): Promise { + if (this.web3ConfigService.env === Web3Env.MAINNET) { + throw new ControlledError('Disabled', HttpStatus.METHOD_NOT_ALLOWED); + } + + return await this.mutexManagerService.runExclusive( + { id: `user${req.user.id}` }, + MUTEX_TIMEOUT, + async () => { + return await this.jobService.createJob(req.user, data.type, data); + }, + ); + } + // @ApiOperation({ // summary: 'Create a hCaptcha job', // description: 'Endpoint to create a new hCaptcha job.', diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 8ac816657e..afdd71d4cd 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -18,6 +18,7 @@ import { IsDefined, IsNotEmptyObject, ArrayMinSize, + Equals, } from 'class-validator'; import { Type } from 'class-transformer'; import { ChainId } from '@human-protocol/sdk'; @@ -208,6 +209,61 @@ export class JobCvatDto extends JobDto { public type: JobRequestType; } +class AudinoLabel { + @ApiProperty() + @IsString() + public name: string; +} + +class AudinoDataDto { + @ApiProperty() + @IsObject() + public dataset: StorageDataDto; +} + +export class JobAudinoDto extends JobDto { + @ApiProperty({ name: 'requester_description' }) + @IsString() + public requesterDescription: string; + + @ApiProperty() + @IsObject() + public data: AudinoDataDto; + + @ApiProperty({ type: [AudinoLabel] }) + @IsArray() + @ArrayMinSize(1) + public labels: AudinoLabel[]; + + @ApiProperty({ name: 'min_quality' }) + @IsNumber() + @IsPositive() + @Max(1) + public minQuality: number; + + @ApiProperty({ name: 'ground_truth' }) + @IsObject() + public groundTruth: StorageDataDto; + + @ApiProperty({ name: 'user_guide' }) + @IsUrl() + public userGuide: string; + + @ApiProperty({ enum: JobRequestType }) + @IsEnumCaseInsensitive(JobRequestType) + public type: JobRequestType; + + @ApiProperty({ name: 'audio_duration' }) + @IsNumber() + @IsPositive() + public audioDuration: number; + + @ApiProperty({ name: 'segment_duration' }) + @IsNumber() + @IsPositive() + public segmentDuration: number; +} + export class JobCancelDto { @ApiProperty() @IsNumberString() @@ -453,6 +509,58 @@ export class CvatManifestDto { public job_bounty: string; } +class AudinoData { + @IsUrl() + public data_url: string; +} + +class AudinoAnnotation { + @IsArray() + public labels: Array<{ name: string }>; + + @IsString() + public description: string; + + @IsString() + @IsUrl() + public user_guide: string; + + @Equals(JobRequestType.AUDIO_TRANSCRIPTION) + public type: JobRequestType.AUDIO_TRANSCRIPTION; + + @IsNumber() + @IsPositive() + public segment_duration: number; + + @IsArray() + @IsOptional() + public qualifications?: string[]; +} + +class AudinoValidation { + @IsNumber() + @IsPositive() + public min_quality: number; + + @IsString() + @IsUrl() + public gt_url: string; +} + +export class AudinoManifestDto { + @IsObject() + public data: AudinoData; + + @IsObject() + public annotation: AudinoAnnotation; + + @IsObject() + public validation: AudinoValidation; + + @IsString() + public job_bounty: string; +} + export class FortuneFinalResultDto { @ApiProperty({ name: 'worker_address' }) @IsNotEmpty() diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 0c2d854853..3b06d45caa 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -62,6 +62,8 @@ import { CvatDataDto, StorageDataDto, GetJobsDto, + JobAudinoDto, + AudinoManifestDto, } from './job.dto'; import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; @@ -370,6 +372,41 @@ export class JobService { } } + public async createAudinoManifest( + dto: JobAudinoDto, + requestType: JobRequestType, + tokenFundAmount: number, + ): Promise { + const { generateUrls } = this.createManifestActions[requestType]; + const urls = generateUrls(dto.data, dto.groundTruth); + const totalSegments = Math.ceil( + (dto.audioDuration * 1000) / dto.segmentDuration, + ); + + const jobBounty = + ethers.parseUnits(tokenFundAmount.toString(), 'ether') / + BigInt(totalSegments); + + return { + annotation: { + description: dto.requesterDescription, + labels: dto.labels, + qualifications: dto.qualifications || [], + type: requestType, + user_guide: dto.userGuide, + segment_duration: dto.segmentDuration, + }, + data: { + data_url: urls.dataUrl.href, + }, + job_bounty: ethers.formatEther(jobBounty), + validation: { + gt_url: urls.gtUrl.href, + min_quality: dto.minQuality, + }, + }; + } + private buildHCaptchaRestrictedAudience(advanced: JobCaptchaAdvancedDto) { const restrictedAudience: RestrictedAudience = {}; @@ -486,6 +523,13 @@ export class JobService { fundAmount: number, ) => this.createCvatManifest(dto, requestType, fundAmount), }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + createManifest: ( + dto: JobAudinoDto, + requestType: JobRequestType, + fundAmount: number, + ) => this.createAudinoManifest(dto, requestType, fundAmount), + }, }; private createEscrowSpecificActions: Record = { @@ -510,6 +554,9 @@ export class JobService { [JobRequestType.IMAGE_SKELETONS_FROM_BOXES]: { getTrustedHandlers: () => [], }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + getTrustedHandlers: () => [], + }, }; private createManifestActions: Record = { @@ -661,6 +708,20 @@ export class JobService { }; }, }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + getElementsCount: async () => 0, + generateUrls: ( + data: CvatDataDto, + groundTruth: StorageDataDto, + ): GenerateUrls => { + const requestType = JobRequestType.AUDIO_TRANSCRIPTION; + + return { + dataUrl: generateBucketUrl(data.dataset, requestType), + gtUrl: generateBucketUrl(groundTruth, requestType), + }; + }, + }, }; private getOraclesSpecificActions: Record = { @@ -734,6 +795,15 @@ export class JobService { return { exchangeOracle, recordingOracle, reputationOracle }; }, }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + getOracleAddresses: (): OracleAddresses => { + return { + exchangeOracle: this.web3ConfigService.audinoExchangeOracleAddress, + recordingOracle: this.web3ConfigService.audinoRecordingOracleAddress, + reputationOracle: this.web3ConfigService.reputationOracleAddress, + }; + }, + }, }; private async checkImageConsistency( @@ -935,7 +1005,10 @@ export class JobService { jobEntity.token = dto.escrowFundToken; jobEntity.waitUntil = new Date(); - if (user.whitelist) { + if ( + user.whitelist || + [JobRequestType.AUDIO_TRANSCRIPTION].includes(requestType) + ) { jobEntity.status = JobStatus.MODERATION_PASSED; } else { jobEntity.status = JobStatus.PAID; @@ -1269,6 +1342,8 @@ export class JobService { } else if (requestType === JobRequestType.HCAPTCHA) { return true; dtoCheck = new HCaptchaManifestDto(); + } else if (requestType === JobRequestType.AUDIO_TRANSCRIPTION) { + dtoCheck = new AudinoManifestDto(); } else { dtoCheck = new CvatManifestDto(); } @@ -1458,6 +1533,8 @@ export class JobService { return OracleType.FORTUNE; } else if (requestType === JobRequestType.HCAPTCHA) { return OracleType.HCAPTCHA; + } else if (requestType === JobRequestType.AUDIO_TRANSCRIPTION) { + return OracleType.AUDINO; } else { return OracleType.CVAT; } @@ -1731,8 +1808,14 @@ export class JobService { if (jobEntity.status === JobStatus.COMPLETED) { return; } - if (jobEntity.status !== JobStatus.LAUNCHED) { - throw new ControlledError(ErrorJob.NotLaunched, HttpStatus.CONFLICT); + if ( + jobEntity.status !== JobStatus.LAUNCHED && + jobEntity.status !== JobStatus.PARTIAL + ) { + throw new ControlledError( + ErrorJob.InvalidStatusCompletion, + HttpStatus.CONFLICT, + ); } jobEntity.status = JobStatus.COMPLETED; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index c713700153..ad65b4ef33 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -212,6 +212,14 @@ export class PaymentDto { @IsOptional() public transaction?: string; + @ApiProperty({ + name: 'job_id', + description: 'Job ID associated with the payment', + }) + @IsNumber() + @IsOptional() + public jobId?: number; + @ApiProperty({ name: 'escrow_address', description: 'Escrow address associated with the payment (if applicable)', diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index c5be8a1128..6dfa82c298 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -45,6 +45,7 @@ import { JobRepository } from '../job/job.repository'; import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; import { SortDirection } from '../../common/enums/collection'; import { Country } from '../../common/enums/job'; +import { faker } from '@faker-js/faker/.'; jest.mock('@human-protocol/sdk'); @@ -130,6 +131,7 @@ describe('PaymentService', () => { create: jest.fn(), retrieve: jest.fn(), update: jest.fn(), + confirm: jest.fn(), }, setupIntents: { create: jest.fn(), @@ -1024,84 +1026,109 @@ describe('PaymentService', () => { }); }); - // describe('createSlash', () => { - // it('should charge user credit card and create slash payments successfully', async () => { - // const user = { - // id: 1, - // email: 'test@hmt.ai', - // stripeCustomerId: 'cus_123', - // }; - - // const jobEntity = { - // id: 1, - // user: user, - // userId: user.id, - // }; - - // const paymentIntent = { - // id: 'pi_123', - // client_secret: 'clientSecret123', - // }; - - // jest.spyOn(userRepository, 'findById').mockResolvedValue(user as any); - // jest - // .spyOn(stripe.paymentIntents, 'create') - // .mockResolvedValue(paymentIntent as any); - - // const result = await paymentService.createSlash(jobEntity as any); - - // expect(result).toBe(undefined); - // expect(stripe.paymentIntents.create).toHaveBeenCalledWith({ - // amount: expect.any(Number), - // currency: Currency.USD, - // customer: 'cus_123', - // off_session: true, - // confirm: true, - // }); - // expect(paymentRepository.createUnique).toHaveBeenCalledTimes(2); - // }); - - // it('should fail if user does not have payment info', async () => { - // const user = { - // id: 1, - // email: 'test@hmt.ai', - // }; - - // const jobEntity = { - // id: 1, - // user: user, - // userId: user.id, - // }; - - // jest - // .spyOn(userRepository, 'findById') - // .mockResolvedValue(undefined as any); - - // await expect( - // paymentService.createSlash(jobEntity as any), - // ).rejects.toThrow(ErrorPayment.CustomerNotFound); - // }); - - // it('should fail if stripe create payment intent fails', async () => { - // const user = { - // id: 1, - // email: 'test@hmt.ai', - // }; - - // const jobEntity = { - // id: 1, - // user: user, - // userId: user.id, - // }; - - // jest.spyOn(userRepository, 'findById').mockResolvedValue(user as any); - // jest.spyOn(stripe.paymentIntents, 'create').mockResolvedValue({} as any); - - // await expect( - // paymentService.createSlash(jobEntity as any), - // ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); - // }); - // }); + describe('createSlash', () => { + const user = { + id: faker.number.int(), + email: faker.internet.email(), + stripeCustomerId: faker.word.sample(), + }; + + const jobEntity = { + id: faker.number.int(), + user: user, + userId: user.id, + }; + + const paymentIntent = { + id: faker.word.sample(), + client_secret: faker.word.sample(), + }; + + const invoiceId = faker.word.sample(); + const paymentMethodId = faker.word.sample(); + + it('should charge user credit card and create slash payments successfully', async () => { + jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); + jest + .spyOn(stripe.paymentIntents, 'retrieve') + .mockResolvedValueOnce(paymentIntent as any); + jest + .spyOn(stripe.paymentIntents, 'confirm') + .mockResolvedValueOnce(paymentIntent as any); + jest + .spyOn(stripe.invoices, 'create') + .mockResolvedValueOnce({ id: invoiceId } as any); + jest + .spyOn(stripe.invoiceItems, 'create') + .mockResolvedValueOnce({} as any); + jest + .spyOn(stripe.invoices, 'finalizeInvoice') + .mockResolvedValueOnce({ payment_intent: paymentIntent.id } as any); + jest.spyOn(stripe.customers, 'retrieve').mockResolvedValueOnce({ + invoice_settings: { default_payment_method: paymentMethodId }, + } as any); + + const result = await paymentService.createSlash(jobEntity as any); + + expect(result).toBe(undefined); + expect(stripe.invoices.create).toHaveBeenCalledWith({ + customer: user.stripeCustomerId, + currency: PaymentCurrency.USD, + auto_advance: false, + payment_settings: { + payment_method_types: ['card'], + }, + }); + expect(stripe.invoiceItems.create).toHaveBeenCalledWith({ + customer: user.stripeCustomerId, + amount: expect.any(Number), + invoice: invoiceId, + description: 'Slash Job Id ' + jobEntity.id, + }); + expect(stripe.invoices.finalizeInvoice).toHaveBeenCalledWith(invoiceId); + expect(stripe.paymentIntents.confirm).toHaveBeenCalledWith( + paymentIntent.id, + { + payment_method: paymentMethodId, + off_session: true, + }, + ); + expect(paymentRepository.createUnique).toHaveBeenCalledTimes(2); + }); + + it('should fail if user does not have payment info', async () => { + jest + .spyOn(userRepository, 'findById') + .mockResolvedValueOnce(undefined as any); + + await expect( + paymentService.createSlash(jobEntity as any), + ).rejects.toThrow(ErrorPayment.CustomerNotFound); + }); + + it('should fail if stripe create payment intent fails', async () => { + jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); + jest + .spyOn(stripe.invoices, 'create') + .mockResolvedValueOnce({ id: invoiceId } as any); + jest + .spyOn(stripe.invoiceItems, 'create') + .mockResolvedValueOnce({} as any); + jest + .spyOn(stripe.invoices, 'finalizeInvoice') + .mockResolvedValueOnce({ payment_intent: paymentIntent.id } as any); + jest.spyOn(stripe.customers, 'retrieve').mockResolvedValueOnce({ + invoice_settings: { default_payment_method: paymentMethodId }, + } as any); + jest + .spyOn(stripe.paymentIntents, 'confirm') + .mockRejectedValue(new Error()); + + await expect( + paymentService.createSlash(jobEntity as any), + ).rejects.toThrow(ErrorPayment.PaymentMethodAssociationFailed); + }); + }); describe('listUserPaymentMethods', () => { it('should list user payment methods successfully', async () => { @@ -1119,10 +1146,10 @@ describe('PaymentService', () => { jest .spyOn(stripe.customers, 'listPaymentMethods') - .mockResolvedValue(paymentMethods as any); + .mockResolvedValueOnce(paymentMethods as any); jest .spyOn(paymentService as any, 'getDefaultPaymentMethod') - .mockResolvedValue('pm_123'); + .mockResolvedValueOnce('pm_123'); const result = await paymentService.listUserPaymentMethods(user as any); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index b87385c04b..412bec9075 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -30,6 +30,7 @@ import { import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { ServerConfigService } from '../../common/config/server-config.service'; import { HMToken, HMToken__factory, @@ -47,6 +48,7 @@ import { JobRepository } from '../job/job.repository'; import { PageDto } from '../../common/pagination/pagination.dto'; import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; import { EscrowFundToken } from '../../common/enums/job'; +import { JobEntity } from '../job/job.entity'; @Injectable() export class PaymentService { @@ -60,6 +62,7 @@ export class PaymentService { private readonly userRepository: UserRepository, private readonly jobRepository: JobRepository, private stripeConfigService: StripeConfigService, + private serverConfigService: ServerConfigService, private rateService: RateService, ) { this.stripe = new Stripe(this.stripeConfigService.secretKey, { @@ -160,61 +163,23 @@ export class PaymentService { user: UserEntity, dto: PaymentFiatCreateDto, ): Promise { - // Creates an invoice for fiat currency and associates it with a payment intent. - const { amount, currency } = dto; - + const { amount, currency, paymentMethodId } = dto; const amountInCents = Math.ceil(mul(amount, 100)); - let invoice = await this.stripe.invoices.create({ - customer: user.stripeCustomerId, - currency: currency, - auto_advance: false, - payment_settings: { - payment_method_types: ['card'], - }, - }); - - await this.stripe.invoiceItems.create({ - customer: user.stripeCustomerId, - amount: amountInCents, - invoice: invoice.id, - description: 'Top up', - }); - - // Finalize the invoice to prepare it for payment. - invoice = await this.stripe.invoices.finalizeInvoice(invoice.id); - - if (!invoice.payment_intent) { - throw new ControlledError( - ErrorPayment.IntentNotCreated, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + const invoice = await this.createInvoice( + user.stripeCustomerId, + amountInCents, + currency, + 'Top up', + ); - const paymentIntent = await this.stripe.paymentIntents.retrieve( + const paymentIntent = await this.handleStripePaymentIntent( invoice.payment_intent as string, + paymentMethodId, + false, // on-session payment ); - try { - // Associate the payment method with the payment intent. - await this.stripe.paymentIntents.update(paymentIntent.id, { - payment_method: dto.paymentMethodId, - }); - } catch { - throw new ControlledError( - ErrorPayment.PaymentMethodAssociationFailed, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - if (!paymentIntent?.client_secret) { - throw new ControlledError( - ErrorPayment.ClientSecretDoesNotExist, - HttpStatus.NOT_FOUND, - ); - } - - // Check if the transaction already exists to prevent duplicates. + // Check if the transaction already exists to prevent duplicates const paymentEntity = await this.paymentRepository.findOneByTransaction( paymentIntent.id, ); @@ -239,7 +204,7 @@ export class PaymentService { }); await this.paymentRepository.createUnique(newPaymentEntity); - return paymentIntent.client_secret; + return paymentIntent.client_secret!; } public async confirmFiatPayment( @@ -444,68 +409,143 @@ export class PaymentService { return mul(amount, rate); } - // public async createSlash(job: JobEntity): Promise { - // const amount = this.serverConfigService.abuseAmount, - // currency = Currency.USD; - - // const user = await this.userRepository.findById(job.userId); - // if (!user) { - // this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); - // throw new ControlledError( - // ErrorPayment.CustomerNotFound, - // HttpStatus.BAD_REQUEST, - // ); - // } - - // const amountInCents = Math.ceil(mul(amount, 100)); - // const params: Stripe.PaymentIntentCreateParams = { - // amount: amountInCents, - // currency: currency, - // customer: user.stripeCustomerId, - // off_session: true, - // confirm: true, - // }; - - // const paymentIntent = await this.stripe.paymentIntents.create(params); - - // if (!paymentIntent?.client_secret) { - // this.logger.log( - // ErrorPayment.ClientSecretDoesNotExist, - // PaymentService.name, - // ); - // throw new ControlledError( - // ErrorPayment.ClientSecretDoesNotExist, - // HttpStatus.BAD_REQUEST, - // ); - // } - - // const newPaymentEntity = new PaymentEntity(); - // Object.assign(newPaymentEntity, { - // userId: job.user.id, - // source: PaymentSource.FIAT, - // type: PaymentType.DEPOSIT, - // amount: div(amountInCents, 100), - // currency, - // rate: 1, - // transaction: paymentIntent.id, - // status: PaymentStatus.SUCCEEDED, - // }); - // await this.paymentRepository.createUnique(newPaymentEntity); - - // Object.assign(newPaymentEntity, { - // userId: job.user.id, - // source: PaymentSource.FIAT, - // type: PaymentType.SLASH, - // amount: div(-amountInCents, 100), - // currency, - // rate: 1, - // transaction: null, - // status: PaymentStatus.SUCCEEDED, - // }); - // await this.paymentRepository.createUnique(newPaymentEntity); - - // return; - // } + private async createInvoice( + customerId: string, + amountInCents: number, + currency: string, + description: string, + ): Promise { + let invoice = await this.stripe.invoices.create({ + customer: customerId, + currency: currency, + auto_advance: false, + payment_settings: { + payment_method_types: ['card'], + }, + }); + + await this.stripe.invoiceItems.create({ + customer: customerId, + amount: amountInCents, + invoice: invoice.id, + description: description, + }); + + // Finalize the invoice to prepare it for payment. + invoice = await this.stripe.invoices.finalizeInvoice(invoice.id); + + if (!invoice.payment_intent) { + throw new ControlledError( + ErrorPayment.IntentNotCreated, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return invoice; + } + + private async handleStripePaymentIntent( + paymentIntentId: string, + paymentMethodId: string, + offSession: boolean, + ): Promise { + try { + if (offSession) { + // Use confirm for off-session payments + await this.stripe.paymentIntents.confirm(paymentIntentId, { + payment_method: paymentMethodId, + off_session: true, + }); + } else { + // Use update for on-session payments + await this.stripe.paymentIntents.update(paymentIntentId, { + payment_method: paymentMethodId, + }); + } + } catch { + throw new ControlledError( + ErrorPayment.PaymentMethodAssociationFailed, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const paymentIntent = + await this.stripe.paymentIntents.retrieve(paymentIntentId); + + if (!paymentIntent?.client_secret) { + throw new ControlledError( + ErrorPayment.ClientSecretDoesNotExist, + HttpStatus.NOT_FOUND, + ); + } + + return paymentIntent; + } + + public async createSlash(job: JobEntity): Promise { + const amount = this.serverConfigService.abuseAmount; + const currency = PaymentCurrency.USD; + + const user = await this.userRepository.findById(job.userId); + if (!user) { + this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); + throw new ControlledError( + ErrorPayment.CustomerNotFound, + HttpStatus.BAD_REQUEST, + ); + } + + const amountInCents = Math.ceil(mul(amount, 100)); + const invoice = await this.createInvoice( + user.stripeCustomerId, + amountInCents, + currency, + 'Slash Job Id ' + job.id, + ); + + const defaultPaymentMethod = await this.getDefaultPaymentMethod( + user.stripeCustomerId, + ); + + if (!defaultPaymentMethod) { + throw new ControlledError( + ErrorPayment.NotDefaultPaymentMethod, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const paymentIntent = await this.handleStripePaymentIntent( + invoice.payment_intent as string, + defaultPaymentMethod, + true, // off-session payment + ); + + const newPaymentEntity = new PaymentEntity(); + Object.assign(newPaymentEntity, { + userId: job.userId, + source: PaymentSource.FIAT, + type: PaymentType.DEPOSIT, + amount: div(amountInCents, 100), + currency, + rate: 1, + transaction: paymentIntent.id, + status: PaymentStatus.SUCCEEDED, + }); + await this.paymentRepository.createUnique(newPaymentEntity); + + Object.assign(newPaymentEntity, { + userId: job.userId, + source: PaymentSource.BALANCE, + type: PaymentType.SLASH, + amount: div(-amountInCents, 100), + currency, + rate: 1, + transaction: null, + status: PaymentStatus.SUCCEEDED, + jobId: job.id, + }); + await this.paymentRepository.createUnique(newPaymentEntity); + } public async createWithdrawalPayment( userId: number, @@ -629,20 +669,17 @@ export class PaymentService { ); } // If the VAT or VAT type has changed, update it in Stripe - if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - const existingTaxIds = await this.stripe.customers.listTaxIds( - user.stripeCustomerId, - ); + const existingTaxIds = await this.stripe.customers.listTaxIds( + user.stripeCustomerId, + ); - // Delete any existing tax IDs before adding the new one - for (const taxId of existingTaxIds.data) { - await this.stripe.customers.deleteTaxId( - user.stripeCustomerId, - taxId.id, - ); - } + // Delete any existing tax IDs before adding the new one + for (const taxId of existingTaxIds.data) { + await this.stripe.customers.deleteTaxId(user.stripeCustomerId, taxId.id); + } - // Create the new VAT tax ID + // Create the new VAT tax ID + if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { await this.stripe.customers.createTaxId(user.stripeCustomerId, { type: updateBillingInfoDto.vatType, value: updateBillingInfoDto.vat, @@ -720,6 +757,7 @@ export class PaymentService { status: payment.status, transaction: payment.transaction, createdAt: payment.createdAt.toISOString(), + jobId: payment.job ? payment.jobId : undefined, escrowAddress: payment.job ? payment.job.escrowAddress : undefined, }; }); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.ts index 8c6ce339c8..27e9980d2b 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; import { ApiBody, ApiResponse, @@ -53,6 +53,7 @@ export class WebhookController { status: 404, description: 'Not Found. Could not find the requested content.', }) + @HttpCode(200) @Post() public async processWebhook(@Body() body: WebhookDataDto): Promise { await this.webhookService.handleWebhook(body); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts index bf5dfd1537..1c5fe17804 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { BaseRepository } from '../../database/base.repository'; -import { DataSource, LessThanOrEqual } from 'typeorm'; +import { DataSource, In, LessThanOrEqual } from 'typeorm'; import { ServerConfigService } from '../../common/config/server-config.service'; import { EventType, WebhookStatus } from '../../common/enums/webhook'; import { WebhookEntity } from './webhook.entity'; @@ -17,12 +17,13 @@ export class WebhookRepository extends BaseRepository { } public findByStatusAndType( status: WebhookStatus, - type: EventType, + type: EventType | EventType[], ): Promise { + const typeClause = !Array.isArray(type) ? [type] : type; return this.find({ where: { status: status, - eventType: type, + eventType: In(typeClause), retriesCount: LessThanOrEqual(this.serverConfigService.maxRetryCount), waitUntil: LessThanOrEqual(new Date()), }, diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts index 0a93382dd4..83bc6db03a 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts @@ -29,7 +29,8 @@ import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ControlledError } from '../../common/errors/controlled'; import { JobRepository } from '../job/job.repository'; -// import { JobRequestType } from '../../common/enums/job'; +import { JobRequestType } from '../../common/enums/job'; +import { faker } from '@faker-js/faker/.'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -43,7 +44,7 @@ describe('WebhookService', () => { webhookRepository: WebhookRepository, web3Service: Web3Service, jobService: JobService, - // jobRepository: JobRepository, + jobRepository: JobRepository, httpService: HttpService; const signerMock = { @@ -96,7 +97,7 @@ describe('WebhookService', () => { web3Service = moduleRef.get(Web3Service); httpService = moduleRef.get(HttpService); jobService = moduleRef.get(JobService); - // jobRepository = moduleRef.get(JobRepository); + jobRepository = moduleRef.get(JobRepository); }); afterEach(() => { @@ -342,21 +343,36 @@ describe('WebhookService', () => { expect(jobService.escrowFailedWebhook).toHaveBeenCalledWith(webhook); }); - // it('should handle an incoming abused escrow webhook', async () => { - // const webhook: WebhookDataDto = { - // chainId, - // escrowAddress, - // eventType: EventType.ABUSE_DETECTED, - // }; - - // jest.spyOn(webhookService, 'createIncomingWebhook'); + it('should handle an incoming abused escrow webhook', async () => { + const webhook: WebhookDataDto = { + chainId, + escrowAddress, + eventType: EventType.ABUSE_DETECTED, + }; - // expect(await webhookService.handleWebhook(webhook)).toBe(undefined); + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce({ requestType: JobRequestType.FORTUNE } as any); + jest + .spyOn(jobService, 'getOracleType') + .mockReturnValueOnce(OracleType.FORTUNE); + jest.spyOn(webhookService as any, 'createIncomingWebhook'); - // expect(webhookService.createIncomingWebhook).toHaveBeenCalledWith( - // webhook, - // ); - // }); + expect(await webhookService.handleWebhook(webhook)).toBe(undefined); + expect( + (webhookService as any).createIncomingWebhook, + ).toHaveBeenCalledWith(webhook); + expect(webhookRepository.createUnique).toHaveBeenCalledWith({ + chainId: chainId, + escrowAddress: escrowAddress, + hasSignature: false, + oracleType: OracleType.FORTUNE, + retriesCount: 0, + status: WebhookStatus.PENDING, + waitUntil: expect.any(Date), + eventType: EventType.ABUSE_DETECTED, + }); + }); it('should return an error when the event type is invalid', async () => { const webhook: WebhookDataDto = { @@ -374,46 +390,48 @@ describe('WebhookService', () => { }); }); - // describe('createIncomingWebhook', () => { - // it('should create a new incoming webhook', async () => { - // const dto = { - // chainId: ChainId.LOCALHOST, - // escrowAddress: '', - // }; - - // jest - // .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') - // .mockResolvedValue({ requestType: JobRequestType.FORTUNE } as any); - // jest - // .spyOn(jobService, 'getOracleType') - // .mockReturnValue(OracleType.FORTUNE); - // const result = await webhookService.createIncomingWebhook(dto as any); - - // expect(result).toBe(undefined); - // expect(webhookRepository.createUnique).toHaveBeenCalledWith({ - // chainId: ChainId.LOCALHOST, - // escrowAddress: '', - // hasSignature: false, - // oracleType: OracleType.FORTUNE, - // retriesCount: 0, - // status: WebhookStatus.PENDING, - // waitUntil: expect.any(Date), - // }); - // }); - - // it('should create a new incoming webhook', async () => { - // const dto = { - // chainId: ChainId.LOCALHOST, - // escrowAddress: '', - // }; - - // jest - // .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') - // .mockResolvedValue(undefined as any); - - // await expect( - // webhookService.createIncomingWebhook(dto as any), - // ).rejects.toThrow(ErrorWebhook.InvalidEscrow); - // }); - // }); + describe('createIncomingWebhook', () => { + it('should create a new incoming webhook', async () => { + const dto = { + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + }; + + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce({ requestType: JobRequestType.FORTUNE } as any); + jest + .spyOn(jobService, 'getOracleType') + .mockReturnValueOnce(OracleType.FORTUNE); + const result = await (webhookService as any).createIncomingWebhook( + dto as any, + ); + + expect(result).toBe(undefined); + expect(webhookRepository.createUnique).toHaveBeenCalledWith({ + chainId: ChainId.LOCALHOST, + escrowAddress: dto.escrowAddress, + hasSignature: false, + oracleType: OracleType.FORTUNE, + retriesCount: 0, + status: WebhookStatus.PENDING, + waitUntil: expect.any(Date), + }); + }); + + it('should create a new incoming webhook', async () => { + const dto = { + chainId: ChainId.LOCALHOST, + escrowAddress: faker.finance.ethereumAddress(), + }; + + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValueOnce(undefined as any); + + await expect( + (webhookService as any).createIncomingWebhook(dto as any), + ).rejects.toThrow(ErrorWebhook.InvalidEscrow); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts index cdc7c0629f..7a6076ee20 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts @@ -139,9 +139,9 @@ export class WebhookService { await this.jobService.escrowFailedWebhook(webhook); break; - // case EventType.ABUSE_DETECTED: - // await this.createIncomingWebhook(webhook); - // break; + case EventType.ABUSE_DETECTED: + await this.createIncomingWebhook(webhook); + break; default: throw new ControlledError( @@ -151,26 +151,26 @@ export class WebhookService { } } - // public async createIncomingWebhook(webhook: WebhookDataDto): Promise { - // const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( - // webhook.chainId, - // webhook.escrowAddress, - // ); - - // if (!jobEntity) { - // throw new ControlledError( - // ErrorWebhook.InvalidEscrow, - // HttpStatus.BAD_REQUEST, - // ); - // } - - // const webhookEntity = new WebhookEntity(); - // Object.assign(webhookEntity, webhook); - // webhookEntity.oracleType = this.jobService.getOracleType( - // jobEntity.requestType, - // ); - // webhookEntity.hasSignature = false; - - // this.webhookRepository.createUnique(webhookEntity); - // } + private async createIncomingWebhook(webhook: WebhookDataDto): Promise { + const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhook.chainId, + webhook.escrowAddress, + ); + + if (!jobEntity) { + throw new ControlledError( + ErrorWebhook.InvalidEscrow, + HttpStatus.BAD_REQUEST, + ); + } + + const webhookEntity = new WebhookEntity(); + Object.assign(webhookEntity, webhook); + webhookEntity.oracleType = this.jobService.getOracleType( + jobEntity.requestType, + ); + webhookEntity.hasSignature = false; + + this.webhookRepository.createUnique(webhookEntity); + } } diff --git a/packages/apps/job-launcher/server/src/modules/whitelist/whitelist.service.spec.ts b/packages/apps/job-launcher/server/src/modules/whitelist/whitelist.service.spec.ts index 99448f4f1f..cdb5995884 100644 --- a/packages/apps/job-launcher/server/src/modules/whitelist/whitelist.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/whitelist/whitelist.service.spec.ts @@ -23,7 +23,6 @@ describe('WhitelistService', () => { moduleRef.get(WhitelistRepository); }); - // TODO: Enable it when billing system is active describe('isUserWhitelisted', () => { it('should return true if user is whitelisted', async () => { const userId = 1; @@ -33,7 +32,7 @@ describe('WhitelistService', () => { const result = await whitelistService.isUserWhitelisted(userId); - // expect(whitelistRepository.findOneByUserId).toHaveBeenCalledWith(userId); + expect(whitelistRepository.findOneByUserId).toHaveBeenCalledWith(userId); expect(result).toBe(true); }); diff --git a/packages/apps/reputation-oracle/server/package.json b/packages/apps/reputation-oracle/server/package.json index 2cff88a632..e9589dc0f1 100644 --- a/packages/apps/reputation-oracle/server/package.json +++ b/packages/apps/reputation-oracle/server/package.json @@ -71,7 +71,7 @@ "@faker-js/faker": "^9.4.0", "@golevelup/ts-jest": "^0.6.1", "@nestjs/cli": "^10.3.2", - "@nestjs/schematics": "^10.1.3", + "@nestjs/schematics": "^11.0.2", "@nestjs/testing": "^10.4.6", "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.13", diff --git a/packages/apps/reputation-oracle/server/src/common/constants/index.ts b/packages/apps/reputation-oracle/server/src/common/constants/index.ts index 2d1b98eeb5..d964f6ea18 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -4,6 +4,10 @@ export const JWT_STRATEGY_NAME = 'jwt-http'; export const CVAT_RESULTS_ANNOTATIONS_FILENAME = 'resulting_annotations.zip'; export const CVAT_VALIDATION_META_FILENAME = 'validation_meta.json'; + +export const AUDINO_RESULTS_ANNOTATIONS_FILENAME = 'resulting_annotations.zip'; +export const AUDINO_VALIDATION_META_FILENAME = 'validation_meta.json'; + export const DEFAULT_BULK_PAYOUT_TX_ID = 1; export const HEADER_SIGNATURE_KEY = 'human-signature'; diff --git a/packages/apps/reputation-oracle/server/src/common/enums/job.ts b/packages/apps/reputation-oracle/server/src/common/enums/job.ts index 6f9c4b449b..635a565a6d 100644 --- a/packages/apps/reputation-oracle/server/src/common/enums/job.ts +++ b/packages/apps/reputation-oracle/server/src/common/enums/job.ts @@ -5,6 +5,7 @@ export enum JobRequestType { IMAGE_SKELETONS_FROM_BOXES = 'image_skeletons_from_boxes', FORTUNE = 'fortune', IMAGE_POLYGONS = 'image_polygons', + AUDIO_TRANSCRIPTION = 'audio_transcription', } export enum SolutionError { diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/job-result.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/job-result.ts index a02680c699..747744d3a6 100644 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/job-result.ts +++ b/packages/apps/reputation-oracle/server/src/common/interfaces/job-result.ts @@ -22,3 +22,20 @@ export interface CvatAnnotationMeta { jobs: CvatAnnotationMetaJobs[]; results: CvatAnnotationMetaResults[]; } + +interface AudinoAnnotationMetaJob { + job_id: number; + final_result_id: number; +} + +export interface AudinoAnnotationMetaResult { + id: number; + job_id: number; + annotator_wallet_address: string; + annotation_quality: number; +} + +export interface AudinoAnnotationMeta { + jobs: AudinoAnnotationMetaJob[]; + results: AudinoAnnotationMetaResult[]; +} diff --git a/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts b/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts index ddd0ad6b4a..9d57807d6f 100644 --- a/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts +++ b/packages/apps/reputation-oracle/server/src/common/interfaces/manifest.ts @@ -36,3 +36,30 @@ export interface FortuneManifest { fundAmount: number; requestType: JobRequestType; } + +interface AudinoData { + data_url: string; +} + +interface AudinoLabel { + name: string; +} + +interface AudinoValidation { + gt_url: string; + min_quality: number; +} + +interface AudinoAnnotation { + type: JobRequestType; + labels: AudinoLabel[]; + description: string; + segment_duration: number; +} + +export interface AudinoManifest { + data: AudinoData; + annotation: AudinoAnnotation; + job_bounty: string; + validation: AudinoValidation; +} diff --git a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts index 81c24aa0e1..e192d2f4fb 100644 --- a/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/auth-config.service.ts @@ -22,43 +22,47 @@ export class AuthConfigService { } /** - * The expiration time (in seconds) for access tokens. - * Default: 600 + * The expiration time (in ms) for access tokens. + * Default: 600000 */ get accessTokenExpiresIn(): number { - return +this.configService.get('JWT_ACCESS_TOKEN_EXPIRES_IN', 600); + return ( + +this.configService.get('JWT_ACCESS_TOKEN_EXPIRES_IN', 600) * 1000 + ); } /** - * The expiration time (in seconds) for refresh tokens. - * Default: 3600 + * The expiration time (in ms) for refresh tokens. + * Default: 3600000 */ get refreshTokenExpiresIn(): number { - return +this.configService.get( - 'JWT_REFRESH_TOKEN_EXPIRES_IN', - 3600, + return ( + +this.configService.get('JWT_REFRESH_TOKEN_EXPIRES_IN', 3600) * + 1000 ); } /** - * The expiration time (in seconds) for email verification tokens. - * Default: 86400 + * The expiration time (in ms) for email verification tokens. + * Default: 86400000 */ get verifyEmailTokenExpiresIn(): number { - return +this.configService.get( - 'VERIFY_EMAIL_TOKEN_EXPIRES_IN', - 86400, + return ( + +this.configService.get('VERIFY_EMAIL_TOKEN_EXPIRES_IN', 86400) * + 1000 ); } /** - * The expiration time (in seconds) for forgot password tokens. - * Default: 86400 + * The expiration time (in ms) for forgot password tokens. + * Default: 86400000 */ get forgotPasswordExpiresIn(): number { - return +this.configService.get( - 'FORGOT_PASSWORD_TOKEN_EXPIRES_IN', - 86400, + return ( + +this.configService.get( + 'FORGOT_PASSWORD_TOKEN_EXPIRES_IN', + 86400, + ) * 1000 ); } diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1742377717756-updateTokenEntity.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1742377717756-updateTokenEntity.ts new file mode 100644 index 0000000000..2f1fdc1c36 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1742377717756-updateTokenEntity.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateTokenEntity1742377717756 implements MigrationInterface { + name = 'UpdateTokenEntity1742377717756'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "hmt"."IDX_306030d9411d291750fd115857"`, + ); + await queryRunner.query( + `ALTER TYPE "hmt"."tokens_type_enum" RENAME TO "tokens_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."tokens_type_enum" AS ENUM('email', 'password', 'refresh')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" ALTER COLUMN "type" TYPE "hmt"."tokens_type_enum" USING "type"::"text"::"hmt"."tokens_type_enum"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."tokens_type_enum_old"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_306030d9411d291750fd115857" ON "hmt"."tokens" ("type", "user_id") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "hmt"."IDX_306030d9411d291750fd115857"`, + ); + await queryRunner.query( + `CREATE TYPE "hmt"."tokens_type_enum_old" AS ENUM('EMAIL', 'PASSWORD', 'REFRESH')`, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."tokens" ALTER COLUMN "type" TYPE "hmt"."tokens_type_enum_old" USING "type"::"text"::"hmt"."tokens_type_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "hmt"."tokens_type_enum"`); + await queryRunner.query( + `ALTER TYPE "hmt"."tokens_type_enum_old" RENAME TO "tokens_type_enum"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_306030d9411d291750fd115857" ON "hmt"."tokens" ("type", "user_id") `, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts index 351f3fed96..523095a796 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.controller.ts @@ -64,7 +64,32 @@ export class AuthController { @Post('/web2/signup') @HttpCode(200) async signup(@Body() data: Web2SignUpDto): Promise { - await this.authService.signup(data); + await this.authService.signup(data.email, data.password); + } + + @ApiOperation({ + summary: 'Web3 user signup', + description: 'Endpoint for Web3 user registration', + }) + @ApiBody({ type: Web3SignUpDto }) + @ApiResponse({ + status: 200, + description: 'User registered successfully', + type: SuccessAuthDto, + }) + @ApiResponse({ + status: 409, + description: 'User with provided address already registered', + }) + @Public() + @Post('/web3/signup') + @HttpCode(200) + async web3SignUp(@Body() data: Web3SignUpDto): Promise { + const authTokens = await this.authService.web3Signup( + data.signature, + data.address, + ); + return authTokens; } @ApiOperation({ @@ -82,7 +107,46 @@ export class AuthController { @Post('/web2/signin') @HttpCode(200) async signin(@Body() data: Web2SignInDto): Promise { - const authTokens = await this.authService.signin(data); + const authTokens = await this.authService.signin(data.email, data.password); + return authTokens; + } + + @ApiOperation({ + summary: 'Web3 user signin', + description: 'Endpoint for Web3 user authentication', + }) + @ApiBody({ type: Web3SignInDto }) + @ApiResponse({ + status: 200, + description: 'User authenticated successfully', + type: SuccessAuthDto, + }) + @Public() + @Post('/web3/signin') + @HttpCode(200) + async web3SignIn(@Body() data: Web3SignInDto): Promise { + const authTokens = await this.authService.web3Signin( + data.address, + data.signature, + ); + return authTokens; + } + + @ApiBody({ type: RefreshDto }) + @ApiOperation({ + summary: 'Refresh token', + description: 'Endpoint to refresh the authentication token.', + }) + @ApiResponse({ + status: 200, + description: 'Token refreshed successfully', + type: SuccessAuthDto, + }) + @Public() + @Post('/refresh') + @HttpCode(200) + async refreshToken(@Body() data: RefreshDto): Promise { + const authTokens = await this.authService.refresh(data.refreshToken); return authTokens; } @@ -100,7 +164,7 @@ export class AuthController { @Post('/web2/forgot-password') @HttpCode(200) async forgotPassword(@Body() data: ForgotPasswordDto): Promise { - await this.authService.forgotPassword(data); + await this.authService.forgotPassword(data.email); } @ApiOperation({ @@ -117,7 +181,7 @@ export class AuthController { @Post('/web2/restore-password') @HttpCode(200) async restorePassword(@Body() data: RestorePasswordDto): Promise { - await this.authService.restorePassword(data); + await this.authService.restorePassword(data.password, data.token); } @ApiOperation({ @@ -133,7 +197,7 @@ export class AuthController { @Post('/web2/verify-email') @HttpCode(200) async emailVerification(@Body() data: VerifyEmailDto): Promise { - await this.authService.emailVerification(data); + await this.authService.emailVerification(data.token); } @ApiOperation({ @@ -150,67 +214,9 @@ export class AuthController { @Post('/web2/resend-verification-email') @HttpCode(200) async resendEmailVerification( - @Body() data: ResendVerificationEmailDto, + @Req() request: RequestWithUser, ): Promise { - await this.authService.resendEmailVerification(data); - } - - @ApiOperation({ - summary: 'Web3 user signup', - description: 'Endpoint for Web3 user registration', - }) - @ApiBody({ type: Web3SignUpDto }) - @ApiResponse({ - status: 200, - description: 'User registered successfully', - type: SuccessAuthDto, - }) - @ApiResponse({ - status: 409, - description: 'User with provided address already registered', - }) - @Public() - @Post('/web3/signup') - @HttpCode(200) - async web3SignUp(@Body() data: Web3SignUpDto): Promise { - const authTokens = await this.authService.web3Signup(data); - return authTokens; - } - - @ApiOperation({ - summary: 'Web3 user signin', - description: 'Endpoint for Web3 user authentication', - }) - @ApiBody({ type: Web3SignInDto }) - @ApiResponse({ - status: 200, - description: 'User authenticated successfully', - type: SuccessAuthDto, - }) - @Public() - @Post('/web3/signin') - @HttpCode(200) - async web3SignIn(@Body() data: Web3SignInDto): Promise { - const authTokens = await this.authService.web3Signin(data); - return authTokens; - } - - @ApiBody({ type: RefreshDto }) - @ApiOperation({ - summary: 'Refresh token', - description: 'Endpoint to refresh the authentication token.', - }) - @ApiResponse({ - status: 200, - description: 'Token refreshed successfully', - type: SuccessAuthDto, - }) - @Public() - @Post('/refresh') - @HttpCode(200) - async refreshToken(@Body() data: RefreshDto): Promise { - const authTokens = await this.authService.refresh(data); - return authTokens; + await this.authService.resendEmailVerification(request.user); } @ApiOperation({ diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.filter.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.filter.ts index 3274dea31e..4b431061b0 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.filter.ts @@ -6,25 +6,28 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; +import logger from '../../logger'; + import { AuthError, DuplicatedUserAddressError, DuplicatedUserEmailError, + InactiveUserError, InvalidOperatorSignupDataError, } from './auth.error'; -import logger from '../../logger'; - type AuthControllerError = | AuthError + | DuplicatedUserAddressError | DuplicatedUserEmailError - | InvalidOperatorSignupDataError - | DuplicatedUserAddressError; + | InactiveUserError + | InvalidOperatorSignupDataError; @Catch( AuthError, - DuplicatedUserEmailError, DuplicatedUserAddressError, + DuplicatedUserEmailError, + InactiveUserError, InvalidOperatorSignupDataError, ) export class AuthControllerErrorsFilter implements ExceptionFilter { @@ -46,6 +49,9 @@ export class AuthControllerErrorsFilter implements ExceptionFilter { this.logger.error('Auth conflict', exception); } else if (exception instanceof InvalidOperatorSignupDataError) { status = HttpStatus.BAD_REQUEST; + } else if (exception instanceof InactiveUserError) { + status = HttpStatus.FORBIDDEN; + this.logger.error('Auth error', exception); } return response.status(status).json({ diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts index f9c643dd5a..a3634ddede 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.error.ts @@ -4,6 +4,10 @@ export enum AuthErrorMessage { INVALID_CREDENTIALS = 'Invalid email or password', INVALID_REFRESH_TOKEN = 'Refresh token is not valid', REFRESH_TOKEN_EXPIRED = 'Refresh token expired', + PASSWORD_TOKEN_EXPIRED = 'Password token expired', + INVALID_PASSWORD_TOKEN = 'Password token is not valid', + EMAIL_TOKEN_EXPIRED = 'Email token expired', + INVALID_EMAIL_TOKEN = 'Email token is not valid', INVALID_WEB3_SIGNATURE = 'Invalid signature', INVALID_ADDRESS = 'Invalid address', } @@ -15,7 +19,7 @@ export class AuthError extends BaseError { } export class InvalidOperatorSignupDataError extends BaseError { - constructor(public readonly detail: string) { + constructor(readonly detail: string) { super('Invalid operator signup data'); } } @@ -38,14 +42,8 @@ export class InvalidOperatorUrlError extends InvalidOperatorSignupDataError { } } -export class InvalidOperatorJobTypesError extends InvalidOperatorSignupDataError { - constructor(url: string) { - super(`Invalid job types: ${url}`); - } -} - export class DuplicatedUserEmailError extends BaseError { - constructor(public readonly email: string) { + constructor(readonly email: string) { super( 'The email you are trying to use already exists. Please check that the email is correct or use a different email.', ); @@ -53,9 +51,15 @@ export class DuplicatedUserEmailError extends BaseError { } export class DuplicatedUserAddressError extends BaseError { - constructor(public readonly address: string) { + constructor(readonly address: string) { super( 'The address you are trying to use already exists. Please, use a different address.', ); } } + +export class InactiveUserError extends BaseError { + constructor(readonly userId: number) { + super('User is in inactive status. Login forbidden.'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts index f8808993ce..799ca09542 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.module.ts @@ -7,9 +7,9 @@ import { EmailModule } from '../email/module'; import { UserModule } from '../user'; import { Web3Module } from '../web3/web3.module'; -import { JwtHttpStrategy } from './jwt-http-strategy'; -import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtHttpStrategy } from './jwt-http-strategy'; import { TokenRepository } from './token.repository'; @Module({ diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts index ec1fc2af6e..9ae39fe8b6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.spec.ts @@ -1,859 +1,1125 @@ +jest.mock('@human-protocol/sdk'); + +import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; -import { - KVStoreClient, - KVStoreUtils, - Role as SDKRole, -} from '@human-protocol/sdk'; -import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; -import { JwtService } from '@nestjs/jwt'; +import { KVStoreKeys, KVStoreUtils, Role } from '@human-protocol/sdk'; +import { JwtModule, JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import { faker } from '@faker-js/faker'; -import { v4 } from 'uuid'; -import { - MOCK_ACCESS_TOKEN, - MOCK_ADDRESS, - MOCK_EMAIL, - MOCK_FE_URL, - MOCK_HASHED_PASSWORD, - MOCK_HCAPTCHA_TOKEN, - MOCK_PASSWORD, - MOCK_PRIVATE_KEY, - MOCK_REFRESH_TOKEN, - mockConfig, -} from '../../../test/constants'; +import { omit } from 'lodash'; + +import { generateES256Keys } from '../../../test/fixtures/crypto'; +import { generateEthWallet } from '../../../test/fixtures/web3'; +import { SignatureType } from '../../common/enums/web3'; import { AuthConfigService } from '../../config/auth-config.service'; import { NDAConfigService } from '../../config/nda-config.service'; -import { HCaptchaConfigService } from '../../config/hcaptcha-config.service'; import { ServerConfigService } from '../../config/server-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; -import { JobRequestType } from '../../common/enums'; -import { SignatureType } from '../../common/enums/web3'; -import { - generateNonce, - prepareSignatureBody, - signMessage, -} from '../../utils/web3'; -import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; +import * as secutiryUtils from '../../utils/security'; import { SiteKeyRepository } from '../user/site-key.repository'; -import { PrepareSignatureDto } from '../user/user.dto'; +import * as web3Utils from '../../utils/web3'; +import { EmailAction } from '../email/constants'; +import { EmailService } from '../email/email.service'; import { UserStatus, UserRole, UserEntity, UserRepository, UserService, - type OperatorUserEntity, } from '../user'; -import { Web3Service } from '../web3/web3.service'; -import { - AuthError, - AuthErrorMessage, - DuplicatedUserEmailError, - InvalidOperatorFeeError, - InvalidOperatorJobTypesError, - InvalidOperatorRoleError, - InvalidOperatorUrlError, -} from './auth.error'; +import { generateOperator, generateWorkerUser } from '../user/fixtures'; +import { mockWeb3ConfigService } from '../web3/fixtures'; +import * as AuthErrors from './auth.error'; import { AuthService } from './auth.service'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; -import { EmailService } from '../email/email.service'; -import { EmailAction } from '../email/constants'; -jest.mock('@human-protocol/sdk', () => ({ - ...jest.requireActual('@human-protocol/sdk'), - KVStoreClient: { - build: jest.fn().mockImplementation(() => ({ - set: jest.fn(), - })), - }, - KVStoreUtils: { - get: jest.fn().mockResolvedValue(''), - }, -})); - -jest.mock('uuid', () => ({ - v4: jest.fn().mockReturnValue('mocked-uuid'), -})); +const mockKVStoreUtils = jest.mocked(KVStoreUtils); + +const { publicKey, privateKey } = generateES256Keys(); +const mockAuthConfigService = { + jwtPrivateKey: privateKey, + jwtPublicKey: publicKey, + accessTokenExpiresIn: 600000, + refreshTokenExpiresIn: 3600000, + verifyEmailTokenExpiresIn: 86400000, + forgotPasswordExpiresIn: 86400000, + humanAppEmail: faker.internet.email(), +}; + +const mockEmailService = createMock(); const mockNdaConfigService = { latestNdaUrl: faker.internet.url(), }; +const mockServerConfigService = { + feURL: faker.internet.url(), +}; + +const mockSiteKeyRepository = createMock(); +const mockTokenRepository = createMock(); +const mockUserRepository = createMock(); +const mockUserService = createMock(); + describe('AuthService', () => { - let authService: AuthService; - let tokenRepository: TokenRepository; - let userService: UserService; - let userRepository: UserRepository; + let service: AuthService; let jwtService: JwtService; - let emailService: EmailService; - let authConfigService: AuthConfigService; - let hcaptchaService: HCaptchaService; beforeAll(async () => { - const signerMock = { - address: MOCK_ADDRESS, - getNetwork: jest.fn().mockResolvedValue({ chainId: 1 }), - }; - - const moduleRef = await Test.createTestingModule({ + const module = await Test.createTestingModule({ + imports: [ + JwtModule.registerAsync({ + useFactory: () => ({ + privateKey: mockAuthConfigService.jwtPrivateKey, + signOptions: { + algorithm: 'ES256', + expiresIn: mockAuthConfigService.accessTokenExpiresIn, + }, + }), + }), + ], providers: [ - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string) => mockConfig[key]), - getOrThrow: jest.fn((key: string) => { - if (!mockConfig[key]) { - throw new Error(`Configuration key "${key}" does not exist`); - } - return mockConfig[key]; - }), - }, - }, + { provide: AuthConfigService, useValue: mockAuthConfigService }, AuthService, - UserService, - AuthConfigService, + { provide: EmailService, useValue: mockEmailService }, { provide: NDAConfigService, useValue: mockNdaConfigService }, - ServerConfigService, - Web3ConfigService, - HCaptchaService, - HCaptchaConfigService, - { - provide: JwtService, - useValue: { - signAsync: jest.fn(), - }, - }, - { provide: TokenRepository, useValue: createMock() }, - { provide: UserRepository, useValue: createMock() }, - { - provide: SiteKeyRepository, - useValue: createMock(), - }, - { provide: HttpService, useValue: createMock() }, - { provide: EmailService, useValue: createMock() }, - { - provide: Web3Service, - useValue: { - getSigner: jest.fn().mockReturnValue(signerMock), - }, - }, + { provide: ServerConfigService, useValue: mockServerConfigService }, + { provide: SiteKeyRepository, useValue: mockSiteKeyRepository }, + { provide: TokenRepository, useValue: mockTokenRepository }, + { provide: UserRepository, useValue: mockUserRepository }, + { provide: UserService, useValue: mockUserService }, + { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, ], }).compile(); - authService = moduleRef.get(AuthService); - tokenRepository = moduleRef.get(TokenRepository); - userService = moduleRef.get(UserService); - userRepository = moduleRef.get(UserRepository); - jwtService = moduleRef.get(JwtService); - emailService = moduleRef.get(EmailService); - authConfigService = moduleRef.get(AuthConfigService); - hcaptchaService = moduleRef.get(HCaptchaService); - - hcaptchaService.verifyToken = jest.fn().mockReturnValue({ success: true }); + service = module.get(AuthService); + jwtService = module.get(JwtService); }); afterEach(() => { - jest.restoreAllMocks(); + jest.resetAllMocks(); }); - describe('signin', () => { - const signInDto = { - email: MOCK_EMAIL, - password: MOCK_PASSWORD, - hCaptchaToken: MOCK_HCAPTCHA_TOKEN, - }; - - const userEntity: Partial = { - id: 1, - email: signInDto.email, - password: MOCK_HASHED_PASSWORD, - status: UserStatus.ACTIVE, - role: UserRole.WORKER, - }; - - let findOneByEmailMock: any; - - beforeEach(() => { - findOneByEmailMock = jest.spyOn(userRepository, 'findOneByEmail'); - - jest.spyOn(authService, 'auth').mockResolvedValue({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, + describe('signup', () => { + it('should create a new user', async () => { + const email = faker.internet.email(); + const password = faker.string.alphanumeric(); + const userId = faker.number.int(); + const tokenUuid = faker.string.uuid(); + + mockUserRepository.findOneByEmail.mockResolvedValueOnce(null); + mockUserRepository.createUnique.mockResolvedValueOnce({ + id: userId, + } as UserEntity); + mockTokenRepository.createUnique.mockResolvedValueOnce({ + uuid: tokenUuid, + } as TokenEntity); + + await service.signup(email, password); + + expect(mockUserRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockUserRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + email, + role: UserRole.WORKER, + status: UserStatus.PENDING, + }), + ); + + const user = mockUserRepository.createUnique.mock.calls[0][0]; + expect( + secutiryUtils.comparePasswordWithHash( + password, + user.password as string, + ), + ).toBe(true); + + expect(mockTokenRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.createUnique).toHaveBeenCalledWith({ + type: TokenType.EMAIL, + userId, + expiresAt: expect.any(Date), }); - }); - afterEach(() => { - jest.clearAllMocks(); + expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith( + email, + EmailAction.SIGNUP, + { + url: `${mockServerConfigService.feURL}/verify?token=${tokenUuid}`, + }, + ); }); - it('should sign in the user and return the JWT', async () => { - findOneByEmailMock.mockResolvedValue(userEntity as UserEntity); + it('should throw a DuplicatedUserEmailError', async () => { + const email = faker.internet.email(); + const password = faker.string.alphanumeric(); - const result = await authService.signin(signInDto); + mockUserRepository.findOneByEmail.mockResolvedValueOnce({ + email, + } as UserEntity); - expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email, { - relations: { - kyc: true, - siteKeys: true, - userQualifications: { - qualification: true, - }, - }, + await expect(service.signup(email, password)).rejects.toThrow( + new AuthErrors.DuplicatedUserEmailError(email), + ); + }); + }); + + describe('web3Signup', () => { + it('should create a new operator', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, }); - expect(authService.auth).toHaveBeenCalledWith(userEntity); - expect(result).toStrictEqual({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); + + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return Role.ExchangeOracle; + } + if (key === KVStoreKeys.fee) { + return String(faker.number.int({ min: 1, max: 50 })); + } + if (key === KVStoreKeys.url) { + return faker.internet.url(); + } + + throw new Error('Invalid key'); + }, + ); + + const spyOnWeb3Auth = jest + .spyOn(service, 'web3Auth') + .mockImplementation(); + spyOnWeb3Auth.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); + + await service.web3Signup(signature, ethWallet.address); + + expect(mockUserRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockUserRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + evmAddress: ethWallet.address.toLowerCase(), + nonce: expect.any(String), + role: UserRole.OPERATOR, + status: UserStatus.ACTIVE, + }), + ); + + expect(service.web3Auth).toHaveBeenCalledTimes(1); + + spyOnWeb3Auth.mockRestore(); }); - it('should throw if user credentials are invalid', async () => { - findOneByEmailMock.mockResolvedValue(undefined); + it('should throw DuplicatedUserAddressError', async () => { + const ethWallet = generateEthWallet(); + const signature = faker.string.alpha(); + mockUserRepository.findOneByAddress.mockImplementationOnce( + async (address) => { + if (address === ethWallet.address) { + return { + evmAddress: ethWallet.address, + } as UserEntity; + } + return null; + }, + ); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow( + new AuthErrors.DuplicatedUserAddressError(ethWallet.address), + ); + }); - await expect(authService.signin(signInDto)).rejects.toThrow( - new AuthError(AuthErrorMessage.INVALID_CREDENTIALS), + it('should throw AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE)', async () => { + const ethWallet = generateEthWallet(); + const signature = faker.string.alpha(); + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_WEB3_SIGNATURE, + ), ); + }); - expect(findOneByEmailMock).toHaveBeenCalledWith(signInDto.email, { - relations: { - kyc: true, - siteKeys: true, - userQualifications: { - qualification: true, - }, + it('should throw InvalidOperatorRoleError for invalid role', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); + + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + const mockedRole = faker.string.alpha(); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return mockedRole; + } + + throw new Error('Invalid key'); }, + ); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorRoleError(mockedRole)); + }); + + it('should throw InvalidOperatorRoleError when role is not set', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); + + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + const mockedRole = faker.string.alpha(); + mockKVStoreUtils.get.mockImplementation(async () => { + throw new Error('Invalid key'); }); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorRoleError(mockedRole)); }); - }); - describe('signup', () => { - const userCreateDto = { - email: MOCK_EMAIL, - password: MOCK_PASSWORD, - hCaptchaToken: 'token', - }; + it('should throw InvalidOperatorFeeError for invalid fee', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); - const userEntity: Partial & - Pick = { - id: 1, - email: userCreateDto.email, - password: MOCK_HASHED_PASSWORD, - role: UserRole.WORKER, - }; + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return Role.ExchangeOracle; + } + if (key === KVStoreKeys.fee) { + return ''; + } + + throw new Error('Invalid key'); + }, + ); - let createUserMock: any; + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorFeeError('')); + }); - beforeEach(() => { - createUserMock = jest.spyOn(userService, 'createWorkerUser'); + it('should throw InvalidOperatorFeeError when fee is not set', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); - createUserMock.mockResolvedValue(userEntity); + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return Role.ExchangeOracle; + } - jest.spyOn(userRepository, 'findOneByEmail').mockResolvedValue(null); + throw new Error('Invalid key'); + }, + ); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorFeeError('')); }); - afterEach(() => { - jest.clearAllMocks(); + it.each([ + '', + `${faker.string.alpha()}.test`, + `ftp://${faker.string.alpha()}.test`, + ])( + 'should throw InvalidOperatorUrlError for invalid url [%#]', + async (invalidUrl) => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); + + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return Role.ExchangeOracle; + } + if (key === KVStoreKeys.fee) { + return String(faker.number.int({ min: 1, max: 50 })); + } + if (key === KVStoreKeys.url) { + return invalidUrl; + } + + throw new Error('Invalid key'); + }, + ); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorUrlError(invalidUrl)); + }, + ); + + it('should throw InvalidOperatorFeeError when url is not set', async () => { + const ethWallet = generateEthWallet(); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); + + mockUserRepository.findOneByAddress.mockResolvedValueOnce(null); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === KVStoreKeys.role) { + return Role.ExchangeOracle; + } + if (key === KVStoreKeys.fee) { + return String(faker.number.int({ min: 1, max: 50 })); + } + + throw new Error('Invalid key'); + }, + ); + + await expect( + service.web3Signup(signature, ethWallet.address), + ).rejects.toThrow(new AuthErrors.InvalidOperatorFeeError('')); }); + }); - it('should create a new user and return the user entity', async () => { - await authService.signup(userCreateDto); + describe('signin', () => { + it('should signin user', async () => { + const password = faker.string.alphanumeric(); + const user = generateWorkerUser({ password }); - expect(userService.createWorkerUser).toHaveBeenCalledWith(userCreateDto); - expect(tokenRepository.createUnique).toHaveBeenCalledWith({ - type: TokenType.EMAIL, - userId: userEntity.id, - expiresAt: expect.any(Date), + mockUserService.findWeb2UserByEmail.mockResolvedValueOnce(user); + + const spyOnAuth = jest.spyOn(service, 'auth').mockImplementation(); + spyOnAuth.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); + + await service.signin(user.email, password); + + expect(service.auth).toHaveBeenCalledTimes(1); + expect(service.auth).toHaveBeenCalledWith(user); + + spyOnAuth.mockRestore(); }); - it("should call emailService sendEmail if user's email is valid", async () => { - emailService.sendEmail = jest.fn(); + it('should throw AuthError(AuthErrorMessage.INVALID_CREDENTIALS) if user not found', async () => { + const email = faker.internet.email(); + const password = faker.string.alphanumeric(); - await authService.signup(userCreateDto); + mockUserService.findWeb2UserByEmail.mockResolvedValueOnce(null); - expect(emailService.sendEmail).toHaveBeenCalled(); + await expect(service.signin(email, password)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_CREDENTIALS, + ), + ); }); - it('should fail if the user already exists', async () => { - jest - .spyOn(userRepository, 'findOneByEmail') - .mockResolvedValue(userEntity as any); + it('should throw AuthError(AuthErrorMessage.INVALID_CREDENTIALS) if password does not match', async () => { + const invalidPassword = faker.string.alphanumeric(); + const user = generateWorkerUser(); - await expect(authService.signup(userCreateDto)).rejects.toThrow( - new DuplicatedUserEmailError(userEntity.email as string), + mockUserService.findWeb2UserByEmail.mockResolvedValueOnce(user); + + await expect(service.signin(user.email, invalidPassword)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_CREDENTIALS, + ), ); + }); + + it('should throw InactiveUserError if user status is `inactive`', async () => { + const password = faker.string.alphanumeric(); + const user = generateWorkerUser({ + password, + status: UserStatus.INACTIVE, + }); + + mockUserService.findWeb2UserByEmail.mockResolvedValueOnce(user); - expect(userRepository.findOneByEmail).toHaveBeenCalledWith( - userEntity.email, + await expect(service.signin(user.email, password)).rejects.toThrow( + new AuthErrors.InactiveUserError(user.id), ); }); }); - describe('auth', () => { - let jwtSignMock: any, findTokenMock: any; + describe('web3Signin', () => { + it('should signin operator', async () => { + const ethWallet = generateEthWallet(); + const operator = generateOperator({ privateKey: ethWallet.privateKey }); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNIN, + nonce: operator.nonce, + }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); - const userEntity: Partial = { - id: 1, - email: 'user@example.com', - status: UserStatus.ACTIVE, - evmAddress: MOCK_ADDRESS, - }; + mockUserService.findOperatorUser.mockResolvedValueOnce(operator); - beforeEach(() => { - jwtSignMock = jest - .spyOn(jwtService, 'signAsync') - .mockResolvedValueOnce(MOCK_ACCESS_TOKEN); - }); + const spyOnWeb3Auth = jest + .spyOn(service, 'web3Auth') + .mockImplementation(); + spyOnWeb3Auth.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), + }); - afterEach(() => { - jest.clearAllMocks(); + await service.web3Signin(ethWallet.address, signature); + + expect(mockUserRepository.updateOneById).toHaveBeenCalledWith( + operator.id, + { nonce: expect.any(String) }, + ); + expect(service.web3Auth).toHaveBeenCalledTimes(1); + expect(service.web3Auth).toHaveBeenCalledWith(operator); + + spyOnWeb3Auth.mockRestore(); }); - it('should create authentication tokens and return them', async () => { - findTokenMock = jest - .spyOn(tokenRepository, 'findOneByUserIdAndType') - .mockResolvedValueOnce(null); + it('should throw AuthError(AuthErrorMessage.INVALID_ADDRESS)', async () => { + const ethWallet = generateEthWallet(); + const signature = faker.string.alpha(); - const result = await authService.auth(userEntity as UserEntity); - expect(findTokenMock).toHaveBeenCalledWith( - userEntity.id, - TokenType.REFRESH, - ); - expect(jwtSignMock).toHaveBeenLastCalledWith( - { - email: userEntity.email, - status: userEntity.status, - userId: userEntity.id, - wallet_address: userEntity.evmAddress, - kyc_status: userEntity.kyc?.status, - reputation_network: MOCK_ADDRESS, - nda_signed: false, - qualifications: [], - role: userEntity.role, - }, - { - expiresIn: authConfigService.accessTokenExpiresIn, - }, + mockUserService.findOperatorUser.mockResolvedValueOnce(null); + + await expect( + service.web3Signin(ethWallet.address, signature), + ).rejects.toThrow( + new AuthErrors.AuthError(AuthErrors.AuthErrorMessage.INVALID_ADDRESS), ); - expect(result).toEqual({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: undefined, - }); }); - describe('forgotPassword', () => { - let findOneByEmailMock: any, findTokenMock: any; - let userEntity: Partial, tokenEntity: Partial; + it('should throw AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE)', async () => { + const invalidSignature = faker.string.alphanumeric(); + const operator = generateOperator(); - beforeEach(() => { - userEntity = { - id: 1, - email: 'user@example.com', - status: UserStatus.ACTIVE, - }; - tokenEntity = { - uuid: v4(), - type: TokenType.EMAIL, - user: userEntity as UserEntity, - }; + mockUserService.findOperatorUser.mockResolvedValueOnce(operator); - findOneByEmailMock = jest.spyOn(userRepository, 'findOneByEmail'); - findTokenMock = jest.spyOn(tokenRepository, 'findOneByUserIdAndType'); - findOneByEmailMock.mockResolvedValue(userEntity); - }); + await expect( + service.web3Signin(operator.evmAddress, invalidSignature), + ).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_WEB3_SIGNATURE, + ), + ); + }); - afterEach(() => { - jest.clearAllMocks(); + it('should throw InactiveUserError if operator status in DB is `inactive`', async () => { + const ethWallet = generateEthWallet(); + const operator = generateOperator({ + privateKey: ethWallet.privateKey, + status: UserStatus.INACTIVE, + }); + const signatureBody = web3Utils.prepareSignatureBody({ + from: ethWallet.address, + to: mockWeb3ConfigService.operatorAddress, + contents: SignatureType.SIGNIN, + nonce: operator.nonce, }); + const signature = await web3Utils.signMessage( + signatureBody, + ethWallet.privateKey, + ); - it('should exit early w/o action if user is not found', () => { - findOneByEmailMock.mockResolvedValue(null); - expect( - authService.forgotPassword({ - email: 'user@example.com', - hCaptchaToken: 'token', - }), - ).resolves.toBeUndefined(); + mockUserService.findOperatorUser.mockResolvedValueOnce(operator); - expect(emailService.sendEmail).not.toHaveBeenCalledWith(); - }); + await expect( + service.web3Signin(operator.evmAddress, signature), + ).rejects.toThrow(new AuthErrors.InactiveUserError(operator.id)); + }); + }); - it('should remove existing token if it exists', async () => { - findTokenMock.mockResolvedValue(tokenEntity); - await authService.forgotPassword({ - email: 'user@example.com', - hCaptchaToken: 'token', - }); + describe('auth', () => { + it('should generate jwt payload for worker', async () => { + const user = generateWorkerUser(); + + mockSiteKeyRepository.findByUserAndType.mockResolvedValueOnce([]); + + const expectedJwtPayload = { + email: user.email, + status: user.status, + user_id: user.id, + wallet_address: user.evmAddress, + role: user.role, + kyc_status: user.kyc?.status, + nda_signed: user.ndaSignedUrl === mockNdaConfigService.latestNdaUrl, + reputation_network: mockWeb3ConfigService.operatorAddress, + qualifications: user.userQualifications + ? user.userQualifications.map( + (userQualification) => userQualification.qualification.reference, + ) + : [], + }; - expect(tokenRepository.deleteOne).toHaveBeenCalled(); + const spyOnGenerateTokens = jest + .spyOn(service, 'generateTokens') + .mockImplementation(); + spyOnGenerateTokens.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); - it('should create a new token and send email', async () => { - emailService.sendEmail = jest.fn(); - const email = faker.internet.email(); + await service.auth(user); - await authService.forgotPassword({ email, hCaptchaToken: 'token' }); + expect(service.generateTokens).toHaveBeenCalledTimes(1); + expect(service.generateTokens).toHaveBeenCalledWith( + user.id, + expectedJwtPayload, + ); - expect(emailService.sendEmail).toHaveBeenCalledWith( - email, - EmailAction.RESET_PASSWORD, - { - url: expect.stringContaining( - `${MOCK_FE_URL}/reset-password?token=`, - ), - }, - ); + spyOnGenerateTokens.mockRestore(); + }); + }); + + describe('web3Auth', () => { + it('should generate jwt payload for operator', async () => { + const operator = generateOperator(); + + const mockedOperatorStatus = 'active'; + const expectedJwtPayload = { + status: operator.status, + user_id: operator.id, + wallet_address: operator.evmAddress, + role: operator.role, + reputation_network: mockWeb3ConfigService.operatorAddress, + operator_status: mockedOperatorStatus, + }; + + const spyOnGenerateTokens = jest + .spyOn(service, 'generateTokens') + .mockImplementation(); + spyOnGenerateTokens.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); + mockKVStoreUtils.get.mockImplementation( + async (_chainId, _address, key) => { + if (key === operator.evmAddress) { + return mockedOperatorStatus; + } - it('should create a new token and send email if user is not active', async () => { - emailService.sendEmail = jest.fn(); - userEntity.status = UserStatus.PENDING; - const email = faker.internet.email(); + throw new Error('Invalid key'); + }, + ); - await authService.forgotPassword({ email, hCaptchaToken: 'token' }); + await service.web3Auth(operator); - expect(emailService.sendEmail).toHaveBeenCalledWith( - email, - EmailAction.RESET_PASSWORD, - { - url: expect.stringContaining( - `${MOCK_FE_URL}/reset-password?token=`, - ), - }, - ); - }); + expect(service.generateTokens).toHaveBeenCalledTimes(1); + expect(service.generateTokens).toHaveBeenCalledWith( + operator.id, + expectedJwtPayload, + ); + + spyOnGenerateTokens.mockRestore(); }); - describe('restorePassword', () => { - const userEntity: Partial = { - id: 1, - email: 'user@example.com', - }; + it('should generate jwt payload for operator when status is not set', async () => { + const operator = generateOperator(); - const tokenEntity: Partial = { - uuid: v4(), - type: TokenType.EMAIL, - userId: userEntity.id, + const expectedJwtPayload = { + status: operator.status, + user_id: operator.id, + wallet_address: operator.evmAddress, + role: operator.role, + reputation_network: mockWeb3ConfigService.operatorAddress, + operator_status: 'inactive', }; - let findTokenMock: any; - - beforeEach(() => { - findTokenMock = jest.spyOn(tokenRepository, 'findOneByUuidAndType'); - emailService.sendEmail = jest.fn(); + const spyOnGenerateTokens = jest + .spyOn(service, 'generateTokens') + .mockImplementation(); + spyOnGenerateTokens.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); - - afterEach(() => { - jest.clearAllMocks(); + mockKVStoreUtils.get.mockImplementation(async () => { + throw new Error('Invalid key'); }); - it('should throw an error if token is not found', () => { - findTokenMock.mockResolvedValue(null); + await service.web3Auth(operator); - expect( - authService.restorePassword({ - token: 'token', - password: 'password', - hCaptchaToken: 'token', - }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN), - ); - }); + expect(service.generateTokens).toHaveBeenCalledTimes(1); + expect(service.generateTokens).toHaveBeenCalledWith( + operator.id, + expectedJwtPayload, + ); - it('should throw an error if token is expired', () => { - tokenEntity.expiresAt = new Date(new Date().getDate() - 1); - findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); + spyOnGenerateTokens.mockRestore(); + }); + }); - expect( - authService.restorePassword({ - token: 'token', - password: 'password', - hCaptchaToken: 'token', - }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED), + describe('generateTokens', () => { + it.each([null, { uuid: faker.string.uuid() }])( + 'should generate access and refresh tokens for worker [%#]', + async (existingRefreshToken) => { + const user = generateWorkerUser(); + + const jwtPayload = { + email: user.email, + status: user.status, + user_id: user.id, + wallet_address: user.evmAddress, + role: user.role, + kyc_status: user.kyc?.status, + nda_signed: user.ndaSignedUrl === mockNdaConfigService.latestNdaUrl, + reputation_network: mockWeb3ConfigService.operatorAddress, + qualifications: user.userQualifications + ? user.userQualifications.map( + (userQualification) => + userQualification.qualification.reference, + ) + : [], + }; + + mockTokenRepository.findOneByUserIdAndType.mockResolvedValueOnce( + existingRefreshToken as TokenEntity, ); - }); - it('should update password and send email', async () => { - tokenEntity.expiresAt = new Date( - new Date().setDate(new Date().getDate() + 1), + const { accessToken } = await service.generateTokens( + user.id, + jwtPayload, ); - findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); - userService.updatePassword = jest - .fn() - .mockResolvedValueOnce(userEntity); - emailService.sendEmail = jest.fn(); - - const updatePasswordMock = jest.spyOn(userService, 'updatePassword'); - - await authService.restorePassword({ - token: 'token', - password: 'password', - hCaptchaToken: 'token', + + if (existingRefreshToken) { + expect(mockTokenRepository.deleteOne).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.deleteOne).toHaveBeenCalledWith( + existingRefreshToken as TokenEntity, + ); + } + + const decodedAccessToken = await jwtService.verifyAsync(accessToken, { + secret: mockAuthConfigService.jwtPrivateKey, }); - expect(updatePasswordMock).toHaveBeenCalled(); - expect(emailService.sendEmail).toHaveBeenCalled(); - expect(tokenRepository.deleteOne).toHaveBeenCalled(); + expect(omit(decodedAccessToken, ['exp', 'iat'])).toEqual(jwtPayload); + }, + ); + }); + + describe('refresh', () => { + it('should generate tokens for worker', async () => { + const user = generateWorkerUser(); + const uuid = faker.string.uuid(); + + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.future(), + } as TokenEntity); + + mockUserRepository.findOneById.mockResolvedValueOnce(user); + + const spyOnAuth = jest.spyOn(service, 'auth').mockImplementation(); + spyOnAuth.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); + + await service.refresh(uuid); + + expect(service.auth).toHaveBeenCalledTimes(1); + expect(service.auth).toHaveBeenCalledWith(user); + + spyOnAuth.mockRestore(); }); - describe('emailVerification', () => { - const userEntity: Partial = { - id: 1, - email: 'user@example.com', - }; + it('should generate tokens for operator', async () => { + const operator = generateOperator(); + const uuid = faker.string.uuid(); - const tokenEntity: Partial = { - uuid: v4(), - type: TokenType.EMAIL, - userId: userEntity.id, - }; + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.future(), + } as TokenEntity); - let findTokenMock: any; + mockUserRepository.findOneById.mockResolvedValueOnce(operator); - beforeEach(() => { - findTokenMock = jest.spyOn(tokenRepository, 'findOneByUuidAndType'); + const spyOnWeb3Auth = jest + .spyOn(service, 'web3Auth') + .mockImplementation(); + spyOnWeb3Auth.mockResolvedValueOnce({ + accessToken: faker.string.alpha(), + refreshToken: faker.string.uuid(), }); - afterEach(() => { - jest.clearAllMocks(); - }); + await service.refresh(uuid); - it('should throw an error if token is not found', () => { - findTokenMock.mockResolvedValue(null); - expect( - authService.emailVerification({ token: 'token' }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN), - ); - }); + expect(service.web3Auth).toHaveBeenCalledTimes(1); + expect(service.web3Auth).toHaveBeenCalledWith(operator); - it('should throw an error if token is expired', () => { - tokenEntity.expiresAt = new Date(new Date().getDate() - 1); - findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); - expect( - authService.emailVerification({ token: 'token' }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED), - ); - }); + spyOnWeb3Auth.mockRestore(); + }); + + it('should throw AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN) if token not found', async () => { + const uuid = faker.string.uuid(); + + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); + + await expect(service.refresh(uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_REFRESH_TOKEN, + ), + ); + }); + + it('should throw AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN) if user not found', async () => { + const uuid = faker.string.uuid(); + + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.future(), + } as TokenEntity); + + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + await expect(service.refresh(uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_REFRESH_TOKEN, + ), + ); + }); - it('should activate user', async () => { - tokenEntity.expiresAt = new Date( - new Date().setDate(new Date().getDate() + 1), + it('should throw AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED)', async () => { + const uuid = faker.string.uuid(); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.past(), + } as TokenEntity); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); + + await expect(service.refresh(uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.REFRESH_TOKEN_EXPIRED, + ), + ); + }); + }); + + describe('forgotPassword', () => { + it.each([null, { uuid: faker.string.uuid() }])( + 'should create a password token and send an email [%#]', + async (existingForgotPasswordToken) => { + const user = generateWorkerUser(); + const tokenUuid = faker.string.uuid(); + + mockUserRepository.findOneByEmail.mockResolvedValueOnce(user); + mockTokenRepository.findOneByUserIdAndType.mockResolvedValueOnce( + existingForgotPasswordToken as TokenEntity, ); - findTokenMock.mockResolvedValue(tokenEntity as TokenEntity); - userRepository.updateOneById = jest.fn(); + mockTokenRepository.createUnique.mockResolvedValueOnce({ + uuid: tokenUuid, + } as TokenEntity); - await authService.emailVerification({ token: 'token' }); + await service.forgotPassword(user.email); - expect(userRepository.updateOneById).toHaveBeenCalledWith( - userEntity.id, + if (existingForgotPasswordToken) { + expect(mockTokenRepository.deleteOne).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.deleteOne).toHaveBeenCalledWith( + existingForgotPasswordToken as TokenEntity, + ); + } + expect(mockTokenRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + type: TokenType.PASSWORD, + userId: user.id, + }), + ); + expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith( + user.email, + EmailAction.RESET_PASSWORD, { - status: UserStatus.ACTIVE, + url: `${mockServerConfigService.feURL}/reset-password?token=${tokenUuid}`, }, ); - }); + }, + ); + + it('should do nothing if email not found', async () => { + const email = faker.internet.email(); + mockUserRepository.findOneByEmail.mockResolvedValueOnce(null); + + await service.forgotPassword(email); + + expect(mockTokenRepository.findOneByUserIdAndType).not.toHaveBeenCalled(); + expect(mockTokenRepository.deleteOne).not.toHaveBeenCalled(); + expect(mockTokenRepository.createUnique).not.toHaveBeenCalled(); + expect(mockEmailService.sendEmail).not.toHaveBeenCalled(); }); + }); - describe('resendEmailVerification', () => { - let findOneByEmailMock: any, - findTokenMock: any, - createTokenMock: any, - userEntity: Partial; + describe('restorePassword', () => { + it('should change password and delete password token', async () => { + const newPassword = faker.string.alphanumeric(); + const mockToken = { + user: { + email: faker.internet.email(), + }, + userId: faker.number.int(), + uuid: faker.string.uuid(), + expiresAt: faker.date.future(), + }; - beforeEach(() => { - userEntity = { - id: 1, - email: 'user@example.com', - status: UserStatus.PENDING, - }; - findOneByEmailMock = jest.spyOn(userRepository, 'findOneByEmail'); - findTokenMock = jest.spyOn(tokenRepository, 'findOneByUserIdAndType'); - createTokenMock = jest.spyOn(tokenRepository, 'createUnique'); - }); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce( + mockToken as TokenEntity, + ); - afterEach(() => { - jest.clearAllMocks(); - }); + mockUserRepository.updateOneById.mockResolvedValueOnce(true); - it('should exit early w/o action if user is not found', () => { - findOneByEmailMock.mockResolvedValue(null); - expect( - authService.resendEmailVerification({ - email: 'user@example.com', - hCaptchaToken: 'token', - }), - ).resolves.toBeUndefined(); + await service.restorePassword(newPassword, mockToken.uuid); - expect(emailService.sendEmail).not.toHaveBeenCalledWith(); - }); + expect(mockUserRepository.updateOneById).toHaveBeenCalledTimes(1); + expect(mockUserRepository.updateOneById).toHaveBeenCalledWith( + mockToken.userId, + { + password: expect.any(String), + }, + ); - it('should exit early w/o action if user is not pending', () => { - userEntity.status = UserStatus.ACTIVE; - findOneByEmailMock.mockResolvedValue(userEntity); - expect( - authService.resendEmailVerification({ - email: 'user@example.com', - hCaptchaToken: 'token', - }), - ).resolves.toBeUndefined(); + expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith( + mockToken.user.email, + EmailAction.PASSWORD_CHANGED, + ); - expect(emailService.sendEmail).not.toHaveBeenCalledWith(); - }); + expect(mockTokenRepository.deleteOne).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.deleteOne).toHaveBeenCalledWith(mockToken); + }); - it('should create token and send email', async () => { - findOneByEmailMock.mockResolvedValue(userEntity); - findTokenMock.mockResolvedValueOnce(null); - emailService.sendEmail = jest.fn(); - const email = faker.internet.email(); + it('should not send an email and delete password token if password change unsuccessful', async () => { + const newPassword = faker.string.alphanumeric(); + const mockToken = { + user: { + email: faker.internet.email(), + }, + userId: faker.number.int(), + uuid: faker.string.uuid(), + expiresAt: faker.date.future(), + }; - await authService.resendEmailVerification({ - email, - hCaptchaToken: faker.string.alphanumeric(), - }); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce( + mockToken as TokenEntity, + ); - expect(createTokenMock).toHaveBeenCalled(); - expect(emailService.sendEmail).toHaveBeenCalledWith( - email, - EmailAction.SIGNUP, - { - url: expect.stringContaining(`${MOCK_FE_URL}/verify?token=`), - }, - ); - }); + mockUserRepository.updateOneById.mockResolvedValueOnce(false); + + await service.restorePassword(newPassword, mockToken.uuid); + + expect(mockUserRepository.updateOneById).toHaveBeenCalledTimes(1); + expect(mockUserRepository.updateOneById).toHaveBeenCalledWith( + mockToken.userId, + { + password: expect.any(String), + }, + ); + + expect(mockEmailService.sendEmail).not.toHaveBeenCalled(); + expect(mockTokenRepository.deleteOne).not.toHaveBeenCalled(); }); - describe('web3auth', () => { - describe('signin', () => { - const nonce = generateNonce(); - const nonce1 = generateNonce(); + it('should throw AuthError(AuthErrorMessage.INVALID_PASSWORD_TOKEN) if token not found', async () => { + const newPassword = faker.string.alphanumeric(); + const uuid = faker.string.uuid(); - const userEntity: Partial = { - id: 1, - evmAddress: MOCK_ADDRESS, - nonce, - }; + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); - let updateNonceMock: any; + await expect(service.restorePassword(newPassword, uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_PASSWORD_TOKEN, + ), + ); + }); - beforeEach(() => { - jest - .spyOn(userService, 'findOperatorUser') - .mockResolvedValue(userEntity as OperatorUserEntity); - updateNonceMock = jest.spyOn(userService, 'updateNonce'); + it('should throw AuthError(AuthErrorMessage.PASSWORD_TOKEN_EXPIRED)', async () => { + const newPassword = faker.string.alphanumeric(); + const uuid = faker.string.uuid(); - jest.spyOn(authService, 'auth').mockResolvedValue({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - }); - }); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.past(), + } as TokenEntity); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); - afterEach(() => { - jest.clearAllMocks(); - }); + await expect(service.restorePassword(newPassword, uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.PASSWORD_TOKEN_EXPIRED, + ), + ); + }); + }); - it('should sign in the user, reset nonce and return the JWT', async () => { - updateNonceMock.mockResolvedValue({ - ...userEntity, - nonce: nonce1, - } as UserEntity); - - const data = { - from: MOCK_ADDRESS.toLowerCase(), - to: MOCK_ADDRESS.toLowerCase(), - contents: 'signin', - nonce: nonce, - }; - - const signature = await signMessage(data, MOCK_PRIVATE_KEY); - const result = await authService.web3Signin({ - address: MOCK_ADDRESS, - signature, - }); - - expect(userService.findOperatorUser).toHaveBeenCalledWith( - MOCK_ADDRESS, - ); - expect(userService.updateNonce).toHaveBeenCalledWith(userEntity); + describe('emailVerification', () => { + it('should verify an email', async () => { + const mockToken = { + userId: faker.number.int(), + uuid: faker.string.uuid(), + type: TokenType.EMAIL, + expiresAt: faker.date.future(), + }; - expect(authService.auth).toHaveBeenCalledWith(userEntity); - expect(result).toStrictEqual({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - }); - }); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce( + mockToken as TokenEntity, + ); - it("should throw ConflictException if signature doesn't match", async () => { - const invalidSignature = await signMessage( - 'invalid message', - MOCK_PRIVATE_KEY, - ); + await service.emailVerification(mockToken.uuid); - await expect( - authService.web3Signin({ - address: MOCK_ADDRESS, - signature: invalidSignature, - }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE), - ); - }); - }); + expect(mockUserRepository.updateOneById).toHaveBeenCalledTimes(1); + expect(mockUserRepository.updateOneById).toHaveBeenCalledWith( + mockToken.userId, + { + status: UserStatus.ACTIVE, + }, + ); + }); - describe('signup', () => { - const web3PreSignUpDto: PrepareSignatureDto = { - address: MOCK_ADDRESS, - type: SignatureType.SIGNUP, - }; + it('should throw AuthError(AuthErrorMessage.INVALID_EMAIL_TOKEN) if token not found', async () => { + const uuid = faker.string.uuid(); - const nonce = generateNonce(); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); - const userEntity: Partial = { - id: 1, - evmAddress: web3PreSignUpDto.address, - nonce, - }; + await expect(service.emailVerification(uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.INVALID_EMAIL_TOKEN, + ), + ); + }); - const preSignUpData = prepareSignatureBody({ - from: web3PreSignUpDto.address, - to: MOCK_ADDRESS, - contents: SignatureType.SIGNUP, - }); + it('should throw AuthError(AuthErrorMessage.EMAIL_TOKEN_EXPIRED)', async () => { + const uuid = faker.string.uuid(); - let createUserMock: any; + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce({ + uuid, + expiresAt: faker.date.past(), + } as TokenEntity); + mockTokenRepository.findOneByUuidAndType.mockResolvedValueOnce(null); - beforeEach(() => { - createUserMock = jest.spyOn(userService, 'createOperatorUser'); + await expect(service.emailVerification(uuid)).rejects.toThrow( + new AuthErrors.AuthError( + AuthErrors.AuthErrorMessage.EMAIL_TOKEN_EXPIRED, + ), + ); + }); + }); - createUserMock.mockResolvedValue(userEntity); + describe('resendEmailVerification', () => { + it.each([null, { uuid: faker.string.uuid() }])( + 'should send a verification email [%#]', + async (existingEmailToken) => { + const user = generateWorkerUser({ status: UserStatus.PENDING }); + const tokenUuid = faker.string.uuid(); - jest.spyOn(authService, 'auth').mockResolvedValue({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - }); - jest - .spyOn(userRepository, 'findOneByAddress') - .mockResolvedValue(null); - }); + mockUserRepository.findOneByEmail.mockResolvedValueOnce(user); + mockTokenRepository.findOneByUserIdAndType.mockResolvedValueOnce( + existingEmailToken as TokenEntity, + ); + mockTokenRepository.createUnique.mockResolvedValueOnce({ + uuid: tokenUuid, + } as TokenEntity); - afterEach(() => { - jest.clearAllMocks(); - }); + await service.resendEmailVerification(user); - it('should create a new web3 user and return the token', async () => { - (KVStoreClient.build as any).mockImplementationOnce(() => ({ - set: jest.fn(), - })); - KVStoreUtils.get = jest - .fn() - .mockResolvedValueOnce(SDKRole.JobLauncher) - .mockResolvedValueOnce('url') - .mockResolvedValueOnce(1) - .mockResolvedValueOnce(JobRequestType.FORTUNE); - - const signature = await signMessage(preSignUpData, MOCK_PRIVATE_KEY); - - const result = await authService.web3Signup({ - address: web3PreSignUpDto.address, - type: UserRole.WORKER, - signature, - }); - - expect(userService.createOperatorUser).toHaveBeenCalledWith( - web3PreSignUpDto.address, + if (existingEmailToken) { + expect(mockTokenRepository.deleteOne).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.deleteOne).toHaveBeenCalledWith( + existingEmailToken as TokenEntity, ); + } + expect(mockTokenRepository.createUnique).toHaveBeenCalledTimes(1); + expect(mockTokenRepository.createUnique).toHaveBeenCalledWith( + expect.objectContaining({ + type: TokenType.EMAIL, + userId: user.id, + }), + ); + expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1); + expect(mockEmailService.sendEmail).toHaveBeenCalledWith( + user.email, + EmailAction.SIGNUP, + { + url: `${mockServerConfigService.feURL}/verify?token=${tokenUuid}`, + }, + ); + }, + ); - expect(authService.auth).toHaveBeenCalledWith(userEntity); - expect(result).toStrictEqual({ - accessToken: MOCK_ACCESS_TOKEN, - refreshToken: MOCK_REFRESH_TOKEN, - }); - }); + it.each([UserStatus.INACTIVE, UserStatus.ACTIVE])( + 'should do nothing if user is not in PENDING status [%#]', + async (userStatus) => { + const user = generateWorkerUser({ status: userStatus }); - it("should throw if signature doesn't match", async () => { - const invalidSignature = await signMessage( - 'invalid message', - MOCK_PRIVATE_KEY, - ); + mockUserRepository.findOneByEmail.mockResolvedValueOnce(null); - await expect( - authService.web3Signup({ - ...web3PreSignUpDto, - type: UserRole.WORKER, - signature: invalidSignature, - }), - ).rejects.toThrow( - new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE), - ); - }); - it('should throw if role is not in KVStore', async () => { - KVStoreUtils.get = jest.fn().mockResolvedValueOnce(''); - - const signature = await signMessage(preSignUpData, MOCK_PRIVATE_KEY); - - await expect( - authService.web3Signup({ - ...web3PreSignUpDto, - type: UserRole.WORKER, - signature: signature, - }), - ).rejects.toThrow(new InvalidOperatorRoleError('')); - }); - it('should throw if fee is not in KVStore', async () => { - KVStoreUtils.get = jest - .fn() - .mockResolvedValueOnce(SDKRole.JobLauncher); - - const signature = await signMessage(preSignUpData, MOCK_PRIVATE_KEY); - - await expect( - authService.web3Signup({ - ...web3PreSignUpDto, - type: UserRole.WORKER, - signature: signature, - }), - ).rejects.toThrow(new InvalidOperatorFeeError('')); - }); - it('should throw if url is not in KVStore', async () => { - KVStoreUtils.get = jest - .fn() - .mockResolvedValueOnce(SDKRole.JobLauncher) - .mockResolvedValueOnce('url'); - - const signature = await signMessage(preSignUpData, MOCK_PRIVATE_KEY); - - await expect( - authService.web3Signup({ - ...web3PreSignUpDto, - type: UserRole.WORKER, - signature: signature, - }), - ).rejects.toThrow(new InvalidOperatorUrlError('')); - }); - it('should throw if job type is not in KVStore', async () => { - KVStoreUtils.get = jest - .fn() - .mockResolvedValueOnce(SDKRole.JobLauncher) - .mockResolvedValueOnce('url') - .mockResolvedValueOnce(1); - - const signature = await signMessage(preSignUpData, MOCK_PRIVATE_KEY); - - await expect( - authService.web3Signup({ - ...web3PreSignUpDto, - type: UserRole.WORKER, - signature: signature, - }), - ).rejects.toThrow(new InvalidOperatorJobTypesError('')); - }); - }); - }); + await service.resendEmailVerification(user); + + expect( + mockTokenRepository.findOneByUserIdAndType, + ).not.toHaveBeenCalled(); + expect(mockTokenRepository.deleteOne).not.toHaveBeenCalled(); + expect(mockTokenRepository.createUnique).not.toHaveBeenCalled(); + expect(mockEmailService.sendEmail).not.toHaveBeenCalled(); + }, + ); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts index 68c8c2f8bb..eeeaa4dd80 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/auth.service.ts @@ -1,222 +1,342 @@ -import { - KVStoreClient, - KVStoreKeys, - KVStoreUtils, - Role, -} from '@human-protocol/sdk'; +import { KVStoreKeys, KVStoreUtils, Role } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { SignatureType } from '../../common/enums/web3'; +import { AuthConfigService } from '../../config/auth-config.service'; +import { NDAConfigService } from '../../config/nda-config.service'; +import { ServerConfigService } from '../../config/server-config.service'; +import { Web3ConfigService } from '../../config/web3-config.service'; +import logger from '../../logger'; +import * as web3Utils from '../../utils/web3'; +import * as securityUtils from '../../utils/security'; + +import { EmailAction } from '../email/constants'; +import { EmailService } from '../email/email.service'; +import { StorageService } from '../storage/storage.service'; import { + OperatorStatus, + SiteKeyRepository, SiteKeyType, - UserStatus, - UserRole, UserEntity, UserRepository, + UserRole, UserService, - OperatorStatus, - type Web2UserEntity, + UserStatus, type OperatorUserEntity, + type Web2UserEntity, } from '../user'; -import { TokenEntity, TokenType } from './token.entity'; -import { TokenRepository } from './token.repository'; -import { verifySignature } from '../../utils/web3'; -import { Web3Service } from '../web3/web3.service'; -import { SignatureType } from '../../common/enums/web3'; -import { prepareSignatureBody } from '../../utils/web3'; -import * as securityUtils from '../../utils/security'; -import { AuthConfigService } from '../../config/auth-config.service'; -import { NDAConfigService } from '../../config/nda-config.service'; -import { ServerConfigService } from '../../config/server-config.service'; -import { Web3ConfigService } from '../../config/web3-config.service'; + import { AuthError, AuthErrorMessage, DuplicatedUserAddressError, DuplicatedUserEmailError, + InactiveUserError, InvalidOperatorFeeError, - InvalidOperatorJobTypesError, InvalidOperatorRoleError, InvalidOperatorUrlError, } from './auth.error'; -import { - ForgotPasswordDto, - SuccessAuthDto, - RefreshDto, - Web2SignUpDto, - Web2SignInDto, - Web3SignInDto, - Web3SignUpDto, - RestorePasswordDto, - VerifyEmailDto, - ResendVerificationEmailDto, -} from './dto'; +import { TokenEntity, TokenType } from './token.entity'; +import { TokenRepository } from './token.repository'; -import { EmailService } from '../email/email.service'; -import { EmailAction } from '../email/constants'; +type AuthTokens = { + accessToken: string; + refreshToken: string; +}; @Injectable() export class AuthService { - private readonly salt: string; - + private readonly logger = logger.child({ + context: AuthService.name, + }); constructor( - private readonly jwtService: JwtService, - private readonly userService: UserService, - private readonly tokenRepository: TokenRepository, - private readonly serverConfigService: ServerConfigService, private readonly authConfigService: AuthConfigService, - private readonly ndaConfigService: NDAConfigService, - private readonly web3ConfigService: Web3ConfigService, private readonly emailService: EmailService, - private readonly web3Service: Web3Service, + private readonly jwtService: JwtService, + private readonly ndaConfigService: NDAConfigService, + private readonly serverConfigService: ServerConfigService, + private readonly siteKeyRepository: SiteKeyRepository, + private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, + private readonly userService: UserService, + private readonly web3ConfigService: Web3ConfigService, ) {} - public async signin({ - email, - password, - }: Web2SignInDto): Promise { - const userEntity = await this.userService.findWeb2UserByEmail(email); - if (!userEntity) { - throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); - } - - if (!securityUtils.comparePasswordWithHash(password, userEntity.password)) { - throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); + async signup(email: string, password: string): Promise { + const storedUser = await this.userRepository.findOneByEmail(email); + if (storedUser) { + throw new DuplicatedUserEmailError(email); } - return this.auth(userEntity); - } + const newUser = new UserEntity(); + newUser.email = email; + newUser.password = securityUtils.hashPassword(password); + newUser.role = UserRole.WORKER; + newUser.status = UserStatus.PENDING; - public async signup(data: Web2SignUpDto): Promise { - const storedUser = await this.userRepository.findOneByEmail(data.email); - if (storedUser) { - throw new DuplicatedUserEmailError(data.email); - } - const userEntity = await this.userService.createWorkerUser(data); + const userEntity = await this.userRepository.createUnique(newUser); const tokenEntity = new TokenEntity(); tokenEntity.type = TokenType.EMAIL; tokenEntity.userId = userEntity.id; const date = new Date(); tokenEntity.expiresAt = new Date( - date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn * 1000, + date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn, ); - await this.tokenRepository.createUnique(tokenEntity); - await this.emailService.sendEmail(data.email, EmailAction.SIGNUP, { - url: `${this.serverConfigService.feURL}/verify?token=${tokenEntity.uuid}`, + const token = await this.tokenRepository.createUnique(tokenEntity); + await this.emailService.sendEmail(email, EmailAction.SIGNUP, { + url: `${this.serverConfigService.feURL}/verify?token=${token.uuid}`, }); } - public async refresh(data: RefreshDto): Promise { - const tokenEntity = await this.tokenRepository.findOneByUuidAndType( - data.refreshToken, - TokenType.REFRESH, + async web3Signup(signature: string, address: string): Promise { + const user = await this.userRepository.findOneByAddress(address); + if (user) { + throw new DuplicatedUserAddressError(address); + } + + const preSignUpData = web3Utils.prepareSignatureBody({ + from: address, + to: this.web3ConfigService.operatorAddress, + contents: SignatureType.SIGNUP, + }); + + const isValidSignature = web3Utils.verifySignature( + preSignUpData, + signature, + [address], ); - if (!tokenEntity) { - throw new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN); + if (!isValidSignature) { + throw new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE); } - if (new Date() > tokenEntity.expiresAt) { - throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); + const chainId = this.web3ConfigService.reputationNetworkChainId; + let role = ''; + try { + role = await KVStoreUtils.get(chainId, address, KVStoreKeys.role); + } catch (noop) {} + + // We need to exclude ReputationOracle role + const isValidRole = [ + Role.JobLauncher, + Role.ExchangeOracle, + Role.RecordingOracle, + ].includes(role); + + if (!isValidRole) { + throw new InvalidOperatorRoleError(role); } - const userEntity = await this.userRepository.findOneById( - tokenEntity.userId, - { - relations: { - kyc: true, - siteKeys: true, - }, - }, - ); + let fee = ''; + try { + fee = await KVStoreUtils.get(chainId, address, KVStoreKeys.fee); + } catch (noop) {} + if (!fee) { + throw new InvalidOperatorFeeError(fee); + } + + let url = ''; + try { + url = await KVStoreUtils.get(chainId, address, KVStoreKeys.url); + } catch (noop) {} + if (!url || !StorageService.isValidUrl(url)) { + throw new InvalidOperatorUrlError(url); + } + + const newUser = new UserEntity(); + newUser.evmAddress = address.toLowerCase(); + newUser.nonce = web3Utils.generateNonce(); + newUser.role = UserRole.OPERATOR; + newUser.status = UserStatus.ACTIVE; + + const userEntity = await this.userRepository.createUnique(newUser); + return this.web3Auth(userEntity as OperatorUserEntity); + } + + async signin(email: string, password: string): Promise { + const userEntity = await this.userService.findWeb2UserByEmail(email); if (!userEntity) { - throw new Error('User not found'); + throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); + } + + if (userEntity.status === UserStatus.INACTIVE) { + throw new InactiveUserError(userEntity.id); + } + + if (!securityUtils.comparePasswordWithHash(password, userEntity.password)) { + throw new AuthError(AuthErrorMessage.INVALID_CREDENTIALS); } return this.auth(userEntity); } - public async auth( - userEntity: Web2UserEntity | OperatorUserEntity | UserEntity, - ): Promise { - const refreshTokenEntity = - await this.tokenRepository.findOneByUserIdAndType( - userEntity.id, - TokenType.REFRESH, - ); + async web3Signin(address: string, signature: string): Promise { + const userEntity = await this.userService.findOperatorUser(address); - const operatorAddress = this.web3ConfigService.operatorAddress; - - let status = userEntity.status.toString(); - if (userEntity.role === UserRole.OPERATOR && userEntity.evmAddress) { - let operatorStatus: string | undefined; - try { - operatorStatus = await KVStoreUtils.get( - this.web3ConfigService.reputationNetworkChainId, - operatorAddress, - userEntity.evmAddress.toLowerCase(), - ); - } catch {} - - if (operatorStatus && operatorStatus !== '') { - status = operatorStatus; - } + if (!userEntity) { + throw new AuthError(AuthErrorMessage.INVALID_ADDRESS); + } + + if (userEntity.status === UserStatus.INACTIVE) { + throw new InactiveUserError(userEntity.id); + } + + const preSigninData = web3Utils.prepareSignatureBody({ + from: address, + to: this.web3ConfigService.operatorAddress, + contents: SignatureType.SIGNIN, + nonce: userEntity.nonce, + }); + const isValidSignature = web3Utils.verifySignature( + preSigninData, + signature, + [address], + ); + + if (!isValidSignature) { + throw new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE); + } + + const nonce = web3Utils.generateNonce(); + await this.userRepository.updateOneById(userEntity.id, { nonce }); + + return this.web3Auth(userEntity); + } + + async auth(userEntity: Web2UserEntity | UserEntity): Promise { + let hCaptchaSiteKey: string | undefined; + const hCaptchaSiteKeys = await this.siteKeyRepository.findByUserAndType( + userEntity.id, + SiteKeyType.HCAPTCHA, + ); + if (hCaptchaSiteKeys && hCaptchaSiteKeys.length > 0) { + // We know for sure that only one hcaptcha sitekey might exist + hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } - const payload: any = { + const jwtPayload = { email: userEntity.email, - status, - userId: userEntity.id, + status: userEntity.status, + user_id: userEntity.id, wallet_address: userEntity.evmAddress, role: userEntity.role, kyc_status: userEntity.kyc?.status, nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, - reputation_network: operatorAddress, + reputation_network: this.web3ConfigService.operatorAddress, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( (userQualification) => userQualification.qualification.reference, ) : [], + site_key: hCaptchaSiteKey, }; - // TODO: load sitekeys from repository instead of user entity in request - if (userEntity.siteKeys && userEntity.siteKeys.length > 0) { - const existingHcaptchaSiteKey = userEntity.siteKeys.find( - (key) => key.type === SiteKeyType.HCAPTCHA, - ); - if (existingHcaptchaSiteKey) { - payload.site_key = existingHcaptchaSiteKey.siteKey; - } - } + return this.generateTokens(userEntity.id, jwtPayload); + } - const accessToken = await this.jwtService.signAsync(payload, { - expiresIn: this.authConfigService.accessTokenExpiresIn, - }); + async web3Auth(userEntity: OperatorUserEntity): Promise { + /** + * NOTE + * In case if operator recently activated/deactivated itself + * and subgraph does not have the actual value yet, + * the status can be outdated + */ + let operatorStatus = OperatorStatus.INACTIVE; + try { + operatorStatus = (await KVStoreUtils.get( + this.web3ConfigService.reputationNetworkChainId, + this.web3ConfigService.operatorAddress, + userEntity.evmAddress, + )) as OperatorStatus; + } catch (noop) {} + + const jwtPayload = { + status: userEntity.status, + user_id: userEntity.id, + wallet_address: userEntity.evmAddress, + role: userEntity.role, + reputation_network: this.web3ConfigService.operatorAddress, + operator_status: operatorStatus, + }; + return this.generateTokens(userEntity.id, jwtPayload); + } + + async generateTokens( + userId: number, + jwtPayload: Record, + ): Promise { + const refreshTokenEntity = + await this.tokenRepository.findOneByUserIdAndType( + userId, + TokenType.REFRESH, + ); if (refreshTokenEntity) { await this.tokenRepository.deleteOne(refreshTokenEntity); } const newRefreshTokenEntity = new TokenEntity(); - newRefreshTokenEntity.userId = userEntity.id; + newRefreshTokenEntity.userId = userId; newRefreshTokenEntity.type = TokenType.REFRESH; const date = new Date(); newRefreshTokenEntity.expiresAt = new Date( - date.getTime() + this.authConfigService.refreshTokenExpiresIn * 1000, + date.getTime() + this.authConfigService.refreshTokenExpiresIn, ); await this.tokenRepository.createUnique(newRefreshTokenEntity); + const accessToken = await this.jwtService.signAsync(jwtPayload, { + expiresIn: this.authConfigService.accessTokenExpiresIn, + }); + return { accessToken, refreshToken: newRefreshTokenEntity.uuid }; } - public async forgotPassword(data: ForgotPasswordDto): Promise { - const userEntity = await this.userRepository.findOneByEmail(data.email); + async refresh(refreshToken: string): Promise { + const tokenEntity = await this.tokenRepository.findOneByUuidAndType( + refreshToken, + TokenType.REFRESH, + ); + + if (!tokenEntity) { + throw new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN); + } + + if (new Date() > tokenEntity.expiresAt) { + throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); + } + + const userEntity = await this.userRepository.findOneById( + tokenEntity.userId, + { + relations: { + kyc: true, + siteKeys: true, + }, + }, + ); + + if (!userEntity) { + this.logger.warn('User not found during token refresh', { + userId: tokenEntity.userId, + }); + throw new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN); + } + + if (userEntity.role === UserRole.OPERATOR) { + return this.web3Auth(userEntity as OperatorUserEntity); + } else { + return this.auth(userEntity); + } + } + + async forgotPassword(email: string): Promise { + const userEntity = await this.userRepository.findOneByEmail(email); if (!userEntity) { return; @@ -236,53 +356,65 @@ export class AuthService { tokenEntity.userId = userEntity.id; const date = new Date(); tokenEntity.expiresAt = new Date( - date.getTime() + this.authConfigService.forgotPasswordExpiresIn * 1000, + date.getTime() + this.authConfigService.forgotPasswordExpiresIn, ); - await this.tokenRepository.createUnique(tokenEntity); - await this.emailService.sendEmail(data.email, EmailAction.RESET_PASSWORD, { - url: `${this.serverConfigService.feURL}/reset-password?token=${tokenEntity.uuid}`, + const token = await this.tokenRepository.createUnique(tokenEntity); + await this.emailService.sendEmail(email, EmailAction.RESET_PASSWORD, { + url: `${this.serverConfigService.feURL}/reset-password?token=${token.uuid}`, }); } - public async restorePassword(data: RestorePasswordDto): Promise { + async restorePassword(password: string, token: string): Promise { const tokenEntity = await this.tokenRepository.findOneByUuidAndType( - data.token, + token, TokenType.PASSWORD, + { + relations: { + user: true, + }, + }, ); if (!tokenEntity) { - throw new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN); + throw new AuthError(AuthErrorMessage.INVALID_PASSWORD_TOKEN); } if (new Date() > tokenEntity.expiresAt) { - throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); + throw new AuthError(AuthErrorMessage.PASSWORD_TOKEN_EXPIRED); } - const userEntity = await this.userService.updatePassword( + const hashedPassword = securityUtils.hashPassword(password); + + const isPasswordChanged = await this.userRepository.updateOneById( tokenEntity.userId, - data.password, - ); - await this.emailService.sendEmail( - userEntity.email, - EmailAction.PASSWORD_CHANGED, + { + password: hashedPassword, + }, ); - await this.tokenRepository.deleteOne(tokenEntity); + if (isPasswordChanged) { + await this.emailService.sendEmail( + tokenEntity.user?.email as string, + EmailAction.PASSWORD_CHANGED, + ); + + await this.tokenRepository.deleteOne(tokenEntity); + } } - public async emailVerification(data: VerifyEmailDto): Promise { + async emailVerification(token: string): Promise { const tokenEntity = await this.tokenRepository.findOneByUuidAndType( - data.token, + token, TokenType.EMAIL, ); if (!tokenEntity) { - throw new AuthError(AuthErrorMessage.INVALID_REFRESH_TOKEN); + throw new AuthError(AuthErrorMessage.INVALID_EMAIL_TOKEN); } if (new Date() > tokenEntity.expiresAt) { - throw new AuthError(AuthErrorMessage.REFRESH_TOKEN_EXPIRED); + throw new AuthError(AuthErrorMessage.EMAIL_TOKEN_EXPIRED); } await this.userRepository.updateOneById(tokenEntity.userId, { @@ -290,16 +422,13 @@ export class AuthService { }); } - public async resendEmailVerification( - data: ResendVerificationEmailDto, - ): Promise { - const userEntity = await this.userRepository.findOneByEmail(data.email); - if (!userEntity || userEntity.status !== UserStatus.PENDING) { + async resendEmailVerification(user: Web2UserEntity): Promise { + if (user.status !== UserStatus.PENDING) { return; } const existingToken = await this.tokenRepository.findOneByUserIdAndType( - userEntity.id, + user.id, TokenType.EMAIL, ); @@ -309,125 +438,15 @@ export class AuthService { const tokenEntity = new TokenEntity(); tokenEntity.type = TokenType.EMAIL; - tokenEntity.userId = userEntity.id; + tokenEntity.userId = user.id; const date = new Date(); tokenEntity.expiresAt = new Date( - date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn * 1000, + date.getTime() + this.authConfigService.verifyEmailTokenExpiresIn, ); - await this.tokenRepository.createUnique(tokenEntity); - await this.emailService.sendEmail(data.email, EmailAction.SIGNUP, { - url: `${this.serverConfigService.feURL}/verify?token=${tokenEntity.uuid}`, + const token = await this.tokenRepository.createUnique(tokenEntity); + await this.emailService.sendEmail(user.email, EmailAction.SIGNUP, { + url: `${this.serverConfigService.feURL}/verify?token=${token.uuid}`, }); } - - public async web3Signup(data: Web3SignUpDto): Promise { - const preSignUpData = prepareSignatureBody({ - from: data.address, - to: this.web3ConfigService.operatorAddress, - contents: SignatureType.SIGNUP, - }); - - const verified = verifySignature(preSignUpData, data.signature, [ - data.address, - ]); - - if (!verified) { - throw new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE); - } - - const chainId = this.web3ConfigService.reputationNetworkChainId; - - const signer = this.web3Service.getSigner(chainId); - const kvstore = await KVStoreClient.build(signer); - - let role = ''; - try { - role = await KVStoreUtils.get(chainId, data.address, KVStoreKeys.role); - } catch {} - - const validRolesValue = [ - Role.JobLauncher, - Role.ExchangeOracle, - Role.RecordingOracle, - ].map((role) => role.toLowerCase()); - - if (!validRolesValue.includes(role.toLowerCase())) { - throw new InvalidOperatorRoleError(role); - } - - let fee = ''; - try { - fee = await KVStoreUtils.get(chainId, data.address, KVStoreKeys.fee); - } catch {} - - if (!fee) { - throw new InvalidOperatorFeeError(fee); - } - - let url = ''; - try { - url = await KVStoreUtils.get(chainId, data.address, KVStoreKeys.url); - } catch {} - - if (!url) { - throw new InvalidOperatorUrlError(url); - } - - let jobTypes = ''; - try { - jobTypes = await KVStoreUtils.get( - chainId, - data.address, - KVStoreKeys.jobTypes, - ); - } catch {} - - if (!jobTypes) { - throw new InvalidOperatorJobTypesError(jobTypes); - } - - const user = await this.userRepository.findOneByAddress(data.address); - - if (user) { - throw new DuplicatedUserAddressError(data.address); - } - - const userEntity = await this.userService.createOperatorUser(data.address); - - /** - * TODO: revisit if we want to make it active by default - * since we have `enableOperator` functionality and - * they might not have enough tokens, which should not impact signup - */ - await kvstore.set(data.address.toLowerCase(), OperatorStatus.ACTIVE); - - return this.auth(userEntity); - } - - public async web3Signin(data: Web3SignInDto): Promise { - const userEntity = await this.userService.findOperatorUser(data.address); - - if (!userEntity) { - throw new AuthError(AuthErrorMessage.INVALID_ADDRESS); - } - - const preSigninData = prepareSignatureBody({ - from: data.address, - to: this.web3ConfigService.operatorAddress, - contents: SignatureType.SIGNIN, - nonce: userEntity.nonce, - }); - const verified = verifySignature(preSigninData, data.signature, [ - data.address, - ]); - - if (!verified) { - throw new AuthError(AuthErrorMessage.INVALID_WEB3_SIGNATURE); - } - - await this.userService.updateNonce(userEntity); - - return this.auth(userEntity); - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/email-verification.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/email-verification.dto.ts index 9c8cf0be04..b27fc14d02 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/email-verification.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/email-verification.dto.ts @@ -1,19 +1,20 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsUUID } from 'class-validator'; + import { IsLowercasedEmail } from '../../../common/validators'; export class ResendVerificationEmailDto { @ApiProperty() @IsLowercasedEmail() - public email: string; + email: string; @ApiProperty({ name: 'h_captcha_token' }) @IsString() - public hCaptchaToken: string; + hCaptchaToken: string; } export class VerifyEmailDto { @ApiProperty() @IsUUID() - public token: string; + token: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/index.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/index.ts index cee91b57db..7e51b20046 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/index.ts @@ -1,5 +1,5 @@ -export * from './sign-in.dto'; -export * from './sign-up.dto'; -export { RefreshDto } from './refresh.dto'; export * from './email-verification.dto'; export { ForgotPasswordDto, RestorePasswordDto } from './password.dto'; +export { RefreshDto } from './refresh.dto'; +export * from './sign-in.dto'; +export * from './sign-up.dto'; diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/password.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/password.dto.ts index 3c0fee33ba..edfbabfba3 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/password.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/password.dto.ts @@ -1,6 +1,7 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { IsString, MinLength } from 'class-validator'; + import { IsLowercasedEmail } from '../../../common/validators'; export function ValidPassword() { @@ -15,23 +16,23 @@ export function ValidPassword() { export class ForgotPasswordDto { @ApiProperty() @IsLowercasedEmail() - public email: string; + email: string; @ApiProperty({ name: 'h_captcha_token' }) @IsString() - public hCaptchaToken: string; + hCaptchaToken: string; } export class RestorePasswordDto { @ApiProperty() @ValidPassword() - public password: string; + password: string; @ApiProperty() @IsString() - public token: string; + token: string; @ApiProperty({ name: 'h_captcha_token' }) @IsString() - public hCaptchaToken: string; + hCaptchaToken: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/refresh.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/refresh.dto.ts index 29897e11ab..1064c22198 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/refresh.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/refresh.dto.ts @@ -4,5 +4,5 @@ import { IsUUID } from 'class-validator'; export class RefreshDto { @ApiProperty({ name: 'refresh_token' }) @IsUUID() - public refreshToken: string; + refreshToken: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts index 08f92af916..4b67d4e5cb 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-in.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEthereumAddress, IsOptional, IsString } from 'class-validator'; + import { IsLowercasedEmail, IsValidWeb3Signature, @@ -8,34 +9,34 @@ import { export class Web2SignInDto { @ApiProperty() @IsLowercasedEmail() - public email: string; + email: string; @ApiProperty() @IsString() - public password: string; + password: string; @ApiPropertyOptional({ name: 'h_captcha_token' }) @IsOptional() @IsString() - public hCaptchaToken?: string; + hCaptchaToken?: string; } export class Web3SignInDto { @ApiProperty() @IsEthereumAddress() - public address: string; + address: string; @ApiProperty() @IsValidWeb3Signature() - public signature: string; + signature: string; } export class SuccessAuthDto { @ApiProperty({ name: 'access_token' }) @IsString() - public accessToken: string; + accessToken: string; @ApiProperty({ name: 'refresh_token' }) @IsString() - public refreshToken: string; + refreshToken: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts index fee906fe90..c4c705fb65 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/dto/sign-up.dto.ts @@ -1,39 +1,40 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEthereumAddress, IsString } from 'class-validator'; -import { ValidPassword } from './password.dto'; + import { IsLowercasedEmail, IsLowercasedEnum, IsValidWeb3Signature, } from '../../../common/validators'; import { UserRole } from '../../user'; +import { ValidPassword } from './password.dto'; export class Web2SignUpDto { @ApiProperty() @IsLowercasedEmail() - public email: string; + email: string; @ApiProperty() @ValidPassword() - public password: string; + password: string; @ApiProperty({ name: 'h_captcha_token' }) @IsString() - public hCaptchaToken: string; + hCaptchaToken: string; } export class Web3SignUpDto { @ApiProperty() @IsValidWeb3Signature() - public signature: string; + signature: string; @ApiProperty({ enum: UserRole, }) @IsLowercasedEnum(UserRole) - public type: UserRole; + type: UserRole; @ApiProperty() @IsEthereumAddress() - public address: string; + address: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts index 09f653b9b8..8c5d7cd8b6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/jwt-http-strategy.ts @@ -9,8 +9,8 @@ import { } from '../../common/constants'; import { UserEntity, UserStatus, UserRepository } from '../user'; import { AuthConfigService } from '../../config/auth-config.service'; -import { TokenRepository } from './token.repository'; import { TokenType } from './token.entity'; +import { TokenRepository } from './token.repository'; @Injectable() export class JwtHttpStrategy extends PassportStrategy( @@ -32,9 +32,18 @@ export class JwtHttpStrategy extends PassportStrategy( async validate( @Req() request: any, - payload: { userId: number }, + payload: { user_id: number }, ): Promise { - const user = await this.userRepository.findOneById(payload.userId, { + const token = await this.tokenRepository.findOneByUserIdAndType( + payload.user_id, + TokenType.REFRESH, + ); + + if (!token) { + throw new UnauthorizedException('User is not authorized'); + } + + const user = await this.userRepository.findOneById(payload.user_id, { relations: { kyc: true, siteKeys: true, @@ -53,15 +62,6 @@ export class JwtHttpStrategy extends PassportStrategy( throw new UnauthorizedException('User not active'); } - const token = await this.tokenRepository.findOneByUserIdAndType( - user.id, - TokenType.REFRESH, - ); - - if (!token) { - throw new UnauthorizedException('User is not authorized'); - } - return user; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts index 4a1f08f814..88c81e2432 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.entity.ts @@ -6,14 +6,15 @@ import { JoinColumn, ManyToOne, } from 'typeorm'; + import type { UserEntity } from '../user'; -import { BaseEntity } from '../../database/base.entity'; import { DATABASE_SCHEMA_NAME } from '../../common/constants'; +import { BaseEntity } from '../../database/base.entity'; export enum TokenType { - EMAIL = 'EMAIL', - PASSWORD = 'PASSWORD', - REFRESH = 'REFRESH', + EMAIL = 'email', + PASSWORD = 'password', + REFRESH = 'refresh', } @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'tokens' }) @@ -33,7 +34,7 @@ export class TokenEntity extends BaseEntity { expiresAt: Date; @JoinColumn() - @ManyToOne('UserEntity') + @ManyToOne('UserEntity', { persistence: false }) user?: UserEntity; @Column({ type: 'int' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts index b7e4362051..100f5baf29 100644 --- a/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/auth/token.repository.ts @@ -1,8 +1,13 @@ import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions } from 'typeorm'; + import { BaseRepository } from '../../database/base.repository'; -import { DataSource } from 'typeorm'; import { TokenEntity, TokenType } from './token.entity'; +type FindOptions = { + relations?: FindManyOptions['relations']; +}; + @Injectable() export class TokenRepository extends BaseRepository { constructor(dataSource: DataSource) { @@ -12,24 +17,28 @@ export class TokenRepository extends BaseRepository { async findOneByUuidAndType( uuid: string, type: TokenType, + options: FindOptions = {}, ): Promise { return this.findOne({ where: { uuid, type, }, + relations: options.relations, }); } async findOneByUserIdAndType( userId: number, type: TokenType, + options: FindOptions = {}, ): Promise { return this.findOne({ where: { userId, type, }, + relations: options.relations, }); } diff --git a/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts b/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts index 95427cddb5..3c9dbcc2c9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/health/dto/ping-response.dto.ts @@ -2,11 +2,11 @@ import { ApiProperty } from '@nestjs/swagger'; export class PingResponseDto { @ApiProperty({ name: 'app_name' }) - public appName: string; + appName: string; @ApiProperty({ name: 'node_env' }) - public nodeEnv: string; + nodeEnv: string; @ApiProperty({ name: 'git_hash' }) - public gitHash: string; + gitHash: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts index 8a2001a167..ee2eaff5f7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.entity.ts @@ -25,7 +25,9 @@ export class KycEntity extends BaseEntity { message: string | null; @JoinColumn() - @OneToOne('UserEntity', (user: UserEntity) => user.kyc) + @OneToOne('UserEntity', (user: UserEntity) => user.kyc, { + persistence: false, + }) user?: UserEntity; @Column({ type: 'int' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts index 036698922d..744bd6e69b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/kyc/kyc.repository.ts @@ -10,9 +10,7 @@ export class KycRepository extends BaseRepository { super(KycEntity, dataSource); } - public async findOneBySessionId( - sessionId: string, - ): Promise { + async findOneBySessionId(sessionId: string): Promise { const kycEntity = await this.findOne({ where: { sessionId: sessionId, diff --git a/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts b/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts index 43a7c5c916..685148ca5b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/nda/nda.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, IsUrl } from 'class-validator'; +import { IsUrl } from 'class-validator'; export class NDASignatureDto { @ApiProperty({ name: 'url' }) - @IsString() @IsUrl() - public url: string; + url: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.interface.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.interface.ts index ff0d14b8c3..56ecb6e6a1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.interface.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.interface.ts @@ -1,5 +1,6 @@ import { ChainId } from '@human-protocol/sdk'; import { + AudinoManifest, CvatManifest, FortuneManifest, } from '../../common/interfaces/manifest'; @@ -31,7 +32,7 @@ export type CalculatedPayout = { export interface RequestAction { calculatePayouts: ( - manifest: FortuneManifest | CvatManifest, + manifest: FortuneManifest | CvatManifest | AudinoManifest, data: CalculatePayoutsInput, ) => Promise; saveResults: ( diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts index 2bc2436b9a..d8b6925093 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.spec.ts @@ -96,6 +96,10 @@ describe('PayoutService', () => { calculatePayouts: jest.fn(), saveResults: jest.fn(), }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + calculatePayouts: jest.fn(), + saveResults: jest.fn(), + }, }; }); diff --git a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts index e4df068d77..d68d67e2c9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/payout/payout.service.ts @@ -3,6 +3,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { ChainId, EscrowClient } from '@human-protocol/sdk'; import { + AUDINO_RESULTS_ANNOTATIONS_FILENAME, + AUDINO_VALIDATION_META_FILENAME, CVAT_RESULTS_ANNOTATIONS_FILENAME, CVAT_VALIDATION_META_FILENAME, } from '../../common/constants'; @@ -11,10 +13,12 @@ import { Web3Service } from '../web3/web3.service'; import { JobRequestType } from '../../common/enums'; import { StorageService } from '../storage/storage.service'; import { + AudinoManifest, CvatManifest, FortuneManifest, } from '../../common/interfaces/manifest'; import { + AudinoAnnotationMeta, CvatAnnotationMeta, FortuneFinalResult, } from '../../common/interfaces/job-result'; @@ -180,6 +184,18 @@ export class PayoutService { escrowAddress: string, ): Promise => this.saveResultsCvat(chainId, escrowAddress), }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + calculatePayouts: async ( + manifest: AudinoManifest, + data: CalculatePayoutsInput, + ): Promise => + this.calculatePayoutsAudino(manifest, data.chainId, data.escrowAddress), + saveResults: async ( + chainId: ChainId, + escrowAddress: string, + ): Promise => + this.saveResultsAudino(chainId, escrowAddress), + }, }; /** @@ -347,4 +363,95 @@ export class PayoutService { }), ); } + + /** + * Calculates payment distributions for a Audino-type job based on annotations data. + * Verifies annotation quality, accumulates bounties, and assigns payouts to qualified annotators. + * @param manifest The Audino manifest data. + * @param chainId The blockchain chain ID. + * @param escrowAddress The escrow contract address. + * @returns {Promise} Recipients, amounts, and relevant storage data. + */ + public async calculatePayoutsAudino( + manifest: AudinoManifest, + chainId: ChainId, + escrowAddress: string, + ): Promise { + const signer = this.web3Service.getSigner(chainId); + + const escrowClient = await EscrowClient.build(signer); + + const intermediateResultsUrl = + await escrowClient.getIntermediateResultsUrl(escrowAddress); + + const annotations: AudinoAnnotationMeta = + await this.storageService.downloadJsonLikeData( + `${intermediateResultsUrl}/${AUDINO_VALIDATION_META_FILENAME}`, + ); + + if ( + Array.isArray(annotations?.results) && + annotations.results.length === 0 && + Array.isArray(annotations?.jobs) && + annotations.jobs.length === 0 + ) { + throw new Error('No annotations meta found'); + } + + const jobBountyValue = ethers.parseUnits(manifest.job_bounty, 18); + const workersBounties = new Map(); + + for (const job of annotations.jobs) { + const jobFinalResult = annotations.results.find( + (result) => result.id === job.final_result_id, + ); + if ( + jobFinalResult + // && jobFinalResult.annotation_quality >= manifest.validation.min_quality + ) { + const workerAddress = jobFinalResult.annotator_wallet_address; + + const currentWorkerBounty = workersBounties.get(workerAddress) || 0n; + + workersBounties.set( + workerAddress, + currentWorkerBounty + jobBountyValue, + ); + } + } + + return Array.from(workersBounties.entries()).map( + ([workerAddress, bountyAmount]) => ({ + address: workerAddress, + amount: bountyAmount, + }), + ); + } + + /** + * Saves final results of a Audino-type job, using intermediate results for annotations. + * Retrieves intermediate results, copies files to storage, and returns the final results URL and hash. + * @param chainId The blockchain chain ID. + * @param escrowAddress The escrow contract address. + * @returns {Promise} The URL and hash for the saved results. + */ + public async saveResultsAudino( + chainId: ChainId, + escrowAddress: string, + ): Promise { + const signer = this.web3Service.getSigner(chainId); + + const escrowClient = await EscrowClient.build(signer); + + const intermediateResultsUrl = + await escrowClient.getIntermediateResultsUrl(escrowAddress); + + const { url, hash } = await this.storageService.copyFileFromURLToBucket( + escrowAddress, + chainId, + `${intermediateResultsUrl}/${AUDINO_RESULTS_ANNOTATIONS_FILENAME}`, + ); + + return { url, hash }; + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts index bc5509c4a9..9d17d72f73 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.interface.ts @@ -1,5 +1,6 @@ import { ChainId } from '@human-protocol/sdk'; import { + AudinoManifest, CvatManifest, FortuneManifest, } from '../../common/interfaces/manifest'; @@ -8,6 +9,6 @@ export interface RequestAction { assessWorkerReputationScores: ( chainId: ChainId, escrowAddress: string, - manifest?: FortuneManifest | CvatManifest, + manifest?: FortuneManifest | CvatManifest | AudinoManifest, ) => Promise; } diff --git a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts index b658887ea9..a8db78fbf7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/reputation/reputation.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ChainId } from '@human-protocol/sdk'; import { + AUDINO_VALIDATION_META_FILENAME, CVAT_VALIDATION_META_FILENAME, INITIAL_REPUTATION, } from '../../common/constants'; @@ -18,13 +19,15 @@ import { StorageService } from '../storage/storage.service'; import { Web3Service } from '../web3/web3.service'; import { EscrowClient } from '@human-protocol/sdk'; import { + AudinoAnnotationMeta, + AudinoAnnotationMetaResult, CvatAnnotationMeta, CvatAnnotationMetaResults, FortuneFinalResult, } from '../../common/interfaces/job-result'; import { RequestAction } from './reputation.interface'; import { getRequestType } from '../../utils/manifest'; -import { CvatManifest } from '../../common/interfaces/manifest'; +import { AudinoManifest, CvatManifest } from '../../common/interfaces/manifest'; import { ReputationConfigService } from '../../config/reputation-config.service'; import { Web3ConfigService } from '../../config/web3-config.service'; import { ReputationEntity } from './reputation.entity'; @@ -151,6 +154,13 @@ export class ReputationService { manifest: CvatManifest, ): Promise => this.processCvat(chainId, escrowAddress, manifest), }, + [JobRequestType.AUDIO_TRANSCRIPTION]: { + assessWorkerReputationScores: async ( + chainId: ChainId, + escrowAddress: string, + manifest: AudinoManifest, + ): Promise => this.processAudino(chainId, escrowAddress, manifest), + }, }; private async processFortune( @@ -223,6 +233,43 @@ export class ReputationService { ); } + private async processAudino( + chainId: ChainId, + escrowAddress: string, + manifest: AudinoManifest, + ): Promise { + const signer = this.web3Service.getSigner(chainId); + const escrowClient = await EscrowClient.build(signer); + + const intermediateResultsUrl = + await escrowClient.getIntermediateResultsUrl(escrowAddress); + + const annotations: AudinoAnnotationMeta = + await this.storageService.downloadJsonLikeData( + `${intermediateResultsUrl}/${AUDINO_VALIDATION_META_FILENAME}`, + ); + + // Assess reputation scores for workers based on the annoation quality. + // Decreases or increases worker reputation based on comparison annoation quality to minimum threshold. + await Promise.all( + annotations.results.map(async (result: AudinoAnnotationMetaResult) => { + if (result.annotation_quality < manifest.validation.min_quality) { + await this.decreaseReputation( + chainId, + result.annotator_wallet_address, + ReputationEntityType.WORKER, + ); + } else { + await this.increaseReputation( + chainId, + result.annotator_wallet_address, + ReputationEntityType.WORKER, + ); + } + }), + ); + } + /** * Increases the reputation points of a specified entity on a given blockchain chain. * If the entity doesn't exist in the database, it creates a new entry with initial reputation points. diff --git a/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts index 95d4f1c054..51e1d5fabb 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/fixtures/user.ts @@ -16,13 +16,14 @@ type GeneratedUser = T extends { privateKey: string } : Web2UserEntity; type GenerateUserOptions = { + password?: string; privateKey?: string; status?: UserStatus; }; export function generateWorkerUser( options?: T, ): GeneratedUser { - const password = faker.internet.password(); + const password = options?.password || faker.internet.password(); const passwordHash = securityUtils.hashPassword(password); const generatedUser: Web2UserEntity | Web2UserWithAddress = { diff --git a/packages/apps/reputation-oracle/server/src/modules/user/index.ts b/packages/apps/reputation-oracle/server/src/modules/user/index.ts index ce8027f924..8f6d1bce07 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/index.ts @@ -1,3 +1,4 @@ +export { SiteKeyRepository } from './site-key.repository'; export { SiteKeyType } from './site-key.entity'; export type * from './types'; export { UserEntity, UserStatus, Role as UserRole } from './user.entity'; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts index b1daaecc69..386c0449f8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.entity.ts @@ -22,7 +22,9 @@ export class SiteKeyEntity extends BaseEntity { }) type: SiteKeyType; - @ManyToOne('UserEntity', (user: UserEntity) => user.siteKeys) + @ManyToOne('UserEntity', (user: UserEntity) => user.siteKeys, { + persistence: false, + }) @JoinColumn() user?: UserEntity; diff --git a/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts b/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts index d951a16b5f..d2cc2d1f87 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/site-key.repository.ts @@ -16,6 +16,9 @@ export class SiteKeyRepository extends BaseRepository { siteKey: string, type: SiteKeyType, ): Promise { + if (!userId || !siteKey || !type) { + throw new Error('Invalid arguments'); + } return this.findOne({ where: { userId, siteKey, type }, }); diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts index 8a516d9924..ccdca718ce 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.controller.ts @@ -36,7 +36,6 @@ import { RegistrationInExchangeOracleResponseDto, } from './user.dto'; import { UserErrorFilter } from './user.error.filter'; -import { UserRepository } from './user.repository'; import { UserService } from './user.service'; /** @@ -55,7 +54,6 @@ export class UserController { constructor( private readonly userService: UserService, private readonly web3ConfigService: Web3ConfigService, - private readonly userRepository: UserRepository, ) {} @ApiOperation({ diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts index fb38018770..fb290974af 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.module.ts @@ -13,6 +13,6 @@ import { UserService } from './user.service'; imports: [Web3Module, HCaptchaModule], providers: [UserService, UserRepository, SiteKeyRepository], controllers: [UserController], - exports: [UserService, UserRepository], + exports: [SiteKeyRepository, UserService, UserRepository], }) export class UserModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts index 2921ad4193..b3cdf55a4b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.repository.ts @@ -19,6 +19,9 @@ export class UserRepository extends BaseRepository { id: number, options: FindOptions = {}, ): Promise { + if (!id) { + throw new Error('Invalid arguments'); + } return this.findOne({ where: { id }, relations: options.relations, @@ -29,6 +32,9 @@ export class UserRepository extends BaseRepository { email: string, options: FindOptions = {}, ): Promise { + if (!email) { + throw new Error('Invalid arguments'); + } return this.findOne({ where: { email }, relations: options.relations, @@ -39,6 +45,9 @@ export class UserRepository extends BaseRepository { address: string, options: FindOptions = {}, ): Promise { + if (!address) { + throw new Error('Invalid arguments'); + } return this.findOne({ where: { evmAddress: address.toLowerCase(), diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts index d0e679cb42..c8216f4436 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts @@ -10,7 +10,6 @@ import { generateEthWallet } from '../../../test/fixtures/web3'; import { SignatureType } from '../../common/enums/web3'; import { Web3ConfigService } from '../../config/web3-config.service'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; -import * as securityUtils from '../../utils/security'; import * as web3Utils from '../../utils/web3'; import { KycStatus } from '../kyc/constants'; @@ -25,7 +24,7 @@ import { } from './fixtures'; import { SiteKeyRepository } from './site-key.repository'; import { SiteKeyType } from './site-key.entity'; -import { Role, UserStatus } from './user.entity'; +import { Role } from './user.entity'; import { DuplicatedWalletAddressError, InvalidWeb3SignatureError, @@ -91,118 +90,6 @@ describe('UserService', () => { }); }); - describe('createWorkerUser', () => { - it('should create worker user and return the created entity', async () => { - const createUserData = { - email: faker.internet.email(), - password: faker.internet.password(), - }; - - const expectedUserData = { - email: createUserData.email, - role: Role.WORKER, - status: UserStatus.PENDING, - password: expect.not.stringMatching(createUserData.password), - }; - - const result = await userService.createWorkerUser(createUserData); - - expect(mockUserRepository.createUnique).toHaveBeenCalledWith( - expectedUserData, - ); - expect(result).toEqual(expectedUserData); - - expect( - securityUtils.comparePasswordWithHash( - createUserData.password, - result.password, - ), - ).toBe(true); - }); - }); - - describe('updatePassword', () => { - it('should throw if user not found', async () => { - mockUserRepository.findOneById.mockResolvedValueOnce(null); - - await expect( - userService.updatePassword( - faker.number.int(), - faker.internet.password(), - ), - ).rejects.toThrow('User not found'); - - expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); - }); - - it('should throw if not web2 user', async () => { - const mockUserEntity = generateOperator(); - mockUserRepository.findOneById.mockResolvedValueOnce(mockUserEntity); - - await expect( - userService.updatePassword( - faker.number.int(), - faker.internet.password(), - ), - ).rejects.toThrow('Only web2 users can have password'); - - expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(0); - }); - - it('should update password for requested user', async () => { - const mockUserEntity = generateWorkerUser(); - mockUserRepository.findOneById.mockResolvedValueOnce(mockUserEntity); - - const newPassword = faker.internet.password(); - - const result = await userService.updatePassword( - mockUserEntity.id, - newPassword, - ); - - expect( - securityUtils.comparePasswordWithHash(newPassword, result.password), - ).toBe(true); - - expect(mockUserRepository.findOneById).toHaveBeenCalledTimes(1); - expect(mockUserRepository.findOneById).toHaveBeenCalledWith( - mockUserEntity.id, - ); - - const expectedUserData = { - ...mockUserEntity, - password: expect.not.stringMatching(mockUserEntity.password), - }; - - expect(mockUserRepository.updateOne).toHaveBeenCalledTimes(1); - expect(mockUserRepository.updateOne).toHaveBeenCalledWith( - expectedUserData, - ); - - expect(result).toEqual(expectedUserData); - }); - }); - - describe('createOperatorUser', () => { - it('should create operator user and return the created entity', async () => { - const newOperatorAddress = generateEthWallet().address; - - const expectedUserData = { - evmAddress: newOperatorAddress.toLowerCase(), - nonce: expect.any(String), - role: Role.OPERATOR, - status: UserStatus.ACTIVE, - }; - - const result = await userService.createOperatorUser(newOperatorAddress); - - expect(mockUserRepository.createUnique).toHaveBeenCalledWith( - expectedUserData, - ); - expect(result).toEqual(expectedUserData); - }); - }); - describe('registerLabeler', () => { beforeEach(() => { mockHCaptchaService.registerLabeler.mockResolvedValue(false); diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts index 24cc83b788..e981308765 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts @@ -4,7 +4,6 @@ import { Injectable } from '@nestjs/common'; import { SignatureType } from '../../common/enums/web3'; import { Web3ConfigService } from '../../config/web3-config.service'; import { HCaptchaService } from '../../integrations/hcaptcha/hcaptcha.service'; -import * as securityUtils from '../../utils/security'; import * as web3Utils from '../../utils/web3'; import { KycStatus } from '../kyc/constants'; @@ -13,7 +12,7 @@ import { Web3Service } from '../web3/web3.service'; import { SiteKeyEntity, SiteKeyType } from './site-key.entity'; import { SiteKeyRepository } from './site-key.repository'; import { OperatorUserEntity, Web2UserEntity } from './types'; -import { Role as UserRole, UserStatus, UserEntity } from './user.entity'; +import { Role as UserRole, UserEntity } from './user.entity'; import { UserError, UserErrorMessage, @@ -43,26 +42,10 @@ export class UserService { ); } - async createWorkerUser(data: { - email: string; - password: string; - }): Promise { - const newUser = new UserEntity(); - newUser.email = data.email; - newUser.password = securityUtils.hashPassword(data.password); - newUser.role = UserRole.WORKER; - newUser.status = UserStatus.PENDING; - - await this.userRepository.createUnique(newUser); - - return newUser as Web2UserEntity; - } - async findWeb2UserByEmail(email: string): Promise { const userEntity = await this.userRepository.findOneByEmail(email, { relations: { kyc: true, - siteKeys: true, userQualifications: { qualification: true, }, @@ -76,39 +59,6 @@ export class UserService { return null; } - async updatePassword( - userId: number, - newPassword: string, - ): Promise { - const userEntity = await this.userRepository.findOneById(userId); - - if (!userEntity) { - throw new Error('User not found'); - } - - if (!UserService.isWeb2UserRole(userEntity.role)) { - throw new Error('Only web2 users can have password'); - } - - userEntity.password = securityUtils.hashPassword(newPassword); - - await this.userRepository.updateOne(userEntity); - - return userEntity as Web2UserEntity; - } - - async createOperatorUser(address: string): Promise { - const newUser = new UserEntity(); - newUser.evmAddress = address.toLowerCase(); - newUser.nonce = web3Utils.generateNonce(); - newUser.role = UserRole.OPERATOR; - newUser.status = UserStatus.ACTIVE; - - await this.userRepository.createUnique(newUser); - - return newUser as OperatorUserEntity; - } - async findOperatorUser(address: string): Promise { const userEntity = await this.userRepository.findOneByAddress(address); @@ -119,11 +69,6 @@ export class UserService { return null; } - async updateNonce(userEntity: OperatorUserEntity): Promise { - userEntity.nonce = web3Utils.generateNonce(); - return this.userRepository.updateOne(userEntity); - } - async registerLabeler(user: Web2UserEntity): Promise { if (user.role !== UserRole.WORKER) { throw new UserError(UserErrorMessage.INVALID_ROLE, user.id); diff --git a/packages/apps/reputation-oracle/server/src/utils/manifest.ts b/packages/apps/reputation-oracle/server/src/utils/manifest.ts index ff7691d7b2..66dbd9b991 100644 --- a/packages/apps/reputation-oracle/server/src/utils/manifest.ts +++ b/packages/apps/reputation-oracle/server/src/utils/manifest.ts @@ -1,14 +1,21 @@ -import { CvatManifest, FortuneManifest } from '../common/interfaces/manifest'; +import { + AudinoManifest, + CvatManifest, + FortuneManifest, +} from '../common/interfaces/manifest'; import { JobRequestType } from '../common/enums'; import { UnsupportedManifestTypeError } from '../common/errors/manifest'; export function getRequestType( - manifest: FortuneManifest | CvatManifest, + manifest: FortuneManifest | CvatManifest | AudinoManifest, ): JobRequestType { - const requestType = - (manifest as FortuneManifest).requestType || - ((manifest as CvatManifest).annotation && - (manifest as CvatManifest).annotation.type); + let requestType: JobRequestType | undefined; + + if ('requestType' in manifest) { + requestType = manifest.requestType; + } else if ('annotation' in manifest) { + requestType = manifest.annotation.type; + } if (!requestType) { throw new UnsupportedManifestTypeError(requestType); diff --git a/packages/apps/reputation-oracle/server/test/fixtures/crypto.ts b/packages/apps/reputation-oracle/server/test/fixtures/crypto.ts new file mode 100644 index 0000000000..4d58036693 --- /dev/null +++ b/packages/apps/reputation-oracle/server/test/fixtures/crypto.ts @@ -0,0 +1,15 @@ +import { generateKeyPairSync } from 'crypto'; + +export function generateES256Keys(): { publicKey: string; privateKey: string } { + return generateKeyPairSync('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); +} diff --git a/packages/apps/staking/package.json b/packages/apps/staking/package.json index 7b2b1ddf9b..827719042d 100644 --- a/packages/apps/staking/package.json +++ b/packages/apps/staking/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@human-protocol/sdk": "*", - "@mui/icons-material": "^6.4.6", + "@mui/icons-material": "^7.0.1", "@mui/material": "^5.16.7", "@tanstack/query-sync-storage-persister": "^5.68.0", "@tanstack/react-query": "^5.67.2", @@ -53,7 +53,7 @@ "eslint-plugin-react-refresh": "^0.4.11", "sass": "^1.85.0", "typescript": "^5.6.3", - "vite": "^6.2.0" + "vite": "^6.2.4" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/packages/apps/staking/src/constants/chains.ts b/packages/apps/staking/src/constants/chains.ts index c079b7577f..1c7e7cd18e 100644 --- a/packages/apps/staking/src/constants/chains.ts +++ b/packages/apps/staking/src/constants/chains.ts @@ -7,7 +7,11 @@ export const IS_TESTNET = !IS_MAINNET; let initialSupportedChainIds: ChainId[]; switch (import.meta.env.VITE_APP_ENVIRONMENT.toLowerCase()) { case 'mainnet': - initialSupportedChainIds = [ChainId.POLYGON]; + initialSupportedChainIds = [ + ChainId.POLYGON, + ChainId.MAINNET, + ChainId.BSC_MAINNET, + ]; break; case 'testnet': initialSupportedChainIds = [ diff --git a/packages/examples/cvat/exchange-oracle/alembic/script.py.mako b/packages/examples/cvat/exchange-oracle/alembic/script.py.mako index a2a14b50fa..074ddba5cd 100644 --- a/packages/examples/cvat/exchange-oracle/alembic/script.py.mako +++ b/packages/examples/cvat/exchange-oracle/alembic/script.py.mako @@ -1,4 +1,5 @@ -"""${message} +""" +${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} diff --git a/packages/examples/cvat/exchange-oracle/alembic/versions/1742568844_rename_event_c32b36a87539.py b/packages/examples/cvat/exchange-oracle/alembic/versions/1742568844_rename_event_c32b36a87539.py new file mode 100644 index 0000000000..024f3f9c14 --- /dev/null +++ b/packages/examples/cvat/exchange-oracle/alembic/versions/1742568844_rename_event_c32b36a87539.py @@ -0,0 +1,49 @@ +""" +Rename "job_creation_failed" to "escrow_failed" + +Revision ID: c32b36a87539 +Revises: 0a91b6a5f7b6 +Create Date: 2025-03-21 16:54:04.057456 + +""" + +from sqlalchemy import Column, String, update +from sqlalchemy.orm import declarative_base + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c32b36a87539" +down_revision = "0a91b6a5f7b6" +branch_labels = None +depends_on = None + + +Base = declarative_base() + +old_name = "job_creation_failed" +new_name = "escrow_failed" + + +class Webhook(Base): + # Represents the model before the transaction is applied + + __tablename__ = "webhooks" + id = Column(String, primary_key=True, index=True) + event_type = Column(String, nullable=False) + + +def update_webhook_types(): + op.execute(update(Webhook).where(Webhook.event_type == old_name).values(event_type=new_name)) + + +def revert_webhook_types(): + op.execute(update(Webhook).where(Webhook.event_type == new_name).values(event_type=old_name)) + + +def upgrade() -> None: + update_webhook_types() + + +def downgrade() -> None: + revert_webhook_types() diff --git a/packages/examples/cvat/exchange-oracle/debug.py b/packages/examples/cvat/exchange-oracle/debug.py index 66168f9c65..1b0deab363 100644 --- a/packages/examples/cvat/exchange-oracle/debug.py +++ b/packages/examples/cvat/exchange-oracle/debug.py @@ -3,7 +3,7 @@ from collections.abc import Generator from contextlib import ExitStack, contextmanager from logging import Logger -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from unittest import mock @@ -62,7 +62,9 @@ def patched_get_escrow(chain_id: int, escrow_address: str) -> EscrowData: minio_manifests = minio_client.list_files(bucket="manifests") logger.debug(f"DEV: Local manifests: {format_sequence(minio_manifests)}") - candidate_files = [fn for fn in minio_manifests if f"{escrow_address}.json" in fn] + candidate_files = [ + fn for fn in minio_manifests if PurePosixPath(fn).name == f"{escrow_address}.json" + ] if not candidate_files: return original_get_escrow(ChainId(chain_id), escrow_address) elif len(candidate_files) != 1: diff --git a/packages/examples/cvat/exchange-oracle/poetry.lock b/packages/examples/cvat/exchange-oracle/poetry.lock index 3f7f3005e8..f1970962d5 100644 --- a/packages/examples/cvat/exchange-oracle/poetry.lock +++ b/packages/examples/cvat/exchange-oracle/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiocache" @@ -945,13 +945,13 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "cvat-sdk" -version = "2.25.0" +version = "2.31.0" description = "CVAT REST API" optional = false python-versions = ">=3.9" files = [ - {file = "cvat_sdk-2.25.0-py3-none-any.whl", hash = "sha256:dc52b8ba1b9358902462846e004580b7b9baeed76b742b5f49a11c141a800eb7"}, - {file = "cvat_sdk-2.25.0.tar.gz", hash = "sha256:65c7faf74fbbd4ca799efa31774d6c378afed55d0a4c6e1141a6621845f35178"}, + {file = "cvat_sdk-2.31.0-py3-none-any.whl", hash = "sha256:b33e8526dad8c481f82e445badfced5d69747eaf7e5660b0d176cf86d394a02e"}, + {file = "cvat_sdk-2.31.0.tar.gz", hash = "sha256:aaeff833c32bfe711f418c62bdab135e0746eff0e89757e8b61cfad14a42ef23"}, ] [package.dependencies] @@ -3368,8 +3368,6 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -3872,7 +3870,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -4130,50 +4127,30 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -5070,4 +5047,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10,<3.13" -content-hash = "5745c842dc5369a685b1f65a05ab9e08e6ce73e7897d6211a5d8de7c6dfb232e" +content-hash = "c643f28ae7113ae0b8051952c0adfcb74a0ae182ba5039133f57f52b78608007" diff --git a/packages/examples/cvat/exchange-oracle/pyproject.toml b/packages/examples/cvat/exchange-oracle/pyproject.toml index a4cf79df30..187d9c81e3 100644 --- a/packages/examples/cvat/exchange-oracle/pyproject.toml +++ b/packages/examples/cvat/exchange-oracle/pyproject.toml @@ -16,7 +16,7 @@ sqlalchemy-utils = "^0.41.1" alembic = "^1.11.1" httpx = "^0.24.1" pytest = "^7.2.2" -cvat-sdk = "2.25.0" +cvat-sdk = "2.31.0" sqlalchemy = "^2.0.16" apscheduler = "^3.10.1" xmltodict = "^0.13.0" diff --git a/packages/examples/cvat/exchange-oracle/src/.env.template b/packages/examples/cvat/exchange-oracle/src/.env.template index b4a0d5afc9..b07c93515c 100644 --- a/packages/examples/cvat/exchange-oracle/src/.env.template +++ b/packages/examples/cvat/exchange-oracle/src/.env.template @@ -94,6 +94,7 @@ ENABLE_CUSTOM_CLOUD_HOST= REQUEST_LOGGING_ENABLED= PROFILING_ENABLED= MANIFEST_CACHE_TTL= +MAX_DATA_STORAGE_CONNECTIONS= # Core diff --git a/packages/examples/cvat/exchange-oracle/src/core/annotation_meta.py b/packages/examples/cvat/exchange-oracle/src/core/annotation_meta.py index 69632a6f89..86f306921d 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/annotation_meta.py +++ b/packages/examples/cvat/exchange-oracle/src/core/annotation_meta.py @@ -1,5 +1,3 @@ -from pathlib import Path - from pydantic import BaseModel ANNOTATION_RESULTS_METAFILE_NAME = "annotation_meta.json" @@ -9,7 +7,6 @@ class JobMeta(BaseModel): job_id: int task_id: int - annotation_filename: Path annotator_wallet_address: str assignment_id: str start_frame: int diff --git a/packages/examples/cvat/exchange-oracle/src/core/config.py b/packages/examples/cvat/exchange-oracle/src/core/config.py index 393e3aa600..9e40bb1265 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/config.py +++ b/packages/examples/cvat/exchange-oracle/src/core/config.py @@ -229,9 +229,12 @@ class FeaturesConfig: profiling_enabled = to_bool(getenv("PROFILING_ENABLED", "0")) "Allow to profile specific requests" - manifest_cache_ttl = int(os.getenv("MANIFEST_CACHE_TTL", str(2 * 24 * 60 * 60))) + manifest_cache_ttl = int(getenv("MANIFEST_CACHE_TTL", str(2 * 24 * 60 * 60))) "TTL for cached manifests" + max_data_storage_connections = int(getenv("MAX_DATA_STORAGE_CONNECTIONS", 5)) + "Max parallel data storage connections in 1 client (job creation, ...)" + class CoreConfig: default_assignment_time = int(getenv("DEFAULT_ASSIGNMENT_TIME", 1800)) diff --git a/packages/examples/cvat/exchange-oracle/src/core/oracle_events.py b/packages/examples/cvat/exchange-oracle/src/core/oracle_events.py index 5b0efc0a95..4ac7bef3f2 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/oracle_events.py +++ b/packages/examples/cvat/exchange-oracle/src/core/oracle_events.py @@ -38,7 +38,7 @@ class RejectedAssignmentInfo(BaseModel): assignments: list[RejectedAssignmentInfo] -class ExchangeOracleEvent_JobCreationFailed(OracleEvent): +class ExchangeOracleEvent_EscrowFailed(OracleEvent): # no task_id, escrow is enough for now reason: str @@ -47,6 +47,10 @@ class ExchangeOracleEvent_JobFinished(OracleEvent): pass # escrow is enough for now +class ExchangeOracleEvent_EscrowRecorded(OracleEvent): + pass # escrow is enough for now + + class ExchangeOracleEvent_EscrowCleaned(OracleEvent): pass @@ -56,11 +60,13 @@ class ReputationOracleEvent_EscrowCompleted(OracleEvent): _event_type_map = { + # TODO: make sender-dependent JobLauncherEventTypes.escrow_created: JobLauncherEvent_EscrowCreated, JobLauncherEventTypes.escrow_canceled: JobLauncherEvent_EscrowCanceled, RecordingOracleEventTypes.job_completed: RecordingOracleEvent_JobCompleted, RecordingOracleEventTypes.submission_rejected: RecordingOracleEvent_SubmissionRejected, - ExchangeOracleEventTypes.job_creation_failed: ExchangeOracleEvent_JobCreationFailed, + ExchangeOracleEventTypes.escrow_recorded: ExchangeOracleEvent_EscrowRecorded, + ExchangeOracleEventTypes.escrow_failed: ExchangeOracleEvent_EscrowFailed, ExchangeOracleEventTypes.job_finished: ExchangeOracleEvent_JobFinished, ExchangeOracleEventTypes.escrow_cleaned: ExchangeOracleEvent_EscrowCleaned, ReputationOracleEventTypes.escrow_completed: ReputationOracleEvent_EscrowCompleted, @@ -68,6 +74,7 @@ class ReputationOracleEvent_EscrowCompleted(OracleEvent): def get_class_for_event_type(event_type: str) -> type[OracleEvent]: + # TODO: make sender-dependent event_class = next((v for k, v in _event_type_map.items() if k == event_type), None) if not event_class: diff --git a/packages/examples/cvat/exchange-oracle/src/core/types.py b/packages/examples/cvat/exchange-oracle/src/core/types.py index 779155f138..2b67fe9fab 100644 --- a/packages/examples/cvat/exchange-oracle/src/core/types.py +++ b/packages/examples/cvat/exchange-oracle/src/core/types.py @@ -62,9 +62,10 @@ class OracleWebhookTypes(str, Enum, metaclass=BetterEnumMeta): class ExchangeOracleEventTypes(str, Enum, metaclass=BetterEnumMeta): - job_creation_failed = "job_creation_failed" + escrow_failed = "escrow_failed" job_finished = "job_finished" escrow_cleaned = "escrow_cleaned" + escrow_recorded = "escrow_recorded" class JobLauncherEventTypes(str, Enum, metaclass=BetterEnumMeta): @@ -78,7 +79,6 @@ class RecordingOracleEventTypes(str, Enum, metaclass=BetterEnumMeta): class ReputationOracleEventTypes(str, Enum, metaclass=BetterEnumMeta): - # TODO: rename to ReputationOracleEventType escrow_completed = "escrow_completed" diff --git a/packages/examples/cvat/exchange-oracle/src/crons/__init__.py b/packages/examples/cvat/exchange-oracle/src/crons/__init__.py index 55d8b5627b..a640805474 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/__init__.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/__init__.py @@ -16,6 +16,7 @@ process_outgoing_job_launcher_webhooks, ) from src.crons.webhooks.recording_oracle import ( + process_incoming_recording_oracle_webhook_job_completed, process_incoming_recording_oracle_webhooks, process_outgoing_recording_oracle_webhooks, ) @@ -41,6 +42,11 @@ def cron_record(): "interval", seconds=Config.cron_config.process_recording_oracle_webhooks_int, ) + scheduler.add_job( + process_incoming_recording_oracle_webhook_job_completed, + "interval", + seconds=Config.cron_config.process_recording_oracle_webhooks_int, + ) scheduler.add_job( process_outgoing_recording_oracle_webhooks, "interval", diff --git a/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py b/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py index e4b689aaa6..04252e82bc 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/cvat/state_trackers.py @@ -8,7 +8,7 @@ import src.services.webhook as oracle_db_service from src import db from src.core.config import CronConfig -from src.core.oracle_events import ExchangeOracleEvent_JobCreationFailed +from src.core.oracle_events import ExchangeOracleEvent_EscrowFailed from src.core.types import JobStatuses, OracleWebhookTypes, ProjectStatuses from src.crons._cron_job import cron_job from src.db import SessionLocal @@ -159,7 +159,7 @@ def track_task_creation(logger: logging.Logger, session: Session) -> None: for upload in uploads: status, reason = cvat_api.get_task_upload_status(upload.task_id) project = upload.task.project - if not status or status == cvat_api.UploadStatus.FAILED: + if not status or status == cvat_api.RequestStatus.FAILED: # TODO: add retries if 5xx failed.append(upload) @@ -168,9 +168,9 @@ def track_task_creation(logger: logging.Logger, session: Session) -> None: escrow_address=project.escrow_address, chain_id=project.chain_id, type=OracleWebhookTypes.job_launcher, - event=ExchangeOracleEvent_JobCreationFailed(reason=reason), + event=ExchangeOracleEvent_EscrowFailed(reason=reason), ) - elif status == cvat_api.UploadStatus.FINISHED: + elif status == cvat_api.RequestStatus.FINISHED: try: cvat_jobs = cvat_api.fetch_task_jobs(upload.task_id) @@ -200,7 +200,7 @@ def track_task_creation(logger: logging.Logger, session: Session) -> None: escrow_address=project.escrow_address, chain_id=project.chain_id, type=OracleWebhookTypes.job_launcher, - event=ExchangeOracleEvent_JobCreationFailed(reason=str(e)), + event=ExchangeOracleEvent_EscrowFailed(reason=str(e)), ) if completed: diff --git a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py index dd195d36e8..3d99d5e2cd 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/job_launcher.py @@ -11,7 +11,7 @@ from src.core.config import Config, CronConfig from src.core.oracle_events import ( ExchangeOracleEvent_EscrowCleaned, - ExchangeOracleEvent_JobCreationFailed, + ExchangeOracleEvent_EscrowFailed, ) from src.core.types import JobLauncherEventTypes, Networks, OracleWebhookTypes, ProjectStatuses from src.crons._cron_job import cron_job @@ -36,7 +36,7 @@ def handle_failure(session: Session, webhook: Webhook, exc: Exception) -> None: escrow_address=webhook.escrow_address, chain_id=webhook.chain_id, type=OracleWebhookTypes.job_launcher, - event=ExchangeOracleEvent_JobCreationFailed(reason=str(exc)), + event=ExchangeOracleEvent_EscrowFailed(reason=str(exc)), ) diff --git a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/recording_oracle.py b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/recording_oracle.py index 0315cac825..bf527a9ef0 100644 --- a/packages/examples/cvat/exchange-oracle/src/crons/webhooks/recording_oracle.py +++ b/packages/examples/cvat/exchange-oracle/src/crons/webhooks/recording_oracle.py @@ -19,6 +19,7 @@ from src.crons._cron_job import cron_job from src.crons.webhooks._common import handle_webhook, process_outgoing_webhooks from src.db.utils import ForUpdateParams +from src.handlers.completed_escrows import handle_escrow_export from src.models.webhook import Webhook @@ -30,6 +31,29 @@ def process_incoming_recording_oracle_webhooks(logger: logging.Logger, session: webhooks = oracle_db_service.inbox.get_pending_webhooks( session, OracleWebhookTypes.recording_oracle, + event_type_not_in=[RecordingOracleEventTypes.job_completed], + limit=CronConfig.process_recording_oracle_webhooks_chunk_size, + for_update=ForUpdateParams(skip_locked=True), + ) + + for webhook in webhooks: + with handle_webhook(logger, session, webhook, queue=oracle_db_service.inbox): + handle_recording_oracle_event(webhook, db_session=session, logger=logger) + + +@cron_job +def process_incoming_recording_oracle_webhook_job_completed( + logger: logging.Logger, session: Session +): + """ + Process incoming oracle webhooks of type job_completed + We do it in a separate job as this is a long operation that should not block + other message handling. + """ + webhooks = oracle_db_service.inbox.get_pending_webhooks( + session, + OracleWebhookTypes.recording_oracle, + event_type_in=[RecordingOracleEventTypes.job_completed], limit=CronConfig.process_recording_oracle_webhooks_chunk_size, for_update=ForUpdateParams(skip_locked=True), ) @@ -70,6 +94,7 @@ def handle_recording_oracle_event(webhook: Webhook, *, db_session: Session, logg ) return + recorded_project_cvat_ids: set[int] = set() chunk_size = CronConfig.process_accepted_projects_chunk_size for ids_chunk in take_by(project_ids, chunk_size): projects_chunk = cvat_db_service.get_projects_by_cvat_ids( @@ -89,15 +114,11 @@ def handle_recording_oracle_event(webhook: Webhook, *, db_session: Session, logg ) return - new_status = ProjectStatuses.recorded - logger.info( - "Changing project status to {} (escrow_address={}, project={})".format( - new_status, webhook.escrow_address, project.cvat_id - ) + recorded_project_cvat_ids.add(project.cvat_id) + cvat_db_service.update_project_status( + db_session, project.id, ProjectStatuses.recorded ) - cvat_db_service.update_project_status(db_session, project.id, new_status) - cvat_db_service.touch_final_assignments( db_session, cvat_project_ids=[p.cvat_id for p in projects_chunk], @@ -111,6 +132,27 @@ def handle_recording_oracle_event(webhook: Webhook, *, db_session: Session, logg status=EscrowValidationStatuses.completed, ) + logger.info( + f"Escrow '{webhook.escrow_address}' is accepted, trying to export annotations..." + ) + handle_escrow_export( + logger=logger, + session=db_session, + escrow_address=webhook.escrow_address, + chain_id=webhook.chain_id, + ) + + if recorded_project_cvat_ids: + # Print it after successful export so that the logs were consistent + # in the case of export error + logger.info( + "Changing project statuses to {} (escrow_address={}, project={})".format( + ProjectStatuses.recorded, + webhook.escrow_address, + sorted(recorded_project_cvat_ids), + ) + ) + case RecordingOracleEventTypes.submission_rejected: event = RecordingOracleEvent_SubmissionRejected.model_validate(webhook.event_data) diff --git a/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py b/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py index 8a1eb739ce..27c869c863 100644 --- a/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py +++ b/packages/examples/cvat/exchange-oracle/src/cvat/api_calls.py @@ -18,6 +18,7 @@ from cvat_sdk.api_client.api_client import Endpoint from cvat_sdk.core.helpers import get_paginated_collection from cvat_sdk.core.uploading import AnnotationUploader +from httpx import URL from src.core.config import Config from src.utils.enums import BetterEnumMeta @@ -30,33 +31,37 @@ class CVATException(Exception): """Indicates that CVAT API returned unexpected response""" -def _request_annotations(endpoint: Endpoint, cvat_id: int, format_name: str) -> bool: +class RequestStatus(str, Enum, metaclass=BetterEnumMeta): + QUEUED = "Queued" + STARTED = "Started" + FINISHED = "Finished" + FAILED = "Failed" + + +def _request_annotations(endpoint: Endpoint, cvat_id: int, format_name: str) -> str: """ Requests annotations export. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not _request_annotations(...): - # some waiting like - sleep(1) - - _get_annotations(...) + request_id = _request_annotations(...) + _get_annotations(request_id, ...) """ (_, response) = endpoint.call_with_http_info( id=cvat_id, format=format_name, + save_images=False, _parse_response=False, ) assert response.status in [HTTPStatus.ACCEPTED, HTTPStatus.CREATED] - return response.status == HTTPStatus.CREATED + return response.json()["rq_id"] def _get_annotations( - endpoint: Endpoint, + api_client: ApiClient, + request_id: str, *, - cvat_id: int, - format_name: str, attempt_interval: int = 5, timeout: int | None = _NOTSET, ) -> io.RawIOBase: @@ -64,14 +69,8 @@ def _get_annotations( Downloads annotations. The dataset preparation can take some time (e.g. 10 min), so it should be used like this: - while not _request_annotations(...): - # some waiting like - sleep(1) - - _get_annotations(...) - - - It still can be used as 1 call, but the result can be unreliable. + request_id = _request_annotations(...) + _get_annotations(request_id, ...) """ time_begin = utcnow() @@ -80,13 +79,13 @@ def _get_annotations( timeout = Config.cvat_config.export_timeout while True: - (_, response) = endpoint.call_with_http_info( - id=cvat_id, - action="download", - format=format_name, - _parse_response=False, - ) - if response.status == HTTPStatus.OK: + request_info = api_client.requests_api.retrieve(request_id)[0] + if request_info.status.value == models.RequestStatus.allowed_values[("value",)]["FAILED"]: + raise Exception( + f"Failed to export annotations for {request_id=}: {request_info.message}" + ) + + if request_info.status.value == models.RequestStatus.allowed_values[("value",)]["FINISHED"]: break if timeout is not None and timedelta(seconds=timeout) < (utcnow() - time_begin): @@ -94,6 +93,19 @@ def _get_annotations( sleep(attempt_interval) + result_url = URL(request_info.result_url) + query_params = result_url.params + headers = api_client.get_common_headers() + api_client.update_params_for_auth( + headers=headers, + queries=query_params, + auth_settings=[""], + resource_path="", + method="GET", + request_auths=list(api_client.configuration.auth_settings().values()), + body="", + ) + response = api_client.rest_client.GET(request_info.result_url, headers=headers) file_buffer = io.BytesIO(response.data) assert zipfile.is_zipfile(file_buffer) file_buffer.seek(0) @@ -231,23 +243,20 @@ def create_project( raise -def request_project_annotations(cvat_id: int, format_name: str) -> bool: +def request_project_annotations(cvat_id: int, format_name: str) -> str: """ Requests annotations export. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not request_project_annotations(...): - # some waiting like - sleep(1) - - get_project_annotations(...) + request_id = request_project_annotations(...): + get_project_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: return _request_annotations( - api_client.projects_api.retrieve_annotations_endpoint, + api_client.projects_api.create_dataset_export_endpoint, cvat_id=cvat_id, format_name=format_name, ) @@ -256,32 +265,19 @@ def request_project_annotations(cvat_id: int, format_name: str) -> bool: raise -def get_project_annotations( - cvat_id: int, format_name: str, *, timeout: int | None = _NOTSET -) -> io.RawIOBase: +def get_project_annotations(request_id: str, *, timeout: int | None = _NOTSET) -> io.RawIOBase: """ Downloads annotations. The dataset preparation can take some time (e.g. 10 min), so it should be used like this: - while not request_project_annotations(...): - # some waiting like - sleep(1) - - get_project_annotations(...) - - - It still can be used as 1 call, but the result can be unreliable. + request_id = request_project_annotations(...): + get_project_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: - return _get_annotations( - api_client.projects_api.retrieve_annotations_endpoint, - cvat_id=cvat_id, - format_name=format_name, - timeout=timeout, - ) + return _get_annotations(api_client, request_id=request_id, timeout=timeout) except exceptions.ApiException as e: logger.exception(f"Exception when calling ProjectApi.retrieve_annotations: {e}\n") raise @@ -418,23 +414,20 @@ def put_task_data( raise -def request_task_annotations(cvat_id: int, format_name: str) -> bool: +def request_task_annotations(cvat_id: int, format_name: str) -> str: """ Requests annotations export. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not request_task_annotations(...): - # some waiting like - sleep(1) - - get_task_annotations(...) + request_id = request_task_annotations(...): + get_task_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: return _request_annotations( - api_client.tasks_api.retrieve_annotations_endpoint, + api_client.tasks_api.create_dataset_export_endpoint, cvat_id=cvat_id, format_name=format_name, ) @@ -443,32 +436,19 @@ def request_task_annotations(cvat_id: int, format_name: str) -> bool: raise -def get_task_annotations( - cvat_id: int, format_name: str, *, timeout: int | None = _NOTSET -) -> io.RawIOBase: +def get_task_annotations(request_id: str, *, timeout: int | None = _NOTSET) -> io.RawIOBase: """ Downloads annotations. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not request_task_annotations(...): - # some waiting like - sleep(1) - - get_task_annotations(...) - - - It still can be used as 1 call, but the result can be unreliable. + request_id = request_task_annotations(...): + get_task_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: - return _get_annotations( - api_client.tasks_api.retrieve_annotations_endpoint, - cvat_id=cvat_id, - format_name=format_name, - timeout=timeout, - ) + return _get_annotations(api_client, request_id=request_id, timeout=timeout) except exceptions.ApiException as e: logger.exception(f"Exception when calling TasksApi.retrieve_annotations: {e}\n") raise @@ -488,23 +468,20 @@ def fetch_task_jobs(task_id: int) -> list[models.JobRead]: raise -def request_job_annotations(cvat_id: int, format_name: str) -> bool: +def request_job_annotations(cvat_id: int, format_name: str) -> str: """ Requests annotations export. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not request_job_annotations(...): - # some waiting like - sleep(1) - - get_job_annotations(...) + request_id = request_job_annotations(...): + get_job_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: return _request_annotations( - api_client.jobs_api.retrieve_annotations_endpoint, + api_client.jobs_api.create_dataset_export_endpoint, cvat_id=cvat_id, format_name=format_name, ) @@ -513,32 +490,19 @@ def request_job_annotations(cvat_id: int, format_name: str) -> bool: raise -def get_job_annotations( - cvat_id: int, format_name: str, *, timeout: int | None = _NOTSET -) -> io.RawIOBase: +def get_job_annotations(request_id: str, *, timeout: int | None = _NOTSET) -> io.RawIOBase: """ Downloads annotations. The dataset preparation can take some time (e.g. 10 min), so it must be used like this: - while not request_job_annotations(...): - # some waiting like - sleep(1) - - get_job_annotations(...) - - - It still can be used as 1 call, but the result can be unreliable. + request_id = request_job_annotations(...): + get_job_annotations(request_id, ...) """ logger = logging.getLogger("app") with get_api_client() as api_client: try: - return _get_annotations( - api_client.jobs_api.retrieve_annotations_endpoint, - cvat_id=cvat_id, - format_name=format_name, - timeout=timeout, - ) + return _get_annotations(api_client, request_id=request_id, timeout=timeout) except exceptions.ApiException as e: logger.exception(f"Exception when calling JobsApi.retrieve_annotations: {e}\n") raise @@ -577,24 +541,34 @@ def fetch_projects(assignee: str = "") -> list[models.ProjectRead]: raise -class UploadStatus(str, Enum, metaclass=BetterEnumMeta): - QUEUED = "Queued" - STARTED = "Started" - FINISHED = "Finished" - FAILED = "Failed" - - -def get_task_upload_status(cvat_id: int) -> tuple[UploadStatus | None, str]: +def get_task_upload_status(cvat_id: int) -> tuple[RequestStatus, str]: logger = logging.getLogger("app") with get_api_client() as api_client: try: - (status, _) = api_client.tasks_api.retrieve_status(cvat_id) - return UploadStatus(status.state.value), status.message - except exceptions.ApiException as e: - if e.status == 404: - return None, e.body + results = api_client.requests_api.list(task_id=cvat_id, action="create", page_size=1)[ + 0 + ].results + if not results: + status = None + reason = f"Task #{cvat_id} creation request not found" + else: + status = RequestStatus(results[0].status.value.capitalize()) + reason = results[0].message + + if status is None: + # Double check task status - the request can be removed already + # TODO: remove this workaround when there is a stable replacement + task = api_client.tasks_api.retrieve(cvat_id)[0] + if task.size > 0: + status = RequestStatus.FINISHED + reason = "" + else: + status = RequestStatus.QUEUED + reason = "" + return status, reason + except exceptions.ApiException as e: logger.exception(f"Exception when calling ProjectsApi.list(): {e}\n") raise diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/completed_escrows.py b/packages/examples/cvat/exchange-oracle/src/handlers/completed_escrows.py index 1900024b4f..55821087c2 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/completed_escrows.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/completed_escrows.py @@ -14,7 +14,10 @@ from src.chain.escrow import get_escrow_manifest, validate_escrow from src.core.annotation_meta import ANNOTATION_RESULTS_METAFILE_NAME, RESULTING_ANNOTATIONS_FILE from src.core.config import CronConfig, StorageConfig -from src.core.oracle_events import ExchangeOracleEvent_JobFinished +from src.core.oracle_events import ( + ExchangeOracleEvent_EscrowRecorded, + ExchangeOracleEvent_JobFinished, +) from src.core.storage import compose_results_bucket_filename from src.core.types import EscrowValidationStatuses, OracleWebhookTypes, TaskTypes from src.db import SessionLocal @@ -68,9 +71,23 @@ def _export_escrow_annotations( ) -> None: manifest = parse_manifest(get_escrow_manifest(chain_id, escrow_address)) - logger.debug(f"Downloading results for the escrow ({escrow_address=})") + escrow_creation = cvat_service.get_escrow_creation_by_escrow_address( + session, + escrow_address, + chain_id, + active=False, + ) + if not escrow_creation: + raise AssertionError(f"Can't find escrow creation for escrow '{escrow_address}'") jobs = cvat_service.get_jobs_by_escrow_address(session, escrow_address, chain_id) + if len(jobs) != escrow_creation.total_jobs: + raise AssertionError( + f"Unexpected number of jobs fetched for escrow " + f"'{escrow_address}': {len(jobs)}, expected {escrow_creation.total_jobs}" + ) + + logger.debug(f"Downloading results for the escrow ({escrow_address=})") annotation_format = CVAT_EXPORT_FORMAT_MAPPING[manifest.annotation.type] # FUTURE-TODO: probably can be removed in the future since @@ -79,7 +96,7 @@ def _export_escrow_annotations( if manifest.annotation.type == TaskTypes.image_skeletons_from_boxes.value: # we'll have to merge annotations ourselves for skeletons - # might want to make this the only behaviour in the future + # might want to make this the only behavior in the future project_annotations_file = None project_images = None else: @@ -116,13 +133,13 @@ def _export_escrow_annotations( manifest=manifest, project_images=project_images, ) - logger.debug(f"Uploading results for the escrow ({escrow_address=})") + logger.debug(f"Uploading annotations for the escrow ({escrow_address=})") - _upload_annotations( - annotation_files=( + _upload_escrow_results( + files=( resulting_annotations_file_desc, *job_annotations.values(), - prepare_annotation_metafile(jobs=jobs, job_annotations=job_annotations), + prepare_annotation_metafile(jobs=jobs), ), chain_id=chain_id, escrow_address=escrow_address, @@ -133,7 +150,7 @@ def _export_escrow_annotations( escrow_address=escrow_address, chain_id=chain_id, type=OracleWebhookTypes.recording_oracle, - event=ExchangeOracleEvent_JobFinished(), + event=ExchangeOracleEvent_EscrowRecorded(), ) logger.info( @@ -142,8 +159,40 @@ def _export_escrow_annotations( ) -def _upload_annotations( - annotation_files: Sequence[FileDescriptor], chain_id: int, escrow_address: str +def _request_escrow_validation( + logger: logging.Logger, + chain_id: int, + escrow_address: str, + escrow_projects: Sequence[Project], + session: Session, +) -> None: + # TODO: lock escrow once there is such a DB object + assert escrow_projects # unused, but must hold a lock + + # TODO: maybe upload only current iteration jobs + jobs = cvat_service.get_jobs_by_escrow_address(session, escrow_address, chain_id) + + logger.debug(f"Uploading assignment info for the escrow ({escrow_address=})") + + _upload_escrow_results( + files=[prepare_annotation_metafile(jobs=jobs)], + chain_id=chain_id, + escrow_address=escrow_address, + ) + + oracle_db_service.outbox.create_webhook( + session, + escrow_address=escrow_address, + chain_id=chain_id, + type=OracleWebhookTypes.recording_oracle, + event=ExchangeOracleEvent_JobFinished(), + ) + + logger.info(f"The escrow ({escrow_address=}) annotation is finished, " f"requesting validation") + + +def _upload_escrow_results( + files: Sequence[FileDescriptor], chain_id: int, escrow_address: str ) -> None: storage_info = BucketAccessInfo.parse_obj(StorageConfig) storage_client = cloud_service.make_client(storage_info) @@ -156,7 +205,8 @@ def _upload_annotations( trim_prefix=True, ) ) - {ANNOTATION_RESULTS_METAFILE_NAME, RESULTING_ANNOTATIONS_FILE} - for file_descriptor in annotation_files: + + for file_descriptor in files: if file_descriptor.filename in existing_storage_files: continue @@ -173,48 +223,54 @@ def _upload_annotations( def _download_project_annotations( logger: logging.Logger, annotation_format: str, project_cvat_id: int ) -> io.RawIOBase: - cvat_api.request_project_annotations(project_cvat_id, format_name=annotation_format) + export_ids = [] + + def _request_export(cvat_id: int): + request_id = cvat_api.request_project_annotations(cvat_id, format_name=annotation_format) + export_ids.append(request_id) + return request_id + + def _download_export(): + request_id = export_ids.pop(0) + return cvat_api.get_project_annotations(request_id=request_id) + + _request_export(project_cvat_id) + return _download_with_retries( logger, - download_callback=partial( - cvat_api.get_project_annotations, - project_cvat_id, - format_name=annotation_format, - ), - retry_callback=partial( - cvat_api.request_project_annotations, - project_cvat_id, - format_name=annotation_format, - ), + download_callback=_download_export, + retry_callback=partial(_request_export, project_cvat_id), ) def _download_job_annotations( logger: logging.Logger, annotation_format: str, jobs: Sequence[Job] ) -> dict[int, FileDescriptor]: - job_annotations: dict[int, FileDescriptor] = {} # Collect raw annotations from CVAT, validate and convert them # into a recording oracle suitable format + job_annotations: dict[int, FileDescriptor] = {} + + export_ids: list[tuple[Job, str]] = [] + + def _request_export(job: Job): + request_id = cvat_api.request_job_annotations(job.cvat_id, format_name=annotation_format) + export_ids.append((job, request_id)) + return request_id + for jobs_batch in take_by( jobs, count=CronConfig.track_completed_escrows_jobs_downloading_batch_size ): # Request jobs before downloading for faster batch downloading for job in jobs_batch: - cvat_api.request_job_annotations(job.cvat_id, format_name=annotation_format) + _request_export(job) + + while export_ids: + (job, request_id) = export_ids.pop(0) - for job in jobs_batch: job_annotations_file = _download_with_retries( logger, - download_callback=partial( - cvat_api.get_job_annotations, - job.cvat_id, - format_name=annotation_format, - ), - retry_callback=partial( - cvat_api.request_job_annotations, - job.cvat_id, - format_name=annotation_format, - ), + download_callback=partial(cvat_api.get_job_annotations, request_id=request_id), + retry_callback=partial(_request_export, job), ) job_assignment = job.latest_assignment @@ -228,6 +284,7 @@ def _download_job_annotations( ), file=job_annotations_file, ) + return job_annotations @@ -242,7 +299,7 @@ def _handle_escrow_validation( escrow_projects = cvat_service.get_projects_by_escrow_address( session, escrow_address, limit=None, for_update=ForUpdateParams(nowait=True) ) - _export_escrow_annotations(logger, chain_id, escrow_address, escrow_projects, session) + _request_escrow_validation(logger, chain_id, escrow_address, escrow_projects, session) def handle_escrows_validations(logger: logging.Logger) -> None: @@ -274,3 +331,17 @@ def handle_escrows_validations(logger: logging.Logger) -> None: increase_attempts=True, # increase attempts always to allow escrow rotation **update_kwargs, ) + + +def handle_escrow_export( + logger: logging.Logger, + session: Session, + escrow_address: str, + chain_id: int, +): + validate_escrow(chain_id, escrow_address) + + escrow_projects = cvat_service.get_projects_by_escrow_address( + session, escrow_address, limit=None, for_update=ForUpdateParams(nowait=True) + ) + _export_escrow_annotations(logger, chain_id, escrow_address, escrow_projects, session) diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py b/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py index 9142df3f9d..0378f6c9b4 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/cvat_events.py @@ -142,6 +142,7 @@ def handle_create_job_event(payload: dict) -> None: session, escrow_address=project.escrow_address, chain_id=project.chain_id, + active=True, for_update=True, ) diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py b/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py index 12abf3e891..c9a474fe67 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/job_creation.py @@ -5,11 +5,13 @@ import random import uuid from abc import ABCMeta, abstractmethod +from concurrent.futures import Future, ThreadPoolExecutor from contextlib import ExitStack from dataclasses import dataclass, field from itertools import chain, groupby from math import ceil from pathlib import Path +from queue import Queue from tempfile import TemporaryDirectory from time import sleep from typing import TYPE_CHECKING, TypeVar, cast @@ -30,16 +32,17 @@ import src.services.cvat as db_service from src.chain.escrow import get_escrow_manifest from src.core.config import Config -from src.core.storage import compose_data_bucket_filename +from src.core.storage import compose_data_bucket_filename, compose_data_bucket_prefix from src.core.types import CvatLabelTypes, TaskStatuses, TaskTypes from src.db import SessionLocal from src.log import ROOT_LOGGER_NAME from src.models.cvat import Project from src.services.cloud import CloudProviders, StorageClient -from src.services.cloud.utils import BucketAccessInfo, compose_bucket_url +from src.services.cloud.utils import BucketAccessInfo from src.utils.annotations import InstanceSegmentsToBbox, ProjectLabels, is_point_in_bbox from src.utils.assignments import parse_manifest from src.utils.logging import NullLogger, format_sequence, get_function_logger +from src.utils.roi_uploader import BufferedRoiImageUploader from src.utils.zip_archive import write_dir_to_zip_archive if TYPE_CHECKING: @@ -183,7 +186,18 @@ def set_logger(self, logger: Logger): @classmethod def _make_cloud_storage_client(cls, bucket_info: BucketAccessInfo) -> StorageClient: - return cloud_service.make_client(bucket_info) + extra_args = {} + + if bucket_info.provider == CloudProviders.aws: + import boto3.session + + extra_args["config"] = boto3.session.Config( + max_pool_connections=Config.features.max_data_storage_connections + ) + elif bucket_info.provider == CloudProviders.gcs: + pass # TODO: test and add connections if needed + + return cloud_service.make_client(bucket_info, **extra_args) def _save_cvat_gt_dataset_to_oracle_bucket( # noqa: B027 self, @@ -195,12 +209,12 @@ def _save_cvat_gt_dataset_to_oracle_bucket( # noqa: B027 # into oracle bucket pass - def _wait_task_creation(self, task_id: int) -> cvat_api.UploadStatus: + def _wait_task_creation(self, task_id: int) -> cvat_api.RequestStatus: # TODO: add a timeout or # save gt datasets in the oracle bucket and upload in track_task_creation() while True: task_status, _ = cvat_api.get_task_upload_status(task_id) - if task_status not in [cvat_api.UploadStatus.STARTED, cvat_api.UploadStatus.QUEUED]: + if task_status not in [cvat_api.RequestStatus.STARTED, cvat_api.RequestStatus.QUEUED]: return task_status sleep(Config.cvat_config.task_creation_check_interval) @@ -209,7 +223,7 @@ def _setup_gt_job_for_cvat_task( self, task_id: int, gt_dataset: dm.Dataset, *, dm_export_format: str = "coco" ) -> None: task_status = self._wait_task_creation(task_id) - if task_status != cvat_api.UploadStatus.FINISHED: + if task_status != cvat_api.RequestStatus.FINISHED: return # will be handled in state_trackers.py::track_task_creation dm_format_to_cvat_format = { @@ -396,11 +410,7 @@ def build(self): manifest.annotation.type, escrow_address, chain_id, - compose_bucket_url( - data_bucket.bucket_name, - bucket_host=data_bucket.host_url, - provider=data_bucket.provider, - ), + data_bucket.to_url(), cvat_webhook_id=cvat_webhook.id, ) @@ -1398,11 +1408,6 @@ def _draw_roi_point( ) def _extract_and_upload_rois(self): - # TODO: maybe optimize via splitting into separate - # threads (downloading, uploading, processing) - - # Watch for the memory used, as the whole dataset can be quite big (gigabytes, terabytes) - # Consider also packing RoIs cut into archives assert self._points_dataset is not _unset assert self._rois is not _unset assert self._data_filenames is not _unset @@ -1429,13 +1434,10 @@ def _roi_key(e): for image_id, g in groupby(sorted(self._rois, key=_roi_key), key=_roi_key) } - for filename in self._data_filenames: + def process_file(filename: str, image_pixels: np.ndarray): image_roi_infos = rois_by_image.get(filename, []) if not image_roi_infos: - continue - - image_bytes = src_client.download_file(os.path.join(src_prefix, filename)) - image_pixels = decode_image(image_bytes) + return sample = filename_to_sample[filename] if tuple(sample.image.size) != tuple(image_pixels.shape[:2]): @@ -1464,11 +1466,43 @@ def _roi_key(e): roi_bytes, ) + def download_and_decode(key: str): + image_bytes = src_client.download_file(key) + return decode_image(image_bytes) + + pool_size = Config.features.max_data_storage_connections + download_queue_size = 4 * pool_size + download_queue = Queue[tuple[str, Future[np.ndarray]]](download_queue_size) + roi_uploader = BufferedRoiImageUploader(queue=download_queue) + with ThreadPoolExecutor(pool_size) as pool: + + def put_callback(filename: str): + image_roi_infos = rois_by_image.get(filename, []) + if not image_roi_infos: + return None + + return ( + filename, + pool.submit(download_and_decode, os.path.join(src_prefix, filename)), + ) + + def process_callback(result: tuple[str, Future]): + filename, task = result + process_file(filename, task.result()) + + roi_uploader.process_all( + self._data_filenames, put_callback=put_callback, process_callback=process_callback + ) + def _prepare_gt_roi_dataset(self): self._gt_roi_dataset = dm.Dataset( categories=self._gt_dataset.categories(), media_type=dm.Image ) + roi_info_by_point_id: dict[int, skeletons_from_boxes_task.RoiInfo] = { + roi_info.point_id: roi_info for roi_info in self._rois + } + for sample in self._gt_dataset: for gt_bbox in sample.annotations: assert isinstance(gt_bbox, dm.Bbox) @@ -1478,10 +1512,15 @@ def _prepare_gt_roi_dataset(self): self.escrow_address, self.chain_id, self._roi_filenames[point_id] ) + # update gt bbox coordinates to match RoI shift + roi_info = roi_info_by_point_id[point_id] + new_x = gt_bbox.points[0] - roi_info.roi_x + new_y = gt_bbox.points[1] - roi_info.roi_y + self._gt_roi_dataset.put( sample.wrap( id=os.path.splitext(gt_roi_filename)[0], - annotations=[gt_bbox], + annotations=[gt_bbox.wrap(x=new_x, y=new_y)], media=dm.Image(path=gt_roi_filename, size=sample.media_as(dm.Image).size), attributes=filter_dict(sample.attributes, exclude_keys=["id"]), ) @@ -1495,7 +1534,6 @@ def _create_on_cvat(self): assert self._label_configuration is not _unset assert self._gt_roi_dataset is not _unset - input_data_bucket = BucketAccessInfo.parse_obj(self.manifest.data.data_url) oracle_bucket = self.oracle_data_bucket # Register cloud storage on CVAT to pass user dataset @@ -1535,11 +1573,9 @@ def _create_on_cvat(self): self.manifest.annotation.type, self.escrow_address, self.chain_id, - compose_bucket_url( - input_data_bucket.bucket_name, - bucket_host=input_data_bucket.host_url, - provider=input_data_bucket.provider, - ), + oracle_bucket.to_url().rstrip("/") + + "/" + + compose_data_bucket_prefix(self.escrow_address, self.chain_id), cvat_webhook_id=cvat_webhook.id, ) db_service.get_project_by_id(session, project_id, for_update=True) # lock the row @@ -2509,13 +2545,10 @@ def _roi_info_key(e): if isinstance(bbox, dm.Bbox) } - for filename in self._data_filenames: + def process_file(filename: str, image_pixels: np.ndarray): image_roi_infos = roi_info_by_image.get(filename, []) if not image_roi_infos: - continue - - image_bytes = src_client.download_file(os.path.join(src_prefix, filename)) - image_pixels = decode_image(image_bytes) + return sample = filename_to_sample[filename] if tuple(sample.image.size) != tuple(image_pixels.shape[:2]): @@ -2540,6 +2573,34 @@ def _roi_info_key(e): data=roi_bytes, ) + def download_and_decode(key: str): + image_bytes = src_client.download_file(key) + return decode_image(image_bytes) + + pool_size = Config.features.max_data_storage_connections + download_queue_size = 4 * pool_size + download_queue = Queue[tuple[str, Future[np.ndarray]]](download_queue_size) + roi_uploader = BufferedRoiImageUploader(queue=download_queue) + with ThreadPoolExecutor(pool_size) as pool: + + def put_callback(filename: str): + image_roi_infos = roi_info_by_image.get(filename, []) + if not image_roi_infos: + return None + + return ( + filename, + pool.submit(download_and_decode, os.path.join(src_prefix, filename)), + ) + + def process_callback(result: tuple[str, Future]): + filename, task = result + process_file(filename, task.result()) + + roi_uploader.process_all( + self._data_filenames, put_callback=put_callback, process_callback=process_callback + ) + def _prepare_gt_dataset_for_skeleton_point( self, *, @@ -2635,7 +2696,6 @@ def _task_params_label_key(ts): for skeleton_label_id, skeleton_label in enumerate(self.manifest.annotation.labels) } - input_data_bucket = BucketAccessInfo.parse_obj(self.manifest.data.data_url) oracle_bucket = self.oracle_data_bucket # Register cloud storage on CVAT to pass user dataset @@ -2714,11 +2774,9 @@ def _task_params_label_key(ts): self.manifest.annotation.type, self.escrow_address, self.chain_id, - compose_bucket_url( - input_data_bucket.bucket_name, - bucket_host=input_data_bucket.host_url, - provider=input_data_bucket.provider, - ), + oracle_bucket.to_url().rstrip("/") + + "/" + + compose_data_bucket_prefix(self.escrow_address, self.chain_id), cvat_webhook_id=cvat_webhook.id, ) created_projects.append(project_id) diff --git a/packages/examples/cvat/exchange-oracle/src/handlers/job_export.py b/packages/examples/cvat/exchange-oracle/src/handlers/job_export.py index bf7fe47f4e..1db5ae4be2 100644 --- a/packages/examples/cvat/exchange-oracle/src/handlers/job_export.py +++ b/packages/examples/cvat/exchange-oracle/src/handlers/job_export.py @@ -43,9 +43,7 @@ class FileDescriptor: file: io.RawIOBase | None -def prepare_annotation_metafile( - jobs: list[Job], job_annotations: dict[int, FileDescriptor] -) -> FileDescriptor: +def prepare_annotation_metafile(jobs: list[Job]) -> FileDescriptor: """ Prepares a task/project annotation descriptor file with annotator mapping. """ @@ -54,7 +52,6 @@ def prepare_annotation_metafile( jobs=[ JobMeta( job_id=job.cvat_id, - annotation_filename=job_annotations[job.cvat_id].filename, annotator_wallet_address=job.latest_assignment.user_wallet_address, assignment_id=job.latest_assignment.id, task_id=job.cvat_task_id, diff --git a/packages/examples/cvat/exchange-oracle/src/services/cloud/s3.py b/packages/examples/cvat/exchange-oracle/src/services/cloud/s3.py index daf19d839f..7ede7a3e56 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cloud/s3.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cloud/s3.py @@ -3,6 +3,8 @@ from urllib.parse import unquote import boto3 +import boto3.s3 +import boto3.session from botocore.exceptions import ClientError from botocore.handlers import disable_signing @@ -22,6 +24,7 @@ def __init__( access_key: str | None = None, secret_key: str | None = None, endpoint_url: str | None = None, + config: boto3.session.Config | None = None, ) -> None: super().__init__(bucket) session = boto3.Session( @@ -29,7 +32,9 @@ def __init__( **({"aws_secret_access_key": secret_key} if secret_key else {}), ) s3 = session.resource( - "s3", **({"endpoint_url": unquote(endpoint_url)} if endpoint_url else {}) + "s3", + **({"endpoint_url": unquote(endpoint_url)} if endpoint_url else {}), + **({"config": config} if config else {}), ) self.resource: S3ServiceResourceStub = s3 diff --git a/packages/examples/cvat/exchange-oracle/src/services/cloud/types.py b/packages/examples/cvat/exchange-oracle/src/services/cloud/types.py index 3286fa540b..41ca984fa0 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cloud/types.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cloud/types.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse import pydantic +from httpx import URL from src.core import manifest from src.core.config import Config, StorageConfig @@ -113,7 +114,7 @@ def from_url(cls, url: str) -> BucketAccessInfo: ) elif Config.features.enable_custom_cloud_host: # Check if netloc is an ip address - # or localhost with port (or its /etc/hosts aliast, e.g. minio:9000) + # or localhost with port (or its /etc/hosts alias, e.g. minio:9000) if is_ipv4(parsed_url.netloc) or re.fullmatch(r"\w+:\d{4}", parsed_url.netloc): host = parsed_url.netloc bucket_name, path = parsed_url.path.lstrip("/").split("/", maxsplit=1) @@ -190,3 +191,18 @@ def parse_obj( return cls.from_storage_config(data) raise TypeError(f"Unsupported data type ({type(data)}) was provided") + + def to_url(self) -> str: + url = URL(self.host_url) + + if Config.features.enable_custom_cloud_host and ( + not url.host.endswith(DEFAULT_S3_HOST) and not url.host.endswith(DEFAULT_GCS_HOST) + ): + url = url.copy_with(path="/".join(["", self.bucket_name, url.path.lstrip("/")])) + else: + url = url.copy_with(host=f"{self.bucket_name}.{url.host}") + + if self.path: + url = url.copy_with(path="/".join(["", url.path.lstrip("/"), self.path.lstrip("/")])) + + return str(url) diff --git a/packages/examples/cvat/exchange-oracle/src/services/cloud/utils.py b/packages/examples/cvat/exchange-oracle/src/services/cloud/utils.py index bfc23305c7..48777a1c7b 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cloud/utils.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cloud/utils.py @@ -1,25 +1,14 @@ from src.services.cloud.client import StorageClient -from src.services.cloud.gcs import DEFAULT_GCS_HOST, GcsClient -from src.services.cloud.s3 import DEFAULT_S3_HOST, S3Client +from src.services.cloud.gcs import GcsClient +from src.services.cloud.s3 import S3Client from src.services.cloud.types import BucketAccessInfo, CloudProviders -def compose_bucket_url( - bucket_name: str, provider: CloudProviders, *, bucket_host: str | None = None -) -> str: - match provider: - case CloudProviders.aws: - return f"https://{bucket_name}.{bucket_host or DEFAULT_S3_HOST}/" - case CloudProviders.gcs: - return f"https://{bucket_name}.{bucket_host or DEFAULT_GCS_HOST}/" - - def make_client( bucket_info: BucketAccessInfo, + **kwargs, ) -> StorageClient: - client_kwargs = { - "bucket": bucket_info.bucket_name, - } + client_kwargs = {"bucket": bucket_info.bucket_name, **kwargs} match bucket_info.provider: case CloudProviders.aws: diff --git a/packages/examples/cvat/exchange-oracle/src/services/cvat.py b/packages/examples/cvat/exchange-oracle/src/services/cvat.py index e0f249f8e1..4ba663917d 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/cvat.py +++ b/packages/examples/cvat/exchange-oracle/src/services/cvat.py @@ -439,32 +439,26 @@ def create_escrow_creation( return escrow_creation_id -def get_escrow_creation_by_id( - session: Session, - escrow_creation_id: str, - *, - for_update: bool | ForUpdateParams = False, -) -> EscrowCreation | None: - return ( - _maybe_for_update(session.query(EscrowCreation), enable=for_update) - .where(EscrowCreation.id == escrow_creation_id, EscrowCreation.finished_at.is_(None)) - .first() - ) - - def get_escrow_creation_by_escrow_address( session: Session, escrow_address: str, chain_id: int, *, + active: bool | None, for_update: bool | ForUpdateParams = False, ) -> EscrowCreation | None: + is_active_filter = [] + if active is True: + is_active_filter = [EscrowCreation.finished_at.is_(None)] + elif active is False: + is_active_filter = [EscrowCreation.finished_at.is_not(None)] + return ( _maybe_for_update(session.query(EscrowCreation), enable=for_update) .where( EscrowCreation.escrow_address == escrow_address, EscrowCreation.chain_id == chain_id, - EscrowCreation.finished_at.is_(None), + *is_active_filter, ) .first() ) diff --git a/packages/examples/cvat/exchange-oracle/src/services/webhook.py b/packages/examples/cvat/exchange-oracle/src/services/webhook.py index 5fdf36ffce..98e86d8f9e 100644 --- a/packages/examples/cvat/exchange-oracle/src/services/webhook.py +++ b/packages/examples/cvat/exchange-oracle/src/services/webhook.py @@ -1,5 +1,6 @@ import datetime import uuid +from collections.abc import Sequence from enum import Enum from attrs import define @@ -91,9 +92,15 @@ def get_pending_webhooks( session: Session, type: OracleWebhookTypes, *, + event_type_in: Sequence[str] | None = None, + event_type_not_in: Sequence[str] | None = None, limit: int = 10, for_update: bool | ForUpdateParams = False, ) -> list[Webhook]: + assert not ( + event_type_in and event_type_not_in + ), f"{event_type_in} and {event_type_not_in} cannot be used together" + return ( _maybe_for_update(session.query(Webhook), enable=for_update) .where( @@ -101,6 +108,8 @@ def get_pending_webhooks( Webhook.type == type.value, Webhook.status == OracleWebhookStatuses.pending.value, Webhook.wait_until <= utcnow(), + *([Webhook.event_type.in_(event_type_in)] if event_type_in else []), + *([Webhook.event_type.not_in(event_type_not_in)] if event_type_not_in else []), ) .limit(limit) .all() diff --git a/packages/examples/cvat/exchange-oracle/src/utils/roi_uploader.py b/packages/examples/cvat/exchange-oracle/src/utils/roi_uploader.py new file mode 100644 index 0000000000..844e43be9c --- /dev/null +++ b/packages/examples/cvat/exchange-oracle/src/utils/roi_uploader.py @@ -0,0 +1,47 @@ +from collections.abc import Callable, Iterable +from queue import Queue +from typing import Generic, TypeVar + +T = TypeVar("T") +R = TypeVar("R") + + +class BufferedRoiImageUploader(Generic[R]): + def __init__(self, queue: Queue[R]): + self.queue = queue + + def process_all( + self, + items: Iterable[T], + *, + put_callback: Callable[[T], R | None], + process_callback: Callable[[R], None], + ): + "Put items into the queue and process them until the input items are exhausted" + + item_iter = iter(items) + while True: + result = self.fill_and_get(item_iter, put_callback=put_callback) + if result is None: + break + + process_callback(result) + + def fill_and_get( + self, items: Iterable[T], *, put_callback: Callable[[T], R | None] + ) -> R | None: + "put() as many items into the queue as possible, try to get() one" + + queue = self.queue + item_iter = iter(items) + while not queue.full() and (item := next(item_iter, None)) is not None: + task = put_callback(item) + if not task: + continue + + queue.put(task) + + if queue.empty(): + return None + + return queue.get() diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_completed_escrows.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_completed_escrows.py index 8bc4f9f67a..574ae5983c 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_completed_escrows.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_completed_escrows.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch import datumaro as dm +import pytest from sqlalchemy import select from src.core.types import ( @@ -25,7 +26,17 @@ from src.crons import track_completed_escrows from src.crons.cvat.state_trackers import track_escrow_validations from src.db import SessionLocal -from src.models.cvat import Assignment, EscrowValidation, Image, Job, Project, Task, User +from src.handlers.completed_escrows import handle_escrow_export +from src.models.cvat import ( + Assignment, + EscrowCreation, + EscrowValidation, + Image, + Job, + Project, + Task, + User, +) from src.models.webhook import Webhook from src.services.cvat import create_escrow_validations @@ -172,7 +183,7 @@ def test_cant_begin_validation_when_there_is_an_incomplete_escrow_with_several_p assert self.session.query(EscrowValidation).count() == 0 - def test_retrieve_annotations(self): + def test_can_request_validation(self): escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" chain_id = Networks.localhost @@ -251,6 +262,315 @@ def test_retrieve_annotations(self): self.session.commit() + with ( + patch("src.handlers.completed_escrows.validate_escrow"), + patch("src.handlers.completed_escrows.cloud_service") as mock_cloud_service, + ): + mock_storage_client = Mock() + mock_storage_client.create_file = Mock() + mock_storage_client.list_files = Mock(return_value=[]) + mock_cloud_service.make_client = Mock(return_value=mock_storage_client) + + track_escrow_validations() + + webhook = ( + self.session.query(Webhook) + .filter_by(escrow_address=escrow_address, chain_id=chain_id) + .first() + ) + assert webhook is not None + assert webhook.event_type == ExchangeOracleEventTypes.job_finished + + db_project = self.session.query(Project).filter_by(id=project_id).first() + assert db_project.status == ProjectStatuses.validation + + db_validation = ( + self.session.query(EscrowValidation) + .filter_by(escrow_address=escrow_address, chain_id=chain_id) + .first() + ) + assert db_validation.status == EscrowValidationStatuses.in_progress + assert db_validation.attempts == 1 + + def test_request_validation_error_in_file_uploading_increases_attempts(self): + escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + chain_id = Networks.localhost + + cvat_project_id = 1 + project_id = str(uuid.uuid4()) + cvat_project = Project( + id=project_id, + cvat_id=cvat_project_id, + cvat_cloudstorage_id=1, + status=ProjectStatuses.validation, + job_type=TaskTypes.image_label_binary, + escrow_address=escrow_address, + chain_id=chain_id, + bucket_url="https://test.storage.googleapis.com/", + ) + self.session.add(cvat_project) + + cvat_task_id = 1 + cvat_task = Task( + id=str(uuid.uuid4()), + cvat_id=cvat_task_id, + cvat_project_id=cvat_project_id, + status=TaskStatuses.completed, + ) + self.session.add(cvat_task) + + cvat_job = Job( + id=str(uuid.uuid4()), + cvat_id=1, + cvat_project_id=cvat_project_id, + cvat_task_id=cvat_task_id, + status=JobStatuses.completed, + start_frame=0, + stop_frame=1, + ) + self.session.add(cvat_job) + wallet_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + user = User( + wallet_address=wallet_address, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + wallet_address_2 = "0x86e83d346041E8806e352681f3F14549C0d2BC68" + user = User( + wallet_address=wallet_address_2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(user) + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=wallet_address, + cvat_job_id=cvat_job.cvat_id, + expires_at=datetime.now() + timedelta(days=1), + ) + project_images = ["sample1.jpg", "sample2.png"] + + for image_filename in project_images: + self.session.add( + Image( + id=str(uuid.uuid4()), cvat_project_id=cvat_project_id, filename=image_filename + ) + ) + + self.session.add(assignment) + + validation_id = str(uuid.uuid4()) + validation = EscrowValidation( + id=validation_id, + escrow_address=escrow_address, + chain_id=chain_id, + status=EscrowValidationStatuses.awaiting, + ) + self.session.add(validation) + + self.session.commit() + + with ( + patch("src.handlers.completed_escrows.validate_escrow"), + patch("src.handlers.completed_escrows.cloud_service") as mock_cloud_service, + patch("src.services.cloud.make_client"), + ): + mock_cloud_service.make_client.return_value.create_file.side_effect = _TestException() + + track_escrow_validations() + + mock_cloud_service.make_client.return_value.create_file.assert_called() + + webhook = ( + self.session.query(Webhook) + .filter_by(escrow_address=escrow_address, chain_id=chain_id) + .first() + ) + assert webhook is None + + db_project = self.session.query(Project).filter_by(id=project_id).first() + assert db_project.status == ProjectStatuses.validation + + db_validation = ( + self.session.query(EscrowValidation) + .filter_by(escrow_address=escrow_address, chain_id=chain_id) + .first() + ) + assert db_validation.status == EscrowValidationStatuses.awaiting + assert db_validation.attempts == 1 + + def test_can_request_validation_multiple_projects_per_escrow_all_completed(self): + escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + project1, task1, job1 = create_project_task_and_job(self.session, escrow_address, 1) + project2, task2, job2 = create_project_task_and_job(self.session, escrow_address, 2) + project3, task3, job3 = create_project_task_and_job(self.session, escrow_address, 3) + + project1.job_type = TaskTypes.image_skeletons_from_boxes + project2.job_type = TaskTypes.image_skeletons_from_boxes + project3.job_type = TaskTypes.image_skeletons_from_boxes + project1.status = ProjectStatuses.completed + project2.status = ProjectStatuses.completed + project3.status = ProjectStatuses.completed + task1.status = TaskStatuses.completed + task2.status = TaskStatuses.completed + task3.status = TaskStatuses.completed + job1.status = JobStatuses.completed + job2.status = JobStatuses.completed + job3.status = JobStatuses.completed + + project_images = ["sample1.jpg", "sample2.png"] + for project in [project1, project2, project3]: + for image_filename in project_images: + self.session.add( + Image( + id=str(uuid.uuid4()), + cvat_project_id=project.cvat_id, + filename=image_filename, + ) + ) + + self.session.add_all([project1, task1, job1, project2, task2, job2, project3, task3, job3]) + + wallet_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + user = User( + wallet_address=wallet_address, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + wallet_address_2 = "0x86e83d346041E8806e352681f3F14549C0d2BC68" + user = User( + wallet_address=wallet_address_2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(user) + + for job in [job1, job2, job3]: + now = datetime.now() + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=wallet_address, + cvat_job_id=job.cvat_id, + expires_at=now + timedelta(days=1), + completed_at=now - timedelta(hours=1), + status=AssignmentStatuses.completed, + ) + self.session.add(assignment) + + create_escrow_validations(self.session) + + self.session.commit() + + with ( + patch("src.handlers.completed_escrows.validate_escrow"), + patch("src.handlers.completed_escrows.cloud_service") as mock_cloud_service, + ): + mock_storage_client = Mock() + mock_storage_client.create_file = Mock() + mock_storage_client.list_files = Mock(return_value=[]) + mock_cloud_service.make_client = Mock(return_value=mock_storage_client) + + track_escrow_validations() + + webhook = ( + self.session.query(Webhook) + .filter_by(escrow_address=escrow_address, chain_id=Networks.localhost) + .first() + ) + assert webhook is not None + assert webhook.event_type == ExchangeOracleEventTypes.job_finished + + self.session.refresh(project1) + self.session.refresh(project2) + self.session.refresh(project3) + for db_project in project1, project2, project3: + assert db_project.status == ProjectStatuses.validation + + def test_can_export_escrow(self): + escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + chain_id = Networks.localhost + + cvat_project_id = 1 + project_id = str(uuid.uuid4()) + cvat_project = Project( + id=project_id, + cvat_id=cvat_project_id, + cvat_cloudstorage_id=1, + status=ProjectStatuses.validation, + job_type=TaskTypes.image_label_binary, + escrow_address=escrow_address, + chain_id=chain_id, + bucket_url="https://test.storage.googleapis.com/", + ) + self.session.add(cvat_project) + + project_images = ["sample1.jpg", "sample2.png"] + for image_filename in project_images: + self.session.add( + Image( + id=str(uuid.uuid4()), cvat_project_id=cvat_project_id, filename=image_filename + ) + ) + + cvat_task_id = 1 + cvat_task = Task( + id=str(uuid.uuid4()), + cvat_id=cvat_task_id, + cvat_project_id=cvat_project_id, + status=TaskStatuses.completed, + ) + self.session.add(cvat_task) + + cvat_job = Job( + id=str(uuid.uuid4()), + cvat_id=1, + cvat_project_id=cvat_project_id, + cvat_task_id=cvat_task_id, + status=JobStatuses.completed, + start_frame=0, + stop_frame=1, + ) + self.session.add(cvat_job) + wallet_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + user = User( + wallet_address=wallet_address, + cvat_email="test@hmt.ai", + cvat_id=1, + ) + self.session.add(user) + + wallet_address_2 = "0x86e83d346041E8806e352681f3F14549C0d2BC68" + user = User( + wallet_address=wallet_address_2, + cvat_email="test2@hmt.ai", + cvat_id=2, + ) + self.session.add(user) + assignment = Assignment( + id=str(uuid.uuid4()), + user_wallet_address=wallet_address, + cvat_job_id=cvat_job.cvat_id, + expires_at=datetime.now() + timedelta(days=1), + ) + self.session.add(assignment) + + creation_id = str(uuid.uuid4()) + creation = EscrowCreation( + id=creation_id, + escrow_address=escrow_address, + chain_id=chain_id, + created_at=datetime.now(), + finished_at=datetime.now(), + total_jobs=1, + ) + self.session.add(creation) + + self.session.commit() + with ( open("tests/utils/manifest.json") as data, patch("src.handlers.completed_escrows.get_escrow_manifest") as mock_get_manifest, @@ -285,7 +605,12 @@ def test_retrieve_annotations(self): mock_storage_client.list_files = Mock(return_value=[]) mock_cloud_service.make_client = Mock(return_value=mock_storage_client) - track_escrow_validations() + handle_escrow_export( + logger=Mock(), + session=self.session, + escrow_address=escrow_address, + chain_id=chain_id, + ) webhook = ( self.session.query(Webhook) @@ -293,20 +618,15 @@ def test_retrieve_annotations(self): .first() ) assert webhook is not None - assert webhook.event_type == ExchangeOracleEventTypes.job_finished - - db_project = self.session.query(Project).filter_by(id=project_id).first() - assert db_project.status == ProjectStatuses.validation + assert webhook.event_type == ExchangeOracleEventTypes.escrow_recorded - db_validation = ( - self.session.query(EscrowValidation) - .filter_by(escrow_address=escrow_address, chain_id=chain_id) - .first() + assert ( + mock_cvat_api.get_job_annotations.call_count + or mock_cvat_api.get_project_annotations.call_count ) - assert db_validation.status == EscrowValidationStatuses.in_progress - assert db_validation.attempts == 1 + assert mock_storage_client.create_file.call_count == 3 # meta + merged + per job anns - def test_retrieve_annotations_error_getting_annotations(self): + def test_can_export_escrow_error_getting_annotations(self): escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" chain_id = Networks.localhost @@ -366,14 +686,16 @@ def test_retrieve_annotations_error_getting_annotations(self): ) self.session.add(assignment) - validation_id = str(uuid.uuid4()) - validation = EscrowValidation( - id=validation_id, + creation_id = str(uuid.uuid4()) + creation = EscrowCreation( + id=creation_id, escrow_address=escrow_address, chain_id=chain_id, - status=EscrowValidationStatuses.awaiting, + created_at=datetime.now(), + finished_at=datetime.now(), + total_jobs=1, ) - self.session.add(validation) + self.session.add(creation) self.session.commit() @@ -396,7 +718,13 @@ def test_retrieve_annotations_error_getting_annotations(self): mock_request_job_annotations.side_effect = _TestException() - track_escrow_validations() + with pytest.raises(_TestException): + handle_escrow_export( + logger=Mock(), + session=self.session, + escrow_address=escrow_address, + chain_id=chain_id, + ) mock_request_job_annotations.assert_called() @@ -407,18 +735,12 @@ def test_retrieve_annotations_error_getting_annotations(self): ) assert webhook is None + mock_storage_client.create_file.assert_not_called() + db_project = self.session.query(Project).filter_by(id=project_id).first() assert db_project.status == ProjectStatuses.validation - db_validation = ( - self.session.query(EscrowValidation) - .filter_by(escrow_address=escrow_address, chain_id=chain_id) - .first() - ) - assert db_validation.status == EscrowValidationStatuses.awaiting - assert db_validation.attempts == 1 - - def test_retrieve_annotations_error_uploading_files(self): + def test_can_export_escrow_error_uploading_files(self): escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" chain_id = Networks.localhost @@ -487,14 +809,16 @@ def test_retrieve_annotations_error_uploading_files(self): self.session.add(assignment) - validation_id = str(uuid.uuid4()) - validation = EscrowValidation( - id=validation_id, + creation_id = str(uuid.uuid4()) + creation = EscrowCreation( + id=creation_id, escrow_address=escrow_address, chain_id=chain_id, - status=EscrowValidationStatuses.awaiting, + created_at=datetime.now(), + finished_at=datetime.now(), + total_jobs=1, ) - self.session.add(validation) + self.session.add(creation) self.session.commit() @@ -528,9 +852,15 @@ def test_retrieve_annotations_error_uploading_files(self): mock_cvat_api.get_project_annotations.return_value = dummy_zip_file mock_cloud_service.make_client.return_value.create_file.side_effect = _TestException() - track_escrow_validations() + with pytest.raises(_TestException): + handle_escrow_export( + logger=Mock(), + session=self.session, + escrow_address=escrow_address, + chain_id=chain_id, + ) - mock_cloud_service.make_client.return_value.create_file.assert_called() + mock_cloud_service.make_client.return_value.create_file.assert_called() webhook = ( self.session.query(Webhook) @@ -542,16 +872,10 @@ def test_retrieve_annotations_error_uploading_files(self): db_project = self.session.query(Project).filter_by(id=project_id).first() assert db_project.status == ProjectStatuses.validation - db_validation = ( - self.session.query(EscrowValidation) - .filter_by(escrow_address=escrow_address, chain_id=chain_id) - .first() - ) - assert db_validation.status == EscrowValidationStatuses.awaiting - assert db_validation.attempts == 1 - - def test_retrieve_annotations_multiple_projects_per_escrow_all_completed(self): + def test_can_export_multiple_projects_per_escrow(self): escrow_address = "0x86e83d346041E8806e352681f3F14549C0d2BC67" + chain_id = Networks.localhost + project1, task1, job1 = create_project_task_and_job(self.session, escrow_address, 1) project2, task2, job2 = create_project_task_and_job(self.session, escrow_address, 2) project3, task3, job3 = create_project_task_and_job(self.session, escrow_address, 3) @@ -559,9 +883,9 @@ def test_retrieve_annotations_multiple_projects_per_escrow_all_completed(self): project1.job_type = TaskTypes.image_skeletons_from_boxes project2.job_type = TaskTypes.image_skeletons_from_boxes project3.job_type = TaskTypes.image_skeletons_from_boxes - project1.status = ProjectStatuses.completed - project2.status = ProjectStatuses.completed - project3.status = ProjectStatuses.completed + project1.status = ProjectStatuses.validation + project2.status = ProjectStatuses.validation + project3.status = ProjectStatuses.validation task1.status = TaskStatuses.completed task2.status = TaskStatuses.completed task3.status = TaskStatuses.completed @@ -598,8 +922,8 @@ def test_retrieve_annotations_multiple_projects_per_escrow_all_completed(self): ) self.session.add(user) + now = datetime.now() for job in [job1, job2, job3]: - now = datetime.now() assignment = Assignment( id=str(uuid.uuid4()), user_wallet_address=wallet_address, @@ -610,7 +934,16 @@ def test_retrieve_annotations_multiple_projects_per_escrow_all_completed(self): ) self.session.add(assignment) - create_escrow_validations(self.session) + creation_id = str(uuid.uuid4()) + creation = EscrowCreation( + id=creation_id, + escrow_address=escrow_address, + chain_id=chain_id, + created_at=now, + finished_at=now, + total_jobs=3, + ) + self.session.add(creation) self.session.commit() @@ -674,18 +1007,21 @@ def _fake_postprocess_annotations( mock_storage_client.list_files = Mock(return_value=[]) mock_cloud_service.make_client = Mock(return_value=mock_storage_client) - track_escrow_validations() + handle_escrow_export( + logger=Mock(), + session=self.session, + escrow_address=escrow_address, + chain_id=chain_id, + ) webhook = ( self.session.query(Webhook) - .filter_by(escrow_address=escrow_address, chain_id=Networks.localhost) + .filter_by(escrow_address=escrow_address, chain_id=chain_id) .first() ) assert webhook is not None - assert webhook.event_type == ExchangeOracleEventTypes.job_finished + assert webhook.event_type == ExchangeOracleEventTypes.escrow_recorded - self.session.refresh(project1) - self.session.refresh(project2) - self.session.refresh(project3) - for db_project in project1, project2, project3: - assert db_project.status == ProjectStatuses.validation + assert mock_cvat_api.get_job_annotations.call_count >= 3 + assert mock_cvat_api.get_project_annotations.call_count == 0 + assert mock_storage_client.create_file.call_count == 5 # meta + jobs + merged diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_task_creation.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_task_creation.py index bc2a90a9a9..b860386bd4 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_task_creation.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/state_trackers/test_track_task_creation.py @@ -33,7 +33,7 @@ def test_track_track_failed_task_creation(self): with patch( "src.crons.cvat.state_trackers.cvat_api.get_task_upload_status" ) as mock_get_task_upload_status: - mock_get_task_upload_status.return_value = (cvat_api.UploadStatus.FAILED, "Failed") + mock_get_task_upload_status.return_value = (cvat_api.RequestStatus.FAILED, "Failed") track_task_creation() @@ -60,7 +60,7 @@ def test_track_track_completed_task_creation(self): ) as mock_get_task_upload_status, patch("src.crons.cvat.state_trackers.cvat_api.fetch_task_jobs") as mock_fetch_task_jobs, ): - mock_get_task_upload_status.return_value = (cvat_api.UploadStatus.FINISHED, "Finished") + mock_get_task_upload_status.return_value = (cvat_api.RequestStatus.FINISHED, "Finished") mock_cvat_job_1 = Mock() mock_cvat_job_1.id = cvat_job.cvat_id mock_cvat_job_1.start_frame = 0 @@ -104,7 +104,7 @@ def test_track_track_completed_task_creation_error(self): side_effect=cvat_api.exceptions.ApiException("Error"), ), ): - mock_get_task_upload_status.return_value = (cvat_api.UploadStatus.FINISHED, "Finished") + mock_get_task_upload_status.return_value = (cvat_api.RequestStatus.FINISHED, "Finished") track_task_creation() @@ -112,4 +112,4 @@ def test_track_track_completed_task_creation_error(self): webhook = self.session.query(Webhook).filter_by(escrow_address=escrow_address).first() assert webhook is not None - assert webhook.event_type == ExchangeOracleEventTypes.job_creation_failed + assert webhook.event_type == ExchangeOracleEventTypes.escrow_failed diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py index 79c29c680c..3a3755d1a1 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_job_launcher_webhooks.py @@ -21,7 +21,7 @@ process_incoming_job_launcher_webhooks, process_outgoing_job_launcher_webhooks, ) -from src.cvat.api_calls import UploadStatus +from src.cvat.api_calls import RequestStatus from src.db import SessionLocal from src.models.cvat import EscrowCreation, Project from src.models.webhook import Webhook @@ -80,7 +80,7 @@ def test_process_incoming_job_launcher_webhooks_escrow_created_type(self): mock_cvat_api.create_cvat_webhook.return_value = mock_cvat_object mock_cvat_api.create_cloudstorage.return_value = mock_cvat_object - mock_cvat_api.get_task_upload_status.return_value = (UploadStatus.FINISHED, "Finished") + mock_cvat_api.get_task_upload_status.return_value = (RequestStatus.FINISHED, "Finished") gt_filenames = ["image1.jpg", "image2.png"] gt_dataset = build_gt_dataset(gt_filenames).encode() @@ -195,7 +195,7 @@ def test_process_incoming_job_launcher_webhooks_escrow_created_type_exceed_max_r ) assert new_webhook.status == OracleWebhookStatuses.pending.value - assert new_webhook.event_type == ExchangeOracleEventTypes.job_creation_failed + assert new_webhook.event_type == ExchangeOracleEventTypes.escrow_failed assert new_webhook.attempts == 0 assert mock_storage_client.remove_files.mock_calls == [ call(prefix=compose_data_bucket_prefix(escrow_address, chain_id)), @@ -214,7 +214,7 @@ def test_process_incoming_job_launcher_webhooks_escrow_created_type_exceed_max_r outgoing_webhook = outgoing_webhooks[0] assert outgoing_webhook.type == OracleWebhookTypes.job_launcher - assert outgoing_webhook.event_type == ExchangeOracleEventTypes.job_creation_failed + assert outgoing_webhook.event_type == ExchangeOracleEventTypes.escrow_failed assert mock_storage_client.remove_files.mock_calls == [ call(prefix=compose_data_bucket_prefix(escrow_address, chain_id)), diff --git a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py index 9b016e5fe6..1e11b101f3 100644 --- a/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py +++ b/packages/examples/cvat/exchange-oracle/tests/integration/cron/test_process_recording_oracle_webhooks.py @@ -20,6 +20,7 @@ TaskTypes, ) from src.crons.webhooks.recording_oracle import ( + process_incoming_recording_oracle_webhook_job_completed, process_incoming_recording_oracle_webhooks, process_outgoing_recording_oracle_webhooks, ) @@ -113,12 +114,18 @@ def test_process_incoming_recording_oracle_webhooks_job_completed_type(self): escrow_address=escrow_address, chain_id=chain_id, status=EscrowValidationStatuses.in_progress, + attempts=1, ) self.session.add(validation) self.session.commit() - process_incoming_recording_oracle_webhooks() + with patch( + "src.crons.webhooks.recording_oracle.handle_escrow_export" + ) as mock_handle_escrow_export: + process_incoming_recording_oracle_webhook_job_completed() + + mock_handle_escrow_export.assert_called_once() db_webhook = self.session.query(Webhook).get(webhook_id) assert db_webhook.status == OracleWebhookStatuses.completed.value @@ -183,7 +190,7 @@ def test_process_incoming_recording_oracle_webhooks_job_completed_type_invalid_e self.session.commit() - process_incoming_recording_oracle_webhooks() + process_incoming_recording_oracle_webhook_job_completed() db_webhook = self.session.query(Webhook).get(webhook_id) assert db_webhook.status == OracleWebhookStatuses.completed.value diff --git a/packages/examples/cvat/recording-oracle/alembic/script.py.mako b/packages/examples/cvat/recording-oracle/alembic/script.py.mako index 55df2863d2..b5468c7304 100644 --- a/packages/examples/cvat/recording-oracle/alembic/script.py.mako +++ b/packages/examples/cvat/recording-oracle/alembic/script.py.mako @@ -1,4 +1,5 @@ -"""${message} +""" +${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} diff --git a/packages/examples/cvat/recording-oracle/alembic/versions/a5907f01ac2d_rename_event.py b/packages/examples/cvat/recording-oracle/alembic/versions/a5907f01ac2d_rename_event.py new file mode 100644 index 0000000000..cb80656077 --- /dev/null +++ b/packages/examples/cvat/recording-oracle/alembic/versions/a5907f01ac2d_rename_event.py @@ -0,0 +1,49 @@ +""" +Rename "job_creation_failed" to "escrow_failed" + +Revision ID: a5907f01ac2d +Revises: 1e89224ad721 +Create Date: 2025-03-21 17:18:49.765052 + +""" + +from sqlalchemy import Column, String, update +from sqlalchemy.orm import declarative_base + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a5907f01ac2d" +down_revision = "1e89224ad721" +branch_labels = None +depends_on = None + + +Base = declarative_base() + +old_name = "job_creation_failed" +new_name = "escrow_failed" + + +class Webhook(Base): + # Represents the model before the transaction is applied + + __tablename__ = "webhooks" + id = Column(String, primary_key=True, index=True) + event_type = Column(String, nullable=False) + + +def update_webhook_types(): + op.execute(update(Webhook).where(Webhook.event_type == old_name).values(event_type=new_name)) + + +def revert_webhook_types(): + op.execute(update(Webhook).where(Webhook.event_type == new_name).values(event_type=old_name)) + + +def upgrade() -> None: + update_webhook_types() + + +def downgrade() -> None: + revert_webhook_types() diff --git a/packages/examples/cvat/recording-oracle/debug.py b/packages/examples/cvat/recording-oracle/debug.py index 78d63c2a28..5d21b1029f 100644 --- a/packages/examples/cvat/recording-oracle/debug.py +++ b/packages/examples/cvat/recording-oracle/debug.py @@ -2,6 +2,7 @@ from collections.abc import Generator from contextlib import ExitStack, contextmanager from logging import Logger +from pathlib import PurePosixPath from unittest import mock import uvicorn @@ -27,7 +28,9 @@ def patched_get_escrow(chain_id: int, escrow_address: str) -> EscrowData: minio_manifests = minio_client.list_files(bucket="manifests") logger.debug(f"DEV: Local manifests: {format_sequence(minio_manifests)}") - candidate_files = [fn for fn in minio_manifests if f"{escrow_address}.json" in fn] + candidate_files = [ + fn for fn in minio_manifests if PurePosixPath(fn).name == f"{escrow_address}.json" + ] if not candidate_files: return original_get_escrow(ChainId(chain_id), escrow_address) if len(candidate_files) != 1: diff --git a/packages/examples/cvat/recording-oracle/poetry.lock b/packages/examples/cvat/recording-oracle/poetry.lock index 39fde06975..4b99cada57 100644 --- a/packages/examples/cvat/recording-oracle/poetry.lock +++ b/packages/examples/cvat/recording-oracle/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -914,13 +914,13 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "cvat-sdk" -version = "2.25.0" +version = "2.31.0" description = "CVAT REST API" optional = false python-versions = ">=3.9" files = [ - {file = "cvat_sdk-2.25.0-py3-none-any.whl", hash = "sha256:dc52b8ba1b9358902462846e004580b7b9baeed76b742b5f49a11c141a800eb7"}, - {file = "cvat_sdk-2.25.0.tar.gz", hash = "sha256:65c7faf74fbbd4ca799efa31774d6c378afed55d0a4c6e1141a6621845f35178"}, + {file = "cvat_sdk-2.31.0-py3-none-any.whl", hash = "sha256:b33e8526dad8c481f82e445badfced5d69747eaf7e5660b0d176cf86d394a02e"}, + {file = "cvat_sdk-2.31.0.tar.gz", hash = "sha256:aaeff833c32bfe711f418c62bdab135e0746eff0e89757e8b61cfad14a42ef23"}, ] [package.dependencies] @@ -3225,8 +3225,6 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -3619,7 +3617,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3859,50 +3856,30 @@ files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -4755,4 +4732,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10, <3.13" -content-hash = "3c6da2125945f4a1347faa70ece7cdba58a24880c6bbfaced72db1ae3dd4de67" +content-hash = "5f830a339a6f870a60e94be16dc742280e0ec9002fb4a51404fca9e18a6f399f" diff --git a/packages/examples/cvat/recording-oracle/pyproject.toml b/packages/examples/cvat/recording-oracle/pyproject.toml index 57b84301f4..fa03eb2769 100644 --- a/packages/examples/cvat/recording-oracle/pyproject.toml +++ b/packages/examples/cvat/recording-oracle/pyproject.toml @@ -24,7 +24,7 @@ google-cloud-storage = "^2.14.0" datumaro = {git = "https://github.com/cvat-ai/datumaro.git", rev = "ff83c00c2c1bc4b8fdfcc55067fcab0a9b5b6b11"} hexbytes = ">=1.2.0" # required for to_0x_hex() function starlette = ">=0.40.0" # avoid the vulnerability with multipart/form-data -cvat-sdk = "2.25.0" +cvat-sdk = "2.31.0" cryptography = "<44.0.0" # human-protocol-sdk -> pgpy dep requires cryptography < 45 human-protocol-sdk = "^4.0.3" diff --git a/packages/examples/cvat/recording-oracle/src/core/annotation_meta.py b/packages/examples/cvat/recording-oracle/src/core/annotation_meta.py index 3e67161e1e..73daec8413 100644 --- a/packages/examples/cvat/recording-oracle/src/core/annotation_meta.py +++ b/packages/examples/cvat/recording-oracle/src/core/annotation_meta.py @@ -1,6 +1,5 @@ from __future__ import annotations -from pathlib import Path # noqa: TCH003 from typing import TYPE_CHECKING from pydantic import BaseModel @@ -15,7 +14,6 @@ class JobMeta(BaseModel): job_id: int task_id: int - annotation_filename: Path annotator_wallet_address: str assignment_id: str start_frame: int diff --git a/packages/examples/cvat/recording-oracle/src/core/oracle_events.py b/packages/examples/cvat/recording-oracle/src/core/oracle_events.py index c8ff445cd0..2542e03e7f 100644 --- a/packages/examples/cvat/recording-oracle/src/core/oracle_events.py +++ b/packages/examples/cvat/recording-oracle/src/core/oracle_events.py @@ -24,7 +24,7 @@ class RejectedAssignmentInfo(BaseModel): assignments: list[RejectedAssignmentInfo] -class ExchangeOracleEvent_JobCreationFailed(OracleEvent): +class ExchangeOracleEvent_EscrowFailed(OracleEvent): # no task_id, escrow is enough for now reason: str @@ -33,20 +33,27 @@ class ExchangeOracleEvent_JobFinished(OracleEvent): pass # escrow is enough for now +class ExchangeOracleEvent_EscrowExported(OracleEvent): + pass # escrow is enough for now + + class ExchangeOracleEvent_EscrowCleaned(OracleEvent): pass _event_type_map = { + # TODO: make sender-dependent RecordingOracleEventTypes.job_completed: RecordingOracleEvent_JobCompleted, RecordingOracleEventTypes.submission_rejected: RecordingOracleEvent_SubmissionRejected, - ExchangeOracleEventTypes.job_creation_failed: ExchangeOracleEvent_JobCreationFailed, + ExchangeOracleEventTypes.escrow_failed: ExchangeOracleEvent_EscrowFailed, ExchangeOracleEventTypes.job_finished: ExchangeOracleEvent_JobFinished, ExchangeOracleEventTypes.escrow_cleaned: ExchangeOracleEvent_EscrowCleaned, + ExchangeOracleEventTypes.escrow_recorded: ExchangeOracleEvent_EscrowExported, } def get_class_for_event_type(event_type: str) -> type[OracleEvent]: + # TODO: make sender-dependent event_class = next((v for k, v in _event_type_map.items() if k == event_type), None) if not event_class: diff --git a/packages/examples/cvat/recording-oracle/src/core/types.py b/packages/examples/cvat/recording-oracle/src/core/types.py index 3600589206..6bb84c68cc 100644 --- a/packages/examples/cvat/recording-oracle/src/core/types.py +++ b/packages/examples/cvat/recording-oracle/src/core/types.py @@ -32,9 +32,10 @@ class OracleWebhookStatuses(str, Enum): class ExchangeOracleEventTypes(str, Enum, metaclass=BetterEnumMeta): - job_creation_failed = "job_creation_failed" + escrow_failed = "escrow_failed" job_finished = "job_finished" escrow_cleaned = "escrow_cleaned" + escrow_recorded = "escrow_recorded" class RecordingOracleEventTypes(str, Enum, metaclass=BetterEnumMeta): diff --git a/packages/examples/cvat/recording-oracle/src/core/validation_results.py b/packages/examples/cvat/recording-oracle/src/core/validation_results.py index e3dbf3f5be..2ba26653f5 100644 --- a/packages/examples/cvat/recording-oracle/src/core/validation_results.py +++ b/packages/examples/cvat/recording-oracle/src/core/validation_results.py @@ -12,10 +12,16 @@ class ValidationResult: @dataclass class ValidationSuccess(ValidationResult): validation_meta: ValidationMeta - resulting_annotations: bytes average_quality: float @dataclass class ValidationFailure(ValidationResult): rejected_jobs: dict[int, DatasetValidationError] + + +@dataclass +class FinalResult(ValidationResult): + validation_meta: ValidationMeta + resulting_annotations: bytes + average_quality: float diff --git a/packages/examples/cvat/recording-oracle/src/crons/__init__.py b/packages/examples/cvat/recording-oracle/src/crons/__init__.py index bf21b9678f..cfaa226745 100644 --- a/packages/examples/cvat/recording-oracle/src/crons/__init__.py +++ b/packages/examples/cvat/recording-oracle/src/crons/__init__.py @@ -3,6 +3,7 @@ from src.core.config import Config from src.crons.process_exchange_oracle_webhooks import ( + process_incoming_exchange_oracle_webhook_escrow_recorded, process_incoming_exchange_oracle_webhooks, process_outgoing_exchange_oracle_webhooks, ) @@ -18,6 +19,11 @@ def cron_record(): "interval", seconds=Config.cron_config.process_exchange_oracle_webhooks_int, ) + scheduler.add_job( + process_incoming_exchange_oracle_webhook_escrow_recorded, + "interval", + seconds=Config.cron_config.process_exchange_oracle_webhooks_int, + ) scheduler.add_job( process_outgoing_exchange_oracle_webhooks, "interval", diff --git a/packages/examples/cvat/recording-oracle/src/crons/process_exchange_oracle_webhooks.py b/packages/examples/cvat/recording-oracle/src/crons/process_exchange_oracle_webhooks.py index c4d94dffb8..ab56e79520 100644 --- a/packages/examples/cvat/recording-oracle/src/crons/process_exchange_oracle_webhooks.py +++ b/packages/examples/cvat/recording-oracle/src/crons/process_exchange_oracle_webhooks.py @@ -11,6 +11,7 @@ from src.crons._utils import cron_job, handle_webhook, process_outgoing_webhooks from src.db.utils import ForUpdateParams from src.handlers.cleanup import clean_escrow +from src.handlers.completion import export_results from src.handlers.validation import validate_results from src.log import ROOT_LOGGER_NAME from src.models.webhook import Webhook @@ -23,6 +24,29 @@ def process_incoming_exchange_oracle_webhooks(logger: logging.Logger, session: S webhooks = oracle_db_service.inbox.get_pending_webhooks( session, OracleWebhookTypes.exchange_oracle, + event_type_not_in=[ExchangeOracleEventTypes.escrow_recorded], + limit=Config.cron_config.process_exchange_oracle_webhooks_chunk_size, + for_update=ForUpdateParams(skip_locked=True), + ) + + for webhook in webhooks: + with handle_webhook(logger, session, webhook, queue=oracle_db_service.inbox): + handle_exchange_oracle_event(webhook, db_session=session) + + +@cron_job(module_logger_name) +def process_incoming_exchange_oracle_webhook_escrow_recorded( + logger: logging.Logger, session: Session +): + """ + Process incoming oracle webhooks of type escrow_recorded + We do it in a separate job as this is a long operation that should not block + other message handling. + """ + webhooks = oracle_db_service.inbox.get_pending_webhooks( + session, + OracleWebhookTypes.exchange_oracle, + event_type_in=[ExchangeOracleEventTypes.escrow_recorded], limit=Config.cron_config.process_exchange_oracle_webhooks_chunk_size, for_update=ForUpdateParams(skip_locked=True), ) @@ -44,6 +68,14 @@ def handle_exchange_oracle_event(webhook: Webhook, *, db_session: Session): chain_id=webhook.chain_id, db_session=db_session, ) + case ExchangeOracleEventTypes.escrow_recorded: + validate_escrow(webhook.chain_id, webhook.escrow_address) + + export_results( + escrow_address=webhook.escrow_address, + chain_id=webhook.chain_id, + db_session=db_session, + ) case ExchangeOracleEventTypes.escrow_cleaned: validate_escrow( webhook.chain_id, diff --git a/packages/examples/cvat/recording-oracle/src/handlers/completion.py b/packages/examples/cvat/recording-oracle/src/handlers/completion.py new file mode 100644 index 0000000000..22db6e9f29 --- /dev/null +++ b/packages/examples/cvat/recording-oracle/src/handlers/completion.py @@ -0,0 +1,173 @@ +import io +import os +from logging import Logger + +from sqlalchemy.orm import Session + +import src.core.annotation_meta as annotation +import src.core.validation_meta as validation +import src.services.webhook as oracle_db_service +from src.chain import escrow +from src.core.config import Config +from src.core.manifest import TaskManifest, parse_manifest +from src.core.oracle_events import ( + RecordingOracleEvent_JobCompleted, +) +from src.core.storage import ( + compose_results_bucket_filename as compose_annotation_results_bucket_filename, +) +from src.core.types import OracleWebhookTypes +from src.core.validation_results import FinalResult +from src.handlers.process_intermediate_results import ( + parse_annotation_metafile, + process_final_results, + serialize_validation_meta, +) +from src.log import ROOT_LOGGER_NAME +from src.services.cloud import make_client as make_cloud_client +from src.services.cloud.utils import BucketAccessInfo +from src.utils.assignments import compute_resulting_annotations_hash +from src.utils.logging import NullLogger, get_function_logger + +module_logger_name = f"{ROOT_LOGGER_NAME}.cron.webhook" + + +class _TaskUploader: + def __init__( + self, escrow_address: str, chain_id: int, manifest: TaskManifest, db_session: Session + ) -> None: + self.escrow_address = escrow_address + self.chain_id = chain_id + self.manifest = manifest + self.db_session = db_session + self.logger: Logger = NullLogger() + + self.data_bucket = BucketAccessInfo.parse_obj(Config.exchange_oracle_storage_config) + + self.annotation_meta: annotation.AnnotationMeta | None = None + self.merged_annotations: bytes | None = None + + def set_logger(self, logger: Logger): + self.logger = logger + + def _download_results_meta(self): + data_bucket_client = make_cloud_client(self.data_bucket) + + annotation_meta_path = compose_annotation_results_bucket_filename( + self.escrow_address, + self.chain_id, + annotation.ANNOTATION_RESULTS_METAFILE_NAME, + ) + annotation_metafile_data = data_bucket_client.download_file(annotation_meta_path) + self.annotation_meta = parse_annotation_metafile(io.BytesIO(annotation_metafile_data)) + + def _download_annotations(self): + assert self.annotation_meta is not None + + data_bucket_client = make_cloud_client(self.data_bucket) + + exchange_oracle_merged_annotation_path = compose_annotation_results_bucket_filename( + self.escrow_address, + self.chain_id, + annotation.RESULTING_ANNOTATIONS_FILE, + ) + merged_annotations = data_bucket_client.download_file( + exchange_oracle_merged_annotation_path + ) + + self.merged_annotations = merged_annotations + + def _download_results(self): + self._download_results_meta() + self._download_annotations() + + def _process_annotation_results(self) -> FinalResult: + assert self.annotation_meta is not None + assert self.merged_annotations is not None + + return process_final_results( + session=self.db_session, + escrow_address=self.escrow_address, + chain_id=self.chain_id, + meta=self.annotation_meta, + merged_annotations=io.BytesIO(self.merged_annotations), + manifest=self.manifest, + logger=self.logger, + ) + + def upload(self): + self._download_results() + + validation_result = self._process_annotation_results() + + self._handle_validation_result(validation_result) + + def _compose_validation_results_bucket_filename(self, filename: str) -> str: + return f"{self.escrow_address}@{self.chain_id}/{filename}" + + _LOW_QUALITY_REASON_MESSAGE_TEMPLATE = ( + "Annotation quality ({}) is below the required threshold ({})" + ) + + def _handle_validation_result(self, export_result: FinalResult): + logger = self.logger + escrow_address = self.escrow_address + chain_id = self.chain_id + db_session = self.db_session + + logger.info( + f"Result uploading for escrow_address={escrow_address}: successful, " + f"average annotation quality is {export_result.average_quality * 100:.2f}%" + ) + + recor_merged_annotations_path = self._compose_validation_results_bucket_filename( + validation.RESULTING_ANNOTATIONS_FILE, + ) + + recor_validation_meta_path = self._compose_validation_results_bucket_filename( + validation.VALIDATION_METAFILE_NAME, + ) + validation_metafile = serialize_validation_meta(export_result.validation_meta) + + storage_client = make_cloud_client(BucketAccessInfo.parse_obj(Config.storage_config)) + + # TODO: add encryption + storage_client.create_file( + recor_merged_annotations_path, + export_result.resulting_annotations, + ) + storage_client.create_file( + recor_validation_meta_path, + validation_metafile, + ) + + escrow.store_results( + chain_id, + escrow_address, + Config.storage_config.bucket_url() + os.path.dirname(recor_merged_annotations_path), # noqa: PTH120 + compute_resulting_annotations_hash(export_result.resulting_annotations), + ) + + oracle_db_service.outbox.create_webhook( + db_session, + escrow_address, + chain_id, + OracleWebhookTypes.reputation_oracle, + event=RecordingOracleEvent_JobCompleted(), + ) + + +def export_results( + escrow_address: str, + chain_id: int, + db_session: Session, +): + logger = get_function_logger(module_logger_name) + + manifest = parse_manifest(escrow.get_escrow_manifest(chain_id, escrow_address)) + + uploader = _TaskUploader( + escrow_address=escrow_address, chain_id=chain_id, manifest=manifest, db_session=db_session + ) + uploader.set_logger(logger) + uploader.upload() diff --git a/packages/examples/cvat/recording-oracle/src/handlers/process_intermediate_results.py b/packages/examples/cvat/recording-oracle/src/handlers/process_intermediate_results.py index 191d2d6966..98d13d9cd7 100644 --- a/packages/examples/cvat/recording-oracle/src/handlers/process_intermediate_results.py +++ b/packages/examples/cvat/recording-oracle/src/handlers/process_intermediate_results.py @@ -27,7 +27,7 @@ TooSlowAnnotationError, ) from src.core.validation_meta import JobMeta, ResultMeta, ValidationMeta -from src.core.validation_results import ValidationFailure, ValidationSuccess +from src.core.validation_results import FinalResult, ValidationFailure, ValidationSuccess from src.db.utils import ForUpdateParams from src.services.cloud import make_client as make_cloud_client from src.services.cloud.utils import BucketAccessInfo @@ -79,7 +79,6 @@ class _ValidationResult: job_results: _JobResults rejected_jobs: _RejectedJobs - updated_merged_dataset: io.BytesIO gt_stats: GtStats task_id_to_val_layout: _TaskIdToValidationLayout task_id_to_honeypots_mapping: _TaskIdToHoneypotsMapping @@ -90,62 +89,46 @@ class _ValidationResult: T = TypeVar("T") -class _TaskValidator: - UNKNOWN_QUALITY = -1 - "The value to be used when job quality cannot be computed (e.g. no GT images available)" - +class _TaskHandler: def __init__( self, escrow_address: str, chain_id: int, manifest: TaskManifest, - *, - merged_annotations: io.IOBase, - meta: AnnotationMeta, - gt_stats: GtStats | None = None, ) -> None: self.escrow_address = escrow_address self.chain_id = chain_id self.manifest = manifest - self._gt_stats: GtStats = gt_stats or {} - self._merged_annotations: io.IOBase = merged_annotations - - self._updated_merged_dataset_archive: io.IOBase | None = None - self._job_results: _JobResults | None = None - self._rejected_jobs: _RejectedJobs | None = None - self._temp_dir: Path | None = None - self._input_gt_dataset: dm.Dataset | None = None - self._meta: AnnotationMeta = meta def _require_field(self, field: T | None) -> T: assert field is not None return field - def _gt_key_to_sample_id(self, gt_key: str) -> str: - return gt_key - def _parse_gt_dataset(self, gt_file_data: bytes) -> dm.Dataset: - with TemporaryDirectory() as gt_temp_dir: - gt_filename = os.path.join(gt_temp_dir, "gt_annotations.json") - with open(gt_filename, "wb") as f: - f.write(gt_file_data) +class _TaskValidator(_TaskHandler): + UNKNOWN_QUALITY = -1 + "The value to be used when job quality cannot be computed (e.g. no GT images available)" - gt_dataset = dm.Dataset.import_from( - gt_filename, - format=DM_GT_DATASET_FORMAT_MAPPING[self.manifest.annotation.type], - ) + def __init__( + self, + escrow_address: str, + chain_id: int, + manifest: TaskManifest, + *, + meta: AnnotationMeta, + gt_stats: GtStats | None = None, + ) -> None: + super().__init__(escrow_address=escrow_address, chain_id=chain_id, manifest=manifest) - gt_dataset.init_cache() + self._gt_stats: GtStats = gt_stats or {} - return gt_dataset + self._job_results: _JobResults | None = None + self._rejected_jobs: _RejectedJobs | None = None - def _load_gt_dataset(self): - input_gt_bucket = BucketAccessInfo.parse_obj(self.manifest.validation.gt_url) - gt_bucket_client = make_cloud_client(input_gt_bucket) - gt_data = gt_bucket_client.download_file(input_gt_bucket.path) - self._input_gt_dataset = self._parse_gt_dataset(gt_data) + self._temp_dir: Path | None = None + self._meta: AnnotationMeta = meta def _validate_jobs(self): manifest = self._require_field(self.manifest) @@ -255,6 +238,62 @@ def _validate_jobs(self): self._task_id_to_sequence_of_frame_names = task_id_to_sequence_of_frame_names self._task_id_to_labels = task_id_to_labels + def validate(self) -> _ValidationResult: + with TemporaryDirectory() as tempdir: + self._temp_dir = Path(tempdir) + + self._validate_jobs() + + return _ValidationResult( + job_results=self._require_field(self._job_results), + rejected_jobs=self._require_field(self._rejected_jobs), + gt_stats=self._require_field(self._gt_stats), + task_id_to_val_layout=self._require_field(self._task_id_to_val_layout), + task_id_to_honeypots_mapping=self._require_field(self._task_id_to_honeypots_mapping), + task_id_to_frame_names=self._require_field(self._task_id_to_sequence_of_frame_names), + task_id_to_labels=self._require_field(self._task_id_to_labels), + ) + + +class _TaskAnnotationMerger(_TaskHandler): + def __init__( + self, + escrow_address: str, + chain_id: int, + manifest: TaskManifest, + *, + merged_annotations: io.IOBase, + ) -> None: + super().__init__(escrow_address=escrow_address, chain_id=chain_id, manifest=manifest) + + self._merged_annotations: io.IOBase = merged_annotations + + self._updated_merged_dataset_archive: io.IOBase | None = None + + self._temp_dir: Path | None = None + self._input_gt_dataset: dm.Dataset | None = None + + def _parse_gt_dataset(self, gt_file_data: bytes) -> dm.Dataset: + with TemporaryDirectory() as gt_temp_dir: + gt_filename = os.path.join(gt_temp_dir, "gt_annotations.json") + with open(gt_filename, "wb") as f: + f.write(gt_file_data) + + gt_dataset = dm.Dataset.import_from( + gt_filename, + format=DM_GT_DATASET_FORMAT_MAPPING[self.manifest.annotation.type], + ) + + gt_dataset.init_cache() + + return gt_dataset + + def _load_gt_dataset(self): + input_gt_bucket = BucketAccessInfo.parse_obj(self.manifest.validation.gt_url) + gt_bucket_client = make_cloud_client(input_gt_bucket) + gt_data = gt_bucket_client.download_file(input_gt_bucket.path) + self._input_gt_dataset = self._parse_gt_dataset(gt_data) + def _restore_original_image_paths(self, merged_dataset: dm.Dataset) -> dm.Dataset: class RemoveCommonPrefix(dm.ItemTransform): def __init__(self, extractor: dm.IExtractor, *, prefix: str) -> None: @@ -266,7 +305,7 @@ def transform_item(self, item: dm.DatasetItem) -> dm.DatasetItem: item = item.wrap(id=item.id[len(self._prefix) :]) return item - prefix = BucketAccessInfo.parse_obj(self.manifest.data.data_url).path.lstrip("/\\") + "/" + prefix = BucketAccessInfo.parse_obj(self.manifest.data.data_url).path.strip("/\\") + "/" # Remove prefixes if it can be done safely sample_ids = {sample.id for sample in merged_dataset} @@ -361,24 +400,14 @@ def _put_gt_into_merged_dataset( case _: raise AssertionError(f"Unknown task type {manifest.annotation.type}") - def validate(self) -> _ValidationResult: + def merge_results(self) -> io.IOBase: with TemporaryDirectory() as tempdir: self._temp_dir = Path(tempdir) self._load_gt_dataset() - self._validate_jobs() self._prepare_merged_dataset() - return _ValidationResult( - job_results=self._require_field(self._job_results), - rejected_jobs=self._require_field(self._rejected_jobs), - updated_merged_dataset=self._require_field(self._updated_merged_dataset_archive), - gt_stats=self._require_field(self._gt_stats), - task_id_to_val_layout=self._require_field(self._task_id_to_val_layout), - task_id_to_honeypots_mapping=self._require_field(self._task_id_to_honeypots_mapping), - task_id_to_frame_names=self._require_field(self._task_id_to_sequence_of_frame_names), - task_id_to_labels=self._require_field(self._task_id_to_labels), - ) + return self._require_field(self._updated_merged_dataset_archive) @dataclass @@ -714,7 +743,6 @@ def process_intermediate_results( # noqa: PLR0912 escrow_address: str, chain_id: int, meta: AnnotationMeta, - merged_annotations: io.RawIOBase, manifest: TaskManifest, logger: logging.Logger, ) -> ValidationSuccess | ValidationFailure: @@ -759,7 +787,6 @@ def process_intermediate_results( # noqa: PLR0912 escrow_address=escrow_address, chain_id=chain_id, manifest=manifest, - merged_annotations=merged_annotations, meta=unchecked_jobs_meta, gt_stats=gt_stats, ) @@ -911,7 +938,103 @@ def process_intermediate_results( # noqa: PLR0912 return ValidationSuccess( job_results=job_results, validation_meta=validation_meta, - resulting_annotations=validation_result.updated_merged_dataset.getvalue(), + average_quality=np.mean( + [v for v in job_results.values() if v != _TaskValidator.UNKNOWN_QUALITY and v >= 0] + or [0] + ), + ) + + +def process_final_results( + session: Session, + *, + escrow_address: str, + chain_id: int, + meta: AnnotationMeta, + merged_annotations: io.RawIOBase, + manifest: TaskManifest, + logger: logging.Logger, +) -> FinalResult: + assert logger # unused + + task = db_service.get_task_by_escrow_address( + session, + escrow_address, + for_update=ForUpdateParams( + nowait=True + ), # should not happen, but waiting should not block processing + ) + if not task: + raise AssertionError(f"Validation results for escrow {escrow_address} not found") + + merger = _TaskAnnotationMerger( + escrow_address=escrow_address, + chain_id=chain_id, + manifest=manifest, + merged_annotations=merged_annotations, + ) + + merged_annotations = merger.merge_results() + + job_final_result_ids: dict[str, str] = {} + + for job_meta in meta.jobs: + job = db_service.get_job_by_cvat_id(session, job_meta.job_id) + if not job: + raise AssertionError( + f"Can't find validation results for job " f"{job_meta.job_id} ({escrow_address=})" + ) + + assignment_validation_result = db_service.get_validation_result_by_assignment_id( + session, job_meta.assignment_id + ) + if not assignment_validation_result: + raise AssertionError( + f"Can't find validation results for assignments " + f"{job_meta.assignment_id} ({escrow_address=})" + ) + + job_final_result_ids[job.id] = assignment_validation_result.id + + task_jobs = task.jobs + + task_validation_results = db_service.get_task_validation_results(session, task.id) + + job_id_to_meta_id = {job.id: i for i, job in enumerate(task_jobs)} + + validation_result_id_to_meta_id = {r.id: i for i, r in enumerate(task_validation_results)} + + validation_meta = ValidationMeta( + jobs=[ + JobMeta( + job_id=job_id_to_meta_id[job.id], + final_result_id=validation_result_id_to_meta_id[job_final_result_ids[job.id]], + ) + for job in task_jobs + ], + results=[ + ResultMeta( + id=validation_result_id_to_meta_id[r.id], + job_id=job_id_to_meta_id[r.job.id], + annotator_wallet_address=r.annotator_wallet_address, + annotation_quality=r.annotation_quality, + ) + for r in task_validation_results + ], + ) + + # Include final results for all jobs + job_results: _JobResults = { + job.cvat_id: task_validation_results[ + validation_result_id_to_meta_id[job_final_result_ids[job.id]] + ].annotation_quality + for job in task_jobs + } + + return FinalResult( + job_results=job_results, + validation_meta=validation_meta, + resulting_annotations=merged_annotations.read(), average_quality=np.mean( [v for v in job_results.values() if v != _TaskValidator.UNKNOWN_QUALITY and v >= 0] or [0] diff --git a/packages/examples/cvat/recording-oracle/src/handlers/validation.py b/packages/examples/cvat/recording-oracle/src/handlers/validation.py index 3f8ca5f45a..8c13346efa 100644 --- a/packages/examples/cvat/recording-oracle/src/handlers/validation.py +++ b/packages/examples/cvat/recording-oracle/src/handlers/validation.py @@ -1,5 +1,4 @@ import io -import os from collections import Counter from logging import Logger @@ -29,7 +28,6 @@ from src.log import ROOT_LOGGER_NAME from src.services.cloud import make_client as make_cloud_client from src.services.cloud.utils import BucketAccessInfo -from src.utils.assignments import compute_resulting_annotations_hash from src.utils.logging import NullLogger, get_function_logger module_logger_name = f"{ROOT_LOGGER_NAME}.cron.webhook" @@ -48,7 +46,6 @@ def __init__( self.data_bucket = BucketAccessInfo.parse_obj(Config.exchange_oracle_storage_config) self.annotation_meta: annotation.AnnotationMeta | None = None - self.merged_annotations: bytes | None = None def set_logger(self, logger: Logger): self.logger = logger @@ -64,39 +61,19 @@ def _download_results_meta(self): annotation_metafile_data = data_bucket_client.download_file(annotation_meta_path) self.annotation_meta = parse_annotation_metafile(io.BytesIO(annotation_metafile_data)) - def _download_annotations(self): - assert self.annotation_meta is not None - - data_bucket_client = make_cloud_client(self.data_bucket) - - exchange_oracle_merged_annotation_path = compose_annotation_results_bucket_filename( - self.escrow_address, - self.chain_id, - annotation.RESULTING_ANNOTATIONS_FILE, - ) - merged_annotations = data_bucket_client.download_file( - exchange_oracle_merged_annotation_path - ) - - self.merged_annotations = merged_annotations - def _download_results(self): self._download_results_meta() - self._download_annotations() ValidationResult = ValidationSuccess | ValidationFailure def _process_annotation_results(self) -> ValidationResult: assert self.annotation_meta is not None - assert self.merged_annotations is not None - # TODO: refactor further return process_intermediate_results( session=self.db_session, escrow_address=self.escrow_address, chain_id=self.chain_id, meta=self.annotation_meta, - merged_annotations=io.BytesIO(self.merged_annotations), manifest=self.manifest, logger=self.logger, ) @@ -127,10 +104,6 @@ def _handle_validation_result(self, validation_result: ValidationResult): f"average annotation quality is {validation_result.average_quality * 100:.2f}%" ) - recor_merged_annotations_path = self._compose_validation_results_bucket_filename( - validation.RESULTING_ANNOTATIONS_FILE, - ) - recor_validation_meta_path = self._compose_validation_results_bucket_filename( validation.VALIDATION_METAFILE_NAME, ) @@ -138,30 +111,11 @@ def _handle_validation_result(self, validation_result: ValidationResult): storage_client = make_cloud_client(BucketAccessInfo.parse_obj(Config.storage_config)) - # TODO: add encryption - storage_client.create_file( - recor_merged_annotations_path, - validation_result.resulting_annotations, - ) storage_client.create_file( recor_validation_meta_path, validation_metafile, ) - escrow.store_results( - chain_id, - escrow_address, - Config.storage_config.bucket_url() + os.path.dirname(recor_merged_annotations_path), # noqa: PTH120 - compute_resulting_annotations_hash(validation_result.resulting_annotations), - ) - - oracle_db_service.outbox.create_webhook( - db_session, - escrow_address, - chain_id, - OracleWebhookTypes.reputation_oracle, - event=RecordingOracleEvent_JobCompleted(), - ) oracle_db_service.outbox.create_webhook( db_session, escrow_address, diff --git a/packages/examples/cvat/recording-oracle/src/services/webhook.py b/packages/examples/cvat/recording-oracle/src/services/webhook.py index aa989f56cd..014c4651cd 100644 --- a/packages/examples/cvat/recording-oracle/src/services/webhook.py +++ b/packages/examples/cvat/recording-oracle/src/services/webhook.py @@ -1,5 +1,6 @@ import datetime import uuid +from collections.abc import Sequence from enum import Enum from attrs import define @@ -91,9 +92,15 @@ def get_pending_webhooks( session: Session, type: OracleWebhookTypes, *, + event_type_in: Sequence[str] | None = None, + event_type_not_in: Sequence[str] | None = None, limit: int = 10, for_update: bool | ForUpdateParams = False, ) -> list[Webhook]: + assert not ( + event_type_in and event_type_not_in + ), f"{event_type_in} and {event_type_not_in} cannot be used together" + return ( _maybe_for_update(session.query(Webhook), enable=for_update) .where( @@ -101,6 +108,8 @@ def get_pending_webhooks( Webhook.type == type.value, Webhook.status == OracleWebhookStatuses.pending.value, Webhook.wait_until <= utcnow(), + *([Webhook.event_type.in_(event_type_in)] if event_type_in else []), + *([Webhook.event_type.not_in(event_type_not_in)] if event_type_not_in else []), ) .limit(limit) .all() diff --git a/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py b/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py index 00d3e292c8..eeed92e681 100644 --- a/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py +++ b/packages/examples/cvat/recording-oracle/tests/integration/cron/test_process_exchange_oracle_webhooks.py @@ -15,14 +15,17 @@ OracleWebhookStatuses, OracleWebhookTypes, ) -from src.crons.process_exchange_oracle_webhooks import process_incoming_exchange_oracle_webhooks +from src.crons.process_exchange_oracle_webhooks import ( + process_incoming_exchange_oracle_webhook_escrow_recorded, + process_incoming_exchange_oracle_webhooks, +) from src.db import SessionLocal from src.models.validation import Task from src.models.webhook import Webhook from src.services.cloud import StorageClient from src.services.webhook import OracleWebhookDirectionTags -from tests.utils.constants import DEFAULT_GAS_PAYER_PRIV, SIGNATURE +from tests.utils.constants import DEFAULT_GAS_PAYER_PRIV, ESCROW_ADDRESS, SIGNATURE from tests.utils.setup_escrow import create_escrow, fund_escrow, setup_escrow @@ -74,7 +77,7 @@ def test_process_exchange_oracle_webhook(self): assert updated_webhook.attempts == 1 def test_process_exchange_oracle_webhook_escrow_cleaned(self): - escrow_address = "123" + escrow_address = ESCROW_ADDRESS webhook = self.make_webhook(escrow_address, ExchangeOracleEventTypes.escrow_cleaned) self.session.add(webhook) task_id = str(uuid.uuid4()) @@ -101,6 +104,55 @@ def test_process_exchange_oracle_webhook_escrow_cleaned(self): call(prefix=compose_results_bucket_prefix(escrow_address, webhook.chain_id)), ] + def test_process_exchange_oracle_webhook_escrow_recorded(self): + escrow_address = ESCROW_ADDRESS + + webhook = self.make_webhook(escrow_address, ExchangeOracleEventTypes.escrow_recorded) + self.session.add(webhook) + + task_id = str(uuid.uuid4()) + task = Task(id=task_id, escrow_address=escrow_address, chain_id=webhook.chain_id) + self.session.add(task) + + self.session.commit() + + with ( + patch("src.crons.process_exchange_oracle_webhooks.validate_escrow"), + patch( + "src.crons.process_exchange_oracle_webhooks.export_results" + ) as mock_export_results, + ): + process_incoming_exchange_oracle_webhook_escrow_recorded() + + mock_export_results.assert_called_once() + + self.session.refresh(webhook) + self.session.refresh(task) + assert webhook.status == OracleWebhookStatuses.completed + assert webhook.attempts == 1 + + def test_process_exchange_oracle_webhook_job_finished(self): + escrow_address = ESCROW_ADDRESS + + webhook = self.make_webhook(escrow_address, ExchangeOracleEventTypes.job_finished) + self.session.add(webhook) + + self.session.commit() + + with ( + patch("src.crons.process_exchange_oracle_webhooks.validate_escrow"), + patch( + "src.crons.process_exchange_oracle_webhooks.validate_results" + ) as mock_validate_results, + ): + process_incoming_exchange_oracle_webhooks() + + mock_validate_results.assert_called_once() + + self.session.refresh(webhook) + assert webhook.status == OracleWebhookStatuses.completed + assert webhook.attempts == 1 + def test_process_recording_oracle_webhooks_invalid_escrow_address(self): escrow_address = "invalid_address" webhook = self.make_webhook(escrow_address) diff --git a/packages/examples/cvat/recording-oracle/tests/integration/services/test_validation_service.py b/packages/examples/cvat/recording-oracle/tests/integration/services/test_validation_service.py index 37750be364..1ba1e17052 100644 --- a/packages/examples/cvat/recording-oracle/tests/integration/services/test_validation_service.py +++ b/packages/examples/cvat/recording-oracle/tests/integration/services/test_validation_service.py @@ -24,7 +24,10 @@ from src.core.validation_results import ValidationFailure, ValidationSuccess from src.cvat import api_calls as cvat_api from src.db import SessionLocal -from src.handlers.process_intermediate_results import process_intermediate_results +from src.handlers.process_intermediate_results import ( + process_final_results, + process_intermediate_results, +) from src.services.validation import ( create_job, create_task, @@ -128,15 +131,6 @@ def test_can_handle_lowered_quality_requirements_in_manifest(self, session: Sess common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) common_lock_es.enter_context( mock.patch("src.core.config.ValidationConfig.min_warmup_progress", 0), @@ -174,22 +168,11 @@ def test_can_handle_lowered_quality_requirements_in_manifest(self, session: Sess ) mock_get_task_labels.return_value = [label.name for label in manifest.annotation.labels] - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=cvat_job_id, task_id=cvat_task_id, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=0, @@ -237,7 +220,6 @@ def patched_prepare_merged_dataset(self): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -283,7 +265,6 @@ def patched_prepare_merged_dataset(self): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -354,15 +335,6 @@ def test_can_change_bad_honeypots_in_jobs(self, session: Session, seed: int): common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) mock_make_cloud_client = common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.make_cloud_client") @@ -397,22 +369,11 @@ def test_can_change_bad_honeypots_in_jobs(self, session: Session, seed: int): ) mock_get_task_labels.return_value = [label.name for label in manifest.annotation.labels] - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=1 + i, task_id=cvat_task_id, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=job_start, @@ -468,7 +429,6 @@ def patched_prepare_merged_dataset(self): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -610,15 +570,6 @@ def test_can_stop_on_slow_annotation_after_warmup_iterations(self, session: Sess common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) mock_make_cloud_client = common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.make_cloud_client") @@ -652,22 +603,11 @@ def test_can_stop_on_slow_annotation_after_warmup_iterations(self, session: Sess ) mock_get_task_labels.return_value = [label.name for label in manifest.annotation.labels] - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=cvat_job_id, task_id=cvat_task_id, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=0, @@ -718,7 +658,6 @@ def patched_prepare_merged_dataset(self): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -748,15 +687,6 @@ def test_can_exclude_bad_gt_for_each_label_separately(self, session: Session): common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) mock_make_cloud_client = common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.make_cloud_client") @@ -797,22 +727,11 @@ def patched_get_task_labels(task_id: int): ) ) - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=cvat_task_id1, task_id=cvat_task_id1, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=0, @@ -821,7 +740,6 @@ def patched_prepare_merged_dataset(self): JobMeta( job_id=cvat_task_id2, task_id=cvat_task_id2, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment2_id, start_frame=0, @@ -887,7 +805,6 @@ def patched_get_jobs_quality_reports(task_id: int): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -951,15 +868,6 @@ def test_can_complete_if_not_enough_gt_left_in_task_with_several_jobs( common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) mock_make_cloud_client = common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.make_cloud_client") @@ -1000,22 +908,11 @@ def patched_get_task_labels(task_id: int): ) ) - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=cvat_task_id1, task_id=cvat_task_id1, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=0, @@ -1024,7 +921,6 @@ def patched_prepare_merged_dataset(self): JobMeta( job_id=cvat_task_id2, task_id=cvat_task_id2, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment2_id, start_frame=0, @@ -1090,7 +986,6 @@ def patched_get_jobs_quality_reports(task_id: int): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -1127,15 +1022,6 @@ def test_can_complete_if_not_enough_gt_left_in_task_with_one_job( common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj") ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.extract_zip_archive") - ) - common_lock_es.enter_context( - mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive") - ) mock_make_cloud_client = common_lock_es.enter_context( mock.patch("src.handlers.process_intermediate_results.make_cloud_client") @@ -1176,22 +1062,11 @@ def patched_get_task_labels(task_id: int): ) ) - def patched_prepare_merged_dataset(self): - self._updated_merged_dataset_archive = io.BytesIO() - - common_lock_es.enter_context( - mock.patch( - "src.handlers.process_intermediate_results._TaskValidator._prepare_merged_dataset", - patched_prepare_merged_dataset, - ) - ) - annotation_meta = AnnotationMeta( jobs=[ JobMeta( job_id=cvat_task_id, task_id=cvat_task_id, - annotation_filename="", annotator_wallet_address=annotator1, assignment_id=assignment1_id, start_frame=0, @@ -1249,7 +1124,6 @@ def patched_get_jobs_quality_reports(task_id: int): escrow_address=escrow_address, chain_id=chain_id, meta=annotation_meta, - merged_annotations=io.BytesIO(), manifest=manifest, logger=logger, ) @@ -1258,3 +1132,100 @@ def patched_get_jobs_quality_reports(task_id: int): assert any("Too few validation frames left in the task" in m for m in caplog.messages) mock_update_task_validation_layout.assert_not_called() + + +class TestAnnotationMerging: + def test_can_prepare_final_results_in_validated_escrow(self, session: Session): + escrow_address = ESCROW_ADDRESS + chain_id = Networks.localhost.value + + task1_id = create_task(session, escrow_address=escrow_address, chain_id=chain_id) + + job1_id = create_job(session, job_cvat_id=1, task_id=task1_id) + + job2_id = create_job(session, job_cvat_id=2, task_id=task1_id) + + assignment_id1 = str(uuid.uuid4()) + annotator1 = WALLET_ADDRESS1 + vr1 = models.ValidationResult( + id=str(uuid.uuid4()), + job_id=job1_id, + assignment_id=assignment_id1, + annotator_wallet_address=annotator1, + annotation_quality=0.8, + ) + session.add(vr1) + + assignment_id2 = str(uuid.uuid4()) + vr2 = models.ValidationResult( + id=str(uuid.uuid4()), + job_id=job2_id, + assignment_id=assignment_id2, + annotator_wallet_address=annotator1, + annotation_quality=0.6, + ) + session.add(vr2) + + session.commit() + + manifest = generate_manifest(min_quality=0.4) + + def patched_prepare_merged_dataset(self): + self._updated_merged_dataset_archive = io.BytesIO(b"test") + + with ( + mock.patch("src.handlers.process_intermediate_results.BucketAccessInfo.parse_obj"), + mock.patch( + "src.handlers.process_intermediate_results.make_cloud_client" + ) as mock_make_cloud_client, + mock.patch("src.handlers.process_intermediate_results.dm.Dataset.import_from"), + mock.patch("src.handlers.process_intermediate_results.extract_zip_archive"), + mock.patch("src.handlers.process_intermediate_results.write_dir_to_zip_archive"), + mock.patch( + "src.handlers.process_intermediate_results._TaskAnnotationMerger._prepare_merged_dataset", + patched_prepare_merged_dataset, + ), + ): + mock_make_cloud_client.return_value.download_file = mock.Mock(return_value=b"") + + annotation_meta = AnnotationMeta( + jobs=[ + JobMeta( + job_id=1, + task_id=1, + annotator_wallet_address=annotator1, + assignment_id=assignment_id1, + start_frame=0, + stop_frame=manifest.annotation.job_size + manifest.validation.val_size, + ), + JobMeta( + job_id=2, + task_id=1, + annotator_wallet_address=annotator1, + assignment_id=assignment_id2, + start_frame=0, + stop_frame=manifest.annotation.job_size + manifest.validation.val_size, + ), + ] + ) + + final_result = process_final_results( + session=session, + escrow_address=escrow_address, + chain_id=chain_id, + meta=annotation_meta, + manifest=manifest, + merged_annotations=io.BytesIO(), + logger=mock.Mock(Logger), + ) + + assert mock_make_cloud_client.return_value.download_file.call_count == 1 # gt + + assert final_result.average_quality == 0.7 + assert final_result.resulting_annotations == b"test" + assert final_result.job_results == { + 1: 0.8, + 2: 0.6, + } + assert len(final_result.validation_meta.jobs) == 2 + assert len(final_result.validation_meta.results) == 2 diff --git a/packages/sdk/typescript/human-protocol-sdk/package.json b/packages/sdk/typescript/human-protocol-sdk/package.json index f5446eaef5..49a11c71a0 100644 --- a/packages/sdk/typescript/human-protocol-sdk/package.json +++ b/packages/sdk/typescript/human-protocol-sdk/package.json @@ -47,7 +47,7 @@ "minio": "7.1.3", "openpgp": "^5.11.2", "secp256k1": "^5.0.1", - "vitest": "^1.6.0" + "vitest": "^3.0.9" }, "devDependencies": { "typedoc": "^0.27.5", diff --git a/packages/sdk/typescript/human-protocol-sdk/test/storage.test.ts b/packages/sdk/typescript/human-protocol-sdk/test/storage.test.ts index b47e0cd782..4bc7c0c619 100644 --- a/packages/sdk/typescript/human-protocol-sdk/test/storage.test.ts +++ b/packages/sdk/typescript/human-protocol-sdk/test/storage.test.ts @@ -190,7 +190,7 @@ describe('Storage tests', () => { }); test('should fail URL validation', async () => { - expect(StorageClient.downloadFileFromUrl(FAKE_URL)).rejects.toThrow( + await expect(StorageClient.downloadFileFromUrl(FAKE_URL)).rejects.toThrow( ErrorInvalidUrl ); }); diff --git a/packages/sdk/typescript/subgraph/config/amoy.json b/packages/sdk/typescript/subgraph/config/amoy.json index 7023315ca3..dfeb55c219 100644 --- a/packages/sdk/typescript/subgraph/config/amoy.json +++ b/packages/sdk/typescript/subgraph/config/amoy.json @@ -30,8 +30,5 @@ "address": "0xd866bCEFf6D0F77E1c3EAE28230AE6C79b03fDa7", "startBlock": 5773007, "abi": "../../../../node_modules/@human-protocol/core/abis/RewardPool.json" - }, - "LegacyEscrow": { - "abi": "../../../../node_modules/@human-protocol/core/abis/legacy/Escrow.json" } } diff --git a/packages/sdk/typescript/subgraph/config/localhost.json b/packages/sdk/typescript/subgraph/config/localhost.json index cbd3d5401b..5061d4f78e 100644 --- a/packages/sdk/typescript/subgraph/config/localhost.json +++ b/packages/sdk/typescript/subgraph/config/localhost.json @@ -25,8 +25,5 @@ "address": "0xdc64a140aa3e981100a9beca4e685f962f0cf6c9", "startBlock": 6, "abi": "../../../../node_modules/@human-protocol/core/abis/KVStore.json" - }, - "LegacyEscrow": { - "abi": "../../../../node_modules/@human-protocol/core/abis/legacy/Escrow.json" } } diff --git a/packages/sdk/typescript/subgraph/package.json b/packages/sdk/typescript/subgraph/package.json index 20429030da..0f00345fad 100644 --- a/packages/sdk/typescript/subgraph/package.json +++ b/packages/sdk/typescript/subgraph/package.json @@ -37,7 +37,7 @@ "license": "MIT", "devDependencies": { "@graphprotocol/graph-cli": "^0.95.0", - "@graphprotocol/graph-ts": "^0.37.0", + "@graphprotocol/graph-ts": "^0.38.0", "@graphql-eslint/eslint-plugin": "^3.19.1", "@human-protocol/core": "*", "graphql": "^16.6.0", diff --git a/packages/sdk/typescript/subgraph/src/mapping/KVStore.ts b/packages/sdk/typescript/subgraph/src/mapping/KVStore.ts index 8f2545d240..77e742dfcd 100644 --- a/packages/sdk/typescript/subgraph/src/mapping/KVStore.ts +++ b/packages/sdk/typescript/subgraph/src/mapping/KVStore.ts @@ -3,7 +3,6 @@ import { BigInt, Bytes, dataSource, - log, Value, } from '@graphprotocol/graph-ts'; import { DataSaved } from '../../generated/KVStore/KVStore'; @@ -74,12 +73,6 @@ export function createOrUpdateKVStore(event: DataSaved): void { } export function handleDataSaved(event: DataSaved): void { - // Log the event details - log.info('DataSaved event received:', []); - log.info('Sender: {}', [event.params.sender.toHexString()]); - log.info('Key: {}', [event.params.key]); - log.info('Value: {}', [event.params.value]); - createTransaction(event, 'set', event.transaction.from, dataSource.address()); // Create KVStoreSetEvent entity const eventEntity = new KVStoreSetEvent(toEventId(event)); diff --git a/packages/sdk/typescript/subgraph/template.yaml b/packages/sdk/typescript/subgraph/template.yaml index dc871ade09..041f01f46b 100644 --- a/packages/sdk/typescript/subgraph/template.yaml +++ b/packages/sdk/typescript/subgraph/template.yaml @@ -16,7 +16,13 @@ dataSources: language: wasm/assemblyscript file: ./src/mapping/EscrowFactory.ts entities: - - Launched + - Escrow + - EscrowStatusEvent + - EscrowStatistics + - EventDayData + - Operator + - Transaction + - InternalTransaction abis: - name: EscrowFactory file: '{{{ EscrowFactory.abi }}}' @@ -37,10 +43,21 @@ dataSources: apiVersion: 0.0.7 language: wasm/assemblyscript entities: - - Approval - - BulkApproval - - BulkTransfer - - Transfer + - DailyWorker + - Escrow + - EventDayData + - HMTApprovalEvent + - HMTBulkApprovalEvent + - HMTBulkTransferEvent + - HMTTransferEvent + - HMTokenStatistics + - Holder + - Payout + - UniqueReceiver + - UniqueSender + - Worker + - Transaction + - InternalTransaction abis: - name: HMToken file: '{{{ HMToken.abi }}}' @@ -66,7 +83,11 @@ dataSources: apiVersion: 0.0.7 language: wasm/assemblyscript entities: - - DataSaved + - KVStore + - KVStoreSetEvent + - Operator + - OperatorURL + - ReputationNetwork abis: - name: KVStore file: '{{{ KVStore.abi }}}' @@ -86,10 +107,15 @@ dataSources: apiVersion: 0.0.7 language: wasm/assemblyscript entities: - - StakeDeposited - - StakeLocked - - StakeWithdrawn - - StakeSlashed + - StakeDepositedEvent + - StakeLockedEvent + - StakeWithdrawnEvent + - StakeSlashedEvent + - FeeWithdrawn + - Operator + - OperatorStatistics + - Transaction + - InternalTransaction abis: - name: Staking file: '{{{ Staking.abi }}}' @@ -119,7 +145,12 @@ dataSources: language: wasm/assemblyscript file: ./src/mapping/legacy/EscrowFactory.ts entities: - - Launched + - Escrow + - EscrowStatistics + - EventDayData + - Operator + - Transaction + - InternalTransaction abis: - name: EscrowFactory file: '{{{ LegacyEscrowFactory.abi }}}' @@ -139,13 +170,23 @@ templates: language: wasm/assemblyscript file: ./src/mapping/Escrow.ts entities: - - ISEvent - - PEvent + - Escrow + - Worker + - EscrowStatistics + - EventDayData + - PendingEvent + - EscrowStatusEvent + - Operator + - StoreResultsEvent + - BulkPayoutEvent + - DailyWorker + - FundEvent + - WithdrawEvent + - Transaction + - InternalTransaction abis: - name: Escrow file: '{{{ Escrow.abi }}}' - - name: LegacyEscrow - file: '{{{ LegacyEscrow.abi }}}' eventHandlers: - event: IntermediateStorage(string,string) handler: handleIntermediateStorage @@ -165,6 +206,7 @@ templates: handler: handleFund - event: Withdraw(address,uint256) handler: handleWithdraw +{{ #LegacyEscrow }} - name: LegacyEscrow kind: ethereum/contract network: '{{ network }}' @@ -176,8 +218,14 @@ templates: language: wasm/assemblyscript file: ./src/mapping/legacy/Escrow.ts entities: - - ISEvent - - PEvent + - Escrow + - EscrowStatistics + - EventDayData + - Escrow + - Transaction + - InternalTransaction + - StoreResultsEvent + - BulkPayoutEvent abis: - name: Escrow file: '{{{ LegacyEscrow.abi }}}' @@ -187,4 +235,5 @@ templates: - event: Pending(string,string) handler: handlePending - event: BulkTransfer(indexed uint256,uint256) - handler: handleBulkTransfer \ No newline at end of file + handler: handleBulkTransfer +{{ /LegacyEscrow }} \ No newline at end of file diff --git a/scripts/cvat/env-files/.env.job-launcher b/scripts/cvat/env-files/.env.job-launcher index 5784c6b2ca..8dcf04083d 100644 --- a/scripts/cvat/env-files/.env.job-launcher +++ b/scripts/cvat/env-files/.env.job-launcher @@ -64,3 +64,13 @@ STRIPE_SECRET_KEY=disabled STRIPE_APP_NAME=Launcher Server Local STRIPE_APP_VERSION=1.0.0 STRIPE_APP_INFO_URL=http://local.app + +# Vision +GOOGLE_PROJECT_ID=disabled +GOOGLE_PRIVATE_KEY=disabled +GOOGLE_CLIENT_EMAIL=disabled +GCV_MODERATION_RESULTS_FILES_PATH=disabled +GCV_MODERATION_RESULTS_BUCKET=disabled + +# Slack +SLACK_ABUSE_NOTIFICATION_WEBHOOK_URL=disabled diff --git a/scripts/cvat/env-files/.env.recording-oracle b/scripts/cvat/env-files/.env.recording-oracle index 6c8bbb1a88..bf0805d59d 100644 --- a/scripts/cvat/env-files/.env.recording-oracle +++ b/scripts/cvat/env-files/.env.recording-oracle @@ -12,39 +12,33 @@ ENABLE_CUSTOM_CLOUD_HOST="yes" # Encryption PGP_PRIVATE_KEY="-----BEGIN PGP PRIVATE KEY BLOCK----- -xYYEZ4+5NBYJKwYBBAHaRw8BAQdAB8sDU5DxBk145aSEDgzsDqVvSbxoQffZ -6wybUtZ/s+3+CQMI6+l2w9YuLXDgz7GAmnrbFaUciZFIfAzjng3ZjbABRoWI -5+qwhJ4AITBpNzrju6SZV+NGlkgnYaljDk+cO+eIIL2N8D+v+UDGJokWy9P5 -PM0yQ1ZBVCByZWNvcmRpbmcgb3JhY2xlIDxyZWNvcmRpbmctb3JhY2xlQGxv -Y2FsLmFwcD7CjAQQFgoAPgWCZ4+5NAQLCQcICZDA/EkpGIzNeAMVCAoEFgAC -AQIZAQKbAwIeARYhBL5Bsw7o2pz/KrGZjMD8SSkYjM14AAA+wgEAzTwwMKXJ -QUlIeUwmLR3OHrCuyHQIPWFzxLQcxESPftkA/1PTr20UozX+YNUV9XRLehJt -3pmBtFzQZdndN3rTNJQKx4sEZ4+5NBIKKwYBBAGXVQEFAQEHQBJRjDJjbOVh -k0ofZQv6VZF+GtvMzoZTErWOBSZnjJ5lAwEIB/4JAwhW7zmaNYAF+uA6JpwB -zaAC1o2Cm5IRnBp66vdGG7RsyZBoca0R6aHht/UfzlrUwPKUmP13oTSQ1vxU -Z5Fx+owW1V+w8ZDuxyN1zTWGgQh7wngEGBYKACoFgmePuTQJkMD8SSkYjM14 -ApsMFiEEvkGzDujanP8qsZmMwPxJKRiMzXgAANG2AQCA5PVDyVwo54y9Givv -4V8HSyNHNVgxj0dLknOxJB3HSAEA/jO1djl9b99okxC3cgfic0ArMIT1V3gm -9SSdFWQpfww= -=X8yi +xYYEZ+KclxYJKwYBBAHaRw8BAQdAQmPWFqgCHKBl5orZ14BjdEge9LL3VbyJ +kXqRRe7xJkv+CQMIk7BjanLIRJrgAkRNCk3U5RwAyZax53wbK8NfV+2i7cjY +NFkbiOK8yuq+QG0T/PoKyXFOa126gwsBTlKmyPT5/am8rTFf6k39NzCn8f7D +y80AwowEEBYKAD4FgmfinJcECwkHCAmQ0JGHkZYk1mYDFQgKBBYAAgECGQEC +mwMCHgEWIQQcpeN3tBh8ex2F8HjQkYeRliTWZgAADaMA/0wSvCfUTDYN3gzn +HDDdmqEqg+ji5NTUNjj3jVtLm+XeAP4v2KpGFWXQnCcexTf1USll9ddZC/6H +ZlcFmCuvWX/1B8eLBGfinJcSCisGAQQBl1UBBQEBB0DSGXpmTLBqiPz59qLa +Cuarsa6Q09QJKB6GSEa3Me4zSAMBCAf+CQMI+V7xRESw/5Hg2tbHs6q0oWuF +/jMu2bLAu4n+C5tw+7eZK5imqu9iocp/ZRMBAQgTp/VIUbFopHk18THVGZKE +Vh5zLxinmAP4zkMPm8SsCcJ4BBgWCgAqBYJn4pyXCZDQkYeRliTWZgKbDBYh +BByl43e0GHx7HYXweNCRh5GWJNZmAAAz0wEAgXxWZUkSDk1HfVGb77/jR562 +Kt8i8+ZdOuY7CehB7JIBAPip5C1mea5bxHrOHt/BzUC0oWcvP+U4W6K6Ccdy +/8UO +=IMpo -----END PGP PRIVATE KEY BLOCK-----" PGP_PASSPHRASE=rec-o-pgp-password -PGP_PUBLIC_KEY="-----BEGIN PGP PRIVATE KEY BLOCK----- +PGP_PUBLIC_KEY="-----BEGIN PGP PUBLIC KEY BLOCK----- -xYYEZ4+5NBYJKwYBBAHaRw8BAQdAB8sDU5DxBk145aSEDgzsDqVvSbxoQffZ -6wybUtZ/s+3+CQMI6+l2w9YuLXDgz7GAmnrbFaUciZFIfAzjng3ZjbABRoWI -5+qwhJ4AITBpNzrju6SZV+NGlkgnYaljDk+cO+eIIL2N8D+v+UDGJokWy9P5 -PM0yQ1ZBVCByZWNvcmRpbmcgb3JhY2xlIDxyZWNvcmRpbmctb3JhY2xlQGxv -Y2FsLmFwcD7CjAQQFgoAPgWCZ4+5NAQLCQcICZDA/EkpGIzNeAMVCAoEFgAC -AQIZAQKbAwIeARYhBL5Bsw7o2pz/KrGZjMD8SSkYjM14AAA+wgEAzTwwMKXJ -QUlIeUwmLR3OHrCuyHQIPWFzxLQcxESPftkA/1PTr20UozX+YNUV9XRLehJt -3pmBtFzQZdndN3rTNJQKx4sEZ4+5NBIKKwYBBAGXVQEFAQEHQBJRjDJjbOVh -k0ofZQv6VZF+GtvMzoZTErWOBSZnjJ5lAwEIB/4JAwhW7zmaNYAF+uA6JpwB -zaAC1o2Cm5IRnBp66vdGG7RsyZBoca0R6aHht/UfzlrUwPKUmP13oTSQ1vxU -Z5Fx+owW1V+w8ZDuxyN1zTWGgQh7wngEGBYKACoFgmePuTQJkMD8SSkYjM14 -ApsMFiEEvkGzDujanP8qsZmMwPxJKRiMzXgAANG2AQCA5PVDyVwo54y9Givv -4V8HSyNHNVgxj0dLknOxJB3HSAEA/jO1djl9b99okxC3cgfic0ArMIT1V3gm -9SSdFWQpfww= -=X8yi ------END PGP PRIVATE KEY BLOCK-----" +xjMEZ+KclxYJKwYBBAHaRw8BAQdAQmPWFqgCHKBl5orZ14BjdEge9LL3VbyJ +kXqRRe7xJkvNAMKMBBAWCgA+BYJn4pyXBAsJBwgJkNCRh5GWJNZmAxUICgQW +AAIBAhkBApsDAh4BFiEEHKXjd7QYfHsdhfB40JGHkZYk1mYAAA2jAP9MErwn +1Ew2Dd4M5xww3ZqhKoPo4uTU1DY4941bS5vl3gD+L9iqRhVl0JwnHsU39VEp +ZfXXWQv+h2ZXBZgrr1l/9QfOOARn4pyXEgorBgEEAZdVAQUBAQdA0hl6Zkyw +aoj8+fai2grmq7GukNPUCSgehkhGtzHuM0gDAQgHwngEGBYKACoFgmfinJcJ +kNCRh5GWJNZmApsMFiEEHKXjd7QYfHsdhfB40JGHkZYk1mYAADPTAQCBfFZl +SRIOTUd9UZvvv+NHnrYq3yLz5l065jsJ6EHskgEA+KnkLWZ5rlvEes4e38HN +QLShZy8/5ThboroJx3L/xQ4= +=eS/4 +-----END PGP PUBLIC KEY BLOCK-----" PGP_PUBLIC_KEY_URL=http://minio:9000/recording-oracle/pgp-public-key diff --git a/yarn.lock b/yarn.lock index e63108e219..259aa91610 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,6 +32,18 @@ rxjs "7.8.1" source-map "0.7.4" +"@angular-devkit/core@19.2.0": + version "19.2.0" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-19.2.0.tgz#5187cc0a7f7ac6e721bc6a3a980edec0f8be8764" + integrity sha512-qd2nYoHZOYWRsu4MjXG8KiDtfM9ZDRR2rDGa+rDZ3CYAsngCrPmqOebun10dncUjwAidX49P4S2U2elOmX3VYQ== + dependencies: + ajv "8.17.1" + ajv-formats "3.0.1" + jsonc-parser "3.3.1" + picomatch "4.0.2" + rxjs "7.8.1" + source-map "0.7.4" + "@angular-devkit/schematics-cli@17.3.11": version "17.3.11" resolved "https://registry.yarnpkg.com/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz#dd1963d592ae7d2555ddbbac8406ba03bcddf4fe" @@ -55,6 +67,17 @@ ora "5.4.1" rxjs "7.8.1" +"@angular-devkit/schematics@19.2.0": + version "19.2.0" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-19.2.0.tgz#aa36bea77268e8ffb3c206763d9f343b7c963279" + integrity sha512-cGGqUGqBXIGJkeL65l70y0BflDAu/0Zi/ohbYat3hvadFfumRJnVElVfJ59JtWO7FfKQjxcwCVTyuQ/tevX/9A== + dependencies: + "@angular-devkit/core" "19.2.0" + jsonc-parser "3.3.1" + magic-string "0.30.17" + ora "5.4.1" + rxjs "7.8.1" + "@apollo/client@^3.11.1": version "3.12.11" resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.12.11.tgz#250301155a86c59e2915637fd19c9a2c42f878a8" @@ -1579,10 +1602,10 @@ core-js-pure "^3.30.2" regenerator-runtime "^0.14.0" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.9", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.25.0", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": - version "7.26.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.7.tgz#f4e7fe527cd710f8dc0618610b61b4b060c3c341" - integrity sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ== +"@babel/runtime@^7.12.5", "@babel/runtime@^7.16.3", "@babel/runtime@^7.17.9", "@babel/runtime@^7.18.3", "@babel/runtime@^7.21.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.25.0", "@babel/runtime@^7.25.7", "@babel/runtime@^7.26.0", "@babel/runtime@^7.26.10", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" + integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== dependencies: regenerator-runtime "^0.14.0" @@ -1827,171 +1850,86 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== - "@esbuild/aix-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64" integrity sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ== -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== - "@esbuild/android-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f" integrity sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g== -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== - "@esbuild/android-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b" integrity sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g== -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== - "@esbuild/android-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163" integrity sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg== -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== - "@esbuild/darwin-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c" integrity sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw== -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== - "@esbuild/darwin-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a" integrity sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg== -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== - "@esbuild/freebsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce" integrity sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w== -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== - "@esbuild/freebsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7" integrity sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A== -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== - "@esbuild/linux-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73" integrity sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg== -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== - "@esbuild/linux-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3" integrity sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg== -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== - "@esbuild/linux-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19" integrity sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg== -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== - "@esbuild/linux-loong64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7" integrity sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw== -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== - "@esbuild/linux-mips64el@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1" integrity sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ== -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== - "@esbuild/linux-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951" integrity sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw== -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== - "@esbuild/linux-riscv64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987" integrity sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA== -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== - "@esbuild/linux-s390x@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4" integrity sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA== -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== - "@esbuild/linux-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a" @@ -2002,11 +1940,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b" integrity sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw== -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== - "@esbuild/netbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b" @@ -2017,51 +1950,26 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7" integrity sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw== -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== - "@esbuild/openbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde" integrity sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg== -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== - "@esbuild/sunos-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92" integrity sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg== -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== - "@esbuild/win32-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c" integrity sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw== -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== - "@esbuild/win32-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079" integrity sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA== -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== - "@esbuild/win32-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b" @@ -2635,10 +2543,10 @@ web3-eth-abi "4.4.1" yaml "2.6.1" -"@graphprotocol/graph-ts@^0.37.0": - version "0.37.0" - resolved "https://registry.yarnpkg.com/@graphprotocol/graph-ts/-/graph-ts-0.37.0.tgz#ad5e9bc24a099db336e6488e37d5bd5283501a39" - integrity sha512-3xp/sO8zFDBkX44ydGB87ow5Cyrfr/SAm/cWzIRzUVL7ROw0KUyFBG1xj4KKlMnAod7/RL99zChYquC15H4Oqg== +"@graphprotocol/graph-ts@^0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@graphprotocol/graph-ts/-/graph-ts-0.38.0.tgz#a73cba73bf7931af1c603563416ed6779c4311d7" + integrity sha512-fx9IpqbXHWMskl6wLDstKLy0VeszTQg6K5xsUEHXPkpOtceDkWAiDE8FyqsGF0IVTmE2Pxafw4t6+rEG8lVfyw== dependencies: assemblyscript "0.27.31" @@ -3791,12 +3699,12 @@ resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.14.tgz#e6536f1b6caa873f7915fbf9703fdc840a5a98d9" integrity sha512-sbjXW+BBSvmzn61XyTMun899E7nGPTXwqD9drm1jBUAvWEhJpPFIRxwQQiATWZnd9rvdxtnhhdsDxEGWI0jxqA== -"@mui/icons-material@^6.4.6": - version "6.4.6" - resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-6.4.6.tgz#a26eaeae2f7f1359b48dac3fe8a8eec61640c325" - integrity sha512-rGJBvIQQbQAlyKYljHQ8wAQS/K2/uYwvemcpygnAmCizmCI4zSF9HQPuiG8Ql4YLZ6V/uKjA3WHIYmF/8sV+pQ== +"@mui/icons-material@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-7.0.1.tgz#4a25d74f8f1bfc5eac924819001efae9b39be2d9" + integrity sha512-x8Em7LISFQ6s/KeZj6ZKwJHq2WttRNe9KJLWFa72eQx7B53s/TzMKOEjGKB/YyhOx+bqqSv1pMvK373M4Xf07A== dependencies: - "@babel/runtime" "^7.26.0" + "@babel/runtime" "^7.26.10" "@mui/lab@^5.0.0-alpha.141": version "5.0.0-alpha.175" @@ -4075,7 +3983,7 @@ cron "3.2.1" uuid "11.0.3" -"@nestjs/schematics@^10.0.1", "@nestjs/schematics@^10.1.3": +"@nestjs/schematics@^10.0.1": version "10.2.3" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.2.3.tgz#6053f43c5065b9e825cd08c4db1bf6bcbc9a6a62" integrity sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg== @@ -4086,6 +3994,17 @@ jsonc-parser "3.3.1" pluralize "8.0.0" +"@nestjs/schematics@^11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-11.0.2.tgz#0ee0aba704b14c2b933d0a04dcdfe5203b70469b" + integrity sha512-C4KM3BHBG6tRX8t5UrHdUq8Y49asEfJUora/fBXge3UTAnxKGlXc20p5s2Q0Q1+l+1YaXqTrKGSIbYXdPX8r9g== + dependencies: + "@angular-devkit/core" "19.2.0" + "@angular-devkit/schematics" "19.2.0" + comment-json "4.2.5" + jsonc-parser "3.3.1" + pluralize "8.0.0" + "@nestjs/serve-static@^4.0.1", "@nestjs/serve-static@^4.0.2": version "4.0.2" resolved "https://registry.yarnpkg.com/@nestjs/serve-static/-/serve-static-4.0.2.tgz#f003bbd90922bdc73d0261edacf001dfef174c96" @@ -4134,7 +4053,7 @@ dependencies: eslint-scope "5.1.1" -"@noble/ciphers@^1.0.0": +"@noble/ciphers@1.2.1", "@noble/ciphers@^1.0.0": version "1.2.1" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.2.1.tgz#3812b72c057a28b44ff0ad4aff5ca846e5b9cdc9" integrity sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA== @@ -4153,6 +4072,13 @@ dependencies: "@noble/hashes" "1.4.0" +"@noble/curves@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.8.0.tgz#fe035a23959e6aeadf695851b51a87465b5ba8f7" + integrity sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ== + dependencies: + "@noble/hashes" "1.7.0" + "@noble/curves@1.8.1", "@noble/curves@^1.6.0", "@noble/curves@^1.7.0", "@noble/curves@~1.8.1": version "1.8.1" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.8.1.tgz#19bc3970e205c99e4bdb1c64a4785706bce497ff" @@ -4175,6 +4101,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== +"@noble/hashes@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.0.tgz#5d9e33af2c7d04fee35de1519b80c958b2e35e39" + integrity sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w== + "@noble/hashes@1.7.1", "@noble/hashes@^1.3.1", "@noble/hashes@^1.4.0", "@noble/hashes@^1.5.0", "@noble/hashes@^1.6.1", "@noble/hashes@~1.7.1": version "1.7.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.7.1.tgz#5738f6d765710921e7a751e00c20ae091ed8db0f" @@ -4866,129 +4797,114 @@ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.22.0.tgz#dd8096cb055c475a4de6b35322b8d3b118c17b43" integrity sha512-MBOl8MeOzpK0HQQQshKB7pABXbmyHizdTpqnrIseTbsv0nAepwC2ENZa1aaBExNQcpLoXmWthhak8SABLzvGPw== -"@reown/appkit-adapter-wagmi@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-adapter-wagmi/-/appkit-adapter-wagmi-1.3.2.tgz#54a12df940e32db171c61f8772ddd31061a81b2f" - integrity sha512-hyBJ8mqX58RNlG1TEe9sL5DpfaPLP7i2bhGq7a1zp5LJb3bMmXbAtKOsc4gH3HjBQBxNNacqyYCwK5snMWFKrg== - dependencies: - "@reown/appkit" "1.3.2" - "@reown/appkit-common" "1.3.2" - "@reown/appkit-core" "1.3.2" - "@reown/appkit-polyfills" "1.3.2" - "@reown/appkit-scaffold-ui" "1.3.2" - "@reown/appkit-siwe" "1.3.2" - "@reown/appkit-ui" "1.3.2" - "@reown/appkit-utils" "1.3.2" - "@reown/appkit-wallet" "1.3.2" - "@walletconnect/universal-provider" "2.17.0" - "@walletconnect/utils" "2.17.0" - valtio "1.11.2" +"@reown/appkit-adapter-wagmi@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-adapter-wagmi/-/appkit-adapter-wagmi-1.7.2.tgz#649f1d8ff46eb6f2f5303719d88064bd5236e426" + integrity sha512-Wd5Nhb1DA2rIrZEZDXkzjIpyKEtnbTfWTGenYK4OUJgNyDpPaDkETYj59MjvMGNOZe9WkiMeG/IruN/tK29mnA== + dependencies: + "@reown/appkit" "1.7.2" + "@reown/appkit-common" "1.7.2" + "@reown/appkit-controllers" "1.7.2" + "@reown/appkit-polyfills" "1.7.2" + "@reown/appkit-scaffold-ui" "1.7.2" + "@reown/appkit-utils" "1.7.2" + "@reown/appkit-wallet" "1.7.2" + "@walletconnect/universal-provider" "2.19.1" + valtio "1.13.2" + optionalDependencies: + "@wagmi/connectors" ">=5.7.11" -"@reown/appkit-common@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-common/-/appkit-common-1.3.2.tgz#db9ce8babba0f0303991acbd86d11b71c5db6eb8" - integrity sha512-U/IJSfKFTGm92rlpSp7yV9iefJQjXpp9Z9BzUiyqLTEYX5ZzGHrV69riho5NlLHQRx58qeD1rGbPuz8CqXF6hg== +"@reown/appkit-common@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-common/-/appkit-common-1.7.2.tgz#31f809095b4f1b97fb4951acd92d75e80b612e1e" + integrity sha512-DZkl3P5+Iw3TmsitWmWxYbuSCox8iuzngNp/XhbNDJd7t4Cj4akaIUxSEeCajNDiGHlu4HZnfyM1swWsOJ0cOw== dependencies: - bignumber.js "9.1.2" - dayjs "1.11.10" - viem "2.x" + big.js "6.2.2" + dayjs "1.11.13" + viem ">=2.23.11" -"@reown/appkit-core@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-core/-/appkit-core-1.3.2.tgz#1da867f24b2e8b1f5b7454dbfe4cb6a776f69799" - integrity sha512-A8v7rIMogFs4vDEweWRE7uUCtnp9EjTn8dyezmC8ebelo5M1GbqzhoWlqi8FiVNjESY/fOjCcEVCw6q1h8S8sQ== +"@reown/appkit-controllers@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-controllers/-/appkit-controllers-1.7.2.tgz#d3e5a22716d4273147d7309f4e0d4fbc8dddc64b" + integrity sha512-KCN/VOg+bgwaX5kcxcdN8Xq8YXnchMeZOvmbCltPEFDzaLRUWmqk9tNu1OVml0434iGMNo6hcVimIiwz6oaL3Q== dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-wallet" "1.3.2" - "@walletconnect/universal-provider" "2.17.0" - valtio "1.11.2" - viem "2.x" + "@reown/appkit-common" "1.7.2" + "@reown/appkit-wallet" "1.7.2" + "@walletconnect/universal-provider" "2.19.1" + valtio "1.13.2" + viem ">=2.23.11" -"@reown/appkit-polyfills@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-polyfills/-/appkit-polyfills-1.3.2.tgz#d44b9e3e86d151c2a3f18c47c6f793e112d11d38" - integrity sha512-vS9jRZTLdzhqJllK5iUNnAlyo/veug80S/n7HsPexWrU9haTfs06wHxSpvfwYuqo9sff6wNz7LEiP0atVf9+IA== +"@reown/appkit-polyfills@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-polyfills/-/appkit-polyfills-1.7.2.tgz#8be1bc5b6cb1b87785c844524aecba443724104b" + integrity sha512-TxCVSh9dV2tf1u+OzjzLjAwj7WHhBFufHlJ36tDp5vjXeUUne8KvYUS85Zsyg4Y9Yeh+hdSIOdL2oDCqlRxCmw== dependencies: buffer "6.0.3" -"@reown/appkit-scaffold-ui@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-scaffold-ui/-/appkit-scaffold-ui-1.3.2.tgz#e67bd663188db4ea8805b04f60e0d47d2ae2628c" - integrity sha512-G7VXnN7tphcoYV/bGmL9DBK9xSc1tnBGsQVf8VFrtMu0zVEgJ/oCe1iyk9F+/ctoWug7hwhRX8/ts0UXW5eP/Q== - dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-core" "1.3.2" - "@reown/appkit-siwe" "1.3.2" - "@reown/appkit-ui" "1.3.2" - "@reown/appkit-utils" "1.3.2" - "@reown/appkit-wallet" "1.3.2" - lit "3.1.0" - -"@reown/appkit-siwe@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-siwe/-/appkit-siwe-1.3.2.tgz#1caa2c83d58c8eba9bf525b4be0988e3abb605f5" - integrity sha512-D0XraLV/8rTGAC+WF6KQcaUWs4cJI3Rw1iPFKh4LTNrIYn1d8gflbvk1rV1YrAzPFaNr/hguYySI/iTASixS5A== - dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-core" "1.3.2" - "@reown/appkit-ui" "1.3.2" - "@reown/appkit-utils" "1.3.2" - "@reown/appkit-wallet" "1.3.2" - "@walletconnect/utils" "2.17.0" +"@reown/appkit-scaffold-ui@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-scaffold-ui/-/appkit-scaffold-ui-1.7.2.tgz#485758b89f141a2a5ec458583f71c4215141b627" + integrity sha512-2Aifk5d23e40ijUipsN3qAMIB1Aphm2ZgsRQ+UvKRb838xR1oRs+MOsfDWgXhnccXWKbjPqyapZ25eDFyPYPNw== + dependencies: + "@reown/appkit-common" "1.7.2" + "@reown/appkit-controllers" "1.7.2" + "@reown/appkit-ui" "1.7.2" + "@reown/appkit-utils" "1.7.2" + "@reown/appkit-wallet" "1.7.2" lit "3.1.0" - valtio "1.11.2" -"@reown/appkit-ui@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-ui/-/appkit-ui-1.3.2.tgz#1bae7cdc58be923cc5813b7ee2b134418cb34f53" - integrity sha512-aQcXEAil0eBNbhcQveD3b0XEBV2xz1MgVq++XPnyrelxNkm+uCDI55tH1Kg0xiOoPrd50aEJiHPJOTrEMmjbMg== +"@reown/appkit-ui@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-ui/-/appkit-ui-1.7.2.tgz#80e3bfaa6735298184d11f66860a996caa82a948" + integrity sha512-fZv8K7Df6A/TlTIWD/9ike1HwK56WfzYpHN1/yqnR/BnyOb3CKroNQxmRTmjeLlnwKWkltlOf3yx+Y6ucKMk6Q== dependencies: + "@reown/appkit-common" "1.7.2" + "@reown/appkit-controllers" "1.7.2" + "@reown/appkit-wallet" "1.7.2" lit "3.1.0" qrcode "1.5.3" -"@reown/appkit-utils@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-utils/-/appkit-utils-1.3.2.tgz#d313890e31e19ace3d73324d405e023c1cebbf97" - integrity sha512-9a/gi5QeZZkTDT56tLBZu4cOrFLHh/93qtqV5UE7NPqlncpQbE2bfVLq64tnvkbhE1WXoyQM4s4xrL0k8Wtlgw== +"@reown/appkit-utils@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-utils/-/appkit-utils-1.7.2.tgz#d3255d2ee876ce05d0c13cd082627beb72620e3d" + integrity sha512-Z3gQnMPQopBdf1XEuptbf+/xVl9Hy0+yoK3K9pBb2hDdYNqJgJ4dXComhlRT8LjXFCQe1ZW0pVZTXmGQvOZ/OQ== dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-core" "1.3.2" - "@reown/appkit-polyfills" "1.3.2" - "@reown/appkit-wallet" "1.3.2" + "@reown/appkit-common" "1.7.2" + "@reown/appkit-controllers" "1.7.2" + "@reown/appkit-polyfills" "1.7.2" + "@reown/appkit-wallet" "1.7.2" "@walletconnect/logger" "2.1.2" - "@walletconnect/universal-provider" "2.17.0" - valtio "1.11.2" - viem "2.x" + "@walletconnect/universal-provider" "2.19.1" + valtio "1.13.2" + viem ">=2.23.11" -"@reown/appkit-wallet@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit-wallet/-/appkit-wallet-1.3.2.tgz#df75fb992f8c91d5ef3abd2899bf50abee964c53" - integrity sha512-TnybfBU6vVjm27lMd7VOWd6I632nOqBfex5QL4I8Zgyy1wLugcSQgybZk8LjSKzaW+U9G9yqHXXhFK9iW6/zaQ== +"@reown/appkit-wallet@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit-wallet/-/appkit-wallet-1.7.2.tgz#02fce896c78ed83ea575256306396a42c58ec646" + integrity sha512-WQ0ykk5TwsjOcUL62ajT1bhZYdFZl0HjwwAH9LYvtKYdyZcF0Ps4+y2H4HHYOc03Q+LKOHEfrFztMBLXPTxwZA== dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-polyfills" "1.3.2" + "@reown/appkit-common" "1.7.2" + "@reown/appkit-polyfills" "1.7.2" "@walletconnect/logger" "2.1.2" zod "3.22.4" -"@reown/appkit@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@reown/appkit/-/appkit-1.3.2.tgz#6fd3680b3ca4e809aa3463bcbe6753ed3a679738" - integrity sha512-Kv/mdI2iddot8sB5NzFLVob1fCdK6q2Y6dfyiBL93sXiJv6W5LxNOPOGdTdFOxTHcTHm/OV9nlrmLa1ZIn7ZSg== - dependencies: - "@reown/appkit-common" "1.3.2" - "@reown/appkit-core" "1.3.2" - "@reown/appkit-polyfills" "1.3.2" - "@reown/appkit-scaffold-ui" "1.3.2" - "@reown/appkit-siwe" "1.3.2" - "@reown/appkit-ui" "1.3.2" - "@reown/appkit-utils" "1.3.2" - "@reown/appkit-wallet" "1.3.2" - "@walletconnect/types" "2.17.0" - "@walletconnect/universal-provider" "2.17.0" - "@walletconnect/utils" "2.17.0" +"@reown/appkit@1.7.2", "@reown/appkit@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@reown/appkit/-/appkit-1.7.2.tgz#71b253fc4bee5579bc720cbcb63c8f037a9ba4c8" + integrity sha512-oo/evAyVxwc33i8ZNQ0+A/VE6vyTyzL3NBJmAe3I4vobgQeiobxMM0boKyLRMMbJggPn8DtoAAyG4GfpKaUPzQ== + dependencies: + "@reown/appkit-common" "1.7.2" + "@reown/appkit-controllers" "1.7.2" + "@reown/appkit-polyfills" "1.7.2" + "@reown/appkit-scaffold-ui" "1.7.2" + "@reown/appkit-ui" "1.7.2" + "@reown/appkit-utils" "1.7.2" + "@reown/appkit-wallet" "1.7.2" + "@walletconnect/types" "2.19.1" + "@walletconnect/universal-provider" "2.19.1" bs58 "6.0.0" - valtio "1.11.2" - viem "2.x" + valtio "1.13.2" + viem ">=2.23.11" "@repeaterjs/repeater@3.0.4": version "3.0.4" @@ -7290,49 +7206,64 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.14.2" -"@vitest/expect@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.1.tgz#b90c213f587514a99ac0bf84f88cff9042b0f14d" - integrity sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog== +"@vitest/expect@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.0.9.tgz#b0cb9cd798a131423097cc5a777b699675405fcf" + integrity sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig== dependencies: - "@vitest/spy" "1.6.1" - "@vitest/utils" "1.6.1" - chai "^4.3.10" + "@vitest/spy" "3.0.9" + "@vitest/utils" "3.0.9" + chai "^5.2.0" + tinyrainbow "^2.0.0" -"@vitest/runner@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.1.tgz#10f5857c3e376218d58c2bfacfea1161e27e117f" - integrity sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA== +"@vitest/mocker@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.0.9.tgz#75d176745131caf40810d3a3a73491595fce46e6" + integrity sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA== dependencies: - "@vitest/utils" "1.6.1" - p-limit "^5.0.0" - pathe "^1.1.1" + "@vitest/spy" "3.0.9" + estree-walker "^3.0.3" + magic-string "^0.30.17" -"@vitest/snapshot@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.1.tgz#90414451a634bb36cd539ccb29ae0d048a8c0479" - integrity sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ== +"@vitest/pretty-format@3.0.9", "@vitest/pretty-format@^3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.0.9.tgz#d9c88fe64b4edcdbc88e5bd92c39f9cc8d40930d" + integrity sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA== dependencies: - magic-string "^0.30.5" - pathe "^1.1.1" - pretty-format "^29.7.0" + tinyrainbow "^2.0.0" -"@vitest/spy@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.1.tgz#33376be38a5ed1ecd829eb986edaecc3e798c95d" - integrity sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw== +"@vitest/runner@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.0.9.tgz#92b7f37f65825105dbfdc07196b90dd8c20547d8" + integrity sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw== dependencies: - tinyspy "^2.2.0" + "@vitest/utils" "3.0.9" + pathe "^2.0.3" -"@vitest/utils@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.1.tgz#6d2f36cb6d866f2bbf59da854a324d6bf8040f17" - integrity sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g== +"@vitest/snapshot@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.0.9.tgz#2ab878b3590b2daef1798b645a9d9e72a0eb258d" + integrity sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A== dependencies: - diff-sequences "^29.6.3" - estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" + "@vitest/pretty-format" "3.0.9" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.0.9.tgz#c3e5d47ceff7c1cb9fdfb9b2f168056bbc625534" + integrity sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ== + dependencies: + tinyspy "^3.0.2" + +"@vitest/utils@3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.0.9.tgz#15da261d8cacd6035dc28a8d3ba38ee39545f82b" + integrity sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng== + dependencies: + "@vitest/pretty-format" "3.0.9" + loupe "^3.1.3" + tinyrainbow "^2.0.0" "@wagmi/connectors@5.7.3": version "5.7.3" @@ -7358,6 +7289,18 @@ "@walletconnect/ethereum-provider" "2.17.0" cbw-sdk "npm:@coinbase/wallet-sdk@3.9.3" +"@wagmi/connectors@>=5.7.11": + version "5.7.12" + resolved "https://registry.yarnpkg.com/@wagmi/connectors/-/connectors-5.7.12.tgz#3f96f8b12fa484723b56139cdf65520b878ef9c7" + integrity sha512-pLFuZ1PsLkNyY11mx0+IOrMM7xACWCBRxaulfX17osqixkDFeOAyqFGBjh/XxkvRyrDJUdO4F+QHEeSoOiPpgg== + dependencies: + "@coinbase/wallet-sdk" "4.3.0" + "@metamask/sdk" "0.32.0" + "@safe-global/safe-apps-provider" "0.18.5" + "@safe-global/safe-apps-sdk" "9.1.0" + "@walletconnect/ethereum-provider" "2.19.2" + cbw-sdk "npm:@coinbase/wallet-sdk@3.9.3" + "@wagmi/core@2.16.3": version "2.16.3" resolved "https://registry.yarnpkg.com/@wagmi/core/-/core-2.16.3.tgz#abbff0a19e75beaad56ffb90da772641552d49c3" @@ -7398,6 +7341,52 @@ lodash.isequal "4.5.0" uint8arrays "3.1.0" +"@walletconnect/core@2.19.1": + version "2.19.1" + resolved "https://registry.yarnpkg.com/@walletconnect/core/-/core-2.19.1.tgz#71738940341b438326b65b3f49226decbe070bae" + integrity sha512-rMvpZS0tQXR/ivzOxN1GkHvw3jRRMlI/jRX5g7ZteLgg2L0ZcANsFvAU5IxILxIKcIkTCloF9TcfloKVbK3qmw== + dependencies: + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-provider" "1.0.14" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/jsonrpc-ws-connection" "1.0.16" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + "@walletconnect/relay-api" "1.0.11" + "@walletconnect/relay-auth" "1.1.0" + "@walletconnect/safe-json" "1.0.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.1" + "@walletconnect/utils" "2.19.1" + "@walletconnect/window-getters" "1.0.1" + es-toolkit "1.33.0" + events "3.3.0" + uint8arrays "3.1.0" + +"@walletconnect/core@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/core/-/core-2.19.2.tgz#4bf3918dd8041843a1b796e2c4e1f101363d72a4" + integrity sha512-iu0mgLj51AXcKpdNj8+4EdNNBd/mkNjLEhZn6UMc/r7BM9WbmpPMEydA39WeRLbdLO4kbpmq4wTbiskI1rg+HA== + dependencies: + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-provider" "1.0.14" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/jsonrpc-ws-connection" "1.0.16" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + "@walletconnect/relay-api" "1.0.11" + "@walletconnect/relay-auth" "1.1.0" + "@walletconnect/safe-json" "1.0.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.2" + "@walletconnect/utils" "2.19.2" + "@walletconnect/window-getters" "1.0.1" + es-toolkit "1.33.0" + events "3.3.0" + uint8arrays "3.1.0" + "@walletconnect/environment@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@walletconnect/environment/-/environment-1.0.1.tgz#1d7f82f0009ab821a2ba5ad5e5a7b8ae3b214cd7" @@ -7421,6 +7410,23 @@ "@walletconnect/utils" "2.17.0" events "3.3.0" +"@walletconnect/ethereum-provider@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/ethereum-provider/-/ethereum-provider-2.19.2.tgz#898c85287b02cecbbfead12e0ee26f00e5a64d47" + integrity sha512-NzPzNcjMLqow6ha2nssB1ciMD0cdHZesYcHSQKjCi9waIDMov9Fr2yEJccbiVFE3cxek7f9dCPsoZez2q8ihvg== + dependencies: + "@walletconnect/jsonrpc-http-connection" "1.0.8" + "@walletconnect/jsonrpc-provider" "1.0.14" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/modal" "2.7.0" + "@walletconnect/sign-client" "2.19.2" + "@walletconnect/types" "2.19.2" + "@walletconnect/universal-provider" "2.19.2" + "@walletconnect/utils" "2.19.2" + events "3.3.0" + "@walletconnect/events@1.0.1", "@walletconnect/events@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@walletconnect/events/-/events-1.0.1.tgz#2b5f9c7202019e229d7ccae1369a9e86bda7816c" @@ -7484,6 +7490,16 @@ events "^3.3.0" ws "^7.5.1" +"@walletconnect/jsonrpc-ws-connection@1.0.16": + version "1.0.16" + resolved "https://registry.yarnpkg.com/@walletconnect/jsonrpc-ws-connection/-/jsonrpc-ws-connection-1.0.16.tgz#666bb13fbf32a2d4f7912d5b4d0bdef26a1d057b" + integrity sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q== + dependencies: + "@walletconnect/jsonrpc-utils" "^1.0.6" + "@walletconnect/safe-json" "^1.0.2" + events "^3.3.0" + ws "^7.5.1" + "@walletconnect/keyvaluestorage@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@walletconnect/keyvaluestorage/-/keyvaluestorage-1.1.1.tgz#dd2caddabfbaf80f6b8993a0704d8b83115a1842" @@ -7545,6 +7561,17 @@ tslib "1.14.1" uint8arrays "^3.0.0" +"@walletconnect/relay-auth@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@walletconnect/relay-auth/-/relay-auth-1.1.0.tgz#c3c5f54abd44a5138ea7d4fe77970597ba66c077" + integrity sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ== + dependencies: + "@noble/curves" "1.8.0" + "@noble/hashes" "1.7.0" + "@walletconnect/safe-json" "^1.0.1" + "@walletconnect/time" "^1.0.2" + uint8arrays "^3.0.0" + "@walletconnect/safe-json@1.0.2", "@walletconnect/safe-json@^1.0.1", "@walletconnect/safe-json@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@walletconnect/safe-json/-/safe-json-1.0.2.tgz#7237e5ca48046e4476154e503c6d3c914126fa77" @@ -7567,6 +7594,36 @@ "@walletconnect/utils" "2.17.0" events "3.3.0" +"@walletconnect/sign-client@2.19.1": + version "2.19.1" + resolved "https://registry.yarnpkg.com/@walletconnect/sign-client/-/sign-client-2.19.1.tgz#6cfbb4ee0eaf3a8774a8c70ff65ba23177e8f388" + integrity sha512-OgBHRPo423S02ceN3lAzcZ3MYb1XuLyTTkKqLmKp/icYZCyRzm3/ynqJDKndiBLJ5LTic0y07LiZilnliYqlvw== + dependencies: + "@walletconnect/core" "2.19.1" + "@walletconnect/events" "1.0.1" + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/logger" "2.1.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.1" + "@walletconnect/utils" "2.19.1" + events "3.3.0" + +"@walletconnect/sign-client@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/sign-client/-/sign-client-2.19.2.tgz#6b728fd8b1ebf8f47b231bedf56b725de660633d" + integrity sha512-a/K5PRIFPCjfHq5xx3WYKHAAF8Ft2I1LtxloyibqiQOoUtNLfKgFB1r8sdMvXM7/PADNPe4iAw4uSE6PrARrfg== + dependencies: + "@walletconnect/core" "2.19.2" + "@walletconnect/events" "1.0.1" + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/logger" "2.1.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.2" + "@walletconnect/utils" "2.19.2" + events "3.3.0" + "@walletconnect/time@1.0.2", "@walletconnect/time@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@walletconnect/time/-/time-1.0.2.tgz#6c5888b835750ecb4299d28eecc5e72c6d336523" @@ -7586,6 +7643,30 @@ "@walletconnect/logger" "2.1.2" events "3.3.0" +"@walletconnect/types@2.19.1": + version "2.19.1" + resolved "https://registry.yarnpkg.com/@walletconnect/types/-/types-2.19.1.tgz#ec78c5a05238e220871cca3e360193584af2d968" + integrity sha512-XWWGLioddH7MjxhyGhylL7VVariVON2XatJq/hy0kSGJ1hdp31z194nHN5ly9M495J9Hw8lcYjGXpsgeKvgxzw== + dependencies: + "@walletconnect/events" "1.0.1" + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + events "3.3.0" + +"@walletconnect/types@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/types/-/types-2.19.2.tgz#3518cffdd74a7d07a110c9da5da939c1b185e837" + integrity sha512-/LZWhkVCUN+fcTgQUxArxhn2R8DF+LSd/6Wh9FnpjeK/Sdupx1EPS8okWG6WPAqq2f404PRoNAfQytQ82Xdl3g== + dependencies: + "@walletconnect/events" "1.0.1" + "@walletconnect/heartbeat" "1.2.2" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + events "3.3.0" + "@walletconnect/universal-provider@2.17.0": version "2.17.0" resolved "https://registry.yarnpkg.com/@walletconnect/universal-provider/-/universal-provider-2.17.0.tgz#c9d4bbd9b8f0e41b500b2488ccbc207dc5f7a170" @@ -7601,6 +7682,42 @@ "@walletconnect/utils" "2.17.0" events "3.3.0" +"@walletconnect/universal-provider@2.19.1": + version "2.19.1" + resolved "https://registry.yarnpkg.com/@walletconnect/universal-provider/-/universal-provider-2.19.1.tgz#9908431b766fffcb0f617f3fdb7e85f27f05f9de" + integrity sha512-4rdLvJ2TGDIieNWW3sZw2MXlX65iHpTuKb5vyvUHQtjIVNLj+7X/09iUAI/poswhtspBK0ytwbH+AIT/nbGpjg== + dependencies: + "@walletconnect/events" "1.0.1" + "@walletconnect/jsonrpc-http-connection" "1.0.8" + "@walletconnect/jsonrpc-provider" "1.0.14" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + "@walletconnect/sign-client" "2.19.1" + "@walletconnect/types" "2.19.1" + "@walletconnect/utils" "2.19.1" + es-toolkit "1.33.0" + events "3.3.0" + +"@walletconnect/universal-provider@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/universal-provider/-/universal-provider-2.19.2.tgz#a87d2c5da01a16ac8c107adcd27ee8c894e2331b" + integrity sha512-LkKg+EjcSUpPUhhvRANgkjPL38wJPIWumAYD8OK/g4OFuJ4W3lS/XTCKthABQfFqmiNbNbVllmywiyE44KdpQg== + dependencies: + "@walletconnect/events" "1.0.1" + "@walletconnect/jsonrpc-http-connection" "1.0.8" + "@walletconnect/jsonrpc-provider" "1.0.14" + "@walletconnect/jsonrpc-types" "1.0.4" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/logger" "2.1.2" + "@walletconnect/sign-client" "2.19.2" + "@walletconnect/types" "2.19.2" + "@walletconnect/utils" "2.19.2" + es-toolkit "1.33.0" + events "3.3.0" + "@walletconnect/utils@2.17.0": version "2.17.0" resolved "https://registry.yarnpkg.com/@walletconnect/utils/-/utils-2.17.0.tgz#02b3af0b80d0c1a994d692d829d066271b04d071" @@ -7623,6 +7740,53 @@ query-string "7.1.3" uint8arrays "3.1.0" +"@walletconnect/utils@2.19.1": + version "2.19.1" + resolved "https://registry.yarnpkg.com/@walletconnect/utils/-/utils-2.19.1.tgz#16cbc173cd3b28cbf86ca5c6e362057810da07f9" + integrity sha512-aOwcg+Hpph8niJSXLqkU25pmLR49B8ECXp5gFQDW5IeVgXHoOoK7w8a79GBhIBheMLlIt1322sTKQ7Rq5KzzFg== + dependencies: + "@noble/ciphers" "1.2.1" + "@noble/curves" "1.8.1" + "@noble/hashes" "1.7.1" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/relay-api" "1.0.11" + "@walletconnect/relay-auth" "1.1.0" + "@walletconnect/safe-json" "1.0.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.1" + "@walletconnect/window-getters" "1.0.1" + "@walletconnect/window-metadata" "1.0.1" + bs58 "6.0.0" + detect-browser "5.3.0" + elliptic "6.6.1" + query-string "7.1.3" + uint8arrays "3.1.0" + viem "2.23.2" + +"@walletconnect/utils@2.19.2": + version "2.19.2" + resolved "https://registry.yarnpkg.com/@walletconnect/utils/-/utils-2.19.2.tgz#90259b69367e30ccd13cf8252547a6850ca5fb2e" + integrity sha512-VU5CcUF4sZDg8a2/ov29OJzT3KfLuZqJUM0GemW30dlipI5fkpb0VPenZK7TcdLPXc1LN+Q+7eyTqHRoAu/BIA== + dependencies: + "@noble/ciphers" "1.2.1" + "@noble/curves" "1.8.1" + "@noble/hashes" "1.7.1" + "@walletconnect/jsonrpc-utils" "1.0.8" + "@walletconnect/keyvaluestorage" "1.1.1" + "@walletconnect/relay-api" "1.0.11" + "@walletconnect/relay-auth" "1.1.0" + "@walletconnect/safe-json" "1.0.2" + "@walletconnect/time" "1.0.2" + "@walletconnect/types" "2.19.2" + "@walletconnect/window-getters" "1.0.1" + "@walletconnect/window-metadata" "1.0.1" + bs58 "6.0.0" + detect-browser "5.3.0" + query-string "7.1.3" + uint8arrays "3.1.0" + viem "2.23.2" + "@walletconnect/window-getters@1.0.1", "@walletconnect/window-getters@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@walletconnect/window-getters/-/window-getters-1.0.1.tgz#f36d1c72558a7f6b87ecc4451fc8bd44f63cbbdc" @@ -7919,7 +8083,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.0.2, acorn-walk@^8.1.1, acorn-walk@^8.3.2: +acorn-walk@^8.0.2, acorn-walk@^8.1.1: version "8.3.4" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== @@ -7973,6 +8137,13 @@ ajv-formats@2.1.1, ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" +ajv-formats@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" + integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" @@ -7995,6 +8166,16 @@ ajv@8.12.0: require-from-string "^2.0.2" uri-js "^4.2.2" +ajv@8.17.1, ajv@^8.0.0, ajv@^8.0.1, ajv@^8.9.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ajv@^6.12.4, ajv@^6.12.5, ajv@~6.12.6: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -8005,16 +8186,6 @@ ajv@^6.12.4, ajv@^6.12.5, ajv@~6.12.6: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.0.1, ajv@^8.9.0: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" - integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== - dependencies: - fast-deep-equal "^3.1.3" - fast-uri "^3.0.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - amazon-cognito-identity-js@^6.3.6: version "6.3.12" resolved "https://registry.yarnpkg.com/amazon-cognito-identity-js/-/amazon-cognito-identity-js-6.3.12.tgz#af73df033094ad4c679c19cf6122b90058021619" @@ -8375,6 +8546,11 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" @@ -8652,7 +8828,12 @@ bech32@1.1.4: resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.4.tgz#e38c9f37bf179b8eb16ae3a772b40c356d4832e9" integrity sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ== -bignumber.js@9.1.2, bignumber.js@^9.0.0: +big.js@6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-6.2.2.tgz#be3bb9ac834558b53b099deef2a1d06ac6368e1a" + integrity sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ== + +bignumber.js@^9.0.0: version "9.1.2" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== @@ -9165,7 +9346,7 @@ chai-as-promised@^7.1.1: dependencies: check-error "^1.0.2" -chai@^4.3.10, chai@^4.5.0: +chai@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== @@ -9178,6 +9359,17 @@ chai@^4.3.10, chai@^4.5.0: pathval "^1.1.1" type-detect "^4.1.0" +chai@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05" + integrity sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + chalk-template@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b" @@ -9239,6 +9431,11 @@ check-error@^1.0.2, check-error@^1.0.3: dependencies: get-func-name "^2.0.2" +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + chokidar@3.6.0, chokidar@^3.5.2, chokidar@^3.5.3, chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -9653,11 +9850,6 @@ concurrently@^9.1.2: tree-kill "^1.2.2" yargs "^17.7.2" -confbox@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" - integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== - config-chain@^1.1.11: version "1.1.13" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" @@ -10135,12 +10327,7 @@ dateformat@^4.6.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== -dayjs@1.11.10: - version "1.11.10" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" - integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== - -dayjs@^1.11.11, dayjs@^1.11.12, dayjs@^1.11.9: +dayjs@1.11.13, dayjs@^1.11.11, dayjs@^1.11.12, dayjs@^1.11.9: version "1.11.13" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== @@ -10220,6 +10407,11 @@ deep-eql@^4.0.1, deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + deep-extend@^0.6.0, deep-extend@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -10328,6 +10520,11 @@ dequal@^2.0.3: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +derive-valtio@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/derive-valtio/-/derive-valtio-0.1.0.tgz#4b9fb393dfefccfef15fcbbddd745dd22d5d63d7" + integrity sha512-OCg2UsLbXK7GmmpzMXhYkdO64vhJ1ROUUGaTFyHjVwEdMEcTTRj7W1TxLbSBxdY8QLBPCcp66MTyaSy0RpO17A== + des.js@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da" @@ -10581,7 +10778,7 @@ elliptic@6.5.4: minimalistic-assert "^1.0.1" minimalistic-crypto-utils "^1.0.1" -elliptic@^6.5.2, elliptic@^6.5.3, elliptic@^6.5.5, elliptic@^6.5.7: +elliptic@6.6.1, elliptic@^6.5.2, elliptic@^6.5.3, elliptic@^6.5.5, elliptic@^6.5.7: version "6.6.1" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06" integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g== @@ -10803,7 +11000,7 @@ es-iterator-helpers@^1.2.1: iterator.prototype "^1.1.4" safe-array-concat "^1.1.3" -es-module-lexer@^1.2.1: +es-module-lexer@^1.2.1, es-module-lexer@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.6.0.tgz#da49f587fd9e68ee2404fe4e256c0c7d3a81be21" integrity sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ== @@ -10841,6 +11038,11 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" +es-toolkit@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.33.0.tgz#bcc9d92ef2e1ed4618c00dd30dfda9faddf4a0b7" + integrity sha512-X13Q/ZSc+vsO1q600bvNK4bxgXMkHcf//RxCmYDaRY5DAcT+eoXjY5hoAPGMdRnWQjvyLEcyauG3b6hz76LNqg== + es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" @@ -10853,35 +11055,6 @@ es6-promisify@^5.0.0: dependencies: es6-promise "^4.0.3" -esbuild@^0.21.3: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" - esbuild@^0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" @@ -11604,6 +11777,11 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +expect-type@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.0.tgz#b52a0a1117260f5a8dcf33aef66365be18c13415" + integrity sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA== + expect@^29.0.0, expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -14221,11 +14399,6 @@ js-sha3@0.8.0, js-sha3@^0.8.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" - integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== - js-yaml@3.x, js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -14722,14 +14895,6 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== -local-pkg@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.1.tgz#69658638d2a95287534d4c2fff757980100dbb6d" - integrity sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ== - dependencies: - mlly "^1.7.3" - pkg-types "^1.2.1" - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -14932,13 +15097,18 @@ loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.6, loupe@^2.3.7: +loupe@^2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== dependencies: get-func-name "^2.0.1" +loupe@^3.1.0, loupe@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.3.tgz#042a8f7986d77f3d0f98ef7990a2b2fef18b0fd2" + integrity sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug== + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -14978,6 +15148,13 @@ luxon@~3.5.0: resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20" integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ== +magic-string@0.30.17, magic-string@^0.30.17, magic-string@^0.30.3: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + magic-string@0.30.8: version "0.30.8" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.8.tgz#14e8624246d2bedba70d5462aa99ac9681844613" @@ -14985,13 +15162,6 @@ magic-string@0.30.8: dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -magic-string@^0.30.3, magic-string@^0.30.5: - version "0.30.17" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" - integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" - make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -15413,16 +15583,6 @@ mkdirp@^2.1.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19" integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A== -mlly@^1.7.3, mlly@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f" - integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== - dependencies: - acorn "^8.14.0" - pathe "^2.0.1" - pkg-types "^1.3.0" - ufo "^1.5.4" - mnemonist@^0.38.0: version "0.38.5" resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.5.tgz#4adc7f4200491237fe0fa689ac0b86539685cade" @@ -16171,13 +16331,6 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" - integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -16396,12 +16549,7 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pathe@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" - integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== - -pathe@^2.0.1: +pathe@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== @@ -16411,6 +16559,11 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +pathval@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" + integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== + pause@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" @@ -16511,16 +16664,16 @@ picomatch@4.0.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.1.tgz#68c26c8837399e5819edce48590412ea07f17a07" integrity sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg== +picomatch@4.0.2, picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" - integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== - pidtree@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -16638,15 +16791,6 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -pkg-types@^1.2.1, pkg-types@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" - integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== - dependencies: - confbox "^0.1.8" - mlly "^1.7.4" - pathe "^2.0.1" - pluralize@8.0.0, pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" @@ -16681,7 +16825,7 @@ postcss@8.4.49: picocolors "^1.1.1" source-map-js "^1.2.1" -postcss@^8.4.43, postcss@^8.5.3: +postcss@^8.5.3: version "8.5.3" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== @@ -16932,6 +17076,11 @@ proxy-compare@2.5.1: resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-2.5.1.tgz#17818e33d1653fbac8c2ec31406bce8a2966f600" integrity sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA== +proxy-compare@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-2.6.0.tgz#5e8c8b5c3af7e7f17e839bf6cf1435bcc4d315b0" + integrity sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw== + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -17672,7 +17821,7 @@ rlp@^2.2.3, rlp@^2.2.4: dependencies: bn.js "^5.2.0" -rollup@^4.20.0, rollup@^4.30.1: +rollup@^4.30.1: version "4.34.8" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.34.8.tgz#e859c1a51d899aba9bcf451d4eed1d11fb8e2a6e" integrity sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ== @@ -18428,10 +18577,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -std-env@^3.5.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.0.tgz#b56ffc1baf1a29dcc80a3bdf11d7fca7c315e7d5" - integrity sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w== +std-env@^3.8.0: + version "3.8.1" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.1.tgz#2b81c631c62e3d0b964b87f099b8dcab6c9a5346" + integrity sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA== stream-browserify@^3.0.0: version "3.0.0" @@ -18520,16 +18669,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18638,7 +18778,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -18652,13 +18792,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -18710,17 +18843,10 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.1.tgz#26906e65f606d49f748454a08084e94190c2e5ad" - integrity sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q== - dependencies: - js-tokens "^9.0.1" - -stripe@^17.4.0: - version "17.6.0" - resolved "https://registry.yarnpkg.com/stripe/-/stripe-17.6.0.tgz#6495a42b7a20066e0d96068c362da793d684e79c" - integrity sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA== +stripe@^17.7.0: + version "17.7.0" + resolved "https://registry.yarnpkg.com/stripe/-/stripe-17.7.0.tgz#7816be2559e6f58bf4c005758fe20be78cdbf995" + integrity sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw== dependencies: "@types/node" ">=8.1.0" qs "^6.11.0" @@ -19033,11 +19159,16 @@ tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== -tinybench@^2.5.1: +tinybench@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + tinyglobby@^0.2.6, tinyglobby@^0.2.9: version "0.2.10" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.10.tgz#e712cf2dc9b95a1f5c5bbd159720e15833977a0f" @@ -19046,15 +19177,20 @@ tinyglobby@^0.2.6, tinyglobby@^0.2.9: fdir "^6.4.2" picomatch "^4.0.2" -tinypool@^0.8.3: - version "0.8.4" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" - integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== +tinypool@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" + integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== -tinyspy@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" - integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" + integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== tldts-core@^6.1.77: version "6.1.77" @@ -19850,6 +19986,15 @@ valtio@1.11.2: proxy-compare "2.5.1" use-sync-external-store "1.2.0" +valtio@1.13.2: + version "1.13.2" + resolved "https://registry.yarnpkg.com/valtio/-/valtio-1.13.2.tgz#e31d452d5da3550935417670aafd34d832dc7241" + integrity sha512-Qik0o+DSy741TmkqmRfjq+0xpZBXi/Y6+fXZLn0xNF1z/waFMbE3rkivv5Zcf9RrMUp6zswf2J7sbh2KBlba5A== + dependencies: + derive-valtio "0.1.0" + proxy-compare "2.6.0" + use-sync-external-store "1.2.0" + value-or-promise@^1.0.11, value-or-promise@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.12.tgz#0e5abfeec70148c78460a849f6b003ea7986f15c" @@ -19880,10 +20025,10 @@ victory-vendor@^36.6.8: d3-time "^3.0.0" d3-timer "^3.0.1" -viem@2.7.14, viem@^2.15.1: - version "2.23.11" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.11.tgz#c087a52f3c08a9cf4dadd4b273a1d30c62b249e2" - integrity sha512-yPkHJt4Vn88kLlrv8mrtVN54PW4vNLWRWDScf8SaHK2f44VlMk5IZbMJw4ycUoW9K9GUvCMrYuUa34MAcwYHIg== +viem@2.23.2, viem@2.x, viem@^2.1.1, viem@^2.21.44: + version "2.23.2" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.2.tgz#db395c8cf5f4fb5572914b962fb8ce5db09f681c" + integrity sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA== dependencies: "@noble/curves" "1.8.1" "@noble/hashes" "1.7.1" @@ -19891,13 +20036,13 @@ viem@2.7.14, viem@^2.15.1: "@scure/bip39" "1.5.4" abitype "1.0.8" isows "1.0.6" - ox "0.6.9" - ws "8.18.1" + ox "0.6.7" + ws "8.18.0" -viem@2.x, viem@^2.1.1, viem@^2.21.44: - version "2.23.2" - resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.2.tgz#db395c8cf5f4fb5572914b962fb8ce5db09f681c" - integrity sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA== +viem@2.7.14, viem@>=2.23.11, viem@^2.15.1: + version "2.26.0" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.26.0.tgz#e46aa05212875d4f306449b3bd594e97303abdde" + integrity sha512-Osht20EySRAcfMhCGAJaWuaREXM7y9vDloLvUTXuAfFq7JNGM5+o1wsE4LXw1KpRBrBliIAJjM+c2wMtMEvlCQ== dependencies: "@noble/curves" "1.8.1" "@noble/hashes" "1.7.1" @@ -19905,19 +20050,19 @@ viem@2.x, viem@^2.1.1, viem@^2.21.44: "@scure/bip39" "1.5.4" abitype "1.0.8" isows "1.0.6" - ox "0.6.7" - ws "8.18.0" + ox "0.6.9" + ws "8.18.1" -vite-node@1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.1.tgz#fff3ef309296ea03ceaa6ca4bb660922f5416c57" - integrity sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA== +vite-node@3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.0.9.tgz#97d0b062d3857fb8eaeb6cc6a1d400f847d4a15d" + integrity sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg== dependencies: cac "^6.7.14" - debug "^4.3.4" - pathe "^1.1.1" - picocolors "^1.0.0" - vite "^5.0.0" + debug "^4.4.0" + es-module-lexer "^1.6.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0" vite-plugin-node-polyfills@^0.22.0: version "0.22.0" @@ -19944,21 +20089,10 @@ vite-plugin-svgr@^4.2.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -vite@^5.0.0: - version "5.4.14" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.14.tgz#ff8255edb02134df180dcfca1916c37a6abe8408" - integrity sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA== - dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" - optionalDependencies: - fsevents "~2.3.3" - -vite@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/vite/-/vite-6.2.0.tgz#9dcb543380dab18d8384eb840a76bf30d78633f0" - integrity sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ== +"vite@^5.0.0 || ^6.0.0", vite@^6.2.4: + version "6.2.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.2.5.tgz#d093b5fe8eb96e594761584a966ab13f24457820" + integrity sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA== dependencies: esbuild "^0.25.0" postcss "^8.5.3" @@ -19966,31 +20100,31 @@ vite@^6.2.0: optionalDependencies: fsevents "~2.3.3" -vitest@^1.6.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.1.tgz#b4a3097adf8f79ac18bc2e2e0024c534a7a78d2f" - integrity sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag== - dependencies: - "@vitest/expect" "1.6.1" - "@vitest/runner" "1.6.1" - "@vitest/snapshot" "1.6.1" - "@vitest/spy" "1.6.1" - "@vitest/utils" "1.6.1" - acorn-walk "^8.3.2" - chai "^4.3.10" - debug "^4.3.4" - execa "^8.0.1" - local-pkg "^0.5.0" - magic-string "^0.30.5" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.5.0" - strip-literal "^2.0.0" - tinybench "^2.5.1" - tinypool "^0.8.3" - vite "^5.0.0" - vite-node "1.6.1" - why-is-node-running "^2.2.2" +vitest@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.0.9.tgz#8cf607d27dcaa12b9f21111f001a4e3e92511ba5" + integrity sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ== + dependencies: + "@vitest/expect" "3.0.9" + "@vitest/mocker" "3.0.9" + "@vitest/pretty-format" "^3.0.9" + "@vitest/runner" "3.0.9" + "@vitest/snapshot" "3.0.9" + "@vitest/spy" "3.0.9" + "@vitest/utils" "3.0.9" + chai "^5.2.0" + debug "^4.4.0" + expect-type "^1.1.0" + magic-string "^0.30.17" + pathe "^2.0.3" + std-env "^3.8.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinypool "^1.0.2" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0" + vite-node "3.0.9" + why-is-node-running "^2.3.0" vm-browserify@^1.0.1: version "1.1.2" @@ -20525,7 +20659,7 @@ which@^1.1.1, which@^1.3.1: dependencies: isexe "^2.0.0" -why-is-node-running@^2.2.2: +why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== @@ -20577,7 +20711,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20595,15 +20729,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -20827,11 +20952,6 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" - integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== - yoctocolors-cjs@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242"