From 8ed7377413c732b8643e0579a4e15c94df1e9b83 Mon Sep 17 00:00:00 2001 From: Kolibri1990 <66674482+Kolibri1990@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:13:56 +0100 Subject: [PATCH 1/8] feat(DEV-3610): choose ref asset for EVM users (#2935) --- migration/1768344518359-AddRefAsset.js | 31 ++++++++++++++++ src/shared/auth/ip-country.guard.ts | 12 +++---- src/shared/models/asset/asset.entity.ts | 6 +++- src/shared/models/asset/asset.service.ts | 10 ++++++ .../reward/services/ref-reward.service.ts | 13 +++---- .../user/models/user/dto/user-dto.mapper.ts | 5 ++- .../user/models/user/dto/user-v2.dto.ts | 16 +++++++++ .../models/user/tests/user.service.spec.ts | 4 +++ .../user/models/user/user.controller.ts | 10 +++++- .../generic/user/models/user/user.entity.ts | 4 +++ .../generic/user/models/user/user.service.ts | 35 +++++++++++++++++-- 11 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 migration/1768344518359-AddRefAsset.js diff --git a/migration/1768344518359-AddRefAsset.js b/migration/1768344518359-AddRefAsset.js new file mode 100644 index 0000000000..6f36557f9e --- /dev/null +++ b/migration/1768344518359-AddRefAsset.js @@ -0,0 +1,31 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddRefAsset1768344518359 { + name = 'AddRefAsset1768344518359' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "asset" ADD "refEnabled" bit NOT NULL CONSTRAINT "DF_d2c85e8cbdbff07a1dcd8d17797" DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "user" ADD "refAssetId" int`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_20e823fee19baff0c5090ab72df" FOREIGN KEY ("refAssetId") REFERENCES "asset"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_20e823fee19baff0c5090ab72df"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "refAssetId"`); + await queryRunner.query(`ALTER TABLE "asset" DROP CONSTRAINT "DF_d2c85e8cbdbff07a1dcd8d17797"`); + await queryRunner.query(`ALTER TABLE "asset" DROP COLUMN "refEnabled"`); + } +} diff --git a/src/shared/auth/ip-country.guard.ts b/src/shared/auth/ip-country.guard.ts index ab17a8b583..2a677f8cc1 100644 --- a/src/shared/auth/ip-country.guard.ts +++ b/src/shared/auth/ip-country.guard.ts @@ -1,4 +1,4 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { BadRequestException, CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { getClientIp } from '@supercharge/request-ip'; import { Config } from 'src/config/config'; import { IpLogService } from '../models/ip-log/ip-log.service'; @@ -10,12 +10,10 @@ export class IpCountryGuard implements CanActivate { const req = context.switchToHttp().getRequest(); const ip = getClientIp(req); - const ipLog = await this.ipLogService.create( - ip, - req.url, - req.body?.address ?? req.user?.address, - req.body?.walletType, - ); + const address = req.body?.address ?? req.user?.address; + if (!address) throw new BadRequestException('Address is required'); + + const ipLog = await this.ipLogService.create(ip, req.url, address, req.body?.walletType); if (!ipLog.result) throw new ForbiddenException('The country of IP address is not allowed'); const region = +req.body?.region; diff --git a/src/shared/models/asset/asset.entity.ts b/src/shared/models/asset/asset.entity.ts index 3109bf2d84..d45aa87cfc 100644 --- a/src/shared/models/asset/asset.entity.ts +++ b/src/shared/models/asset/asset.entity.ts @@ -73,6 +73,9 @@ export class Asset extends IEntity { @Column({ default: false }) paymentEnabled: boolean; + @Column({ default: false }) + refEnabled: boolean; + @Column({ default: true }) refundEnabled: boolean; @@ -140,7 +143,8 @@ export class Asset extends IEntity { this.sellable || this.cardSellable || this.instantSellable || - this.paymentEnabled + this.paymentEnabled || + this.refEnabled ); } diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index c86486f5b9..79f1c28715 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -81,6 +81,16 @@ export class AssetService { return this.assetRepo.findOneCachedBy(`native-${blockchain}`, { blockchain, type: AssetType.COIN }); } + async getRefPayoutAsset(blockchain: Blockchain): Promise { + return blockchain === Blockchain.ETHEREUM + ? this.getAssetByQuery({ + blockchain, + name: 'dEURO', + type: AssetType.TOKEN, + }) + : this.getNativeAsset(blockchain); + } + async getTokens(blockchain: Blockchain): Promise { return this.assetRepo.findCachedBy(`token-${blockchain}`, { blockchain, type: AssetType.TOKEN }); } diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index b6b89feebe..81cd63b810 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; -import { AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { Util } from 'src/shared/utils/util'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; @@ -136,16 +135,12 @@ export class RefRewardService { }); if (pendingBlockchainRewards) continue; - const payoutAsset = - blockchain === Blockchain.ETHEREUM - ? await this.assetService.getAssetByQuery({ - blockchain, - name: 'dEURO', - type: AssetType.TOKEN, - }) - : await this.assetService.getNativeAsset(blockchain); + const defaultAsset = await this.assetService.getRefPayoutAsset(blockchain); for (const user of users) { + const payoutAsset = user.refAsset ?? defaultAsset; + if (payoutAsset.blockchain !== blockchain) throw new Error('User ref asset blockchain mismatch'); + const refCreditEur = user.refCredit - user.paidRefCredit; const minCredit = PayoutLimits[blockchain]; diff --git a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts index e1d70390ff..f38232442d 100644 --- a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts +++ b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts @@ -1,5 +1,7 @@ import { addressExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetDtoMapper } from 'src/shared/models/asset/dto/asset-dto.mapper'; import { CountryDtoMapper } from 'src/shared/models/country/dto/country-dto.mapper'; import { FiatDtoMapper } from 'src/shared/models/fiat/dto/fiat-dto.mapper'; import { LanguageDtoMapper } from 'src/shared/models/language/dto/language-dto.mapper'; @@ -70,7 +72,7 @@ export class UserDtoMapper { return Object.assign(new VolumesDto(), dto); } - static mapRef(user: User, userCount: number, activeUserCount: number): ReferralDto { + static mapRef(user: User, userCount: number, activeUserCount: number, payoutAsset: Asset): ReferralDto { const dto: ReferralDto = { code: user.ref, commission: Util.round(user.refFeePercent / 100, 4), @@ -79,6 +81,7 @@ export class UserDtoMapper { paidCredit: user.paidRefCredit, userCount: userCount, activeUserCount: activeUserCount, + payoutAsset: AssetDtoMapper.toDto(payoutAsset), }; return Object.assign(new ReferralDto(), dto); diff --git a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts index 211993ef08..129bc761b6 100644 --- a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts @@ -1,5 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNotEmptyObject, ValidateNested } from 'class-validator'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { EntityDto } from 'src/shared/dto/entity.dto'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetDto } from 'src/shared/models/asset/dto/asset.dto'; import { FiatDto } from 'src/shared/models/fiat/dto/fiat.dto'; import { LanguageDto } from 'src/shared/models/language/dto/language.dto'; import { HistoryFilterKey } from 'src/subdomains/core/history/dto/history-filter.dto'; @@ -39,6 +44,17 @@ export class ReferralDto { @ApiProperty({ description: 'Number of active users referred' }) activeUserCount: number; + + @ApiProperty({ description: 'Referral payout asset' }) + payoutAsset: AssetDto; +} + +export class UpdateRefDto { + @ApiProperty({ type: EntityDto, description: 'Referral payout asset' }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => EntityDto) + payoutAsset: Asset; } export class UserAddressDto { diff --git a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts index e93a85c5a8..f9025101c4 100644 --- a/src/subdomains/generic/user/models/user/tests/user.service.spec.ts +++ b/src/subdomains/generic/user/models/user/tests/user.service.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { GeoLocationService } from 'src/integration/geolocation/geo-location.service'; import { SiftService } from 'src/integration/sift/services/sift.service'; +import { AssetService } from 'src/shared/models/asset/asset.service'; import { CountryService } from 'src/shared/models/country/country.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { LanguageService } from 'src/shared/models/language/language.service'; @@ -34,6 +35,7 @@ describe('UserService', () => { let tfaService: TfaService; let siftService: SiftService; let kycAdminService: KycAdminService; + let assetService: AssetService; beforeEach(async () => { userRepo = createMock(); @@ -50,6 +52,7 @@ describe('UserService', () => { tfaService = createMock(); siftService = createMock(); kycAdminService = createMock(); + assetService = createMock(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -68,6 +71,7 @@ describe('UserService', () => { { provide: TfaService, useValue: tfaService }, { provide: SiftService, useValue: siftService }, { provide: KycAdminService, useValue: kycAdminService }, + { provide: AssetService, useValue: assetService }, TestUtil.provideConfig(), ], }).compile(); diff --git a/src/subdomains/generic/user/models/user/user.controller.ts b/src/subdomains/generic/user/models/user/user.controller.ts index df7b8fd600..c6ea8037a4 100644 --- a/src/subdomains/generic/user/models/user/user.controller.ts +++ b/src/subdomains/generic/user/models/user/user.controller.ts @@ -46,7 +46,7 @@ import { UpdateUserInternalDto } from './dto/update-user-admin.dto'; import { UpdateUserDto, UpdateUserMailDto } from './dto/update-user.dto'; import { UserNameDto } from './dto/user-name.dto'; import { UserProfileDto } from './dto/user-profile.dto'; -import { ReferralDto, UserV2Dto } from './dto/user-v2.dto'; +import { ReferralDto, UpdateRefDto, UserV2Dto } from './dto/user-v2.dto'; import { UserDetailDto, UserDto } from './dto/user.dto'; import { UpdateMailStatus, VerifyMailDto } from './dto/verify-mail.dto'; import { VolumeQuery } from './dto/volume-query.dto'; @@ -319,6 +319,14 @@ export class UserV2Controller { return this.userService.getRefDtoV2(jwt.user); } + @Put('ref') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOkResponse({ type: ReferralDto }) + async updateRefAsset(@GetJwt() jwt: JwtPayload, @Body() dto: UpdateRefDto): Promise { + return this.userService.updateRef(jwt.user, dto); + } + @Get('profile') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 30b546e46d..fed8bcc713 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -2,6 +2,7 @@ import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity, UpdateResult } from 'src/shared/models/entity'; import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; @@ -142,6 +143,9 @@ export class User extends IEntity { @OneToMany(() => StakingRefReward, (reward) => reward.user) stakingRefRewards: StakingRefReward[]; + @ManyToOne(() => Asset, { nullable: true, eager: true }) + refAsset: Asset; + @OneToMany(() => Transaction, (transaction) => transaction.user) transactions: Transaction[]; diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index d316677ea3..6067920ed9 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -14,6 +14,7 @@ import { GeoLocationService } from 'src/integration/geolocation/geo-location.ser import { SiftService } from 'src/integration/sift/services/sift.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { Active } from 'src/shared/models/active'; +import { AssetService } from 'src/shared/models/asset/asset.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; import { LanguageDtoMapper } from 'src/shared/models/language/dto/language-dto.mapper'; import { LanguageService } from 'src/shared/models/language/language.service'; @@ -42,12 +43,12 @@ import { UpdateUserDto, UpdateUserMailDto } from './dto/update-user.dto'; import { UserDtoMapper } from './dto/user-dto.mapper'; import { UserNameDto } from './dto/user-name.dto'; import { UserProfileDto } from './dto/user-profile.dto'; -import { ReferralDto, UserV2Dto } from './dto/user-v2.dto'; +import { ReferralDto, UpdateRefDto, UserV2Dto } from './dto/user-v2.dto'; import { UserDetailDto, UserDetails } from './dto/user.dto'; import { UpdateMailStatus } from './dto/verify-mail.dto'; import { VolumeQuery } from './dto/volume-query.dto'; import { User } from './user.entity'; -import { UserStatus } from './user.enum'; +import { UserAddressType, UserStatus } from './user.enum'; import { UserRepository } from './user.repository'; @Injectable() @@ -65,6 +66,7 @@ export class UserService { private readonly languageService: LanguageService, private readonly fiatService: FiatService, private readonly siftService: SiftService, + private readonly assetService: AssetService, ) {} async getAllUser(): Promise { @@ -180,9 +182,36 @@ export class UserService { where: { id: userId }, }); + return this.mapRefDtoV2(user); + } + + async updateRef(userId: number, dto: UpdateRefDto): Promise { + const [user, refAsset] = await Promise.all([ + this.userRepo.findOne({ where: { id: userId }, relations: { wallet: true } }), + this.assetService.getAssetById(dto.payoutAsset.id), + ]); + + if (!user) throw new NotFoundException('User not found'); + if (user.addressType !== UserAddressType.EVM) + throw new BadRequestException('Ref asset can only be set for EVM addresses'); + + if (!refAsset) throw new BadRequestException('Asset not found'); + if (refAsset.refEnabled === false) throw new BadRequestException('Asset is not enabled for ref payout'); + if (!user.blockchains.includes(refAsset.blockchain)) throw new BadRequestException('Asset blockchain mismatch'); + + user.refAsset = refAsset; + const savedUser = await this.userRepo.save(user); + + return this.mapRefDtoV2(savedUser); + } + + private async mapRefDtoV2(user: User): Promise { const { refCount, refCountActive } = await this.getRefUserCounts(user); + const payoutAsset = + user.refAsset ?? + (await this.assetService.getRefPayoutAsset(CryptoService.getDefaultBlockchainBasedOn(user.address))); - return UserDtoMapper.mapRef(user, refCount, refCountActive); + return UserDtoMapper.mapRef(user, refCount, refCountActive, payoutAsset); } async getUserProfile(userDataId: number): Promise { From c20190a869466c59a53e3e1373c1b712940ea917 Mon Sep 17 00:00:00 2001 From: Kolibri1990 <66674482+Kolibri1990@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:14:13 +0100 Subject: [PATCH 2/8] feat(urble): add client company as user role for fetch wallet payments (#2939) --- src/shared/auth/jwt.strategy.ts | 1 + src/shared/auth/role.guard.ts | 1 + src/shared/auth/user-role.enum.ts | 3 ++- .../generic/kyc/controllers/kyc-client.controller.ts | 2 +- src/subdomains/generic/user/models/auth/auth.service.ts | 6 +++--- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/shared/auth/jwt.strategy.ts b/src/shared/auth/jwt.strategy.ts index 7187632884..fa4380c1da 100644 --- a/src/shared/auth/jwt.strategy.ts +++ b/src/shared/auth/jwt.strategy.ts @@ -22,6 +22,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { break; case UserRole.KYC_CLIENT_COMPANY: + case UserRole.CLIENT_COMPANY: if (!address || !user) throw new UnauthorizedException(); break; diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index 8bdaf9a6b4..16c2b53c14 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -20,6 +20,7 @@ class RoleGuardClass implements CanActivate { [UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.ADMIN]: [UserRole.SUPER_ADMIN], [UserRole.DEBUG]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], + [UserRole.CLIENT_COMPANY]: [UserRole.KYC_CLIENT_COMPANY], }; constructor(private readonly entryRole: UserRole) {} diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index db5c9b4dea..5ccc54cebf 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -14,6 +14,7 @@ export enum UserRole { // service roles BANKING_BOT = 'BankingBot', - // external kyc client company roles + // external client company roles KYC_CLIENT_COMPANY = 'KycClientCompany', + CLIENT_COMPANY = 'ClientCompany', } diff --git a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts index 4b4de85657..e46bae79b2 100644 --- a/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc-client.controller.ts @@ -25,7 +25,7 @@ export class KycClientController { @Get('payments') @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.KYC_CLIENT_COMPANY)) + @UseGuards(AuthGuard(), RoleGuard(UserRole.CLIENT_COMPANY)) @ApiOkResponse({ type: PaymentWebhookData, isArray: true }) async getAllPayments( @GetJwt() jwt: JwtPayload, diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index 9d3520fed1..8f00fb693f 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -343,7 +343,7 @@ export class AuthService { private async companySignIn(dto: SignInDto, ip: string): Promise { const wallet = await this.walletService.getByAddress(dto.address); - if (!wallet?.isKycClient) throw new NotFoundException('Wallet not found'); + if (!wallet) throw new NotFoundException('Wallet not found'); if (!(await this.verifyCompanySignature(dto.address, dto.signature, dto.key, dto.blockchain))) throw new UnauthorizedException('Invalid credentials'); @@ -353,7 +353,7 @@ export class AuthService { async getCompanyChallenge(address: string): Promise { const wallet = await this.walletService.getByAddress(address); - if (!wallet?.isKycClient) throw new BadRequestException('Wallet not found/invalid'); + if (!wallet) throw new BadRequestException('Wallet not found/invalid'); const challenge = randomUUID(); @@ -514,7 +514,7 @@ export class AuthService { const payload: JwtPayload = { user: wallet.id, address: wallet.address, - role: UserRole.KYC_CLIENT_COMPANY, + role: wallet.isKycClient ? UserRole.KYC_CLIENT_COMPANY : UserRole.CLIENT_COMPANY, ip, }; return this.jwtService.sign(payload, { expiresIn: Config.auth.company.signOptions.expiresIn }); From 53ebce16943bba53a3cf60715f3bcf3e5731e2d4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 15 Jan 2026 22:48:36 +0100 Subject: [PATCH 3/8] feat(support): add name search in bank_tx table for compliance endpoint (#2955) --- .../generic/support/support.service.ts | 9 +++++++-- .../bank-tx/bank-tx/services/bank-tx.service.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/subdomains/generic/support/support.service.ts b/src/subdomains/generic/support/support.service.ts index 75cd24f3a0..b167df9b09 100644 --- a/src/subdomains/generic/support/support.service.ts +++ b/src/subdomains/generic/support/support.service.ts @@ -78,11 +78,16 @@ export class SupportService { const searchResult = await this.getUserDatasByKey(query.key); const bankTx = [ComplianceSearchType.IBAN, ComplianceSearchType.VIRTUAL_IBAN].includes(searchResult.type) ? await this.bankTxService.getUnassignedBankTx([query.key], [query.key]) - : []; + : searchResult.type === ComplianceSearchType.NAME + ? await this.bankTxService.getBankTxsByName(query.key) + : []; if ( !searchResult.userDatas.length && - (!bankTx.length || ![ComplianceSearchType.IBAN, ComplianceSearchType.VIRTUAL_IBAN].includes(searchResult.type)) + (!bankTx.length || + ![ComplianceSearchType.IBAN, ComplianceSearchType.VIRTUAL_IBAN, ComplianceSearchType.NAME].includes( + searchResult.type, + )) ) throw new NotFoundException('No user or bankTx found'); diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 01c4c4b5b5..6740185b39 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -32,6 +32,7 @@ import { In, IsNull, LessThan, + Like, MoreThan, MoreThanOrEqual, Not, @@ -529,6 +530,21 @@ export class BankTxService implements OnModuleInit { }); } + async getBankTxsByName(name: string): Promise { + const request: FindOptionsWhere = { + type: In(BankTxUnassignedTypes), + creditDebitIndicator: BankTxIndicator.CREDIT, + }; + + return this.bankTxRepo.find({ + where: [ + { ...request, name: Like(`%${name}%`) }, + { ...request, ultimateName: Like(`%${name}%`) }, + ], + relations: { transaction: true }, + }); + } + async checkAssignAndNotifyUserData(iban: string, userData: UserData): Promise { const bankTxs = await this.getUnassignedBankTx([iban], [], { transaction: { userData: true } }); From ec14c0718055cc46a6b4071700a2b1b131fe0e0e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 16 Jan 2026 18:45:47 +0100 Subject: [PATCH 4/8] feat(payout): add auto-retry for stuck payout orders (#2962) --- .../supporting/payout/services/payout-evm.service.ts | 8 ++++++++ .../payout/strategies/payout/impl/base/evm.strategy.ts | 10 ++++++++++ .../strategies/payout/impl/base/payout.strategy.ts | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/src/subdomains/supporting/payout/services/payout-evm.service.ts b/src/subdomains/supporting/payout/services/payout-evm.service.ts index b7dd831db2..efe50ef50f 100644 --- a/src/subdomains/supporting/payout/services/payout-evm.service.ts +++ b/src/subdomains/supporting/payout/services/payout-evm.service.ts @@ -35,4 +35,12 @@ export abstract class PayoutEvmService { async getTxNonce(txHash: string): Promise { return this.client.getTxNonce(txHash); } + + async isTxExpired(txHash: string): Promise { + const receipt = await this.client.getTxReceipt(txHash); + if (receipt) return false; // TX was mined (success or fail) + + const tx = await this.client.getTx(txHash); + return tx === null; // TX does not exist anymore -> expired + } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts index d271c06a85..291659cd06 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts @@ -61,6 +61,10 @@ export abstract class EvmStrategy extends PayoutStrategy { order.recordPayoutFee(feeAsset, payoutFee, price.convert(payoutFee, Config.defaultVolumeDecimal)); await this.payoutOrderRepo.save(order); + } else if (await this.canRetryFailedPayout(order)) { + // TX expired (not on-chain, not in mempool) - retry immediately, no gas costs incurred + this.logger.info(`Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying immediately`); + await this.doPayout([order]); } } catch (e) { this.logger.error(`Error in checking completion of EVM payout order ${order.id}:`, e); @@ -77,4 +81,10 @@ export abstract class EvmStrategy extends PayoutStrategy { return this.payoutEvmService.getTxNonce(order.payoutTxId); } } + + // Whitelisted failure type: Flashbots expired transactions (TX does not exist on-chain) + override async canRetryFailedPayout(order: PayoutOrder): Promise { + if (!order.payoutTxId) return false; + return this.payoutEvmService.isTxExpired(order.payoutTxId); + } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts index 1051c76e17..a8b150ea5c 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/payout.strategy.ts @@ -32,5 +32,12 @@ export abstract class PayoutStrategy implements OnModuleInit, OnModuleDestroy { abstract estimateFee(targetAsset: Asset, address: string, amount: number, asset: Asset): Promise; abstract estimateBlockchainFee(asset: Asset): Promise; + // Returns true if the payout can be safely retried. + // Uses whitelist approach: only explicitly handled failure types allow retry. + // Default: false (no retry). Override in specific strategies to handle known failure types. + async canRetryFailedPayout(_order: PayoutOrder): Promise { + return false; + } + protected abstract getFeeAsset(): Promise; } From 23b26f32d9d5ee4266a159197773e0e7999075e6 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:46:15 +0100 Subject: [PATCH 5/8] feat: RealUnit balance pdf endpoint (#2961) * feat: add pdf generation with realunit logo. * feat: add balance/pdf endpoint. --- src/shared/utils/{ => logos}/dfx-logo.ts | 0 src/shared/utils/logos/realunit-logo.ts | 5 ++++ src/shared/utils/pdf.util.ts | 29 +++++++++++++++++-- .../balance/controllers/balance.controller.ts | 3 +- .../balance/services/balance-pdf.service.ts | 10 ++++--- .../controllers/realunit.controller.ts | 28 ++++++++++++++++++ .../realunit/dto/realunit-balance-pdf.dto.ts | 27 +++++++++++++++++ .../supporting/realunit/realunit.module.ts | 4 ++- 8 files changed, 98 insertions(+), 8 deletions(-) rename src/shared/utils/{ => logos}/dfx-logo.ts (100%) create mode 100644 src/shared/utils/logos/realunit-logo.ts create mode 100644 src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts diff --git a/src/shared/utils/dfx-logo.ts b/src/shared/utils/logos/dfx-logo.ts similarity index 100% rename from src/shared/utils/dfx-logo.ts rename to src/shared/utils/logos/dfx-logo.ts diff --git a/src/shared/utils/logos/realunit-logo.ts b/src/shared/utils/logos/realunit-logo.ts new file mode 100644 index 0000000000..68d084b5e6 --- /dev/null +++ b/src/shared/utils/logos/realunit-logo.ts @@ -0,0 +1,5 @@ +// RealUnit Logo SVG Path +export const realunitLogoPath = + 'M300.243 74.3239L179.517 5.19353C167.427 -1.73118 152.573 -1.73118 140.483 5.19353L19.7569 74.3239C7.53755 81.3237 0 94.3268 0 108.405V246.12C0 260.203 7.53755 273.206 19.7569 280.202L140.483 349.336C152.573 356.261 167.427 356.261 179.517 349.336L300.243 280.202C312.462 273.206 320 260.203 320 246.12V108.405C320 94.3226 312.462 81.3195 300.243 74.3239ZM281.95 225.95C281.95 238.912 275.004 250.877 263.752 257.301L177.904 306.329C166.81 312.666 153.19 312.666 142.096 306.329L56.2482 257.301C44.996 250.873 38.0505 238.908 38.0505 225.95V128.575C38.0505 115.614 44.996 103.649 56.2482 97.2242L142.096 48.1968C153.19 41.8599 166.81 41.8599 177.904 48.1968L263.752 97.2242C275.004 103.653 281.95 115.618 281.95 128.575V225.95Z'; + +export const realunitLogoColor = '#1988C6'; diff --git a/src/shared/utils/pdf.util.ts b/src/shared/utils/pdf.util.ts index c01fc35ef5..b1386f52c1 100644 --- a/src/shared/utils/pdf.util.ts +++ b/src/shared/utils/pdf.util.ts @@ -3,7 +3,13 @@ import PDFDocument from 'pdfkit'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { PdfLanguage } from 'src/subdomains/supporting/balance/dto/input/get-balance-pdf.dto'; import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; -import { dfxLogoBall1, dfxLogoBall2, dfxLogoText } from './dfx-logo'; +import { dfxLogoBall1, dfxLogoBall2, dfxLogoText } from './logos/dfx-logo'; +import { realunitLogoColor, realunitLogoPath } from './logos/realunit-logo'; + +export enum PdfBrand { + DFX = 'DFX', + REALUNIT = 'REALUNIT', +} export interface BalanceEntry { asset: Asset; @@ -13,7 +19,15 @@ export interface BalanceEntry { } export class PdfUtil { - static drawLogo(pdf: InstanceType): void { + static drawLogo(pdf: InstanceType, brand: PdfBrand = PdfBrand.DFX): void { + if (brand === PdfBrand.REALUNIT) { + this.drawRealUnitLogo(pdf); + } else { + this.drawDfxLogo(pdf); + } + } + + private static drawDfxLogo(pdf: InstanceType): void { pdf.save(); pdf.translate(50, 30); pdf.scale(0.12); @@ -36,6 +50,17 @@ export class PdfUtil { pdf.restore(); } + private static drawRealUnitLogo(pdf: InstanceType): void { + pdf.save(); + pdf.translate(50, 30); + pdf.scale(0.12); + pdf.path(realunitLogoPath).fill(realunitLogoColor); + + pdf.restore(); + + pdf.translate(0, 30); + } + static drawTable( pdf: InstanceType, balances: BalanceEntry[], diff --git a/src/subdomains/supporting/balance/controllers/balance.controller.ts b/src/subdomains/supporting/balance/controllers/balance.controller.ts index 2eec4b1810..1a57ce3533 100644 --- a/src/subdomains/supporting/balance/controllers/balance.controller.ts +++ b/src/subdomains/supporting/balance/controllers/balance.controller.ts @@ -4,6 +4,7 @@ import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PdfBrand } from 'src/shared/utils/pdf.util'; import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; import { GetBalancePdfDto } from '../dto/input/get-balance-pdf.dto'; import { BalancePdfService } from '../services/balance-pdf.service'; @@ -17,7 +18,7 @@ export class BalanceController { @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) @ApiOkResponse({ type: PdfDto, description: 'Balance PDF report (base64 encoded)' }) async getBalancePdf(@Query() dto: GetBalancePdfDto): Promise { - const pdfData = await this.balancePdfService.generateBalancePdf(dto); + const pdfData = await this.balancePdfService.generateBalancePdf(dto, PdfBrand.DFX); return { pdfData }; } } diff --git a/src/subdomains/supporting/balance/services/balance-pdf.service.ts b/src/subdomains/supporting/balance/services/balance-pdf.service.ts index 5602e72f9c..341ba6f4ad 100644 --- a/src/subdomains/supporting/balance/services/balance-pdf.service.ts +++ b/src/subdomains/supporting/balance/services/balance-pdf.service.ts @@ -7,7 +7,7 @@ import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; -import { BalanceEntry, PdfUtil } from 'src/shared/utils/pdf.util'; +import { BalanceEntry, PdfBrand, PdfUtil } from 'src/shared/utils/pdf.util'; import { Util } from 'src/shared/utils/util'; import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { CoinGeckoService } from '../../pricing/services/integration/coin-gecko.service'; @@ -17,6 +17,7 @@ import { GetBalancePdfDto, PdfLanguage } from '../dto/input/get-balance-pdf.dto' // Supported EVM blockchains (must have Alchemy support and chainId mapping) const SUPPORTED_BLOCKCHAINS: Blockchain[] = [ Blockchain.ETHEREUM, + Blockchain.SEPOLIA, Blockchain.BINANCE_SMART_CHAIN, Blockchain.POLYGON, Blockchain.ARBITRUM, @@ -37,7 +38,7 @@ export class BalancePdfService { private readonly i18n: I18nService, ) {} - async generateBalancePdf(dto: GetBalancePdfDto): Promise { + async generateBalancePdf(dto: GetBalancePdfDto, brand: PdfBrand = PdfBrand.DFX): Promise { if (!SUPPORTED_BLOCKCHAINS.includes(dto.blockchain)) { throw new BadRequestException( `Blockchain ${dto.blockchain} is not supported. Supported blockchains: ${SUPPORTED_BLOCKCHAINS.join(', ')}`, @@ -52,7 +53,7 @@ export class BalancePdfService { const totalValue = balances.reduce((sum, b) => sum + (b.value ?? 0), 0); const hasIncompleteData = balances.some((b) => b.value == null); - return this.createPdf(balances, totalValue, hasIncompleteData, dto); + return this.createPdf(balances, totalValue, hasIncompleteData, dto, brand); } private async getBalancesForAddress( @@ -150,6 +151,7 @@ export class BalancePdfService { totalValue: number, hasIncompleteData: boolean, dto: GetBalancePdfDto, + brand: PdfBrand = PdfBrand.DFX, ): Promise { return new Promise((resolve, reject) => { try { @@ -163,7 +165,7 @@ export class BalancePdfService { resolve(base64PDF); }); - PdfUtil.drawLogo(pdf); + PdfUtil.drawLogo(pdf, brand); this.drawHeader(pdf, dto, language); PdfUtil.drawTable(pdf, balances, dto.currency, language, this.i18n); PdfUtil.drawFooter(pdf, totalValue, hasIncompleteData, dto.currency, language, this.i18n); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index 9c3e4ec6a9..267c1e6516 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -12,18 +12,24 @@ import { ApiTags, } from '@nestjs/swagger'; import { Response } from 'express'; +import { Config, Environment } from 'src/config/config'; import { BrokerbotBuyPriceDto, BrokerbotInfoDto, BrokerbotPriceDto, BrokerbotSharesDto, } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PdfBrand } from 'src/shared/utils/pdf.util'; +import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { BalancePdfService } from '../../balance/services/balance-pdf.service'; +import { RealUnitBalancePdfDto } from '../dto/realunit-balance-pdf.dto'; import { RealUnitRegistrationDto, RealUnitRegistrationResponseDto, @@ -50,6 +56,7 @@ import { RealUnitService } from '../realunit.service'; export class RealUnitController { constructor( private readonly realunitService: RealUnitService, + private readonly balancePdfService: BalancePdfService, private readonly userService: UserService, ) {} @@ -120,6 +127,27 @@ export class RealUnitController { return this.realunitService.getRealUnitInfo(); } + // --- Balance PDF Endpoint --- + + @Post('balance/pdf') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get balance report PDF', + description: 'Generates a PDF balance report for a specific address on Ethereum blockchain', + }) + @ApiOkResponse({ type: PdfDto, description: 'Balance PDF report (base64 encoded)' }) + async getBalancePdf(@Body() dto: RealUnitBalancePdfDto): Promise { + const tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; + const pdfData = await this.balancePdfService.generateBalancePdf( + { ...dto, blockchain: tokenBlockchain }, + PdfBrand.REALUNIT, + ); + return { pdfData }; + } + // --- Brokerbot Endpoints --- @Get('brokerbot/info') diff --git a/src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts new file mode 100644 index 0000000000..593c9c5922 --- /dev/null +++ b/src/subdomains/supporting/realunit/dto/realunit-balance-pdf.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDate, IsEnum, IsEthereumAddress, IsNotEmpty, IsOptional } from 'class-validator'; +import { PdfLanguage } from 'src/subdomains/supporting/balance/dto/input/get-balance-pdf.dto'; +import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; + +export class RealUnitBalancePdfDto { + @ApiProperty({ description: 'Blockchain address (EVM)' }) + @IsNotEmpty() + @IsEthereumAddress() + address: string; + + @ApiProperty({ description: 'Fiat currency for the report', enum: PriceCurrency }) + @IsNotEmpty() + @IsEnum(PriceCurrency) + currency: PriceCurrency; + + @ApiProperty({ description: 'Date for the portfolio report (must be in the past)' }) + @IsDate() + @Type(() => Date) + date: Date; + + @ApiPropertyOptional({ description: 'Language for the report', enum: PdfLanguage, default: PdfLanguage.EN }) + @IsOptional() + @IsEnum(PdfLanguage) + language?: PdfLanguage = PdfLanguage.EN; +} diff --git a/src/subdomains/supporting/realunit/realunit.module.ts b/src/subdomains/supporting/realunit/realunit.module.ts index 9c7c6d6b0f..b6b5e5e0da 100644 --- a/src/subdomains/supporting/realunit/realunit.module.ts +++ b/src/subdomains/supporting/realunit/realunit.module.ts @@ -1,11 +1,12 @@ import { forwardRef, Module } from '@nestjs/common'; -import { Eip7702DelegationModule } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.module'; import { RealUnitBlockchainModule } from 'src/integration/blockchain/realunit/realunit-blockchain.module'; +import { Eip7702DelegationModule } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.module'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; import { SellCryptoModule } from 'src/subdomains/core/sell-crypto/sell-crypto.module'; import { KycModule } from 'src/subdomains/generic/kyc/kyc.module'; import { UserModule } from 'src/subdomains/generic/user/user.module'; +import { BalanceModule } from '../balance/balance.module'; import { BankTxModule } from '../bank-tx/bank-tx.module'; import { BankModule } from '../bank/bank.module'; import { PaymentModule } from '../payment/payment.module'; @@ -19,6 +20,7 @@ import { RealUnitService } from './realunit.service'; imports: [ SharedModule, PricingModule, + BalanceModule, RealUnitBlockchainModule, UserModule, KycModule, From a0ca6db7aa823eef5ffbada8778fedddb164edcc Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:58:17 +0100 Subject: [PATCH 6/8] fix: fiat output without amount, crypto input override (#2963) * fix: fiat output without amount, crypto input override * fix: remove unnecessary EVM blockchain check * fix: exchange asset name param for DFX DEX withdraw --- .../adapters/actions/dfx-dex.adapter.ts | 9 ++++++--- .../services/buy-fiat-preparation.service.ts | 2 ++ .../user/models/auth/dto/auth-credentials.dto.ts | 4 ++-- .../payin/entities/crypto-input.entity.ts | 15 +++++++++------ .../send/impl/base/bitcoin-based.strategy.ts | 4 +--- .../strategies/send/impl/base/cardano.strategy.ts | 4 +--- .../strategies/send/impl/base/evm.strategy.ts | 3 +-- .../strategies/send/impl/base/solana.strategy.ts | 4 +--- .../strategies/send/impl/base/tron.strategy.ts | 4 +--- .../strategies/send/impl/binance-pay.strategy.ts | 4 +--- .../strategies/send/impl/kucoin-pay.strategy.ts | 4 +--- .../strategies/send/impl/lightning.strategy.ts | 4 +--- 12 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts index 83c796df25..6b5c065fa1 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts @@ -214,7 +214,7 @@ export class DfxDexAdapter extends LiquidityActionAdapter { } private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise { - const { system, assetId } = this.parseWithdrawParams(order.action.paramMap); + const { system, assetId, exchangeAssetName } = this.parseWithdrawParams(order.action.paramMap); const exchange = this.exchangeRegistry.get(system); @@ -222,8 +222,9 @@ export class DfxDexAdapter extends LiquidityActionAdapter { const sourceChain = sourceAsset && exchange.mapNetwork(sourceAsset.blockchain); const targetAsset = order.pipeline.rule.targetAsset; + const depositAssetName = exchangeAssetName ?? targetAsset.dexName; - const deposits = await exchange.getDeposits(targetAsset.dexName, order.created, sourceChain || undefined); + const deposits = await exchange.getDeposits(depositAssetName, order.created, sourceChain || undefined); const deposit = deposits.find((d) => d.amount === order.inputAmount && d.timestamp > order.created.getTime()); const isComplete = deposit && deposit.status === 'ok'; @@ -249,15 +250,17 @@ export class DfxDexAdapter extends LiquidityActionAdapter { address: string; system: LiquidityManagementSystem; assetId?: number; + exchangeAssetName?: string; } { const address = process.env[params.destinationAddress as string]; const system = params.destinationSystem as LiquidityManagementSystem; const assetId = params.assetId as number | undefined; + const exchangeAssetName = params.exchangeAssetName as string | undefined; const isValid = this.withdrawParamsValid(address, system); if (!isValid) throw new Error(`Params provided to DfxDexAdapter.withdraw(...) command are invalid.`); - return { address, system, assetId }; + return { address, system, assetId, exchangeAssetName }; } private withdrawParamsValid(address: string, system: LiquidityManagementSystem): boolean { diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index 04fbc82b5f..96bbb75209 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -386,6 +386,8 @@ export class BuyFiatPreparationService { amlCheck: CheckStatus.PASS, fiatOutput: IsNull(), cryptoInput: { status: In([PayInStatus.FORWARD_CONFIRMED, PayInStatus.COMPLETED]) }, + outputAmount: Not(IsNull()), + outputAsset: Not(IsNull()), }, }); diff --git a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts index 71e60a97d2..fbc8827cf5 100644 --- a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts +++ b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; +import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; import { GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; @@ -35,7 +35,7 @@ export class SignInDto { enum: EvmBlockchains, }) @IsOptional() - @IsIn(EvmBlockchains) + @IsEnum(Blockchain) blockchain?: Blockchain; @ApiPropertyOptional({ description: 'This field is deprecated, use "specialCode" instead.', deprecated: true }) diff --git a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts index f800bd94b2..3b38b0a49b 100644 --- a/src/subdomains/supporting/payin/entities/crypto-input.entity.ts +++ b/src/subdomains/supporting/payin/entities/crypto-input.entity.ts @@ -253,24 +253,27 @@ export class CryptoInput extends IEntity { return this; } - confirm(direction: PayInConfirmationType, forwardRequired: boolean): this { + confirm(direction: PayInConfirmationType, forwardRequired: boolean): UpdateResult { + let update: Partial = {}; + switch (direction) { case PayInConfirmationType.INPUT: if (!this.purpose) break; - this.isConfirmed = true; - this.status = !forwardRequired ? PayInStatus.COMPLETED : undefined; + update = { isConfirmed: true, status: !forwardRequired ? PayInStatus.COMPLETED : undefined }; break; case PayInConfirmationType.OUTPUT: - this.status = PayInStatus.FORWARD_CONFIRMED; + update = { status: PayInStatus.FORWARD_CONFIRMED }; break; case PayInConfirmationType.RETURN: - this.status = PayInStatus.RETURN_CONFIRMED; + update = { status: PayInStatus.RETURN_CONFIRMED }; break; } - return this; + Object.assign(this, update); + + return [this.id, update]; } confirmationTxId(direction: PayInConfirmationType): string { diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts index 22598db36b..6a4bc916ae 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/bitcoin-based.strategy.ts @@ -77,9 +77,7 @@ export abstract class BitcoinBasedStrategy extends SendStrategy { const isConfirmed = await this.checkTransactionCompletion(payIn.confirmationTxId(direction), minConfirmations); if (isConfirmed) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } catch (e) { this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts index 5b2be8c960..3be566ad6d 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/cardano.strategy.ts @@ -75,9 +75,7 @@ export abstract class CardanoStrategy extends SendStrategy { ); if (isConfirmed) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } catch (e) { this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts index c711b67e7a..dd55ae9696 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/evm.strategy.ts @@ -94,8 +94,7 @@ export abstract class EvmStrategy extends SendStrategy { minConfirmations, ); if (isConfirmed) { - payIn.confirm(direction, this.forwardRequired); - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } else if (direction === PayInConfirmationType.OUTPUT && Util.minutesDiff(payIn.updated) > 30) { await this.resetForward(payIn, 'timed out'); } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts index a7072544f0..5d5e0287b9 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/solana.strategy.ts @@ -86,9 +86,7 @@ export abstract class SolanaStrategy extends SendStrategy { ); if (isConfirmed) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } catch (e) { this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts index 455b92a306..92da27cd62 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/tron.strategy.ts @@ -75,9 +75,7 @@ export abstract class TronStrategy extends SendStrategy { ); if (isConfirmed) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } catch (e) { this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); diff --git a/src/subdomains/supporting/payin/strategies/send/impl/binance-pay.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/binance-pay.strategy.ts index 2bc34e45d5..753901f943 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/binance-pay.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/binance-pay.strategy.ts @@ -33,9 +33,7 @@ export class BinancePayStrategy extends SendStrategy { async checkConfirmations(payIns: CryptoInput[], direction: PayInConfirmationType): Promise { for (const payIn of payIns) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/kucoin-pay.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/kucoin-pay.strategy.ts index 46f4357f25..4774e405b6 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/kucoin-pay.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/kucoin-pay.strategy.ts @@ -33,9 +33,7 @@ export class KucoinPayStrategy extends SendStrategy { async checkConfirmations(payIns: CryptoInput[], direction: PayInConfirmationType): Promise { for (const payIn of payIns) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts index 99f8b5e3c0..447a67e51c 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/lightning.strategy.ts @@ -70,9 +70,7 @@ export class LightningStrategy extends SendStrategy { async checkConfirmations(payIns: CryptoInput[], direction: PayInConfirmationType): Promise { for (const payIn of payIns) { - payIn.confirm(direction, this.forwardRequired); - - await this.payInRepo.save(payIn); + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); } } From db0b0474118f6b0f9319e970ef1177c6be75903f Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:52:36 +0100 Subject: [PATCH 7/8] feat: added scrypt exchange tx sync + log integration (#2949) * feat: added scrypt exchange tx sync + log integration * fix: fix formatting * feat: sync refactoring * feat(scrypt): add LIMIT orders with price tracking like Binance Implement full trading support for Scrypt exchange with the same rules as Binance/Kraken - using LIMIT orders with dynamic price tracking. ScryptWebSocketConnection: - Add MessageTypes: NEW_ORDER_SINGLE, EXECUTION_REPORT - Add MessageTypes: MARKET_DATA_SNAPSHOT, SECURITY - Add MessageTypes: ORDER_CANCEL_REQUEST, ORDER_CANCEL_REPLACE_REQUEST ScryptService: - Add trading enums: ScryptOrderStatus, ScryptOrderSide, ScryptOrderType - Add fetchOrderBook() - get orderbook with bids/offers - Add getCurrentPrice() - get best bid/ask price - Add getSecurityInfo() / getMinTradeAmount() - dynamic min amounts - Add placeOrder() with price parameter, default to LIMIT - Add sell() - places LIMIT order at current market price - Add cancelOrder() - cancel existing order - Add editOrder() - update order price (OrderCancelReplaceRequest) - Add getOrderStatus() with price field ScryptAdapter: - Add SELL command with tradeAsset parameter - Add checkSellCompletion() with Binance-like price tracking: - NEW/PARTIALLY_FILLED: compare price, editOrder() if changed - CANCELLED: auto-restart with remaining if >= minAmount - FILLED: aggregate output from all correlation IDs - Add aggregateSellOutput() for multi-order fills * refactor(scrypt): improve logging and parallelize order fetching - Add verbose log when order price is unchanged (like Binance) - Parallelize aggregateSellOutput() with Promise.allSettled() - Add failure logging and error handling for partial fetch failures - Throw OrderFailedException if no orders can be fetched * feat(scrypt): add migration for EUR/CHF trading rules - Add ScryptTradingActions migration for rules 312 (CHF) and 313 (EUR) - Set maximal=1000 to trigger automatic USDT conversion * fix(scrypt): implement isBalanceTooLowError and fix float comparison - Implement isBalanceTooLowError with common balance error messages - Add tolerance for float comparison in price tracking to avoid unnecessary order updates due to rounding errors * feat: implemented trade sync * feat: refactoring * feat: added from scrypt * fix: fixed trading * fix: fixed Scrypt requests * [NOTASK] Refactoring 2 * fix: fixed trade handling * feat: fixed trading amounts, added buy command, added price/balance checks * feat: added Scrypt to pricing service * [NOTASK] Small refactoring * Add automatic SCB bank transaction detection Automatically assign BankTxType.SCB to transactions where the name contains "SCB AG". * fix: migration not runnable on DEV (manual execution), small refactoring --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Co-authored-by: Yannick1712 <52333989+Yannick1712@users.noreply.github.com> --- .../exchange/dto/exchange-tx.dto.ts | 4 +- src/integration/exchange/dto/scrypt.dto.ts | 186 +++++++ .../exchange/entities/exchange-tx.entity.ts | 1 + .../exchange/mappers/exchange-tx.mapper.ts | 81 +++ .../services/exchange-registry.service.ts | 10 +- .../exchange/services/exchange-tx.service.ts | 16 +- .../services/scrypt-websocket-connection.ts | 26 +- .../exchange/services/scrypt.service.ts | 500 +++++++++++++++--- src/shared/utils/async-field.ts | 51 ++ .../adapters/actions/scrypt.adapter.ts | 216 +++++++- .../adapters/balances/exchange.adapter.ts | 6 +- .../bank-tx/entities/bank-tx.entity.ts | 2 + .../bank-tx/services/bank-tx.service.ts | 9 + src/subdomains/supporting/log/dto/log.dto.ts | 4 + .../supporting/log/log-job.service.ts | 131 ++++- .../payment/entities/transaction.entity.ts | 1 + .../domain/entities/price-rule.entity.ts | 1 + .../pricing/services/pricing.service.ts | 3 + 18 files changed, 1165 insertions(+), 83 deletions(-) create mode 100644 src/integration/exchange/dto/scrypt.dto.ts diff --git a/src/integration/exchange/dto/exchange-tx.dto.ts b/src/integration/exchange/dto/exchange-tx.dto.ts index 3e7e9d95ca..13c9059d4b 100644 --- a/src/integration/exchange/dto/exchange-tx.dto.ts +++ b/src/integration/exchange/dto/exchange-tx.dto.ts @@ -5,8 +5,8 @@ export class ExchangeTxDto { exchange: ExchangeName; type: ExchangeTxType; externalId: string; - externalCreated: Date; - externalUpdated: Date; + externalCreated?: Date; + externalUpdated?: Date; status: string; amount: number; feeAmount: number; diff --git a/src/integration/exchange/dto/scrypt.dto.ts b/src/integration/exchange/dto/scrypt.dto.ts new file mode 100644 index 0000000000..a68f4bcc46 --- /dev/null +++ b/src/integration/exchange/dto/scrypt.dto.ts @@ -0,0 +1,186 @@ +// --- TRANSACTION TYPES --- // + +export enum ScryptTransactionType { + WITHDRAWAL = 'Withdrawal', + DEPOSIT = 'Deposit', +} + +export enum ScryptTransactionStatus { + COMPLETED = 'Completed', + FAILED = 'Failed', + REJECTED = 'Rejected', +} + +export interface ScryptBalance { + Currency: string; + Amount: string; + AvailableAmount: string; + Equivalent?: { + Currency: string; + Amount: string; + AvailableAmount: string; + }; +} + +export interface ScryptBalanceTransaction { + TransactionID: string; + ClReqID?: string; + Currency: string; + TransactionType: ScryptTransactionType; + Status: ScryptTransactionStatus; + Quantity: string; + Fee?: string; + TxHash?: string; + RejectReason?: string; + RejectText?: string; + Timestamp?: string; + TransactTime?: string; +} + +export interface ScryptWithdrawResponse { + id: string; + status: ScryptTransactionStatus; +} + +export interface ScryptWithdrawStatus { + id: string; + status: ScryptTransactionStatus; + txHash?: string; + amount?: number; + rejectReason?: string; + rejectText?: string; +} + +// --- TRADE TYPES --- // + +export enum ScryptTradeSide { + BUY = 'Buy', + SELL = 'Sell', +} + +export enum ScryptTradeStatus { + PENDING = 'Pending', + CONFIRMED = 'Confirmed', + CANCELED = 'Canceled', +} + +export interface ScryptTrade { + Timestamp: string; + Symbol: string; + OrderID: string; + TradeID: string; + Side: ScryptTradeSide; + TransactTime: string; + ExecType: string; + Currency: string; + Price?: string; + Quantity: string; + Amount: string; + Fee: string; + FeeCurrency?: string; + TradeStatus: ScryptTradeStatus; + AmountCurrency: string; + QuoteID?: string; + RFQID?: string; + CustomerUser?: string; + AggressorSide?: ScryptTradeSide; + DealtCurrency?: string; +} + +// --- ORDER TYPES --- // + +export enum ScryptOrderStatus { + NEW = 'New', + PARTIALLY_FILLED = 'PartiallyFilled', + FILLED = 'Filled', + CANCELED = 'Canceled', + PENDING_CANCEL = 'PendingCancel', + REJECTED = 'Rejected', + PENDING_NEW = 'PendingNew', + PENDING_REPLACE = 'PendingReplace', +} + +export enum ScryptOrderSide { + BUY = 'Buy', + SELL = 'Sell', +} + +export enum ScryptOrderType { + MARKET = 'Market', + LIMIT = 'Limit', +} + +export enum ScryptTimeInForce { + FILL_AND_KILL = 'FillAndKill', + FILL_OR_KILL = 'FillOrKill', + GOOD_TILL_CANCEL = 'GoodTillCancel', +} + +export interface ScryptExecutionReport { + ClOrdID: string; + OrigClOrdID?: string; + OrderID?: string; + Symbol: string; + Side: string; + OrdStatus: ScryptOrderStatus; + ExecType?: string; + OrderQty: string; + CumQty: string; + LeavesQty: string; + AvgPx?: string; + Price?: string; + OrdRejReason?: string; + CxlRejReason?: string; + Text?: string; +} + +export interface ScryptOrderResponse { + id: string; + status: ScryptOrderStatus; +} + +export interface ScryptOrderInfo { + id: string; + orderId?: string; + symbol: string; + side: string; + status: ScryptOrderStatus; + quantity: number; + filledQuantity: number; + remainingQuantity: number; + avgPrice?: number; + price?: number; + rejectReason?: string; +} + +// --- MARKET DATA TYPES --- // + +export interface ScryptPriceLevel { + Price: string; + Size: string; +} + +export interface ScryptMarketDataSnapshot { + Timestamp: string; + Symbol: string; + Status: string; + Bids: ScryptPriceLevel[]; + Offers: ScryptPriceLevel[]; +} + +export interface ScryptOrderBook { + bids: Array<{ price: number; size: number }>; + offers: Array<{ price: number; size: number }>; +} + +// --- SECURITY TYPES --- // + +export interface ScryptSecurity { + Symbol: string; + BaseCurrency: string; + QuoteCurrency: string; + MinimumSize?: string; + MaximumSize?: string; + MinPriceIncrement?: string; + MinSizeIncrement?: string; +} diff --git a/src/integration/exchange/entities/exchange-tx.entity.ts b/src/integration/exchange/entities/exchange-tx.entity.ts index cc1541c066..56ffd49931 100644 --- a/src/integration/exchange/entities/exchange-tx.entity.ts +++ b/src/integration/exchange/entities/exchange-tx.entity.ts @@ -137,4 +137,5 @@ export const ExchangeSyncs: ExchangeSync[] = [ tokenReplacements: [], }, { exchange: ExchangeName.BINANCE, tradeTokens: ['BTC', 'USDT'], tokenReplacements: [['BTCB', 'BTC']] }, + { exchange: ExchangeName.SCRYPT, tokens: [], tokenReplacements: [] }, ]; diff --git a/src/integration/exchange/mappers/exchange-tx.mapper.ts b/src/integration/exchange/mappers/exchange-tx.mapper.ts index d845ea964a..ca0718d0e3 100644 --- a/src/integration/exchange/mappers/exchange-tx.mapper.ts +++ b/src/integration/exchange/mappers/exchange-tx.mapper.ts @@ -1,9 +1,18 @@ import { Trade, Transaction } from 'ccxt'; import { ExchangeTxDto } from '../dto/exchange-tx.dto'; +import { + ScryptBalanceTransaction, + ScryptTrade, + ScryptTradeSide, + ScryptTradeStatus, + ScryptTransactionStatus, + ScryptTransactionType, +} from '../dto/scrypt.dto'; import { ExchangeTxType } from '../entities/exchange-tx.entity'; import { ExchangeName } from '../enums/exchange.enum'; export class ExchangeTxMapper { + // --- CCXT TRANSACTIONS --- // static mapDeposits(transactions: Transaction[], exchange: ExchangeName): ExchangeTxDto[] { return transactions .filter((d) => d.type === 'deposit') @@ -70,4 +79,76 @@ export class ExchangeTxMapper { side: t.side, })); } + + // --- SCRYPT TRANSACTIONS --- // + static mapScryptTransactions(transactions: ScryptBalanceTransaction[], exchange: ExchangeName): ExchangeTxDto[] { + return transactions.map((t) => ({ + exchange, + type: this.mapScryptTransactionType(t.TransactionType), + externalId: t.TransactionID, + externalCreated: t.TransactTime ? new Date(t.TransactTime) : null, + externalUpdated: t.Timestamp ? new Date(t.Timestamp) : null, + status: this.mapScryptStatus(t.Status), + amount: parseFloat(t.Quantity) || 0, + feeAmount: t.Fee ? parseFloat(t.Fee) : 0, + feeCurrency: t.Currency, + currency: t.Currency, + txId: t.TxHash, + })); + } + + private static mapScryptTransactionType(type: ScryptTransactionType): ExchangeTxType { + switch (type) { + case ScryptTransactionType.DEPOSIT: + return ExchangeTxType.DEPOSIT; + case ScryptTransactionType.WITHDRAWAL: + return ExchangeTxType.WITHDRAWAL; + default: + throw new Error(`Unknown Scrypt transaction type: ${type}`); + } + } + + private static mapScryptStatus(status: ScryptTransactionStatus): string { + switch (status) { + case ScryptTransactionStatus.COMPLETED: + return 'ok'; + case ScryptTransactionStatus.FAILED: + case ScryptTransactionStatus.REJECTED: + return 'failed'; + default: + return 'pending'; + } + } + + // --- SCRYPT TRADES --- // + static mapScryptTrades(trades: ScryptTrade[], exchange: ExchangeName): ExchangeTxDto[] { + return trades.map((t) => ({ + exchange, + type: ExchangeTxType.TRADE, + externalId: t.TradeID, + externalCreated: new Date(t.TransactTime), + externalUpdated: new Date(t.Timestamp), + status: this.mapScryptTradeStatus(t.TradeStatus), + amount: parseFloat(t.Quantity) || 0, + feeAmount: parseFloat(t.Fee) || 0, + feeCurrency: t.FeeCurrency ?? t.Currency, + symbol: t.Symbol.replace('-', '/'), + side: t.Side === ScryptTradeSide.BUY ? 'buy' : 'sell', + price: t.Price ? parseFloat(t.Price) : undefined, + cost: parseFloat(t.Amount) || 0, + order: t.OrderID, + })); + } + + private static mapScryptTradeStatus(status: ScryptTradeStatus): string { + switch (status) { + case ScryptTradeStatus.CONFIRMED: + return 'ok'; + case ScryptTradeStatus.CANCELED: + return 'canceled'; + case ScryptTradeStatus.PENDING: + default: + return 'pending'; + } + } } diff --git a/src/integration/exchange/services/exchange-registry.service.ts b/src/integration/exchange/services/exchange-registry.service.ts index e7b2f8c9a3..ccdce3e606 100644 --- a/src/integration/exchange/services/exchange-registry.service.ts +++ b/src/integration/exchange/services/exchange-registry.service.ts @@ -1,10 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { StrategyRegistry } from 'src/subdomains/supporting/common/strategy-registry'; +import { ExchangeName } from '../enums/exchange.enum'; import { ExchangeService } from './exchange.service'; +import { ScryptService } from './scrypt.service'; @Injectable() export class ExchangeRegistryService extends StrategyRegistry { + @Inject() private readonly scryptService: ScryptService; + protected getKey(key: string): string { return key.toLowerCase(); } + + getExchange(exchange: string): ExchangeService | ScryptService { + return exchange === ExchangeName.SCRYPT ? this.scryptService : this.get(exchange); + } } diff --git a/src/integration/exchange/services/exchange-tx.service.ts b/src/integration/exchange/services/exchange-tx.service.ts index 10212b0ad7..70fa6a1aa4 100644 --- a/src/integration/exchange/services/exchange-tx.service.ts +++ b/src/integration/exchange/services/exchange-tx.service.ts @@ -19,6 +19,7 @@ import { ExchangeName } from '../enums/exchange.enum'; import { ExchangeTxMapper } from '../mappers/exchange-tx.mapper'; import { ExchangeTxRepository } from '../repositories/exchange-tx.repository'; import { ExchangeRegistryService } from './exchange-registry.service'; +import { ScryptService } from './scrypt.service'; @Injectable() export class ExchangeTxService { @@ -121,7 +122,20 @@ export class ExchangeTxService { private async getTransactionsFor(sync: ExchangeSync, since: Date): Promise { try { - const exchangeService = this.registryService.get(sync.exchange); + const exchangeService = this.registryService.getExchange(sync.exchange); + + // Scrypt special case + if (exchangeService instanceof ScryptService) { + const [transactions, trades] = await Promise.all([ + exchangeService.getAllTransactions(since), + exchangeService.getTrades(since), + ]); + + return [ + ...ExchangeTxMapper.mapScryptTransactions(transactions, sync.exchange), + ...ExchangeTxMapper.mapScryptTrades(trades, sync.exchange), + ]; + } const tokens = sync.tokens ?? (await this.assetService.getAssetsUsedOn(sync.exchange)); diff --git a/src/integration/exchange/services/scrypt-websocket-connection.ts b/src/integration/exchange/services/scrypt-websocket-connection.ts index ef1b220ea4..f2d0f9eb81 100644 --- a/src/integration/exchange/services/scrypt-websocket-connection.ts +++ b/src/integration/exchange/services/scrypt-websocket-connection.ts @@ -22,7 +22,17 @@ export enum ScryptMessageType { NEW_WITHDRAW_REQUEST = 'NewWithdrawRequest', BALANCE_TRANSACTION = 'BalanceTransaction', BALANCE = 'Balance', + TRADE = 'Trade', ERROR = 'error', + // Trading + NEW_ORDER_SINGLE = 'NewOrderSingle', + EXECUTION_REPORT = 'ExecutionReport', + // Market Data + MARKET_DATA_SNAPSHOT = 'MarketDataSnapshot', + SECURITY = 'Security', + // Order Management + ORDER_CANCEL_REQUEST = 'OrderCancelRequest', + ORDER_CANCEL_REPLACE_REQUEST = 'OrderCancelReplaceRequest', } enum ScryptRequestType { @@ -71,7 +81,7 @@ export class ScryptWebSocketConnection { // --- PUBLIC METHODS --- // - async fetch(streamName: ScryptMessageType, filters?: Record): Promise { + async fetch(streamName: ScryptMessageType, filters?: Record): Promise { const response = await this.request({ type: ScryptRequestType.SUBSCRIBE, streams: [{ name: streamName, ...filters }], @@ -79,13 +89,13 @@ export class ScryptWebSocketConnection { if (!response.initial) throw new Error(`Expected initial ${streamName} message`); - return response.data ?? []; + return (response.data ?? []) as T[]; } async requestAndWaitForUpdate( request: ScryptRequest, streamName: ScryptMessageType, - matcher: (data: any) => T | null, + matcher: (data: T[]) => T | null, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { @@ -95,7 +105,7 @@ export class ScryptWebSocketConnection { }, timeoutMs); const unsubscribe = this.subscribe(streamName, (data) => { - const match = matcher(data); + const match = matcher(data as T[]); if (match) { clearTimeout(timeoutId); unsubscribe(); @@ -267,6 +277,14 @@ export class ScryptWebSocketConnection { // --- STREAMING SUBSCRIPTIONS --- // + subscribeToStream( + streamName: ScryptMessageType, + callback: (data: T[]) => void, + filters?: Record, + ): UnsubscribeFunction { + return this.subscribe(streamName, callback as SubscriptionCallback, filters); + } + private subscribe( streamName: ScryptMessageType, callback: SubscriptionCallback, diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index cdfaa35d9a..f1045c2d78 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -1,72 +1,80 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { GetConfig } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { AsyncSubscription } from 'src/shared/utils/async-field'; +import { Util } from 'src/shared/utils/util'; +import { Price } from 'src/subdomains/supporting/pricing/domain/entities/price'; +import { PricingProvider } from 'src/subdomains/supporting/pricing/services/integration/pricing-provider'; +import { + ScryptBalance, + ScryptBalanceTransaction, + ScryptExecutionReport, + ScryptMarketDataSnapshot, + ScryptOrderBook, + ScryptOrderInfo, + ScryptOrderResponse, + ScryptOrderSide, + ScryptOrderStatus, + ScryptOrderType, + ScryptSecurity, + ScryptTimeInForce, + ScryptTrade, + ScryptTransactionStatus, + ScryptTransactionType, + ScryptWithdrawResponse, + ScryptWithdrawStatus, +} from '../dto/scrypt.dto'; +import { TradeChangedException } from '../exceptions/trade-changed.exception'; import { ScryptMessageType, ScryptWebSocketConnection } from './scrypt-websocket-connection'; -export enum ScryptTransactionStatus { - COMPLETE = 'Complete', - FAILED = 'Failed', - REJECTED = 'Rejected', -} - -interface ScryptBalance { - Currency: string; - Amount: string; - AvailableAmount: string; - Equivalent?: { - Currency: string; - Amount: string; - AvailableAmount: string; - }; -} - -interface ScryptBalanceTransaction { - TransactionID: string; - ClReqID?: string; - Currency: string; - TransactionType: string; - Status: ScryptTransactionStatus; - Quantity: string; - Fee?: string; - TxHash?: string; - RejectReason?: string; - RejectText?: string; - Timestamp?: string; - TransactTime?: string; -} - -export interface ScryptWithdrawResponse { - id: string; - status: ScryptTransactionStatus; -} - -export interface ScryptWithdrawStatus { - id: string; - status: ScryptTransactionStatus; - txHash?: string; - amount?: number; - rejectReason?: string; - rejectText?: string; -} - @Injectable() -export class ScryptService { +export class ScryptService extends PricingProvider { + private readonly logger = new DfxLogger(ScryptService); private readonly connection: ScryptWebSocketConnection; + // Subscriptions + private readonly securities: AsyncSubscription; + private readonly balances: AsyncSubscription>; + private readonly executionReports: Map = new Map(); + readonly name: string = 'Scrypt'; constructor() { + super(); + const config = GetConfig().scrypt; this.connection = new ScryptWebSocketConnection(config.wsUrl, config.apiKey, config.apiSecret); + + // Securities subscription + this.securities = new AsyncSubscription((cb) => { + this.connection.subscribeToStream(ScryptMessageType.SECURITY, cb); + }); + + // Balances subscription (accumulate into Map) + this.balances = new AsyncSubscription((cb) => { + const map = new Map(); + this.connection.subscribeToStream(ScryptMessageType.BALANCE, (balances) => { + for (const b of balances) map.set(b.Currency, b); + cb(map); + }); + }); + + // ExecutionReport subscription (accumulate into Map, no await needed) + this.connection.subscribeToStream(ScryptMessageType.EXECUTION_REPORT, (reports) => { + for (const report of reports) { + this.executionReports.set(report.ClOrdID, report); + } + }); } // --- BALANCES --- // async getTotalBalances(): Promise> { - const balances = await this.fetchBalances(); + const balances = await this.balances; const totalBalances: Record = {}; - for (const balance of balances) { + for (const balance of balances.values()) { totalBalances[balance.Currency] = parseFloat(balance.Amount) || 0; } @@ -74,17 +82,10 @@ export class ScryptService { } async getAvailableBalance(currency: string): Promise { - const balances = await this.fetchBalances([currency]); - const balance = balances.find((b) => b.Currency === currency); - return balance ? parseFloat(balance.AvailableAmount) || 0 : 0; - } + const balances = await this.balances; - private async fetchBalances(currencies?: string[]): Promise { - const data = await this.connection.fetch( - ScryptMessageType.BALANCE, - currencies?.length ? { Currencies: currencies } : undefined, - ); - return data as ScryptBalance[]; + const balance = balances.get(currency); + return balance ? parseFloat(balance.AvailableAmount) || 0 : 0; } // --- WITHDRAWALS --- // @@ -117,10 +118,9 @@ export class ScryptService { const transaction = await this.connection.requestAndWaitForUpdate( withdrawRequest, ScryptMessageType.BALANCE_TRANSACTION, - (data) => { - const transactions = data as ScryptBalanceTransaction[]; - return transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === 'Withdrawal') ?? null; - }, + (transactions) => + transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === ScryptTransactionType.WITHDRAWAL) ?? + null, 60000, ); @@ -138,7 +138,9 @@ export class ScryptService { async getWithdrawalStatus(clReqId: string): Promise { const transactions = await this.fetchBalanceTransactions(); - const transaction = transactions.find((t) => t.ClReqID === clReqId && t.TransactionType === 'Withdrawal'); + const transaction = transactions.find( + (t) => t.ClReqID === clReqId && t.TransactionType === ScryptTransactionType.WITHDRAWAL, + ); if (!transaction) return null; @@ -152,8 +154,374 @@ export class ScryptService { }; } + // --- TRANSACTIONS --- // + + async getAllTransactions(since?: Date): Promise { + const transactions = await this.fetchBalanceTransactions(); + return transactions.filter((t) => !since || (t.TransactTime && new Date(t.TransactTime) >= since)); + } + private async fetchBalanceTransactions(): Promise { - const data = await this.connection.fetch(ScryptMessageType.BALANCE_TRANSACTION); - return data as ScryptBalanceTransaction[]; + return this.connection.fetch(ScryptMessageType.BALANCE_TRANSACTION); + } + + async getTrades(since?: Date): Promise { + const filters: Record = {}; + if (since) filters.StartDate = since.toISOString(); + + return this.connection.fetch(ScryptMessageType.TRADE, filters); + } + + // --- TRADING --- // + + async getPrice(from: string, to: string): Promise { + const { symbol, side } = await this.getTradePair(from, to); + const price = await this.getOrderBookPrice(symbol, side); + + return Price.create(from, to, side === ScryptOrderSide.BUY ? price : 1 / price); + } + + async getCurrentPrice(from: string, to: string): Promise { + const { symbol, side } = await this.getTradePair(from, to); + const price = await this.getOrderBookPrice(symbol, side); + + return side === ScryptOrderSide.BUY ? price : 1 / price; + } + + async sell(from: string, to: string, amount: number): Promise { + const { symbol, side } = await this.getTradePair(from, to); + const price = await this.getOrderBookPrice(symbol, side); + const sizeIncrement = await this.getSizeIncrement(symbol); + + // OrderQty must be in base currency + // SELL (from=base): orderQty = amount + // BUY (from=quote): orderQty = amount / price + const rawQty = side === ScryptOrderSide.SELL ? amount : amount / price; + const orderQty = Util.floorToValue(rawQty, sizeIncrement); + + return this.placeAndReturnId(symbol, side, orderQty, price); + } + + async buy(from: string, to: string, amount: number): Promise { + const { symbol, side } = await this.getTradePair(from, to); + const price = await this.getOrderBookPrice(symbol, side); + const sizeIncrement = await this.getSizeIncrement(symbol); + + // OrderQty must be in base currency + // BUY (to=base): orderQty = amount + // SELL (to=quote): orderQty = amount / price + const rawQty = side === ScryptOrderSide.BUY ? amount : amount / price; + const orderQty = Util.floorToValue(rawQty, sizeIncrement); + + return this.placeAndReturnId(symbol, side, orderQty, price); + } + + private async getSizeIncrement(symbol: string): Promise { + const security = await this.getSecurity(symbol); + return parseFloat(security.MinSizeIncrement ?? '0.000001'); + } + + private async placeAndReturnId( + symbol: string, + side: ScryptOrderSide, + orderQty: number, + price: number, + ): Promise { + const response = await this.placeOrder( + symbol, + side, + orderQty, + ScryptOrderType.LIMIT, + ScryptTimeInForce.GOOD_TILL_CANCEL, + price, + ); + return response.id; + } + + async getOrderStatus(clOrdId: string): Promise { + const report = this.executionReports.get(clOrdId); + if (!report) return null; + + return { + id: report.ClOrdID, + orderId: report.OrderID, + symbol: report.Symbol, + side: report.Side, + status: report.OrdStatus, + quantity: parseFloat(report.OrderQty) || 0, + filledQuantity: parseFloat(report.CumQty) || 0, + remainingQuantity: parseFloat(report.LeavesQty) || 0, + avgPrice: report.AvgPx ? parseFloat(report.AvgPx) : undefined, + price: report.Price ? parseFloat(report.Price) : undefined, + rejectReason: report.OrdRejReason ?? report.Text, + }; + } + + async checkTrade(clOrdId: string, from: string, to: string): Promise { + const orderInfo = await this.getOrderStatus(clOrdId); + if (!orderInfo) { + this.logger.verbose(`No order info for id ${clOrdId} at ${this.name} found`); + return false; + } + + switch (orderInfo.status) { + case ScryptOrderStatus.NEW: + case ScryptOrderStatus.PARTIALLY_FILLED: { + const currentPrice = await this.getTradePrice(from, to); + + // Use tolerance for float comparison to avoid unnecessary updates due to rounding + const priceChanged = orderInfo.price && Math.abs(currentPrice - orderInfo.price) > 0.000001; + if (priceChanged) { + this.logger.verbose(`Order ${clOrdId}: price changed ${orderInfo.price} -> ${currentPrice}, updating order`); + + try { + const newId = await this.editOrder(clOrdId, from, to, orderInfo.remainingQuantity, currentPrice); + this.logger.verbose(`Order ${clOrdId} changed to ${newId}`); + throw new TradeChangedException(newId); + } catch (e) { + if (e instanceof TradeChangedException) throw e; + + // If edit fails, try to cancel and let it restart + this.logger.verbose(`Could not update order ${clOrdId}, attempting cancel: ${e.message}`); + try { + await this.cancelOrder(clOrdId, from, to); + } catch (cancelError) { + this.logger.verbose(`Cancel also failed: ${cancelError.message}`); + } + } + } else { + this.logger.verbose(`Order ${clOrdId} open, price is still ${currentPrice}`); + } + return false; + } + + case ScryptOrderStatus.CANCELED: { + const minAmount = await this.getMinTradeAmount(from, to); + const remaining = orderInfo.remainingQuantity; + + // If remaining amount is below minimum, consider complete + if (remaining < minAmount) { + this.logger.verbose( + `Order ${clOrdId} cancelled with remaining ${remaining} < minAmount ${minAmount}, marking complete`, + ); + return true; + } + + // Restart order with remaining amount (already in base currency) + const { symbol, side } = await this.getTradePair(from, to); + const price = await this.getOrderBookPrice(symbol, side); + + this.logger.verbose(`Order ${clOrdId} cancelled, restarting with remaining ${remaining} (base currency)`); + + const response = await this.placeOrder( + symbol, + side, + remaining, + ScryptOrderType.LIMIT, + ScryptTimeInForce.GOOD_TILL_CANCEL, + price, + ); + + this.logger.verbose(`Order ${clOrdId} changed to ${response.id}`); + throw new TradeChangedException(response.id); + } + + case ScryptOrderStatus.FILLED: + this.logger.verbose(`Order ${clOrdId} filled`); + return true; + + case ScryptOrderStatus.REJECTED: + throw new Error(`Order ${clOrdId} has been rejected: ${orderInfo.rejectReason ?? 'unknown reason'}`); + + case ScryptOrderStatus.PENDING_NEW: + case ScryptOrderStatus.PENDING_CANCEL: + case ScryptOrderStatus.PENDING_REPLACE: + this.logger.verbose(`Order ${clOrdId} is pending (${orderInfo.status}), waiting...`); + return false; + } + } + + private async getTradePrice(from: string, to: string): Promise { + const { symbol, side } = await this.getTradePair(from, to); + return this.getOrderBookPrice(symbol, side); + } + + private async getMinTradeAmount(from: string, to: string): Promise { + const { symbol } = await this.getTradePair(from, to); + const security = await this.getSecurity(symbol); + return parseFloat(security.MinimumSize ?? '0'); + } + + private async placeOrder( + symbol: string, + side: ScryptOrderSide, + quantity: number, + orderType: ScryptOrderType = ScryptOrderType.LIMIT, + timeInForce: ScryptTimeInForce = ScryptTimeInForce.GOOD_TILL_CANCEL, + price?: number, + ): Promise { + const clOrdId = randomUUID(); + + // Price is required for LIMIT orders + if (orderType === ScryptOrderType.LIMIT && price === undefined) { + throw new Error('Price is required for LIMIT orders'); + } + + const orderData: Record = { + Symbol: symbol, + ClOrdID: clOrdId, + Side: side, + OrderQty: quantity.toString(), + OrdType: orderType, + TimeInForce: timeInForce, + }; + + if (price !== undefined) { + orderData.Price = price.toString(); + } + + const orderRequest = { + type: ScryptMessageType.NEW_ORDER_SINGLE, + data: [orderData], + }; + + const report = await this.connection.requestAndWaitForUpdate( + orderRequest, + ScryptMessageType.EXECUTION_REPORT, + (reports) => reports.find((r) => r.ClOrdID === clOrdId) ?? null, + 60000, + ); + + if (report.OrdStatus === ScryptOrderStatus.REJECTED) { + throw new Error(`Scrypt order rejected: ${report.Text ?? report.OrdRejReason ?? 'Unknown reason'}`); + } + + return { + id: clOrdId, + status: report.OrdStatus, + }; + } + + private async cancelOrder(clOrdId: string, from: string, to: string): Promise { + const { symbol } = await this.getTradePair(from, to); + const origClOrdId = clOrdId; + const newClOrdId = randomUUID(); + + const cancelRequest = { + type: ScryptMessageType.ORDER_CANCEL_REQUEST, + data: [ + { + OrigClOrdID: origClOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, + }, + ], + }; + + const report = await this.connection.requestAndWaitForUpdate( + cancelRequest, + ScryptMessageType.EXECUTION_REPORT, + (reports) => reports.find((r) => r.OrigClOrdID === origClOrdId || r.ClOrdID === newClOrdId) ?? null, + 60000, + ); + + return report.OrdStatus === ScryptOrderStatus.CANCELED; + } + + private async editOrder( + clOrdId: string, + from: string, + to: string, + newQuantity: number, + newPrice: number, + ): Promise { + const { symbol } = await this.getTradePair(from, to); + const origClOrdId = clOrdId; + const newClOrdId = randomUUID(); + + const replaceRequest = { + type: ScryptMessageType.ORDER_CANCEL_REPLACE_REQUEST, + data: [ + { + OrigClOrdID: origClOrdId, + ClOrdID: newClOrdId, + Symbol: symbol, + OrderQty: newQuantity.toString(), + Price: newPrice.toString(), + }, + ], + }; + + const report = await this.connection.requestAndWaitForUpdate( + replaceRequest, + ScryptMessageType.EXECUTION_REPORT, + (reports) => reports.find((r) => r.ClOrdID === newClOrdId) ?? null, + 60000, + ); + + if (report.OrdStatus === ScryptOrderStatus.REJECTED) { + throw new Error(`Scrypt order edit rejected: ${report.Text ?? report.OrdRejReason ?? 'Unknown reason'}`); + } + + return newClOrdId; + } + + // --- MARKET DATA --- // + + async getTradePair(from: string, to: string): Promise<{ symbol: string; side: ScryptOrderSide }> { + const securities = await this.securities; + + // Find matching pair: either from=base,to=quote (SELL base) or from=quote,to=base (BUY base) + const security = securities.find( + (s) => (s.BaseCurrency === from && s.QuoteCurrency === to) || (s.BaseCurrency === to && s.QuoteCurrency === from), + ); + + if (!security) { + throw new Error(`${this.name}: pair with ${from} and ${to} not supported`); + } + + // If 'from' is the base currency, we're selling the base; otherwise buying the base + const side = security.BaseCurrency === from ? ScryptOrderSide.SELL : ScryptOrderSide.BUY; + + return { symbol: security.Symbol, side }; + } + + private async getSecurity(symbol: string): Promise { + const securities = await this.securities; + const security = securities.find((s) => s.Symbol === symbol); + + if (!security) { + throw new Error(`No security info for symbol ${symbol}`); + } + + return security; + } + + private async getOrderBookPrice(symbol: string, side: ScryptOrderSide): Promise { + const orderBook = await this.fetchOrderBook(symbol); + + // BUY: look at offers (what sellers are asking) - best ask (lowest offer) + // SELL: look at bids (what buyers are offering) - best bid (highest bid) + const orders = side === ScryptOrderSide.BUY ? orderBook.offers : orderBook.bids; + if (!orders.length) + throw new Error(`No ${side === ScryptOrderSide.BUY ? 'offers' : 'bids'} available for ${symbol}`); + + return orders[0].price; + } + + private async fetchOrderBook(symbol: string): Promise { + const snapshots = await this.connection.fetch(ScryptMessageType.MARKET_DATA_SNAPSHOT, { + Symbol: symbol, + }); + const snapshot = snapshots[0]; + + if (!snapshot) { + throw new Error(`No orderbook data for symbol ${symbol}`); + } + + return { + bids: snapshot.Bids.map((b) => ({ price: parseFloat(b.Price), size: parseFloat(b.Size) })), + offers: snapshot.Offers.map((o) => ({ price: parseFloat(o.Price), size: parseFloat(o.Size) })), + }; } } diff --git a/src/shared/utils/async-field.ts b/src/shared/utils/async-field.ts index 9cf7fba4f8..2612ff1515 100644 --- a/src/shared/utils/async-field.ts +++ b/src/shared/utils/async-field.ts @@ -1,3 +1,7 @@ +/** + * A lazy-loaded async field that executes on first access. + * Implements Promise so it can be directly awaited. + */ export class AsyncField implements Promise { private internalPromise?: Promise; private resolvedValue?: T; @@ -42,3 +46,50 @@ export class AsyncField implements Promise { [Symbol.toStringTag] = 'AsyncField'; } + +/** + * A field populated by a subscription callback. Awaiting resolves once the first value arrives. + * Subsequent updates keep the value fresh via the `current` property. + * Implements Promise so it can be directly awaited. + */ +export class AsyncSubscription implements Promise { + private value!: T; + private ready: Promise; + private resolveReady!: (value: T) => void; + private isReady = false; + + constructor(subscribe: (callback: (value: T) => void) => void) { + this.ready = new Promise((resolve) => (this.resolveReady = resolve)); + + subscribe((value) => { + this.value = value; + if (!this.isReady) { + this.isReady = true; + this.resolveReady(value); + } + }); + } + + get current(): T { + return this.value; + } + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, + ): Promise { + return this.ready.then(onfulfilled, onrejected); + } + + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, + ): Promise { + return this.ready.catch(onrejected); + } + + finally(onfinally?: (() => void) | undefined | null): Promise { + return this.ready.finally(onfinally); + } + + [Symbol.toStringTag] = 'AsyncSubscription'; +} diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index cc4769df78..dcc8d903e9 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -1,18 +1,26 @@ import { Injectable } from '@nestjs/common'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; -import { ScryptService, ScryptTransactionStatus } from 'src/integration/exchange/services/scrypt.service'; +import { ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; +import { TradeChangedException } from 'src/integration/exchange/exceptions/trade-changed.exception'; +import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; +import { PriceValidity, PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; import { LiquidityManagementSystem } from '../../enums'; import { OrderFailedException } from '../../exceptions/order-failed.exception'; import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; import { Command, CorrelationId } from '../../interfaces'; +import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository'; import { LiquidityActionAdapter } from './base/liquidity-action.adapter'; export enum ScryptAdapterCommands { WITHDRAW = 'withdraw', + SELL = 'sell', + BUY = 'buy', } @Injectable() @@ -24,10 +32,15 @@ export class ScryptAdapter extends LiquidityActionAdapter { constructor( private readonly scryptService: ScryptService, private readonly dexService: DexService, + private readonly orderRepo: LiquidityManagementOrderRepository, + private readonly pricingService: PricingService, + private readonly assetService: AssetService, ) { super(LiquidityManagementSystem.SCRYPT); this.commands.set(ScryptAdapterCommands.WITHDRAW, this.withdraw.bind(this)); + this.commands.set(ScryptAdapterCommands.SELL, this.sell.bind(this)); + this.commands.set(ScryptAdapterCommands.BUY, this.buy.bind(this)); } async checkCompletion(order: LiquidityManagementOrder): Promise { @@ -35,6 +48,12 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.WITHDRAW: return this.checkWithdrawCompletion(order); + case ScryptAdapterCommands.SELL: + return this.checkSellCompletion(order); + + case ScryptAdapterCommands.BUY: + return this.checkBuyCompletion(order); + default: return false; } @@ -45,6 +64,10 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.WITHDRAW: return this.validateWithdrawParams(params); + case ScryptAdapterCommands.SELL: + case ScryptAdapterCommands.BUY: + return this.validateTradeParams(params); + default: throw new Error(`Command ${command} not supported by ScryptAdapter`); } @@ -83,6 +106,76 @@ export class ScryptAdapter extends LiquidityActionAdapter { } } + private async sell(order: LiquidityManagementOrder): Promise { + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + + const targetAssetEntity = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); + + await this.getAndCheckTradePrice(targetAssetEntity, tradeAssetEntity, maxPriceDeviation); + + const availableBalance = await this.scryptService.getAvailableBalance(targetAssetEntity.dexName); + const effectiveMax = Math.min(order.maxAmount, availableBalance); + + if (effectiveMax < order.minAmount) { + throw new OrderNotProcessableException( + `Scrypt: not enough balance for ${targetAssetEntity.dexName} (balance: ${availableBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); + } + + const amount = Util.floor(effectiveMax, 6); + + order.inputAmount = amount; + order.inputAsset = targetAssetEntity.dexName; + order.outputAsset = tradeAsset; + + try { + return await this.scryptService.sell(targetAssetEntity.dexName, tradeAsset, amount); + } catch (e) { + if (this.isBalanceTooLowError(e)) { + throw new OrderNotProcessableException(e.message); + } + + throw e; + } + } + + private async buy(order: LiquidityManagementOrder): Promise { + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + + const targetAssetEntity = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); + + const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity, maxPriceDeviation); + const minSellAmount = Util.floor(order.minAmount * price, 6); + const maxSellAmount = Util.floor(order.maxAmount * price, 6); + + const availableBalance = await this.getAvailableTradeBalance(tradeAsset, targetAssetEntity.dexName); + const effectiveMax = Math.min(maxSellAmount, availableBalance); + + if (effectiveMax < minSellAmount) { + throw new OrderNotProcessableException( + `Scrypt: not enough balance for ${tradeAsset} (balance: ${availableBalance}, min. requested: ${minSellAmount}, max. requested: ${maxSellAmount})`, + ); + } + + const amount = Util.floor(effectiveMax, 6); + + order.inputAmount = amount; + order.inputAsset = tradeAsset; + order.outputAsset = targetAssetEntity.dexName; + + try { + return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount); + } catch (e) { + if (this.isBalanceTooLowError(e)) { + throw new OrderNotProcessableException(e.message); + } + + throw e; + } + } + // --- COMPLETION CHECKS --- // private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise { @@ -107,6 +200,76 @@ export class ScryptAdapter extends LiquidityActionAdapter { return this.dexService.checkTransferCompletion(withdrawal.txHash, blockchain); } + private async checkSellCompletion(order: LiquidityManagementOrder): Promise { + const { tradeAsset } = this.parseTradeParams(order.action.paramMap); + const asset = order.pipeline.rule.targetAsset.dexName; + + return this.checkTradeCompletion(order, asset, tradeAsset); + } + + private async checkBuyCompletion(order: LiquidityManagementOrder): Promise { + const { tradeAsset } = this.parseTradeParams(order.action.paramMap); + const asset = order.pipeline.rule.targetAsset.dexName; + + return this.checkTradeCompletion(order, tradeAsset, asset); + } + + private async checkTradeCompletion(order: LiquidityManagementOrder, from: string, to: string): Promise { + try { + const isComplete = await this.scryptService.checkTrade(order.correlationId, from, to); + + if (isComplete) { + order.outputAmount = await this.aggregateTradeOutput(order); + } + + return isComplete; + } catch (e) { + if (e instanceof TradeChangedException) { + order.updateCorrelationId(e.id); + await this.orderRepo.save(order); + return false; + } + + throw new OrderFailedException(e.message); + } + } + + private async aggregateTradeOutput(order: LiquidityManagementOrder): Promise { + const correlationIds = order.allCorrelationIds; + + // Fetch all orders in parallel + const orderResults = await Promise.allSettled(correlationIds.map((id) => this.scryptService.getOrderStatus(id))); + + const orders = orderResults + .filter( + (result): result is PromiseFulfilledResult>> => + result.status === 'fulfilled' && result.value !== null, + ) + .map((result) => result.value!); + + // Log failures + const failures = orderResults.filter((result) => result.status === 'rejected'); + if (failures.length > 0) { + this.logger.warn( + `Order ${order.id}: Failed to fetch ${failures.length} of ${correlationIds.length} orders. ` + + `Proceeding with ${orders.length} successful fetches.`, + ); + } + + if (orders.length === 0) { + throw new OrderFailedException(`Failed to fetch any orders for order ${order.id}`); + } + + // For SELL: output is the proceeds (filledQuantity * avgPrice) + return orders.reduce((sum, o) => { + if (o.filledQuantity > 0) { + const output = o.avgPrice ? o.filledQuantity * o.avgPrice : o.filledQuantity; + return sum + output; + } + return sum; + }, 0); + } + // --- PARAM VALIDATION --- // private validateWithdrawParams(params: Record): boolean { @@ -134,9 +297,56 @@ export class ScryptAdapter extends LiquidityActionAdapter { return { address, asset, blockchain }; } + private validateTradeParams(params: Record): boolean { + try { + this.parseTradeParams(params); + return true; + } catch { + return false; + } + } + + private parseTradeParams(params: Record): { + tradeAsset: string; + maxPriceDeviation?: number; + } { + const tradeAsset = params.tradeAsset as string | undefined; + const maxPriceDeviation = params.maxPriceDeviation as number | undefined; + + if (!tradeAsset) { + throw new Error(`Params provided to ScryptAdapter trade command are invalid.`); + } + + return { tradeAsset, maxPriceDeviation }; + } + // --- HELPER METHODS --- // - private isBalanceTooLowError(_e: Error): boolean { - return false; // TODO: implement specific error check for Scrypt + private isBalanceTooLowError(e: Error): boolean { + return ['Insufficient funds', 'insufficient balance', 'Insufficient position', 'not enough balance'].some((m) => + e.message?.toLowerCase().includes(m.toLowerCase()), + ); + } + + private async getAvailableTradeBalance(from: string, to: string): Promise { + const availableBalance = await this.scryptService.getAvailableBalance(from); + + const { side } = await this.scryptService.getTradePair(from, to); + // Reduce balance by 1% when buying to account for price changes + return side === ScryptOrderSide.BUY ? availableBalance * 0.99 : availableBalance; + } + + private async getAndCheckTradePrice(from: Asset, to: Asset, maxPriceDeviation = 0.05): Promise { + const price = await this.scryptService.getCurrentPrice(from.name, to.name); + + const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); + + if (Math.abs((price - checkPrice.price) / checkPrice.price) > maxPriceDeviation) { + throw new OrderFailedException( + `Trade price out of range: exchange price ${price}, check price ${checkPrice.price}, max deviation ${maxPriceDeviation}`, + ); + } + + return price; } } diff --git a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index 18d8768818..4495afa0e0 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExchangeName } from 'src/integration/exchange/enums/exchange.enum'; import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service'; -import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { Active } from 'src/shared/models/active'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; @@ -19,7 +17,6 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { constructor( private readonly exchangeRegistry: ExchangeRegistryService, - private readonly scryptService: ScryptService, private readonly orderRepo: LiquidityManagementOrderRepository, ) {} @@ -61,8 +58,7 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise { try { - const exchangeService = - exchange === ExchangeName.SCRYPT ? this.scryptService : this.exchangeRegistry.get(exchange); + const exchangeService = this.exchangeRegistry.getExchange(exchange); const balances = await exchangeService.getTotalBalances(); return assets.map((a) => { diff --git a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts index 54994ae65c..e2a31ac705 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts @@ -34,6 +34,7 @@ export enum BankTxType { TEST_FIAT_FIAT = 'TestFiatFiat', GSHEET = 'GSheet', KRAKEN = 'Kraken', + SCRYPT = 'Scrypt', SCB = 'SCB', CHECKOUT_LTD = 'CheckoutLtd', BANK_ACCOUNT_FEE = 'BankAccountFee', @@ -395,6 +396,7 @@ export class BankTx extends IEntity { : 0; case BankTxType.KRAKEN: + case BankTxType.SCRYPT: if ( !BankService.isBankMatching(asset, targetIban ?? this.accountIban) || (targetIban && asset.dexName !== this.instructedCurrency) diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 6740185b39..7a14f0a021 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -71,6 +71,7 @@ export const TransactionBankTxTypeMapper: { [BankTxType.BANK_TX_REPEAT_CHARGEBACK]: TransactionTypeInternal.BANK_TX_REPEAT_CHARGEBACK, [BankTxType.FIAT_FIAT]: TransactionTypeInternal.FIAT_FIAT, [BankTxType.KRAKEN]: TransactionTypeInternal.KRAKEN, + [BankTxType.SCRYPT]: TransactionTypeInternal.SCRYPT, [BankTxType.SCB]: TransactionTypeInternal.SCB, [BankTxType.CHECKOUT_LTD]: TransactionTypeInternal.CHECKOUT_LTD, [BankTxType.BANK_ACCOUNT_FEE]: TransactionTypeInternal.BANK_ACCOUNT_FEE, @@ -501,6 +502,14 @@ export class BankTxService implements OnModuleInit { return BankTxType.KRAKEN; } + if (tx.name?.includes('Scrypt Digital Trading')) { + return BankTxType.SCRYPT; + } + + if (tx.name?.includes('SCB AG')) { + return BankTxType.SCB; + } + return null; } diff --git a/src/subdomains/supporting/log/dto/log.dto.ts b/src/subdomains/supporting/log/dto/log.dto.ts index 2046f95f43..e08d607d9b 100644 --- a/src/subdomains/supporting/log/dto/log.dto.ts +++ b/src/subdomains/supporting/log/dto/log.dto.ts @@ -58,6 +58,8 @@ export type ManualLogPosition = { export type LogPairId = { fromKraken: { eur: PairId; chf: PairId }; toKraken: { eur: PairId; chf: PairId }; + fromScrypt?: { eur: PairId; chf: PairId }; + toScrypt?: { eur: PairId; chf: PairId }; }; type PairId = { @@ -108,6 +110,8 @@ type AssetLogPlusPending = { fromOlky?: number; fromKraken?: number; toKraken?: number; + fromScrypt?: number; + toScrypt?: number; }; type AssetLogMinusPending = { diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index aabb57d1c6..fa0a23e549 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -275,6 +275,8 @@ export class LogJobService { financeLogPairIds?.fromKraken.eur.bankTxId, financeLogPairIds?.toKraken.chf.bankTxId, financeLogPairIds?.toKraken.eur.bankTxId, + financeLogPairIds?.toScrypt.chf.bankTxId, + financeLogPairIds?.toScrypt.eur.bankTxId, ], ) : undefined; @@ -285,6 +287,8 @@ export class LogJobService { financeLogPairIds?.fromKraken.eur.exchangeTxId, financeLogPairIds?.toKraken.chf.exchangeTxId, financeLogPairIds?.toKraken.eur.exchangeTxId, + financeLogPairIds?.toScrypt.chf.exchangeTxId, + financeLogPairIds?.toScrypt.eur.exchangeTxId, ], ) : undefined; @@ -298,6 +302,12 @@ export class LogJobService { ExchangeName.KRAKEN, [ExchangeTxType.DEPOSIT, ExchangeTxType.WITHDRAWAL], ); + const recentScryptBankTx = await this.bankTxService.getRecentExchangeTx(minBankTxId, BankTxType.SCRYPT); + const recentScryptExchangeTx = await this.exchangeTxService.getRecentExchangeTx( + minExchangeTxId, + ExchangeName.SCRYPT, + [ExchangeTxType.DEPOSIT, ExchangeTxType.WITHDRAWAL], + ); // fixed sender and receiver data @@ -347,6 +357,14 @@ export class LogJobService { k.address === yapealEurBank.bic.padEnd(11, 'XXX'), ); + // CHF: Yapeal -> Scrypt + const chfSenderScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealChfBank.iban && b.creditDebitIndicator === BankTxIndicator.DEBIT, + ); + const chfReceiverScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'CHF', + ); + // sender and receiver data const { sender: recentChfKrakenYapealTx, receiver: recentChfKrakenBankTx } = this.filterSenderPendingList( chfSenderExchangeTx, @@ -366,6 +384,50 @@ export class LogJobService { eurReceiverExchangeTx, ); + // EUR: Yapeal -> Scrypt + const eurSenderScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealEurBank.iban && b.creditDebitIndicator === BankTxIndicator.DEBIT, + ); + const eurReceiverScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR', + ); + + // CHF: Scrypt -> Yapeal + const chfSenderScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.WITHDRAWAL && k.status === 'ok' && k.currency === 'CHF', + ); + const chfReceiverScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealChfBank.iban && b.creditDebitIndicator === BankTxIndicator.CREDIT, + ); + + // EUR: Scrypt -> Yapeal + const eurSenderScryptExchangeTx = recentScryptExchangeTx.filter( + (k) => k.type === ExchangeTxType.WITHDRAWAL && k.status === 'ok' && k.currency === 'EUR', + ); + const eurReceiverScryptBankTx = recentScryptBankTx.filter( + (b) => b.accountIban === yapealEurBank.iban && b.creditDebitIndicator === BankTxIndicator.CREDIT, + ); + + // sender and receiver data for Yapeal -> Scrypt + const { sender: recentChfYapealScryptTx, receiver: recentChfBankTxScrypt } = this.filterSenderPendingList( + chfSenderScryptBankTx, + chfReceiverScryptExchangeTx, + ); + const { sender: recentEurYapealScryptTx, receiver: recentEurBankTxScrypt } = this.filterSenderPendingList( + eurSenderScryptBankTx, + eurReceiverScryptExchangeTx, + ); + + // sender and receiver data for Scrypt -> Yapeal + const { sender: recentChfScryptYapealTx, receiver: recentChfScryptBankTx } = this.filterSenderPendingList( + chfSenderScryptExchangeTx, + chfReceiverScryptBankTx, + ); + const { sender: recentEurScryptYapealTx, receiver: recentEurScryptBankTx } = this.filterSenderPendingList( + eurSenderScryptExchangeTx, + eurReceiverScryptBankTx, + ); + // assetLog return assets.reduce((prev, curr) => { if ((curr.balance?.amount == null && !curr.isActive) || (curr.balance && !curr.balance.isDfxOwned)) return prev; @@ -492,6 +554,44 @@ export class LogJobService { yapealEurBank.iban, ); + // Yapeal to Scrypt + const pendingYapealScryptPlusAmount = this.getPendingBankAmount( + [curr], + [...recentChfYapealScryptTx, ...recentEurYapealScryptTx], + BankTxType.SCRYPT, + ); + const pendingChfYapealScryptMinusAmount = this.getPendingBankAmount( + [curr], + recentChfBankTxScrypt, + ExchangeTxType.DEPOSIT, + yapealChfBank.iban, + ); + const pendingEurYapealScryptMinusAmount = this.getPendingBankAmount( + [curr], + recentEurBankTxScrypt, + ExchangeTxType.DEPOSIT, + yapealEurBank.iban, + ); + + // Scrypt to Yapeal + const pendingChfScryptYapealPlusAmount = this.getPendingBankAmount( + [curr], + recentChfScryptYapealTx, + ExchangeTxType.WITHDRAWAL, + yapealChfBank.iban, + ); + const pendingEurScryptYapealPlusAmount = this.getPendingBankAmount( + [curr], + recentEurScryptYapealTx, + ExchangeTxType.WITHDRAWAL, + yapealEurBank.iban, + ); + const pendingScryptYapealMinusAmount = this.getPendingBankAmount( + [curr], + [...recentChfScryptBankTx, ...recentEurScryptBankTx], + BankTxType.SCRYPT, + ); + const fromKrakenUnfiltered = pendingChfKrakenYapealPlusAmountUnfiltered + pendingEurKrakenYapealPlusAmountUnfiltered + @@ -506,6 +606,11 @@ export class LogJobService { let toKraken = pendingYapealKrakenPlusAmount + pendingChfYapealKrakenMinusAmount + pendingEurYapealKrakenMinusAmount; + let fromScrypt = + pendingChfScryptYapealPlusAmount + pendingEurScryptYapealPlusAmount + pendingScryptYapealMinusAmount; + let toScrypt = + pendingYapealScryptPlusAmount + pendingChfYapealScryptMinusAmount + pendingEurYapealScryptMinusAmount; + const errors = []; if (fromKraken !== fromKrakenUnfiltered) { @@ -539,6 +644,26 @@ export class LogJobService { toKraken = 0; } + if (toScrypt < 0) { + errors.push(`toScrypt < 0`); + this.logger.verbose( + `Error in financial log, toScrypt balance < 0 for asset: ${curr.id}, pendingPlusAmount: + ${pendingYapealScryptPlusAmount}, pendingChfMinusAmount: ${pendingChfYapealScryptMinusAmount}, + pendingEurMinusAmount: ${pendingEurYapealScryptMinusAmount}`, + ); + toScrypt = 0; + } + + if (fromScrypt < 0) { + errors.push(`fromScrypt < 0`); + this.logger.verbose( + `Error in financial log, fromScrypt balance < 0 for asset: ${curr.id}, pendingChfPlusAmount: + ${pendingChfScryptYapealPlusAmount}, pendingEurPlusAmount: ${pendingEurScryptYapealPlusAmount}, + pendingMinusAmount: ${pendingScryptYapealMinusAmount}`, + ); + fromScrypt = 0; + } + // total pending balance const totalPlusPending = cryptoInput + @@ -546,7 +671,9 @@ export class LogJobService { bridgeOrder + pendingOlkyYapealAmount + (useUnfilteredTx ? fromKrakenUnfiltered : fromKraken) + - (useUnfilteredTx ? toKrakenUnfiltered : toKraken); + (useUnfilteredTx ? toKrakenUnfiltered : toKraken) + + fromScrypt + + toScrypt; const totalPlus = liquidity + totalPlusPending + (totalCustomBalance ?? 0); @@ -631,6 +758,8 @@ export class LogJobService { fromOlky: this.getJsonValue(pendingOlkyYapealAmount, amountType(curr)), fromKraken: this.getJsonValue(useUnfilteredTx ? fromKrakenUnfiltered : fromKraken, amountType(curr)), toKraken: this.getJsonValue(useUnfilteredTx ? toKrakenUnfiltered : toKraken, amountType(curr)), + fromScrypt: this.getJsonValue(fromScrypt, amountType(curr)), + toScrypt: this.getJsonValue(toScrypt, amountType(curr)), } : undefined, // monitoring: errors.length diff --git a/src/subdomains/supporting/payment/entities/transaction.entity.ts b/src/subdomains/supporting/payment/entities/transaction.entity.ts index d3b34946b8..7bee9def4a 100644 --- a/src/subdomains/supporting/payment/entities/transaction.entity.ts +++ b/src/subdomains/supporting/payment/entities/transaction.entity.ts @@ -26,6 +26,7 @@ export enum TransactionTypeInternal { FIAT_FIAT = 'FiatFiat', INTERNAL = 'Internal', KRAKEN = 'Kraken', + SCRYPT = 'Scrypt', BANK_TX_RETURN = 'BankTxReturn', BANK_TX_REPEAT = 'BankTxRepeat', CRYPTO_INPUT_RETURN = 'CryptoInputReturn', diff --git a/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts b/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts index edd9501f8b..825ef2e3db 100644 --- a/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts +++ b/src/subdomains/supporting/pricing/domain/entities/price-rule.entity.ts @@ -12,6 +12,7 @@ export enum PriceSource { KUCOIN = 'Kucoin', MEXC = 'MEXC', XT = 'XT', + SCRYPT = 'Scrypt', COIN_GECKO = 'CoinGecko', DEX = 'DEX', diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index 5b762534ad..f78201d41c 100644 --- a/src/subdomains/supporting/pricing/services/pricing.service.ts +++ b/src/subdomains/supporting/pricing/services/pricing.service.ts @@ -3,6 +3,7 @@ import { BinanceService } from 'src/integration/exchange/services/binance.servic import { KrakenService } from 'src/integration/exchange/services/kraken.service'; import { KucoinService } from 'src/integration/exchange/services/kucoin.service'; import { MexcService } from 'src/integration/exchange/services/mexc.service'; +import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; import { XtService } from 'src/integration/exchange/services/xt.service'; import { Active, activesEqual, isAsset, isFiat } from 'src/shared/models/active'; import { Asset } from 'src/shared/models/asset/asset.entity'; @@ -61,6 +62,7 @@ export class PricingService implements OnModuleInit { readonly kucoinService: KucoinService, readonly mexcService: MexcService, readonly xtService: XtService, + readonly scryptService: ScryptService, readonly coinGeckoService: CoinGeckoService, readonly dexService: PricingDexService, readonly fixerService: FixerService, @@ -77,6 +79,7 @@ export class PricingService implements OnModuleInit { [PriceSource.KUCOIN]: kucoinService, [PriceSource.MEXC]: mexcService, [PriceSource.XT]: xtService, + [PriceSource.SCRYPT]: scryptService, [PriceSource.COIN_GECKO]: coinGeckoService, [PriceSource.DEX]: dexService, [PriceSource.FIXER]: fixerService, From 938dbbe889aaf3ec7251251d7ac4fb0bcf54392c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:06:21 +0100 Subject: [PATCH 8/8] fix(exchange): add MEXC fee tracking and transaction sync (#2965) * fix(exchange): add MEXC fee tracking and transaction sync - Parse transactionFee from MEXC withdrawal responses - Add MEXC to ExchangeSyncs for automatic transaction synchronization - Sync ZCHF, XMR, USDT, ZANO tokens from MEXC every 5 minutes * fix: apply prettier formatting and add fUSD token - Fix prettier formatting in mexc.service.ts - Add fUSD to MEXC token list for exchange sync --- src/integration/exchange/entities/exchange-tx.entity.ts | 1 + src/integration/exchange/services/mexc.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/integration/exchange/entities/exchange-tx.entity.ts b/src/integration/exchange/entities/exchange-tx.entity.ts index 56ffd49931..c59ff7caac 100644 --- a/src/integration/exchange/entities/exchange-tx.entity.ts +++ b/src/integration/exchange/entities/exchange-tx.entity.ts @@ -137,5 +137,6 @@ export const ExchangeSyncs: ExchangeSync[] = [ tokenReplacements: [], }, { exchange: ExchangeName.BINANCE, tradeTokens: ['BTC', 'USDT'], tokenReplacements: [['BTCB', 'BTC']] }, + { exchange: ExchangeName.MEXC, tokens: ['ZCHF', 'XMR', 'USDT', 'ZANO', 'fUSD'], tokenReplacements: [] }, { exchange: ExchangeName.SCRYPT, tokens: [], tokenReplacements: [] }, ]; diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index 4b90486e84..d2bbf65fe4 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -128,7 +128,7 @@ export class MexcService extends ExchangeService { ? 'ok' : 'pending', updated: undefined, - fee: undefined, + fee: d.transactionFee ? { cost: parseFloat(d.transactionFee), currency: d.coin.split('-')[0] } : undefined, network: d.network, comment: d.memo, internal: undefined,