From 01bd3e2188257c6c5e7892afad28e487d6793f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 24 Oct 2025 16:36:19 +0200 Subject: [PATCH 01/26] Integrate staking amount requirement - Added ExchangeApiKeysModule for managing exchange API keys. - Added validation for exchange names using ExchangeNameValidator. - Integrated AES encryption for storing API keys securely. - Implemented exchange-specific services (GateExchangeService, MexcExchangeService) for interacting with respective APIs. - Enhanced AuthService to check staking eligibility based on exchange balances. - Added configuration services for encryption and staking parameters. - Created migration for the exchange_api_keys table in the database. --- docker-setup/docker-compose.dev.yml | 4 +- docker-setup/docker-compose.yml | 2 +- .../server/src/common/constants/index.ts | 3 + .../server/src/common/validators/exchange.ts | 32 ++++ .../server/src/common/validators/index.ts | 1 + .../server/src/config/config.module.ts | 6 + .../src/config/encryption-config.service.ts | 14 ++ .../server/src/config/env-schema.ts | 13 ++ .../server/src/config/index.ts | 2 + .../src/config/staking-config.service.ts | 31 ++++ .../server/src/database/database.module.ts | 2 + .../1761295918414-exchangeApiKeys.ts | 27 +++ .../server/src/modules/auth/auth.module.ts | 4 + .../server/src/modules/auth/auth.service.ts | 41 ++++ .../encryption/aes-encryption.service.ts | 89 +++++++++ .../modules/encryption/encryption.module.ts | 5 +- .../src/modules/encryption/fixtures/index.ts | 4 + .../exchange-api-key.entity.ts | 43 +++++ .../exchange-api-keys.controller.ts | 116 ++++++++++++ .../exchange-api-keys.dto.ts | 36 ++++ .../exchange-api-keys.error-filter.ts | 57 ++++++ .../exchange-api-keys.errors.ts | 28 +++ .../exchange-api-keys.module.ts | 17 ++ .../exchange-api-keys.repository.ts | 89 +++++++++ .../exchange-api-keys.service.ts | 111 +++++++++++ .../src/modules/exchange-api-keys/index.ts | 8 + .../src/modules/exchange/exchange.module.ts | 14 ++ .../exchange/exchange.router.service.ts | 54 ++++++ .../src/modules/exchange/exchange.service.ts | 9 + .../modules/exchange/gate-exchange.service.ts | 175 ++++++++++++++++++ .../server/src/modules/exchange/index.ts | 4 + .../modules/exchange/mexc-exchange.service.ts | 109 +++++++++++ .../server/src/modules/web3/web3.service.ts | 14 +- 33 files changed, 1158 insertions(+), 6 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/common/validators/exchange.ts create mode 100644 packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/config/staking-config.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index b329016fb9..456c82f74e 100644 --- a/docker-setup/docker-compose.dev.yml +++ b/docker-setup/docker-compose.dev.yml @@ -91,7 +91,7 @@ services: timeout: 5s retries: 5 volumes: - - graph-node-db-data:/var/lib/postgresql/data:Z + - graph-node-db-data:/var/lib/postgresql:Z environment: POSTGRES_USER: *graph_db_user POSTGRES_PASSWORD: *graph_db_passwrod @@ -146,7 +146,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: POSTGRES_USER: *postgres_user POSTGRES_PASSWORD: *postgres_password diff --git a/docker-setup/docker-compose.yml b/docker-setup/docker-compose.yml index 5ecff8c246..3e6cae8d23 100644 --- a/docker-setup/docker-compose.yml +++ b/docker-setup/docker-compose.yml @@ -103,7 +103,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql environment: <<: *postgres_auth_vars healthcheck: 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 637d783de0..068b4ee38b 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -15,3 +15,6 @@ export const RESEND_EMAIL_VERIFICATION_PATH = export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; + +export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate']; +export type SupportedExchange = (typeof SUPPORTED_EXCHANGE_NAMES)[number]; diff --git a/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts b/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts new file mode 100644 index 0000000000..798f0f19b0 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts @@ -0,0 +1,32 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { + SUPPORTED_EXCHANGE_NAMES, + type SupportedExchange, +} from '@/common/constants'; + +const validExchangeNameSet = new Set( + SUPPORTED_EXCHANGE_NAMES, +); +export function isValidExchangeName(input: string): input is SupportedExchange { + return validExchangeNameSet.has(input as SupportedExchange); +} + +@ValidatorConstraint({ name: 'ExchangeName', async: false }) +export class ExchangeNameValidator implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + return isValidExchangeName(value); + } + + defaultMessage({ property }: ValidationArguments): string { + return `${property} must be one of the allowed values`; + } +} diff --git a/packages/apps/reputation-oracle/server/src/common/validators/index.ts b/packages/apps/reputation-oracle/server/src/common/validators/index.ts index e71aa847a1..cc4c0487c2 100644 --- a/packages/apps/reputation-oracle/server/src/common/validators/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/validators/index.ts @@ -8,6 +8,7 @@ import { ValidationOptions, } from 'class-validator'; +export * from './exchange'; export * from './password'; export * from './web3'; diff --git a/packages/apps/reputation-oracle/server/src/config/config.module.ts b/packages/apps/reputation-oracle/server/src/config/config.module.ts index eb3e0f4af0..1adb75e47f 100644 --- a/packages/apps/reputation-oracle/server/src/config/config.module.ts +++ b/packages/apps/reputation-oracle/server/src/config/config.module.ts @@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'; import { AuthConfigService } from './auth-config.service'; import { DatabaseConfigService } from './database-config.service'; import { EmailConfigService } from './email-config.service'; +import { EncryptionConfigService } from './encryption-config.service'; import { HCaptchaConfigService } from './hcaptcha-config.service'; import { KycConfigService } from './kyc-config.service'; import { NDAConfigService } from './nda-config.service'; @@ -12,6 +13,7 @@ import { ReputationConfigService } from './reputation-config.service'; import { S3ConfigService } from './s3-config.service'; import { ServerConfigService } from './server-config.service'; import { SlackConfigService } from './slack-config.service'; +import { StakingConfigService } from './staking-config.service'; import { Web3ConfigService } from './web3-config.service'; @Global() @@ -21,11 +23,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, @@ -35,11 +39,13 @@ import { Web3ConfigService } from './web3-config.service'; AuthConfigService, DatabaseConfigService, EmailConfigService, + EncryptionConfigService, HCaptchaConfigService, KycConfigService, NDAConfigService, PGPConfigService, ReputationConfigService, + StakingConfigService, S3ConfigService, ServerConfigService, SlackConfigService, diff --git a/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts new file mode 100644 index 0000000000..ff5e8feb7b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/encryption-config.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EncryptionConfigService { + constructor(private configService: ConfigService) {} + + /** + * 32-byte key for AES encrpytion + */ + get aesEncryptionKey(): string { + return this.configService.getOrThrow('AES_ENCRYPTION_KEY'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 0df0b9fd1f..149ad5deea 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -82,10 +82,23 @@ export const envValidator = Joi.object({ KYC_BASE_URL: Joi.string().uri({ scheme: ['http', 'https'] }), // Human App HUMAN_APP_SECRET_KEY: Joi.string().required(), + // Staking configuration + STAKING_ASSET: Joi.string().description( + 'Asset symbol to check for staking (default HMT)', + ), + STAKING_MIN_THRESHOLD: Joi.number() + .min(0) + .description('Minimum asset amount to qualify as staked'), + STAKING_TIMEOUT_MS: Joi.number() + .integer() + .min(100) + .description('HTTP timeout for exchange staking checks in ms'), // Slack notifications ABUSE_SLACK_WEBHOOK_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) .required(), ABUSE_SLACK_OAUTH_TOKEN: Joi.string().required(), ABUSE_SLACK_SIGNING_SECRET: Joi.string().required(), + // Encryption + AES_ENCRYPTION_KEY: Joi.string().required().length(32), }); diff --git a/packages/apps/reputation-oracle/server/src/config/index.ts b/packages/apps/reputation-oracle/server/src/config/index.ts index 2e76538048..5d1318cd56 100644 --- a/packages/apps/reputation-oracle/server/src/config/index.ts +++ b/packages/apps/reputation-oracle/server/src/config/index.ts @@ -3,11 +3,13 @@ export * from './env-schema'; export { AuthConfigService } from './auth-config.service'; export { DatabaseConfigService } from './database-config.service'; export { EmailConfigService } from './email-config.service'; +export { EncryptionConfigService } from './encryption-config.service'; export { HCaptchaConfigService } from './hcaptcha-config.service'; export { KycConfigService } from './kyc-config.service'; export { NDAConfigService } from './nda-config.service'; export { PGPConfigService } from './pgp-config.service'; export { ReputationConfigService } from './reputation-config.service'; +export { StakingConfigService } from './staking-config.service'; export { S3ConfigService } from './s3-config.service'; export { ServerConfigService } from './server-config.service'; export { Web3ConfigService, Web3Network } from './web3-config.service'; diff --git a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts new file mode 100644 index 0000000000..94b5d6a414 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class StakingConfigService { + constructor(private readonly configService: ConfigService) {} + + /** + * Default asset symbol to check for staking eligibility. + * Default: 'HMT' + */ + get asset(): string { + return this.configService.get('STAKING_ASSET') || 'HMT'; + } + + /** + * Minimum threshold (asset units) required for staking eligibility. + * Default: 1000 + */ + get minThreshold(): number { + return Number(this.configService.get('STAKING_MIN_THRESHOLD')) || 1000; + } + + /** + * Optional per-exchange HTTP timeout, in milliseconds. + * Default: 2000 + */ + get timeoutMs(): number { + return Number(this.configService.get('STAKING_TIMEOUT_MS')) || 2000; + } +} diff --git a/packages/apps/reputation-oracle/server/src/database/database.module.ts b/packages/apps/reputation-oracle/server/src/database/database.module.ts index 093c5d7abf..c8bb6dc8f3 100644 --- a/packages/apps/reputation-oracle/server/src/database/database.module.ts +++ b/packages/apps/reputation-oracle/server/src/database/database.module.ts @@ -13,6 +13,7 @@ import { TokenEntity } from '@/modules/auth/token.entity'; import { CronJobEntity } from '@/modules/cron-job/cron-job.entity'; import { EscrowCompletionEntity } from '@/modules/escrow-completion/escrow-completion.entity'; import { EscrowPayoutsBatchEntity } from '@/modules/escrow-completion/escrow-payouts-batch.entity'; +import { ExchangeApiKeyEntity } from '@/modules/exchange-api-keys'; import { KycEntity } from '@/modules/kyc/kyc.entity'; import { QualificationEntity } from '@/modules/qualification/qualification.entity'; import { UserQualificationEntity } from '@/modules/qualification/user-qualification.entity'; @@ -71,6 +72,7 @@ import { TypeOrmLoggerModule, TypeOrmLoggerService } from './typeorm'; OutgoingWebhookEntity, EscrowCompletionEntity, EscrowPayoutsBatchEntity, + ExchangeApiKeyEntity, ReputationEntity, TokenEntity, UserEntity, diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts new file mode 100644 index 0000000000..6a5d2daed4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExchangeApiKeys1761295918414 implements MigrationInterface { + name = 'ExchangeApiKeys1761295918414'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "hmt"."exchange_api_keys" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, "exchange_name" character varying(20) NOT NULL, "api_key" character varying(1000) NOT NULL, "secret_key" character varying(10000) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "PK_3751a8a0ef5354b32b06ea43983" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8dcd9d18790ca62ebf1fb40cd3" ON "hmt"."exchange_api_keys" ("user_id", "exchange_name") `, + ); + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" ADD CONSTRAINT "FK_96ee74195b058a1b55afc49f673" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "hmt"."exchange_api_keys" DROP CONSTRAINT "FK_96ee74195b058a1b55afc49f673"`, + ); + await queryRunner.query( + `DROP INDEX "hmt"."IDX_8dcd9d18790ca62ebf1fb40cd3"`, + ); + await queryRunner.query(`DROP TABLE "hmt"."exchange_api_keys"`); + } +} 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 f613211882..bb6a77d917 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 @@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt'; import { AuthConfigService } from '@/config'; import { HCaptchaModule } from '@/integrations/hcaptcha'; import { EmailModule } from '@/modules/email'; +import { ExchangeModule } from '@/modules/exchange'; +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; import { UserModule } from '@/modules/user'; import { Web3Module } from '@/modules/web3'; @@ -27,6 +29,8 @@ import { TokenRepository } from './token.repository'; }), Web3Module, HCaptchaModule, + ExchangeModule, + ExchangeApiKeysModule, EmailModule, ], providers: [JwtHttpStrategy, AuthService, TokenRepository], 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 b116b089ae..6b76c169d3 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 @@ -7,10 +7,13 @@ import { AuthConfigService, NDAConfigService, ServerConfigService, + StakingConfigService, Web3ConfigService, } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; +import { ExchangeRouterService } from '@/modules/exchange'; +import { ExchangeApiKeysRepository } from '@/modules/exchange-api-keys'; import { OperatorStatus, SiteKeyRepository, @@ -21,6 +24,7 @@ import { type OperatorUserEntity, type Web2UserEntity, } from '@/modules/user'; +import { Web3Service } from '@/modules/web3'; import * as httpUtils from '@/utils/http'; import * as securityUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -55,7 +59,11 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly exchangeRouterService: ExchangeRouterService, + private readonly stakingConfigService: StakingConfigService, private readonly web3ConfigService: Web3ConfigService, + private readonly web3Service: Web3Service, ) {} async signup(email: string, password: string): Promise { @@ -244,6 +252,38 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } + let stakeEligible = false; + const exchanges = + await this.exchangeApiKeysRepository.listExchangesByUserId(userEntity.id); + + let totalStake = 0; + + if (exchanges.length) { + const exchangeBalance = + await this.exchangeRouterService.getAccountBalance( + exchanges[0], + userEntity.id, + this.stakingConfigService.asset, + ); + + if (exchangeBalance >= this.stakingConfigService.minThreshold) { + totalStake = exchangeBalance; + } else if (userEntity.evmAddress) { + const onChainStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + totalStake = exchangeBalance + onChainStake; + } else { + totalStake = exchangeBalance; + } + } else if (userEntity.evmAddress) { + totalStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + } + + stakeEligible = totalStake >= this.stakingConfigService.minThreshold; + const jwtPayload = { email: userEntity.email, status: userEntity.status, @@ -254,6 +294,7 @@ export class AuthService { nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, reputation_network: this.web3ConfigService.operatorAddress, + stake_eligible: stakeEligible, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( (userQualification) => userQualification.qualification?.reference, diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts new file mode 100644 index 0000000000..e0e9d578f9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts @@ -0,0 +1,89 @@ +import crypto from 'crypto'; + +import { Injectable } from '@nestjs/common'; + +import { EncryptionConfigService } from '@/config'; +import logger from '@/logger'; + +const ALGORITHM = 'aes-256-gcm'; +const GCM_IV_LENGTH_BYTES = 12; + +type EncryptionOutput = { + encrypted: Buffer; + iv: Buffer; + authTag: Buffer; +}; + +@Injectable() +export class AesEncryptionService { + private readonly logger = logger.child({ + context: AesEncryptionService.name, + }); + + constructor( + private readonly encryptionConfigService: EncryptionConfigService, + ) {} + + private composeEnvelopeString({ + encrypted, + authTag, + iv, + }: EncryptionOutput): string { + return `${iv.toString('hex')}:${encrypted.toString('hex')}:${authTag.toString('hex')}`; + } + + private parseEnvelopeString(envelope: string): EncryptionOutput { + const [iv, encrypted, authTag] = envelope.split(':'); + + if (!iv || !encrypted || !authTag) { + throw new Error('Invalid AES envelope'); + } + + return { + iv: Buffer.from(iv, 'hex'), + encrypted: Buffer.from(encrypted, 'hex'), + authTag: Buffer.from(authTag, 'hex'), + }; + } + + async encrypt(data: Buffer): Promise { + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const iv = crypto.randomBytes(GCM_IV_LENGTH_BYTES); + + const cipher = crypto.createCipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return this.composeEnvelopeString({ + iv, + encrypted, + authTag, + }); + } + + async decrypt(envelope: string): Promise { + const { iv, encrypted, authTag } = this.parseEnvelopeString(envelope); + + const encryptionKey = this.encryptionConfigService.aesEncryptionKey; + + const decipher = crypto.createDecipheriv( + ALGORITHM, + Buffer.from(encryptionKey), + iv, + ); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts index 623c981886..6583f9d356 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/encryption.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { Web3Module } from '@/modules/web3'; +import { AesEncryptionService } from './aes-encryption.service'; import { PgpEncryptionService } from './pgp-encryption.service'; @Module({ imports: [Web3Module], - providers: [PgpEncryptionService], - exports: [PgpEncryptionService], + providers: [AesEncryptionService, PgpEncryptionService], + exports: [AesEncryptionService, PgpEncryptionService], }) export class EncryptionModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts new file mode 100644 index 0000000000..0a0ba41458 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -0,0 +1,4 @@ +export const mockEncryptionConfigService = { + // 32-byte key for AES-256-GCM tests + aesEncryptionKey: '12345678901234567890123456789012', +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts new file mode 100644 index 0000000000..55f741c5af --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts @@ -0,0 +1,43 @@ +import { + BeforeInsert, + BeforeUpdate, + Column, + Entity, + Index, + ManyToOne, +} from 'typeorm'; + +import { DATABASE_SCHEMA_NAME } from '@/common/constants'; +import { BaseEntity } from '@/database'; + +import { UserEntity } from '../user'; + +@Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) +@Index(['userId', 'exchangeName'], { unique: true }) +export class ExchangeApiKeyEntity extends BaseEntity { + @Column('varchar', { length: 20 }) + exchangeName: string; + + @Column('varchar', { length: 1000 }) + apiKey: string; + + @Column('varchar', { length: 10000 }) + secretKey: string; + + @ManyToOne('UserEntity', { persistence: false, onDelete: 'CASCADE' }) + user?: UserEntity; + + @Column() + userId: number; + + @BeforeInsert() + protected beforeInsert(): void { + this.createdAt = new Date(); + this.updatedAt = this.createdAt; + } + + @BeforeUpdate() + protected beforeUpdate(): void { + this.updatedAt = new Date(); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..7804fdf2dd --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,116 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + Param, + Post, + Req, + UseFilters, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import type { RequestWithUser } from '@/common/types'; +import Environment from '@/utils/environment'; + +import { + EncrollExchangeApiKeysParamsDto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysResponseDto, + ExchangeNameParamDto, +} from './exchange-api-keys.dto'; +import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; + +@ApiTags('Exchange API Keys') +@ApiBearerAuth() +@UseFilters(ExchangeApiKeysControllerErrorsFilter) +@Controller('exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + ) {} + + @ApiOperation({ + summary: 'Enroll API keys for exchange', + description: + 'Enrolls API keys for provided exchange. If keys already exist for exchange - updates them', + }) + @ApiResponse({ + status: 200, + description: 'Exchange API keys enrolled', + type: EnrollExchangeApiKeysResponseDto, + }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Req() request: RequestWithUser, + @Param() params: EncrollExchangeApiKeysParamsDto, + @Body() data: EnrollExchangeApiKeysDto, + ): Promise { + const userId = request.user.id; + const exchangeName = params.exchangeName; + + const key = await this.exchangeApiKeysService.enroll({ + userId, + exchangeName, + apiKey: data.apiKey, + secretKey: data.secretKey, + }); + + return { id: key.id }; + } + + @ApiOperation({ + summary: 'Delete API keys for exchange', + }) + @ApiResponse({ + status: 204, + description: 'Exchange API keys deleted', + }) + @HttpCode(204) + @Delete('/:exchange_name') + async delete( + @Req() request: RequestWithUser, + @Param() params: EncrollExchangeApiKeysParamsDto, + ): Promise { + const userId = request.user.id; + const exchangeName = params.exchangeName; + + await this.exchangeApiKeysRepository.deleteByUserAndExchange( + userId, + exchangeName, + ); + } + + @ApiOperation({ + summary: 'Retreive API keys for exchange', + description: + 'This functionality is purely for dev solely and works only in non-production environments', + }) + @Get('/:exchange_name') + async retrieve( + @Req() request: RequestWithUser, + @Param() params: ExchangeNameParamDto, + ): Promise { + if (!Environment.isDevelopment()) { + throw new ForbiddenException(); + } + + const userId = request.user.id; + const exchangeName = params.exchangeName; + + return this.exchangeApiKeysService.retrieve(userId, exchangeName); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts new file mode 100644 index 0000000000..e60691a011 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength, Validate } from 'class-validator'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; +import { ExchangeNameValidator } from '@/common/validators'; + +export class EnrollExchangeApiKeysDto { + @ApiProperty({ name: 'api_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + apiKey: string; + + @ApiProperty({ name: 'secret_key' }) + @IsString() + @IsNotEmpty() + @MaxLength(5000) + secretKey: string; +} + +export class ExchangeNameParamDto { + @ApiProperty({ + name: 'exchange_name', + enum: SUPPORTED_EXCHANGE_NAMES, + }) + @Expose({ name: 'exchange_name' }) + @Validate(ExchangeNameValidator) + exchangeName: string; +} +export class EncrollExchangeApiKeysParamsDto extends ExchangeNameParamDto {} + +export class EnrollExchangeApiKeysResponseDto { + @ApiProperty() + id: number; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts new file mode 100644 index 0000000000..7fcf506afe --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -0,0 +1,57 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; + +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { UserNotFoundError } from '../user'; + +@Catch( + UserNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, + ExchangeApiKeyNotFoundError, +) +export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { + private readonly logger = logger.child({ + context: ExchangeApiKeysControllerErrorsFilter.name, + }); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + if (exception instanceof UserNotFoundError) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } else if ( + exception instanceof IncompleteKeySuppliedError || + exception instanceof KeyAuthorizationError || + exception instanceof ActiveExchangeApiKeyExistsError + ) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + // } else if (exception instanceof ExchangeApiClientError) { + // status = HttpStatus.SERVICE_UNAVAILABLE; + } else if (exception instanceof ExchangeApiKeyNotFoundError) { + status = HttpStatus.NOT_FOUND; + } + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts new file mode 100644 index 0000000000..754e6e19f3 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts @@ -0,0 +1,28 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiKeyNotFoundError extends BaseError { + constructor( + readonly userId: number, + readonly exchangeName: string, + ) { + super('Exchange API key not found'); + } +} + +export class IncompleteKeySuppliedError extends BaseError { + constructor(readonly exchangeName: string) { + super('Incomplete credentials supplied for exchange'); + } +} + +export class KeyAuthorizationError extends BaseError { + constructor(readonly exchangeName: string) { + super("Provided API key can't be authorized on exchange"); + } +} + +export class ActiveExchangeApiKeyExistsError extends BaseError { + constructor(readonly userId: number) { + super('User already has an active exchange API key'); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..06a318b2a4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,17 @@ +import { Module, forwardRef } from '@nestjs/common'; + +import { EncryptionModule } from '@/modules/encryption'; +import { ExchangeModule } from '@/modules/exchange/exchange.module'; + +import { ExchangeApiKeysController } from './exchange-api-keys.controller'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { ExchangeApiKeysService } from './exchange-api-keys.service'; +import { UserModule } from '../user'; + +@Module({ + imports: [forwardRef(() => ExchangeModule), EncryptionModule, UserModule], + providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], + controllers: [ExchangeApiKeysController], + exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts new file mode 100644 index 0000000000..403b984840 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; + +type FindOptions = { + relations?: FindManyOptions['relations']; +}; + +@Injectable() +export class ExchangeApiKeysRepository extends Repository { + constructor(dataSource: DataSource) { + super(ExchangeApiKeyEntity, dataSource.createEntityManager()); + } + + async findOneByUserId( + userId: number, + options: FindOptions = {}, + ): Promise { + if (!userId) { + throw new Error('Invalid arguments'); + } + return this.findOne({ + where: { userId }, + relations: options.relations, + }); + } + + async listExchangesByUserId(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + const results: Array> = + await this.find({ + where: { + userId, + }, + select: { + exchangeName: true, + }, + }); + + return results.map((r) => r.exchangeName); + } + + async deleteByUserAndExchange( + userId: number, + exchangeName: string, + ): Promise { + if (!userId) { + throw new Error('userId is required'); + } + if (!exchangeName) { + throw new Error('exchangeName is required'); + } + + await this.delete({ userId, exchangeName }); + } + + async findOneByUserAndExchange( + userId: number, + exchangeName: string, + ): Promise { + if (!userId) { + throw new Error('userId is required'); + } + if (!exchangeName) { + throw new Error('exchangeName is required'); + } + + return this.findOneBy({ + userId, + exchangeName, + }); + } + + async findByUserId(userId: number): Promise { + if (!userId) { + throw new Error('userId is required'); + } + + return this.find({ + where: { + userId, + }, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..fc64955525 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,111 @@ +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { isValidExchangeName } from '@/common/validators/exchange'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { ExchangeRouterService } from '@/modules/exchange/exchange.router.service'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { + ExchangeApiKeyNotFoundError, + IncompleteKeySuppliedError, + KeyAuthorizationError, + ActiveExchangeApiKeyExistsError, +} from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { UserNotFoundError, UserRepository } from '../user'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, + private readonly userRepository: UserRepository, + private readonly aesEncryptionService: AesEncryptionService, + @Inject(forwardRef(() => ExchangeRouterService)) + private readonly exchangeRouterService: ExchangeRouterService, + ) {} + + async enroll(input: { + userId: number; + exchangeName: string; + apiKey: string; + secretKey: string; + }): Promise { + const { userId, exchangeName, apiKey, secretKey } = input; + + if (!userId || !apiKey || !secretKey) { + throw new Error('Invalid arguments'); + } + + if (!isValidExchangeName(exchangeName)) { + throw new Error('Exchange name is not valid'); + } + + const currentKeys = + await this.exchangeApiKeysRepository.findOneByUserId(userId); + if (currentKeys) { + throw new ActiveExchangeApiKeyExistsError(userId); + } + + const creds = { apiKey, secret: secretKey }; + if ( + !this.exchangeRouterService.checkRequiredCredentials(exchangeName, creds) + ) { + throw new IncompleteKeySuppliedError(exchangeName); + } + + const hasRequiredAccess = + await this.exchangeRouterService.checkRequiredAccess(exchangeName, creds); + if (!hasRequiredAccess) { + throw new KeyAuthorizationError(exchangeName); + } + + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const enrolledKey = new ExchangeApiKeyEntity(); + enrolledKey.userId = userId; + enrolledKey.exchangeName = exchangeName; + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.encrypt(Buffer.from(apiKey)), + this.aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + enrolledKey.apiKey = encryptedApiKey; + enrolledKey.secretKey = encryptedSecretKey; + enrolledKey.updatedAt = new Date(); + + await this.exchangeApiKeysRepository.upsert(enrolledKey, [ + 'userId', + 'exchangeName', + ]); + + return enrolledKey; + } + + async retrieve( + userId: number, + exchangeName: string, + ): Promise<{ id: number; apiKey: string; secretKey: string }> { + const entity = + await this.exchangeApiKeysRepository.findOneByUserAndExchange( + userId, + exchangeName, + ); + if (!entity) { + throw new ExchangeApiKeyNotFoundError(userId, exchangeName); + } + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + this.aesEncryptionService.decrypt(entity.apiKey), + this.aesEncryptionService.decrypt(entity.secretKey), + ]); + + return { + id: entity.id, + apiKey: decryptedApiKey.toString(), + secretKey: decryptedSecretKey.toString(), + }; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts new file mode 100644 index 0000000000..2736f37d6b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/index.ts @@ -0,0 +1,8 @@ +export { ExchangeApiKeysModule } from './exchange-api-keys.module'; +export { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +export { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +export { ExchangeApiKeysService } from './exchange-api-keys.service'; +export { + ExchangeApiKeyNotFoundError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts new file mode 100644 index 0000000000..7fca563319 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -0,0 +1,14 @@ +import { Module, forwardRef } from '@nestjs/common'; + +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; + +import { ExchangeRouterService } from './exchange.router.service'; +import { GateExchangeService } from './gate-exchange.service'; +import { MexcExchangeService } from './mexc-exchange.service'; + +@Module({ + imports: [forwardRef(() => ExchangeApiKeysModule)], + providers: [MexcExchangeService, GateExchangeService, ExchangeRouterService], + exports: [ExchangeRouterService], +}) +export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts new file mode 100644 index 0000000000..3eb80aa4f8 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; +import { GateExchangeService } from './gate-exchange.service'; +import { MexcExchangeService } from './mexc-exchange.service'; + +@Injectable() +export class ExchangeRouterService { + private servicesMap: Record; + + constructor( + private readonly mexcService: MexcExchangeService, + private readonly gateService: GateExchangeService, + ) { + this.servicesMap = { + mexc: this.mexcService, + gate: this.gateService, + }; + } + + checkRequiredCredentials( + exchangeName: string, + creds: ExchangeCredentials, + ): boolean { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.checkRequiredCredentials(creds); + } + + async checkRequiredAccess( + exchangeName: string, + creds: ExchangeCredentials, + ): Promise { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.checkRequiredAccess(creds); + } + + async getAccountBalance( + exchangeName: string, + userId: number, + asset?: string, + ): Promise { + const service = this.servicesMap[exchangeName.toLowerCase()]; + if (!service) { + throw new Error(`Exchange service not found for ${exchangeName}`); + } + return service.getAccountBalance(userId, asset); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts new file mode 100644 index 0000000000..41bfb7721e --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts @@ -0,0 +1,9 @@ +export type ExchangeCredentials = { apiKey: string; secret: string }; + +export abstract class ExchangeService { + abstract checkRequiredCredentials(creds: ExchangeCredentials): boolean; + + abstract checkRequiredAccess(creds: ExchangeCredentials): Promise; + + abstract getAccountBalance(userId: number, asset?: string): Promise; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts new file mode 100644 index 0000000000..ea1b6abd8b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts @@ -0,0 +1,175 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { StakingConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import Environment from '@/utils/environment'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; + +const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; + +function signGateRequest( + method: string, + path: string, + query: string, + body: string, + secret: string, + ts: string, +): string { + const bodyHash = createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + return createHmac('sha512', secret).update(payload).digest('hex'); +} + +@Injectable() +export class GateExchangeService extends ExchangeService { + readonly id = 'gate' as const; + private readonly logger = logger.child({ context: GateExchangeService.name }); + private readonly apiBaseUrl = + process.env.GATE_API_BASE_URL && process.env.GATE_API_BASE_URL.length > 0 + ? process.env.GATE_API_BASE_URL + : Environment.isDevelopment() + ? DEVELOP_GATE_API_BASE_URL + : GATE_API_BASE_URL; + + constructor( + @Inject(forwardRef(() => ExchangeApiKeysService)) + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly stakingConfigService: StakingConfigService, + ) { + super(); + } + + checkRequiredCredentials(creds: ExchangeCredentials): boolean { + return Boolean(creds?.apiKey && creds?.secret); + } + + async checkRequiredAccess(creds: ExchangeCredentials): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = ''; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const signature = signGateRequest( + method, + `/api/v4${path}`, + query, + body, + creds.secret, + ts, + ); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(`${this.apiBaseUrl}${path}`, { + method, + headers: { + KEY: creds.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + console.warn('Gate access check http error', { + status: res.status, + body: text, + }); + return false; + } + return true; + } catch (error) { + this.logger.error('Gate access check failed', { + error, + }); + return false; + } finally { + clearTimeout(timeout); + } + } + + async getAccountBalance(userId: number, asset = 'HMT'): Promise { + const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); + + const method = 'GET'; + const path = '/spot/accounts'; + const query = `currency=${encodeURIComponent(asset)}`; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const requestPath = `/api/v4${path}`; + const signature = signGateRequest( + method, + requestPath, + query, + body, + creds.secretKey, + ts, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method, + headers: { + KEY: creds.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + this.logger.error('Gate getAccountBalance http error', { + status: res.status, + body: text, + }); + return 0; + } + const data = (await res.json()) as Array<{ + currency: string; + available: string; + locked?: string; + freeze?: string; + }>; + + const normalize = (item: { + currency: string; + available: string; + locked?: string; + freeze?: string; + }) => { + const free = parseFloat(item.available) || 0; + const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; + return free + locked; + }; + + const entry = data.find((d) => d.currency === asset); + return entry ? normalize(entry) : 0; + } catch (error) { + this.logger.error('Gate getAccountBalance failed', { + error, + }); + return 0; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts new file mode 100644 index 0000000000..49bc0304e4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -0,0 +1,4 @@ +export { ExchangeModule } from './exchange.module'; +export { ExchangeRouterService } from './exchange.router.service'; +export { MexcExchangeService } from './mexc-exchange.service'; +export { GateExchangeService } from './gate-exchange.service'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts new file mode 100644 index 0000000000..65b976f971 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts @@ -0,0 +1,109 @@ +import { createHmac } from 'node:crypto'; + +import { Inject, Injectable, forwardRef } from '@nestjs/common'; + +import { StakingConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; + +import { ExchangeCredentials, ExchangeService } from './exchange.service'; + +const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; + +@Injectable() +export class MexcExchangeService extends ExchangeService { + readonly id = 'mexc' as const; + readonly recvWindow = 5000; + private readonly logger = logger.child({ context: MexcExchangeService.name }); + + constructor( + @Inject(forwardRef(() => ExchangeApiKeysService)) + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly stakingConfigService: StakingConfigService, + ) { + super(); + } + + checkRequiredCredentials(creds: ExchangeCredentials): boolean { + return Boolean(creds?.apiKey && creds?.secret); + } + + async checkRequiredAccess(creds: ExchangeCredentials): Promise { + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = createHmac('sha256', creds.secret) + .update(query) + .digest('hex'); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method: 'GET', + headers: { 'X-MEXC-APIKEY': creds.apiKey }, + signal: controller.signal, + } as RequestInit); + return res.ok; + } catch (error) { + this.logger.error('MEXC access check failed', { + error, + }); + return false; + } finally { + clearTimeout(timeout); + } + } + + async getAccountBalance(userId: number, asset = 'HMT'): Promise { + const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); + + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = createHmac('sha256', creds.secretKey) + .update(query) + .digest('hex'); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + this.stakingConfigService.timeoutMs, + ); + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-MEXC-APIKEY': creds.apiKey, + }, + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Http error ${res.status}: ${text}`); + } + const data = (await res.json()) as { + balances?: Array<{ asset: string; free: string; locked: string }>; + }; + const balances = data.balances || []; + const entry = balances.find((b) => b.asset === asset); + if (!entry) return 0; + const total = + (parseFloat(entry.free || '0') || 0) + + (parseFloat(entry.locked || '0') || 0); + return total; + } catch (error) { + this.logger.error('MEXC getAccountBalance failed', { + error, + }); + return 0; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index e1bc6b34ae..58c5801ef8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -1,5 +1,5 @@ import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { ChainId } from '@human-protocol/sdk'; +import { ChainId, StakingClient } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; @@ -106,4 +106,16 @@ export class Web3Service { throw new Error('Failed to fetch token decimals'); } } + + async getStakedBalance(address: string): Promise { + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.getSigner(chainId).provider; + + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); + + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } } From 23be3beaaedcab493959dfdd7bee758abf7e4a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 24 Oct 2025 18:52:21 +0200 Subject: [PATCH 02/26] Add stake eligibility checks in Human App --- .../src/common/guards/strategy/jwt.http.ts | 2 + .../src/common/utils/jwt-token.model.ts | 2 + .../job-assignment.controller.ts | 23 +++++++ .../jobs-discovery.controller.ts | 65 +++++++++++-------- .../oracle-discovery.controller.ts | 4 ++ 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts index 1a06cd60ee..c72ada8f12 100644 --- a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts +++ b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts @@ -44,6 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { status: string; wallet_address: string; reputation_network: string; + stake_eligible?: boolean; qualifications?: string[]; site_key?: string; email?: string; @@ -58,6 +59,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { wallet_address: payload.wallet_address, status: payload.status, reputation_network: payload.reputation_network, + stake_eligible: payload.stake_eligible, qualifications: payload.qualifications, site_key: payload.site_key, email: payload.email, 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 85da60b3c1..aea8e277c3 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 @@ -10,6 +10,8 @@ export class JwtUserData { @AutoMap() reputation_network: string; @AutoMap() + stake_eligible?: boolean; + @AutoMap() email?: string; @AutoMap() qualifications?: string[]; diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 03cfa10059..6f0090c85b 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -9,6 +9,7 @@ import { Post, Query, Request, + ForbiddenException, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; @@ -46,6 +47,10 @@ export class JobAssignmentController { @Body() jobAssignmentDto: JobAssignmentDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } // TODO: temporal - THIRSTYFI if (jobAssignmentDto.escrow_address === 'thirstyfi-task') { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -103,6 +108,16 @@ export class JobAssignmentController { @Query() jobsAssignmentParamsDto: JobsFetchParamsDto, @Request() req: RequestWithUser, ): Promise { + // Require stake eligibility + if (!req.user?.stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } // TODO: temporal - THIRSTYFI if ( jobsAssignmentParamsDto.oracle_address === @@ -166,6 +181,10 @@ export class JobAssignmentController { @Body() dto: ResignJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = this.mapper.map(dto, ResignJobDto, ResignJobCommand); command.token = req.token; return this.service.resignJob(command); @@ -180,6 +199,10 @@ export class JobAssignmentController { @Body() dto: RefreshJobDto, @Request() req: RequestWithUser, ) { + // Require stake eligibility + if (!req.user?.stake_eligible) { + throw new ForbiddenException('Stake requirement not met'); + } const command = new JobsFetchParamsCommand(); command.oracleAddress = dto.oracle_address; command.token = req.token; diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts index abfc928d02..2efa2dd23f 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts @@ -34,7 +34,7 @@ export class JobsDiscoveryController { private readonly service: JobsDiscoveryService, private readonly environmentConfigService: EnvironmentConfigService, @InjectMapper() private readonly mapper: Mapper, - ) { } + ) {} @Get('/jobs') @ApiOperation({ @@ -51,6 +51,18 @@ export class JobsDiscoveryController { HttpStatus.FORBIDDEN, ); } + + // Require stake eligibility + if (!req.user?.stake_eligible) { + return { + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }; + } + // TODO: temporal - THIRSTYFI if ( jobsDiscoveryParamsDto.oracle_address === @@ -70,32 +82,33 @@ export class JobsDiscoveryController { return (data?.id ?? 0 > 0) ? { - page: 0, - page_size: 1, - total_pages: 1, - total_results: 0, - results: [], - } + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + } : { - page: 0, - page_size: 1, - total_pages: 1, - total_results: 1, - results: [ - { - chain_id: ChainId.POLYGON, - escrow_address: 'thirstyfi-task', - job_type: 'thirstyfi', - job_description: 'Check job description at https://thirsty.fi/blog/campaign-human-protocol', - reward_amount: '5 - 50', - reward_token: 'USDT', - status: JobStatus.ACTIVE, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - qualifications: [], - }, - ], - }; + page: 0, + page_size: 1, + total_pages: 1, + total_results: 1, + results: [ + { + chain_id: ChainId.POLYGON, + escrow_address: 'thirstyfi-task', + job_type: 'thirstyfi', + job_description: + 'Check job description at https://thirsty.fi/blog/campaign-human-protocol', + reward_amount: '5 - 50', + reward_token: 'USDT', + status: JobStatus.ACTIVE, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + qualifications: [], + }, + ], + }; } return { diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts index d0584d2635..eff081faf3 100644 --- a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts @@ -47,6 +47,10 @@ export class OracleDiscoveryController { HttpStatus.FORBIDDEN, ); } + if (!req.user?.stake_eligible) { + return []; + } + const command = this.mapper.map(query, GetOraclesQuery, GetOraclesCommand); const oracles = await this.oracleDiscoveryService.getOracles(command); From bc964902c384413bb8d0c925544c85772dcacef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 15:35:17 +0100 Subject: [PATCH 03/26] - Added new ExchangeApiKeys module in Human App. - Introduced ExchangeClientFactory to handle exchange client creation for different exchanges. - Implemented GateExchangeClient and MexcExchangeClient for API interactions. - Removed deprecated ExchangeRouterService and related services. - Updated error handling to include ExchangeApiClientError for better clarity. --- docker-setup/docker-compose.yml | 2 +- .../apps/human-app/server/src/app.module.ts | 4 + .../common/config/gateway-config.service.ts | 15 ++ .../server/src/common/enums/http-method.ts | 1 + .../enums/reputation-oracle-endpoints.ts | 3 + .../src/common/guards/strategy/jwt.http.ts | 4 +- .../src/common/utils/jwt-token.model.ts | 2 +- .../reputation-oracle.gateway.ts | 43 +++++ .../reputation-oracle.mapper.profile.ts | 13 ++ .../exchange-api-keys.controller.ts | 80 ++++++++ .../exchange-api-keys.mapper.profile.ts | 20 ++ .../exchange-api-keys.module.ts | 13 ++ .../exchange-api-keys.service.ts | 32 ++++ .../model/exchange-api-keys.model.ts | 45 +++++ .../job-assignment.controller.ts | 8 +- .../jobs-discovery.controller.ts | 2 +- .../oracle-discovery.controller.ts | 12 +- .../server/scripts/setup-kv-store.ts | 5 +- .../server/scripts/setup-staking.ts | 7 +- .../server/src/common/constants/index.ts | 4 +- .../interceptors/transform.interceptor.ts | 4 + ...ys.ts => 1761653939799-exchangeApiKeys.ts} | 8 +- .../server/src/modules/auth/auth.service.ts | 86 +++++---- .../encryption/aes-encryption.service.ts | 11 ++ .../src/modules/encryption/fixtures/index.ts | 4 +- .../exchange-api-key.entity.ts | 25 +-- .../exchange-api-keys.controller.ts | 63 ++++--- .../exchange-api-keys.dto.ts | 11 +- .../exchange-api-keys.error-filter.ts | 5 +- .../exchange-api-keys.errors.ts | 10 +- .../exchange-api-keys.module.ts | 4 +- .../exchange-api-keys.repository.ts | 75 +------- .../exchange-api-keys.service.ts | 72 +++---- .../server/src/modules/exchange/errors.ts | 3 + .../exchange/exchange-client.factory.ts | 31 ++++ .../src/modules/exchange/exchange.module.ts | 13 +- .../exchange/exchange.router.service.ts | 54 ------ .../src/modules/exchange/exchange.service.ts | 9 - .../modules/exchange/gate-exchange.client.ts | 172 +++++++++++++++++ .../modules/exchange/gate-exchange.service.ts | 175 ------------------ .../server/src/modules/exchange/index.ts | 4 +- .../modules/exchange/mexc-exchange.client.ts | 121 ++++++++++++ .../modules/exchange/mexc-exchange.service.ts | 109 ----------- .../server/src/modules/exchange/types.ts | 16 ++ 44 files changed, 816 insertions(+), 584 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts rename packages/apps/reputation-oracle/server/src/database/migrations/{1761295918414-exchangeApiKeys.ts => 1761653939799-exchangeApiKeys.ts} (79%) create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/types.ts diff --git a/docker-setup/docker-compose.yml b/docker-setup/docker-compose.yml index 3e6cae8d23..5ecff8c246 100644 --- a/docker-setup/docker-compose.yml +++ b/docker-setup/docker-compose.yml @@ -103,7 +103,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql + - postgres-data:/var/lib/postgresql/data environment: <<: *postgres_auth_vars healthcheck: diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index 529154b0ed..fd0187533e 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -58,6 +58,8 @@ import { OperatorController } from './modules/user-operator/operator.controller' import { OperatorModule } from './modules/user-operator/operator.module'; import { WorkerController } from './modules/user-worker/worker.controller'; import { WorkerModule } from './modules/user-worker/worker.module'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys/exchange-api-keys.module'; +import { ExchangeApiKeysController } from './modules/exchange-api-keys/exchange-api-keys.controller'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -147,6 +149,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAModule, AbuseModule, GovernanceModule, + ExchangeApiKeysModule, ], controllers: [ AppController, @@ -162,6 +165,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); NDAController, AbuseController, GovernanceController, + ExchangeApiKeysController, ], exports: [HttpModule], providers: [ 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 7f7c548fb2..facb921ecf 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 @@ -141,6 +141,21 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.POST, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.DELETE, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE]: { + endpoint: '/exchange-api-keys', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { diff --git a/packages/apps/human-app/server/src/common/enums/http-method.ts b/packages/apps/human-app/server/src/common/enums/http-method.ts index 5a3bbf6de1..4dfcef38b9 100644 --- a/packages/apps/human-app/server/src/common/enums/http-method.ts +++ b/packages/apps/human-app/server/src/common/enums/http-method.ts @@ -1,4 +1,5 @@ export enum HttpMethod { GET = 'GET', POST = 'POST', + DELETE = 'DELETE', } 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 bedd90d15d..2271ce18d5 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 @@ -22,6 +22,9 @@ export enum ReputationOracleEndpoints { SIGN_NDA = 'sign_nda', REPORT_ABUSE = 'report_abuse', GET_ABUSE_REPORTS = 'get_abuse_reports', + EXCHANGE_API_KEYS_ENROLL = 'exchange_api_keys_enroll', + EXCHANGE_API_KEYS_DELETE = 'exchange_api_keys_delete', + EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', diff --git a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts index c72ada8f12..5d79c29c28 100644 --- a/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts +++ b/packages/apps/human-app/server/src/common/guards/strategy/jwt.http.ts @@ -44,7 +44,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { status: string; wallet_address: string; reputation_network: string; - stake_eligible?: boolean; + is_stake_eligible?: boolean; qualifications?: string[]; site_key?: string; email?: string; @@ -59,7 +59,7 @@ export class JwtHttpStrategy extends PassportStrategy(Strategy, 'jwt-http') { wallet_address: payload.wallet_address, status: payload.status, reputation_network: payload.reputation_network, - stake_eligible: payload.stake_eligible, + is_stake_eligible: payload.is_stake_eligible, qualifications: payload.qualifications, site_key: payload.site_key, email: payload.email, 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 aea8e277c3..a0175c642d 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 @@ -10,7 +10,7 @@ export class JwtUserData { @AutoMap() reputation_network: string; @AutoMap() - stake_eligible?: boolean; + is_stake_eligible?: boolean; @AutoMap() email?: string; @AutoMap() 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 adebda27cf..bd9a285233 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 @@ -95,6 +95,11 @@ import { ReportAbuseParams, ReportedAbuseResponse, } from '../../modules/abuse/model/abuse.model'; +import { HttpMethod } from '../../common/enums/http-method'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() export class ReputationOracleGateway { @@ -136,6 +141,44 @@ export class ReputationOracleGateway { const response = await lastValueFrom(this.httpService.request(options)); return response.data as T; } + + async enrollExchangeApiKeys( + command: EnrollExchangeApiKeysCommand, + ): Promise<{ id: number }> { + const enrollExchangeApiKeysData = this.mapper.map( + command, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + ); + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_ENROLL, + enrollExchangeApiKeysData, + command.token, + ); + options.url = `${options.url}/${command.exchangeName}`; + return this.handleRequestToReputationOracle<{ id: number }>(options); + } + + async deleteExchangeApiKeys(token: string) { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_DELETE, + undefined, + token, + ); + options.method = HttpMethod.DELETE; + return this.handleRequestToReputationOracle(options); + } + + async retrieveExchangeApiKeys(token: string): Promise<{ apiKey: string }> { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE, + undefined, + token, + ); + return this.handleRequestToReputationOracle<{ + apiKey: string; + }>(options); + } async sendWorkerSignup(command: SignupWorkerCommand): Promise { const signupWorkerData = this.mapper.map( command, 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 1c147e2833..3488762b01 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 @@ -62,6 +62,10 @@ import { ReportAbuseData, ReportAbuseParams, } from '../../modules/abuse/model/abuse.model'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, +} from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() export class ReputationOracleProfile extends AutomapperProfile { @@ -166,6 +170,15 @@ export class ReputationOracleProfile extends AutomapperProfile { destination: new SnakeCaseNamingConvention(), }), ); + createMap( + mapper, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysData, + namingConventions({ + source: new CamelCaseNamingConvention(), + destination: new SnakeCaseNamingConvention(), + }), + ); }; } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts new file mode 100644 index 0000000000..a827806b04 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, + Request, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { RequestWithUser } from '../../common/interfaces/jwt'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { + DeleteExchangeApiKeysCommand, + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysCommand, + RetrieveExchangeApiKeysResponse, +} from './model/exchange-api-keys.model'; + +@ApiTags('Exchange-Api-Keys') +@ApiBearerAuth() +@Controller('/exchange-api-keys') +export class ExchangeApiKeysController { + constructor( + private readonly service: ExchangeApiKeysService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiOperation({ summary: 'Enroll API keys for exchange' }) + @ApiBody({ type: EnrollExchangeApiKeysDto }) + @ApiResponse({ status: 200, description: 'Exchange API keys enrolled' }) + @HttpCode(200) + @Post('/:exchange_name') + async enroll( + @Param('exchange_name') exchangeName: string, + @Body() dto: EnrollExchangeApiKeysDto, + @Request() req: RequestWithUser, + ): Promise<{ id: number }> { + const command = this.mapper.map( + dto, + EnrollExchangeApiKeysDto, + EnrollExchangeApiKeysCommand, + ); + command.token = req.token; + command.exchangeName = exchangeName; + return this.service.enroll(command); + } + + @ApiOperation({ summary: 'Delete API keys for exchange' }) + @ApiResponse({ status: 204, description: 'Exchange API keys deleted' }) + @HttpCode(204) + @Delete('/') + async delete(@Request() req: RequestWithUser): Promise { + const command = new DeleteExchangeApiKeysCommand(); + command.token = req.token; + await this.service.delete(command); + } + + @ApiOperation({ + summary: 'Retrieve API keys for exchange', + }) + @Get('/') + async retrieve( + @Request() req: RequestWithUser, + ): Promise { + const command = new RetrieveExchangeApiKeysCommand(); + command.token = req.token; + return this.service.retrieve(command); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts new file mode 100644 index 0000000000..5f65c8f475 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.mapper.profile.ts @@ -0,0 +1,20 @@ +import { Mapper, createMap } from '@automapper/core'; +import { AutomapperProfile, InjectMapper } from '@automapper/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysProfile extends AutomapperProfile { + constructor(@InjectMapper() mapper: Mapper) { + super(mapper); + } + + override get profile() { + return (mapper: Mapper) => { + createMap(mapper, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysCommand); + }; + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts new file mode 100644 index 0000000000..3b6d627810 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ExchangeApiKeysController } from '../../modules/exchange-api-keys/exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange-api-keys.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; +import { ExchangeApiKeysProfile } from './exchange-api-keys.mapper.profile'; + +@Module({ + imports: [ReputationOracleModule], + controllers: [ExchangeApiKeysController], + providers: [ExchangeApiKeysService, ExchangeApiKeysProfile], + exports: [ExchangeApiKeysService], +}) +export class ExchangeApiKeysModule {} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts new file mode 100644 index 0000000000..2f8d117366 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { InjectMapper } from '@automapper/nestjs'; +import { Mapper } from '@automapper/core'; +import { + DeleteExchangeApiKeysCommand, + EnrollExchangeApiKeysCommand, + RetrieveExchangeApiKeysCommand, + RetrieveExchangeApiKeysResponse, +} from './model/exchange-api-keys.model'; + +@Injectable() +export class ExchangeApiKeysService { + constructor( + private readonly reputationOracle: ReputationOracleGateway, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { + return this.reputationOracle.enrollExchangeApiKeys(command); + } + + delete(command: DeleteExchangeApiKeysCommand): Promise { + return this.reputationOracle.deleteExchangeApiKeys(command.token); + } + + retrieve( + command: RetrieveExchangeApiKeysCommand, + ): Promise { + return this.reputationOracle.retrieveExchangeApiKeys(command.token); + } +} diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts new file mode 100644 index 0000000000..04179d3eeb --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -0,0 +1,45 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class EnrollExchangeApiKeysDto { + @AutoMap() + @IsString() + @ApiProperty() + apiKey: string; + + @AutoMap() + @IsString() + @ApiProperty() + secretKey: string; +} + +export class EnrollExchangeApiKeysCommand { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; + token: string; + exchangeName: string; +} + +export class EnrollExchangeApiKeysData { + @AutoMap() + apiKey: string; + @AutoMap() + secretKey: string; +} + +export class DeleteExchangeApiKeysCommand { + token: string; + exchangeName: string; +} + +export class RetrieveExchangeApiKeysCommand { + token: string; + exchangeName: string; +} + +export class RetrieveExchangeApiKeysResponse { + apiKey: string; +} diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 6f0090c85b..9ebe0ade19 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -48,7 +48,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ): Promise { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } // TODO: temporal - THIRSTYFI @@ -109,7 +109,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ): Promise { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { return { page: 0, page_size: 1, @@ -182,7 +182,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ) { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } const command = this.mapper.map(dto, ResignJobDto, ResignJobCommand); @@ -200,7 +200,7 @@ export class JobAssignmentController { @Request() req: RequestWithUser, ) { // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { throw new ForbiddenException('Stake requirement not met'); } const command = new JobsFetchParamsCommand(); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts index 2efa2dd23f..843c37c38b 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/jobs-discovery.controller.ts @@ -53,7 +53,7 @@ export class JobsDiscoveryController { } // Require stake eligibility - if (!req.user?.stake_eligible) { + if (!req.user?.is_stake_eligible) { return { page: 0, page_size: 1, diff --git a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts index eff081faf3..038a12a021 100644 --- a/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts +++ b/packages/apps/human-app/server/src/modules/oracle-discovery/oracle-discovery.controller.ts @@ -9,7 +9,12 @@ import { Query, Request, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { RequestWithUser } from '../../common/interfaces/jwt'; import { @@ -22,6 +27,7 @@ import Environment from '../../common/utils/environment'; import { ChainId } from '@human-protocol/sdk'; @ApiTags('Oracle-Discovery') +@ApiBearerAuth() @Controller() export class OracleDiscoveryController { constructor( @@ -47,10 +53,6 @@ export class OracleDiscoveryController { HttpStatus.FORBIDDEN, ); } - if (!req.user?.stake_eligible) { - return []; - } - const command = this.mapper.map(query, GetOraclesQuery, GetOraclesCommand); const oracles = await this.oracleDiscoveryService.getOracles(command); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts index e64b8aaf7f..5165db585c 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts @@ -1,6 +1,6 @@ import { KVStoreClient, KVStoreKeys, Role } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { Wallet, ethers } from 'ethers'; +import { Wallet, ethers, NonceManager } from 'ethers'; import * as Minio from 'minio'; const isLocalEnv = process.env.LOCAL === 'true'; @@ -105,7 +105,8 @@ async function setup(): Promise { } const provider = new ethers.JsonRpcProvider(RPC_URL); - const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const wallet = new NonceManager(baseWallet); const kvStoreClient = await KVStoreClient.build(wallet); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts index 8d266da004..71f6c4c57d 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts @@ -1,12 +1,12 @@ +import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { ChainId, NetworkData, NETWORKS, StakingClient, } from '@human-protocol/sdk'; -import { HMToken__factory } from '@human-protocol/core/typechain-types'; import * as dotenv from 'dotenv'; -import { Wallet, ethers } from 'ethers'; +import { ethers, NonceManager, Wallet } from 'ethers'; dotenv.config({ path: '.env.local' }); @@ -26,7 +26,8 @@ export async function setup(): Promise { const { hmtAddress: hmtTokenAddress, stakingAddress } = NETWORKS[ ChainId.LOCALHOST ] as NetworkData; - const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); + const wallet = new NonceManager(baseWallet); const hmtContract = HMToken__factory.connect(hmtTokenAddress, wallet); const hmtTx = await hmtContract.approve(stakingAddress, 1); 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 068b4ee38b..2a0061d1c3 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -16,5 +16,7 @@ export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; -export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate']; +export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate'] as const; export type SupportedExchange = (typeof SUPPORTED_EXCHANGE_NAMES)[number]; + +export const DEFAULT_TIMEOUT_MS = 5000; diff --git a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts index 5408ab7dd1..c82683d25e 100644 --- a/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts +++ b/packages/apps/reputation-oracle/server/src/common/interceptors/transform.interceptor.ts @@ -23,6 +23,10 @@ export class TransformInterceptor implements NestInterceptor { request.query = this.transformRequestData(request.query); } + if (request.params) { + request.params = this.transformRequestData(request.params); + } + return next.handle().pipe(map((data) => this.transformResponseData(data))); } diff --git a/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts similarity index 79% rename from packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts rename to packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts index 6a5d2daed4..3b5f7f91bd 100644 --- a/packages/apps/reputation-oracle/server/src/database/migrations/1761295918414-exchangeApiKeys.ts +++ b/packages/apps/reputation-oracle/server/src/database/migrations/1761653939799-exchangeApiKeys.ts @@ -1,14 +1,14 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class ExchangeApiKeys1761295918414 implements MigrationInterface { - name = 'ExchangeApiKeys1761295918414'; +export class ExchangeApiKeys1761653939799 implements MigrationInterface { + name = 'ExchangeApiKeys1761653939799'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "hmt"."exchange_api_keys" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, "exchange_name" character varying(20) NOT NULL, "api_key" character varying(1000) NOT NULL, "secret_key" character varying(10000) NOT NULL, "user_id" integer NOT NULL, CONSTRAINT "PK_3751a8a0ef5354b32b06ea43983" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE UNIQUE INDEX "IDX_8dcd9d18790ca62ebf1fb40cd3" ON "hmt"."exchange_api_keys" ("user_id", "exchange_name") `, + `CREATE UNIQUE INDEX "IDX_96ee74195b058a1b55afc49f67" ON "hmt"."exchange_api_keys" ("user_id") `, ); await queryRunner.query( `ALTER TABLE "hmt"."exchange_api_keys" ADD CONSTRAINT "FK_96ee74195b058a1b55afc49f673" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, @@ -20,7 +20,7 @@ export class ExchangeApiKeys1761295918414 implements MigrationInterface { `ALTER TABLE "hmt"."exchange_api_keys" DROP CONSTRAINT "FK_96ee74195b058a1b55afc49f673"`, ); await queryRunner.query( - `DROP INDEX "hmt"."IDX_8dcd9d18790ca62ebf1fb40cd3"`, + `DROP INDEX "hmt"."IDX_96ee74195b058a1b55afc49f67"`, ); await queryRunner.query(`DROP TABLE "hmt"."exchange_api_keys"`); } 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 6b76c169d3..8c583e5eb7 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 @@ -2,6 +2,7 @@ import { KVStoreKeys, KVStoreUtils, Role } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { SupportedExchange } from '@/common/constants'; import { SignatureType, UserRole, UserStatus } from '@/common/enums'; import { AuthConfigService, @@ -12,8 +13,8 @@ import { } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeRouterService } from '@/modules/exchange'; -import { ExchangeApiKeysRepository } from '@/modules/exchange-api-keys'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { OperatorStatus, SiteKeyRepository, @@ -59,8 +60,8 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, - private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, - private readonly exchangeRouterService: ExchangeRouterService, + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeClientFactory: ExchangeClientFactory, private readonly stakingConfigService: StakingConfigService, private readonly web3ConfigService: Web3ConfigService, private readonly web3Service: Web3Service, @@ -252,37 +253,7 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } - let stakeEligible = false; - const exchanges = - await this.exchangeApiKeysRepository.listExchangesByUserId(userEntity.id); - - let totalStake = 0; - - if (exchanges.length) { - const exchangeBalance = - await this.exchangeRouterService.getAccountBalance( - exchanges[0], - userEntity.id, - this.stakingConfigService.asset, - ); - - if (exchangeBalance >= this.stakingConfigService.minThreshold) { - totalStake = exchangeBalance; - } else if (userEntity.evmAddress) { - const onChainStake = await this.web3Service.getStakedBalance( - userEntity.evmAddress, - ); - totalStake = exchangeBalance + onChainStake; - } else { - totalStake = exchangeBalance; - } - } else if (userEntity.evmAddress) { - totalStake = await this.web3Service.getStakedBalance( - userEntity.evmAddress, - ); - } - - stakeEligible = totalStake >= this.stakingConfigService.minThreshold; + const stakeEligible = await this.calculateStakeEligible(userEntity); const jwtPayload = { email: userEntity.email, @@ -293,8 +264,8 @@ export class AuthService { kyc_status: userEntity.kyc?.status, nda_signed: userEntity.ndaSignedUrl === this.ndaConfigService.latestNdaUrl, + is_stake_eligible: stakeEligible, reputation_network: this.web3ConfigService.operatorAddress, - stake_eligible: stakeEligible, qualifications: userEntity.userQualifications ? userEntity.userQualifications.map( (userQualification) => userQualification.qualification?.reference, @@ -306,6 +277,49 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } + private async calculateStakeEligible( + userEntity: Web2UserEntity | UserEntity, + ): Promise { + const apiKeys = await this.exchangeApiKeysService.retrieve(userEntity.id); + + let inspectedStakeAmount = 0; + + if (apiKeys) { + try { + const client = await this.exchangeClientFactory.create( + apiKeys.exchangeName as SupportedExchange, + { + apiKey: apiKeys.apiKey, + secretKey: apiKeys.secretKey, + }, + { timeoutMs: this.stakingConfigService.timeoutMs }, + ); + const exchangeBalance = await client.getAccountBalance( + this.stakingConfigService.asset, + ); + inspectedStakeAmount += exchangeBalance; + } catch (err) { + this.logger.warn('Failed to query exchange balance; continuing', { + userId: userEntity.id, + exchangeName: apiKeys.exchangeName, + error: (err as Error)?.message, + }); + } + } + + if ( + inspectedStakeAmount < this.stakingConfigService.minThreshold && + userEntity.evmAddress + ) { + const onChainStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + inspectedStakeAmount += onChainStake; + } + + return inspectedStakeAmount >= this.stakingConfigService.minThreshold; + } + async web3Auth(userEntity: OperatorUserEntity): Promise { /** * NOTE diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts index e0e9d578f9..d42ddbfa30 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.ts @@ -8,6 +8,17 @@ import logger from '@/logger'; const ALGORITHM = 'aes-256-gcm'; const GCM_IV_LENGTH_BYTES = 12; +/** + * Security note: + * - Best practice is to use one encryption key per purpose (e.g., exchange API keys, PII, etc.). + * - At the moment, we only encrypt exchange API keys and therefore use a single key + * provided by EncryptionConfigService.aesEncryptionKey. + * - If/when we start encrypting different kinds of data, we should switch to per-purpose keys: + * - add dedicated config entries for each purpose (e.g., ENCRYPTION_USER_EXCHANGE_API_KEY, ...), + * - update this service so encrypt/decrypt accept an explicit encryptionKey parameter, + * - and ensure callers pass the correct key for the data they are encrypting/decrypting. + */ + type EncryptionOutput = { encrypted: Buffer; iv: Buffer; diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts index 0a0ba41458..a2b44a974e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -1,4 +1,6 @@ +import { faker } from '@faker-js/faker'; + export const mockEncryptionConfigService = { // 32-byte key for AES-256-GCM tests - aesEncryptionKey: '12345678901234567890123456789012', + aesEncryptionKey: faker.string.sample(32), }; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts index 55f741c5af..5a9252614e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-key.entity.ts @@ -1,19 +1,11 @@ -import { - BeforeInsert, - BeforeUpdate, - Column, - Entity, - Index, - ManyToOne, -} from 'typeorm'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; import { DATABASE_SCHEMA_NAME } from '@/common/constants'; import { BaseEntity } from '@/database'; - -import { UserEntity } from '../user'; +import type { UserEntity } from '@/modules/user'; @Entity({ schema: DATABASE_SCHEMA_NAME, name: 'exchange_api_keys' }) -@Index(['userId', 'exchangeName'], { unique: true }) +@Index(['userId'], { unique: true }) export class ExchangeApiKeyEntity extends BaseEntity { @Column('varchar', { length: 20 }) exchangeName: string; @@ -29,15 +21,4 @@ export class ExchangeApiKeyEntity extends BaseEntity { @Column() userId: number; - - @BeforeInsert() - protected beforeInsert(): void { - this.createdAt = new Date(); - this.updatedAt = this.createdAt; - } - - @BeforeUpdate() - protected beforeUpdate(): void { - this.updatedAt = new Date(); - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index 7804fdf2dd..ceb1de5de0 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -16,16 +16,17 @@ import { ApiOperation, ApiResponse, ApiTags, + getSchemaPath, } from '@nestjs/swagger'; import type { RequestWithUser } from '@/common/types'; import Environment from '@/utils/environment'; import { - EncrollExchangeApiKeysParamsDto, + ExchangeNameParamDto, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysResponseDto, - ExchangeNameParamDto, + EnrolledApiKeyDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @@ -41,6 +42,26 @@ export class ExchangeApiKeysController { private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, ) {} + @ApiOperation({ + summary: 'Retrieve enrolled exchange with api key', + description: 'Returns the enrolled api key for exchange w/o secret key', + }) + @ApiResponse({ + status: 200, + schema: { + nullable: true, + allOf: [{ $ref: getSchemaPath(EnrolledApiKeyDto) }], + }, + }) + @Get('/') + async retrieveEnrolledApiKeys( + @Req() request: RequestWithUser, + ): Promise { + const userId = request.user.id; + + return this.exchangeApiKeysService.retrievedEnrolledApiKey(userId); + } + @ApiOperation({ summary: 'Enroll API keys for exchange', description: @@ -56,15 +77,12 @@ export class ExchangeApiKeysController { @Post('/:exchange_name') async enroll( @Req() request: RequestWithUser, - @Param() params: EncrollExchangeApiKeysParamsDto, + @Param() params: ExchangeNameParamDto, @Body() data: EnrollExchangeApiKeysDto, ): Promise { - const userId = request.user.id; - const exchangeName = params.exchangeName; - const key = await this.exchangeApiKeysService.enroll({ - userId, - exchangeName, + userId: request.user.id, + exchangeName: params.exchangeName, apiKey: data.apiKey, secretKey: data.secretKey, }); @@ -73,25 +91,16 @@ export class ExchangeApiKeysController { } @ApiOperation({ - summary: 'Delete API keys for exchange', + summary: 'Delete API keys', }) @ApiResponse({ status: 204, description: 'Exchange API keys deleted', }) @HttpCode(204) - @Delete('/:exchange_name') - async delete( - @Req() request: RequestWithUser, - @Param() params: EncrollExchangeApiKeysParamsDto, - ): Promise { - const userId = request.user.id; - const exchangeName = params.exchangeName; - - await this.exchangeApiKeysRepository.deleteByUserAndExchange( - userId, - exchangeName, - ); + @Delete('/') + async delete(@Req() request: RequestWithUser): Promise { + await this.exchangeApiKeysRepository.deleteByUser(request.user.id); } @ApiOperation({ @@ -99,18 +108,12 @@ export class ExchangeApiKeysController { description: 'This functionality is purely for dev solely and works only in non-production environments', }) - @Get('/:exchange_name') - async retrieve( - @Req() request: RequestWithUser, - @Param() params: ExchangeNameParamDto, - ): Promise { + @Get('/exchange') + async retrieve(@Req() request: RequestWithUser): Promise { if (!Environment.isDevelopment()) { throw new ForbiddenException(); } - const userId = request.user.id; - const exchangeName = params.exchangeName; - - return this.exchangeApiKeysService.retrieve(userId, exchangeName); + return this.exchangeApiKeysService.retrieve(request.user.id); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts index e60691a011..220f04c953 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; import { IsNotEmpty, IsString, MaxLength, Validate } from 'class-validator'; import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; @@ -24,13 +23,19 @@ export class ExchangeNameParamDto { name: 'exchange_name', enum: SUPPORTED_EXCHANGE_NAMES, }) - @Expose({ name: 'exchange_name' }) @Validate(ExchangeNameValidator) exchangeName: string; } -export class EncrollExchangeApiKeysParamsDto extends ExchangeNameParamDto {} export class EnrollExchangeApiKeysResponseDto { @ApiProperty() id: number; } + +export class EnrolledApiKeyDto { + @ApiProperty({ name: 'exchange_name' }) + exchangeName: string; + + @ApiProperty({ name: 'api_key' }) + apiKey: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index 7fcf506afe..38aa4e7943 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -14,6 +14,7 @@ import { KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; +import { ExchangeApiClientError } from '../exchange/errors'; import { UserNotFoundError } from '../user'; @Catch( @@ -42,8 +43,8 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { exception instanceof ActiveExchangeApiKeyExistsError ) { status = HttpStatus.UNPROCESSABLE_ENTITY; - // } else if (exception instanceof ExchangeApiClientError) { - // status = HttpStatus.SERVICE_UNAVAILABLE; + } else if (exception instanceof ExchangeApiClientError) { + status = HttpStatus.SERVICE_UNAVAILABLE; } else if (exception instanceof ExchangeApiKeyNotFoundError) { status = HttpStatus.NOT_FOUND; } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts index 754e6e19f3..e45850a31c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts @@ -1,10 +1,7 @@ import { BaseError } from '@/common/errors/base'; export class ExchangeApiKeyNotFoundError extends BaseError { - constructor( - readonly userId: number, - readonly exchangeName: string, - ) { + constructor(readonly userId: number) { super('Exchange API key not found'); } } @@ -22,7 +19,10 @@ export class KeyAuthorizationError extends BaseError { } export class ActiveExchangeApiKeyExistsError extends BaseError { - constructor(readonly userId: number) { + constructor( + readonly userId: number, + readonly exchangeName: string, + ) { super('User already has an active exchange API key'); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts index 06a318b2a4..8f975d6914 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -1,4 +1,4 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { EncryptionModule } from '@/modules/encryption'; import { ExchangeModule } from '@/modules/exchange/exchange.module'; @@ -9,7 +9,7 @@ import { ExchangeApiKeysService } from './exchange-api-keys.service'; import { UserModule } from '../user'; @Module({ - imports: [forwardRef(() => ExchangeModule), EncryptionModule, UserModule], + imports: [ExchangeModule, EncryptionModule, UserModule], providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], controllers: [ExchangeApiKeysController], exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts index 403b984840..ea0d264915 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.repository.ts @@ -1,89 +1,30 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { DataSource } from 'typeorm'; -import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { BaseRepository } from '@/database'; -type FindOptions = { - relations?: FindManyOptions['relations']; -}; +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; @Injectable() -export class ExchangeApiKeysRepository extends Repository { +export class ExchangeApiKeysRepository extends BaseRepository { constructor(dataSource: DataSource) { - super(ExchangeApiKeyEntity, dataSource.createEntityManager()); + super(ExchangeApiKeyEntity, dataSource); } - async findOneByUserId( - userId: number, - options: FindOptions = {}, - ): Promise { + async findOneByUserId(userId: number): Promise { if (!userId) { throw new Error('Invalid arguments'); } return this.findOne({ where: { userId }, - relations: options.relations, }); } - async listExchangesByUserId(userId: number): Promise { + async deleteByUser(userId: number): Promise { if (!userId) { throw new Error('userId is required'); } - const results: Array> = - await this.find({ - where: { - userId, - }, - select: { - exchangeName: true, - }, - }); - - return results.map((r) => r.exchangeName); - } - - async deleteByUserAndExchange( - userId: number, - exchangeName: string, - ): Promise { - if (!userId) { - throw new Error('userId is required'); - } - if (!exchangeName) { - throw new Error('exchangeName is required'); - } - - await this.delete({ userId, exchangeName }); - } - - async findOneByUserAndExchange( - userId: number, - exchangeName: string, - ): Promise { - if (!userId) { - throw new Error('userId is required'); - } - if (!exchangeName) { - throw new Error('exchangeName is required'); - } - - return this.findOneBy({ - userId, - exchangeName, - }); - } - - async findByUserId(userId: number): Promise { - if (!userId) { - throw new Error('userId is required'); - } - - return this.find({ - where: { - userId, - }, - }); + await this.delete({ userId }); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index fc64955525..c9c3db7d6b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -1,18 +1,17 @@ -import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { isValidExchangeName } from '@/common/validators/exchange'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; -import { ExchangeRouterService } from '@/modules/exchange/exchange.router.service'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { - ExchangeApiKeyNotFoundError, - IncompleteKeySuppliedError, KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; import { UserNotFoundError, UserRepository } from '../user'; +import { EnrolledApiKeyDto } from './exchange-api-keys.dto'; @Injectable() export class ExchangeApiKeysService { @@ -20,8 +19,7 @@ export class ExchangeApiKeysService { private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly userRepository: UserRepository, private readonly aesEncryptionService: AesEncryptionService, - @Inject(forwardRef(() => ExchangeRouterService)) - private readonly exchangeRouterService: ExchangeRouterService, + private readonly exchangeClientFactory: ExchangeClientFactory, ) {} async enroll(input: { @@ -43,18 +41,14 @@ export class ExchangeApiKeysService { const currentKeys = await this.exchangeApiKeysRepository.findOneByUserId(userId); if (currentKeys) { - throw new ActiveExchangeApiKeyExistsError(userId); + throw new ActiveExchangeApiKeyExistsError(userId, exchangeName); } - const creds = { apiKey, secret: secretKey }; - if ( - !this.exchangeRouterService.checkRequiredCredentials(exchangeName, creds) - ) { - throw new IncompleteKeySuppliedError(exchangeName); - } - - const hasRequiredAccess = - await this.exchangeRouterService.checkRequiredAccess(exchangeName, creds); + const client = await this.exchangeClientFactory.create(exchangeName, { + apiKey, + secretKey, + }); + const hasRequiredAccess = await client.checkRequiredAccess(); if (!hasRequiredAccess) { throw new KeyAuthorizationError(exchangeName); } @@ -74,27 +68,19 @@ export class ExchangeApiKeysService { ]); enrolledKey.apiKey = encryptedApiKey; enrolledKey.secretKey = encryptedSecretKey; - enrolledKey.updatedAt = new Date(); - - await this.exchangeApiKeysRepository.upsert(enrolledKey, [ - 'userId', - 'exchangeName', - ]); + await this.exchangeApiKeysRepository.createUnique(enrolledKey); return enrolledKey; } - async retrieve( - userId: number, - exchangeName: string, - ): Promise<{ id: number; apiKey: string; secretKey: string }> { - const entity = - await this.exchangeApiKeysRepository.findOneByUserAndExchange( - userId, - exchangeName, - ); + async retrieve(userId: number): Promise<{ + exchangeName: string; + apiKey: string; + secretKey: string; + } | null> { + const entity = await this.exchangeApiKeysRepository.findOneByUserId(userId); if (!entity) { - throw new ExchangeApiKeyNotFoundError(userId, exchangeName); + return null; } const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ @@ -103,9 +89,29 @@ export class ExchangeApiKeysService { ]); return { - id: entity.id, + exchangeName: entity.exchangeName, apiKey: decryptedApiKey.toString(), secretKey: decryptedSecretKey.toString(), }; } + + async retrievedEnrolledApiKey( + userId: number, + ): Promise { + const enrolledKey = + await this.exchangeApiKeysRepository.findOneByUserId(userId); + + if (!enrolledKey) { + return null; + } + + const decodedApiKey = await this.aesEncryptionService.decrypt( + enrolledKey.apiKey, + ); + + return { + exchangeName: enrolledKey.exchangeName, + apiKey: decodedApiKey.toString(), + }; + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts new file mode 100644 index 0000000000..9617016bd6 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts @@ -0,0 +1,3 @@ +import { BaseError } from '@/common/errors/base'; + +export class ExchangeApiClientError extends BaseError {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts new file mode 100644 index 0000000000..035c2b0ef2 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange-client.factory.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; + +import type { SupportedExchange } from '@/common/constants'; + +import { GateExchangeClient } from './gate-exchange.client'; +import { MexcExchangeClient } from './mexc-exchange.client'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +@Injectable() +export class ExchangeClientFactory { + async create( + exchange: SupportedExchange, + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ): Promise { + switch (exchange) { + case 'mexc': { + return new MexcExchangeClient(creds, options); + } + case 'gate': { + return new GateExchangeClient(creds, options); + } + default: + throw new Error(`Unsupported exchange: ${exchange}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts index 7fca563319..ddc664fdc8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.module.ts @@ -1,14 +1,9 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; -import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; - -import { ExchangeRouterService } from './exchange.router.service'; -import { GateExchangeService } from './gate-exchange.service'; -import { MexcExchangeService } from './mexc-exchange.service'; +import { ExchangeClientFactory } from './exchange-client.factory'; @Module({ - imports: [forwardRef(() => ExchangeApiKeysModule)], - providers: [MexcExchangeService, GateExchangeService, ExchangeRouterService], - exports: [ExchangeRouterService], + providers: [ExchangeClientFactory], + exports: [ExchangeClientFactory], }) export class ExchangeModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts deleted file mode 100644 index 3eb80aa4f8..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.router.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; -import { GateExchangeService } from './gate-exchange.service'; -import { MexcExchangeService } from './mexc-exchange.service'; - -@Injectable() -export class ExchangeRouterService { - private servicesMap: Record; - - constructor( - private readonly mexcService: MexcExchangeService, - private readonly gateService: GateExchangeService, - ) { - this.servicesMap = { - mexc: this.mexcService, - gate: this.gateService, - }; - } - - checkRequiredCredentials( - exchangeName: string, - creds: ExchangeCredentials, - ): boolean { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.checkRequiredCredentials(creds); - } - - async checkRequiredAccess( - exchangeName: string, - creds: ExchangeCredentials, - ): Promise { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.checkRequiredAccess(creds); - } - - async getAccountBalance( - exchangeName: string, - userId: number, - asset?: string, - ): Promise { - const service = this.servicesMap[exchangeName.toLowerCase()]; - if (!service) { - throw new Error(`Exchange service not found for ${exchangeName}`); - } - return service.getAccountBalance(userId, asset); - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts deleted file mode 100644 index 41bfb7721e..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/exchange.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type ExchangeCredentials = { apiKey: string; secret: string }; - -export abstract class ExchangeService { - abstract checkRequiredCredentials(creds: ExchangeCredentials): boolean; - - abstract checkRequiredAccess(creds: ExchangeCredentials): Promise; - - abstract getAccountBalance(userId: number, asset?: string): Promise; -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts new file mode 100644 index 0000000000..6be1e45eaa --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -0,0 +1,172 @@ +import { createHash, createHmac } from 'node:crypto'; + +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import appLogger from '@/logger'; +import Environment from '@/utils/environment'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; + +function signGateRequest( + method: string, + path: string, + query: string, + body: string, + secret: string, + ts: string, +): string { + const bodyHash = createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + return createHmac('sha512', secret).update(payload).digest('hex'); +} + +export class GateExchangeClient implements ExchangeClient { + readonly id: SupportedExchange = 'gate'; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly apiBaseUrl = Environment.isDevelopment() + ? DEVELOP_GATE_API_BASE_URL + : GATE_API_BASE_URL; + private readonly logger = appLogger.child({ + context: 'GateExchangeClient', + exchange: 'gate', + }); + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for Gate'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + async checkRequiredAccess(): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = ''; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const signature = signGateRequest( + method, + `/api/v4${path}`, + query, + body, + this.secretKey, + ts, + ); + + try { + const res = await fetch(`${this.apiBaseUrl}${path}`, { + method, + headers: { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (res.ok) return true; + if (res.status === 401 || res.status === 403) return false; + this.logger.warn('Gate access check failed', { + status: res.status, + statusText: res.statusText, + }); + throw new ExchangeApiClientError( + `Gate access check failed with status ${res.status}`, + ); + } catch (err) { + const message: string = 'Gate network error during access check'; + this.logger.error(message, { + error: err.message, + }); + throw new ExchangeApiClientError(`${message}: ${(err as Error).message}`); + } + } + + async getAccountBalance(asset: string): Promise { + const method = 'GET'; + const path = '/spot/accounts'; + const query = `currency=${encodeURIComponent(asset)}`; + const body = ''; + const ts = String(Math.floor(Date.now() / 1000)); + const requestPath = `/api/v4${path}`; + const signature = signGateRequest( + method, + requestPath, + query, + body, + this.secretKey, + ts, + ); + const url = `${this.apiBaseUrl}${path}?${query}`; + + try { + const res = await fetch(url, { + method, + headers: { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) return 0; + this.logger.warn('Gate balance fetch failed', { + status: res.status, + statusText: res.statusText, + asset, + }); + throw new ExchangeApiClientError( + `Gate balance fetch failed with status ${res.status}`, + ); + } + + const data = (await res.json()) as Array<{ + currency: string; + available: string; + locked?: string; + freeze?: string; + }>; + + const normalize = (item: { + currency: string; + available: string; + locked?: string; + freeze?: string; + }) => { + const free = parseFloat(item.available) || 0; + const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; + return free + locked; + }; + + const entry = data.find((d) => d.currency === asset); + return entry ? normalize(entry) : 0; + } catch (err) { + const message: string = 'Gate network error during balance fetch'; + this.logger.error(message, { + error: (err as Error)?.message, + asset, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts deleted file mode 100644 index ea1b6abd8b..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.service.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { createHash, createHmac } from 'node:crypto'; - -import { Inject, Injectable, forwardRef } from '@nestjs/common'; - -import { StakingConfigService } from '@/config'; -import logger from '@/logger'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; -import Environment from '@/utils/environment'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; - -const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; -const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; - -function signGateRequest( - method: string, - path: string, - query: string, - body: string, - secret: string, - ts: string, -): string { - const bodyHash = createHash('sha512') - .update(body ?? '') - .digest('hex'); - const payload = [method, path, query, bodyHash, ts].join('\n'); - return createHmac('sha512', secret).update(payload).digest('hex'); -} - -@Injectable() -export class GateExchangeService extends ExchangeService { - readonly id = 'gate' as const; - private readonly logger = logger.child({ context: GateExchangeService.name }); - private readonly apiBaseUrl = - process.env.GATE_API_BASE_URL && process.env.GATE_API_BASE_URL.length > 0 - ? process.env.GATE_API_BASE_URL - : Environment.isDevelopment() - ? DEVELOP_GATE_API_BASE_URL - : GATE_API_BASE_URL; - - constructor( - @Inject(forwardRef(() => ExchangeApiKeysService)) - private readonly exchangeApiKeysService: ExchangeApiKeysService, - private readonly stakingConfigService: StakingConfigService, - ) { - super(); - } - - checkRequiredCredentials(creds: ExchangeCredentials): boolean { - return Boolean(creds?.apiKey && creds?.secret); - } - - async checkRequiredAccess(creds: ExchangeCredentials): Promise { - const method = 'GET'; - const path = '/spot/accounts'; - const query = ''; - const body = ''; - const ts = String(Math.floor(Date.now() / 1000)); - const signature = signGateRequest( - method, - `/api/v4${path}`, - query, - body, - creds.secret, - ts, - ); - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(`${this.apiBaseUrl}${path}`, { - method, - headers: { - KEY: creds.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - console.warn('Gate access check http error', { - status: res.status, - body: text, - }); - return false; - } - return true; - } catch (error) { - this.logger.error('Gate access check failed', { - error, - }); - return false; - } finally { - clearTimeout(timeout); - } - } - - async getAccountBalance(userId: number, asset = 'HMT'): Promise { - const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); - - const method = 'GET'; - const path = '/spot/accounts'; - const query = `currency=${encodeURIComponent(asset)}`; - const body = ''; - const ts = String(Math.floor(Date.now() / 1000)); - const requestPath = `/api/v4${path}`; - const signature = signGateRequest( - method, - requestPath, - query, - body, - creds.secretKey, - ts, - ); - const url = `${this.apiBaseUrl}${path}?${query}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method, - headers: { - KEY: creds.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - this.logger.error('Gate getAccountBalance http error', { - status: res.status, - body: text, - }); - return 0; - } - const data = (await res.json()) as Array<{ - currency: string; - available: string; - locked?: string; - freeze?: string; - }>; - - const normalize = (item: { - currency: string; - available: string; - locked?: string; - freeze?: string; - }) => { - const free = parseFloat(item.available) || 0; - const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; - return free + locked; - }; - - const entry = data.find((d) => d.currency === asset); - return entry ? normalize(entry) : 0; - } catch (error) { - this.logger.error('Gate getAccountBalance failed', { - error, - }); - return 0; - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts index 49bc0304e4..083c7dd8bd 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -1,4 +1,2 @@ export { ExchangeModule } from './exchange.module'; -export { ExchangeRouterService } from './exchange.router.service'; -export { MexcExchangeService } from './mexc-exchange.service'; -export { GateExchangeService } from './gate-exchange.service'; +export { ExchangeClientFactory } from './exchange-client.factory'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts new file mode 100644 index 0000000000..de191b025a --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -0,0 +1,121 @@ +import { createHmac } from 'node:crypto'; + +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import appLogger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; +import type { + ExchangeClient, + ExchangeClientCredentials, + ExchangeClientOptions, +} from './types'; + +const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; + +export class MexcExchangeClient implements ExchangeClient { + readonly id: SupportedExchange = 'mexc'; + private readonly apiKey: string; + private readonly secretKey: string; + private readonly timeoutMs?: number; + private readonly logger = appLogger.child({ + context: 'MexcExchangeClient', + exchange: 'mexc', + }); + readonly recvWindow = 5000; + + constructor( + creds: ExchangeClientCredentials, + options?: ExchangeClientOptions, + ) { + if (!creds?.apiKey || !creds?.secretKey) { + throw new ExchangeApiClientError('Incomplete credentials for MEXC'); + } + this.apiKey = creds.apiKey; + this.secretKey = creds.secretKey; + this.timeoutMs = options?.timeoutMs; + } + + private signQuery(query: string): string { + return createHmac('sha256', this.secretKey).update(query).digest('hex'); + } + + async checkRequiredAccess(): Promise { + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { 'X-MEXC-APIKEY': this.apiKey }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (res.ok) return true; + if (res.status === 401 || res.status === 403) return false; + this.logger.warn('MEXC access check failed', { + status: res.status, + statusText: res.statusText, + }); + throw new ExchangeApiClientError( + `MEXC access check failed with status ${res.status}`, + ); + } catch (err) { + const message: string = 'MEXC network error during access check'; + this.logger.error(message, { + error: err.message, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } + + async getAccountBalance(asset: string): Promise { + const path = '/account'; + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; + + try { + const res = await fetch(url, { + method: 'GET', + headers: { + 'X-MEXC-APIKEY': this.apiKey, + }, + signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + } as RequestInit); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) return 0; + this.logger.warn('MEXC balance fetch failed', { + status: res.status, + statusText: res.statusText, + asset, + }); + throw new ExchangeApiClientError( + `MEXC balance fetch failed with status ${res.status}`, + ); + } + + const data = (await res.json()) as { + balances?: Array<{ asset: string; free: string; locked: string }>; + }; + const balances = data.balances || []; + const entry = balances.find((b) => b.asset === asset); + if (!entry) return 0; + const total = + (parseFloat(entry.free || '0') || 0) + + (parseFloat(entry.locked || '0') || 0); + return total; + } catch (err) { + const message: string = 'MEXC network error during balance fetch'; + this.logger.error(message, { + error: err.message, + asset, + }); + throw new ExchangeApiClientError(`${message}: ${err.message}`); + } + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts deleted file mode 100644 index 65b976f971..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.service.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createHmac } from 'node:crypto'; - -import { Inject, Injectable, forwardRef } from '@nestjs/common'; - -import { StakingConfigService } from '@/config'; -import logger from '@/logger'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; - -import { ExchangeCredentials, ExchangeService } from './exchange.service'; - -const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; - -@Injectable() -export class MexcExchangeService extends ExchangeService { - readonly id = 'mexc' as const; - readonly recvWindow = 5000; - private readonly logger = logger.child({ context: MexcExchangeService.name }); - - constructor( - @Inject(forwardRef(() => ExchangeApiKeysService)) - private readonly exchangeApiKeysService: ExchangeApiKeysService, - private readonly stakingConfigService: StakingConfigService, - ) { - super(); - } - - checkRequiredCredentials(creds: ExchangeCredentials): boolean { - return Boolean(creds?.apiKey && creds?.secret); - } - - async checkRequiredAccess(creds: ExchangeCredentials): Promise { - const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = createHmac('sha256', creds.secret) - .update(query) - .digest('hex'); - const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method: 'GET', - headers: { 'X-MEXC-APIKEY': creds.apiKey }, - signal: controller.signal, - } as RequestInit); - return res.ok; - } catch (error) { - this.logger.error('MEXC access check failed', { - error, - }); - return false; - } finally { - clearTimeout(timeout); - } - } - - async getAccountBalance(userId: number, asset = 'HMT'): Promise { - const creds = await this.exchangeApiKeysService.retrieve(userId, this.id); - - const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = createHmac('sha256', creds.secretKey) - .update(query) - .digest('hex'); - const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - - const controller = new AbortController(); - const timeout = setTimeout( - () => controller.abort(), - this.stakingConfigService.timeoutMs, - ); - try { - const res = await fetch(url, { - method: 'GET', - headers: { - 'X-MEXC-APIKEY': creds.apiKey, - }, - signal: controller.signal, - } as RequestInit); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Http error ${res.status}: ${text}`); - } - const data = (await res.json()) as { - balances?: Array<{ asset: string; free: string; locked: string }>; - }; - const balances = data.balances || []; - const entry = balances.find((b) => b.asset === asset); - if (!entry) return 0; - const total = - (parseFloat(entry.free || '0') || 0) + - (parseFloat(entry.locked || '0') || 0); - return total; - } catch (error) { - this.logger.error('MEXC getAccountBalance failed', { - error, - }); - return 0; - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts new file mode 100644 index 0000000000..1fd5cfa68b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/types.ts @@ -0,0 +1,16 @@ +import type { SupportedExchange } from '@/common/constants'; + +export interface ExchangeClientCredentials { + apiKey: string; + secretKey: string; +} + +export interface ExchangeClientOptions { + timeoutMs?: number; +} + +export interface ExchangeClient { + readonly id: SupportedExchange; + checkRequiredAccess(): Promise; + getAccountBalance(asset: string): Promise; +} From c3a6886fd1e17a02db72723708bd3d08bfc1a19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 15:46:14 +0100 Subject: [PATCH 04/26] simplify error handling in Gate and Mexc --- .../src/modules/exchange/gate-exchange.client.ts | 10 ++-------- .../src/modules/exchange/mexc-exchange.client.ts | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index 6be1e45eaa..eb100f89f9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -82,14 +82,11 @@ export class GateExchangeClient implements ExchangeClient { } as RequestInit); if (res.ok) return true; - if (res.status === 401 || res.status === 403) return false; this.logger.warn('Gate access check failed', { status: res.status, statusText: res.statusText, }); - throw new ExchangeApiClientError( - `Gate access check failed with status ${res.status}`, - ); + return false; } catch (err) { const message: string = 'Gate network error during access check'; this.logger.error(message, { @@ -129,15 +126,12 @@ export class GateExchangeClient implements ExchangeClient { } as RequestInit); if (!res.ok) { - if (res.status === 401 || res.status === 403) return 0; this.logger.warn('Gate balance fetch failed', { status: res.status, statusText: res.statusText, asset, }); - throw new ExchangeApiClientError( - `Gate balance fetch failed with status ${res.status}`, - ); + return 0; } const data = (await res.json()) as Array<{ diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index de191b025a..21d13f588c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -54,14 +54,11 @@ export class MexcExchangeClient implements ExchangeClient { } as RequestInit); if (res.ok) return true; - if (res.status === 401 || res.status === 403) return false; this.logger.warn('MEXC access check failed', { status: res.status, statusText: res.statusText, }); - throw new ExchangeApiClientError( - `MEXC access check failed with status ${res.status}`, - ); + return false; } catch (err) { const message: string = 'MEXC network error during access check'; this.logger.error(message, { @@ -88,15 +85,12 @@ export class MexcExchangeClient implements ExchangeClient { } as RequestInit); if (!res.ok) { - if (res.status === 401 || res.status === 403) return 0; this.logger.warn('MEXC balance fetch failed', { status: res.status, statusText: res.statusText, asset, }); - throw new ExchangeApiClientError( - `MEXC balance fetch failed with status ${res.status}`, - ); + return 0; } const data = (await res.json()) as { From 65fe558596fcf1544a41a83212f4138bc509d92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 16:02:27 +0100 Subject: [PATCH 05/26] add AES encryption key to .env.example and improve error handling in getStakedBalance method --- .../apps/reputation-oracle/server/.env.example | 3 +++ .../exchange-api-keys.error-filter.ts | 2 +- .../server/src/modules/web3/web3.service.ts | 18 +++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/apps/reputation-oracle/server/.env.example b/packages/apps/reputation-oracle/server/.env.example index c3c6a028ce..80da4aa283 100644 --- a/packages/apps/reputation-oracle/server/.env.example +++ b/packages/apps/reputation-oracle/server/.env.example @@ -97,3 +97,6 @@ NDA_URL=https://humanprotocol.org # HUMAN App secret key for auth in RepO HUMAN_APP_SECRET_KEY=sk_example_1VwUpBMO8H0v4Pmu4TPiWFEwuMguW4PkozSban4Rfbc + +# Aes +AES_ENCRYPTION_KEY=e59a0bb854ff5b338fbfc86452690be2 diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index 38aa4e7943..bb21e69d46 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -7,6 +7,7 @@ import { import { Request, Response } from 'express'; import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; import { ExchangeApiKeyNotFoundError, @@ -15,7 +16,6 @@ import { ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiClientError } from '../exchange/errors'; -import { UserNotFoundError } from '../user'; @Catch( UserNotFoundError, diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index 58c5801ef8..dcaecb47c9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -108,14 +108,18 @@ export class Web3Service { } async getStakedBalance(address: string): Promise { - const chainId = this.web3ConfigService.reputationNetworkChainId; - const provider = this.getSigner(chainId).provider; + try { + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.getSigner(chainId).provider; - const stakingClient = await StakingClient.build(provider); - const stakerInfo = await stakingClient.getStakerInfo(address); + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); - const total = - (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); - return Number(ethers.formatEther(total)); + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } catch (error) { + throw new Error(`Failed to fetch staked balance: ${error.message}`); + } } } From b58f09f49985b8865d783f3e6465fd506717fe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 28 Oct 2025 16:19:19 +0100 Subject: [PATCH 06/26] add staking eligibility env var to enable or disable this functionality --- .../server/src/config/staking-config.service.ts | 14 +++++++++++++- .../server/src/modules/auth/auth.service.ts | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts index 94b5d6a414..174d3ed5d1 100644 --- a/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/staking-config.service.ts @@ -10,7 +10,7 @@ export class StakingConfigService { * Default: 'HMT' */ get asset(): string { - return this.configService.get('STAKING_ASSET') || 'HMT'; + return this.configService.get('STAKING_ASSET', 'HMT'); } /** @@ -28,4 +28,16 @@ export class StakingConfigService { get timeoutMs(): number { return Number(this.configService.get('STAKING_TIMEOUT_MS')) || 2000; } + + /** + * Feature flag to enable/disable staking eligibility enforcement. + * When disabled, eligibility will be treated as true unconditionally. + * Default: false + */ + get eligibilityEnabled(): boolean { + return ( + this.configService.get('STAKING_ELIGIBILITY_ENABLED', 'false') === + 'true' + ); + } } 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 8c583e5eb7..3c8aa220b0 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 @@ -280,6 +280,8 @@ export class AuthService { private async calculateStakeEligible( userEntity: Web2UserEntity | UserEntity, ): Promise { + if (!this.stakingConfigService.eligibilityEnabled) return true; + const apiKeys = await this.exchangeApiKeysService.retrieve(userEntity.id); let inspectedStakeAmount = 0; From 853833a2ce67173e16b6e155704d2061f9d43d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 29 Oct 2025 18:03:47 +0100 Subject: [PATCH 07/26] Generate unit tests for AesEncriptionService, AuthService, GateExchangeClient, MexcExchangeClient and exchangeApiKeyService --- docker-setup/docker-compose.dev.yml | 2 +- .../server/scripts/setup-kv-store.ts | 5 +- .../server/scripts/setup-staking.ts | 5 +- .../server/src/app.module.ts | 2 + .../src/modules/auth/auth.service.spec.ts | 159 +++++++++++++ .../server/src/modules/auth/auth.service.ts | 6 +- .../encryption/aes-encryption.service.spec.ts | 85 +++++++ .../src/modules/encryption/fixtures/index.ts | 10 +- .../exchange-api-keys.controller.ts | 25 ++- .../exchange-api-keys.service.spec.ts | 212 ++++++++++++++++++ .../exchange-api-keys.service.ts | 27 +-- .../exchange-api-keys/fixtures/index.ts | 25 +++ .../src/modules/exchange/fixtures/exchange.ts | 30 +++ .../src/modules/exchange/fixtures/index.ts | 1 + .../exchange/gate-exchange.client.spec.ts | 137 +++++++++++ .../modules/exchange/gate-exchange.client.ts | 30 +-- .../exchange/mexc-exchange.client.spec.ts | 178 +++++++++++++++ .../modules/exchange/mexc-exchange.client.ts | 30 +-- .../server/src/modules/web3/web3.service.ts | 8 +- 19 files changed, 903 insertions(+), 74 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index 456c82f74e..e7555180cb 100644 --- a/docker-setup/docker-compose.dev.yml +++ b/docker-setup/docker-compose.dev.yml @@ -146,7 +146,7 @@ services: published: ${POSTGRES_PORT:-5433} volumes: - ./initdb:/docker-entrypoint-initdb.d - - postgres-data:/var/lib/postgresql + - postgres-data:/var/lib/postgresql/data environment: POSTGRES_USER: *postgres_user POSTGRES_PASSWORD: *postgres_password diff --git a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts index 5165db585c..e64b8aaf7f 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-kv-store.ts @@ -1,6 +1,6 @@ import { KVStoreClient, KVStoreKeys, Role } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { Wallet, ethers, NonceManager } from 'ethers'; +import { Wallet, ethers } from 'ethers'; import * as Minio from 'minio'; const isLocalEnv = process.env.LOCAL === 'true'; @@ -105,8 +105,7 @@ async function setup(): Promise { } const provider = new ethers.JsonRpcProvider(RPC_URL); - const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); - const wallet = new NonceManager(baseWallet); + const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); const kvStoreClient = await KVStoreClient.build(wallet); diff --git a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts index 71f6c4c57d..e19787ae8b 100644 --- a/packages/apps/reputation-oracle/server/scripts/setup-staking.ts +++ b/packages/apps/reputation-oracle/server/scripts/setup-staking.ts @@ -6,7 +6,7 @@ import { StakingClient, } from '@human-protocol/sdk'; import * as dotenv from 'dotenv'; -import { ethers, NonceManager, Wallet } from 'ethers'; +import { Wallet, ethers } from 'ethers'; dotenv.config({ path: '.env.local' }); @@ -26,8 +26,7 @@ export async function setup(): Promise { const { hmtAddress: hmtTokenAddress, stakingAddress } = NETWORKS[ ChainId.LOCALHOST ] as NetworkData; - const baseWallet = new Wallet(WEB3_PRIVATE_KEY, provider); - const wallet = new NonceManager(baseWallet); + const wallet = new Wallet(WEB3_PRIVATE_KEY, provider); const hmtContract = HMToken__factory.connect(hmtTokenAddress, wallet); const hmtTx = await hmtContract.approve(stakingAddress, 1); diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index a064f1b5c9..91bcb99048 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -14,6 +14,7 @@ import { AbuseModule } from './modules/abuse'; import { AuthModule } from './modules/auth'; import { CronJobModule } from './modules/cron-job'; import { EscrowCompletionModule } from './modules/escrow-completion'; +import { ExchangeApiKeysModule } from './modules/exchange-api-keys'; import { HealthModule } from './modules/health'; import { KycModule } from './modules/kyc'; import { NDAModule } from './modules/nda'; @@ -72,6 +73,7 @@ import Environment from './utils/environment'; CronJobModule, UserModule, NDAModule, + ExchangeApiKeysModule, EscrowCompletionModule, HealthModule, KycModule, 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 8fe72fc2a0..6892757060 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 @@ -11,13 +11,18 @@ import { SignatureType, UserStatus, UserRole } from '@/common/enums'; import { AuthConfigService, NDAConfigService, + StakingConfigService, ServerConfigService, Web3ConfigService, } from '@/config'; import { EmailAction, EmailService } from '@/modules/email'; +import { ExchangeClientFactory } from '@/modules/exchange'; +import type { ExchangeClient } from '@/modules/exchange/types'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { SiteKeyRepository } from '@/modules/user'; import { UserEntity, UserRepository, UserService } from '@/modules/user'; import { generateOperator, generateWorkerUser } from '@/modules/user/fixtures'; +import { Web3Service } from '@/modules/web3'; import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; import * as secutiryUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -28,6 +33,7 @@ import * as AuthErrors from './auth.error'; import { AuthService } from './auth.service'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; +import { generateExchangeApiKeysData } from '../exchange-api-keys/fixtures'; const mockKVStoreUtils = jest.mocked(KVStoreUtils); @@ -41,6 +47,12 @@ const mockAuthConfigService: Omit = { forgotPasswordExpiresIn: 86400000, humanAppSecretKey: faker.string.alphanumeric({ length: 42 }), }; +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: 100, + asset: 'ETH', + timeoutMs: 2000, +}; const mockEmailService = createMock(); @@ -56,6 +68,9 @@ const mockSiteKeyRepository = createMock(); const mockTokenRepository = createMock(); const mockUserRepository = createMock(); const mockUserService = createMock(); +const mockExchangeApiKeysService = createMock(); +const mockExchangeClientFactory = createMock(); +const mockWeb3Service = createMock(); describe('AuthService', () => { let service: AuthService; @@ -85,6 +100,13 @@ describe('AuthService', () => { { provide: UserRepository, useValue: mockUserRepository }, { provide: UserService, useValue: mockUserService }, { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, + { + provide: ExchangeApiKeysService, + useValue: mockExchangeApiKeysService, + }, + { provide: ExchangeClientFactory, useValue: mockExchangeClientFactory }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, + { provide: Web3Service, useValue: mockWeb3Service }, ], }).compile(); @@ -626,6 +648,7 @@ describe('AuthService', () => { wallet_address: user.evmAddress, role: user.role, kyc_status: user.kyc?.status, + is_stake_eligible: true, nda_signed: user.ndaSignedUrl === mockNdaConfigService.latestNdaUrl, reputation_network: mockWeb3ConfigService.operatorAddress, qualifications: user.userQualifications @@ -635,6 +658,11 @@ describe('AuthService', () => { : [], }; + const spyOncheckStakeEligible = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(service as any, 'checkStakeEligible') + .mockImplementation(); + spyOncheckStakeEligible.mockResolvedValueOnce(true); const spyOnGenerateTokens = jest .spyOn(service, 'generateTokens') .mockImplementation(); @@ -651,10 +679,141 @@ describe('AuthService', () => { expectedJwtPayload, ); + expect(spyOncheckStakeEligible).toHaveBeenCalledTimes(1); + + spyOncheckStakeEligible.mockRestore(); spyOnGenerateTokens.mockRestore(); }); }); + describe('checkStakeEligible', () => { + it('returns true when feature flag disabled', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = false; + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeApiKeysService.retrieve).not.toHaveBeenCalled(); + expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance meets threshold (no on-chain call)', async () => { + const user = generateWorkerUser(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockResolvedValue(1500), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + }); + + it('returns true when exchange balance below threshold but on-chain makes up the difference', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockResolvedValue(400), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(600); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + }); + + it('returns false when no exchange keys and on-chain stake below threshold', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(500); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(false); + expect(mockExchangeClientFactory.create).not.toHaveBeenCalled(); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + }); + + it('continues on exchange error and returns based on on-chain stake', async () => { + const user = generateWorkerUser({ + privateKey: generateEthWallet().privateKey, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).eligibilityEnabled = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).minThreshold = 1000; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockStakingConfigService as any).asset = 'HMT'; + + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( + generateExchangeApiKeysData(), + ); + + const mockClient = { + getAccountBalance: jest.fn().mockRejectedValue(new Error('network')), + }; + mockExchangeClientFactory.create.mockResolvedValueOnce( + mockClient as unknown as ExchangeClient, + ); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(1200); + + const result = await service['checkStakeEligible'](user); + + expect(result).toBe(true); + expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + }); + }); + describe('web3Auth', () => { it('should generate jwt payload for operator', async () => { const operator = generateOperator(); 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 3c8aa220b0..f1f6a0e502 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 @@ -253,7 +253,7 @@ export class AuthService { hCaptchaSiteKey = hCaptchaSiteKeys[0].siteKey; } - const stakeEligible = await this.calculateStakeEligible(userEntity); + const stakeEligible = await this.checkStakeEligible(userEntity); const jwtPayload = { email: userEntity.email, @@ -277,7 +277,7 @@ export class AuthService { return this.generateTokens(userEntity.id, jwtPayload); } - private async calculateStakeEligible( + private async checkStakeEligible( userEntity: Web2UserEntity | UserEntity, ): Promise { if (!this.stakingConfigService.eligibilityEnabled) return true; @@ -304,7 +304,7 @@ export class AuthService { this.logger.warn('Failed to query exchange balance; continuing', { userId: userEntity.id, exchangeName: apiKeys.exchangeName, - error: (err as Error)?.message, + error: err, }); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts new file mode 100644 index 0000000000..e9f856a784 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/aes-encryption.service.spec.ts @@ -0,0 +1,85 @@ +import { faker } from '@faker-js/faker'; +import { Test } from '@nestjs/testing'; + +import { EncryptionConfigService } from '@/config'; + +import { AesEncryptionService } from './aes-encryption.service'; +import { generateAesEncryptionKey } from './fixtures'; + +const HEX_FORMAT_REGEX = /[0-9a-f]+/; + +const mockGetAesEncryptionKey = jest.fn(); + +const mockEncryptionConfigService: Omit< + EncryptionConfigService, + 'configService' +> = { + get aesEncryptionKey() { + return mockGetAesEncryptionKey(); + }, +}; + +describe('AesEncryptionService', () => { + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + AesEncryptionService, + ], + }).compile(); + + aesEncryptionService = + moduleRef.get(AesEncryptionService); + }); + + beforeEach(() => { + mockGetAesEncryptionKey.mockReturnValue(generateAesEncryptionKey()); + }); + + it('should be defined', () => { + expect(aesEncryptionService).toBeDefined(); + }); + + it('should encrypt data and return envelope string', async () => { + const data = faker.lorem.lines(); + + const envelope = await aesEncryptionService.encrypt(Buffer.from(data)); + + expect(typeof envelope).toBe('string'); + + const [authTag, encrypted, iv] = envelope.split(':'); + + expect(authTag).toMatch(HEX_FORMAT_REGEX); + expect(authTag).toHaveLength(24); + + expect(encrypted).toMatch(HEX_FORMAT_REGEX); + + expect(iv).toHaveLength(32); + expect(iv).toMatch(HEX_FORMAT_REGEX); + }); + + it('should decrypt data encrypted by itslef', async () => { + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + const decrypted = await aesEncryptionService.decrypt(encrypted); + + expect(decrypted.toString()).toBe(data); + }); + + it('should fail to decrypt if different encryption key used', async () => { + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + mockGetAesEncryptionKey.mockReturnValueOnce(generateAesEncryptionKey()); + + const data = faker.lorem.lines(); + + const encrypted = await aesEncryptionService.encrypt(Buffer.from(data)); + + await expect(aesEncryptionService.decrypt(encrypted)).rejects.toThrow(); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts index a2b44a974e..656d5464ac 100644 --- a/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/encryption/fixtures/index.ts @@ -2,5 +2,13 @@ import { faker } from '@faker-js/faker'; export const mockEncryptionConfigService = { // 32-byte key for AES-256-GCM tests - aesEncryptionKey: faker.string.sample(32), + aesEncryptionKey: generateAesEncryptionKey(), }; + +/** + * Generates random key for AES encryption + * with the key length .expected by the app + */ +export function generateAesEncryptionKey(): string { + return faker.string.sample(32); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index ceb1de5de0..5648537f40 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -16,7 +16,6 @@ import { ApiOperation, ApiResponse, ApiTags, - getSchemaPath, } from '@nestjs/swagger'; import type { RequestWithUser } from '@/common/types'; @@ -29,6 +28,7 @@ import { EnrolledApiKeyDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; +import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; import { ExchangeApiKeysService } from './exchange-api-keys.service'; @@ -48,18 +48,23 @@ export class ExchangeApiKeysController { }) @ApiResponse({ status: 200, - schema: { - nullable: true, - allOf: [{ $ref: getSchemaPath(EnrolledApiKeyDto) }], - }, + type: EnrolledApiKeyDto, }) @Get('/') async retrieveEnrolledApiKeys( @Req() request: RequestWithUser, - ): Promise { + ): Promise { const userId = request.user.id; - return this.exchangeApiKeysService.retrievedEnrolledApiKey(userId); + const apiKey = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(userId); + } + + return { + exchangeName: apiKey.exchangeName, + apiKey: apiKey.apiKey, + }; } @ApiOperation({ @@ -114,6 +119,10 @@ export class ExchangeApiKeysController { throw new ForbiddenException(); } - return this.exchangeApiKeysService.retrieve(request.user.id); + const apiKey = await this.exchangeApiKeysService.retrieve(request.user.id); + if (!apiKey) { + throw new ExchangeApiKeyNotFoundError(request.user.id); + } + return apiKey; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..5f26db89b9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -0,0 +1,212 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { EncryptionConfigService } from '@/config/encryption-config.service'; +import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; +import { mockEncryptionConfigService } from '@/modules/encryption/fixtures'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; + +// eslint-disable-next-line import/order +import { ExchangeApiKeysService } from './exchange-api-keys.service'; +import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; + +import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; +import { + ActiveExchangeApiKeyExistsError, + KeyAuthorizationError, +} from './exchange-api-keys.errors'; +import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { + generateExchangeApiKey, + generateExchangeApiKeysData, +} from './fixtures'; +import { ExchangeClient } from '../exchange/types'; + +const mockUserRepository = createMock(); +const mockExchangeApiKeysRepository = createMock(); +const mockExchangeClient = createMock(); + +describe('ExchangeApiKeysService', () => { + let exchangeApiKeysService: ExchangeApiKeysService; + let aesEncryptionService: AesEncryptionService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + AesEncryptionService, + { provide: UserRepository, useValue: mockUserRepository }, + { + provide: ExchangeApiKeysRepository, + useValue: mockExchangeApiKeysRepository, + }, + { + provide: EncryptionConfigService, + useValue: mockEncryptionConfigService, + }, + { + provide: ExchangeClientFactory, + useValue: { create: () => mockExchangeClient }, + }, + ], + }).compile(); + + exchangeApiKeysService = module.get( + ExchangeApiKeysService, + ); + aesEncryptionService = + module.get(AesEncryptionService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should be defined', () => { + expect(exchangeApiKeysService).toBeDefined(); + }); + + describe('enroll', () => { + it.each([ + Object.assign(generateExchangeApiKeysData(), { userId: '' }), + Object.assign(generateExchangeApiKeysData(), { apiKey: '' }), + Object.assign(generateExchangeApiKeysData(), { secretKey: '' }), + ])('should throw if required param is missing [%#]', async (input) => { + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError.constructor).toBe(Error); + expect(thrownError.message).toBe('Invalid arguments'); + }); + + it('should throw if not supported exchange name provided', async () => { + const input = generateExchangeApiKeysData(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (input as any).exchangeName = input.exchangeName.toUpperCase(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError.constructor).toBe(Error); + expect(thrownError.message).toBe('Exchange name is not valid'); + }); + + it('should throw if provided keys do not have required access', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(false); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(KeyAuthorizationError); + expect(thrownError.exchangeName).toBe(input.exchangeName); + }); + + it('should throw if user already has active keys', async () => { + const input = generateExchangeApiKeysData(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce( + generateExchangeApiKey(), + ); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(ActiveExchangeApiKeyExistsError); + }); + + it('should throw if user not exists', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + const input = generateExchangeApiKeysData(); + + let thrownError; + try { + await exchangeApiKeysService.enroll(input); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(UserNotFoundError); + }); + + it('should insert encrypted keys if data is valid', async () => { + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + mockUserRepository.findOneById.mockResolvedValueOnce({ + id: 1, + } as UserEntity); + + const input = generateExchangeApiKeysData(); + + const entity = await exchangeApiKeysService.enroll(input); + + expect(entity.userId).toBe(input.userId); + expect(entity.exchangeName).toBe(input.exchangeName); + expect(entity.apiKey).not.toBe(input.apiKey); + expect(entity.secretKey).not.toBe(input.secretKey); + + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + aesEncryptionService.decrypt(entity.apiKey), + aesEncryptionService.decrypt(entity.secretKey), + ]); + + expect(decryptedApiKey.toString()).toBe(input.apiKey); + expect(decryptedSecretKey.toString()).toBe(input.secretKey); + }); + }); + + describe('retrieve', () => { + it('should return null if key not found for the user', async () => { + const { userId } = generateExchangeApiKeysData(); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); + + const result = await exchangeApiKeysService.retrieve(userId); + expect(result).toBeNull(); + }); + + it('should return decrypted keys', async () => { + const { userId, exchangeName, apiKey, secretKey } = + generateExchangeApiKeysData(); + + const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ + aesEncryptionService.encrypt(Buffer.from(apiKey)), + aesEncryptionService.encrypt(Buffer.from(secretKey)), + ]); + mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce({ + exchangeName, + apiKey: encryptedApiKey, + secretKey: encryptedSecretKey, + } as ExchangeApiKeyEntity); + + const result = await exchangeApiKeysService.retrieve(userId); + + expect(result).not.toBeNull(); + expect(result!.apiKey).toBe(apiKey); + expect(result!.secretKey).toBe(secretKey); + expect( + mockExchangeApiKeysRepository.findOneByUserId, + ).toHaveBeenCalledWith(userId); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index c9c3db7d6b..f7d785a272 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { isValidExchangeName } from '@/common/validators/exchange'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { UserNotFoundError, UserRepository } from '@/modules/user'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { @@ -10,16 +11,14 @@ import { ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; -import { UserNotFoundError, UserRepository } from '../user'; -import { EnrolledApiKeyDto } from './exchange-api-keys.dto'; @Injectable() export class ExchangeApiKeysService { constructor( - private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, - private readonly userRepository: UserRepository, private readonly aesEncryptionService: AesEncryptionService, + private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, ) {} async enroll(input: { @@ -94,24 +93,4 @@ export class ExchangeApiKeysService { secretKey: decryptedSecretKey.toString(), }; } - - async retrievedEnrolledApiKey( - userId: number, - ): Promise { - const enrolledKey = - await this.exchangeApiKeysRepository.findOneByUserId(userId); - - if (!enrolledKey) { - return null; - } - - const decodedApiKey = await this.aesEncryptionService.decrypt( - enrolledKey.apiKey, - ); - - return { - exchangeName: enrolledKey.exchangeName, - apiKey: decodedApiKey.toString(), - }; - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts new file mode 100644 index 0000000000..8597aa47fc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/fixtures/index.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker'; + +import { generateExchangeName } from '@/modules/exchange/fixtures'; + +import { ExchangeApiKeyEntity } from '../exchange-api-key.entity'; + +export function generateExchangeApiKeysData() { + return { + userId: faker.number.int(), + exchangeName: generateExchangeName(), + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; +} + +export function generateExchangeApiKey(): ExchangeApiKeyEntity { + const entity = { + id: faker.number.int(), + ...generateExchangeApiKeysData(), + createdAt: faker.date.recent(), + updatedAt: new Date(), + }; + + return entity as ExchangeApiKeyEntity; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts new file mode 100644 index 0000000000..d1fabc9c61 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -0,0 +1,30 @@ +export function generateGateAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return tokens.map((token) => ({ + currency: token, + available: faker.finance.amount(), + locked: faker.finance.amount(), + freeze: faker.finance.amount(), + })); +} +export function generateMexcAccountBalance(tokens: string[] = []) { + if (tokens.length === 0) { + throw new Error('At least one token must be specified'); + } + return { + balances: tokens.map((token) => ({ + asset: token, + free: faker.finance.amount(), + locked: faker.finance.amount(), + })), + }; +} +import { faker } from '@faker-js/faker'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; + +export function generateExchangeName() { + return faker.helpers.arrayElement(SUPPORTED_EXCHANGE_NAMES); +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts new file mode 100644 index 0000000000..40165d35a9 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/index.ts @@ -0,0 +1 @@ +export * from './exchange'; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts new file mode 100644 index 0000000000..9ac6a7a961 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -0,0 +1,137 @@ +jest.mock('@/logger'); + +import { faker } from '@faker-js/faker'; + +import { ExchangeApiClientError } from './errors'; +import { generateGateAccountBalance } from './fixtures'; +import { GateExchangeClient } from './gate-exchange.client'; + +describe('GateExchangeClient', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new GateExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient( + { apiKey, secretKey }, + { timeoutMs: 1234 }, + ); + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(1234); + }); + }); + + describe('checkRequiredAccess', () => { + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + } as Response); + const result = await client.checkRequiredAccess(); + expect(result).toBe(true); + }); + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + const result = await client.checkRequiredAccess(); + expect(result).toBe(false); + }); + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); + + describe('getAccountBalance', () => { + it('returns 0 if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + const result = await client.getAccountBalance(asset); + expect(result).toBe(0); + }); + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => generateGateAccountBalance(['OTHER']), + } as Response); + const result = await client.getAccountBalance(asset); + expect(result).toBe(0); + }); + it('returns sum of available and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + const balanceFixture = generateGateAccountBalance([asset]); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => balanceFixture, + } as Response); + + const result = await client.getAccountBalance(asset); + expect(result).toBe( + parseFloat(balanceFixture[0].available) + + parseFloat(balanceFixture[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + const client = new GateExchangeClient({ apiKey, secretKey }); + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index eb100f89f9..655f1395d7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -33,13 +33,13 @@ export class GateExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'gate'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs?: number; + private readonly timeoutMs: number; private readonly apiBaseUrl = Environment.isDevelopment() ? DEVELOP_GATE_API_BASE_URL : GATE_API_BASE_URL; private readonly logger = appLogger.child({ - context: 'GateExchangeClient', - exchange: 'gate', + context: GateExchangeClient.name, + exchange: this.id, }); constructor( @@ -51,7 +51,7 @@ export class GateExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs; + this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; } async checkRequiredAccess(): Promise { @@ -78,21 +78,21 @@ export class GateExchangeClient implements ExchangeClient { Timestamp: ts, Accept: 'application/json', }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (res.ok) return true; - this.logger.warn('Gate access check failed', { + this.logger.debug('Gate access check failed', { status: res.status, statusText: res.statusText, }); return false; - } catch (err) { - const message: string = 'Gate network error during access check'; + } catch (error) { + const message: string = 'Failed to check access for Gate'; this.logger.error(message, { - error: err.message, + error, }); - throw new ExchangeApiClientError(`${message}: ${(err as Error).message}`); + throw new ExchangeApiClientError(message); } } @@ -122,7 +122,7 @@ export class GateExchangeClient implements ExchangeClient { Timestamp: ts, Accept: 'application/json', }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (!res.ok) { @@ -154,13 +154,13 @@ export class GateExchangeClient implements ExchangeClient { const entry = data.find((d) => d.currency === asset); return entry ? normalize(entry) : 0; - } catch (err) { - const message: string = 'Gate network error during balance fetch'; + } catch (error) { + const message: string = 'Failed to get account balance for Gate'; this.logger.error(message, { - error: (err as Error)?.message, + error, asset, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + throw new ExchangeApiClientError(message); } } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts new file mode 100644 index 0000000000..d3fece0bc3 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -0,0 +1,178 @@ +jest.mock('@/logger'); + +import { faker } from '@faker-js/faker'; + +import { ExchangeApiClientError } from './errors'; +import { generateMexcAccountBalance } from './fixtures'; +import { MexcExchangeClient } from './mexc-exchange.client'; + +describe('MexcExchangeClient', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('constructor', () => { + it('throws if credentials are missing', () => { + expect( + () => new MexcExchangeClient({ apiKey: '', secretKey: '' }), + ).toThrow(ExchangeApiClientError); + }); + + it('sets fields correctly', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient( + { apiKey, secretKey }, + { timeoutMs: 1234 }, + ); + + expect(client).toBeDefined(); + expect(client['apiKey']).toBe(apiKey); + expect(client['secretKey']).toBe(secretKey); + expect(client['timeoutMs']).toBe(1234); + }); + }); + + describe('signQuery', () => { + it('returns a valid signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + const query = 'timestamp=123&recvWindow=5000'; + const signature = client['signQuery'](query); + + expect(typeof signature).toBe('string'); + expect(signature.length).toBeGreaterThan(0); + }); + }); + + describe('checkRequiredAccess', () => { + it('returns true if fetch is ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + } as Response); + + const result = await client.checkRequiredAccess(); + expect(result).toBe(true); + }); + + it('returns false if fetch is not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + + const result = await client.checkRequiredAccess(); + expect(result).toBe(false); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + + await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); + + describe('getAccountBalance', () => { + it('returns 0 if fetch not ok', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + json: async () => ({}), + } as Response); + + const result = await client.getAccountBalance(asset); + expect(result).toBe(0); + }); + + it('returns 0 if asset not found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => generateMexcAccountBalance(['OTHER']), + } as Response); + + const result = await client.getAccountBalance(asset); + expect(result).toBe(0); + }); + + it('returns sum of free and locked if asset found', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + const balanceFixture = generateMexcAccountBalance([asset]); + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => balanceFixture, + } as Response); + + const result = await client.getAccountBalance(asset); + expect(result).toBe( + parseFloat(balanceFixture.balances[0].free) + + parseFloat(balanceFixture.balances[0].locked), + ); + }); + + it('throws ExchangeApiClientError on fetch error', async () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const asset = faker.finance.currencyCode(); + + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(global, 'fetch').mockImplementation(async () => { + throw new Error('network error'); + }); + + await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( + ExchangeApiClientError, + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index 21d13f588c..466cd548ee 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -16,10 +16,10 @@ export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs?: number; + private readonly timeoutMs: number; private readonly logger = appLogger.child({ - context: 'MexcExchangeClient', - exchange: 'mexc', + context: MexcExchangeClient.name, + exchange: this.id, }); readonly recvWindow = 5000; @@ -32,7 +32,7 @@ export class MexcExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs; + this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; } private signQuery(query: string): string { @@ -50,21 +50,21 @@ export class MexcExchangeClient implements ExchangeClient { const res = await fetch(url, { method: 'GET', headers: { 'X-MEXC-APIKEY': this.apiKey }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (res.ok) return true; - this.logger.warn('MEXC access check failed', { + this.logger.debug('MEXC access check failed', { status: res.status, statusText: res.statusText, }); return false; - } catch (err) { - const message: string = 'MEXC network error during access check'; + } catch (error) { + const message: string = 'Failed to check access for MEXC'; this.logger.error(message, { - error: err.message, + error, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + throw new ExchangeApiClientError(message); } } @@ -81,7 +81,7 @@ export class MexcExchangeClient implements ExchangeClient { headers: { 'X-MEXC-APIKEY': this.apiKey, }, - signal: AbortSignal.timeout(this.timeoutMs ?? DEFAULT_TIMEOUT_MS), + signal: AbortSignal.timeout(this.timeoutMs), } as RequestInit); if (!res.ok) { @@ -103,13 +103,13 @@ export class MexcExchangeClient implements ExchangeClient { (parseFloat(entry.free || '0') || 0) + (parseFloat(entry.locked || '0') || 0); return total; - } catch (err) { - const message: string = 'MEXC network error during balance fetch'; + } catch (error) { + const message: string = 'Failed to get account balance for MEXC'; this.logger.error(message, { - error: err.message, + error, asset, }); - throw new ExchangeApiClientError(`${message}: ${err.message}`); + throw new ExchangeApiClientError(message); } } } diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index dcaecb47c9..1ca505f54b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; import { Web3ConfigService, Web3Network } from '@/config'; +import logger from '@/logger'; import type { Chain, WalletWithProvider } from './types'; @@ -23,6 +24,9 @@ export class Web3Service { private signersByChainId: { [chainId: number]: WalletWithProvider; } = {}; + private readonly logger = logger.child({ + context: Web3Service.name, + }); constructor(private readonly web3ConfigService: Web3ConfigService) { const privateKey = this.web3ConfigService.privateKey; @@ -119,7 +123,9 @@ export class Web3Service { (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); return Number(ethers.formatEther(total)); } catch (error) { - throw new Error(`Failed to fetch staked balance: ${error.message}`); + const message = 'Failed to fetch staked balance'; + this.logger.error(message, { address, error }); + throw new Error(message); } } } From e79625324a420c659476d9b91f04439d27073d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 30 Oct 2025 10:10:34 +0100 Subject: [PATCH 08/26] Refactor GateExchangeClient and MexcExchangeClient to use fetchWithHandling utility for improved error handling and code consistency --- .../modules/exchange/gate-exchange.client.ts | 136 ++++++++---------- .../modules/exchange/mexc-exchange.client.ts | 99 ++++++------- .../server/src/modules/exchange/utils.ts | 27 ++++ 3 files changed, 130 insertions(+), 132 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index 655f1395d7..0f87574d4b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -1,7 +1,7 @@ import { createHash, createHmac } from 'node:crypto'; -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; -import appLogger from '@/logger'; +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; import Environment from '@/utils/environment'; import { ExchangeApiClientError } from './errors'; @@ -10,6 +10,7 @@ import type { ExchangeClientCredentials, ExchangeClientOptions, } from './types'; +import { fetchWithHandling } from './utils'; const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; @@ -33,11 +34,11 @@ export class GateExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'gate'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs: number; + private readonly timeoutMs?: number; private readonly apiBaseUrl = Environment.isDevelopment() ? DEVELOP_GATE_API_BASE_URL : GATE_API_BASE_URL; - private readonly logger = appLogger.child({ + private readonly logger = logger.child({ context: GateExchangeClient.name, exchange: this.id, }); @@ -51,7 +52,7 @@ export class GateExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; + this.timeoutMs = options?.timeoutMs; } async checkRequiredAccess(): Promise { @@ -69,31 +70,25 @@ export class GateExchangeClient implements ExchangeClient { ts, ); - try { - const res = await fetch(`${this.apiBaseUrl}${path}`, { - method, - headers: { - KEY: this.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); + const res = await fetchWithHandling( + this.id, + `${this.apiBaseUrl}${path}`, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); - if (res.ok) return true; - this.logger.debug('Gate access check failed', { - status: res.status, - statusText: res.statusText, - }); - return false; - } catch (error) { - const message: string = 'Failed to check access for Gate'; - this.logger.error(message, { - error, - }); - throw new ExchangeApiClientError(message); - } + if (res.ok) return true; + this.logger.debug('Gate access check failed', { + status: res.status, + statusText: res.statusText, + }); + return false; } async getAccountBalance(asset: string): Promise { @@ -113,54 +108,47 @@ export class GateExchangeClient implements ExchangeClient { ); const url = `${this.apiBaseUrl}${path}?${query}`; - try { - const res = await fetch(url, { - method, - headers: { - KEY: this.apiKey, - SIGN: signature, - Timestamp: ts, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (!res.ok) { - this.logger.warn('Gate balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); - return 0; - } - - const data = (await res.json()) as Array<{ - currency: string; - available: string; - locked?: string; - freeze?: string; - }>; - - const normalize = (item: { - currency: string; - available: string; - locked?: string; - freeze?: string; - }) => { - const free = parseFloat(item.available) || 0; - const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; - return free + locked; - }; + const res = await fetchWithHandling( + this.id, + url, + { + KEY: this.apiKey, + SIGN: signature, + Timestamp: ts, + Accept: 'application/json', + }, + this.logger, + this.timeoutMs, + ); - const entry = data.find((d) => d.currency === asset); - return entry ? normalize(entry) : 0; - } catch (error) { - const message: string = 'Failed to get account balance for Gate'; - this.logger.error(message, { - error, + if (!res.ok) { + this.logger.warn('Gate balance fetch failed', { + status: res.status, + statusText: res.statusText, asset, }); - throw new ExchangeApiClientError(message); + return 0; } + + const data = (await res.json()) as Array<{ + currency: string; + available: string; + locked?: string; + freeze?: string; + }>; + + const normalize = (item: { + currency: string; + available: string; + locked?: string; + freeze?: string; + }) => { + const free = parseFloat(item.available) || 0; + const locked = parseFloat(item.locked ?? item.freeze ?? '0') || 0; + return free + locked; + }; + + const entry = data.find((d) => d.currency === asset); + return entry ? normalize(entry) : 0; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index 466cd548ee..dabc6b2ece 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -1,7 +1,7 @@ import { createHmac } from 'node:crypto'; -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; -import appLogger from '@/logger'; +import { type SupportedExchange } from '@/common/constants'; +import logger from '@/logger'; import { ExchangeApiClientError } from './errors'; import type { @@ -9,6 +9,7 @@ import type { ExchangeClientCredentials, ExchangeClientOptions, } from './types'; +import { fetchWithHandling } from './utils'; const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; @@ -16,8 +17,8 @@ export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; private readonly apiKey: string; private readonly secretKey: string; - private readonly timeoutMs: number; - private readonly logger = appLogger.child({ + private readonly timeoutMs?: number; + private readonly logger = logger.child({ context: MexcExchangeClient.name, exchange: this.id, }); @@ -32,7 +33,7 @@ export class MexcExchangeClient implements ExchangeClient { } this.apiKey = creds.apiKey; this.secretKey = creds.secretKey; - this.timeoutMs = options?.timeoutMs || DEFAULT_TIMEOUT_MS; + this.timeoutMs = options?.timeoutMs; } private signQuery(query: string): string { @@ -46,26 +47,19 @@ export class MexcExchangeClient implements ExchangeClient { const signature = this.signQuery(query); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { 'X-MEXC-APIKEY': this.apiKey }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (res.ok) return true; - this.logger.debug('MEXC access check failed', { - status: res.status, - statusText: res.statusText, - }); - return false; - } catch (error) { - const message: string = 'Failed to check access for MEXC'; - this.logger.error(message, { - error, - }); - throw new ExchangeApiClientError(message); - } + const res = await fetchWithHandling( + this.id, + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (res.ok) return true; + this.logger.debug('MEXC access check failed', { + status: res.status, + statusText: res.statusText, + }); + return false; } async getAccountBalance(asset: string): Promise { @@ -75,41 +69,30 @@ export class MexcExchangeClient implements ExchangeClient { const signature = this.signQuery(query); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - 'X-MEXC-APIKEY': this.apiKey, - }, - signal: AbortSignal.timeout(this.timeoutMs), - } as RequestInit); - - if (!res.ok) { - this.logger.warn('MEXC balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); - return 0; - } - - const data = (await res.json()) as { - balances?: Array<{ asset: string; free: string; locked: string }>; - }; - const balances = data.balances || []; - const entry = balances.find((b) => b.asset === asset); - if (!entry) return 0; - const total = - (parseFloat(entry.free || '0') || 0) + - (parseFloat(entry.locked || '0') || 0); - return total; - } catch (error) { - const message: string = 'Failed to get account balance for MEXC'; - this.logger.error(message, { - error, + const res = await fetchWithHandling( + this.id, + url, + { 'X-MEXC-APIKEY': this.apiKey }, + this.logger, + this.timeoutMs, + ); + if (!res.ok) { + this.logger.warn('MEXC balance fetch failed', { + status: res.status, + statusText: res.statusText, asset, }); - throw new ExchangeApiClientError(message); + return 0; } + const data = (await res.json()) as { + balances?: Array<{ asset: string; free: string; locked: string }>; + }; + const balances = data.balances || []; + const entry = balances.find((b) => b.asset === asset); + if (!entry) return 0; + const total = + (parseFloat(entry.free || '0') || 0) + + (parseFloat(entry.locked || '0') || 0); + return total; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts new file mode 100644 index 0000000000..ab4d3631ec --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -0,0 +1,27 @@ +import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import Logger from '@/logger'; + +import { ExchangeApiClientError } from './errors'; + +export async function fetchWithHandling( + id: SupportedExchange, + url: string, + headers: HeadersInit, + logger: typeof Logger, + timeoutMs?: number, +): Promise { + try { + const res = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(timeoutMs || DEFAULT_TIMEOUT_MS), + }); + return res; + } catch (error) { + const message: string = `Failed to fetch ${id.toUpperCase()}`; + logger.error(message, { + error, + }); + throw new ExchangeApiClientError(message); + } +} From d9f9dd4ecc0a653c180f6538c6fb86b4fe17d93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 30 Oct 2025 11:53:33 +0100 Subject: [PATCH 09/26] Add unit tests for staking requirement in HUMAN App --- .../exchange-api-keys.controller.ts | 10 +-- .../exchange-api-keys.service.ts | 19 ++--- .../model/exchange-api-keys.model.ts | 10 --- .../spec/exchange-api-keys.controller.spec.ts | 85 +++++++++++++++++++ .../spec/exchange-api-keys.fixtures.ts | 33 +++++++ .../spec/exchange-api-keys.service.mock.ts | 10 +++ .../spec/exchange-api-keys.service.spec.ts | 73 ++++++++++++++++ .../spec/job-assignment.controller.spec.ts | 55 ++++++++++++ .../spec/jobs-discovery.controller.spec.ts | 19 ++++- 9 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts create mode 100644 packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index a827806b04..620001c690 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -20,10 +20,8 @@ import { ExchangeApiKeysService } from '../../modules/exchange-api-keys/exchange import { InjectMapper } from '@automapper/nestjs'; import { Mapper } from '@automapper/core'; import { - DeleteExchangeApiKeysCommand, EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysDto, - RetrieveExchangeApiKeysCommand, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @@ -61,9 +59,7 @@ export class ExchangeApiKeysController { @HttpCode(204) @Delete('/') async delete(@Request() req: RequestWithUser): Promise { - const command = new DeleteExchangeApiKeysCommand(); - command.token = req.token; - await this.service.delete(command); + await this.service.delete(req.token); } @ApiOperation({ @@ -73,8 +69,6 @@ export class ExchangeApiKeysController { async retrieve( @Request() req: RequestWithUser, ): Promise { - const command = new RetrieveExchangeApiKeysCommand(); - command.token = req.token; - return this.service.retrieve(command); + return this.service.retrieve(req.token); } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index 2f8d117366..f42d311e3b 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -1,32 +1,23 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { InjectMapper } from '@automapper/nestjs'; -import { Mapper } from '@automapper/core'; import { - DeleteExchangeApiKeysCommand, EnrollExchangeApiKeysCommand, - RetrieveExchangeApiKeysCommand, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @Injectable() export class ExchangeApiKeysService { - constructor( - private readonly reputationOracle: ReputationOracleGateway, - @InjectMapper() private readonly mapper: Mapper, - ) {} + constructor(private readonly reputationOracle: ReputationOracleGateway) {} enroll(command: EnrollExchangeApiKeysCommand): Promise<{ id: number }> { return this.reputationOracle.enrollExchangeApiKeys(command); } - delete(command: DeleteExchangeApiKeysCommand): Promise { - return this.reputationOracle.deleteExchangeApiKeys(command.token); + delete(token: string): Promise { + return this.reputationOracle.deleteExchangeApiKeys(token); } - retrieve( - command: RetrieveExchangeApiKeysCommand, - ): Promise { - return this.reputationOracle.retrieveExchangeApiKeys(command.token); + retrieve(token: string): Promise { + return this.reputationOracle.retrieveExchangeApiKeys(token); } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts index 04179d3eeb..2a22018705 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -30,16 +30,6 @@ export class EnrollExchangeApiKeysData { secretKey: string; } -export class DeleteExchangeApiKeysCommand { - token: string; - exchangeName: string; -} - -export class RetrieveExchangeApiKeysCommand { - token: string; - exchangeName: string; -} - export class RetrieveExchangeApiKeysResponse { apiKey: string; } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts new file mode 100644 index 0000000000..b260195d86 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts @@ -0,0 +1,85 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestWithUser } from '../../../common/interfaces/jwt'; +import { ExchangeApiKeysController } from '../exchange-api-keys.controller'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysDtoFixture, + enrollExchangeApiKeysResponseFixture, + EXCHANGE_NAME, + retrieveExchangeApiKeysResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; +import { exchangeApiKeysServiceMock } from './exchange-api-keys.service.mock'; +import { ExchangeApiKeysProfile } from '../exchange-api-keys.mapper.profile'; + +describe('ExchangeApiKeysController', () => { + let controller: ExchangeApiKeysController; + let service: ExchangeApiKeysService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ExchangeApiKeysController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + ExchangeApiKeysService, + ExchangeApiKeysProfile, + { + provide: ExchangeApiKeysService, + useValue: exchangeApiKeysServiceMock, + }, + ], + }) + .overrideProvider(ExchangeApiKeysService) + .useValue(exchangeApiKeysServiceMock) + .compile(); + + controller = module.get( + ExchangeApiKeysController, + ); + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('enroll', () => { + it('should call service.enroll with mapped command and return id', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.enroll( + EXCHANGE_NAME, + enrollExchangeApiKeysDtoFixture, + req, + ); + expect(service.enroll).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should call service.delete with token', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.delete(req); + expect(service.delete).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(undefined); + }); + }); + + describe('retrieve', () => { + it('should call service.retrieve with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.retrieve(req); + expect(service.retrieve).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts new file mode 100644 index 0000000000..2c74f9b6c4 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts @@ -0,0 +1,33 @@ +import { + EnrollExchangeApiKeysCommand, + EnrollExchangeApiKeysDto, + RetrieveExchangeApiKeysResponse, +} from '../model/exchange-api-keys.model'; + +export const EXCHANGE_NAME = 'mexc'; +export const TOKEN = 'test_user_token'; +export const API_KEY = 'test_api_key'; +export const API_SECRET = 'test_api_secret'; +export const ID = 123; + +export const enrollExchangeApiKeysDtoFixture: EnrollExchangeApiKeysDto = { + apiKey: API_KEY, + secretKey: API_SECRET, +}; + +export const enrollExchangeApiKeysCommandFixture: EnrollExchangeApiKeysCommand = + { + apiKey: API_KEY, + secretKey: API_SECRET, + token: TOKEN, + exchangeName: EXCHANGE_NAME, + }; + +export const enrollExchangeApiKeysResponseFixture = { + id: ID, +}; + +export const retrieveExchangeApiKeysResponseFixture: RetrieveExchangeApiKeysResponse = + { + apiKey: API_KEY, + }; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts new file mode 100644 index 0000000000..d08c84b0c1 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts @@ -0,0 +1,10 @@ +import { + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, +} from './exchange-api-keys.fixtures'; + +export const exchangeApiKeysServiceMock = { + enroll: jest.fn().mockReturnValue(enrollExchangeApiKeysResponseFixture), + delete: jest.fn().mockResolvedValue(undefined), + retrieve: jest.fn().mockReturnValue(retrieveExchangeApiKeysResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts new file mode 100644 index 0000000000..0e0c63037e --- /dev/null +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { ExchangeApiKeysService } from '../exchange-api-keys.service'; +import { + enrollExchangeApiKeysCommandFixture, + enrollExchangeApiKeysResponseFixture, + retrieveExchangeApiKeysResponseFixture, + TOKEN, +} from './exchange-api-keys.fixtures'; + +describe('ExchangeApiKeysService', () => { + let service: ExchangeApiKeysService; + let reputationOracleMock: Partial; + + beforeEach(async () => { + reputationOracleMock = { + enrollExchangeApiKeys: jest.fn(), + deleteExchangeApiKeys: jest.fn(), + retrieveExchangeApiKeys: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ExchangeApiKeysService, + { provide: ReputationOracleGateway, useValue: reputationOracleMock }, + ], + }).compile(); + + service = module.get(ExchangeApiKeysService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enroll', () => { + it('should enroll exchange API keys and return id', async () => { + ( + reputationOracleMock.enrollExchangeApiKeys as jest.Mock + ).mockResolvedValue(enrollExchangeApiKeysResponseFixture); + const result = await service.enroll(enrollExchangeApiKeysCommandFixture); + expect(reputationOracleMock.enrollExchangeApiKeys).toHaveBeenCalledWith( + enrollExchangeApiKeysCommandFixture, + ); + expect(result).toEqual(enrollExchangeApiKeysResponseFixture); + }); + }); + + describe('delete', () => { + it('should delete exchange API keys', async () => { + ( + reputationOracleMock.deleteExchangeApiKeys as jest.Mock + ).mockResolvedValue(undefined); + await service.delete(TOKEN); + expect(reputationOracleMock.deleteExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + }); + }); + + describe('retrieve', () => { + it('should retrieve exchange API keys', async () => { + ( + reputationOracleMock.retrieveExchangeApiKeys as jest.Mock + ).mockResolvedValue(retrieveExchangeApiKeysResponseFixture); + const result = await service.retrieve(TOKEN); + expect(reputationOracleMock.retrieveExchangeApiKeys).toHaveBeenCalledWith( + TOKEN, + ); + expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts index ae69ce4e12..f810cce1ca 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/spec/job-assignment.controller.spec.ts @@ -26,6 +26,7 @@ import { TOKEN, } from './job-assignment.fixtures'; import { jobAssignmentServiceMock } from './job-assignment.service.mock'; +import { ForbiddenException } from '@nestjs/common'; const httpServiceMock = { request: jest.fn().mockImplementation((options) => { @@ -77,6 +78,7 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processJobAssignment).toHaveBeenCalledWith( command, @@ -88,32 +90,85 @@ describe('JobAssignmentController', () => { const command: JobAssignmentCommand = jobAssignmentCommandFixture; const result = await controller.assignJob(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(result).toEqual( jobAssignmentServiceMock.processJobAssignment(command), ); }); + it('should throw ForbiddenException if user is not stake eligible in assignJob', async () => { + const dto: JobAssignmentDto = jobAssignmentDtoFixture; + await expect( + controller.assignJob(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + it('should call service processGetAssignedJobs method with proper fields set', async () => { const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; const command: JobsFetchParamsCommand = jobsFetchParamsCommandFixture; await controller.getAssignedJobs(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.processGetAssignedJobs).toHaveBeenCalledWith( command, ); }); + it('should return empty results if user is not stake eligible in getAssignedJobs', async () => { + const dto: JobsFetchParamsDto = jobsFetchParamsDtoFixture; + const result = await controller.getAssignedJobs(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); + }); + it('should call service refreshAssigments method with proper fields set', async () => { const dto: RefreshJobDto = refreshJobDtoFixture; await controller.refreshAssigments(dto, { token: jobAssignmentToken, + user: { is_stake_eligible: true }, } as RequestWithUser); expect(jobAssignmentService.updateAssignmentsCache).toHaveBeenCalledWith({ oracleAddress: EXCHANGE_ORACLE_ADDRESS, token: TOKEN, }); }); + + it('should throw ForbiddenException if user is not stake eligible in refreshAssigments', async () => { + const dto: RefreshJobDto = refreshJobDtoFixture; + await expect( + controller.refreshAssigments(dto, { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); + }); + + describe('resignAssigment', () => { + it('should throw ForbiddenException if user is not stake eligible in resignAssigment', async () => { + const dto = { assignment_id: '1' }; + await expect( + controller.resignAssigment( + dto as any, + { + token: jobAssignmentToken, + user: { is_stake_eligible: false }, + } as RequestWithUser, + ), + ).rejects.toThrow(new ForbiddenException('Stake requirement not met')); + }); }); }); diff --git a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts index fb4e14b4bf..9b73a31176 100644 --- a/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/jobs-discovery/spec/jobs-discovery.controller.spec.ts @@ -14,6 +14,7 @@ import { dtoFixture, jobsDiscoveryParamsCommandFixture, responseFixture, + jobDiscoveryToken, } from './jobs-discovery.fixtures'; import { jobsDiscoveryServiceMock } from './jobs-discovery.service.mock'; @@ -73,7 +74,7 @@ describe('JobsDiscoveryController', () => { const dto = dtoFixture; const command = jobsDiscoveryParamsCommandFixture; await controller.getJobs(dto, { - user: { qualifications: [] }, + user: { qualifications: [], is_stake_eligible: true }, token: command.token, } as any); command.data.qualifications = []; @@ -90,6 +91,22 @@ describe('JobsDiscoveryController', () => { ).rejects.toThrow( new HttpException('Jobs discovery is disabled', HttpStatus.FORBIDDEN), ); + (configServiceMock as any).jobsDiscoveryFlag = true; + }); + + it('should return empty results if user is not stake eligible', async () => { + const dto = dtoFixture; + const result = await controller.getJobs(dto, { + user: { qualifications: [], is_stake_eligible: false }, + token: jobDiscoveryToken, + } as any); + expect(result).toEqual({ + page: 0, + page_size: 1, + total_pages: 1, + total_results: 0, + results: [], + }); }); }); }); From 5b92eac7f6bfb16204f21c8f31a4b56786514ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 31 Oct 2025 16:10:39 +0100 Subject: [PATCH 10/26] Refactor exchange client methods to improve error handling and code structure; add signature generation for API requests --- .../src/modules/exchange/fixtures/exchange.ts | 8 +- .../exchange/gate-exchange.client.spec.ts | 124 +++++++++++----- .../modules/exchange/gate-exchange.client.ts | 50 +++---- .../exchange/mexc-exchange.client.spec.ts | 133 +++++++++--------- .../modules/exchange/mexc-exchange.client.ts | 31 ++-- .../server/src/modules/exchange/utils.ts | 6 +- 6 files changed, 198 insertions(+), 154 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts index d1fabc9c61..bb56ea256b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -1,3 +1,7 @@ +import { faker } from '@faker-js/faker'; + +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; + export function generateGateAccountBalance(tokens: string[] = []) { if (tokens.length === 0) { throw new Error('At least one token must be specified'); @@ -9,6 +13,7 @@ export function generateGateAccountBalance(tokens: string[] = []) { freeze: faker.finance.amount(), })); } + export function generateMexcAccountBalance(tokens: string[] = []) { if (tokens.length === 0) { throw new Error('At least one token must be specified'); @@ -21,9 +26,6 @@ export function generateMexcAccountBalance(tokens: string[] = []) { })), }; } -import { faker } from '@faker-js/faker'; - -import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; export function generateExchangeName() { return faker.helpers.arrayElement(SUPPORTED_EXCHANGE_NAMES); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts index 9ac6a7a961..3c35786cac 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -1,14 +1,57 @@ jest.mock('@/logger'); +import crypto from 'crypto'; + import { faker } from '@faker-js/faker'; +import nock from 'nock'; import { ExchangeApiClientError } from './errors'; import { generateGateAccountBalance } from './fixtures'; -import { GateExchangeClient } from './gate-exchange.client'; +import { + DEVELOP_GATE_API_BASE_URL, + GateExchangeClient, +} from './gate-exchange.client'; describe('GateExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + afterEach(() => { jest.resetAllMocks(); + nock.cleanAll(); + }); + + describe('signGateRequest', () => { + it('returns the expected signature for known input', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const method = faker.string.sample(); + const path = faker.string.sample(); + const query = faker.string.sample(); + const body = faker.string.sample(); + const secret = faker.string.sample(); + const ts = faker.number.int().toString(); + + const client = new GateExchangeClient({ apiKey, secretKey }); + + const bodyHash = crypto.createHash('sha512').update(body).digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + const expectedSignature = crypto + .createHmac('sha512', secret) + .update(payload) + .digest('hex'); + + const signature = client['signGateRequest']( + method, + path, + query, + body, + secret, + ts, + ); + expect(signature).toBe(expectedSignature); + }); }); describe('constructor', () => { @@ -33,88 +76,93 @@ describe('GateExchangeClient', () => { }); describe('checkRequiredAccess', () => { + const path = '/spot/accounts'; + it('returns true if fetch is ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - } as Response); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200); const result = await client.checkRequiredAccess(); + scope.done(); expect(result).toBe(true); }); + it('returns false if fetch is not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403); const result = await client.checkRequiredAccess(); + scope.done(); expect(result).toBe(false); }); + it('throws ExchangeApiClientError on fetch error', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( ExchangeApiClientError, ); + scope.done(); }); }); describe('getAccountBalance', () => { + const path = '/spot/accounts'; + it('returns 0 if fetch not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(403); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe(0); }); + it('returns 0 if asset not found', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => generateGateAccountBalance(['OTHER']), - } as Response); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateGateAccountBalance(['OTHER'])); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe(0); }); + it('returns sum of available and locked if asset found', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); const balanceFixture = generateGateAccountBalance([asset]); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => balanceFixture, - } as Response); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe( parseFloat(balanceFixture[0].available) + parseFloat(balanceFixture[0].locked), @@ -126,12 +174,14 @@ describe('GateExchangeClient', () => { const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); + const scope = nock(DEVELOP_GATE_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( ExchangeApiClientError, ); + scope.done(); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index 0f87574d4b..c9d95b0ba8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -12,23 +12,9 @@ import type { } from './types'; import { fetchWithHandling } from './utils'; -const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; -const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; - -function signGateRequest( - method: string, - path: string, - query: string, - body: string, - secret: string, - ts: string, -): string { - const bodyHash = createHash('sha512') - .update(body ?? '') - .digest('hex'); - const payload = [method, path, query, bodyHash, ts].join('\n'); - return createHmac('sha512', secret).update(payload).digest('hex'); -} +export const GATE_API_BASE_URL = 'https://api.gateio.ws/api/v4'; +export const DEVELOP_GATE_API_BASE_URL = + 'https://api-testnet.gateapi.io/api/v4'; export class GateExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'gate'; @@ -61,7 +47,7 @@ export class GateExchangeClient implements ExchangeClient { const query = ''; const body = ''; const ts = String(Math.floor(Date.now() / 1000)); - const signature = signGateRequest( + const signature = this.signGateRequest( method, `/api/v4${path}`, query, @@ -71,7 +57,6 @@ export class GateExchangeClient implements ExchangeClient { ); const res = await fetchWithHandling( - this.id, `${this.apiBaseUrl}${path}`, { KEY: this.apiKey, @@ -84,10 +69,6 @@ export class GateExchangeClient implements ExchangeClient { ); if (res.ok) return true; - this.logger.debug('Gate access check failed', { - status: res.status, - statusText: res.statusText, - }); return false; } @@ -98,7 +79,7 @@ export class GateExchangeClient implements ExchangeClient { const body = ''; const ts = String(Math.floor(Date.now() / 1000)); const requestPath = `/api/v4${path}`; - const signature = signGateRequest( + const signature = this.signGateRequest( method, requestPath, query, @@ -109,7 +90,6 @@ export class GateExchangeClient implements ExchangeClient { const url = `${this.apiBaseUrl}${path}?${query}`; const res = await fetchWithHandling( - this.id, url, { KEY: this.apiKey, @@ -122,11 +102,6 @@ export class GateExchangeClient implements ExchangeClient { ); if (!res.ok) { - this.logger.warn('Gate balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); return 0; } @@ -151,4 +126,19 @@ export class GateExchangeClient implements ExchangeClient { const entry = data.find((d) => d.currency === asset); return entry ? normalize(entry) : 0; } + + private signGateRequest( + method: string, + path: string, + query: string, + body: string, + secret: string, + ts: string, + ): string { + const bodyHash = createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [method, path, query, bodyHash, ts].join('\n'); + return createHmac('sha512', secret).update(payload).digest('hex'); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts index d3fece0bc3..8b35c5a95a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -1,14 +1,22 @@ jest.mock('@/logger'); +import crypto from 'crypto'; + import { faker } from '@faker-js/faker'; +import nock from 'nock'; import { ExchangeApiClientError } from './errors'; import { generateMexcAccountBalance } from './fixtures'; -import { MexcExchangeClient } from './mexc-exchange.client'; +import { MexcExchangeClient, MEXC_API_BASE_URL } from './mexc-exchange.client'; describe('MexcExchangeClient', () => { + afterAll(() => { + nock.restore(); + }); + afterEach(() => { jest.resetAllMocks(); + nock.cleanAll(); }); describe('constructor', () => { @@ -21,16 +29,17 @@ describe('MexcExchangeClient', () => { it('sets fields correctly', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); + const timeoutMs = faker.number.int(); const client = new MexcExchangeClient( { apiKey, secretKey }, - { timeoutMs: 1234 }, + { timeoutMs: timeoutMs }, ); expect(client).toBeDefined(); expect(client['apiKey']).toBe(apiKey); expect(client['secretKey']).toBe(secretKey); - expect(client['timeoutMs']).toBe(1234); + expect(client['timeoutMs']).toBe(timeoutMs); }); }); @@ -38,84 +47,90 @@ describe('MexcExchangeClient', () => { it('returns a valid signature', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); + const query = faker.string.sample(); const client = new MexcExchangeClient({ apiKey, secretKey }); - const query = 'timestamp=123&recvWindow=5000'; const signature = client['signQuery'](query); - expect(typeof signature).toBe('string'); - expect(signature.length).toBeGreaterThan(0); + const expectedSignature = crypto + .createHmac('sha256', secretKey) + .update(query) + .digest('hex'); + expect(signature).toBe(expectedSignature); + }); + + it('getSignedQuery returns correct structure and signature', () => { + const apiKey = faker.string.sample(); + const secretKey = faker.string.sample(); + const timestamp = faker.number.int(); + const client = new MexcExchangeClient({ apiKey, secretKey }); + + jest.spyOn(Date, 'now').mockReturnValue(timestamp); + const result = client['getSignedQuery'](); + expect(result).toHaveProperty('query'); + expect(result).toHaveProperty('signature'); + expect(result.query).toBe(`timestamp=${timestamp}&recvWindow=5000`); + + const expectedSignature = crypto + .createHmac('sha256', secretKey) + .update(result.query) + .digest('hex'); + expect(result.signature).toBe(expectedSignature); + jest.restoreAllMocks(); }); }); describe('checkRequiredAccess', () => { + const path = '/account'; + it('returns true if fetch is ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => ({}), - } as Response); - + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(200); const result = await client.checkRequiredAccess(); + scope.done(); expect(result).toBe(true); }); it('returns false if fetch is not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); - + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); const result = await client.checkRequiredAccess(); + scope.done(); expect(result).toBe(false); }); it('throws ExchangeApiClientError on fetch error', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); - + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( ExchangeApiClientError, ); + + scope.done(); }); }); describe('getAccountBalance', () => { + const path = '/account'; + it('returns 0 if fetch not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: false, - status: 403, - statusText: 'Forbidden', - json: async () => ({}), - } as Response); - + const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe(0); }); @@ -123,17 +138,13 @@ describe('MexcExchangeClient', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => generateMexcAccountBalance(['OTHER']), - } as Response); - + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, generateMexcAccountBalance(['OTHER'])); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe(0); }); @@ -141,18 +152,14 @@ describe('MexcExchangeClient', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - const balanceFixture = generateMexcAccountBalance([asset]); - jest.spyOn(global, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - json: async () => balanceFixture, - } as Response); - + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(200, balanceFixture); const result = await client.getAccountBalance(asset); + scope.done(); expect(result).toBe( parseFloat(balanceFixture.balances[0].free) + parseFloat(balanceFixture.balances[0].locked), @@ -163,16 +170,16 @@ describe('MexcExchangeClient', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); - const client = new MexcExchangeClient({ apiKey, secretKey }); - - jest.spyOn(global, 'fetch').mockImplementation(async () => { - throw new Error('network error'); - }); - + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .replyWithError('network error'); await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( ExchangeApiClientError, ); + + scope.done(); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index dabc6b2ece..e92e4e9f01 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -11,7 +11,7 @@ import type { } from './types'; import { fetchWithHandling } from './utils'; -const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; +export const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; export class MexcExchangeClient implements ExchangeClient { readonly id: SupportedExchange = 'mexc'; @@ -42,46 +42,31 @@ export class MexcExchangeClient implements ExchangeClient { async checkRequiredAccess(): Promise { const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = this.signQuery(query); + const { query, signature } = this.getSignedQuery(); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; const res = await fetchWithHandling( - this.id, url, { 'X-MEXC-APIKEY': this.apiKey }, this.logger, this.timeoutMs, ); if (res.ok) return true; - this.logger.debug('MEXC access check failed', { - status: res.status, - statusText: res.statusText, - }); return false; } async getAccountBalance(asset: string): Promise { const path = '/account'; - const timestamp = Date.now(); - const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; - const signature = this.signQuery(query); + const { query, signature } = this.getSignedQuery(); const url = `${MEXC_API_BASE_URL}${path}?${query}&signature=${signature}`; const res = await fetchWithHandling( - this.id, url, { 'X-MEXC-APIKEY': this.apiKey }, this.logger, this.timeoutMs, ); if (!res.ok) { - this.logger.warn('MEXC balance fetch failed', { - status: res.status, - statusText: res.statusText, - asset, - }); return 0; } const data = (await res.json()) as { @@ -95,4 +80,14 @@ export class MexcExchangeClient implements ExchangeClient { (parseFloat(entry.locked || '0') || 0); return total; } + + private getSignedQuery(): { + query: string; + signature: string; + } { + const timestamp = Date.now(); + const query = `timestamp=${timestamp}&recvWindow=${this.recvWindow}`; + const signature = this.signQuery(query); + return { query, signature }; + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts index ab4d3631ec..8a2fd42fdc 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/utils.ts @@ -1,10 +1,9 @@ -import { DEFAULT_TIMEOUT_MS, type SupportedExchange } from '@/common/constants'; +import { DEFAULT_TIMEOUT_MS } from '@/common/constants'; import Logger from '@/logger'; import { ExchangeApiClientError } from './errors'; export async function fetchWithHandling( - id: SupportedExchange, url: string, headers: HeadersInit, logger: typeof Logger, @@ -18,8 +17,9 @@ export async function fetchWithHandling( }); return res; } catch (error) { - const message: string = `Failed to fetch ${id.toUpperCase()}`; + const message: string = `Failed to make request for exchange`; logger.error(message, { + url, error, }); throw new ExchangeApiClientError(message); From a7880a94415f5c5c8c71260ab325cb7a5cb5c0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 11 Dec 2025 17:48:27 +0100 Subject: [PATCH 11/26] Refactor GateExchangeClient and MexcExchangeClient tests for improved error handling and signature generation; use jest fake timers for consistent timestamp handling --- .../exchange/gate-exchange.client.spec.ts | 69 +++++++++++++------ .../modules/exchange/gate-exchange.client.ts | 26 +++---- .../exchange/mexc-exchange.client.spec.ts | 58 ++++++++-------- 3 files changed, 89 insertions(+), 64 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts index 3c35786cac..6185c468f1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -30,26 +30,36 @@ describe('GateExchangeClient', () => { const path = faker.string.sample(); const query = faker.string.sample(); const body = faker.string.sample(); - const secret = faker.string.sample(); - const ts = faker.number.int().toString(); + const now = faker.number.int(); const client = new GateExchangeClient({ apiKey, secretKey }); - const bodyHash = crypto.createHash('sha512').update(body).digest('hex'); - const payload = [method, path, query, bodyHash, ts].join('\n'); - const expectedSignature = crypto - .createHmac('sha512', secret) - .update(payload) - .digest('hex'); - - const signature = client['signGateRequest']( + jest.useFakeTimers({ now }); + const { signature, timestamp } = client['signGateRequest']( method, path, query, body, - secret, - ts, ); + jest.useRealTimers(); + + const bodyHash = crypto + .createHash('sha512') + .update(body ?? '') + .digest('hex'); + const payload = [ + method, + path, + query, + bodyHash, + String(Math.floor(now / 1000)), + ].join('\n'); + const expectedSignature = crypto + .createHmac('sha512', secretKey) + .update(payload) + .digest('hex'); + + expect(timestamp).toBe(String(Math.floor(now / 1000))); expect(signature).toBe(expectedSignature); }); }); @@ -64,14 +74,15 @@ describe('GateExchangeClient', () => { it('sets fields correctly', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); + const timeoutMs = faker.number.int(); const client = new GateExchangeClient( { apiKey, secretKey }, - { timeoutMs: 1234 }, + { timeoutMs: timeoutMs }, ); expect(client).toBeDefined(); expect(client['apiKey']).toBe(apiKey); expect(client['secretKey']).toBe(secretKey); - expect(client['timeoutMs']).toBe(1234); + expect(client['timeoutMs']).toBe(timeoutMs); }); }); @@ -112,10 +123,19 @@ describe('GateExchangeClient', () => { .get(path) .query(true) .replyWithError('network error'); - await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( - ExchangeApiClientError, - ); + let thrownError; + try { + await client.checkRequiredAccess(); + } catch (error) { + thrownError = error; + } + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); }); }); @@ -178,10 +198,19 @@ describe('GateExchangeClient', () => { .get(path) .query(true) .replyWithError('network error'); - await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( - ExchangeApiClientError, - ); + let thrownError; + try { + await client.getAccountBalance(asset); + } catch (error) { + thrownError = error; + } + scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index c9d95b0ba8..38fa90d8db 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -46,14 +46,11 @@ export class GateExchangeClient implements ExchangeClient { const path = '/spot/accounts'; const query = ''; const body = ''; - const ts = String(Math.floor(Date.now() / 1000)); - const signature = this.signGateRequest( + const { signature, timestamp } = this.signGateRequest( method, `/api/v4${path}`, query, body, - this.secretKey, - ts, ); const res = await fetchWithHandling( @@ -61,7 +58,7 @@ export class GateExchangeClient implements ExchangeClient { { KEY: this.apiKey, SIGN: signature, - Timestamp: ts, + Timestamp: timestamp, Accept: 'application/json', }, this.logger, @@ -77,15 +74,12 @@ export class GateExchangeClient implements ExchangeClient { const path = '/spot/accounts'; const query = `currency=${encodeURIComponent(asset)}`; const body = ''; - const ts = String(Math.floor(Date.now() / 1000)); const requestPath = `/api/v4${path}`; - const signature = this.signGateRequest( + const { signature, timestamp } = this.signGateRequest( method, requestPath, query, body, - this.secretKey, - ts, ); const url = `${this.apiBaseUrl}${path}?${query}`; @@ -94,7 +88,7 @@ export class GateExchangeClient implements ExchangeClient { { KEY: this.apiKey, SIGN: signature, - Timestamp: ts, + Timestamp: timestamp, Accept: 'application/json', }, this.logger, @@ -132,13 +126,15 @@ export class GateExchangeClient implements ExchangeClient { path: string, query: string, body: string, - secret: string, - ts: string, - ): string { + ): { signature: string; timestamp: string } { + const timestamp = String(Math.floor(Date.now() / 1000)); const bodyHash = createHash('sha512') .update(body ?? '') .digest('hex'); - const payload = [method, path, query, bodyHash, ts].join('\n'); - return createHmac('sha512', secret).update(payload).digest('hex'); + const payload = [method, path, query, bodyHash, timestamp].join('\n'); + const signature = createHmac('sha512', this.secretKey) + .update(payload) + .digest('hex'); + return { signature, timestamp }; } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts index 8b35c5a95a..dbc44c07c9 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -1,6 +1,6 @@ jest.mock('@/logger'); -import crypto from 'crypto'; +import { createHmac } from 'crypto'; import { faker } from '@faker-js/faker'; import nock from 'nock'; @@ -44,40 +44,24 @@ describe('MexcExchangeClient', () => { }); describe('signQuery', () => { - it('returns a valid signature', () => { - const apiKey = faker.string.sample(); - const secretKey = faker.string.sample(); - const query = faker.string.sample(); - - const client = new MexcExchangeClient({ apiKey, secretKey }); - - const signature = client['signQuery'](query); - - const expectedSignature = crypto - .createHmac('sha256', secretKey) - .update(query) - .digest('hex'); - expect(signature).toBe(expectedSignature); - }); - it('getSignedQuery returns correct structure and signature', () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); - const timestamp = faker.number.int(); const client = new MexcExchangeClient({ apiKey, secretKey }); - jest.spyOn(Date, 'now').mockReturnValue(timestamp); + const now = Date.now(); + jest.useFakeTimers({ now }); const result = client['getSignedQuery'](); + jest.useRealTimers(); + expect(result).toHaveProperty('query'); expect(result).toHaveProperty('signature'); - expect(result.query).toBe(`timestamp=${timestamp}&recvWindow=5000`); + expect(result.query).toBe(`timestamp=${now}&recvWindow=5000`); - const expectedSignature = crypto - .createHmac('sha256', secretKey) + const expectedSignature = createHmac('sha256', secretKey) .update(result.query) .digest('hex'); expect(result.signature).toBe(expectedSignature); - jest.restoreAllMocks(); }); }); @@ -112,11 +96,19 @@ describe('MexcExchangeClient', () => { .get(path) .query(true) .replyWithError('network error'); - await expect(client.checkRequiredAccess()).rejects.toBeInstanceOf( - ExchangeApiClientError, - ); + let thrownError: unknown; + try { + await client.checkRequiredAccess(); + } catch (error) { + thrownError = error; + } scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); }); }); @@ -175,11 +167,19 @@ describe('MexcExchangeClient', () => { .get(path) .query(true) .replyWithError('network error'); - await expect(client.getAccountBalance(asset)).rejects.toBeInstanceOf( - ExchangeApiClientError, - ); + let thrownError: unknown; + try { + await client.getAccountBalance(asset); + } catch (error) { + thrownError = error; + } scope.done(); + + expect(thrownError).toBeInstanceOf(ExchangeApiClientError); + expect((thrownError as Error).message).toBe( + 'Failed to make request for exchange', + ); }); }); }); From 14e0cb8d8695a1decc32d775ca29707bed43ded5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 15 Dec 2025 18:41:03 +0100 Subject: [PATCH 12/26] Add exchange stake summary retrieval and allowed exchanges - Refactor AuthService to use exchangeApiKeysService for fetching staked balances. - Add getExchangeStakedBalance and getStakeSummary methods in ExchangeApiKeysService. - Introduce SupportedExchangesResponseDto and StakeSummaryResponseDto for API responses. - Enhance error handling with ExchangeProviderResponseError for exchange API failures. - Update GateExchangeClient and MexcExchangeClient to throw ExchangeProviderResponseError on API errors. - Add tests for new functionality in ExchangeApiKeysService and related components. --- .../src/modules/auth/auth.service.spec.ts | 62 ++++----- .../server/src/modules/auth/auth.service.ts | 52 +++---- .../exchange-api-keys.controller.ts | 22 ++- .../exchange-api-keys.dto.ts | 16 +++ .../exchange-api-keys.error-filter.ts | 14 +- .../exchange-api-keys.module.ts | 3 +- .../exchange-api-keys.service.spec.ts | 130 +++++++++++++++++- .../exchange-api-keys.service.ts | 52 ++++++- .../src/modules/exchange-api-keys/types.ts | 5 + .../server/src/modules/exchange/errors.ts | 12 ++ .../exchange/gate-exchange.client.spec.ts | 18 ++- .../modules/exchange/gate-exchange.client.ts | 12 +- .../exchange/mexc-exchange.client.spec.ts | 21 ++- .../modules/exchange/mexc-exchange.client.ts | 12 +- .../modules/storage/storage.service.spec.ts | 1 + .../server/src/modules/web3/web3.service.ts | 24 ++-- 16 files changed, 350 insertions(+), 106 deletions(-) create mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts 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 92e5de90f3..18fa333ad4 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 @@ -16,8 +16,6 @@ import { Web3ConfigService, } from '@/config'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeClientFactory } from '@/modules/exchange'; -import type { ExchangeClient } from '@/modules/exchange/types'; import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { SiteKeyRepository } from '@/modules/user'; import { UserEntity, UserRepository, UserService } from '@/modules/user'; @@ -33,7 +31,6 @@ import * as AuthErrors from './auth.error'; import { AuthService } from './auth.service'; import { TokenEntity, TokenType } from './token.entity'; import { TokenRepository } from './token.repository'; -import { generateExchangeApiKeysData } from '../exchange-api-keys/fixtures'; const mockKVStoreUtils = jest.mocked(KVStoreUtils); @@ -69,7 +66,6 @@ const mockTokenRepository = createMock(); const mockUserRepository = createMock(); const mockUserService = createMock(); const mockExchangeApiKeysService = createMock(); -const mockExchangeClientFactory = createMock(); const mockWeb3Service = createMock(); describe('AuthService', () => { @@ -104,7 +100,6 @@ describe('AuthService', () => { provide: ExchangeApiKeysService, useValue: mockExchangeApiKeysService, }, - { provide: ExchangeClientFactory, useValue: mockExchangeClientFactory }, { provide: StakingConfigService, useValue: mockStakingConfigService }, { provide: Web3Service, useValue: mockWeb3Service }, ], @@ -696,7 +691,9 @@ describe('AuthService', () => { const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect(mockExchangeApiKeysService.retrieve).not.toHaveBeenCalled(); + expect( + mockExchangeApiKeysService.getExchangeStakedBalance, + ).not.toHaveBeenCalled(); expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); }); @@ -710,21 +707,16 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( - generateExchangeApiKeysData(), - ); - - const mockClient = { - getAccountBalance: jest.fn().mockResolvedValue(1500), - }; - mockExchangeClientFactory.create.mockResolvedValueOnce( - mockClient as unknown as ExchangeClient, + mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( + 1500, ); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect( + mockExchangeApiKeysService.getExchangeStakedBalance, + ).toHaveBeenCalledWith(user.id); expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); }); @@ -740,22 +732,17 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( - generateExchangeApiKeysData(), - ); - - const mockClient = { - getAccountBalance: jest.fn().mockResolvedValue(400), - }; - mockExchangeClientFactory.create.mockResolvedValueOnce( - mockClient as unknown as ExchangeClient, + mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( + 400, ); mockWeb3Service.getStakedBalance.mockResolvedValueOnce(600); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect( + mockExchangeApiKeysService.getExchangeStakedBalance, + ).toHaveBeenCalledWith(user.id); expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( user.evmAddress, @@ -772,13 +759,17 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).minThreshold = 1000; - mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); + mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( + 0, + ); mockWeb3Service.getStakedBalance.mockResolvedValueOnce(500); const result = await service['checkStakeEligible'](user); expect(result).toBe(false); - expect(mockExchangeClientFactory.create).not.toHaveBeenCalled(); + expect( + mockExchangeApiKeysService.getExchangeStakedBalance, + ).toHaveBeenCalledWith(user.id); expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); }); @@ -794,22 +785,17 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.retrieve.mockResolvedValueOnce( - generateExchangeApiKeysData(), - ); - - const mockClient = { - getAccountBalance: jest.fn().mockRejectedValue(new Error('network')), - }; - mockExchangeClientFactory.create.mockResolvedValueOnce( - mockClient as unknown as ExchangeClient, + mockExchangeApiKeysService.getExchangeStakedBalance.mockRejectedValueOnce( + new Error('network'), ); mockWeb3Service.getStakedBalance.mockResolvedValueOnce(1200); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect(mockExchangeClientFactory.create).toHaveBeenCalledTimes(1); + expect( + mockExchangeApiKeysService.getExchangeStakedBalance, + ).toHaveBeenCalledWith(user.id); expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); }); }); 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 f1f6a0e502..eb55f03440 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 @@ -2,7 +2,6 @@ import { KVStoreKeys, KVStoreUtils, Role } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { SupportedExchange } from '@/common/constants'; import { SignatureType, UserRole, UserStatus } from '@/common/enums'; import { AuthConfigService, @@ -13,7 +12,6 @@ import { } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { OperatorStatus, @@ -60,10 +58,9 @@ export class AuthService { private readonly tokenRepository: TokenRepository, private readonly userRepository: UserRepository, private readonly userService: UserService, + private readonly web3ConfigService: Web3ConfigService, private readonly exchangeApiKeysService: ExchangeApiKeysService, - private readonly exchangeClientFactory: ExchangeClientFactory, private readonly stakingConfigService: StakingConfigService, - private readonly web3ConfigService: Web3ConfigService, private readonly web3Service: Web3Service, ) {} @@ -282,41 +279,36 @@ export class AuthService { ): Promise { if (!this.stakingConfigService.eligibilityEnabled) return true; - const apiKeys = await this.exchangeApiKeysService.retrieve(userEntity.id); - let inspectedStakeAmount = 0; - if (apiKeys) { - try { - const client = await this.exchangeClientFactory.create( - apiKeys.exchangeName as SupportedExchange, - { - apiKey: apiKeys.apiKey, - secretKey: apiKeys.secretKey, - }, - { timeoutMs: this.stakingConfigService.timeoutMs }, - ); - const exchangeBalance = await client.getAccountBalance( - this.stakingConfigService.asset, + try { + const exchangeBalance = + await this.exchangeApiKeysService.getExchangeStakedBalance( + userEntity.id, ); - inspectedStakeAmount += exchangeBalance; - } catch (err) { - this.logger.warn('Failed to query exchange balance; continuing', { - userId: userEntity.id, - exchangeName: apiKeys.exchangeName, - error: err, - }); - } + inspectedStakeAmount += exchangeBalance; + } catch (err) { + this.logger.warn('Failed to query exchange balance; continuing', { + userId: userEntity.id, + error: err, + }); } if ( inspectedStakeAmount < this.stakingConfigService.minThreshold && userEntity.evmAddress ) { - const onChainStake = await this.web3Service.getStakedBalance( - userEntity.evmAddress, - ); - inspectedStakeAmount += onChainStake; + try { + const onChainStake = await this.web3Service.getStakedBalance( + userEntity.evmAddress, + ); + inspectedStakeAmount += onChainStake; + } catch (err) { + this.logger.warn('Failed to query on-chain stake; continuing', { + userId: userEntity.id, + error: err, + }); + } } return inspectedStakeAmount >= this.stakingConfigService.minThreshold; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index 5648537f40..c0fa2135f8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -22,10 +22,12 @@ import type { RequestWithUser } from '@/common/types'; import Environment from '@/utils/environment'; import { - ExchangeNameParamDto, EnrollExchangeApiKeysDto, EnrollExchangeApiKeysResponseDto, EnrolledApiKeyDto, + ExchangeNameParamDto, + StakeSummaryResponseDto, + SupportedExchangesResponseDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; @@ -125,4 +127,22 @@ export class ExchangeApiKeysController { } return apiKey; } + + @ApiOperation({ summary: 'List supported exchanges' }) + @ApiResponse({ status: 200, type: SupportedExchangesResponseDto }) + @Get('/supported') + async getSupportedExchanges(): Promise { + return { + exchanges: this.exchangeApiKeysService.getSupportedExchanges(), + }; + } + + @ApiOperation({ summary: 'Retrieve aggregated staking info' }) + @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) + @Get('/stake') + async getStakeSummary( + @Req() request: RequestWithUser, + ): Promise { + return this.exchangeApiKeysService.getStakeSummary(request.user.id); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts index 220f04c953..8e6ea8d2b2 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -39,3 +39,19 @@ export class EnrolledApiKeyDto { @ApiProperty({ name: 'api_key' }) apiKey: string; } + +export class SupportedExchangesResponseDto { + @ApiProperty({ type: [String], enum: SUPPORTED_EXCHANGE_NAMES }) + exchanges: string[]; +} + +export class StakeSummaryResponseDto { + @ApiProperty({ name: 'exchange_stake' }) + exchangeStake: number; + + @ApiProperty({ name: 'on_chain_stake' }) + onChainStake: number; + + @ApiProperty({ name: 'min_threshold' }) + minThreshold: number; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index bb21e69d46..ef64fc3d83 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -15,7 +15,10 @@ import { KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; -import { ExchangeApiClientError } from '../exchange/errors'; +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from '../exchange/errors'; @Catch( UserNotFoundError, @@ -23,6 +26,7 @@ import { ExchangeApiClientError } from '../exchange/errors'; KeyAuthorizationError, ActiveExchangeApiKeyExistsError, ExchangeApiKeyNotFoundError, + ExchangeProviderResponseError, ) export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { private readonly logger = logger.child({ @@ -35,14 +39,16 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { const request = ctx.getRequest(); let status = HttpStatus.INTERNAL_SERVER_ERROR; - if (exception instanceof UserNotFoundError) { - status = HttpStatus.UNPROCESSABLE_ENTITY; - } else if ( + if ( + exception instanceof UserNotFoundError || exception instanceof IncompleteKeySuppliedError || exception instanceof KeyAuthorizationError || exception instanceof ActiveExchangeApiKeyExistsError ) { status = HttpStatus.UNPROCESSABLE_ENTITY; + } else if (exception instanceof ExchangeProviderResponseError) { + status = HttpStatus.SERVICE_UNAVAILABLE; + this.logger.error('Exchange API client error', { error: exception }); } else if (exception instanceof ExchangeApiClientError) { status = HttpStatus.SERVICE_UNAVAILABLE; } else if (exception instanceof ExchangeApiKeyNotFoundError) { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts index 8f975d6914..7c2e582802 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { EncryptionModule } from '@/modules/encryption'; import { ExchangeModule } from '@/modules/exchange/exchange.module'; +import { Web3Module } from '@/modules/web3'; import { ExchangeApiKeysController } from './exchange-api-keys.controller'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @@ -9,7 +10,7 @@ import { ExchangeApiKeysService } from './exchange-api-keys.service'; import { UserModule } from '../user'; @Module({ - imports: [ExchangeModule, EncryptionModule, UserModule], + imports: [ExchangeModule, EncryptionModule, UserModule, Web3Module], providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], controllers: [ExchangeApiKeysController], exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts index 5f26db89b9..efe9690246 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -1,6 +1,9 @@ +import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; +import { StakingConfigService } from '@/config'; import { EncryptionConfigService } from '@/config/encryption-config.service'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; import { mockEncryptionConfigService } from '@/modules/encryption/fixtures'; @@ -8,11 +11,14 @@ import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factor // eslint-disable-next-line import/order import { ExchangeApiKeysService } from './exchange-api-keys.service'; +// eslint-disable-next-line import/order import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; +import { Web3Service } from '../web3'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { ActiveExchangeApiKeyExistsError, + ExchangeApiKeyNotFoundError, KeyAuthorizationError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @@ -25,6 +31,16 @@ import { ExchangeClient } from '../exchange/types'; const mockUserRepository = createMock(); const mockExchangeApiKeysRepository = createMock(); const mockExchangeClient = createMock(); +const mockExchangeClientFactory = { + create: jest.fn().mockReturnValue(mockExchangeClient), +}; +const mockWeb3Service = createMock(); +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: 100, + asset: 'ETH', + timeoutMs: 2000, +}; describe('ExchangeApiKeysService', () => { let exchangeApiKeysService: ExchangeApiKeysService; @@ -46,8 +62,10 @@ describe('ExchangeApiKeysService', () => { }, { provide: ExchangeClientFactory, - useValue: { create: () => mockExchangeClient }, + useValue: mockExchangeClientFactory, }, + { provide: Web3Service, useValue: mockWeb3Service }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, ], }).compile(); @@ -59,7 +77,7 @@ describe('ExchangeApiKeysService', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('should be defined', () => { @@ -209,4 +227,112 @@ describe('ExchangeApiKeysService', () => { ).toHaveBeenCalledWith(userId); }); }); + + describe('getExchangeStakedBalance', () => { + const userId = faker.number.int(); + it('throws ExchangeApiKeyNotFoundError when user has no exchange keys', async () => { + jest + .spyOn(exchangeApiKeysService, 'retrieve') + .mockResolvedValueOnce(null); + + await expect( + exchangeApiKeysService.getExchangeStakedBalance(userId), + ).rejects.toBeInstanceOf(ExchangeApiKeyNotFoundError); + }); + + it('returns balance fetched from exchange client', async () => { + const keys = { + exchangeName: SUPPORTED_EXCHANGE_NAMES[0], + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; + const balance = faker.number.int(); + jest + .spyOn(exchangeApiKeysService, 'retrieve') + .mockResolvedValueOnce(keys); + mockExchangeClient.getAccountBalance.mockResolvedValueOnce(balance); + + const result = + await exchangeApiKeysService.getExchangeStakedBalance(userId); + + expect(mockExchangeClientFactory.create).toHaveBeenCalledWith( + keys.exchangeName, + { + apiKey: keys.apiKey, + secretKey: keys.secretKey, + }, + { timeoutMs: mockStakingConfigService.timeoutMs }, + ); + expect(mockExchangeClient.getAccountBalance).toHaveBeenCalledWith( + mockStakingConfigService.asset, + ); + expect(result).toBe(balance); + }); + }); + + describe('getStakeSummary', () => { + const user = { + id: faker.number.int(), + evmAddress: faker.finance.ethereumAddress(), + }; + const onChainStake = faker.number.int(); + const exchangeStake = faker.number.int(); + + it('throws when user is not found', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + await expect( + exchangeApiKeysService.getStakeSummary(user.id), + ).rejects.toBeInstanceOf(UserNotFoundError); + }); + + it('returns aggregated exchange and on-chain stakes', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(user as UserEntity); + const exchangeStakeSpy = jest + .spyOn(exchangeApiKeysService, 'getExchangeStakedBalance') + .mockResolvedValueOnce(exchangeStake); + mockWeb3Service.getStakedBalance.mockResolvedValueOnce(onChainStake); + + const result = await exchangeApiKeysService.getStakeSummary(user.id); + + expect(exchangeStakeSpy).toHaveBeenCalledWith(user.id); + expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + expect(result).toEqual({ + exchangeStake: exchangeStake, + onChainStake: onChainStake, + minThreshold: mockStakingConfigService.minThreshold, + }); + }); + + it('skips on-chain lookup when user has no address', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce({ + ...user, + evmAddress: null, + } as UserEntity); + const exchangeStakeSpy = jest + .spyOn(exchangeApiKeysService, 'getExchangeStakedBalance') + .mockResolvedValueOnce(exchangeStake); + + const result = await exchangeApiKeysService.getStakeSummary(user.id); + + expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + expect(result).toEqual({ + exchangeStake: exchangeStake, + onChainStake: 0, + minThreshold: mockStakingConfigService.minThreshold, + }); + + exchangeStakeSpy.mockRestore(); + }); + }); + + describe('getSupportedExchanges', () => { + it('returns a copy of supported exchanges constant', () => { + const result = exchangeApiKeysService.getSupportedExchanges(); + + expect(result).toEqual(SUPPORTED_EXCHANGE_NAMES); + }); + }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index f7d785a272..e712335fba 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -1,16 +1,24 @@ import { Injectable } from '@nestjs/common'; +import { + SUPPORTED_EXCHANGE_NAMES, + SupportedExchange, +} from '@/common/constants'; import { isValidExchangeName } from '@/common/validators/exchange'; +import { StakingConfigService } from '@/config'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { UserNotFoundError, UserRepository } from '@/modules/user'; +import { Web3Service } from '@/modules/web3'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { - KeyAuthorizationError, ActiveExchangeApiKeyExistsError, + ExchangeApiKeyNotFoundError, + KeyAuthorizationError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; +import { StakeSummaryData } from './types'; @Injectable() export class ExchangeApiKeysService { @@ -19,6 +27,8 @@ export class ExchangeApiKeysService { private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly exchangeClientFactory: ExchangeClientFactory, private readonly userRepository: UserRepository, + private readonly web3Service: Web3Service, + private readonly stakingConfigService: StakingConfigService, ) {} async enroll(input: { @@ -93,4 +103,44 @@ export class ExchangeApiKeysService { secretKey: decryptedSecretKey.toString(), }; } + + async getExchangeStakedBalance(userId: number): Promise { + const apiKeys = await this.retrieve(userId); + if (!apiKeys) { + throw new ExchangeApiKeyNotFoundError(userId); + } + + const client = await this.exchangeClientFactory.create( + apiKeys.exchangeName as SupportedExchange, + { + apiKey: apiKeys.apiKey, + secretKey: apiKeys.secretKey, + }, + { timeoutMs: this.stakingConfigService.timeoutMs }, + ); + + return client.getAccountBalance(this.stakingConfigService.asset); + } + + async getStakeSummary(userId: number): Promise { + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const exchangeStake = await this.getExchangeStakedBalance(userId); + let onChainStake = 0; + if (user.evmAddress) + onChainStake = await this.web3Service.getStakedBalance(user.evmAddress); + + return { + exchangeStake, + onChainStake, + minThreshold: this.stakingConfigService.minThreshold, + }; + } + + getSupportedExchanges(): string[] { + return [...SUPPORTED_EXCHANGE_NAMES]; + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts new file mode 100644 index 0000000000..fe0d6a3fcc --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts @@ -0,0 +1,5 @@ +export type StakeSummaryData = { + exchangeStake: number; + onChainStake: number; + minThreshold: number; +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts index 9617016bd6..d529464ffa 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/errors.ts @@ -1,3 +1,15 @@ import { BaseError } from '@/common/errors/base'; export class ExchangeApiClientError extends BaseError {} + +export class ExchangeProviderResponseError extends BaseError { + constructor(exchange: string, status: number, detail?: string) { + const exchangeLabel = `${exchange.toUpperCase()} API`; + const fallback = status ? `status ${status}` : 'an error'; + super( + detail + ? `${exchangeLabel} error: ${detail}` + : `${exchangeLabel} responded with ${fallback}`, + ); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts index 6185c468f1..9b7b60d150 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.spec.ts @@ -5,7 +5,10 @@ import crypto from 'crypto'; import { faker } from '@faker-js/faker'; import nock from 'nock'; -import { ExchangeApiClientError } from './errors'; +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; import { generateGateAccountBalance } from './fixtures'; import { DEVELOP_GATE_API_BASE_URL, @@ -142,18 +145,23 @@ describe('GateExchangeClient', () => { describe('getAccountBalance', () => { const path = '/spot/accounts'; - it('returns 0 if fetch not ok', async () => { + it('throws ExchangeProviderResponseError with response body if fetch not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new GateExchangeClient({ apiKey, secretKey }); + const errorPayload = { message: 'forbidden' }; const scope = nock(DEVELOP_GATE_API_BASE_URL) .get(path) .query(true) - .reply(403); - const result = await client.getAccountBalance(asset); + .reply(403, errorPayload); + + const balancePromise = client.getAccountBalance(asset); + await expect(balancePromise).rejects.toThrow( + ExchangeProviderResponseError, + ); + scope.done(); - expect(result).toBe(0); }); it('returns 0 if asset not found', async () => { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index 38fa90d8db..9ee5f8019f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -4,7 +4,10 @@ import { type SupportedExchange } from '@/common/constants'; import logger from '@/logger'; import Environment from '@/utils/environment'; -import { ExchangeApiClientError } from './errors'; +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; import type { ExchangeClient, ExchangeClientCredentials, @@ -96,7 +99,12 @@ export class GateExchangeClient implements ExchangeClient { ); if (!res.ok) { - return 0; + const errorBody = await res.json(); + throw new ExchangeProviderResponseError( + this.id, + res.status, + errorBody.message as string, + ); } const data = (await res.json()) as Array<{ diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts index dbc44c07c9..5b91f88d3b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.spec.ts @@ -5,7 +5,10 @@ import { createHmac } from 'crypto'; import { faker } from '@faker-js/faker'; import nock from 'nock'; -import { ExchangeApiClientError } from './errors'; +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; import { generateMexcAccountBalance } from './fixtures'; import { MexcExchangeClient, MEXC_API_BASE_URL } from './mexc-exchange.client'; @@ -115,15 +118,23 @@ describe('MexcExchangeClient', () => { describe('getAccountBalance', () => { const path = '/account'; - it('returns 0 if fetch not ok', async () => { + it('throws ExchangeProviderResponseError with response detail if fetch not ok', async () => { const apiKey = faker.string.sample(); const secretKey = faker.string.sample(); const asset = faker.finance.currencyCode(); const client = new MexcExchangeClient({ apiKey, secretKey }); - const scope = nock(MEXC_API_BASE_URL).get(path).query(true).reply(403); - const result = await client.getAccountBalance(asset); + const errorPayload = { msg: 'forbidden' }; + const scope = nock(MEXC_API_BASE_URL) + .get(path) + .query(true) + .reply(400, errorPayload); + + const balancePromise = client.getAccountBalance(asset); + await expect(balancePromise).rejects.toThrow( + ExchangeProviderResponseError, + ); + scope.done(); - expect(result).toBe(0); }); it('returns 0 if asset not found', async () => { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index e92e4e9f01..f7dc0b6f86 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -3,7 +3,10 @@ import { createHmac } from 'node:crypto'; import { type SupportedExchange } from '@/common/constants'; import logger from '@/logger'; -import { ExchangeApiClientError } from './errors'; +import { + ExchangeApiClientError, + ExchangeProviderResponseError, +} from './errors'; import type { ExchangeClient, ExchangeClientCredentials, @@ -67,7 +70,12 @@ export class MexcExchangeClient implements ExchangeClient { this.timeoutMs, ); if (!res.ok) { - return 0; + const errorBody = await res.json(); + throw new ExchangeProviderResponseError( + this.id, + res.status, + errorBody.msg as string, + ); } const data = (await res.json()) as { balances?: Array<{ asset: string; free: string; locked: string }>; diff --git a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts index 9c8864a012..275cd853d5 100644 --- a/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/storage/storage.service.spec.ts @@ -137,6 +137,7 @@ describe('StorageService', () => { mockS3ConfigService.bucket, fileName, fileContent, + undefined, { 'Content-Type': contentType, 'Cache-Control': 'no-store', diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index 8be3600027..45fd33e79a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -111,20 +111,14 @@ export class Web3Service { } async getStakedBalance(address: string): Promise { - try { - const chainId = this.web3ConfigService.reputationNetworkChainId; - const provider = this.getSigner(chainId).provider; - - const stakingClient = await StakingClient.build(provider); - const stakerInfo = await stakingClient.getStakerInfo(address); - - const total = - (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); - return Number(ethers.formatEther(total)); - } catch (error) { - const message = 'Failed to fetch staked balance'; - this.logger.error(message, { address, error }); - throw new Error(message); - } + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.getSigner(chainId).provider; + + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); + + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); } } From 09441445c9dc3a82758860c28f9df77ca006866d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 16 Dec 2025 10:37:19 +0100 Subject: [PATCH 13/26] Add endpoints for retrieving exchange API keys stake summary and supported exchanges --- .../common/config/gateway-config.service.ts | 10 ++++++ .../enums/reputation-oracle-endpoints.ts | 2 ++ .../reputation-oracle.gateway.ts | 31 ++++++++++++++++--- .../exchange-api-keys.controller.ts | 17 ++++++++++ .../exchange-api-keys.service.ts | 9 ++++++ .../model/exchange-api-keys.model.ts | 15 +++++++++ .../spec/exchange-api-keys.controller.spec.ts | 20 ++++++++++++ .../spec/exchange-api-keys.fixtures.ts | 9 ++++++ .../spec/exchange-api-keys.service.mock.ts | 6 ++++ .../spec/exchange-api-keys.service.spec.ts | 28 +++++++++++++++++ .../exchange-api-keys.controller.ts | 9 ++---- .../exchange-api-keys.dto.ts | 5 --- 12 files changed, 146 insertions(+), 15 deletions(-) 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 facb921ecf..88497d5503 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 @@ -156,6 +156,16 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_STAKE_SUMMARY]: { + endpoint: '/exchange-api-keys/stake', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES]: { + endpoint: '/exchange-api-keys/supported', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { 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 2271ce18d5..1ce2c07c0a 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 @@ -25,6 +25,8 @@ export enum ReputationOracleEndpoints { EXCHANGE_API_KEYS_ENROLL = 'exchange_api_keys_enroll', EXCHANGE_API_KEYS_DELETE = 'exchange_api_keys_delete', EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', + EXCHANGE_API_KEYS_STAKE_SUMMARY = 'exchange_api_keys_stake_summary', + EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES = 'exchange_api_keys_supported_exchanges', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', 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 bd9a285233..0aef47365b 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 @@ -99,6 +99,8 @@ import { HttpMethod } from '../../common/enums/http-method'; import { EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysData, + RetrieveExchangeApiKeysResponse, + StakeSummaryResponse, } from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; @Injectable() @@ -169,16 +171,37 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } - async retrieveExchangeApiKeys(token: string): Promise<{ apiKey: string }> { + async retrieveExchangeApiKeys( + token: string, + ): Promise { const options = this.getEndpointOptions( ReputationOracleEndpoints.EXCHANGE_API_KEYS_RETRIEVE, undefined, token, ); - return this.handleRequestToReputationOracle<{ - apiKey: string; - }>(options); + return this.handleRequestToReputationOracle( + options, + ); + } + + async getStakeSummary(token: string): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_STAKE_SUMMARY, + undefined, + token, + ); + return this.handleRequestToReputationOracle(options); + } + + async supportedExchanges(token: string): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES, + undefined, + token, + ); + return this.handleRequestToReputationOracle(options); } + async sendWorkerSignup(command: SignupWorkerCommand): Promise { const signupWorkerData = this.mapper.map( command, diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index 620001c690..ebd33b5f50 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -22,6 +22,7 @@ import { Mapper } from '@automapper/core'; import { EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysDto, + StakeSummaryResponse, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @@ -71,4 +72,20 @@ export class ExchangeApiKeysController { ): Promise { return this.service.retrieve(req.token); } + + @ApiOperation({ summary: 'Get exchange API keys stake summary' }) + @Get('/stake') + async getStakeSummary( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getStakeSummary(req.token); + } + + @ApiOperation({ summary: 'Get supported exchanges' }) + @Get('/supported-exchanges') + async getSupportedExchanges( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getSupportedExchanges(req.token); + } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index f42d311e3b..494930f78c 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; import { EnrollExchangeApiKeysCommand, + StakeSummaryResponse, RetrieveExchangeApiKeysResponse, } from './model/exchange-api-keys.model'; @@ -20,4 +21,12 @@ export class ExchangeApiKeysService { retrieve(token: string): Promise { return this.reputationOracle.retrieveExchangeApiKeys(token); } + + getStakeSummary(token: string): Promise { + return this.reputationOracle.getStakeSummary(token); + } + + getSupportedExchanges(token: string): Promise { + return this.reputationOracle.supportedExchanges(token); + } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts index 2a22018705..b8676c1308 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -32,4 +32,19 @@ export class EnrollExchangeApiKeysData { export class RetrieveExchangeApiKeysResponse { apiKey: string; + exchangeName: string; +} + +export class StakeSummaryResponse { + @ApiProperty({ name: 'exchange_stake' }) + @AutoMap() + exchangeStake: number; + + @ApiProperty({ name: 'on_chain_stake' }) + @AutoMap() + onChainStake: number; + + @ApiProperty({ name: 'min_threshold' }) + @AutoMap() + minThreshold: number; } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts index b260195d86..84ea2e3253 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts @@ -82,4 +82,24 @@ describe('ExchangeApiKeysController', () => { expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); }); }); + + describe('getStakeSummary', () => { + it('should call service.getStakeSummary with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getStakeSummary(req); + expect(service.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(exchangeApiKeysServiceMock.getStakeSummary(TOKEN)); + }); + }); + + describe('getSupportedExchanges', () => { + it('should call service.getSupportedExchanges with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getSupportedExchanges(req); + expect(service.getSupportedExchanges).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual( + exchangeApiKeysServiceMock.getSupportedExchanges(TOKEN), + ); + }); + }); }); diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts index 2c74f9b6c4..156159f5b8 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts @@ -30,4 +30,13 @@ export const enrollExchangeApiKeysResponseFixture = { export const retrieveExchangeApiKeysResponseFixture: RetrieveExchangeApiKeysResponse = { apiKey: API_KEY, + exchangeName: EXCHANGE_NAME, }; + +export const stakeSummaryResponseFixture = { + exchangeStake: 1000, + onChainStake: 500, + minThreshold: 1000, +}; + +export const supportedExchangesResponseFixture = ['Mexc', 'Gate']; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts index d08c84b0c1..1254d8904e 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts @@ -1,10 +1,16 @@ import { enrollExchangeApiKeysResponseFixture, retrieveExchangeApiKeysResponseFixture, + stakeSummaryResponseFixture, + supportedExchangesResponseFixture, } from './exchange-api-keys.fixtures'; export const exchangeApiKeysServiceMock = { enroll: jest.fn().mockReturnValue(enrollExchangeApiKeysResponseFixture), delete: jest.fn().mockResolvedValue(undefined), retrieve: jest.fn().mockReturnValue(retrieveExchangeApiKeysResponseFixture), + getStakeSummary: jest.fn().mockReturnValue(stakeSummaryResponseFixture), + getSupportedExchanges: jest + .fn() + .mockReturnValue(supportedExchangesResponseFixture), }; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts index 0e0c63037e..308b333c0c 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts @@ -5,6 +5,8 @@ import { enrollExchangeApiKeysCommandFixture, enrollExchangeApiKeysResponseFixture, retrieveExchangeApiKeysResponseFixture, + stakeSummaryResponseFixture, + supportedExchangesResponseFixture, TOKEN, } from './exchange-api-keys.fixtures'; @@ -17,6 +19,8 @@ describe('ExchangeApiKeysService', () => { enrollExchangeApiKeys: jest.fn(), deleteExchangeApiKeys: jest.fn(), retrieveExchangeApiKeys: jest.fn(), + getStakeSummary: jest.fn(), + supportedExchanges: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -70,4 +74,28 @@ describe('ExchangeApiKeysService', () => { expect(result).toEqual(retrieveExchangeApiKeysResponseFixture); }); }); + + describe('getStakeSummary', () => { + it('should retrieve stake summary', async () => { + (reputationOracleMock.getStakeSummary as jest.Mock).mockResolvedValue( + stakeSummaryResponseFixture, + ); + const result = await service.getStakeSummary(TOKEN); + expect(reputationOracleMock.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakeSummaryResponseFixture); + }); + }); + + describe('getSupportedExchanges', () => { + it('should retrieve supported exchanges', async () => { + (reputationOracleMock.supportedExchanges as jest.Mock).mockResolvedValue( + supportedExchangesResponseFixture, + ); + const result = await service.getSupportedExchanges(TOKEN); + expect(reputationOracleMock.supportedExchanges).toHaveBeenCalledWith( + TOKEN, + ); + expect(result).toEqual(supportedExchangesResponseFixture); + }); + }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index c0fa2135f8..5020565ab1 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -27,7 +27,6 @@ import { EnrolledApiKeyDto, ExchangeNameParamDto, StakeSummaryResponseDto, - SupportedExchangesResponseDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; @@ -129,12 +128,10 @@ export class ExchangeApiKeysController { } @ApiOperation({ summary: 'List supported exchanges' }) - @ApiResponse({ status: 200, type: SupportedExchangesResponseDto }) + @ApiResponse({ status: 200, type: String, isArray: true }) @Get('/supported') - async getSupportedExchanges(): Promise { - return { - exchanges: this.exchangeApiKeysService.getSupportedExchanges(), - }; + async getSupportedExchanges(): Promise { + return this.exchangeApiKeysService.getSupportedExchanges(); } @ApiOperation({ summary: 'Retrieve aggregated staking info' }) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts index 8e6ea8d2b2..2b6c4b9b5e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -40,11 +40,6 @@ export class EnrolledApiKeyDto { apiKey: string; } -export class SupportedExchangesResponseDto { - @ApiProperty({ type: [String], enum: SUPPORTED_EXCHANGE_NAMES }) - exchanges: string[]; -} - export class StakeSummaryResponseDto { @ApiProperty({ name: 'exchange_stake' }) exchangeStake: number; From 997411fc4c9f645d6ec38f5f9ade1651dad2aed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 16 Dec 2025 10:47:31 +0100 Subject: [PATCH 14/26] Fix ExchangeProviderResponseError status to UNPROCESSABLE_ENTITY --- .../modules/exchange-api-keys/exchange-api-keys.error-filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index ef64fc3d83..fff8e27f55 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -47,7 +47,7 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { ) { status = HttpStatus.UNPROCESSABLE_ENTITY; } else if (exception instanceof ExchangeProviderResponseError) { - status = HttpStatus.SERVICE_UNAVAILABLE; + status = HttpStatus.UNPROCESSABLE_ENTITY; this.logger.error('Exchange API client error', { error: exception }); } else if (exception instanceof ExchangeApiClientError) { status = HttpStatus.SERVICE_UNAVAILABLE; From d6e5c44069bf3007a3df66333a685b7bdd302ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 16 Dec 2025 17:49:54 +0100 Subject: [PATCH 15/26] Refactor exchange API keys and create new staking module --- .../apps/human-app/server/src/app.module.ts | 4 + .../common/config/gateway-config.service.ts | 8 +- .../enums/reputation-oracle-endpoints.ts | 2 +- .../reputation-oracle.gateway.ts | 13 +- .../exchange-api-keys.controller.ts | 12 +- .../exchange-api-keys.service.ts | 8 +- .../model/exchange-api-keys.model.ts | 16 +- .../spec/exchange-api-keys.controller.spec.ts | 9 - .../spec/exchange-api-keys.fixtures.ts | 17 +- .../spec/exchange-api-keys.service.mock.ts | 2 - .../spec/exchange-api-keys.service.spec.ts | 12 - .../modules/staking/model/staking.model.ts | 24 ++ .../staking/spec/staking.controller.spec.ts | 50 ++++ .../modules/staking/spec/staking.fixtures.ts | 7 + .../staking/spec/staking.service.mock.ts | 5 + .../staking/spec/staking.service.spec.ts | 39 +++ .../src/modules/staking/staking.controller.ts | 25 ++ .../src/modules/staking/staking.module.ts | 12 + .../src/modules/staking/staking.service.ts | 12 + .../server/src/app.module.ts | 2 + .../server/src/common/constants/index.ts | 16 +- .../server/src/common/validators/exchange.ts | 32 --- .../server/src/common/validators/index.ts | 1 - .../server/src/modules/auth/auth.module.ts | 4 +- .../src/modules/auth/auth.service.spec.ts | 76 +++--- .../server/src/modules/auth/auth.service.ts | 12 +- .../exchange-api-keys.controller.ts | 15 +- .../exchange-api-keys.dto.ts | 24 +- .../exchange-api-keys.error-filter.ts | 9 +- .../exchange-api-keys.module.ts | 5 +- .../exchange-api-keys.service.spec.ts | 135 +---------- .../exchange-api-keys.service.ts | 56 +---- .../src/modules/exchange-api-keys/types.ts | 2 + .../src/modules/exchange/fixtures/exchange.ts | 4 +- .../modules/exchange/gate-exchange.client.ts | 4 +- .../modules/exchange/mexc-exchange.client.ts | 4 +- .../server/src/modules/staking/index.ts | 3 + .../src/modules/staking/staking.controller.ts | 30 +++ .../server/src/modules/staking/staking.dto.ts | 18 ++ .../modules/staking/staking.error-filter.ts | 34 +++ .../src/modules/staking/staking.module.ts | 17 ++ .../modules/staking/staking.service.spec.ts | 226 ++++++++++++++++++ .../src/modules/staking/staking.service.ts | 112 +++++++++ .../server/src/modules/staking/types.ts | 7 + .../server/src/modules/web3/web3.service.ts | 14 +- 45 files changed, 748 insertions(+), 391 deletions(-) create mode 100644 packages/apps/human-app/server/src/modules/staking/model/staking.model.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/staking.controller.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/staking.module.ts create mode 100644 packages/apps/human-app/server/src/modules/staking/staking.service.ts delete mode 100644 packages/apps/reputation-oracle/server/src/common/validators/exchange.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/index.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts create mode 100644 packages/apps/reputation-oracle/server/src/modules/staking/types.ts diff --git a/packages/apps/human-app/server/src/app.module.ts b/packages/apps/human-app/server/src/app.module.ts index fd0187533e..6de9bbd087 100644 --- a/packages/apps/human-app/server/src/app.module.ts +++ b/packages/apps/human-app/server/src/app.module.ts @@ -60,6 +60,8 @@ import { WorkerController } from './modules/user-worker/worker.controller'; import { WorkerModule } from './modules/user-worker/worker.module'; import { ExchangeApiKeysModule } from './modules/exchange-api-keys/exchange-api-keys.module'; import { ExchangeApiKeysController } from './modules/exchange-api-keys/exchange-api-keys.controller'; +import { StakingController } from './modules/staking/staking.controller'; +import { StakingModule } from './modules/staking/staking.module'; const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); @@ -150,6 +152,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); AbuseModule, GovernanceModule, ExchangeApiKeysModule, + StakingModule, ], controllers: [ AppController, @@ -166,6 +169,7 @@ const JOI_BOOLEAN_STRING_SCHEMA = Joi.string().valid('true', 'false'); AbuseController, GovernanceController, ExchangeApiKeysController, + StakingController, ], exports: [HttpModule], providers: [ 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 88497d5503..c80afa902a 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 @@ -156,13 +156,13 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, - [ReputationOracleEndpoints.EXCHANGE_API_KEYS_STAKE_SUMMARY]: { - endpoint: '/exchange-api-keys/stake', + [ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES]: { + endpoint: '/exchange-api-keys/supported', method: HttpMethod.GET, headers: this.JSON_HEADER, }, - [ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES]: { - endpoint: '/exchange-api-keys/supported', + [ReputationOracleEndpoints.STAKE_SUMMARY]: { + endpoint: '/staking/summary', method: HttpMethod.GET, headers: this.JSON_HEADER, }, 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 1ce2c07c0a..88fc58209f 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 @@ -25,8 +25,8 @@ export enum ReputationOracleEndpoints { EXCHANGE_API_KEYS_ENROLL = 'exchange_api_keys_enroll', EXCHANGE_API_KEYS_DELETE = 'exchange_api_keys_delete', EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', - EXCHANGE_API_KEYS_STAKE_SUMMARY = 'exchange_api_keys_stake_summary', EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES = 'exchange_api_keys_supported_exchanges', + STAKE_SUMMARY = 'stake_summary', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', 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 0aef47365b..cca1c38281 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 @@ -100,8 +100,9 @@ import { EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysData, RetrieveExchangeApiKeysResponse, - StakeSummaryResponse, + SupportedExchangeResponse, } from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; +import { StakeSummaryResponse } from '../../modules/staking/model/staking.model'; @Injectable() export class ReputationOracleGateway { @@ -186,20 +187,24 @@ export class ReputationOracleGateway { async getStakeSummary(token: string): Promise { const options = this.getEndpointOptions( - ReputationOracleEndpoints.EXCHANGE_API_KEYS_STAKE_SUMMARY, + ReputationOracleEndpoints.STAKE_SUMMARY, undefined, token, ); return this.handleRequestToReputationOracle(options); } - async supportedExchanges(token: string): Promise { + async supportedExchanges( + token: string, + ): Promise { const options = this.getEndpointOptions( ReputationOracleEndpoints.EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES, undefined, token, ); - return this.handleRequestToReputationOracle(options); + return this.handleRequestToReputationOracle( + options, + ); } async sendWorkerSignup(command: SignupWorkerCommand): Promise { diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index ebd33b5f50..efb605dfa9 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -22,8 +22,8 @@ import { Mapper } from '@automapper/core'; import { EnrollExchangeApiKeysCommand, EnrollExchangeApiKeysDto, - StakeSummaryResponse, RetrieveExchangeApiKeysResponse, + SupportedExchangeResponse, } from './model/exchange-api-keys.model'; @ApiTags('Exchange-Api-Keys') @@ -73,19 +73,11 @@ export class ExchangeApiKeysController { return this.service.retrieve(req.token); } - @ApiOperation({ summary: 'Get exchange API keys stake summary' }) - @Get('/stake') - async getStakeSummary( - @Request() req: RequestWithUser, - ): Promise { - return this.service.getStakeSummary(req.token); - } - @ApiOperation({ summary: 'Get supported exchanges' }) @Get('/supported-exchanges') async getSupportedExchanges( @Request() req: RequestWithUser, - ): Promise { + ): Promise { return this.service.getSupportedExchanges(req.token); } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index 494930f78c..712335441b 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; import { EnrollExchangeApiKeysCommand, - StakeSummaryResponse, RetrieveExchangeApiKeysResponse, + SupportedExchangeResponse, } from './model/exchange-api-keys.model'; @Injectable() @@ -22,11 +22,7 @@ export class ExchangeApiKeysService { return this.reputationOracle.retrieveExchangeApiKeys(token); } - getStakeSummary(token: string): Promise { - return this.reputationOracle.getStakeSummary(token); - } - - getSupportedExchanges(token: string): Promise { + getSupportedExchanges(token: string): Promise { return this.reputationOracle.supportedExchanges(token); } } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts index b8676c1308..f07c049e26 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/model/exchange-api-keys.model.ts @@ -35,16 +35,10 @@ export class RetrieveExchangeApiKeysResponse { exchangeName: string; } -export class StakeSummaryResponse { - @ApiProperty({ name: 'exchange_stake' }) - @AutoMap() - exchangeStake: number; - - @ApiProperty({ name: 'on_chain_stake' }) - @AutoMap() - onChainStake: number; +export class SupportedExchangeResponse { + @ApiProperty() + name: string; - @ApiProperty({ name: 'min_threshold' }) - @AutoMap() - minThreshold: number; + @ApiProperty() + displayName: string; } diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts index 84ea2e3253..e298642428 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.controller.spec.ts @@ -83,15 +83,6 @@ describe('ExchangeApiKeysController', () => { }); }); - describe('getStakeSummary', () => { - it('should call service.getStakeSummary with token and return response', async () => { - const req: RequestWithUser = { token: TOKEN } as RequestWithUser; - const result = await controller.getStakeSummary(req); - expect(service.getStakeSummary).toHaveBeenCalledWith(TOKEN); - expect(result).toEqual(exchangeApiKeysServiceMock.getStakeSummary(TOKEN)); - }); - }); - describe('getSupportedExchanges', () => { it('should call service.getSupportedExchanges with token and return response', async () => { const req: RequestWithUser = { token: TOKEN } as RequestWithUser; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts index 156159f5b8..8cfda1ab77 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.fixtures.ts @@ -33,10 +33,13 @@ export const retrieveExchangeApiKeysResponseFixture: RetrieveExchangeApiKeysResp exchangeName: EXCHANGE_NAME, }; -export const stakeSummaryResponseFixture = { - exchangeStake: 1000, - onChainStake: 500, - minThreshold: 1000, -}; - -export const supportedExchangesResponseFixture = ['Mexc', 'Gate']; +export const supportedExchangesResponseFixture = [ + { + name: 'mexc', + displayName: 'MEXC Global', + }, + { + name: 'gate', + displayName: 'Gate', + }, +]; diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts index 1254d8904e..357e2142d0 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.mock.ts @@ -1,7 +1,6 @@ import { enrollExchangeApiKeysResponseFixture, retrieveExchangeApiKeysResponseFixture, - stakeSummaryResponseFixture, supportedExchangesResponseFixture, } from './exchange-api-keys.fixtures'; @@ -9,7 +8,6 @@ export const exchangeApiKeysServiceMock = { enroll: jest.fn().mockReturnValue(enrollExchangeApiKeysResponseFixture), delete: jest.fn().mockResolvedValue(undefined), retrieve: jest.fn().mockReturnValue(retrieveExchangeApiKeysResponseFixture), - getStakeSummary: jest.fn().mockReturnValue(stakeSummaryResponseFixture), getSupportedExchanges: jest .fn() .mockReturnValue(supportedExchangesResponseFixture), diff --git a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts index 308b333c0c..f29868a395 100644 --- a/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/exchange-api-keys/spec/exchange-api-keys.service.spec.ts @@ -5,7 +5,6 @@ import { enrollExchangeApiKeysCommandFixture, enrollExchangeApiKeysResponseFixture, retrieveExchangeApiKeysResponseFixture, - stakeSummaryResponseFixture, supportedExchangesResponseFixture, TOKEN, } from './exchange-api-keys.fixtures'; @@ -75,17 +74,6 @@ describe('ExchangeApiKeysService', () => { }); }); - describe('getStakeSummary', () => { - it('should retrieve stake summary', async () => { - (reputationOracleMock.getStakeSummary as jest.Mock).mockResolvedValue( - stakeSummaryResponseFixture, - ); - const result = await service.getStakeSummary(TOKEN); - expect(reputationOracleMock.getStakeSummary).toHaveBeenCalledWith(TOKEN); - expect(result).toEqual(stakeSummaryResponseFixture); - }); - }); - describe('getSupportedExchanges', () => { it('should retrieve supported exchanges', async () => { (reputationOracleMock.supportedExchanges as jest.Mock).mockResolvedValue( diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts new file mode 100644 index 0000000000..552146e73d --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -0,0 +1,24 @@ +import { AutoMap } from '@automapper/classes'; +import { ApiProperty } from '@nestjs/swagger'; + +export class StakeSummaryResponse { + @ApiProperty({ name: 'exchange_stake' }) + @AutoMap() + exchangeStake: number; + + @ApiProperty({ name: 'on_chain_stake' }) + @AutoMap() + onChainStake: number; + + @ApiProperty({ name: 'min_threshold' }) + @AutoMap() + minThreshold: number; + + @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) + @AutoMap() + exchangeError?: string; + + @ApiProperty({ name: 'on_chain_error', required: false, nullable: true }) + @AutoMap() + onChainError?: string; +} diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts new file mode 100644 index 0000000000..f92224d522 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts @@ -0,0 +1,50 @@ +import { classes } from '@automapper/classes'; +import { AutomapperModule } from '@automapper/nestjs'; +import { Test, TestingModule } from '@nestjs/testing'; +import { RequestWithUser } from '../../../common/interfaces/jwt'; +import { TOKEN } from './staking.fixtures'; +import { StakingService } from '../staking.service'; +import { StakingController } from '../staking.controller'; +import { stakingServiceMock } from './staking.service.mock'; + +describe('StakingController', () => { + let controller: StakingController; + let service: StakingService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StakingController], + imports: [ + AutomapperModule.forRoot({ + strategyInitializer: classes(), + }), + ], + providers: [ + StakingService, + { + provide: StakingService, + useValue: stakingServiceMock, + }, + ], + }) + .overrideProvider(StakingService) + .useValue(stakingServiceMock) + .compile(); + + controller = module.get(StakingController); + service = module.get(StakingService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getStakeSummary', () => { + it('should call service.getStakeSummary with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getStakeSummary(req); + expect(service.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakingServiceMock.getStakeSummary(TOKEN)); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts new file mode 100644 index 0000000000..0cf4202d5b --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -0,0 +1,7 @@ +export const TOKEN = 'test_user_token'; + +export const stakeSummaryResponseFixture = { + exchangeStake: 1000, + onChainStake: 500, + minThreshold: 1000, +}; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts new file mode 100644 index 0000000000..08243dd9d7 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts @@ -0,0 +1,5 @@ +import { stakeSummaryResponseFixture } from './staking.fixtures'; + +export const stakingServiceMock = { + getStakeSummary: jest.fn().mockReturnValue(stakeSummaryResponseFixture), +}; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts new file mode 100644 index 0000000000..32dab519d2 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts @@ -0,0 +1,39 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { StakingService } from '../staking.service'; +import { stakeSummaryResponseFixture, TOKEN } from './staking.fixtures'; + +describe('StakingService', () => { + let service: StakingService; + let reputationOracleMock: Partial; + + beforeEach(async () => { + reputationOracleMock = { + getStakeSummary: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingService, + { provide: ReputationOracleGateway, useValue: reputationOracleMock }, + ], + }).compile(); + + service = module.get(StakingService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getStakeSummary', () => { + it('should retrieve stake summary', async () => { + (reputationOracleMock.getStakeSummary as jest.Mock).mockResolvedValue( + stakeSummaryResponseFixture, + ); + const result = await service.getStakeSummary(TOKEN); + expect(reputationOracleMock.getStakeSummary).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakeSummaryResponseFixture); + }); + }); +}); diff --git a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts new file mode 100644 index 0000000000..f8bcdae90f --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts @@ -0,0 +1,25 @@ +import { Mapper } from '@automapper/core'; +import { InjectMapper } from '@automapper/nestjs'; +import { Controller, Get, Request } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RequestWithUser } from '../../common/interfaces/jwt'; +import { StakingService } from './staking.service'; +import { StakeSummaryResponse } from './model/staking.model'; + +@ApiTags('Staking') +@ApiBearerAuth() +@Controller('/staking') +export class StakingController { + constructor( + private readonly service: StakingService, + @InjectMapper() private readonly mapper: Mapper, + ) {} + + @ApiOperation({ summary: 'Get exchange API keys stake summary' }) + @Get('/summary') + async getStakeSummary( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getStakeSummary(req.token); + } +} diff --git a/packages/apps/human-app/server/src/modules/staking/staking.module.ts b/packages/apps/human-app/server/src/modules/staking/staking.module.ts new file mode 100644 index 0000000000..49bc184eda --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { StakingController } from './staking.controller'; +import { StakingService } from './staking.service'; +import { ReputationOracleModule } from '../../integrations/reputation-oracle/reputation-oracle.module'; + +@Module({ + imports: [ReputationOracleModule], + controllers: [StakingController], + providers: [StakingService], + exports: [StakingService], +}) +export class StakingModule {} diff --git a/packages/apps/human-app/server/src/modules/staking/staking.service.ts b/packages/apps/human-app/server/src/modules/staking/staking.service.ts new file mode 100644 index 0000000000..1d75dd95b5 --- /dev/null +++ b/packages/apps/human-app/server/src/modules/staking/staking.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; +import { StakeSummaryResponse } from './model/staking.model'; + +@Injectable() +export class StakingService { + constructor(private readonly reputationOracle: ReputationOracleGateway) {} + + getStakeSummary(token: string): Promise { + return this.reputationOracle.getStakeSummary(token); + } +} diff --git a/packages/apps/reputation-oracle/server/src/app.module.ts b/packages/apps/reputation-oracle/server/src/app.module.ts index 91bcb99048..a57b33c078 100644 --- a/packages/apps/reputation-oracle/server/src/app.module.ts +++ b/packages/apps/reputation-oracle/server/src/app.module.ts @@ -20,6 +20,7 @@ import { KycModule } from './modules/kyc'; import { NDAModule } from './modules/nda'; import { QualificationModule } from './modules/qualification'; import { ReputationModule } from './modules/reputation'; +import { StakingModule } from './modules/staking'; import { UserModule } from './modules/user'; import { IncomingWebhookModule, @@ -74,6 +75,7 @@ import Environment from './utils/environment'; UserModule, NDAModule, ExchangeApiKeysModule, + StakingModule, EscrowCompletionModule, HealthModule, KycModule, 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 2a0061d1c3..52410cb005 100644 --- a/packages/apps/reputation-oracle/server/src/common/constants/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/constants/index.ts @@ -16,7 +16,19 @@ export const LOGOUT_PATH = '/auth/logout'; export const BACKOFF_INTERVAL_SECONDS = 120; -export const SUPPORTED_EXCHANGE_NAMES = ['mexc', 'gate'] as const; -export type SupportedExchange = (typeof SUPPORTED_EXCHANGE_NAMES)[number]; +export enum SupportedExchange { + MEXC = 'mexc', + GATE = 'gate', +} + +export type SupportedExchangeInfo = { + name: SupportedExchange; + displayName: string; +}; + +export const SUPPORTED_EXCHANGES_INFO: readonly SupportedExchangeInfo[] = [ + { name: SupportedExchange.MEXC, displayName: 'MEXC' }, + { name: SupportedExchange.GATE, displayName: 'Gate' }, +] as const; export const DEFAULT_TIMEOUT_MS = 5000; diff --git a/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts b/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts deleted file mode 100644 index 798f0f19b0..0000000000 --- a/packages/apps/reputation-oracle/server/src/common/validators/exchange.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ValidationArguments, - ValidatorConstraint, - ValidatorConstraintInterface, -} from 'class-validator'; - -import { - SUPPORTED_EXCHANGE_NAMES, - type SupportedExchange, -} from '@/common/constants'; - -const validExchangeNameSet = new Set( - SUPPORTED_EXCHANGE_NAMES, -); -export function isValidExchangeName(input: string): input is SupportedExchange { - return validExchangeNameSet.has(input as SupportedExchange); -} - -@ValidatorConstraint({ name: 'ExchangeName', async: false }) -export class ExchangeNameValidator implements ValidatorConstraintInterface { - validate(value: unknown): boolean { - if (typeof value !== 'string') { - return false; - } - - return isValidExchangeName(value); - } - - defaultMessage({ property }: ValidationArguments): string { - return `${property} must be one of the allowed values`; - } -} diff --git a/packages/apps/reputation-oracle/server/src/common/validators/index.ts b/packages/apps/reputation-oracle/server/src/common/validators/index.ts index cc4c0487c2..e71aa847a1 100644 --- a/packages/apps/reputation-oracle/server/src/common/validators/index.ts +++ b/packages/apps/reputation-oracle/server/src/common/validators/index.ts @@ -8,7 +8,6 @@ import { ValidationOptions, } from 'class-validator'; -export * from './exchange'; export * from './password'; export * from './web3'; 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 bb6a77d917..bcb7cd27c6 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 @@ -6,8 +6,8 @@ import { HCaptchaModule } from '@/integrations/hcaptcha'; import { EmailModule } from '@/modules/email'; import { ExchangeModule } from '@/modules/exchange'; import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; +import { StakingModule } from '@/modules/staking'; import { UserModule } from '@/modules/user'; -import { Web3Module } from '@/modules/web3'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -27,10 +27,10 @@ import { TokenRepository } from './token.repository'; }, }), }), - Web3Module, HCaptchaModule, ExchangeModule, ExchangeApiKeysModule, + StakingModule, EmailModule, ], providers: [JwtHttpStrategy, AuthService, TokenRepository], 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 18fa333ad4..9606f53499 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 @@ -16,11 +16,10 @@ import { Web3ConfigService, } from '@/config'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import { StakingService } from '@/modules/staking'; import { SiteKeyRepository } from '@/modules/user'; import { UserEntity, UserRepository, UserService } from '@/modules/user'; import { generateOperator, generateWorkerUser } from '@/modules/user/fixtures'; -import { Web3Service } from '@/modules/web3'; import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; import * as secutiryUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -65,8 +64,7 @@ const mockSiteKeyRepository = createMock(); const mockTokenRepository = createMock(); const mockUserRepository = createMock(); const mockUserService = createMock(); -const mockExchangeApiKeysService = createMock(); -const mockWeb3Service = createMock(); +const mockStakingService = createMock(); describe('AuthService', () => { let service: AuthService; @@ -96,12 +94,8 @@ describe('AuthService', () => { { provide: UserRepository, useValue: mockUserRepository }, { provide: UserService, useValue: mockUserService }, { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, - { - provide: ExchangeApiKeysService, - useValue: mockExchangeApiKeysService, - }, + { provide: StakingService, useValue: mockStakingService }, { provide: StakingConfigService, useValue: mockStakingConfigService }, - { provide: Web3Service, useValue: mockWeb3Service }, ], }).compile(); @@ -692,9 +686,9 @@ describe('AuthService', () => { expect(result).toBe(true); expect( - mockExchangeApiKeysService.getExchangeStakedBalance, + mockStakingService.getExchangeStakedBalance, ).not.toHaveBeenCalled(); - expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + expect(mockStakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); }); it('returns true when exchange balance meets threshold (no on-chain call)', async () => { @@ -707,17 +701,15 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( - 1500, - ); + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(1500); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect( - mockExchangeApiKeysService.getExchangeStakedBalance, - ).toHaveBeenCalledWith(user.id); - expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); }); it('returns true when exchange balance below threshold but on-chain makes up the difference', async () => { @@ -732,19 +724,19 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( - 400, - ); - mockWeb3Service.getStakedBalance.mockResolvedValueOnce(600); + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(400); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(600); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect( - mockExchangeApiKeysService.getExchangeStakedBalance, - ).toHaveBeenCalledWith(user.id); - expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); - expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledWith( user.evmAddress, ); }); @@ -759,18 +751,18 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).minThreshold = 1000; - mockExchangeApiKeysService.getExchangeStakedBalance.mockResolvedValueOnce( - 0, - ); - mockWeb3Service.getStakedBalance.mockResolvedValueOnce(500); + mockStakingService.getExchangeStakedBalance.mockResolvedValueOnce(0); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(500); const result = await service['checkStakeEligible'](user); expect(result).toBe(false); - expect( - mockExchangeApiKeysService.getExchangeStakedBalance, - ).toHaveBeenCalledWith(user.id); - expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); }); it('continues on exchange error and returns based on on-chain stake', async () => { @@ -785,18 +777,20 @@ describe('AuthService', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockStakingConfigService as any).asset = 'HMT'; - mockExchangeApiKeysService.getExchangeStakedBalance.mockRejectedValueOnce( + mockStakingService.getExchangeStakedBalance.mockRejectedValueOnce( new Error('network'), ); - mockWeb3Service.getStakedBalance.mockResolvedValueOnce(1200); + mockStakingService.getOnChainStakedBalance.mockResolvedValueOnce(1200); const result = await service['checkStakeEligible'](user); expect(result).toBe(true); - expect( - mockExchangeApiKeysService.getExchangeStakedBalance, - ).toHaveBeenCalledWith(user.id); - expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledTimes(1); + expect(mockStakingService.getExchangeStakedBalance).toHaveBeenCalledWith( + user.id, + ); + expect(mockStakingService.getOnChainStakedBalance).toHaveBeenCalledTimes( + 1, + ); }); }); 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 eb55f03440..24afae39c2 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 @@ -12,7 +12,7 @@ import { } from '@/config'; import logger from '@/logger'; import { EmailAction, EmailService } from '@/modules/email'; -import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; +import { StakingService } from '@/modules/staking'; import { OperatorStatus, SiteKeyRepository, @@ -23,7 +23,6 @@ import { type OperatorUserEntity, type Web2UserEntity, } from '@/modules/user'; -import { Web3Service } from '@/modules/web3'; import * as httpUtils from '@/utils/http'; import * as securityUtils from '@/utils/security'; import * as web3Utils from '@/utils/web3'; @@ -59,9 +58,8 @@ export class AuthService { private readonly userRepository: UserRepository, private readonly userService: UserService, private readonly web3ConfigService: Web3ConfigService, - private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly stakingService: StakingService, private readonly stakingConfigService: StakingConfigService, - private readonly web3Service: Web3Service, ) {} async signup(email: string, password: string): Promise { @@ -283,9 +281,7 @@ export class AuthService { try { const exchangeBalance = - await this.exchangeApiKeysService.getExchangeStakedBalance( - userEntity.id, - ); + await this.stakingService.getExchangeStakedBalance(userEntity.id); inspectedStakeAmount += exchangeBalance; } catch (err) { this.logger.warn('Failed to query exchange balance; continuing', { @@ -299,7 +295,7 @@ export class AuthService { userEntity.evmAddress ) { try { - const onChainStake = await this.web3Service.getStakedBalance( + const onChainStake = await this.stakingService.getOnChainStakedBalance( userEntity.evmAddress, ); inspectedStakeAmount += onChainStake; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index 5020565ab1..7ce0c5dca6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -26,7 +26,7 @@ import { EnrollExchangeApiKeysResponseDto, EnrolledApiKeyDto, ExchangeNameParamDto, - StakeSummaryResponseDto, + SupportedExchangeDto, } from './exchange-api-keys.dto'; import { ExchangeApiKeysControllerErrorsFilter } from './exchange-api-keys.error-filter'; import { ExchangeApiKeyNotFoundError } from './exchange-api-keys.errors'; @@ -128,18 +128,9 @@ export class ExchangeApiKeysController { } @ApiOperation({ summary: 'List supported exchanges' }) - @ApiResponse({ status: 200, type: String, isArray: true }) + @ApiResponse({ status: 200, type: SupportedExchangeDto, isArray: true }) @Get('/supported') - async getSupportedExchanges(): Promise { + async getSupportedExchanges(): Promise { return this.exchangeApiKeysService.getSupportedExchanges(); } - - @ApiOperation({ summary: 'Retrieve aggregated staking info' }) - @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) - @Get('/stake') - async getStakeSummary( - @Req() request: RequestWithUser, - ): Promise { - return this.exchangeApiKeysService.getStakeSummary(request.user.id); - } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts index 2b6c4b9b5e..e9546e80d8 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength, Validate } from 'class-validator'; +import { IsEnum, IsNotEmpty, IsString, MaxLength } from 'class-validator'; -import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; -import { ExchangeNameValidator } from '@/common/validators'; +import { SupportedExchange } from '@/common/constants'; export class EnrollExchangeApiKeysDto { @ApiProperty({ name: 'api_key' }) @@ -21,10 +20,10 @@ export class EnrollExchangeApiKeysDto { export class ExchangeNameParamDto { @ApiProperty({ name: 'exchange_name', - enum: SUPPORTED_EXCHANGE_NAMES, + enum: SupportedExchange, }) - @Validate(ExchangeNameValidator) - exchangeName: string; + @IsEnum(SupportedExchange) + exchangeName: SupportedExchange; } export class EnrollExchangeApiKeysResponseDto { @@ -40,13 +39,10 @@ export class EnrolledApiKeyDto { apiKey: string; } -export class StakeSummaryResponseDto { - @ApiProperty({ name: 'exchange_stake' }) - exchangeStake: number; - - @ApiProperty({ name: 'on_chain_stake' }) - onChainStake: number; +export class SupportedExchangeDto { + @ApiProperty({ name: 'name' }) + name: string; - @ApiProperty({ name: 'min_threshold' }) - minThreshold: number; + @ApiProperty({ name: 'display_name' }) + displayName: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index fff8e27f55..09413aebe5 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -15,10 +15,7 @@ import { KeyAuthorizationError, ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; -import { - ExchangeApiClientError, - ExchangeProviderResponseError, -} from '../exchange/errors'; +import { ExchangeApiClientError } from '../exchange/errors'; @Catch( UserNotFoundError, @@ -26,7 +23,6 @@ import { KeyAuthorizationError, ActiveExchangeApiKeyExistsError, ExchangeApiKeyNotFoundError, - ExchangeProviderResponseError, ) export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { private readonly logger = logger.child({ @@ -46,9 +42,6 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { exception instanceof ActiveExchangeApiKeyExistsError ) { status = HttpStatus.UNPROCESSABLE_ENTITY; - } else if (exception instanceof ExchangeProviderResponseError) { - status = HttpStatus.UNPROCESSABLE_ENTITY; - this.logger.error('Exchange API client error', { error: exception }); } else if (exception instanceof ExchangeApiClientError) { status = HttpStatus.SERVICE_UNAVAILABLE; } else if (exception instanceof ExchangeApiKeyNotFoundError) { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts index 7c2e582802..5d7a416588 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.module.ts @@ -2,15 +2,14 @@ import { Module } from '@nestjs/common'; import { EncryptionModule } from '@/modules/encryption'; import { ExchangeModule } from '@/modules/exchange/exchange.module'; -import { Web3Module } from '@/modules/web3'; +import { UserModule } from '@/modules/user'; import { ExchangeApiKeysController } from './exchange-api-keys.controller'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; import { ExchangeApiKeysService } from './exchange-api-keys.service'; -import { UserModule } from '../user'; @Module({ - imports: [ExchangeModule, EncryptionModule, UserModule, Web3Module], + imports: [ExchangeModule, EncryptionModule, UserModule], providers: [ExchangeApiKeysRepository, ExchangeApiKeysService], controllers: [ExchangeApiKeysController], exports: [ExchangeApiKeysRepository, ExchangeApiKeysService], diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts index efe9690246..7a501d9dfc 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -1,9 +1,7 @@ -import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; -import { StakingConfigService } from '@/config'; +import { SUPPORTED_EXCHANGES_INFO } from '@/common/constants'; import { EncryptionConfigService } from '@/config/encryption-config.service'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; import { mockEncryptionConfigService } from '@/modules/encryption/fixtures'; @@ -11,14 +9,11 @@ import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factor // eslint-disable-next-line import/order import { ExchangeApiKeysService } from './exchange-api-keys.service'; -// eslint-disable-next-line import/order import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; -import { Web3Service } from '../web3'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { ActiveExchangeApiKeyExistsError, - ExchangeApiKeyNotFoundError, KeyAuthorizationError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @@ -34,13 +29,6 @@ const mockExchangeClient = createMock(); const mockExchangeClientFactory = { create: jest.fn().mockReturnValue(mockExchangeClient), }; -const mockWeb3Service = createMock(); -const mockStakingConfigService: Omit = { - eligibilityEnabled: true, - minThreshold: 100, - asset: 'ETH', - timeoutMs: 2000, -}; describe('ExchangeApiKeysService', () => { let exchangeApiKeysService: ExchangeApiKeysService; @@ -64,8 +52,6 @@ describe('ExchangeApiKeysService', () => { provide: ExchangeClientFactory, useValue: mockExchangeClientFactory, }, - { provide: Web3Service, useValue: mockWeb3Service }, - { provide: StakingConfigService, useValue: mockStakingConfigService }, ], }).compile(); @@ -101,22 +87,6 @@ describe('ExchangeApiKeysService', () => { expect(thrownError.message).toBe('Invalid arguments'); }); - it('should throw if not supported exchange name provided', async () => { - const input = generateExchangeApiKeysData(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (input as any).exchangeName = input.exchangeName.toUpperCase(); - - let thrownError; - try { - await exchangeApiKeysService.enroll(input); - } catch (error) { - thrownError = error; - } - - expect(thrownError.constructor).toBe(Error); - expect(thrownError.message).toBe('Exchange name is not valid'); - }); - it('should throw if provided keys do not have required access', async () => { mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce(null); mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(false); @@ -228,111 +198,12 @@ describe('ExchangeApiKeysService', () => { }); }); - describe('getExchangeStakedBalance', () => { - const userId = faker.number.int(); - it('throws ExchangeApiKeyNotFoundError when user has no exchange keys', async () => { - jest - .spyOn(exchangeApiKeysService, 'retrieve') - .mockResolvedValueOnce(null); - - await expect( - exchangeApiKeysService.getExchangeStakedBalance(userId), - ).rejects.toBeInstanceOf(ExchangeApiKeyNotFoundError); - }); - - it('returns balance fetched from exchange client', async () => { - const keys = { - exchangeName: SUPPORTED_EXCHANGE_NAMES[0], - apiKey: faker.string.sample(), - secretKey: faker.string.sample(), - }; - const balance = faker.number.int(); - jest - .spyOn(exchangeApiKeysService, 'retrieve') - .mockResolvedValueOnce(keys); - mockExchangeClient.getAccountBalance.mockResolvedValueOnce(balance); - - const result = - await exchangeApiKeysService.getExchangeStakedBalance(userId); - - expect(mockExchangeClientFactory.create).toHaveBeenCalledWith( - keys.exchangeName, - { - apiKey: keys.apiKey, - secretKey: keys.secretKey, - }, - { timeoutMs: mockStakingConfigService.timeoutMs }, - ); - expect(mockExchangeClient.getAccountBalance).toHaveBeenCalledWith( - mockStakingConfigService.asset, - ); - expect(result).toBe(balance); - }); - }); - - describe('getStakeSummary', () => { - const user = { - id: faker.number.int(), - evmAddress: faker.finance.ethereumAddress(), - }; - const onChainStake = faker.number.int(); - const exchangeStake = faker.number.int(); - - it('throws when user is not found', async () => { - mockUserRepository.findOneById.mockResolvedValueOnce(null); - - await expect( - exchangeApiKeysService.getStakeSummary(user.id), - ).rejects.toBeInstanceOf(UserNotFoundError); - }); - - it('returns aggregated exchange and on-chain stakes', async () => { - mockUserRepository.findOneById.mockResolvedValueOnce(user as UserEntity); - const exchangeStakeSpy = jest - .spyOn(exchangeApiKeysService, 'getExchangeStakedBalance') - .mockResolvedValueOnce(exchangeStake); - mockWeb3Service.getStakedBalance.mockResolvedValueOnce(onChainStake); - - const result = await exchangeApiKeysService.getStakeSummary(user.id); - - expect(exchangeStakeSpy).toHaveBeenCalledWith(user.id); - expect(mockWeb3Service.getStakedBalance).toHaveBeenCalledWith( - user.evmAddress, - ); - expect(result).toEqual({ - exchangeStake: exchangeStake, - onChainStake: onChainStake, - minThreshold: mockStakingConfigService.minThreshold, - }); - }); - - it('skips on-chain lookup when user has no address', async () => { - mockUserRepository.findOneById.mockResolvedValueOnce({ - ...user, - evmAddress: null, - } as UserEntity); - const exchangeStakeSpy = jest - .spyOn(exchangeApiKeysService, 'getExchangeStakedBalance') - .mockResolvedValueOnce(exchangeStake); - - const result = await exchangeApiKeysService.getStakeSummary(user.id); - - expect(mockWeb3Service.getStakedBalance).not.toHaveBeenCalled(); - expect(result).toEqual({ - exchangeStake: exchangeStake, - onChainStake: 0, - minThreshold: mockStakingConfigService.minThreshold, - }); - - exchangeStakeSpy.mockRestore(); - }); - }); - describe('getSupportedExchanges', () => { it('returns a copy of supported exchanges constant', () => { const result = exchangeApiKeysService.getSupportedExchanges(); - expect(result).toEqual(SUPPORTED_EXCHANGE_NAMES); + expect(result).toEqual(SUPPORTED_EXCHANGES_INFO); + expect(result).not.toBe(SUPPORTED_EXCHANGES_INFO); }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index e712335fba..cb8099c043 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -1,24 +1,20 @@ import { Injectable } from '@nestjs/common'; import { - SUPPORTED_EXCHANGE_NAMES, + SUPPORTED_EXCHANGES_INFO, SupportedExchange, + type SupportedExchangeInfo, } from '@/common/constants'; -import { isValidExchangeName } from '@/common/validators/exchange'; -import { StakingConfigService } from '@/config'; import { AesEncryptionService } from '@/modules/encryption/aes-encryption.service'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { UserNotFoundError, UserRepository } from '@/modules/user'; -import { Web3Service } from '@/modules/web3'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; import { ActiveExchangeApiKeyExistsError, - ExchangeApiKeyNotFoundError, KeyAuthorizationError, } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; -import { StakeSummaryData } from './types'; @Injectable() export class ExchangeApiKeysService { @@ -27,13 +23,11 @@ export class ExchangeApiKeysService { private readonly exchangeApiKeysRepository: ExchangeApiKeysRepository, private readonly exchangeClientFactory: ExchangeClientFactory, private readonly userRepository: UserRepository, - private readonly web3Service: Web3Service, - private readonly stakingConfigService: StakingConfigService, ) {} async enroll(input: { userId: number; - exchangeName: string; + exchangeName: SupportedExchange; apiKey: string; secretKey: string; }): Promise { @@ -43,10 +37,6 @@ export class ExchangeApiKeysService { throw new Error('Invalid arguments'); } - if (!isValidExchangeName(exchangeName)) { - throw new Error('Exchange name is not valid'); - } - const currentKeys = await this.exchangeApiKeysRepository.findOneByUserId(userId); if (currentKeys) { @@ -104,43 +94,7 @@ export class ExchangeApiKeysService { }; } - async getExchangeStakedBalance(userId: number): Promise { - const apiKeys = await this.retrieve(userId); - if (!apiKeys) { - throw new ExchangeApiKeyNotFoundError(userId); - } - - const client = await this.exchangeClientFactory.create( - apiKeys.exchangeName as SupportedExchange, - { - apiKey: apiKeys.apiKey, - secretKey: apiKeys.secretKey, - }, - { timeoutMs: this.stakingConfigService.timeoutMs }, - ); - - return client.getAccountBalance(this.stakingConfigService.asset); - } - - async getStakeSummary(userId: number): Promise { - const user = await this.userRepository.findOneById(userId); - if (!user) { - throw new UserNotFoundError(userId); - } - - const exchangeStake = await this.getExchangeStakedBalance(userId); - let onChainStake = 0; - if (user.evmAddress) - onChainStake = await this.web3Service.getStakedBalance(user.evmAddress); - - return { - exchangeStake, - onChainStake, - minThreshold: this.stakingConfigService.minThreshold, - }; - } - - getSupportedExchanges(): string[] { - return [...SUPPORTED_EXCHANGE_NAMES]; + getSupportedExchanges(): SupportedExchangeInfo[] { + return SUPPORTED_EXCHANGES_INFO.map((exchange) => ({ ...exchange })); } } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts index fe0d6a3fcc..2aa44e2348 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts @@ -2,4 +2,6 @@ export type StakeSummaryData = { exchangeStake: number; onChainStake: number; minThreshold: number; + exchangeError?: string; + onChainError?: string; }; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts index bb56ea256b..bca953a18b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/fixtures/exchange.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -import { SUPPORTED_EXCHANGE_NAMES } from '@/common/constants'; +import { SupportedExchange } from '@/common/constants'; export function generateGateAccountBalance(tokens: string[] = []) { if (tokens.length === 0) { @@ -28,5 +28,5 @@ export function generateMexcAccountBalance(tokens: string[] = []) { } export function generateExchangeName() { - return faker.helpers.arrayElement(SUPPORTED_EXCHANGE_NAMES); + return faker.helpers.enumValue(SupportedExchange); } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts index 9ee5f8019f..ad50394dea 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/gate-exchange.client.ts @@ -1,6 +1,6 @@ import { createHash, createHmac } from 'node:crypto'; -import { type SupportedExchange } from '@/common/constants'; +import { SupportedExchange } from '@/common/constants'; import logger from '@/logger'; import Environment from '@/utils/environment'; @@ -20,7 +20,7 @@ export const DEVELOP_GATE_API_BASE_URL = 'https://api-testnet.gateapi.io/api/v4'; export class GateExchangeClient implements ExchangeClient { - readonly id: SupportedExchange = 'gate'; + readonly id = SupportedExchange.GATE; private readonly apiKey: string; private readonly secretKey: string; private readonly timeoutMs?: number; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts index f7dc0b6f86..9c3b0d4460 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/mexc-exchange.client.ts @@ -1,6 +1,6 @@ import { createHmac } from 'node:crypto'; -import { type SupportedExchange } from '@/common/constants'; +import { SupportedExchange } from '@/common/constants'; import logger from '@/logger'; import { @@ -17,7 +17,7 @@ import { fetchWithHandling } from './utils'; export const MEXC_API_BASE_URL = 'https://api.mexc.com/api/v3'; export class MexcExchangeClient implements ExchangeClient { - readonly id: SupportedExchange = 'mexc'; + readonly id = SupportedExchange.MEXC; private readonly apiKey: string; private readonly secretKey: string; private readonly timeoutMs?: number; diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/index.ts b/packages/apps/reputation-oracle/server/src/modules/staking/index.ts new file mode 100644 index 0000000000..fc1296365f --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/index.ts @@ -0,0 +1,3 @@ +export { StakingModule } from './staking.module'; +export { StakingService } from './staking.service'; +export type { StakeSummaryData } from './types'; diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts new file mode 100644 index 0000000000..409d0f766b --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Req, UseFilters } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; + +import type { RequestWithUser } from '@/common/types'; + +import { StakeSummaryResponseDto } from './staking.dto'; +import { StakingControllerErrorsFilter } from './staking.error-filter'; +import { StakingService } from './staking.service'; + +@ApiTags('Staking') +@ApiBearerAuth() +@UseFilters(StakingControllerErrorsFilter) +@Controller('staking') +export class StakingController { + constructor(private readonly stakingService: StakingService) {} + + @ApiOperation({ summary: 'Retrieve aggregated staking info' }) + @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) + @Get('/summary') + async getStakeSummary( + @Req() request: RequestWithUser, + ): Promise { + return this.stakingService.getStakeSummary(request.user.id); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts new file mode 100644 index 0000000000..7f40f4a59a --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StakeSummaryResponseDto { + @ApiProperty({ name: 'exchange_stake' }) + exchangeStake: string; + + @ApiProperty({ name: 'on_chain_stake' }) + onChainStake: string; + + @ApiProperty({ name: 'min_threshold' }) + minThreshold: string; + + @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) + exchangeError?: string; + + @ApiProperty({ name: 'on_chain_error', required: false, nullable: true }) + onChainError?: string; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts new file mode 100644 index 0000000000..af660d4d70 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.error-filter.ts @@ -0,0 +1,34 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +import logger from '@/logger'; +import { UserNotFoundError } from '@/modules/user'; + +@Catch(UserNotFoundError) +export class StakingControllerErrorsFilter implements ExceptionFilter { + private readonly logger = logger.child({ + context: StakingControllerErrorsFilter.name, + }); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + + if (exception instanceof UserNotFoundError) { + status = HttpStatus.UNPROCESSABLE_ENTITY; + } + + return response.status(status).json({ + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts new file mode 100644 index 0000000000..a28244e510 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { ExchangeModule } from '@/modules/exchange/exchange.module'; +import { ExchangeApiKeysModule } from '@/modules/exchange-api-keys'; +import { UserModule } from '@/modules/user'; +import { Web3Module } from '@/modules/web3'; + +import { StakingController } from './staking.controller'; +import { StakingService } from './staking.service'; + +@Module({ + imports: [ExchangeApiKeysModule, ExchangeModule, UserModule, Web3Module], + providers: [StakingService], + controllers: [StakingController], + exports: [StakingService], +}) +export class StakingModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts new file mode 100644 index 0000000000..d71a4866e4 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -0,0 +1,226 @@ +import { faker } from '@faker-js/faker'; +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ethers } from 'ethers'; + +import { SupportedExchange } from '@/common/constants'; +import { StakingConfigService, Web3ConfigService } from '@/config'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { + ExchangeApiKeyNotFoundError, + ExchangeApiKeysService, +} from '@/modules/exchange-api-keys'; +import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; +import { WalletWithProvider, Web3Service } from '@/modules/web3'; +import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; + +import { StakingService } from './staking.service'; +import { ExchangeClient } from '../exchange/types'; + +jest.mock('@human-protocol/sdk', () => { + const actual = jest.requireActual('@human-protocol/sdk'); + return { + ...actual, + StakingClient: { + build: jest.fn(), + }, + }; +}); + +const { StakingClient } = jest.requireMock('@human-protocol/sdk'); +const mockExchangeApiKeysService = createMock(); +const mockExchangeClientFactory = { + create: jest.fn(), +}; +const mockExchangeClient = createMock(); +const mockUserRepository = createMock(); +const mockWeb3Service = createMock(); +const mockStakingConfigService: Omit = { + eligibilityEnabled: true, + minThreshold: faker.number.int({ min: 1, max: 1000 }), + asset: 'HMT', + timeoutMs: faker.number.int({ min: 1000, max: 10000 }), +}; + +describe('StakingService', () => { + let stakingService: StakingService; + + beforeEach(async () => { + mockExchangeClientFactory.create.mockResolvedValue(mockExchangeClient); + mockExchangeClient.getAccountBalance.mockReset(); + (StakingClient.build as jest.Mock).mockReset(); + mockWeb3Service.getSigner.mockReturnValue({ + provider: {}, + } as never); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StakingService, + { + provide: ExchangeApiKeysService, + useValue: mockExchangeApiKeysService, + }, + { + provide: ExchangeClientFactory, + useValue: mockExchangeClientFactory, + }, + { provide: UserRepository, useValue: mockUserRepository }, + { provide: Web3Service, useValue: mockWeb3Service }, + { provide: StakingConfigService, useValue: mockStakingConfigService }, + { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, + ], + }).compile(); + + stakingService = module.get(StakingService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getExchangeStakedBalance', () => { + const userId = faker.number.int(); + it('throws ExchangeApiKeyNotFoundError when user has no exchange keys', async () => { + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); + + await expect( + stakingService.getExchangeStakedBalance(userId), + ).rejects.toBeInstanceOf(ExchangeApiKeyNotFoundError); + }); + + it('returns balance fetched from exchange client', async () => { + const keys = { + exchangeName: SupportedExchange.GATE, + apiKey: faker.string.sample(), + secretKey: faker.string.sample(), + }; + const balance = faker.number.int(); + mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(keys); + mockExchangeClient.getAccountBalance.mockResolvedValueOnce(balance); + + const result = await stakingService.getExchangeStakedBalance(userId); + expect(mockExchangeClientFactory.create).toHaveBeenCalledWith( + keys.exchangeName, + { + apiKey: keys.apiKey, + secretKey: keys.secretKey, + }, + { timeoutMs: mockStakingConfigService.timeoutMs }, + ); + expect(mockExchangeClient.getAccountBalance).toHaveBeenCalledWith( + mockStakingConfigService.asset, + ); + expect(result).toBe(balance); + }); + }); + + describe('getStakeSummary', () => { + const user = { + id: faker.number.int(), + evmAddress: faker.finance.ethereumAddress(), + }; + const onChainStake = faker.number.int(); + const exchangeStake = faker.number.int(); + + it('throws when user is not found', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(null); + + await expect( + stakingService.getStakeSummary(user.id), + ).rejects.toBeInstanceOf(UserNotFoundError); + }); + + it('returns aggregated exchange and on-chain stakes', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce(user as UserEntity); + const exchangeStakeSpy = jest + .spyOn(stakingService, 'getExchangeStakedBalance') + .mockResolvedValueOnce(exchangeStake); + jest + .spyOn(stakingService, 'getOnChainStakedBalance') + .mockResolvedValueOnce(onChainStake); + + const result = await stakingService.getStakeSummary(user.id); + + expect(exchangeStakeSpy).toHaveBeenCalledWith(user.id); + expect(stakingService.getOnChainStakedBalance).toHaveBeenCalledWith( + user.evmAddress, + ); + expect(result).toEqual({ + exchangeStake: exchangeStake.toLocaleString(undefined, { + maximumFractionDigits: 18, + }), + onChainStake: onChainStake.toLocaleString(undefined, { + maximumFractionDigits: 18, + }), + minThreshold: mockStakingConfigService.minThreshold.toLocaleString( + undefined, + { + maximumFractionDigits: 18, + }, + ), + }); + }); + + it('skips on-chain lookup when user has no address', async () => { + mockUserRepository.findOneById.mockResolvedValueOnce({ + ...user, + evmAddress: null, + } as UserEntity); + const exchangeStakeSpy = jest + .spyOn(stakingService, 'getExchangeStakedBalance') + .mockResolvedValueOnce(exchangeStake); + jest.spyOn(stakingService, 'getOnChainStakedBalance'); + + const result = await stakingService.getStakeSummary(user.id); + + expect(stakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); + expect(result).toEqual({ + exchangeStake: exchangeStake.toLocaleString(undefined, { + maximumFractionDigits: 18, + }), + onChainStake: '0', + minThreshold: mockStakingConfigService.minThreshold.toLocaleString( + undefined, + { + maximumFractionDigits: 18, + }, + ), + }); + + exchangeStakeSpy.mockRestore(); + }); + }); + + describe('getOnChainStakedBalance', () => { + it('returns total staked and locked balance', async () => { + const address = faker.finance.ethereumAddress(); + const stakedAmount = ethers.toBigInt( + faker.number.int({ min: 500, max: 1000000 }), + ); + const lockedAmount = ethers.toBigInt( + faker.number.int({ min: 500, max: 999999 }), + ); + const mockProvider = {}; + mockWeb3Service.getSigner.mockReturnValueOnce({ + provider: mockProvider, + } as WalletWithProvider); + const mockStakingClient = { + getStakerInfo: jest.fn().mockResolvedValue({ + stakedAmount, + lockedAmount, + }), + }; + (StakingClient.build as jest.Mock).mockResolvedValueOnce( + mockStakingClient, + ); + + const result = await stakingService.getOnChainStakedBalance(address); + + expect(StakingClient.build).toHaveBeenCalledWith(mockProvider); + expect(mockStakingClient.getStakerInfo).toHaveBeenCalledWith(address); + expect(result).toBe( + Number(ethers.formatEther(stakedAmount + lockedAmount)), + ); + }); + }); +}); diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts new file mode 100644 index 0000000000..01570a2e41 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -0,0 +1,112 @@ +import { StakingClient } from '@human-protocol/sdk'; +import { Injectable } from '@nestjs/common'; +import { ethers } from 'ethers'; + +import { SupportedExchange } from '@/common/constants'; +import { StakingConfigService, Web3ConfigService } from '@/config'; +import logger from '@/logger'; +import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { + ExchangeApiKeyNotFoundError, + ExchangeApiKeysService, +} from '@/modules/exchange-api-keys'; +import { UserNotFoundError, UserRepository } from '@/modules/user'; +import { Web3Service } from '@/modules/web3'; + +import { StakeSummaryData } from './types'; + +@Injectable() +export class StakingService { + private readonly logger = logger.child({ + context: StakingService.name, + }); + + constructor( + private readonly exchangeApiKeysService: ExchangeApiKeysService, + private readonly exchangeClientFactory: ExchangeClientFactory, + private readonly userRepository: UserRepository, + private readonly web3Service: Web3Service, + private readonly stakingConfigService: StakingConfigService, + private readonly web3ConfigService: Web3ConfigService, + ) {} + + async getExchangeStakedBalance(userId: number): Promise { + const apiKeys = await this.exchangeApiKeysService.retrieve(userId); + if (!apiKeys) { + throw new ExchangeApiKeyNotFoundError(userId); + } + + const client = await this.exchangeClientFactory.create( + apiKeys.exchangeName as SupportedExchange, + { + apiKey: apiKeys.apiKey, + secretKey: apiKeys.secretKey, + }, + { timeoutMs: this.stakingConfigService.timeoutMs }, + ); + + return client.getAccountBalance(this.stakingConfigService.asset); + } + + async getOnChainStakedBalance(address: string): Promise { + const chainId = this.web3ConfigService.reputationNetworkChainId; + const provider = this.web3Service.getSigner(chainId).provider; + + const stakingClient = await StakingClient.build(provider); + const stakerInfo = await stakingClient.getStakerInfo(address); + + const total = + (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); + return Number(ethers.formatEther(total)); + } + + async getStakeSummary(userId: number): Promise { + const user = await this.userRepository.findOneById(userId); + if (!user) { + throw new UserNotFoundError(userId); + } + + const summary: StakeSummaryData = { + exchangeStake: '0', + onChainStake: '0', + minThreshold: this.stakingConfigService.minThreshold.toString(), + }; + + try { + summary.exchangeStake = ( + await this.getExchangeStakedBalance(userId) + ).toLocaleString(undefined, { + maximumFractionDigits: 18, + }); + } catch (error) { + summary.onChainError = error.message + ? error.message + : 'Unable to fetch on-chain stake'; + this.logger.warn('Failed to retrieve exchange stake', { + userId, + error, + }); + } + + if (user.evmAddress) { + try { + summary.onChainStake = ( + await this.getOnChainStakedBalance(user.evmAddress) + ).toLocaleString(undefined, { + maximumFractionDigits: 18, + }); + } catch (error) { + summary.onChainError = error.message + ? error.message + : 'Unable to fetch on-chain stake'; + this.logger.warn('Failed to retrieve on-chain stake', { + userId, + evmAddress: user.evmAddress, + error, + }); + } + } + + return summary; + } +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts new file mode 100644 index 0000000000..3d714fa33a --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts @@ -0,0 +1,7 @@ +export type StakeSummaryData = { + exchangeStake: string; + onChainStake: string; + minThreshold: string; + exchangeError?: string; + onChainError?: string; +}; diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts index 45fd33e79a..768196b62a 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/web3.service.ts @@ -1,5 +1,5 @@ import { HMToken__factory } from '@human-protocol/core/typechain-types'; -import { ChainId, StakingClient } from '@human-protocol/sdk'; +import { ChainId } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { Wallet, ethers } from 'ethers'; @@ -109,16 +109,4 @@ export class Web3Service { throw new Error('Failed to fetch token decimals'); } } - - async getStakedBalance(address: string): Promise { - const chainId = this.web3ConfigService.reputationNetworkChainId; - const provider = this.getSigner(chainId).provider; - - const stakingClient = await StakingClient.build(provider); - const stakerInfo = await stakingClient.getStakerInfo(address); - - const total = - (stakerInfo.stakedAmount ?? 0n) + (stakerInfo.lockedAmount ?? 0n); - return Number(ethers.formatEther(total)); - } } From 03e0416d07ec518f564bc4131596d5da9d9dbe8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 17 Dec 2025 11:19:45 +0100 Subject: [PATCH 16/26] implement stake formatting utility and improve some unit tests --- .../src/modules/exchange-api-keys/types.ts | 7 -- .../server/src/modules/exchange/index.ts | 1 + .../server/src/modules/staking/staking.dto.ts | 6 +- .../modules/staking/staking.service.spec.ts | 105 +++++++----------- .../src/modules/staking/staking.service.ts | 17 ++- .../server/src/utils/stake.ts | 6 + 6 files changed, 60 insertions(+), 82 deletions(-) delete mode 100644 packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts create mode 100644 packages/apps/reputation-oracle/server/src/utils/stake.ts diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts deleted file mode 100644 index 2aa44e2348..0000000000 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type StakeSummaryData = { - exchangeStake: number; - onChainStake: number; - minThreshold: number; - exchangeError?: string; - onChainError?: string; -}; diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts index 083c7dd8bd..3a0424a866 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange/index.ts @@ -1,2 +1,3 @@ export { ExchangeModule } from './exchange.module'; export { ExchangeClientFactory } from './exchange-client.factory'; +export * from './types'; diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts index 7f40f4a59a..eae7ea6a01 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class StakeSummaryResponseDto { @ApiProperty({ name: 'exchange_stake' }) @@ -10,9 +10,9 @@ export class StakeSummaryResponseDto { @ApiProperty({ name: 'min_threshold' }) minThreshold: string; - @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) + @ApiPropertyOptional({ name: 'exchange_error' }) exchangeError?: string; - @ApiProperty({ name: 'on_chain_error', required: false, nullable: true }) + @ApiPropertyOptional({ name: 'on_chain_error' }) onChainError?: string; } diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts index d71a4866e4..de1d431dc7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -1,10 +1,14 @@ +jest.mock('@human-protocol/sdk'); + import { faker } from '@faker-js/faker'; import { createMock } from '@golevelup/ts-jest'; +import { StakingClient } from '@human-protocol/sdk'; import { Test, TestingModule } from '@nestjs/testing'; import { ethers } from 'ethers'; import { SupportedExchange } from '@/common/constants'; import { StakingConfigService, Web3ConfigService } from '@/config'; +import { type ExchangeClient } from '@/modules/exchange'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; import { ExchangeApiKeyNotFoundError, @@ -15,19 +19,7 @@ import { WalletWithProvider, Web3Service } from '@/modules/web3'; import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; import { StakingService } from './staking.service'; -import { ExchangeClient } from '../exchange/types'; - -jest.mock('@human-protocol/sdk', () => { - const actual = jest.requireActual('@human-protocol/sdk'); - return { - ...actual, - StakingClient: { - build: jest.fn(), - }, - }; -}); -const { StakingClient } = jest.requireMock('@human-protocol/sdk'); const mockExchangeApiKeysService = createMock(); const mockExchangeClientFactory = { create: jest.fn(), @@ -41,14 +33,14 @@ const mockStakingConfigService: Omit = { asset: 'HMT', timeoutMs: faker.number.int({ min: 1000, max: 10000 }), }; +const mockedStakingClient = jest.mocked(StakingClient); describe('StakingService', () => { let stakingService: StakingService; - beforeEach(async () => { + beforeAll(async () => { mockExchangeClientFactory.create.mockResolvedValue(mockExchangeClient); mockExchangeClient.getAccountBalance.mockReset(); - (StakingClient.build as jest.Mock).mockReset(); mockWeb3Service.getSigner.mockReturnValue({ provider: {}, } as never); @@ -74,7 +66,7 @@ describe('StakingService', () => { stakingService = module.get(StakingService); }); - afterEach(() => { + afterAll(() => { jest.clearAllMocks(); }); @@ -121,6 +113,22 @@ describe('StakingService', () => { }; const onChainStake = faker.number.int(); const exchangeStake = faker.number.int(); + let spyOnGetExchangeStakedBalance: jest.SpyInstance; + let spyOnGetOnChainStakedBalance: jest.SpyInstance; + + beforeAll(() => { + spyOnGetExchangeStakedBalance = jest + .spyOn(stakingService, 'getExchangeStakedBalance') + .mockImplementation(); + spyOnGetOnChainStakedBalance = jest + .spyOn(stakingService, 'getOnChainStakedBalance') + .mockImplementation(); + }); + + afterAll(() => { + spyOnGetExchangeStakedBalance.mockRestore(); + spyOnGetOnChainStakedBalance.mockRestore(); + }); it('throws when user is not found', async () => { mockUserRepository.findOneById.mockResolvedValueOnce(null); @@ -132,32 +140,19 @@ describe('StakingService', () => { it('returns aggregated exchange and on-chain stakes', async () => { mockUserRepository.findOneById.mockResolvedValueOnce(user as UserEntity); - const exchangeStakeSpy = jest - .spyOn(stakingService, 'getExchangeStakedBalance') - .mockResolvedValueOnce(exchangeStake); - jest - .spyOn(stakingService, 'getOnChainStakedBalance') - .mockResolvedValueOnce(onChainStake); + spyOnGetExchangeStakedBalance.mockResolvedValueOnce(exchangeStake); + spyOnGetOnChainStakedBalance.mockResolvedValueOnce(onChainStake); const result = await stakingService.getStakeSummary(user.id); - expect(exchangeStakeSpy).toHaveBeenCalledWith(user.id); - expect(stakingService.getOnChainStakedBalance).toHaveBeenCalledWith( + expect(spyOnGetExchangeStakedBalance).toHaveBeenCalledWith(user.id); + expect(spyOnGetOnChainStakedBalance).toHaveBeenCalledWith( user.evmAddress, ); expect(result).toEqual({ - exchangeStake: exchangeStake.toLocaleString(undefined, { - maximumFractionDigits: 18, - }), - onChainStake: onChainStake.toLocaleString(undefined, { - maximumFractionDigits: 18, - }), - minThreshold: mockStakingConfigService.minThreshold.toLocaleString( - undefined, - { - maximumFractionDigits: 18, - }, - ), + exchangeStake: exchangeStake.toString(), + onChainStake: onChainStake.toString(), + minThreshold: mockStakingConfigService.minThreshold.toString(), }); }); @@ -166,28 +161,16 @@ describe('StakingService', () => { ...user, evmAddress: null, } as UserEntity); - const exchangeStakeSpy = jest - .spyOn(stakingService, 'getExchangeStakedBalance') - .mockResolvedValueOnce(exchangeStake); - jest.spyOn(stakingService, 'getOnChainStakedBalance'); + spyOnGetExchangeStakedBalance.mockResolvedValueOnce(exchangeStake); const result = await stakingService.getStakeSummary(user.id); - expect(stakingService.getOnChainStakedBalance).not.toHaveBeenCalled(); + expect(spyOnGetOnChainStakedBalance).not.toHaveBeenCalled(); expect(result).toEqual({ - exchangeStake: exchangeStake.toLocaleString(undefined, { - maximumFractionDigits: 18, - }), + exchangeStake: exchangeStake.toString(), onChainStake: '0', - minThreshold: mockStakingConfigService.minThreshold.toLocaleString( - undefined, - { - maximumFractionDigits: 18, - }, - ), + minThreshold: mockStakingConfigService.minThreshold.toString(), }); - - exchangeStakeSpy.mockRestore(); }); }); @@ -204,20 +187,18 @@ describe('StakingService', () => { mockWeb3Service.getSigner.mockReturnValueOnce({ provider: mockProvider, } as WalletWithProvider); - const mockStakingClient = { - getStakerInfo: jest.fn().mockResolvedValue({ - stakedAmount, - lockedAmount, - }), - }; - (StakingClient.build as jest.Mock).mockResolvedValueOnce( - mockStakingClient, - ); + + const getStakerInfoMock = jest + .fn() + .mockResolvedValue({ stakedAmount, lockedAmount }); + mockedStakingClient.build.mockResolvedValueOnce({ + getStakerInfo: getStakerInfoMock, + } as unknown as StakingClient); const result = await stakingService.getOnChainStakedBalance(address); - expect(StakingClient.build).toHaveBeenCalledWith(mockProvider); - expect(mockStakingClient.getStakerInfo).toHaveBeenCalledWith(address); + expect(mockedStakingClient.build).toHaveBeenCalledWith(mockProvider); + expect(getStakerInfoMock).toHaveBeenCalledWith(address); expect(result).toBe( Number(ethers.formatEther(stakedAmount + lockedAmount)), ); diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts index 01570a2e41..4946b8866e 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -12,6 +12,7 @@ import { } from '@/modules/exchange-api-keys'; import { UserNotFoundError, UserRepository } from '@/modules/user'; import { Web3Service } from '@/modules/web3'; +import { formatStake } from '@/utils/stake'; import { StakeSummaryData } from './types'; @@ -73,11 +74,9 @@ export class StakingService { }; try { - summary.exchangeStake = ( - await this.getExchangeStakedBalance(userId) - ).toLocaleString(undefined, { - maximumFractionDigits: 18, - }); + summary.exchangeStake = formatStake( + await this.getExchangeStakedBalance(userId), + ); } catch (error) { summary.onChainError = error.message ? error.message @@ -90,11 +89,9 @@ export class StakingService { if (user.evmAddress) { try { - summary.onChainStake = ( - await this.getOnChainStakedBalance(user.evmAddress) - ).toLocaleString(undefined, { - maximumFractionDigits: 18, - }); + summary.onChainStake = formatStake( + await this.getOnChainStakedBalance(user.evmAddress), + ); } catch (error) { summary.onChainError = error.message ? error.message diff --git a/packages/apps/reputation-oracle/server/src/utils/stake.ts b/packages/apps/reputation-oracle/server/src/utils/stake.ts new file mode 100644 index 0000000000..8516a79e86 --- /dev/null +++ b/packages/apps/reputation-oracle/server/src/utils/stake.ts @@ -0,0 +1,6 @@ +export const formatStake = (value: number) => + new Intl.NumberFormat(undefined, { + maximumFractionDigits: 18, + notation: 'standard', + useGrouping: false, + }).format(value); From 06e69166b45897e5ba1e0f4e7b83276605bb225a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 17 Dec 2025 11:53:39 +0100 Subject: [PATCH 17/26] update error handling for exchange stake retrieval in StakingService --- .../server/src/modules/staking/staking.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts index 4946b8866e..c9617060e7 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -78,9 +78,9 @@ export class StakingService { await this.getExchangeStakedBalance(userId), ); } catch (error) { - summary.onChainError = error.message + summary.exchangeError = error.message ? error.message - : 'Unable to fetch on-chain stake'; + : 'Unable to fetch exchange stake'; this.logger.warn('Failed to retrieve exchange stake', { userId, error, From d729ef84426427e8affa4cf66dc4e5f210455e2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 17 Dec 2025 12:48:40 +0100 Subject: [PATCH 18/26] update error handling in ExchangeApiKeysController and StakingService to return null and zero respectively --- .../exchange-api-keys/exchange-api-keys.controller.ts | 4 ++-- .../server/src/modules/staking/staking.service.spec.ts | 9 +++------ .../server/src/modules/staking/staking.service.ts | 7 ++----- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts index 7ce0c5dca6..df34ad7f2c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.controller.ts @@ -54,12 +54,12 @@ export class ExchangeApiKeysController { @Get('/') async retrieveEnrolledApiKeys( @Req() request: RequestWithUser, - ): Promise { + ): Promise { const userId = request.user.id; const apiKey = await this.exchangeApiKeysService.retrieve(userId); if (!apiKey) { - throw new ExchangeApiKeyNotFoundError(userId); + return null; } return { diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts index de1d431dc7..f1e0e10d02 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -10,10 +10,7 @@ import { SupportedExchange } from '@/common/constants'; import { StakingConfigService, Web3ConfigService } from '@/config'; import { type ExchangeClient } from '@/modules/exchange'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; -import { - ExchangeApiKeyNotFoundError, - ExchangeApiKeysService, -} from '@/modules/exchange-api-keys'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; import { WalletWithProvider, Web3Service } from '@/modules/web3'; import { mockWeb3ConfigService } from '@/modules/web3/fixtures'; @@ -72,12 +69,12 @@ describe('StakingService', () => { describe('getExchangeStakedBalance', () => { const userId = faker.number.int(); - it('throws ExchangeApiKeyNotFoundError when user has no exchange keys', async () => { + it('returns 0 when user has no exchange keys', async () => { mockExchangeApiKeysService.retrieve.mockResolvedValueOnce(null); await expect( stakingService.getExchangeStakedBalance(userId), - ).rejects.toBeInstanceOf(ExchangeApiKeyNotFoundError); + ).resolves.toBe(0); }); it('returns balance fetched from exchange client', async () => { diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts index c9617060e7..cbb3c33536 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -6,10 +6,7 @@ import { SupportedExchange } from '@/common/constants'; import { StakingConfigService, Web3ConfigService } from '@/config'; import logger from '@/logger'; import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; -import { - ExchangeApiKeyNotFoundError, - ExchangeApiKeysService, -} from '@/modules/exchange-api-keys'; +import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { UserNotFoundError, UserRepository } from '@/modules/user'; import { Web3Service } from '@/modules/web3'; import { formatStake } from '@/utils/stake'; @@ -34,7 +31,7 @@ export class StakingService { async getExchangeStakedBalance(userId: number): Promise { const apiKeys = await this.exchangeApiKeysService.retrieve(userId); if (!apiKeys) { - throw new ExchangeApiKeyNotFoundError(userId); + return 0; } const client = await this.exchangeClientFactory.create( From 8a1bc2987b5b595c80730b7177632e252f925e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 17 Dec 2025 17:11:26 +0100 Subject: [PATCH 19/26] update CORS methods and remove unused error class; enhance key enrollment logic --- packages/apps/human-app/server/src/main.ts | 2 +- .../exchange-api-keys.error-filter.ts | 5 +-- .../exchange-api-keys.errors.ts | 9 ----- .../exchange-api-keys.service.spec.ts | 34 ++++++++++++------- .../exchange-api-keys.service.ts | 23 +++++++------ 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/apps/human-app/server/src/main.ts b/packages/apps/human-app/server/src/main.ts index 6f1ea1f0dd..726bd19b9b 100644 --- a/packages/apps/human-app/server/src/main.ts +++ b/packages/apps/human-app/server/src/main.ts @@ -19,7 +19,7 @@ async function bootstrap() { if (envConfigService.isCorsEnabled) { app.enableCors({ origin: envConfigService.corsEnabledOrigin, - methods: ['GET', 'POST', 'OPTIONS', 'PUT'], + methods: ['GET', 'POST', 'OPTIONS', 'PUT', 'DELETE'], allowedHeaders: envConfigService.corsAllowedHeaders, }); } diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts index 09413aebe5..d4d629d673 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.error-filter.ts @@ -13,7 +13,6 @@ import { ExchangeApiKeyNotFoundError, IncompleteKeySuppliedError, KeyAuthorizationError, - ActiveExchangeApiKeyExistsError, } from './exchange-api-keys.errors'; import { ExchangeApiClientError } from '../exchange/errors'; @@ -21,7 +20,6 @@ import { ExchangeApiClientError } from '../exchange/errors'; UserNotFoundError, IncompleteKeySuppliedError, KeyAuthorizationError, - ActiveExchangeApiKeyExistsError, ExchangeApiKeyNotFoundError, ) export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { @@ -38,8 +36,7 @@ export class ExchangeApiKeysControllerErrorsFilter implements ExceptionFilter { if ( exception instanceof UserNotFoundError || exception instanceof IncompleteKeySuppliedError || - exception instanceof KeyAuthorizationError || - exception instanceof ActiveExchangeApiKeyExistsError + exception instanceof KeyAuthorizationError ) { status = HttpStatus.UNPROCESSABLE_ENTITY; } else if (exception instanceof ExchangeApiClientError) { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts index e45850a31c..b16190ddd2 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.errors.ts @@ -17,12 +17,3 @@ export class KeyAuthorizationError extends BaseError { super("Provided API key can't be authorized on exchange"); } } - -export class ActiveExchangeApiKeyExistsError extends BaseError { - constructor( - readonly userId: number, - readonly exchangeName: string, - ) { - super('User already has an active exchange API key'); - } -} diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts index 7a501d9dfc..7869bf4f60 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.spec.ts @@ -12,10 +12,7 @@ import { ExchangeApiKeysService } from './exchange-api-keys.service'; import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; -import { - ActiveExchangeApiKeyExistsError, - KeyAuthorizationError, -} from './exchange-api-keys.errors'; +import { KeyAuthorizationError } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; import { generateExchangeApiKey, @@ -104,20 +101,31 @@ describe('ExchangeApiKeysService', () => { expect(thrownError.exchangeName).toBe(input.exchangeName); }); - it('should throw if user already has active keys', async () => { + it('should overwrite existing keys if user already has active ones', async () => { const input = generateExchangeApiKeysData(); + const existingKey = generateExchangeApiKey(); mockExchangeApiKeysRepository.findOneByUserId.mockResolvedValueOnce( - generateExchangeApiKey(), + existingKey, + ); + mockExchangeClient.checkRequiredAccess.mockResolvedValueOnce(true); + mockUserRepository.findOneById.mockResolvedValueOnce({ + id: input.userId, + } as UserEntity); + mockExchangeApiKeysRepository.updateOne.mockImplementation( + async (entity) => entity, ); - let thrownError; - try { - await exchangeApiKeysService.enroll(input); - } catch (error) { - thrownError = error; - } + const updatedEntity = await exchangeApiKeysService.enroll(input); - expect(thrownError).toBeInstanceOf(ActiveExchangeApiKeyExistsError); + expect(mockExchangeApiKeysRepository.updateOne).toHaveBeenCalledWith( + existingKey, + ); + const [decryptedApiKey, decryptedSecretKey] = await Promise.all([ + aesEncryptionService.decrypt(updatedEntity.apiKey), + aesEncryptionService.decrypt(updatedEntity.secretKey), + ]); + expect(decryptedApiKey.toString()).toBe(input.apiKey); + expect(decryptedSecretKey.toString()).toBe(input.secretKey); }); it('should throw if user not exists', async () => { diff --git a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts index cb8099c043..c59b852fdc 100644 --- a/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/exchange-api-keys/exchange-api-keys.service.ts @@ -10,10 +10,7 @@ import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factor import { UserNotFoundError, UserRepository } from '@/modules/user'; import { ExchangeApiKeyEntity } from './exchange-api-key.entity'; -import { - ActiveExchangeApiKeyExistsError, - KeyAuthorizationError, -} from './exchange-api-keys.errors'; +import { KeyAuthorizationError } from './exchange-api-keys.errors'; import { ExchangeApiKeysRepository } from './exchange-api-keys.repository'; @Injectable() @@ -39,9 +36,6 @@ export class ExchangeApiKeysService { const currentKeys = await this.exchangeApiKeysRepository.findOneByUserId(userId); - if (currentKeys) { - throw new ActiveExchangeApiKeyExistsError(userId, exchangeName); - } const client = await this.exchangeClientFactory.create(exchangeName, { apiKey, @@ -57,14 +51,21 @@ export class ExchangeApiKeysService { throw new UserNotFoundError(userId); } - const enrolledKey = new ExchangeApiKeyEntity(); - enrolledKey.userId = userId; - enrolledKey.exchangeName = exchangeName; - const [encryptedApiKey, encryptedSecretKey] = await Promise.all([ this.aesEncryptionService.encrypt(Buffer.from(apiKey)), this.aesEncryptionService.encrypt(Buffer.from(secretKey)), ]); + if (currentKeys) { + currentKeys.exchangeName = exchangeName; + currentKeys.apiKey = encryptedApiKey; + currentKeys.secretKey = encryptedSecretKey; + + return this.exchangeApiKeysRepository.updateOne(currentKeys); + } + + const enrolledKey = new ExchangeApiKeyEntity(); + enrolledKey.userId = userId; + enrolledKey.exchangeName = exchangeName; enrolledKey.apiKey = encryptedApiKey; enrolledKey.secretKey = encryptedSecretKey; await this.exchangeApiKeysRepository.createUnique(enrolledKey); From 64664dbdbfe218a103331a1f4b1aaf0f8daaee30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 19 Dec 2025 14:56:17 +0100 Subject: [PATCH 20/26] add eligibilityEnabled property to StakeSummaryResponse and related DTOs; update env schema and service logic --- .../src/modules/staking/model/staking.model.ts | 4 ++++ .../src/modules/staking/spec/staking.fixtures.ts | 1 + .../server/src/config/env-schema.ts | 4 ++++ .../server/src/modules/staking/staking.dto.ts | 3 +++ .../src/modules/staking/staking.service.spec.ts | 5 +++-- .../server/src/modules/staking/staking.service.ts | 1 + .../server/src/modules/staking/types.ts | 1 + .../apps/reputation-oracle/server/src/utils/stake.ts | 12 ++++++------ 8 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts index 552146e73d..c1e0eb51f4 100644 --- a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -14,6 +14,10 @@ export class StakeSummaryResponse { @AutoMap() minThreshold: number; + @ApiProperty({ name: 'eligibility_enabled' }) + @AutoMap() + eligibilityEnabled: boolean; + @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) @AutoMap() exchangeError?: string; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts index 0cf4202d5b..ac04dad1a2 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -4,4 +4,5 @@ export const stakeSummaryResponseFixture = { exchangeStake: 1000, onChainStake: 500, minThreshold: 1000, + eligibilityEnabled: true, }; diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 30680455f8..78876e7ec8 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -92,6 +92,10 @@ export const envValidator = Joi.object({ .integer() .min(100) .description('HTTP timeout for exchange staking checks in ms'), + STAKING_ELIGIBILITY_ENABLED: Joi.string() + .valid('true', 'false') + .default('false') + .description('Enable or disable staking eligibility checks'), // Slack notifications ABUSE_SLACK_WEBHOOK_URL: Joi.string() .uri({ scheme: ['http', 'https'] }) diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts index eae7ea6a01..876fb09ebb 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts @@ -10,6 +10,9 @@ export class StakeSummaryResponseDto { @ApiProperty({ name: 'min_threshold' }) minThreshold: string; + @ApiProperty({ name: 'eligibility_enabled' }) + eligibilityEnabled: boolean; + @ApiPropertyOptional({ name: 'exchange_error' }) exchangeError?: string; diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts index f1e0e10d02..2946fd1fbf 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -8,8 +8,7 @@ import { ethers } from 'ethers'; import { SupportedExchange } from '@/common/constants'; import { StakingConfigService, Web3ConfigService } from '@/config'; -import { type ExchangeClient } from '@/modules/exchange'; -import { ExchangeClientFactory } from '@/modules/exchange/exchange-client.factory'; +import { type ExchangeClient, ExchangeClientFactory } from '@/modules/exchange'; import { ExchangeApiKeysService } from '@/modules/exchange-api-keys'; import { UserEntity, UserNotFoundError, UserRepository } from '@/modules/user'; import { WalletWithProvider, Web3Service } from '@/modules/web3'; @@ -150,6 +149,7 @@ describe('StakingService', () => { exchangeStake: exchangeStake.toString(), onChainStake: onChainStake.toString(), minThreshold: mockStakingConfigService.minThreshold.toString(), + eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, }); }); @@ -167,6 +167,7 @@ describe('StakingService', () => { exchangeStake: exchangeStake.toString(), onChainStake: '0', minThreshold: mockStakingConfigService.minThreshold.toString(), + eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, }); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts index cbb3c33536..7674ee3de6 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -68,6 +68,7 @@ export class StakingService { exchangeStake: '0', onChainStake: '0', minThreshold: this.stakingConfigService.minThreshold.toString(), + eligibilityEnabled: this.stakingConfigService.eligibilityEnabled, }; try { diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts index 3d714fa33a..bb8eeac57c 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts @@ -2,6 +2,7 @@ export type StakeSummaryData = { exchangeStake: string; onChainStake: string; minThreshold: string; + eligibilityEnabled: boolean; exchangeError?: string; onChainError?: string; }; diff --git a/packages/apps/reputation-oracle/server/src/utils/stake.ts b/packages/apps/reputation-oracle/server/src/utils/stake.ts index 8516a79e86..49988b7c22 100644 --- a/packages/apps/reputation-oracle/server/src/utils/stake.ts +++ b/packages/apps/reputation-oracle/server/src/utils/stake.ts @@ -1,6 +1,6 @@ -export const formatStake = (value: number) => - new Intl.NumberFormat(undefined, { - maximumFractionDigits: 18, - notation: 'standard', - useGrouping: false, - }).format(value); +const stakeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 18, + notation: 'standard', + useGrouping: false, +}); +export const formatStake = (value: number) => stakeFormatter.format(value); From 3df929fc9dbb32d73e57e564ed4bc2fd0d005f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 19 Dec 2025 15:41:47 +0100 Subject: [PATCH 21/26] add stake configuration endpoint --- .../common/config/gateway-config.service.ts | 5 +++++ .../enums/reputation-oracle-endpoints.ts | 1 + .../reputation-oracle.gateway.ts | 14 +++++++++++++- .../src/modules/staking/model/staking.model.ts | 18 ++++++++++-------- .../staking/spec/staking.controller.spec.ts | 9 +++++++++ .../modules/staking/spec/staking.fixtures.ts | 9 ++++++--- .../staking/spec/staking.service.mock.ts | 6 +++++- .../staking/spec/staking.service.spec.ts | 18 +++++++++++++++++- .../src/modules/staking/staking.controller.ts | 13 ++++++++++++- .../src/modules/staking/staking.service.ts | 9 ++++++++- .../src/modules/staking/staking.controller.ts | 9 ++++++++- .../server/src/modules/staking/staking.dto.ts | 14 ++++++++------ .../modules/staking/staking.service.spec.ts | 15 +++++++++++---- .../src/modules/staking/staking.service.ts | 11 ++++++++--- .../server/src/modules/staking/types.ts | 7 +++++-- 15 files changed, 126 insertions(+), 32 deletions(-) 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 c80afa902a..0dbbb0c2cb 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 @@ -166,6 +166,11 @@ export class GatewayConfigService { method: HttpMethod.GET, headers: this.JSON_HEADER, }, + [ReputationOracleEndpoints.STAKE_CONFIG]: { + endpoint: '/staking/config', + method: HttpMethod.GET, + headers: this.JSON_HEADER, + }, } as Record, }, [ExternalApiName.HCAPTCHA_LABELING_STATS]: { 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 88fc58209f..63cc216389 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 @@ -27,6 +27,7 @@ export enum ReputationOracleEndpoints { EXCHANGE_API_KEYS_RETRIEVE = 'exchange_api_keys_retrieve', EXCHANGE_API_KEYS_SUPPORTED_EXCHANGES = 'exchange_api_keys_supported_exchanges', STAKE_SUMMARY = 'stake_summary', + STAKE_CONFIG = 'stake_config', } export enum HCaptchaLabelingStatsEndpoints { USER_STATS = 'user_stats', 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 cca1c38281..2d9b643fc2 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 @@ -102,7 +102,10 @@ import { RetrieveExchangeApiKeysResponse, SupportedExchangeResponse, } from '../../modules/exchange-api-keys/model/exchange-api-keys.model'; -import { StakeSummaryResponse } from '../../modules/staking/model/staking.model'; +import { + StakeConfigResponse, + StakeSummaryResponse, +} from '../../modules/staking/model/staking.model'; @Injectable() export class ReputationOracleGateway { @@ -194,6 +197,15 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } + async getStakeConfig(token: string): Promise { + const options = this.getEndpointOptions( + ReputationOracleEndpoints.STAKE_CONFIG, + undefined, + token, + ); + return this.handleRequestToReputationOracle(options); + } + async supportedExchanges( token: string, ): Promise { diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts index c1e0eb51f4..9137e68931 100644 --- a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -10,14 +10,6 @@ export class StakeSummaryResponse { @AutoMap() onChainStake: number; - @ApiProperty({ name: 'min_threshold' }) - @AutoMap() - minThreshold: number; - - @ApiProperty({ name: 'eligibility_enabled' }) - @AutoMap() - eligibilityEnabled: boolean; - @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) @AutoMap() exchangeError?: string; @@ -26,3 +18,13 @@ export class StakeSummaryResponse { @AutoMap() onChainError?: string; } + +export class StakeConfigResponse { + @ApiProperty({ name: 'min_threshold' }) + @AutoMap() + minThreshold: number; + + @ApiProperty({ name: 'eligibility_enabled' }) + @AutoMap() + eligibilityEnabled: boolean; +} diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts index f92224d522..db3a445fa4 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts @@ -47,4 +47,13 @@ describe('StakingController', () => { expect(result).toEqual(stakingServiceMock.getStakeSummary(TOKEN)); }); }); + + describe('getStakeConfig', () => { + it('should call service.getStakeConfig with token and return response', async () => { + const req: RequestWithUser = { token: TOKEN } as RequestWithUser; + const result = await controller.getStakeConfig(req); + expect(service.getStakeConfig).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakingServiceMock.getStakeConfig(TOKEN)); + }); + }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts index ac04dad1a2..00e0298a49 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -1,8 +1,11 @@ export const TOKEN = 'test_user_token'; export const stakeSummaryResponseFixture = { - exchangeStake: 1000, - onChainStake: 500, - minThreshold: 1000, + exchangeStake: '1000', + onChainStake: '500', +}; + +export const stakeConfigResponseFixture = { + minThreshold: '1000', eligibilityEnabled: true, }; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts index 08243dd9d7..582f931236 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.mock.ts @@ -1,5 +1,9 @@ -import { stakeSummaryResponseFixture } from './staking.fixtures'; +import { + stakeConfigResponseFixture, + stakeSummaryResponseFixture, +} from './staking.fixtures'; export const stakingServiceMock = { getStakeSummary: jest.fn().mockReturnValue(stakeSummaryResponseFixture), + getStakeConfig: jest.fn().mockReturnValue(stakeConfigResponseFixture), }; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts index 32dab519d2..f01058e6a9 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts @@ -1,7 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ReputationOracleGateway } from '../../../integrations/reputation-oracle/reputation-oracle.gateway'; import { StakingService } from '../staking.service'; -import { stakeSummaryResponseFixture, TOKEN } from './staking.fixtures'; +import { + stakeConfigResponseFixture, + stakeSummaryResponseFixture, + TOKEN, +} from './staking.fixtures'; describe('StakingService', () => { let service: StakingService; @@ -10,6 +14,7 @@ describe('StakingService', () => { beforeEach(async () => { reputationOracleMock = { getStakeSummary: jest.fn(), + getStakeConfig: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -36,4 +41,15 @@ describe('StakingService', () => { expect(result).toEqual(stakeSummaryResponseFixture); }); }); + + describe('getStakeConfig', () => { + it('should retrieve stake config', async () => { + (reputationOracleMock.getStakeConfig as jest.Mock).mockResolvedValue( + stakeConfigResponseFixture, + ); + const result = await service.getStakeConfig(TOKEN); + expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(TOKEN); + expect(result).toEqual(stakeConfigResponseFixture); + }); + }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts index f8bcdae90f..62d7a7fc14 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts @@ -4,7 +4,10 @@ import { Controller, Get, Request } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; import { StakingService } from './staking.service'; -import { StakeSummaryResponse } from './model/staking.model'; +import { + StakeConfigResponse, + StakeSummaryResponse, +} from './model/staking.model'; @ApiTags('Staking') @ApiBearerAuth() @@ -22,4 +25,12 @@ export class StakingController { ): Promise { return this.service.getStakeSummary(req.token); } + + @ApiOperation({ summary: 'Get staking configuration' }) + @Get('/config') + async getStakeConfig( + @Request() req: RequestWithUser, + ): Promise { + return this.service.getStakeConfig(req.token); + } } diff --git a/packages/apps/human-app/server/src/modules/staking/staking.service.ts b/packages/apps/human-app/server/src/modules/staking/staking.service.ts index 1d75dd95b5..d30c6e14a5 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.service.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; import { ReputationOracleGateway } from '../../integrations/reputation-oracle/reputation-oracle.gateway'; -import { StakeSummaryResponse } from './model/staking.model'; +import { + StakeConfigResponse, + StakeSummaryResponse, +} from './model/staking.model'; @Injectable() export class StakingService { @@ -9,4 +12,8 @@ export class StakingService { getStakeSummary(token: string): Promise { return this.reputationOracle.getStakeSummary(token); } + + getStakeConfig(token: string): Promise { + return this.reputationOracle.getStakeConfig(token); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts index 409d0f766b..ebad148aa5 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts @@ -8,7 +8,7 @@ import { import type { RequestWithUser } from '@/common/types'; -import { StakeSummaryResponseDto } from './staking.dto'; +import { StakeConfigResponseDto, StakeSummaryResponseDto } from './staking.dto'; import { StakingControllerErrorsFilter } from './staking.error-filter'; import { StakingService } from './staking.service'; @@ -27,4 +27,11 @@ export class StakingController { ): Promise { return this.stakingService.getStakeSummary(request.user.id); } + + @ApiOperation({ summary: 'Retrieve staking configuration' }) + @ApiResponse({ status: 200, type: StakeConfigResponseDto }) + @Get('/config') + async getStakeConfig(): Promise { + return this.stakingService.getStakeConfig(); + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts index 876fb09ebb..114430a9da 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.dto.ts @@ -7,15 +7,17 @@ export class StakeSummaryResponseDto { @ApiProperty({ name: 'on_chain_stake' }) onChainStake: string; - @ApiProperty({ name: 'min_threshold' }) - minThreshold: string; - - @ApiProperty({ name: 'eligibility_enabled' }) - eligibilityEnabled: boolean; - @ApiPropertyOptional({ name: 'exchange_error' }) exchangeError?: string; @ApiPropertyOptional({ name: 'on_chain_error' }) onChainError?: string; } + +export class StakeConfigResponseDto { + @ApiProperty({ name: 'min_threshold' }) + minThreshold: string; + + @ApiProperty({ name: 'eligibility_enabled' }) + eligibilityEnabled: boolean; +} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts index 2946fd1fbf..bac6576a7b 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.spec.ts @@ -148,8 +148,6 @@ describe('StakingService', () => { expect(result).toEqual({ exchangeStake: exchangeStake.toString(), onChainStake: onChainStake.toString(), - minThreshold: mockStakingConfigService.minThreshold.toString(), - eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, }); }); @@ -166,8 +164,6 @@ describe('StakingService', () => { expect(result).toEqual({ exchangeStake: exchangeStake.toString(), onChainStake: '0', - minThreshold: mockStakingConfigService.minThreshold.toString(), - eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, }); }); }); @@ -202,4 +198,15 @@ describe('StakingService', () => { ); }); }); + + describe('getStakeConfig', () => { + it('returns current staking configuration', async () => { + const result = await stakingService.getStakeConfig(); + + expect(result).toEqual({ + minThreshold: mockStakingConfigService.minThreshold.toString(), + eligibilityEnabled: mockStakingConfigService.eligibilityEnabled, + }); + }); + }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts index 7674ee3de6..b99412ddb0 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.service.ts @@ -11,7 +11,7 @@ import { UserNotFoundError, UserRepository } from '@/modules/user'; import { Web3Service } from '@/modules/web3'; import { formatStake } from '@/utils/stake'; -import { StakeSummaryData } from './types'; +import { StakeConfigData, StakeSummaryData } from './types'; @Injectable() export class StakingService { @@ -67,8 +67,6 @@ export class StakingService { const summary: StakeSummaryData = { exchangeStake: '0', onChainStake: '0', - minThreshold: this.stakingConfigService.minThreshold.toString(), - eligibilityEnabled: this.stakingConfigService.eligibilityEnabled, }; try { @@ -104,4 +102,11 @@ export class StakingService { return summary; } + + async getStakeConfig(): Promise { + return { + minThreshold: this.stakingConfigService.minThreshold.toString(), + eligibilityEnabled: this.stakingConfigService.eligibilityEnabled, + }; + } } diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts index bb8eeac57c..fcf1e16436 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/types.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/types.ts @@ -1,8 +1,11 @@ export type StakeSummaryData = { exchangeStake: string; onChainStake: string; - minThreshold: string; - eligibilityEnabled: boolean; exchangeError?: string; onChainError?: string; }; + +export type StakeConfigData = { + minThreshold: string; + eligibilityEnabled: boolean; +}; From 93e96b2a4a77b9f830a32702a207a604e21d0f4e Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 22 Dec 2025 18:07:57 +0300 Subject: [PATCH 22/26] [Human App FE] Staking Requirements UI (#3725) Co-authored-by: Dmitry Nechay --- packages/apps/human-app/frontend/.env.example | 1 + .../assets/icons-dark-mode/content-copy.svg | 3 + .../assets/icons-dark-mode/delete-icon.svg | 3 + .../src/assets/icons-dark-mode/edit-icon.svg | 3 + .../src/assets/icons/content-copy.svg | 3 + .../frontend/src/assets/icons/delete-icon.svg | 3 + .../frontend/src/assets/icons/edit-icon.svg | 3 + packages/apps/human-app/frontend/src/main.tsx | 44 +-- .../src/modules/auth/context/auth-context.tsx | 1 + .../worker/hooks/use-exchange-api-keys.ts | 58 ++++ .../worker/hooks/use-idv-notification.tsx | 18 -- .../src/modules/worker/hooks/use-staking.ts | 11 + .../should-navigate-to-registration.ts | 6 +- .../hooks/use-available-jobs-filter-modal.tsx | 6 +- .../src/modules/worker/jobs/jobs.page.tsx | 16 +- .../hooks/use-my-jobs-filter-modal.tsx | 6 +- .../profile/components/add-api-key-modal.tsx | 252 ++++++++++++++++ .../profile/components/api-key-data.tsx | 101 +++++++ .../profile/components/custom-text-field.tsx | 42 +++ .../components/delete-api-key-modal.tsx | 118 ++++++++ .../profile/components/edit-api-key-modal.tsx | 280 ++++++++++++++++++ .../identity-verification-control.tsx | 2 +- .../worker/profile/components/index.ts | 9 +- .../profile/components/modal-states.tsx | 55 ++++ .../profile/components/profile-actions.tsx | 17 -- .../profile/components/profile-data.tsx | 23 +- .../profile/components/staking-info.tsx | 183 ++++++++++++ .../components/wallet-connect-done.tsx | 102 +++++-- .../profile/hooks/use-api-key-modals.tsx | 52 ++++ .../worker/profile/hooks/use-start-idv.ts | 14 +- .../worker/profile/views/profile.page.tsx | 32 +- .../worker/providers/require-stake.tsx | 31 ++ .../services/exchangeApiKeys.service.ts | 46 +++ .../worker/services/staking.service.ts | 17 ++ .../drawer-menu-items-worker.tsx | 11 +- .../human-app/frontend/src/router/router.tsx | 37 ++- .../shared/components/data-entry/input.tsx | 6 +- .../src/shared/components/ui/icons.tsx | 21 ++ .../components/ui/modal/global-modal.tsx | 49 ++- .../components/ui/modal/modal-header.tsx | 42 --- .../src/shared/contexts/modal-context.tsx | 19 +- .../apps/human-app/frontend/src/shared/env.ts | 1 + .../frontend/src/shared/i18n/en.json | 44 ++- .../shared/providers/ui-config-provider.tsx | 44 +++ .../src/shared/services/ui-config.service.ts | 3 +- .../frontend/src/shared/styles/theme.ts | 7 + .../apps/human-app/frontend/vite.config.mjs | 5 + .../reputation-oracle.gateway.ts | 3 +- .../job-assignment.controller.ts | 11 +- .../modules/staking/model/staking.model.ts | 29 +- .../staking/spec/staking.controller.spec.ts | 9 - .../modules/staking/spec/staking.fixtures.ts | 8 +- .../staking/spec/staking.service.spec.ts | 4 +- .../src/modules/staking/staking.controller.ts | 13 +- .../src/modules/staking/staking.service.ts | 4 +- .../ui-configuration.controller.spec.ts | 25 +- .../ui-configuration.controller.ts | 7 +- .../ui-configuration/ui-configuration.dto.ts | 6 + .../ui-configuration.module.ts | 2 + .../src/modules/staking/staking.controller.ts | 4 +- 60 files changed, 1703 insertions(+), 272 deletions(-) create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/content-copy.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg create mode 100644 packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg create mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx delete mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx create mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts create mode 100644 packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts delete mode 100644 packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx create mode 100644 packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx diff --git a/packages/apps/human-app/frontend/.env.example b/packages/apps/human-app/frontend/.env.example index b0ea764d9e..8a13a7385e 100644 --- a/packages/apps/human-app/frontend/.env.example +++ b/packages/apps/human-app/frontend/.env.example @@ -25,6 +25,7 @@ VITE_H_CAPTCHA_ORACLE_TASK_TYPES=image_points,image_boxes # Other VITE_HUMAN_PROTOCOL_HELP_URL=https://docs.humanprotocol.org/ VITE_HUMAN_PROTOCOL_URL=https://humanprotocol.org/ +VITE_STAKING_DASHBOARD_URL=https://staking.humanprotocol.org/ VITE_HUMAN_SUPPORT_EMAIL=support@local.app VITE_NAVBAR__LINK__HOW_IT_WORK_URL=https://humanprotocol.org/ VITE_NAVBAR__LINK__PROTOCOL_URL=https://humanprotocol.org/ diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg new file mode 100644 index 0000000000..c4c4f89b16 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg new file mode 100644 index 0000000000..7be6824ded --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg new file mode 100644 index 0000000000..37d6a7af11 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons-dark-mode/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg new file mode 100644 index 0000000000..4423c76961 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/content-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg new file mode 100644 index 0000000000..77a98a0a93 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/delete-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg new file mode 100644 index 0000000000..3bbfdd7749 --- /dev/null +++ b/packages/apps/human-app/frontend/src/assets/icons/edit-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/apps/human-app/frontend/src/main.tsx b/packages/apps/human-app/frontend/src/main.tsx index ccf792a2b6..bf9aaa190f 100644 --- a/packages/apps/human-app/frontend/src/main.tsx +++ b/packages/apps/human-app/frontend/src/main.tsx @@ -23,6 +23,7 @@ import { HomePageStateProvider } from '@/shared/contexts/homepage-state'; import { NotificationProvider } from '@/shared/providers/notifications-provider'; import { ModalProvider } from './shared/contexts/modal-context'; import { GlobalModal } from './shared/components/ui/modal/global-modal'; +import { UiConfigProvider } from './shared/providers/ui-config-provider'; const root = document.getElementById('root'); if (!root) throw Error('root element is undefined'); @@ -39,25 +40,30 @@ createRoot(root).render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + 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 2b2478b5f4..13b9001fed 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 @@ -11,6 +11,7 @@ const userDataSchema = z.object({ user_id: z.number(), reputation_network: z.string(), exp: z.number(), + is_stake_eligible: z.boolean(), }); export type UserData = z.infer; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts new file mode 100644 index 0000000000..c958791ca7 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-exchange-api-keys.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + deleteExchangeApiKeys, + enrollExchangeApiKeys, + getExchangeApiKeys, + getSupportedExchanges, +} from '../services/exchangeApiKeys.service'; + +function useGetSupportedExchanges() { + return useQuery({ + queryKey: ['supported-exchanges'], + queryFn: () => getSupportedExchanges(), + }); +} + +function useGetExchangeApiKeys() { + return useQuery({ + queryKey: ['exchange-api-keys'], + queryFn: () => getExchangeApiKeys(), + }); +} + +function useEnrollExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['enroll-exchange-api-keys'], + mutationFn: (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => enrollExchangeApiKeys(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); + }, + }); +} + +function useDeleteExchangeApiKeys() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['delete-exchange-api-keys'], + mutationFn: () => deleteExchangeApiKeys(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['exchange-api-keys'] }); + queryClient.invalidateQueries({ queryKey: ['staking-summary'] }); + }, + }); +} + +export { + useGetSupportedExchanges, + useDeleteExchangeApiKeys, + useGetExchangeApiKeys, + useEnrollExchangeApiKeys, +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx deleted file mode 100644 index 7f6663e162..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-idv-notification.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { - TopNotificationType, - useNotification, -} from '@/shared/hooks/use-notification'; -import { getErrorMessageForError } from '@/shared/errors'; -import type { ResponseError } from '@/shared/types/global.type'; - -export function useIdvErrorNotifications() { - const { showNotification } = useNotification(); - - return (error: ResponseError) => { - showNotification({ - type: TopNotificationType.WARNING, - message: getErrorMessageForError(error), - durationMs: 5000, - }); - }; -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts new file mode 100644 index 0000000000..1556c072d6 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/hooks/use-staking.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { getStakingSummary } from '../services/staking.service'; + +function useGetStakingSummary() { + return useQuery({ + queryKey: ['staking-summary'], + queryFn: () => getStakingSummary(), + }); +} + +export { useGetStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts index 8bd98d3628..e92ec7dc33 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs-discovery/helpers/should-navigate-to-registration.ts @@ -8,7 +8,5 @@ export const shouldNavigateToRegistration = ( oracle: Oracle, registrationData?: RegistrationResult ): boolean => - Boolean( - oracle.registrationNeeded && - !registrationData?.oracle_addresses.includes(oracle.address) - ); + !!oracle.registrationNeeded && + !registrationData?.oracle_addresses.includes(oracle.address); diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx index f90e919961..6ce55c5c48 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/available-jobs/hooks/use-available-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { AvailableJobsFilterModal } from '../available-jobs-filter-modal'; export function useAvailableJobsFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx index 5e65b627ae..21364ccedf 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/jobs.page.tsx @@ -7,7 +7,7 @@ import { useIsMobile } from '@/shared/hooks/use-is-mobile'; import { useColorMode } from '@/shared/contexts/color-mode'; import { NoRecords } from '@/shared/components/ui/no-records'; import { PageCardLoader } from '@/shared/components/ui/page-card'; -import { useGetUiConfig } from '@/shared/hooks'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; import { useGetOracles } from '../hooks'; import { useGetOraclesNotifications } from '../hooks/use-get-oracles-notifications'; import { TabPanel } from './components'; @@ -30,19 +30,15 @@ export function JobsPage() { error, } = useGetOracles(); - const { - data: uiConfigData, - isPending: isPendingUiConfig, - isError: isErrorUiConfig, - } = useGetUiConfig(); + const { uiConfig, isUiConfigLoading, isUiConfigError } = useUiConfig(); const { address: oracle_address } = useParams<{ address: string }>(); const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(0); const isMobile = useIsMobile(); - const isError = isErrorGetOracles || isErrorUiConfig; - const isPending = isPendingGetOracles || isPendingUiConfig; + const isError = isErrorGetOracles || isUiConfigError; + const isPending = isPendingGetOracles || isUiConfigLoading; const { onError } = useGetOraclesNotifications(); const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { @@ -119,7 +115,7 @@ export function JobsPage() { ) : ( )} @@ -128,7 +124,7 @@ export function JobsPage() { ) : ( )} diff --git a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx index 40a0c9c1be..a41ca0c491 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/jobs/my-jobs/hooks/use-my-jobs-filter-modal.tsx @@ -1,17 +1,17 @@ import { useModal } from '@/shared/contexts/modal-context'; -import { useGetUiConfig } from '@/shared/hooks'; import { MyJobsFilterModal } from '../components/mobile/my-jobs-filter-modal'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function useMyJobFilterModal() { const { openModal, closeModal } = useModal(); - const { data: uiConfigData } = useGetUiConfig(); + const { uiConfig } = useUiConfig(); return { openModal: () => { openModal({ content: ( ), diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx new file mode 100644 index 0000000000..f3b1ee6954 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/add-api-key-modal.tsx @@ -0,0 +1,252 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + Box, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; +import { useEffect } from 'react'; + +interface AddApiKeyModalProps { + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function AddApiKeyModal({ onClose, disableClose }: AddApiKeyModalProps) { + const { t } = useTranslation(); + const { + mutate: enrollExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isPending, + isError, + isIdle, + } = useEnrollExchangeApiKeys(); + const isMobile = useIsMobile(); + const { data: supportedExchanges } = useGetSupportedExchanges(); + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + secretKey: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().trim().min(1, t('validation.required')), + secretKey: z.string().trim().min(1, t('validation.required')), + }) + ), + }); + + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => { + enrollExchangeApiKey(data); + }; + + return ( +
+ + + {isMobile + ? t('worker.profile.apiKeyData.connectApiKey') + : t('worker.profile.apiKeyData.connectYourApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} + /> + )} + /> + {errors.exchange && ( + + {errors.exchange.message} + + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + {errors.secretKey && ( + + {errors.secretKey.message} + + )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.connectSuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + {t('worker.profile.apiKeyData.modalFooterAgreement')} + + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx new file mode 100644 index 0000000000..55cb3efb93 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/api-key-data.tsx @@ -0,0 +1,101 @@ +import { IconButton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; +import { useColorMode } from '@/shared/contexts/color-mode'; +import { DeleteIcon, EditIcon } from '@/shared/components/ui/icons'; +import { + useDeleteApiKeyModal, + useEditApiKeyModal, +} from '../hooks/use-api-key-modals'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; + +export function ApiKeyData() { + const { isDarkMode } = useColorMode(); + const { t } = useTranslation(); + const { openModal: openEditApiKeyModal } = useEditApiKeyModal(); + const { openModal: openDeleteApiKeyModal } = useDeleteApiKeyModal(); + const { data: exchangeApiKeyData, isError: isExchangeApiKeyError } = + useGetExchangeApiKeys(); + + const textField = isDarkMode ? ( + + ) : ( + + ); + + return ( + + + + {t('worker.profile.apiKeyData.apiKey')} + + + + + {textField} + {exchangeApiKeyData?.exchange_name && ( + + + openEditApiKeyModal(exchangeApiKeyData.exchange_name) + } + > + + + + + + + )} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx new file mode 100644 index 0000000000..de9183c226 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/custom-text-field.tsx @@ -0,0 +1,42 @@ +import { colorPalette } from '@/shared/styles/color-palette'; +import { + darkColorPalette, + onlyDarkModeColor, +} from '@/shared/styles/dark-color-palette'; +import { styled, TextField } from '@mui/material'; + +const CustomTextField = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: `${colorPalette.text.disabledSecondary} !important`, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${colorPalette.text.primary} !important`, + color: colorPalette.text.disabledSecondary, + WebkitTextFillColor: colorPalette.text.disabledSecondary, + }, + }, +})); + +const CustomTextFieldDark = styled(TextField)(() => ({ + '& .Mui-disabled': { + height: '48px', + maxWidth: '376px', + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: `${darkColorPalette.text.disabledSecondary} !important`, + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + border: '1px dashed', + borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, + color: darkColorPalette.text.disabledSecondary, + WebkitTextFillColor: darkColorPalette.text.disabledSecondary, + }, + }, +})); + +export { CustomTextField, CustomTextFieldDark }; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx new file mode 100644 index 0000000000..6a75d44423 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/delete-api-key-modal.tsx @@ -0,0 +1,118 @@ +import { Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { useDeleteExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useEffect } from 'react'; +import { ModalError, ModalSuccess, ModalLoading } from './modal-states'; + +interface DeleteApiKeyModalProps { + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function DeleteApiKeyModal({ + onClose, + disableClose, +}: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const { + mutate: deleteExchangeApiKey, + reset: resetMutation, + isSuccess, + isError, + isPending, + isIdle, + } = useDeleteExchangeApiKeys(); + const isMobile = useIsMobile(); + + const handleDeleteExchangeApiKey = () => { + deleteExchangeApiKey(); + }; + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + useEffect(() => { + return () => { + resetMutation(); + }; + }, [resetMutation]); + + return ( + + + {t('worker.profile.apiKeyData.deleteApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.deleteApiKeyConfirmation')} + + + {t('worker.profile.apiKeyData.deleteApiKeyDescription')} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.deleteKeySuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx new file mode 100644 index 0000000000..9141eb9b02 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/edit-api-key-modal.tsx @@ -0,0 +1,280 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Autocomplete, + Box, + FormControl, + FormHelperText, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { Button } from '@/shared/components/ui/button'; +import { useIsMobile } from '@/shared/hooks'; +import { useEffect } from 'react'; +import { + useEnrollExchangeApiKeys, + useGetSupportedExchanges, +} from '../../hooks/use-exchange-api-keys'; +import { ModalError, ModalLoading, ModalSuccess } from './modal-states'; + +interface EditApiKeyModalProps { + exchangeName: string; + onClose: () => void; + disableClose: (disable: boolean) => void; +} + +export function EditApiKeyModal({ + onClose, + exchangeName, + disableClose, +}: EditApiKeyModalProps) { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + const { + mutate: postExchangeApiKey, + reset: resetMutation, + error, + isSuccess, + isError, + isPending, + isIdle, + } = useEnrollExchangeApiKeys(); + const { data: supportedExchanges } = useGetSupportedExchanges(); + + const { + control, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + defaultValues: { + exchange: '', + apiKey: '', + secretKey: '', + }, + resolver: zodResolver( + z.object({ + exchange: z.string().min(1, t('validation.required')), + apiKey: z.string().trim().min(1, t('validation.required')), + secretKey: z.string().trim().min(1, t('validation.required')), + }) + ), + }); + + useEffect(() => { + if (exchangeName && supportedExchanges) { + reset({ + exchange: exchangeName, + apiKey: '', + secretKey: '', + }); + } + }, [exchangeName, reset, supportedExchanges]); + + useEffect(() => { + disableClose(isPending); + }, [isPending, disableClose]); + + useEffect(() => { + return () => { + reset(); + resetMutation(); + }; + }, [reset, resetMutation]); + + const onSubmit = (data: { + exchange: string; + apiKey: string; + secretKey: string; + }) => { + postExchangeApiKey(data); + }; + + return ( +
+ + + {t('worker.profile.apiKeyData.editApiKey')} + + {isPending && } + {isIdle && ( + <> + + {t('worker.profile.apiKeyData.modalDescription')} + + + + ( + exchange.name) || + [] + } + getOptionLabel={(option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return exchange?.display_name || option || ''; + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => { + const exchange = supportedExchanges?.find( + (exchange) => exchange.name === option + ); + return ( + + + {exchange?.display_name || exchange?.name} + + + ); + }} + {...field} + onChange={(_, value) => field.onChange(value)} + /> + )} + /> + {errors.exchange && ( + + {errors.exchange.message} + + )} + + + ( + + )} + /> + {errors.apiKey && ( + {errors.apiKey.message} + )} + + + + ( + + )} + /> + {errors.secretKey && ( + + {errors.secretKey.message} + + )} + + + )} + {isSuccess && ( + + + {t('worker.profile.apiKeyData.editSuccess')} + + + )} + {isError && ( + + )} + {isIdle && ( + + + + + )} + {(isPending || isSuccess) && ( + + )} + {isError && ( + + )} + +
+ ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx index b808043119..6acd2ab252 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/identity-verification-control.tsx @@ -29,7 +29,7 @@ export function IdentityVerificationControl() { return ( - {t('worker.profile.identityVerificationStatus')}:{' '} + {t('worker.profile.identityVerificationStatus')} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts index 43ff1e202d..7df62f04b8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/index.ts @@ -1,3 +1,10 @@ export * from './wallet-connect-done'; -export * from './profile-actions'; export * from './profile-data'; +export * from './identity-verification-control'; +export * from './wallet-connection-control'; +export * from './staking-info'; +export * from './api-key-data'; +export * from './custom-text-field'; +export * from './add-api-key-modal'; +export * from './edit-api-key-modal'; +export * from './delete-api-key-modal'; diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx new file mode 100644 index 0000000000..5225e3bd2b --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/modal-states.tsx @@ -0,0 +1,55 @@ +import CheckIcon from '@mui/icons-material/CheckCircle'; +import { Loader } from '@/shared/components/ui/loader'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import CloseIcon from '@mui/icons-material/Cancel'; + +export function ModalLoading() { + return ( + + + + ); +} + +export function ModalSuccess({ children }: { children: React.ReactNode }) { + return ( + <> + + + + {children} + + ); +} + +export function ModalError({ message }: { message: string }) { + return ( + <> + + + + + {message} + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx deleted file mode 100644 index 56551421f6..0000000000 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-actions.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Grid from '@mui/material/Grid'; -import { IdentityVerificationControl } from './identity-verification-control'; -import { WalletConnectionControl } from './wallet-connection-control'; - -export function ProfileActions() { - return ( - - - - - - - - - - ); -} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx index b3222fe956..42278a97c8 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/profile-data.tsx @@ -1,5 +1,4 @@ -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; +import { Stack, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; @@ -13,8 +12,8 @@ export function ProfileData() { const { user } = useAuthenticatedUser(); const { t } = useTranslation(); return ( - - + + {t('worker.profile.email')} {user.email} - - + + + + {t('worker.profile.password')} + - - + + ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx new file mode 100644 index 0000000000..c985b55b79 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -0,0 +1,183 @@ +import { Button, IconButton, Skeleton, Stack, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { Chip } from '@/shared/components/ui/chip'; +import { env } from '@/shared/env'; +import { useAddApiKeyModal } from '../hooks/use-api-key-modals'; +import { ApiKeyData } from './'; +import { useGetStakingSummary } from '../../hooks/use-staking'; +import { RefreshIcon } from '@/shared/components/ui/icons'; +import { useGetExchangeApiKeys } from '../../hooks/use-exchange-api-keys'; +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { useEffect, useRef, useState } from 'react'; +import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; +import { colorPalette } from '@/shared/styles/color-palette'; + +export function StakingInfo() { + const [isPromptExpanded, setIsPromptExpanded] = useState(false); + const tokenRefreshLock = useRef(false); + + const { user, updateUserData } = useAuthenticatedUser(); + const { refreshAccessTokenAsync } = useAccessTokenRefresh(); + const { t } = useTranslation(); + + const { openModal: openAddApiKeyModal } = useAddApiKeyModal(); + const { data: exchangeApiKeyData, isLoading: isExchangeApiKeyLoading } = + useGetExchangeApiKeys(); + const { + data: stakingSummary, + isLoading, + isError, + refetch, + isRefetching, + } = useGetStakingSummary(); + + const isConnectButtonDisabled = + !!exchangeApiKeyData?.exchange_name || isExchangeApiKeyLoading; + + const stakedAmount = + Number(stakingSummary?.on_chain_stake || 0) + + Number(stakingSummary?.exchange_stake || 0); + + const isStakingError = + !!stakingSummary?.on_chain_error || + !!stakingSummary?.exchange_error || + isError; + + const isStaked = + isLoading || isStakingError + ? false + : stakedAmount >= Number(stakingSummary?.min_threshold); + + useEffect(() => { + if (isRefetching || isLoading) return; + + if (isStaked !== user.is_stake_eligible) { + if (!tokenRefreshLock.current) { + tokenRefreshLock.current = true; + updateUserData({ is_stake_eligible: isStaked }); + void refreshAccessTokenAsync({ authType: 'web2' }); + } + } else { + tokenRefreshLock.current = false; + } + }, [ + isStaked, + user.is_stake_eligible, + refreshAccessTokenAsync, + isRefetching, + isLoading, + updateUserData, + ]); + + const handleRefreshStakingInfo = () => { + if (isRefetching || isLoading) return; + tokenRefreshLock.current = false; + refetch(); + }; + + return ( + + + + {t('worker.profile.stakingInfo.stakeHmt')} + + {isLoading || isRefetching ? ( + + ) : ( + + )} + + + {isPromptExpanded + ? t('worker.profile.stakingInfo.prompt', { + amount: stakingSummary?.min_threshold, + }) + : t('worker.profile.stakingInfo.promptShort')}{' '} + + + + + {t('worker.profile.stakingInfo.stakedAmount')} + + svg > path': { + fill: + isRefetching || isLoading + ? colorPalette.button.disabled + : 'primary.main', + }, + }} + onClick={handleRefreshStakingInfo} + > + + + + {isLoading || isRefetching ? ( + + ) : ( + + {stakedAmount} HMT + + )} + + + + + + + ); +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx index 263b262173..808b282eb1 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/wallet-connect-done.tsx @@ -1,57 +1,97 @@ -import { Stack, TextField, Typography } from '@mui/material'; +import { IconButton, Stack, Tooltip, Typography } from '@mui/material'; import { t } from 'i18next'; -import styled from '@mui/material/styles/styled'; -import { colorPalette } from '@/shared/styles/color-palette'; import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; import { useColorMode } from '@/shared/contexts/color-mode'; -import { - darkColorPalette, - onlyDarkModeColor, -} from '@/shared/styles/dark-color-palette'; import { useWalletConnect } from '@/shared/contexts/wallet-connect'; import { Chip } from '@/shared/components/ui/chip'; - -const CustomTextField = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: colorPalette.text.disabledSecondary, - WebkitTextFillColor: colorPalette.text.disabledSecondary, - }, -})); -const CustomTextFieldDark = styled(TextField)(() => ({ - '& .Mui-disabled': { - color: darkColorPalette.text.disabledSecondary, - WebkitTextFillColor: darkColorPalette.text.disabledSecondary, - }, - '& .MuiOutlinedInput-root': { - '& fieldset': { - borderColor: `${onlyDarkModeColor.mainColorWithOpacity} !important`, - }, - }, -})); +import { CopyIcon } from '@/shared/components/ui/icons'; +import { MouseEvent, useRef, useState } from 'react'; +import { shortenEscrowAddress } from '@/shared/helpers/evm'; +import { CustomTextField, CustomTextFieldDark } from './custom-text-field'; export function WalletConnectDone() { + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(); const { isDarkMode } = useColorMode(); const { address } = useWalletConnect(); - const { user } = useAuthenticatedUser(); + const { + user: { wallet_address }, + } = useAuthenticatedUser(); + + if (!wallet_address) { + return null; + } + + const shortAddress = shortenEscrowAddress(wallet_address, 6, 6); + + const handleCopyClick = (e: MouseEvent) => { + if (isCopied) return; + + e.stopPropagation(); + navigator.clipboard.writeText(wallet_address); + setIsCopied(true); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + setIsCopied(false); + }, 1500); + }; const textFiled = isDarkMode ? ( - + + + + + + ), + }} + /> ) : ( - + + + + + + ), + }} + /> ); return ( - + - {t('worker.profile.wallet')}:{' '} + {t('worker.profile.wallet')} - {address && !user.wallet_address ? null : textFiled} + {address && !wallet_address ? null : textFiled} ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx new file mode 100644 index 0000000000..5d7db03586 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-api-key-modals.tsx @@ -0,0 +1,52 @@ +import { useModal } from '@/shared/contexts/modal-context'; +import { + AddApiKeyModal, + EditApiKeyModal, + DeleteApiKeyModal, +} from '../components'; + +export function useAddApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: () => + openModal({ + content: ( + + ), + }), + }; +} + +export function useEditApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: (exchangeName: string) => + openModal({ + content: ( + + ), + }), + }; +} + +export function useDeleteApiKeyModal() { + const { openModal, closeModal, setDisableClose } = useModal(); + + return { + openModal: () => + openModal({ + content: ( + + ), + }), + }; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts index ef40c57822..ca9bc21805 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/hooks/use-start-idv.ts @@ -1,11 +1,13 @@ import { useEffect, useState, useCallback } from 'react'; -import { useIdvErrorNotifications } from '@/modules/worker/hooks/use-idv-notification'; import { ApiClientError } from '@/api'; import { useIdvStartMutation } from './use-start-idv-mutation'; +import { TopNotificationType, useNotification } from '@/shared/hooks'; +import { getErrorMessageForError } from '@/shared/errors'; export function useStartIdv() { const [isIdvAlreadyInProgress, setIsIdvAlreadyInProgress] = useState(false); - const onError = useIdvErrorNotifications(); + + const { showNotification } = useNotification(); const { data: idvStartData, isPending: idvStartIsPending, @@ -28,7 +30,11 @@ export function useStartIdv() { setIsIdvAlreadyInProgress(true); return; } - onError(idvStartMutationError); + showNotification({ + type: TopNotificationType.WARNING, + message: getErrorMessageForError(idvStartMutationError), + durationMs: 5000, + }); } if (idvStarted && idvStartData.url) { @@ -39,7 +45,7 @@ export function useStartIdv() { idvStartFailed, idvStarted, idvStartMutationError, - onError, + showNotification, ]); return { diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx index 4f03b09c81..3fa42e64b9 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/views/profile.page.tsx @@ -1,4 +1,4 @@ -import { Grid, Paper } from '@mui/material'; +import { Paper, Stack } from '@mui/material'; import { useEffect } from 'react'; import { t } from 'i18next'; import { useIsMobile } from '@/shared/hooks/use-is-mobile'; @@ -8,7 +8,14 @@ import { TopNotificationType, useNotification, } from '@/shared/hooks/use-notification'; -import { ProfileData, ProfileActions } from '../components'; +import { + ProfileData, + IdentityVerificationControl, + WalletConnectionControl, + StakingInfo, +} from '../components'; +import { PageCardLoader } from '@/shared/components/ui/page-card/page-card-loader'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function WorkerProfilePage() { const { user } = useAuthenticatedUser(); @@ -16,6 +23,7 @@ export function WorkerProfilePage() { const { isConnected, initializing, web3ProviderMutation } = useWalletConnect(); const { showNotification } = useNotification(); + const { uiConfig, isUiConfigLoading } = useUiConfig(); useEffect(() => { if (initializing) return; @@ -42,6 +50,10 @@ export function WorkerProfilePage() { showNotification, ]); + if (isUiConfigLoading) { + return ; + } + return ( - + - - + + + {!!user.wallet_address && uiConfig?.stakingEligibilityEnabled && ( + + )} + ); } diff --git a/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx new file mode 100644 index 0000000000..c9e9843718 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/providers/require-stake.tsx @@ -0,0 +1,31 @@ +import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-user'; +import { routerPaths } from '@/router/router-paths'; +import { protectedRoutes } from '@/router/routes'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; +import { Navigate, useLocation, matchPath } from 'react-router-dom'; + +const stakeProtectedPaths = protectedRoutes + .map((route) => route.routerProps.path) + .filter((path) => path !== routerPaths.worker.profile); + +export function RequireStake({ + children, +}: Readonly<{ children: JSX.Element }>) { + const { user } = useAuthenticatedUser(); + const location = useLocation(); + const { uiConfig } = useUiConfig(); + + const isStakeProtectedRoute = stakeProtectedPaths.some( + (path) => path && matchPath(path, location.pathname) + ); + + if ( + uiConfig?.stakingEligibilityEnabled && + !user?.is_stake_eligible && + isStakeProtectedRoute + ) { + return ; + } + + return children; +} diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts new file mode 100644 index 0000000000..41db9404e2 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/exchangeApiKeys.service.ts @@ -0,0 +1,46 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +interface ExchangeApiKey { + api_key: string; + exchange_name: string; +} + +interface Exchange { + name: string; + display_name: string; +} + +async function getSupportedExchanges(): Promise { + const response = await authorizedHumanAppApiClient.get( + '/exchange-api-keys/supported-exchanges' + ); + return response || []; +} + +async function getExchangeApiKeys(): Promise { + const response = + await authorizedHumanAppApiClient.get('/exchange-api-keys'); + return response || null; +} + +async function enrollExchangeApiKeys(data: { + exchange: string; + apiKey: string; + secretKey: string; +}): Promise { + const { exchange, ...body } = data; + await authorizedHumanAppApiClient.post(`/exchange-api-keys/${exchange}`, { + body, + }); +} + +async function deleteExchangeApiKeys(): Promise { + await authorizedHumanAppApiClient.delete('/exchange-api-keys'); +} + +export { + enrollExchangeApiKeys, + getExchangeApiKeys, + deleteExchangeApiKeys, + getSupportedExchanges, +}; diff --git a/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts new file mode 100644 index 0000000000..9f9ea2b237 --- /dev/null +++ b/packages/apps/human-app/frontend/src/modules/worker/services/staking.service.ts @@ -0,0 +1,17 @@ +import { authorizedHumanAppApiClient } from '@/api'; + +interface StakeSummary { + exchange_stake: string; + exchange_error?: string; + on_chain_stake: string; + on_chain_error?: string; + min_threshold: string; +} + +async function getStakingSummary(): Promise { + const response = + await authorizedHumanAppApiClient.get('/staking/summary'); + return response || null; +} + +export { getStakingSummary }; diff --git a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx index 427bba124b..5c39359e16 100644 --- a/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx +++ b/packages/apps/human-app/frontend/src/router/components/drawer-menu-items/drawer-menu-items-worker.tsx @@ -9,14 +9,21 @@ import type { UserData } from '@/modules/auth/context/auth-context'; import { routerPaths } from '@/router/router-paths'; import { KycStatus } from '@/modules/worker/profile/types'; import { type MenuItem } from '../layout/protected'; +import { UiConfig } from '@/shared/services/ui-config.service'; -export const workerDrawerTopMenuItems = (user: UserData | null): MenuItem[] => { +export const workerDrawerTopMenuItems = ( + user: UserData | null, + uiConfig: UiConfig | undefined +): MenuItem[] => { return [ { label: t('components.DrawerNavigation.jobs'), icon: , link: routerPaths.worker.jobsDiscovery, - disabled: !user?.wallet_address || user.kyc_status !== KycStatus.APPROVED, + disabled: + !user?.wallet_address || + (uiConfig?.stakingEligibilityEnabled && !user?.is_stake_eligible) || + user.kyc_status !== KycStatus.APPROVED, }, ]; }; diff --git a/packages/apps/human-app/frontend/src/router/router.tsx b/packages/apps/human-app/frontend/src/router/router.tsx index b22c8052d8..0d27ce4c46 100644 --- a/packages/apps/human-app/frontend/src/router/router.tsx +++ b/packages/apps/human-app/frontend/src/router/router.tsx @@ -20,9 +20,12 @@ import { workerDrawerBottomMenuItems, workerDrawerTopMenuItems, } from './components'; +import { RequireStake } from '@/modules/worker/providers/require-stake'; +import { useUiConfig } from '@/shared/providers/ui-config-provider'; export function Router() { const { user } = useAuth(); + const { uiConfig } = useUiConfig(); const handleSignOut = () => { browserAuthProvider.signOut({ @@ -57,22 +60,24 @@ export function Router() { - ( - - )} - renderHCaptchaStatisticsDrawer={(isOpen) => ( - - )} - renderGovernanceBanner - /> + + ( + + )} + renderHCaptchaStatisticsDrawer={(isOpen) => ( + + )} + renderGovernanceBanner + /> + } key={routerProps.path} diff --git a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx index 4a8c8ada84..ba1cb12ec9 100644 --- a/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/data-entry/input.tsx @@ -5,10 +5,8 @@ import { Typography } from '@mui/material'; import { type InputMask } from '@/shared/components/data-entry/input-masks'; import { useColorMode } from '@/shared/contexts/color-mode'; -export interface InputProps extends Omit< - TextFieldProps, - 'name' | 'error' | 'helperText' -> { +type OmittedProps = Omit; +export interface InputProps extends OmittedProps { name: string; label?: string; autoComplete?: string; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx index 25681beef6..f58d0b3cba 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/icons.tsx @@ -39,6 +39,12 @@ import MoonIconLight from '@/assets/icons/moon.svg'; import { useColorMode } from '@/shared/contexts/color-mode'; import WorkHeaderDark from '@/assets/icons-dark-mode/work-header.svg'; import WorkHeaderLight from '@/assets/icons/work-header.svg'; +import CopyIconLight from '@/assets/icons/content-copy.svg'; +import CopyIconDark from '@/assets/icons-dark-mode/content-copy.svg'; +import EditIconLight from '@/assets/icons/edit-icon.svg'; +import EditIconDark from '@/assets/icons-dark-mode/edit-icon.svg'; +import DeleteIconLight from '@/assets/icons/delete-icon.svg'; +import DeleteIconDark from '@/assets/icons-dark-mode/delete-icon.svg'; function HomepageLogoIcon() { const { isDarkMode } = useColorMode(); @@ -124,6 +130,18 @@ function MoonIcon() { const { isDarkMode } = useColorMode(); return isDarkMode ? : ; } +function CopyIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function EditIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} +function DeleteIcon() { + const { isDarkMode } = useColorMode(); + return isDarkMode ? : ; +} export { HomepageLogoIcon, @@ -148,4 +166,7 @@ export { SunIcon, MoonIcon, WorkHeaderIcon, + CopyIcon, + EditIcon, + DeleteIcon, }; diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx index 16958d5dc7..7a73af61b8 100644 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx +++ b/packages/apps/human-app/frontend/src/shared/components/ui/modal/global-modal.tsx @@ -1,30 +1,49 @@ -import { DialogContent } from '@mui/material'; -import DialogMui from '@mui/material/Dialog'; +import { DialogContent, IconButton, Dialog } from '@mui/material'; import { useModal } from '../../../contexts/modal-context'; -import { ModalHeader } from './modal-header'; +import CloseIcon from '@mui/icons-material/Close'; export function GlobalModal() { - const { open, closeModal, showCloseButton, content, onTransitionExited } = - useModal(); + const { + open, + closeModal, + showCloseButton, + disableClose, + content, + onTransitionExited, + } = useModal(); return ( - - - {content} - + {showCloseButton && ( + + + + )} + + {content} + + ); } diff --git a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx b/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx deleted file mode 100644 index 423a295366..0000000000 --- a/packages/apps/human-app/frontend/src/shared/components/ui/modal/modal-header.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Grid from '@mui/material/Grid'; -import Button from '@mui/material/Button'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -interface ModalHeaderElementProps { - isVisible: boolean; - onClick: () => void; -} - -interface ModalHeaderProps { - closeButton?: ModalHeaderElementProps; -} - -export function ModalHeader({ closeButton }: Readonly) { - const { t } = useTranslation(); - return ( - - - {closeButton?.isVisible && ( - - - - )} - - - ); -} diff --git a/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx b/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx index 276c85be50..daf1e32eab 100644 --- a/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx +++ b/packages/apps/human-app/frontend/src/shared/contexts/modal-context.tsx @@ -10,9 +10,11 @@ interface ModalContextType { open: boolean; content: React.ReactNode; showCloseButton: boolean; + disableClose: boolean; openModal: ({ content, showCloseButton }: OpenModalProps) => void; closeModal: () => void; onTransitionExited: () => void; + setDisableClose: (disable: boolean) => void; } interface OpenModalProps { @@ -28,6 +30,7 @@ export function ModalProvider({ const [open, setOpen] = useState(false); const [content, setContent] = useState(null); const [showCloseButton, setShowCloseButton] = useState(true); + const [disableClose, setDisableClose] = useState(false); const openModal = useCallback( ({ @@ -36,14 +39,16 @@ export function ModalProvider({ }: OpenModalProps) => { setContent(_modalContent); setShowCloseButton(_showCloseButton ?? showCloseButton); + setDisableClose(false); setOpen(true); }, [showCloseButton] ); const closeModal = useCallback(() => { + if (disableClose) return; setOpen(false); - }, []); + }, [disableClose]); const onTransitionExited = useCallback(() => { setContent(null); @@ -54,11 +59,21 @@ export function ModalProvider({ open, content, showCloseButton, + disableClose, openModal, closeModal, onTransitionExited, + setDisableClose, }), - [open, content, showCloseButton, openModal, closeModal, onTransitionExited] + [ + open, + content, + showCloseButton, + disableClose, + openModal, + closeModal, + onTransitionExited, + ] ); return ( diff --git a/packages/apps/human-app/frontend/src/shared/env.ts b/packages/apps/human-app/frontend/src/shared/env.ts index b442081d05..3be4fb921a 100644 --- a/packages/apps/human-app/frontend/src/shared/env.ts +++ b/packages/apps/human-app/frontend/src/shared/env.ts @@ -5,6 +5,7 @@ const envSchema = z.object({ VITE_PRIVACY_POLICY_URL: z.string(), VITE_TERMS_OF_SERVICE_URL: z.string(), VITE_HUMAN_PROTOCOL_URL: z.string(), + VITE_STAKING_DASHBOARD_URL: z.string(), VITE_NAVBAR__LINK__PROTOCOL_URL: z.string(), VITE_NAVBAR__LINK__HOW_IT_WORK_URL: z.string(), VITE_HUMAN_SUPPORT_EMAIL: z.string(), 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 91bae6249a..951559cb63 100644 --- a/packages/apps/human-app/frontend/src/shared/i18n/en.json +++ b/packages/apps/human-app/frontend/src/shared/i18n/en.json @@ -90,7 +90,8 @@ "pageCardError": { "reload": "Reload", "goHome": "Home Page" - } + }, + "copyToClipboard": "Copied" }, "homepage": { "humanApp": "HUMAN App", @@ -225,6 +226,47 @@ "review": "Under Review", "expired": "Expired", "abandoned": "Abandoned" + }, + "stakingStatusValues": { + "staked": "Staked", + "error": "Error", + "notStaked": "Not Staked" + }, + "stakingInfo": { + "promptShort": "Before you can start earning on the HUMAN App, you'll need to stake HMT.", + "prompt": "Before you can start earning on the HUMAN App, you’ll need to stake HMT. You can stake directly on our Staking Dashboard, or connect your API key having minimum balance of {{amount}} HMT in your exchange account to stake automatically via API KEY.", + "stakeHmt": "Stake HMT", + "stakingInfoValue": "Staking Info Value", + "stakedAmount": "Staked amount", + "readMore": "Read more", + "readLess": "Read less" + }, + "apiKeyData": { + "apiKey": "API KEY", + "exchange": "Exchange", + "apiSecret": "API Secret", + "apiKeyConnected": "Connected", + "apiKeyNotConnected": "Not connected", + "cancel": "Cancel", + "error": "Error", + "close": "Close", + "edit": "Edit", + "tryAgain": "Try Again", + "connectYourApiKey": "Connect your API KEY", + "connectApiKey": "Connect API KEY", + "connectSuccess": "You have successfully connected your API key", + "connectError": "Failed to connect API key.", + "editSuccess": "You have successfully edited your API key", + "editError": "Failed to edit API key.", + "deleteKeySuccess": "You have successfully deleted your API key", + "deleteKeyError": "Failed to delete API key.", + "modalDescription": "To stake HMT using your exchange balance, connect your exchange account by adding your API key and secret. Your credentials are encrypted and securely stored.", + "modalFooterAgreement": "By connecting your API KEY, you agree to HUMAN Protocol Terms of Service and consent to its Privacy Policy.", + "deleteApiKey": "Delete API KEY", + "deleteApiKeyConfirmation": "Are you sure you want to delete your API key?", + "deleteApiKeyDescription": "You can only solve jobs in the HUMAN App if you have staked HMT.", + "editApiKey": "Edit API KEY", + "saveChanges": "Save Changes" } }, "oraclesTable": { diff --git a/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx new file mode 100644 index 0000000000..5f23cd7d49 --- /dev/null +++ b/packages/apps/human-app/frontend/src/shared/providers/ui-config-provider.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useGetUiConfig } from '../hooks/use-get-ui-config'; +import { UiConfig } from '../services/ui-config.service'; + +interface UiConfigContextType { + uiConfig: UiConfig | undefined; + isUiConfigLoading: boolean; + isUiConfigError: boolean; + error: Error | null; +} + +const UiConfigContext = createContext( + undefined +); + +export function UiConfigProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { data, isLoading, error, isError } = useGetUiConfig(); + + const contextValue = useMemo( + () => ({ + uiConfig: data, + isUiConfigLoading: isLoading, + isUiConfigError: isError, + error, + }), + [data, isLoading, isError, error] + ); + + return ( + + {children} + + ); +} + +export const useUiConfig = () => { + const context = useContext(UiConfigContext); + if (!context) { + throw new Error('useUiConfig must be used within a UiConfigProvider'); + } + return context; +}; diff --git a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts index 4343540456..97d418ec9b 100644 --- a/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts +++ b/packages/apps/human-app/frontend/src/shared/services/ui-config.service.ts @@ -7,9 +7,10 @@ const apiPaths = { const uiConfigSchema = z.object({ chainIdsEnabled: z.array(z.number()), + stakingEligibilityEnabled: z.boolean().prefault(false), }); -type UiConfig = z.infer; +export type UiConfig = z.infer; async function getUiConfig() { try { diff --git a/packages/apps/human-app/frontend/src/shared/styles/theme.ts b/packages/apps/human-app/frontend/src/shared/styles/theme.ts index 871d14719e..44a04ae421 100644 --- a/packages/apps/human-app/frontend/src/shared/styles/theme.ts +++ b/packages/apps/human-app/frontend/src/shared/styles/theme.ts @@ -140,6 +140,13 @@ export const theme: ThemeOptions = { }, }, }, + MuiSkeleton: { + styleOverrides: { + root: { + transform: 'scale(1,1)', + }, + }, + }, }, breakpoints: { values: { diff --git a/packages/apps/human-app/frontend/vite.config.mjs b/packages/apps/human-app/frontend/vite.config.mjs index 40943d146b..436d6c86a8 100644 --- a/packages/apps/human-app/frontend/vite.config.mjs +++ b/packages/apps/human-app/frontend/vite.config.mjs @@ -22,6 +22,9 @@ const config = defineConfig({ }, build: { target: 'esnext', + commonjsOptions: { + include: [/core/, /human-protocol-sdk/, /node_modules/], + }, }, server: { host: '127.0.0.1', @@ -29,12 +32,14 @@ const config = defineConfig({ }, optimizeDeps: { include: [ + '@human-protocol/sdk', '@mui/material', '@emotion/react', '@emotion/styled', '@mui/material/Tooltip', '@mui/material/Paper', ], + force: true, }, }); 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 2d9b643fc2..618833409a 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 @@ -197,11 +197,10 @@ export class ReputationOracleGateway { return this.handleRequestToReputationOracle(options); } - async getStakeConfig(token: string): Promise { + async getStakeConfig(): Promise { const options = this.getEndpointOptions( ReputationOracleEndpoints.STAKE_CONFIG, undefined, - token, ); return this.handleRequestToReputationOracle(options); } diff --git a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts index 7eccf4556c..bbfa432d1c 100644 --- a/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts +++ b/packages/apps/human-app/server/src/modules/job-assignment/job-assignment.controller.ts @@ -28,11 +28,16 @@ import { import { ChainId } from '@human-protocol/sdk'; import axios from 'axios'; import { JobStatus } from '../../common/enums/global-common'; +import logger from '../../logger'; @ApiTags('Job-Assignment') @ApiBearerAuth() @Controller('/assignment') export class JobAssignmentController { + private readonly logger = logger.child({ + context: JobAssignmentController.name, + }); + constructor( private readonly service: JobAssignmentService, @InjectMapper() private readonly mapper: Mapper, @@ -85,8 +90,10 @@ export class JobAssignmentController { expires_at: process.env.THIRSTYFI_TASK_EXPIRATION_DATE ?? '', }; } catch (error) { - // eslint-disable-next-line no-console - console.error(error); + this.logger.error('Failed to assign thirstyfi job', { + userId: req.user.user_id, + error, + }); throw new BadRequestException(error.response.data.error); } } diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts index 9137e68931..4bfe201ea6 100644 --- a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -1,30 +1,21 @@ -import { AutoMap } from '@automapper/classes'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class StakeSummaryResponse { - @ApiProperty({ name: 'exchange_stake' }) - @AutoMap() - exchangeStake: number; + @ApiProperty() + exchange_stake: number; @ApiProperty({ name: 'on_chain_stake' }) - @AutoMap() - onChainStake: number; + on_chain_stake: number; - @ApiProperty({ name: 'exchange_error', required: false, nullable: true }) - @AutoMap() - exchangeError?: string; + @ApiPropertyOptional() + exchange_error?: string | null; - @ApiProperty({ name: 'on_chain_error', required: false, nullable: true }) - @AutoMap() - onChainError?: string; + @ApiPropertyOptional() + on_chain_error?: string | null; } export class StakeConfigResponse { - @ApiProperty({ name: 'min_threshold' }) - @AutoMap() - minThreshold: number; + min_threshold: number; - @ApiProperty({ name: 'eligibility_enabled' }) - @AutoMap() - eligibilityEnabled: boolean; + eligibility_enabled: boolean; } diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts index db3a445fa4..f92224d522 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.controller.spec.ts @@ -47,13 +47,4 @@ describe('StakingController', () => { expect(result).toEqual(stakingServiceMock.getStakeSummary(TOKEN)); }); }); - - describe('getStakeConfig', () => { - it('should call service.getStakeConfig with token and return response', async () => { - const req: RequestWithUser = { token: TOKEN } as RequestWithUser; - const result = await controller.getStakeConfig(req); - expect(service.getStakeConfig).toHaveBeenCalledWith(TOKEN); - expect(result).toEqual(stakingServiceMock.getStakeConfig(TOKEN)); - }); - }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts index 00e0298a49..93699eef96 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.fixtures.ts @@ -1,11 +1,11 @@ export const TOKEN = 'test_user_token'; export const stakeSummaryResponseFixture = { - exchangeStake: '1000', - onChainStake: '500', + exchange_stake: '1000', + on_chain_stake: '500', }; export const stakeConfigResponseFixture = { - minThreshold: '1000', - eligibilityEnabled: true, + min_threshold: '1000', + eligibility_enabled: true, }; diff --git a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts index f01058e6a9..ad97851205 100644 --- a/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts +++ b/packages/apps/human-app/server/src/modules/staking/spec/staking.service.spec.ts @@ -47,8 +47,8 @@ describe('StakingService', () => { (reputationOracleMock.getStakeConfig as jest.Mock).mockResolvedValue( stakeConfigResponseFixture, ); - const result = await service.getStakeConfig(TOKEN); - expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(TOKEN); + const result = await service.getStakeConfig(); + expect(reputationOracleMock.getStakeConfig).toHaveBeenCalledWith(); expect(result).toEqual(stakeConfigResponseFixture); }); }); diff --git a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts index 62d7a7fc14..f8bcdae90f 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.controller.ts @@ -4,10 +4,7 @@ import { Controller, Get, Request } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequestWithUser } from '../../common/interfaces/jwt'; import { StakingService } from './staking.service'; -import { - StakeConfigResponse, - StakeSummaryResponse, -} from './model/staking.model'; +import { StakeSummaryResponse } from './model/staking.model'; @ApiTags('Staking') @ApiBearerAuth() @@ -25,12 +22,4 @@ export class StakingController { ): Promise { return this.service.getStakeSummary(req.token); } - - @ApiOperation({ summary: 'Get staking configuration' }) - @Get('/config') - async getStakeConfig( - @Request() req: RequestWithUser, - ): Promise { - return this.service.getStakeConfig(req.token); - } } diff --git a/packages/apps/human-app/server/src/modules/staking/staking.service.ts b/packages/apps/human-app/server/src/modules/staking/staking.service.ts index d30c6e14a5..820c8f9361 100644 --- a/packages/apps/human-app/server/src/modules/staking/staking.service.ts +++ b/packages/apps/human-app/server/src/modules/staking/staking.service.ts @@ -13,7 +13,7 @@ export class StakingService { return this.reputationOracle.getStakeSummary(token); } - getStakeConfig(token: string): Promise { - return this.reputationOracle.getStakeConfig(token); + getStakeConfig(): Promise { + return this.reputationOracle.getStakeConfig(); } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts index 95e4889355..c8df57939b 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.spec.ts @@ -1,15 +1,22 @@ +import { ChainId } from '@human-protocol/sdk'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; -import { UiConfigurationController } from './ui-configuration.controller'; + import { EnvironmentConfigService } from '../../common/config/environment-config.service'; -import { ChainId } from '@human-protocol/sdk'; +import { StakingService } from '../staking/staking.service'; +import { UiConfigurationController } from './ui-configuration.controller'; process.env.CHAIN_IDS_ENABLED = '80002,11155111'; describe('UiConfigurationController', () => { let controller: UiConfigurationController; + let stakingServiceMock: Pick, 'getStakeConfig'>; beforeAll(async () => { + stakingServiceMock = { + getStakeConfig: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ @@ -17,7 +24,13 @@ describe('UiConfigurationController', () => { isGlobal: true, }), ], - providers: [EnvironmentConfigService], + providers: [ + EnvironmentConfigService, + { + provide: StakingService, + useValue: stakingServiceMock, + }, + ], controllers: [UiConfigurationController], }).compile(); @@ -27,10 +40,16 @@ describe('UiConfigurationController', () => { }); it('should return proper config', async () => { + stakingServiceMock.getStakeConfig.mockResolvedValueOnce({ + eligibility_enabled: true, + min_threshold: Math.random(), + }); + const result = await controller.getConfig(); expect(result.chainIdsEnabled).toEqual([ ChainId.POLYGON_AMOY, ChainId.SEPOLIA, ]); + expect(result.stakingEligibilityEnabled).toBe(true); }); }); diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts index 0ed982fe27..f48f56f08f 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts @@ -3,6 +3,7 @@ import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { EnvironmentConfigService } from '../../common/config/environment-config.service'; import { Public } from '../../common/decorators'; import { UiConfigResponseDto } from './ui-configuration.dto'; +import { StakingService } from '../staking/staking.service'; @Controller() @Public() @@ -10,6 +11,7 @@ import { UiConfigResponseDto } from './ui-configuration.dto'; export class UiConfigurationController { constructor( private readonly environmentConfigService: EnvironmentConfigService, + private readonly stakingService: StakingService, ) {} @ApiOperation({ summary: 'Retrieve UI configuration' }) @@ -17,11 +19,14 @@ export class UiConfigurationController { type: UiConfigResponseDto, description: 'UI Configuration object', }) - @Header('Cache-Control', 'public, max-age=3600') + @Header('Cache-Control', 'public, max-age=600') @Get('/ui-config') public async getConfig(): Promise { + const stakingRequirementConfig = await this.stakingService.getStakeConfig(); + return { chainIdsEnabled: this.environmentConfigService.chainIdsEnabled, + stakingEligibilityEnabled: stakingRequirementConfig.eligibility_enabled, }; } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts index 864757461b..3d53be6747 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts @@ -9,4 +9,10 @@ export class UiConfigResponseDto { enumName: 'ChainId', }) chainIdsEnabled: ChainId[]; + + @ApiProperty({ + description: + 'Indicated if stake eligibility check enabled on app & oracles', + }) + stakingEligibilityEnabled: boolean; } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts index d14d2d39bd..2e99816674 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { UiConfigurationController } from './ui-configuration.controller'; +import { StakingModule } from '../staking/staking.module'; @Module({ + imports: [StakingModule], controllers: [UiConfigurationController], }) export class UiConfigurationModule {} diff --git a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts index ebad148aa5..c4f711281d 100644 --- a/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts +++ b/packages/apps/reputation-oracle/server/src/modules/staking/staking.controller.ts @@ -6,6 +6,7 @@ import { ApiTags, } from '@nestjs/swagger'; +import { Public } from '@/common/decorators'; import type { RequestWithUser } from '@/common/types'; import { StakeConfigResponseDto, StakeSummaryResponseDto } from './staking.dto'; @@ -13,12 +14,12 @@ import { StakingControllerErrorsFilter } from './staking.error-filter'; import { StakingService } from './staking.service'; @ApiTags('Staking') -@ApiBearerAuth() @UseFilters(StakingControllerErrorsFilter) @Controller('staking') export class StakingController { constructor(private readonly stakingService: StakingService) {} + @ApiBearerAuth() @ApiOperation({ summary: 'Retrieve aggregated staking info' }) @ApiResponse({ status: 200, type: StakeSummaryResponseDto }) @Get('/summary') @@ -28,6 +29,7 @@ export class StakingController { return this.stakingService.getStakeSummary(request.user.id); } + @Public() @ApiOperation({ summary: 'Retrieve staking configuration' }) @ApiResponse({ status: 200, type: StakeConfigResponseDto }) @Get('/config') From 7f3ba72f0c54a52fd3d046a8ecc95a8602fdf002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 5 Jan 2026 10:53:56 +0100 Subject: [PATCH 23/26] update PostgreSQL volume path in docker-compose for correct data storage --- docker-setup/docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-setup/docker-compose.dev.yml b/docker-setup/docker-compose.dev.yml index 6c69b26bc7..3a20df61dc 100644 --- a/docker-setup/docker-compose.dev.yml +++ b/docker-setup/docker-compose.dev.yml @@ -91,7 +91,7 @@ services: timeout: 5s retries: 5 volumes: - - graph-node-db-data:/var/lib/postgresql:Z + - graph-node-db-data:/var/lib/postgresql/data:Z environment: POSTGRES_USER: *graph_db_user POSTGRES_PASSWORD: *graph_db_passwrod From 89c8fa4cd5ae491939a83f691d140778feafebfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 5 Jan 2026 12:27:16 +0100 Subject: [PATCH 24/26] add minimum staking threshold to UI configuration response --- .../server/src/modules/staking/model/staking.model.ts | 1 - .../modules/ui-configuration/ui-configuration.controller.ts | 1 + .../src/modules/ui-configuration/ui-configuration.dto.ts | 5 +++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts index 4bfe201ea6..a6b6f9d95b 100644 --- a/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts +++ b/packages/apps/human-app/server/src/modules/staking/model/staking.model.ts @@ -16,6 +16,5 @@ export class StakeSummaryResponse { export class StakeConfigResponse { min_threshold: number; - eligibility_enabled: boolean; } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts index f48f56f08f..0c4107eb01 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.controller.ts @@ -27,6 +27,7 @@ export class UiConfigurationController { return { chainIdsEnabled: this.environmentConfigService.chainIdsEnabled, stakingEligibilityEnabled: stakingRequirementConfig.eligibility_enabled, + minThreshold: stakingRequirementConfig.min_threshold, }; } } diff --git a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts index 3d53be6747..6633c8cf49 100644 --- a/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts +++ b/packages/apps/human-app/server/src/modules/ui-configuration/ui-configuration.dto.ts @@ -15,4 +15,9 @@ export class UiConfigResponseDto { 'Indicated if stake eligibility check enabled on app & oracles', }) stakingEligibilityEnabled: boolean; + + @ApiProperty({ + description: 'Minimum staking threshold required for eligibility', + }) + minThreshold: number; } From 84391d0d41cea7c7188ff85ded9f4bcb465bf9a8 Mon Sep 17 00:00:00 2001 From: KirillKirill Date: Mon, 5 Jan 2026 15:12:29 +0300 Subject: [PATCH 25/26] chore: retrieving minThreshold from uiConfig --- .../src/modules/worker/profile/components/staking-info.tsx | 6 ++++-- .../frontend/src/modules/worker/services/staking.service.ts | 1 - .../frontend/src/shared/services/ui-config.service.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx index c985b55b79..fde8818982 100644 --- a/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx +++ b/packages/apps/human-app/frontend/src/modules/worker/profile/components/staking-info.tsx @@ -11,6 +11,7 @@ import { useAuthenticatedUser } from '@/modules/auth/hooks/use-authenticated-use import { useEffect, useRef, useState } from 'react'; import { useAccessTokenRefresh } from '@/api/hooks/use-access-token-refresh'; import { colorPalette } from '@/shared/styles/color-palette'; +import { useGetUiConfig } from '@/shared/hooks/use-get-ui-config'; export function StakingInfo() { const [isPromptExpanded, setIsPromptExpanded] = useState(false); @@ -30,6 +31,7 @@ export function StakingInfo() { refetch, isRefetching, } = useGetStakingSummary(); + const { data: uiConfig } = useGetUiConfig(); const isConnectButtonDisabled = !!exchangeApiKeyData?.exchange_name || isExchangeApiKeyLoading; @@ -46,7 +48,7 @@ export function StakingInfo() { const isStaked = isLoading || isStakingError ? false - : stakedAmount >= Number(stakingSummary?.min_threshold); + : stakedAmount >= Number(uiConfig?.minThreshold || '0'); useEffect(() => { if (isRefetching || isLoading) return; @@ -99,7 +101,7 @@ export function StakingInfo() { {isPromptExpanded ? t('worker.profile.stakingInfo.prompt', { - amount: stakingSummary?.min_threshold, + amount: uiConfig?.minThreshold, }) : t('worker.profile.stakingInfo.promptShort')}{' '}