From e004e8c4d26ac6666aceb20f7778be04fabb944c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 00:29:16 +0100 Subject: [PATCH 1/9] Add SafeAccount multi-account system (#2602) * Add SafeAccount multi-account system - Add SafeAccount and SafeAccountAccess entities - Add SafeAccountService with legacy mode fallback - Add SafeAccountController with CRUD endpoints - Add SafeAccountReadGuard and SafeAccountWriteGuard - Extend User, CustodyBalance, CustodyOrder with safeAccount relations - Add database migration for new tables * feat: renaming * feat: small cleanup * feat: refactoring * feat: refactoring 2 * feat: refactoring 3 * fix: fixed format --------- Co-authored-by: David May --- .../1768341824012-AddCustodyAccountTables.js | 52 ++++++ .../controllers/custody-account.controller.ts | 93 +++++++++++ .../custody/controllers/custody.controller.ts | 4 +- src/subdomains/core/custody/custody.module.ts | 18 ++- .../dto/input/create-custody-account.dto.ts | 30 +--- .../custody/dto/input/custody-signup.dto.ts | 32 ++++ .../dto/input/update-custody-account.dto.ts | 15 ++ .../custody/dto/output/custody-account.dto.ts | 38 +++++ .../entities/custody-account-access.entity.ts | 18 +++ .../entities/custody-account.entity.ts | 26 +++ .../entities/custody-balance.entity.ts | 6 +- .../custody/entities/custody-order.entity.ts | 8 + src/subdomains/core/custody/enums/custody.ts | 13 ++ .../guards/custody-account-access.guard.ts | 48 ++++++ .../mappers/custody-account-dto.mapper.ts | 28 ++++ .../custody-account-access.repository.ts | 11 ++ .../custody-account.repository.ts | 11 ++ .../services/custody-account.service.ts | 153 ++++++++++++++++++ .../core/custody/services/custody.service.ts | 4 +- .../user/models/user-data/user-data.entity.ts | 8 + .../generic/user/models/user/user.entity.ts | 4 + 21 files changed, 588 insertions(+), 32 deletions(-) create mode 100644 migration/1768341824012-AddCustodyAccountTables.js create mode 100644 src/subdomains/core/custody/controllers/custody-account.controller.ts create mode 100644 src/subdomains/core/custody/dto/input/custody-signup.dto.ts create mode 100644 src/subdomains/core/custody/dto/input/update-custody-account.dto.ts create mode 100644 src/subdomains/core/custody/dto/output/custody-account.dto.ts create mode 100644 src/subdomains/core/custody/entities/custody-account-access.entity.ts create mode 100644 src/subdomains/core/custody/entities/custody-account.entity.ts create mode 100644 src/subdomains/core/custody/guards/custody-account-access.guard.ts create mode 100644 src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts create mode 100644 src/subdomains/core/custody/repositories/custody-account-access.repository.ts create mode 100644 src/subdomains/core/custody/repositories/custody-account.repository.ts create mode 100644 src/subdomains/core/custody/services/custody-account.service.ts diff --git a/migration/1768341824012-AddCustodyAccountTables.js b/migration/1768341824012-AddCustodyAccountTables.js new file mode 100644 index 0000000000..81eebf2414 --- /dev/null +++ b/migration/1768341824012-AddCustodyAccountTables.js @@ -0,0 +1,52 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddCustodyAccountTables1768341824012 { + name = 'AddCustodyAccountTables1768341824012' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "custody_account_access" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_7de1867f044392358470c9ade75" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_8b3a56557a24d8c9157c4867435" DEFAULT getdate(), "accessLevel" nvarchar(255) NOT NULL, "accountId" int NOT NULL, "userDataId" int NOT NULL, CONSTRAINT "PK_1657b7fd1d5a0ee0b01f657e508" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access" ("accountId", "userDataId") `); + await queryRunner.query(`CREATE TABLE "custody_account" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_91a0617046b1f9ca14218362617" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_281789479a65769e9189e24f7d9" DEFAULT getdate(), "title" nvarchar(256) NOT NULL, "description" nvarchar(MAX), "requiredSignatures" int NOT NULL CONSTRAINT "DF_980d2b28fe8284b5060c70a36fd" DEFAULT 1, "status" nvarchar(255) NOT NULL CONSTRAINT "DF_aaeefb3ab36f3b7e02b5f4c67fc" DEFAULT 'Active', "ownerId" int NOT NULL, CONSTRAINT "PK_89fae3a990abaa76d843242fc6d" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD "accountId" int`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD "initiatedById" int`); + await queryRunner.query(`ALTER TABLE "custody_balance" ADD "accountId" int`); + await queryRunner.query(`ALTER TABLE "user" ADD "custodyAccountId" int`); + await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_45213c9c7521d41be00fa5ead93" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_8a4612269b283bf40950ddb8485" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_account" ADD CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3" FOREIGN KEY ("ownerId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6" FOREIGN KEY ("initiatedById") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_balance" ADD CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e"`); + await queryRunner.query(`ALTER TABLE "custody_account" DROP CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3"`); + await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_8a4612269b283bf40950ddb8485"`); + await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_45213c9c7521d41be00fa5ead93"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "custodyAccountId"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "accountId"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "accountId"`); + await queryRunner.query(`DROP TABLE "custody_account"`); + await queryRunner.query(`DROP INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access"`); + await queryRunner.query(`DROP TABLE "custody_account_access"`); + } +} diff --git a/src/subdomains/core/custody/controllers/custody-account.controller.ts b/src/subdomains/core/custody/controllers/custody-account.controller.ts new file mode 100644 index 0000000000..d15699d560 --- /dev/null +++ b/src/subdomains/core/custody/controllers/custody-account.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, Get, NotFoundException, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +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 { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { UpdateCustodyAccountDto } from '../dto/input/update-custody-account.dto'; +import { CustodyAccountAccessDto, CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from '../guards/custody-account-access.guard'; +import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper'; +import { CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; + +@ApiTags('Custody') +@Controller('custody/account') +export class CustodyAccountController { + constructor(private readonly custodyAccountService: CustodyAccountService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOkResponse({ type: [CustodyAccountDto], description: 'List of custody accounts for the user' }) + async getCustodyAccounts(@GetJwt() jwt: JwtPayload): Promise { + return this.custodyAccountService.getCustodyAccountsForUser(jwt.account); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Custody account details' }) + async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account); + + const isLegacy = id === LegacyAccountId; + const account = isLegacy ? custodyAccounts.find((ca) => ca.isLegacy) : custodyAccounts.find((ca) => ca.id === +id); + if (!account) throw new NotFoundException(`${isLegacy ? 'Legacy' : 'Custody'} account not found`); + + return account; + } + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiCreatedResponse({ type: CustodyAccountDto, description: 'Create a new custody account' }) + async createCustodyAccount( + @GetJwt() jwt: JwtPayload, + @Body() dto: CreateCustodyAccountDto, + ): Promise { + const custodyAccount = await this.custodyAccountService.createCustodyAccount( + jwt.account, + dto.title, + dto.description, + ); + + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); + } + + @Put(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountWriteGuard) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Update custody account' }) + async updateCustodyAccount( + @GetJwt() jwt: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateCustodyAccountDto, + ): Promise { + const custodyAccount = await this.custodyAccountService.updateCustodyAccount( + +id, + jwt.account, + dto.title, + dto.description, + ); + + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); + } + + @Get(':id/access') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard) + @ApiOkResponse({ type: [CustodyAccountAccessDto], description: 'List of users with access' }) + async getAccessList(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const accessList = await this.custodyAccountService.getAccessList(+id, jwt.account); + + return accessList.map((access) => ({ + id: access.id, + user: { id: access.userData.id }, + accessLevel: access.accessLevel, + })); + } +} diff --git a/src/subdomains/core/custody/controllers/custody.controller.ts b/src/subdomains/core/custody/controllers/custody.controller.ts index b9512363d7..419c56f2e4 100644 --- a/src/subdomains/core/custody/controllers/custody.controller.ts +++ b/src/subdomains/core/custody/controllers/custody.controller.ts @@ -10,7 +10,7 @@ import { UserRole } from 'src/shared/auth/user-role.enum'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; -import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { CustodySignupDto } from '../dto/input/custody-signup.dto'; import { GetCustodyInfoDto } from '../dto/input/get-custody-info.dto'; import { GetCustodyPdfDto } from '../dto/input/get-custody-pdf.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; @@ -60,7 +60,7 @@ export class CustodyController { @ApiCreatedResponse({ type: CustodyAuthDto }) async createCustodyAccount( @GetJwt() jwt: JwtPayload, - @Body() dto: CreateCustodyAccountDto, + @Body() dto: CustodySignupDto, @RealIP() ip: string, ): Promise { return this.service.createCustodyAccount(jwt.account, dto, ip); diff --git a/src/subdomains/core/custody/custody.module.ts b/src/subdomains/core/custody/custody.module.ts index 6ba580bff5..0c60b05e0d 100644 --- a/src/subdomains/core/custody/custody.module.ts +++ b/src/subdomains/core/custody/custody.module.ts @@ -10,20 +10,27 @@ import { ReferralModule } from '../referral/referral.module'; import { SellCryptoModule } from '../sell-crypto/sell-crypto.module'; import { DfxOrderStepAdapter } from './adapter/dfx-order-step.adapter'; import { CustodyAdminController, CustodyController } from './controllers/custody.controller'; +import { CustodyAccountController } from './controllers/custody-account.controller'; import { CustodyBalance } from './entities/custody-balance.entity'; import { CustodyOrderStep } from './entities/custody-order-step.entity'; import { CustodyOrder } from './entities/custody-order.entity'; +import { CustodyAccountAccess } from './entities/custody-account-access.entity'; +import { CustodyAccount } from './entities/custody-account.entity'; import { CustodyBalanceRepository } from './repositories/custody-balance.repository'; import { CustodyOrderStepRepository } from './repositories/custody-order-step.repository'; import { CustodyOrderRepository } from './repositories/custody-order.repository'; +import { CustodyAccountAccessRepository } from './repositories/custody-account-access.repository'; +import { CustodyAccountRepository } from './repositories/custody-account.repository'; import { CustodyJobService } from './services/custody-job.service'; import { CustodyOrderService } from './services/custody-order.service'; import { CustodyPdfService } from './services/custody-pdf.service'; import { CustodyService } from './services/custody.service'; +import { CustodyAccountService } from './services/custody-account.service'; +import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from './guards/custody-account-access.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep]), + TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, CustodyAccount, CustodyAccountAccess]), forwardRef(() => UserModule), forwardRef(() => ReferralModule), SharedModule, @@ -33,7 +40,7 @@ import { CustodyService } from './services/custody.service'; PricingModule, PayoutModule, ], - controllers: [CustodyController, CustodyAdminController], + controllers: [CustodyController, CustodyAdminController, CustodyAccountController], providers: [ CustodyService, CustodyOrderRepository, @@ -44,7 +51,12 @@ import { CustodyService } from './services/custody.service'; CustodyPdfService, CustodyBalance, CustodyBalanceRepository, + CustodyAccountRepository, + CustodyAccountAccessRepository, + CustodyAccountService, + CustodyAccountReadGuard, + CustodyAccountWriteGuard, ], - exports: [CustodyService, CustodyOrderService], + exports: [CustodyService, CustodyOrderService, CustodyAccountService], }) export class CustodyModule {} diff --git a/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts b/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts index 95f990968b..fb53a54b59 100644 --- a/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts +++ b/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts @@ -1,32 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; -import { GetConfig } from 'src/config/config'; -import { Moderator } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; -import { CustodyAddressType } from '../../enums/custody'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateCustodyAccountDto { - @ApiProperty({ enum: CustodyAddressType }) - @IsEnum(CustodyAddressType) - addressType: CustodyAddressType; - - @ApiPropertyOptional() - @IsOptional() + @ApiProperty({ description: 'Title of the custody account' }) @IsString() - wallet?: string; + @MaxLength(256) + title: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Description of the custody account' }) @IsOptional() @IsString() - @Matches(GetConfig().formats.ref) - usedRef?: string; - - @ApiPropertyOptional({ description: 'Special code' }) - @IsOptional() - @IsString() - specialCode?: string; - - @ApiPropertyOptional({ description: 'Moderator' }) - @IsOptional() - @IsEnum(Moderator) - moderator?: Moderator; + description?: string; } diff --git a/src/subdomains/core/custody/dto/input/custody-signup.dto.ts b/src/subdomains/core/custody/dto/input/custody-signup.dto.ts new file mode 100644 index 0000000000..3c9249b84c --- /dev/null +++ b/src/subdomains/core/custody/dto/input/custody-signup.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; +import { GetConfig } from 'src/config/config'; +import { Moderator } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { CustodyAddressType } from '../../enums/custody'; + +export class CustodySignupDto { + @ApiProperty({ enum: CustodyAddressType }) + @IsEnum(CustodyAddressType) + addressType: CustodyAddressType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + wallet?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @Matches(GetConfig().formats.ref) + usedRef?: string; + + @ApiPropertyOptional({ description: 'Special code' }) + @IsOptional() + @IsString() + specialCode?: string; + + @ApiPropertyOptional({ description: 'Moderator' }) + @IsOptional() + @IsEnum(Moderator) + moderator?: Moderator; +} diff --git a/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts new file mode 100644 index 0000000000..7bd55bfc70 --- /dev/null +++ b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateCustodyAccountDto { + @ApiPropertyOptional({ description: 'Title of the custody account' }) + @IsOptional() + @IsString() + @MaxLength(256) + title?: string; + + @ApiPropertyOptional({ description: 'Description of the custody account' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/subdomains/core/custody/dto/output/custody-account.dto.ts b/src/subdomains/core/custody/dto/output/custody-account.dto.ts new file mode 100644 index 0000000000..5f35f0dc31 --- /dev/null +++ b/src/subdomains/core/custody/dto/output/custody-account.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CustodyAccessLevel } from '../../enums/custody'; + +export class CustodyUserDto { + @ApiProperty() + id: number; +} + +export class CustodyAccountDto { + @ApiPropertyOptional({ description: 'ID of the custody account (null for legacy)' }) + id: number | null; + + @ApiProperty({ description: 'Title of the custody account' }) + title: string; + + @ApiPropertyOptional({ description: 'Description of the custody account' }) + description?: string; + + @ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' }) + isLegacy: boolean; + + @ApiProperty({ enum: CustodyAccessLevel, description: 'Access level for current user' }) + accessLevel: CustodyAccessLevel; + + @ApiPropertyOptional({ type: CustodyUserDto }) + owner?: CustodyUserDto; +} + +export class CustodyAccountAccessDto { + @ApiProperty() + id: number; + + @ApiProperty({ type: CustodyUserDto }) + user: CustodyUserDto; + + @ApiProperty({ enum: CustodyAccessLevel }) + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/custody-account-access.entity.ts b/src/subdomains/core/custody/entities/custody-account-access.entity.ts new file mode 100644 index 0000000000..4652bdcebd --- /dev/null +++ b/src/subdomains/core/custody/entities/custody-account-access.entity.ts @@ -0,0 +1,18 @@ +import { IEntity } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccount } from './custody-account.entity'; + +@Entity() +@Index((a: CustodyAccountAccess) => [a.account, a.userData], { unique: true }) +export class CustodyAccountAccess extends IEntity { + @ManyToOne(() => CustodyAccount, (custodyAccount) => custodyAccount.accessGrants, { nullable: false }) + account: CustodyAccount; + + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Column() + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/custody-account.entity.ts b/src/subdomains/core/custody/entities/custody-account.entity.ts new file mode 100644 index 0000000000..e2b2db0a95 --- /dev/null +++ b/src/subdomains/core/custody/entities/custody-account.entity.ts @@ -0,0 +1,26 @@ +import { IEntity } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Column, Entity, ManyToOne, OneToMany } from 'typeorm'; +import { CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountAccess } from './custody-account-access.entity'; + +@Entity() +export class CustodyAccount extends IEntity { + @Column({ length: 256 }) + title: string; + + @Column({ length: 'MAX', nullable: true }) + description?: string; + + @ManyToOne(() => UserData, { nullable: false }) + owner: UserData; + + @Column({ type: 'int', default: 1 }) + requiredSignatures: number; + + @Column({ default: CustodyAccountStatus.ACTIVE }) + status: CustodyAccountStatus; + + @OneToMany(() => CustodyAccountAccess, (access) => access.account) + accessGrants: CustodyAccountAccess[]; +} diff --git a/src/subdomains/core/custody/entities/custody-balance.entity.ts b/src/subdomains/core/custody/entities/custody-balance.entity.ts index 98323960c9..55f6540123 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -2,9 +2,10 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity } from 'src/shared/models/entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { CustodyAccount } from './custody-account.entity'; @Entity() -@Index((custodyBalance: CustodyBalance) => [custodyBalance.user, custodyBalance.asset], { unique: true }) +@Index((cb: CustodyBalance) => [cb.user, cb.asset], { unique: true }) export class CustodyBalance extends IEntity { @Column({ type: 'float', default: 0 }) balance: number; @@ -14,4 +15,7 @@ export class CustodyBalance extends IEntity { @ManyToOne(() => Asset, { nullable: false, eager: true }) asset: Asset; + + @ManyToOne(() => CustodyAccount, { nullable: true }) + account?: CustodyAccount; } diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index f3d992688e..86b942bc81 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -1,6 +1,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity, UpdateResult } from 'src/shared/models/entity'; import { Util } from 'src/shared/utils/util'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; @@ -9,6 +10,7 @@ import { Buy } from '../../buy-crypto/routes/buy/buy.entity'; import { Swap } from '../../buy-crypto/routes/swap/swap.entity'; import { Sell } from '../../sell-crypto/route/sell.entity'; import { CustodyOrderStatus, CustodyOrderType } from '../enums/custody'; +import { CustodyAccount } from './custody-account.entity'; import { CustodyOrderStep } from './custody-order-step.entity'; @Entity() @@ -37,6 +39,12 @@ export class CustodyOrder extends IEntity { @ManyToOne(() => User, (user) => user.custodyOrders, { nullable: false }) user: User; + @ManyToOne(() => CustodyAccount, { nullable: true }) + account?: CustodyAccount; + + @ManyToOne(() => UserData, { nullable: true }) + initiatedBy?: UserData; + @ManyToOne(() => Sell, { nullable: true }) sell?: Sell; diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index 124b7d5e45..5baef3a174 100644 --- a/src/subdomains/core/custody/enums/custody.ts +++ b/src/subdomains/core/custody/enums/custody.ts @@ -2,6 +2,7 @@ export enum CustodyAddressType { EVM = 'EVM', } +// orders export enum CustodyOrderType { DEPOSIT = 'Deposit', WITHDRAWAL = 'Withdrawal', @@ -38,3 +39,15 @@ export enum CustodyOrderStepCommand { CHARGE_ROUTE = 'ChargeRoute', SEND_TO_ROUTE = 'SendToRoute', } + +// accounts +export enum CustodyAccountStatus { + ACTIVE = 'Active', + BLOCKED = 'Blocked', + CLOSED = 'Closed', +} + +export enum CustodyAccessLevel { + READ = 'Read', + WRITE = 'Write', +} diff --git a/src/subdomains/core/custody/guards/custody-account-access.guard.ts b/src/subdomains/core/custody/guards/custody-account-access.guard.ts new file mode 100644 index 0000000000..d8ead9b53f --- /dev/null +++ b/src/subdomains/core/custody/guards/custody-account-access.guard.ts @@ -0,0 +1,48 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccountId, CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; + +abstract class CustodyAccountAccessGuard implements CanActivate { + protected abstract readonly requiredLevel: CustodyAccessLevel; + + constructor(protected readonly custodyAccountService: CustodyAccountService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const accountId = request.user?.account; + if (!accountId) { + throw new ForbiddenException('User not authenticated'); + } + + try { + const custodyAccountId = this.getCustodyAccountId(request); + await this.custodyAccountService.checkAccess(custodyAccountId, accountId, this.requiredLevel); + return true; + } catch (error) { + throw new ForbiddenException(error.message || 'Access denied'); + } + } + + private getCustodyAccountId(request: any): CustodyAccountId { + const id = request.params?.custodyAccountId || request.params?.id; + if (id == null) throw new ForbiddenException('Custody account ID required'); + + if (id === LegacyAccountId) return id; + + const parsed = +id; + if (isNaN(parsed)) throw new ForbiddenException('Invalid custody account ID'); + + return parsed; + } +} + +@Injectable() +export class CustodyAccountReadGuard extends CustodyAccountAccessGuard { + protected readonly requiredLevel = CustodyAccessLevel.READ; +} + +@Injectable() +export class CustodyAccountWriteGuard extends CustodyAccountAccessGuard { + protected readonly requiredLevel = CustodyAccessLevel.WRITE; +} diff --git a/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts b/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts new file mode 100644 index 0000000000..092f3a8042 --- /dev/null +++ b/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts @@ -0,0 +1,28 @@ +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccount } from '../entities/custody-account.entity'; +import { CustodyAccessLevel } from '../enums/custody'; + +export class CustodyAccountDtoMapper { + static toDto(custodyAccount: CustodyAccount, accessLevel: CustodyAccessLevel): CustodyAccountDto { + return { + id: custodyAccount.id, + title: custodyAccount.title, + description: custodyAccount.description, + isLegacy: false, + accessLevel, + owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, + }; + } + + static toLegacyDto(userData: UserData): CustodyAccountDto { + return { + id: null, + title: 'Custody', + description: undefined, + isLegacy: true, + accessLevel: CustodyAccessLevel.WRITE, + owner: { id: userData.id }, + }; + } +} diff --git a/src/subdomains/core/custody/repositories/custody-account-access.repository.ts b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts new file mode 100644 index 0000000000..0019430f0b --- /dev/null +++ b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; + +@Injectable() +export class CustodyAccountAccessRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(CustodyAccountAccess, manager); + } +} diff --git a/src/subdomains/core/custody/repositories/custody-account.repository.ts b/src/subdomains/core/custody/repositories/custody-account.repository.ts new file mode 100644 index 0000000000..7497dfdf6a --- /dev/null +++ b/src/subdomains/core/custody/repositories/custody-account.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { CustodyAccount } from '../entities/custody-account.entity'; + +@Injectable() +export class CustodyAccountRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(CustodyAccount, manager); + } +} diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts new file mode 100644 index 0000000000..2c984329af --- /dev/null +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -0,0 +1,153 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; +import { CustodyAccount } from '../entities/custody-account.entity'; +import { CustodyAccessLevel, CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper'; +import { CustodyAccountAccessRepository } from '../repositories/custody-account-access.repository'; +import { CustodyAccountRepository } from '../repositories/custody-account.repository'; + +export const LegacyAccountId = 'legacy'; +export type CustodyAccountId = number | typeof LegacyAccountId; + +@Injectable() +export class CustodyAccountService { + constructor( + private readonly custodyAccountRepo: CustodyAccountRepository, + private readonly custodyAccountAccessRepo: CustodyAccountAccessRepository, + private readonly userDataService: UserDataService, + ) {} + + // --- GET CUSTODY ACCOUNTS --- // + async getCustodyAccountsForUser(accountId: number): Promise { + const account = await this.userDataService.getUserData(accountId, { + users: true, + custodyAccounts: true, + custodyAccountAccesses: { account: { owner: true } }, + }); + if (!account) throw new NotFoundException('User not found'); + + // owned accounts + const ownedAccounts = (account.custodyAccounts ?? []).filter((ca) => ca.status === CustodyAccountStatus.ACTIVE); + + // shared accounts (via access grants, excluding owned) + const sharedAccounts = (account.custodyAccountAccesses ?? []) + .filter((a) => a.account.status === CustodyAccountStatus.ACTIVE) + .filter((a) => a.account.owner.id !== accountId); + + const custodyAccounts: CustodyAccountDto[] = [ + ...ownedAccounts.map((ca) => CustodyAccountDtoMapper.toDto(ca, CustodyAccessLevel.WRITE)), + ...sharedAccounts.map((a) => CustodyAccountDtoMapper.toDto(a.account, a.accessLevel)), + ]; + + if (custodyAccounts.length > 0) { + return custodyAccounts; + } + + // fallback to legacy custody account + const hasCustody = account.users.some((u) => u.role === UserRole.CUSTODY); + if (hasCustody) { + return [CustodyAccountDtoMapper.toLegacyDto(account)]; + } + + return []; + } + + async getCustodyAccountById(custodyAccountId: number): Promise { + const custodyAccount = await this.custodyAccountRepo.findOne({ + where: { id: custodyAccountId }, + relations: { owner: true, accessGrants: { userData: true } }, + }); + + if (!custodyAccount) throw new NotFoundException('Custody account not found'); + + return custodyAccount; + } + + // --- ACCESS CHECK --- // + async checkAccess( + custodyAccountId: CustodyAccountId, + accountId: number, + requiredLevel: CustodyAccessLevel, + ): Promise<{ custodyAccount: CustodyAccount | null; isLegacy: boolean }> { + // Legacy mode + if (custodyAccountId === LegacyAccountId) { + if (requiredLevel === CustodyAccessLevel.WRITE) { + throw new ForbiddenException('Cannot modify legacy account'); + } + return { custodyAccount: null, isLegacy: true }; + } + + const custodyAccount = await this.getCustodyAccountById(custodyAccountId); + + // Owner has WRITE access + if (custodyAccount.owner.id === accountId) { + return { custodyAccount, isLegacy: false }; + } + + // Check access grants + const access = custodyAccount.accessGrants.find((a) => a.userData.id === accountId); + if (!access) { + throw new ForbiddenException('No access to this custody account'); + } + + // Check if access level is sufficient + if (requiredLevel === CustodyAccessLevel.WRITE && access.accessLevel === CustodyAccessLevel.READ) { + throw new ForbiddenException('Write access required'); + } + + return { custodyAccount, isLegacy: false }; + } + + // --- CREATE --- // + async createCustodyAccount(accountId: number, title: string, description?: string): Promise { + const owner = await this.userDataService.getUserData(accountId); + if (!owner) throw new NotFoundException('User not found'); + + const custodyAccount = this.custodyAccountRepo.create({ + title, + description, + owner, + status: CustodyAccountStatus.ACTIVE, + requiredSignatures: 1, + }); + + const saved = await this.custodyAccountRepo.save(custodyAccount); + + // Create WRITE access for owner + const ownerAccess = this.custodyAccountAccessRepo.create({ + account: saved, + userData: owner, + accessLevel: CustodyAccessLevel.WRITE, + }); + await this.custodyAccountAccessRepo.save(ownerAccess); + + return saved; + } + + // --- UPDATE --- // + async updateCustodyAccount( + custodyAccountId: number, + accountId: number, + title?: string, + description?: string, + ): Promise { + const { custodyAccount } = await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); + + Object.assign(custodyAccount, { title, description }); + + return this.custodyAccountRepo.save(custodyAccount); + } + + // --- GET ACCESS LIST --- // + async getAccessList(custodyAccountId: number, accountId: number): Promise { + await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); + + return this.custodyAccountAccessRepo.find({ + where: { account: { id: custodyAccountId } }, + relations: { userData: true }, + }); + } +} diff --git a/src/subdomains/core/custody/services/custody.service.ts b/src/subdomains/core/custody/services/custody.service.ts index 5b4d73eb7a..7f785cd0c8 100644 --- a/src/subdomains/core/custody/services/custody.service.ts +++ b/src/subdomains/core/custody/services/custody.service.ts @@ -12,7 +12,7 @@ import { WalletService } from 'src/subdomains/generic/user/models/wallet/wallet. import { AssetPricesService } from 'src/subdomains/supporting/pricing/services/asset-prices.service'; import { In } from 'typeorm'; import { RefService } from '../../referral/process/ref.service'; -import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { CustodySignupDto } from '../dto/input/custody-signup.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; import { CustodyBalanceDto, CustodyHistoryDto, CustodyHistoryEntryDto } from '../dto/output/custody-balance.dto'; import { CustodyBalance } from '../entities/custody-balance.entity'; @@ -41,7 +41,7 @@ export class CustodyService { ) {} // --- ACCOUNT --- // - async createCustodyAccount(accountId: number, dto: CreateCustodyAccountDto, userIp: string): Promise { + async createCustodyAccount(accountId: number, dto: CustodySignupDto, userIp: string): Promise { const ref = await this.refService.get(userIp); if (ref) dto.usedRef ??= ref.ref; diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 7f5b423fe9..58dd36249f 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -7,6 +7,8 @@ import { Language } from 'src/shared/models/language/language.entity'; import { Util } from 'src/shared/utils/util'; import { AmlListStatus } from 'src/subdomains/core/aml/enums/aml-list-status.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { CustodyAccountAccess } from 'src/subdomains/core/custody/entities/custody-account-access.entity'; +import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { FaucetRequest } from 'src/subdomains/core/faucet-request/entities/faucet-request.entity'; import { DefaultPaymentLinkConfig, @@ -377,6 +379,12 @@ export class UserData extends IEntity { @OneToMany(() => User, (user) => user.userData) users?: User[]; + @OneToMany(() => CustodyAccount, (account) => account.owner) + custodyAccounts?: CustodyAccount[]; + + @OneToMany(() => CustodyAccountAccess, (access) => access.userData) + custodyAccountAccesses?: CustodyAccountAccess[]; + // --- ENTITY METHODS --- // sendMail(): UpdateResult { this.blackSquadRecipientMail = this.mail; diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 8b150f0d7a..30b546e46d 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -7,6 +7,7 @@ import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { CustodyBalance } from 'src/subdomains/core/custody/entities/custody-balance.entity'; import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; +import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { CustodyAddressType } from 'src/subdomains/core/custody/enums/custody'; import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; @@ -154,6 +155,9 @@ export class User extends IEntity { @Column({ nullable: true }) custodyAddressType: CustodyAddressType; + @ManyToOne(() => CustodyAccount, { nullable: true }) + custodyAccount?: CustodyAccount; + @OneToMany(() => CustodyOrder, (custodyOrder) => custodyOrder.user) custodyOrders: CustodyOrder[]; From 482652fca6067447f52ce8927c9e9df45d41050d Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 12:13:58 +0100 Subject: [PATCH 2/9] fix: small fixes --- .../controllers/transaction.controller.ts | 31 ++++++++++++------- .../generic/kyc/services/kyc.service.ts | 2 +- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/subdomains/core/history/controllers/transaction.controller.ts b/src/subdomains/core/history/controllers/transaction.controller.ts index 099182832d..934e16e4eb 100644 --- a/src/subdomains/core/history/controllers/transaction.controller.ts +++ b/src/subdomains/core/history/controllers/transaction.controller.ts @@ -428,14 +428,24 @@ export class TransactionController { const refundData = this.refundList.get(transaction.id); if (!refundData) throw new BadRequestException('Request refund data first'); if (!this.isRefundDataValid(refundData)) throw new BadRequestException('Refund data request invalid'); + + await this.executeRefund(transaction, transaction.targetEntity, refundData, dto); + this.refundList.delete(transaction.id); + } + private async executeRefund( + transaction: Transaction, + targetEntity: BuyCrypto | BuyFiat | BankTxReturn | undefined, + refundData: RefundDataDto, + dto: TransactionRefundDto, + ): Promise { const inputCurrency = await this.transactionHelper.getRefundActive(transaction.refundTargetEntity); if (!inputCurrency.refundEnabled) throw new BadRequestException(`Refund for ${inputCurrency.name} not allowed`); const refundDto = { chargebackAmount: refundData.refundAmount, chargebackAllowedDateUser: new Date() }; - if (!transaction.targetEntity) { + if (!targetEntity) { transaction.bankTxReturn = await this.bankTxService .updateInternal(transaction.bankTx, { type: BankTxType.BANK_TX_RETURN }) .then((b) => b.bankTxReturn); @@ -443,10 +453,10 @@ export class TransactionController { const chargebackCurrency = refundData.refundAsset.name; - if (transaction.targetEntity instanceof BankTxReturn) { + if (targetEntity instanceof BankTxReturn) { if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds'); - return this.bankTxReturnService.refundBankTx(transaction.targetEntity, { + return this.bankTxReturnService.refundBankTx(targetEntity, { refundIban: dto.refundTarget ?? refundData.refundTarget, chargebackCurrency, creditorData: dto.creditorData, @@ -454,28 +464,27 @@ export class TransactionController { }); } - if (NotRefundableAmlReasons.includes(transaction.targetEntity.amlReason)) + if (NotRefundableAmlReasons.includes(targetEntity.amlReason)) throw new BadRequestException('You cannot refund with this reason'); - if (transaction.targetEntity instanceof BuyFiat) - return this.buyFiatService.refundBuyFiatInternal(transaction.targetEntity, { + if (targetEntity instanceof BuyFiat) + return this.buyFiatService.refundBuyFiatInternal(targetEntity, { refundUserAddress: dto.refundTarget, ...refundDto, }); - if (transaction.targetEntity.cryptoInput) - return this.buyCryptoService.refundCryptoInput(transaction.targetEntity, { + if (targetEntity.cryptoInput) + return this.buyCryptoService.refundCryptoInput(targetEntity, { refundUserAddress: dto.refundTarget, ...refundDto, }); - if (transaction.targetEntity.checkoutTx) - return this.buyCryptoService.refundCheckoutTx(transaction.targetEntity, { ...refundDto }); + if (targetEntity.checkoutTx) return this.buyCryptoService.refundCheckoutTx(targetEntity, { ...refundDto }); // BuyCrypto bank refund if (!dto.creditorData) throw new BadRequestException('Creditor data is required for bank refunds'); - return this.buyCryptoService.refundBankTx(transaction.targetEntity, { + return this.buyCryptoService.refundBankTx(targetEntity, { refundIban: dto.refundTarget ?? refundData.refundTarget, chargebackCurrency, creditorData: dto.creditorData, diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 878c1e2160..6064e6d09a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -432,7 +432,7 @@ export class KycService { async initializeProcess(userData: UserData): Promise { const user = await this.getUser(userData.kycHash); - if (user.hasDoneStep(KycStepName.CONTACT_DATA)) return user; + if (user.getStepsWith(KycStepName.CONTACT_DATA).length > 0) return user; return this.updateProgress(user, true, false); } From aceb4318e9117fc0ff8a31bff438a8f4a02e3ba9 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:57:41 +0100 Subject: [PATCH 3/9] fix: add MEXC and XT to LiquidityManagementExchanges list (#2940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEXC and XT were defined as LiquidityManagementSystem enums with full adapter implementations, but were missing from the LiquidityManagementExchanges array. This caused pendingExchangeOrders to not include MEXC/XT transfers, leading to temporary balance dips in FinancialDataLog when funds were transferred between exchanges (e.g., Binance → MEXC). Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> --- src/subdomains/core/liquidity-management/enums/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/subdomains/core/liquidity-management/enums/index.ts b/src/subdomains/core/liquidity-management/enums/index.ts index dc64476a48..b30ff2e77b 100644 --- a/src/subdomains/core/liquidity-management/enums/index.ts +++ b/src/subdomains/core/liquidity-management/enums/index.ts @@ -53,6 +53,8 @@ export enum LiquidityOptimizationType { export const LiquidityManagementExchanges = [ LiquidityManagementSystem.KRAKEN, LiquidityManagementSystem.BINANCE, + LiquidityManagementSystem.MEXC, + LiquidityManagementSystem.XT, LiquidityManagementSystem.SCRYPT, LiquidityManagementSystem.FRANKENCOIN, LiquidityManagementSystem.DEURO, From 045ff91033f9e4df22446d3c112243863783e483 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:19:46 +0100 Subject: [PATCH 4/9] feat: add ERC-1271 support for smart contract wallet authentication (#2937) * feat: add ERC-1271 support for smart contract wallet authentication Smart contract wallets (Gnosis Safe, Argent, EIP-4337 AA wallets) cannot sign messages with ECDSA since they have no private key. ERC-1271 defines a standard interface for contract-based signature verification. Changes: - Add ERC-1271 ABI (isValidSignature function) - Extend verifyEthereumBased() to detect contracts via getCode() - Add verifyErc1271Signature() for on-chain signature verification - Initialize Ethereum provider in CryptoService for RPC calls - Add unit tests for ERC-1271 logic Flow: 1. Check if address has bytecode (is a contract) 2. If contract: call isValidSignature(hash, signature) 3. If EOA: use standard ECDSA recovery (unchanged) * refactor: add logging and document multi-chain limitation for ERC-1271 - Add DfxLogger for debugging ERC-1271 verification - Log successful/failed signature verifications at verbose level - Document that ERC-1271 currently only supports Ethereum Mainnet - Smart contract wallets on other chains (Polygon, Arbitrum, etc.) are not yet supported * fix: handle RPC failures gracefully in ERC-1271 detection If getCode() fails (network error, rate limiting), fall back to EOA verification instead of failing the entire auth flow. This prevents temporary RPC issues from blocking all EVM wallet authentication. - Wrap getCode() in try-catch - Log warning on RPC failure - Fall back to standard EOA verification * style: fix prettier formatting in test file * feat: add multi-chain support for ERC-1271 contract wallet verification - Add optional `blockchain` parameter to SignInDto for specifying which chain to use for ERC-1271 smart contract verification - Initialize EVM providers for all supported chains on startup: Ethereum, Sepolia, Arbitrum, Optimism, Polygon, Base, Gnosis, BSC, Citrea - Pass blockchain parameter through AuthService to CryptoService - Use chain-specific provider for getCode() and isValidSignature() calls When a smart contract wallet signs on Polygon/Arbitrum/etc., the frontend can now pass `blockchain: "Polygon"` to ensure verification uses the correct chain's RPC. * fix: restrict blockchain parameter validation to EVM chains only Change validation from @IsEnum(Blockchain) to @IsIn(EvmBlockchains) to match the API documentation. Only EVM chains are valid for ERC-1271 smart contract wallet verification. * fix: pass blockchain parameter through companySignIn flow The verifyCompanySignature method was not passing the blockchain parameter to cryptoService.verifySignature, breaking ERC-1271 support for company/KYC-client sign-ins. * feat: refactoring * feat: refactoring 2 --------- Co-authored-by: David May --- .../shared/__test__/erc1271-signature.spec.ts | 67 ++++++++++++++++++ .../shared/evm/abi/erc1271.abi.json | 26 +++++++ .../blockchain/shared/evm/evm-client.ts | 16 +++++ .../shared/services/crypto.service.ts | 69 ++++++++++++++----- .../generic/user/models/auth/auth.service.ts | 26 +++++-- .../models/auth/dto/auth-credentials.dto.ts | 13 +++- 6 files changed, 192 insertions(+), 25 deletions(-) create mode 100644 src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts create mode 100644 src/integration/blockchain/shared/evm/abi/erc1271.abi.json diff --git a/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts b/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts new file mode 100644 index 0000000000..8b95c93bf2 --- /dev/null +++ b/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts @@ -0,0 +1,67 @@ +import { ethers } from 'ethers'; +import { hashMessage } from 'ethers/lib/utils'; + +// ERC-1271 Magic Values +const ERC1271_MAGIC_VALUE = '0x1626ba7e'; +const ERC1271_INVALID_VALUE = '0xffffffff'; + +describe('ERC-1271 Signature Verification', () => { + describe('hashMessage', () => { + it('should hash message correctly for ERC-1271', () => { + const message = + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_0x623777Cc098C6058a46cF7530f45150ff6a8459D'; + const hash = hashMessage(message); + + // Hash should be a 32-byte hex string + expect(hash).toMatch(/^0x[a-fA-F0-9]{64}$/); + }); + }); + + describe('ERC-1271 interface', () => { + it('should define correct magic value', () => { + expect(ERC1271_MAGIC_VALUE).toBe('0x1626ba7e'); + }); + + it('should define correct invalid value', () => { + expect(ERC1271_INVALID_VALUE).toBe('0xffffffff'); + }); + + it('should create correct function selector for isValidSignature', () => { + const iface = new ethers.utils.Interface([ + 'function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)', + ]); + const selector = iface.getSighash('isValidSignature'); + + // isValidSignature(bytes32,bytes) selector + expect(selector).toBe('0x1626ba7e'); + }); + }); + + describe('EOA vs Contract detection', () => { + it('should identify EOA by empty code', () => { + const eoaCode: string = '0x'; + const isEoa = eoaCode === '0x'; + expect(isEoa).toBe(true); + }); + + it('should identify contract by non-empty code', () => { + // Sample bytecode (just needs to be non-empty) + const contractCode: string = '0x608060405234801561001057600080fd5b50'; + const isContract = contractCode !== '0x'; + expect(isContract).toBe(true); + }); + }); + + describe('Signature format', () => { + it('should normalize signature with 0x prefix', () => { + const sigWithoutPrefix = 'abc123'; + const sigWithPrefix = '0xabc123'; + + const normalized1 = sigWithoutPrefix.startsWith('0x') ? sigWithoutPrefix : '0x' + sigWithoutPrefix; + const normalized2 = sigWithPrefix.startsWith('0x') ? sigWithPrefix : '0x' + sigWithPrefix; + + expect(normalized1).toBe('0xabc123'); + expect(normalized2).toBe('0xabc123'); + }); + }); +}); diff --git a/src/integration/blockchain/shared/evm/abi/erc1271.abi.json b/src/integration/blockchain/shared/evm/abi/erc1271.abi.json new file mode 100644 index 0000000000..a4c0270980 --- /dev/null +++ b/src/integration/blockchain/shared/evm/abi/erc1271.abi.json @@ -0,0 +1,26 @@ +[ + { + "inputs": [ + { + "internalType": "bytes32", + "name": "hash", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "signature", + "type": "bytes" + } + ], + "name": "isValidSignature", + "outputs": [ + { + "internalType": "bytes4", + "name": "", + "type": "bytes4" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 8d9d4da8d1..7db6a69bf5 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -7,7 +7,9 @@ import { FeeAmount, MethodParameters, Pool, Route, SwapQuoter, Trade } from '@un import { AssetTransfersCategory, AssetTransfersWithMetadataResult, BigNumberish } from 'alchemy-sdk'; import BigNumber from 'bignumber.js'; import { Contract, BigNumber as EthersNumber, ethers } from 'ethers'; +import { hashMessage } from 'ethers/lib/utils'; import { AlchemyService, AssetTransfersParams } from 'src/integration/alchemy/services/alchemy.service'; +import ERC1271_ABI from 'src/integration/blockchain/shared/evm/abi/erc1271.abi.json'; import ERC20_ABI from 'src/integration/blockchain/shared/evm/abi/erc20.abi.json'; import SIGNATURE_TRANSFER_ABI from 'src/integration/blockchain/shared/evm/abi/signature-transfer.abi.json'; import UNISWAP_V3_NFT_MANAGER_ABI from 'src/integration/blockchain/shared/evm/abi/uniswap-v3-nft-manager.abi.json'; @@ -398,6 +400,20 @@ export abstract class EvmClient extends BlockchainClient { return this.provider.getTransactionReceipt(txHash); } + async isContract(address: string): Promise { + const code = await this.provider.getCode(address); + return code !== '0x'; + } + + async verifyErc1271Signature(message: string, address: string, signature: string): Promise { + const ERC1271_MAGIC_VALUE = '0x1626ba7e'; + + const hash = hashMessage(message); + const contract = new Contract(address, ERC1271_ABI, this.provider); + const result = await contract.isValidSignature(hash, signature); + return result === ERC1271_MAGIC_VALUE; + } + // got from https://gist.github.com/gluk64/fdea559472d957f1138ed93bcbc6f78a async getTxError(txHash: string): Promise { const tx = await this.getTx(txHash); diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 9ad594c177..8691cdb491 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -11,17 +11,18 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { UserAddressType } from 'src/subdomains/generic/user/models/user/user.enum'; import { ArweaveService } from '../../arweave/services/arweave.service'; import { BitcoinService } from '../../bitcoin/node/bitcoin.service'; +import { CardanoService } from '../../cardano/services/cardano.service'; import { LiquidHelper } from '../../liquid/liquid-helper'; import { MoneroService } from '../../monero/services/monero.service'; import { SolanaService } from '../../solana/services/solana.service'; import { SparkService } from '../../spark/spark.service'; import { TronService } from '../../tron/services/tron.service'; -import { CardanoService } from '../../cardano/services/cardano.service'; import { ZanoService } from '../../zano/services/zano.service'; import { Blockchain } from '../enums/blockchain.enum'; import { EvmUtil } from '../evm/evm.util'; import { SignatureException } from '../exceptions/signature.exception'; import { EvmBlockchains, TestBlockchains } from '../util/blockchain.util'; +import { BlockchainRegistryService } from './blockchain-registry.service'; @Injectable() export class CryptoService { @@ -38,6 +39,7 @@ export class CryptoService { private readonly cardanoService: CardanoService, private readonly arweaveService: ArweaveService, private readonly railgunService: RailgunService, + private readonly blockchainRegistry: BlockchainRegistryService, ) {} // --- PAYMENT REQUEST --- // @@ -232,22 +234,29 @@ export class CryptoService { } // --- SIGNATURE VERIFICATION --- // - public async verifySignature(message: string, address: string, signature: string, key?: string): Promise { - const blockchain = CryptoService.getDefaultBlockchainBasedOn(address); + public async verifySignature( + message: string, + address: string, + signature: string, + key?: string, + blockchain?: Blockchain, + ): Promise { + const detectedBlockchain = CryptoService.getDefaultBlockchainBasedOn(address); try { - if (EvmBlockchains.includes(blockchain)) return this.verifyEthereumBased(message, address, signature); - if (blockchain === Blockchain.BITCOIN) return this.verifyBitcoinBased(message, address, signature, null); - if (blockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); - if (blockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); - if (blockchain === Blockchain.MONERO) return await this.verifyMonero(message, address, signature); - if (blockchain === Blockchain.ZANO) return await this.verifyZano(message, address, signature); - if (blockchain === Blockchain.SOLANA) return await this.verifySolana(message, address, signature); - if (blockchain === Blockchain.TRON) return await this.verifyTron(message, address, signature); - if (blockchain === Blockchain.LIQUID) return this.verifyLiquid(message, address, signature); - if (blockchain === Blockchain.ARWEAVE) return await this.verifyArweave(message, signature, key); - if (blockchain === Blockchain.CARDANO) return this.verifyCardano(message, address, signature, key); - if (blockchain === Blockchain.RAILGUN) return await this.verifyRailgun(message, address, signature); + if (EvmBlockchains.includes(detectedBlockchain)) + return await this.verifyEthereumBased(message, address, signature, blockchain ?? detectedBlockchain); + if (detectedBlockchain === Blockchain.BITCOIN) return this.verifyBitcoinBased(message, address, signature, null); + if (detectedBlockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); + if (detectedBlockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); + if (detectedBlockchain === Blockchain.MONERO) return await this.verifyMonero(message, address, signature); + if (detectedBlockchain === Blockchain.ZANO) return await this.verifyZano(message, address, signature); + if (detectedBlockchain === Blockchain.SOLANA) return await this.verifySolana(message, address, signature); + if (detectedBlockchain === Blockchain.TRON) return await this.verifyTron(message, address, signature); + if (detectedBlockchain === Blockchain.LIQUID) return this.verifyLiquid(message, address, signature); + if (detectedBlockchain === Blockchain.ARWEAVE) return await this.verifyArweave(message, signature, key); + if (detectedBlockchain === Blockchain.CARDANO) return this.verifyCardano(message, address, signature, key); + if (detectedBlockchain === Blockchain.RAILGUN) return await this.verifyRailgun(message, address, signature); } catch (e) { if (e instanceof SignatureException) throw new BadRequestException(e.message); } @@ -255,10 +264,36 @@ export class CryptoService { return false; } - private verifyEthereumBased(message: string, address: string, signature: string): boolean { + private async verifyEthereumBased( + message: string, + address: string, + signature: string, + blockchain: Blockchain, + ): Promise { // there are signatures out there, which do not have '0x' in the beginning, but for verification this is needed const signatureToUse = signature.startsWith('0x') ? signature : '0x' + signature; - return verifyMessage(message, signatureToUse).toLowerCase() === address.toLowerCase(); + + if (this.verifyEoaSignature(message, address, signatureToUse)) return true; + + // Fallback to ERC-1271 for smart contract wallets + try { + const client = this.blockchainRegistry.getEvmClient(blockchain); + if (await client.isContract(address)) { + return await client.verifyErc1271Signature(message, address, signatureToUse); + } + } catch { + // ignore + } + + return false; + } + + private verifyEoaSignature(message: string, address: string, signature: string): boolean { + try { + return verifyMessage(message, signature).toLowerCase() === address.toLowerCase(); + } catch { + return false; + } } private verifyBitcoinBased(message: string, address: string, signature: string, prefix: string | null): boolean { diff --git a/src/subdomains/generic/user/models/auth/auth.service.ts b/src/subdomains/generic/user/models/auth/auth.service.ts index cbc95ff3c1..9d3520fed1 100644 --- a/src/subdomains/generic/user/models/auth/auth.service.ts +++ b/src/subdomains/generic/user/models/auth/auth.service.ts @@ -147,7 +147,10 @@ export class AuthService { const primaryUser = userId && (await this.userService.getUser(userId)); const custodyProvider = await this.custodyProviderService.getWithMasterKey(dto.signature).catch(() => undefined); - if (!custodyProvider && !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key))) { + if ( + !custodyProvider && + !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, undefined, dto.blockchain)) + ) { throw new BadRequestException('Invalid signature'); } @@ -205,7 +208,9 @@ export class AuthService { private async doSignIn(user: User, dto: SignInDto, userIp: string, isCustodial: boolean) { if (!user.custodyProvider || user.custodyProvider.masterKey !== dto.signature) { - if (!(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, user.signature))) { + if ( + !(await this.verifySignature(dto.address, dto.signature, isCustodial, dto.key, user.signature, dto.blockchain)) + ) { throw new UnauthorizedException('Invalid credentials'); } else if (!user.signature) { // TODO: temporary code to update empty signatures (remove?) @@ -340,7 +345,7 @@ export class AuthService { const wallet = await this.walletService.getByAddress(dto.address); if (!wallet?.isKycClient) throw new NotFoundException('Wallet not found'); - if (!(await this.verifyCompanySignature(dto.address, dto.signature, dto.key))) + if (!(await this.verifyCompanySignature(dto.address, dto.signature, dto.key, dto.blockchain))) throw new UnauthorizedException('Invalid credentials'); return { accessToken: this.generateCompanyToken(wallet, ip) }; @@ -438,6 +443,7 @@ export class AuthService { isCustodial: boolean, key?: string, dbSignature?: string, + blockchain?: Blockchain, ): Promise { const { defaultMessage, fallbackMessage } = this.getSignMessages(address); @@ -453,18 +459,24 @@ export class AuthService { return dbSignature && signature === dbSignature; } - let isValid = await this.cryptoService.verifySignature(defaultMessage, address, signature, key); - if (!isValid) isValid = await this.cryptoService.verifySignature(fallbackMessage, address, signature, key); + let isValid = await this.cryptoService.verifySignature(defaultMessage, address, signature, key, blockchain); + if (!isValid) + isValid = await this.cryptoService.verifySignature(fallbackMessage, address, signature, key, blockchain); return isValid; } - private async verifyCompanySignature(address: string, signature: string, key?: string): Promise { + private async verifyCompanySignature( + address: string, + signature: string, + key?: string, + blockchain?: Blockchain, + ): Promise { const challengeData = this.challengeList.get(address); if (!this.isChallengeValid(challengeData)) throw new UnauthorizedException('Challenge invalid'); this.challengeList.delete(address); - return this.cryptoService.verifySignature(challengeData.challenge, address, signature, key); + return this.cryptoService.verifySignature(challengeData.challenge, address, signature, key, blockchain); } private hasChallenge(address: string): boolean { 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 7fc379a1db..71e60a97d2 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,7 +1,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Matches, ValidateIf } from 'class-validator'; +import { IsEnum, IsIn, 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'; +import { EvmBlockchains } from 'src/integration/blockchain/shared/util/blockchain.util'; import { Moderator } from '../../user-data/user-data.enum'; import { WalletType } from '../../user/user.enum'; @@ -27,6 +29,15 @@ export class SignInDto { ) key?: string; + @ApiPropertyOptional({ + description: + 'Blockchain for smart contract wallet signature verification (ERC-1271). Required for contract wallets on non-Ethereum chains.', + enum: EvmBlockchains, + }) + @IsOptional() + @IsIn(EvmBlockchains) + blockchain?: Blockchain; + @ApiPropertyOptional({ description: 'This field is deprecated, use "specialCode" instead.', deprecated: true }) @IsOptional() @IsString() From 4c55cc9bc6593f528551d8c0bb2ce236a83c9c80 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 13:23:09 +0100 Subject: [PATCH 5/9] fix: tests --- .../services/__tests__/crypto.service.spec.ts | 52 +++++-------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index ae49b2970c..191bb46442 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -4,6 +4,7 @@ import { ArweaveService } from 'src/integration/blockchain/arweave/services/arwe import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { SparkService } from 'src/integration/blockchain/spark/spark.service'; @@ -16,53 +17,24 @@ import { UserAddressType } from 'src/subdomains/generic/user/models/user/user.en import { BitcoinService } from '../../node/bitcoin.service'; describe('CryptoService', () => { - let service: CryptoService; - - let bitcoinService: BitcoinService; - let lightningService: LightningService; - let sparkService: SparkService; - let moneroService: MoneroService; - let zanoService: ZanoService; - let solanaService: SolanaService; - let tronService: TronService; - let cardanoService: CardanoService; - let arweaveService: ArweaveService; - let railgunService: RailgunService; - beforeEach(async () => { - bitcoinService = createMock(); - lightningService = createMock(); - sparkService = createMock(); - moneroService = createMock(); - zanoService = createMock(); - solanaService = createMock(); - tronService = createMock(); - cardanoService = createMock(); - arweaveService = createMock(); - railgunService = createMock(); - const module: TestingModule = await Test.createTestingModule({ providers: [ CryptoService, - { provide: BitcoinService, useValue: bitcoinService }, - { provide: LightningService, useValue: lightningService }, - { provide: SparkService, useValue: sparkService }, - { provide: MoneroService, useValue: moneroService }, - { provide: ZanoService, useValue: zanoService }, - { provide: SolanaService, useValue: solanaService }, - { provide: TronService, useValue: tronService }, - { provide: CardanoService, useValue: cardanoService }, - { provide: ArweaveService, useValue: arweaveService }, - { provide: RailgunService, useValue: railgunService }, + { provide: BitcoinService, useValue: createMock() }, + { provide: LightningService, useValue: createMock() }, + { provide: SparkService, useValue: createMock() }, + { provide: MoneroService, useValue: createMock() }, + { provide: ZanoService, useValue: createMock() }, + { provide: SolanaService, useValue: createMock() }, + { provide: TronService, useValue: createMock() }, + { provide: CardanoService, useValue: createMock() }, + { provide: ArweaveService, useValue: createMock() }, + { provide: RailgunService, useValue: createMock() }, + { provide: BlockchainRegistryService, useValue: createMock() }, TestUtil.provideConfig(), ], }).compile(); - - service = module.get(CryptoService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); }); it('should return Blockchain.BITCOIN for address bc1q4mzpjac5e53dmgnq54j58klvldhme39ed71234', () => { From 0a255fd14211750a4e7cf1c0ddda712a44793583 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 13:58:53 +0100 Subject: [PATCH 6/9] fix: removed useless test --- .../shared/__test__/erc1271-signature.spec.ts | 67 ------------------- .../core/buy-crypto/routes/buy/buy.service.ts | 4 +- .../routes/buy/dto/buy-payment-info.dto.ts | 4 +- 3 files changed, 4 insertions(+), 71 deletions(-) delete mode 100644 src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts diff --git a/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts b/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts deleted file mode 100644 index 8b95c93bf2..0000000000 --- a/src/integration/blockchain/shared/__test__/erc1271-signature.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ethers } from 'ethers'; -import { hashMessage } from 'ethers/lib/utils'; - -// ERC-1271 Magic Values -const ERC1271_MAGIC_VALUE = '0x1626ba7e'; -const ERC1271_INVALID_VALUE = '0xffffffff'; - -describe('ERC-1271 Signature Verification', () => { - describe('hashMessage', () => { - it('should hash message correctly for ERC-1271', () => { - const message = - 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_0x623777Cc098C6058a46cF7530f45150ff6a8459D'; - const hash = hashMessage(message); - - // Hash should be a 32-byte hex string - expect(hash).toMatch(/^0x[a-fA-F0-9]{64}$/); - }); - }); - - describe('ERC-1271 interface', () => { - it('should define correct magic value', () => { - expect(ERC1271_MAGIC_VALUE).toBe('0x1626ba7e'); - }); - - it('should define correct invalid value', () => { - expect(ERC1271_INVALID_VALUE).toBe('0xffffffff'); - }); - - it('should create correct function selector for isValidSignature', () => { - const iface = new ethers.utils.Interface([ - 'function isValidSignature(bytes32 hash, bytes signature) view returns (bytes4)', - ]); - const selector = iface.getSighash('isValidSignature'); - - // isValidSignature(bytes32,bytes) selector - expect(selector).toBe('0x1626ba7e'); - }); - }); - - describe('EOA vs Contract detection', () => { - it('should identify EOA by empty code', () => { - const eoaCode: string = '0x'; - const isEoa = eoaCode === '0x'; - expect(isEoa).toBe(true); - }); - - it('should identify contract by non-empty code', () => { - // Sample bytecode (just needs to be non-empty) - const contractCode: string = '0x608060405234801561001057600080fd5b50'; - const isContract = contractCode !== '0x'; - expect(isContract).toBe(true); - }); - }); - - describe('Signature format', () => { - it('should normalize signature with 0x prefix', () => { - const sigWithoutPrefix = 'abc123'; - const sigWithPrefix = '0xabc123'; - - const normalized1 = sigWithoutPrefix.startsWith('0x') ? sigWithoutPrefix : '0x' + sigWithoutPrefix; - const normalized2 = sigWithPrefix.startsWith('0x') ? sigWithPrefix : '0x' + sigWithPrefix; - - expect(normalized1).toBe('0xabc123'); - expect(normalized2).toBe('0xabc123'); - }); - }); -}); diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts index f0480bbae4..5031a1702e 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.service.ts @@ -377,7 +377,7 @@ export class BuyService { return { name: selector.userData.completeName, street: address.street, - number: address.houseNumber, + ...(address.houseNumber && { number: address.houseNumber }), zip: address.zip, city: address.city, country: address.country?.name, @@ -399,7 +399,7 @@ export class BuyService { return { name: selector.userData.completeName, street: address.street, - number: address.houseNumber, + ...(address.houseNumber && { number: address.houseNumber }), zip: address.zip, city: address.city, country: address.country?.name, diff --git a/src/subdomains/core/buy-crypto/routes/buy/dto/buy-payment-info.dto.ts b/src/subdomains/core/buy-crypto/routes/buy/dto/buy-payment-info.dto.ts index db1205633f..cde563d7a5 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/dto/buy-payment-info.dto.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/dto/buy-payment-info.dto.ts @@ -16,8 +16,8 @@ export class BankInfoDto { @ApiProperty() street: string; - @ApiProperty() - number: string; + @ApiPropertyOptional() + number?: string; @ApiProperty() zip: string; From b488e146c7a3ea5da098e140bba54e1c07f5619e Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:59:58 +0100 Subject: [PATCH 7/9] [DEV-4532] auto set usedRef with recommendation (#2832) * [DEV-4532] auto set usedRef with recommendation * [DEV-4532] Refactoring * [DEV-4532] test defaultRef as usedRef --- .../recommendation/recommendation.service.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts index 83d7e6233b..ca5703e0eb 100644 --- a/src/subdomains/generic/user/models/recommendation/recommendation.service.ts +++ b/src/subdomains/generic/user/models/recommendation/recommendation.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException import { Config } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; +import { KycRecommendationData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; import { MailKey, MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; @@ -166,7 +167,7 @@ export class RecommendationService { async confirmRecommendation(userDataId: number, id: number, isConfirmed: boolean): Promise { const entity = await this.recommendationRepo.findOne({ where: { id }, - relations: { recommender: true, recommended: true }, + relations: { recommender: { users: true }, recommended: { users: true }, kycStep: true }, }); if (!entity) throw new NotFoundException('Recommendation not found'); if (entity.recommender.id !== userDataId) @@ -191,18 +192,33 @@ export class RecommendationService { async updateRecommendationInternal(entity: Recommendation, update: Partial): Promise { Object.assign(entity, update); - if (update.isConfirmed && entity.recommended) - await this.userDataService.updateUserDataInternal(update.recommended ?? entity.recommended, { + if (update.isConfirmed && entity.recommended) { + await this.userDataService.updateUserDataInternal(entity.recommended, { tradeApprovalDate: new Date(), }); + const refCode = + entity.kycStep && entity.method === RecommendationMethod.REF_CODE + ? entity.kycStep.getResult().key + : (entity.recommender.users.find((u) => u.ref).ref ?? Config.defaultRef); + + for (const user of entity.recommended.users ?? + (await this.userService.getAllUserDataUsers(entity.recommended.id))) { + if (user.usedRef === Config.defaultRef) await this.userService.updateUserInternal(user, { usedRef: refCode }); + } + } + return this.recommendationRepo.save(entity); } async getAndCheckRecommendationByCode(code: string): Promise { const entity = await this.recommendationRepo.findOne({ where: { code }, - relations: { recommended: true, recommender: true }, + relations: { + recommended: { users: true }, + recommender: { users: true }, + kycStep: true, + }, }); if (!entity) throw new BadRequestException('Recommendation code not found'); if (entity.isExpired) throw new BadRequestException('Recommendation code is expired'); @@ -223,7 +239,7 @@ export class RecommendationService { async checkAndConfirmRecommendInvitation(recommendedId: number): Promise { const entity = await this.recommendationRepo.findOne({ where: { recommended: { id: recommendedId }, isConfirmed: IsNull(), expirationDate: MoreThan(new Date()) }, - relations: { recommended: true, recommender: true }, + relations: { recommended: { users: true }, recommender: { users: true }, kycStep: true }, }); if ( !entity || From ded860a5ceac87827a9514c01f645dbe05f27c4e Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 14:08:49 +0100 Subject: [PATCH 8/9] fix: fixed linter errors --- .github/workflows/pr-review-bot.yml | 12 ++++++------ .../services/__tests__/crypto.service.spec.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-review-bot.yml b/.github/workflows/pr-review-bot.yml index e2573e003c..67ef5d4237 100644 --- a/.github/workflows/pr-review-bot.yml +++ b/.github/workflows/pr-review-bot.yml @@ -55,18 +55,18 @@ jobs: continue-on-error: true run: | npm run lint 2>&1 | tee eslint-output.txt || true - WARNINGS=$(grep -c "warning" eslint-output.txt || echo "0") - ERRORS=$(grep -c "error" eslint-output.txt || echo "0") - echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT - echo "errors=$ERRORS" >> $GITHUB_OUTPUT + WARNINGS=$(grep -c "warning" eslint-output.txt || true) + ERRORS=$(grep -c "error" eslint-output.txt || true) + echo "warnings=${WARNINGS:-0}" >> $GITHUB_OUTPUT + echo "errors=${ERRORS:-0}" >> $GITHUB_OUTPUT - name: Run TypeScript check id: typescript continue-on-error: true run: | npx tsc --noEmit 2>&1 | tee tsc-output.txt || true - ERRORS=$(grep -c "error TS" tsc-output.txt || echo "0") - echo "errors=$ERRORS" >> $GITHUB_OUTPUT + ERRORS=$(grep -c "error TS" tsc-output.txt || true) + echo "errors=${ERRORS:-0}" >> $GITHUB_OUTPUT - name: Security Audit id: audit diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index 191bb46442..93828e07ea 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -18,7 +18,7 @@ import { BitcoinService } from '../../node/bitcoin.service'; describe('CryptoService', () => { beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ + await Test.createTestingModule({ providers: [ CryptoService, { provide: BitcoinService, useValue: createMock() }, From d4b34fd48f216ae30f6f39bc64788c880b995920 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 14:12:41 +0100 Subject: [PATCH 9/9] fix: fixed linter errors 2 --- .../bitcoin/services/__tests__/crypto.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index 93828e07ea..9e7c5a491d 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -1,5 +1,5 @@ import { createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; import { ArweaveService } from 'src/integration/blockchain/arweave/services/arweave.service'; import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service';