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/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/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index ae49b2970c..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,9 +1,10 @@ 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'; 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({ + 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', () => { 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/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; 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/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/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, 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); } 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() 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 || 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[];