From 001b6501a0a7f1ac0b6f0e20ac347aca5dec6846 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 9 Dec 2025 02:29:31 -0800 Subject: [PATCH] feat(deposit): add deposit balance API and Kafka consumer for deposit events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blockchain Service: - Add /api/v1/deposit/balances endpoint to query on-chain USDT balances - Add JWT authentication (passport, passport-jwt) - Add JwtStrategy, JwtAuthGuard, Public decorator Wallet Service: - Add Kafka consumer for blockchain.deposits topic - Add DepositConfirmedHandler to process deposit events and update wallet balance Infrastructure: - Add JWT_SECRET env var to blockchain-service in docker-compose.yml - Add blockchain-service routes to Kong API Gateway Frontend: - Fix deposit_service.dart API path (remove duplicate /api prefix) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../blockchain-service/package-lock.json | 214 +++++++++++++++++- .../services/blockchain-service/package.json | 5 + .../blockchain-service/src/api/api.module.ts | 22 +- .../src/api/controllers/deposit.controller.ts | 123 ++++++++++ .../src/api/controllers/index.ts | 1 + .../src/shared/decorators/index.ts | 1 + .../src/shared/decorators/public.decorator.ts | 4 + .../src/shared/guards/jwt-auth.guard.ts | 22 ++ .../blockchain-service/src/shared/index.ts | 1 + .../src/shared/strategies/jwt.strategy.ts | 32 +++ backend/services/docker-compose.yml | 21 +- .../wallet-service/src/api/api.module.ts | 4 + .../deposit-confirmed.handler.ts | 84 +++++++ .../src/application/event-handlers/index.ts | 1 + .../kafka/deposit-event-consumer.service.ts | 152 +++++++++++++ .../kafka/event-publisher.service.ts | 116 ++++++++++ .../src/infrastructure/kafka/index.ts | 3 + .../src/infrastructure/kafka/kafka.module.ts | 10 + .../lib/core/services/deposit_service.dart | 2 +- 19 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 backend/services/blockchain-service/src/api/controllers/deposit.controller.ts create mode 100644 backend/services/blockchain-service/src/shared/decorators/index.ts create mode 100644 backend/services/blockchain-service/src/shared/decorators/public.decorator.ts create mode 100644 backend/services/blockchain-service/src/shared/guards/jwt-auth.guard.ts create mode 100644 backend/services/blockchain-service/src/shared/strategies/jwt.strategy.ts create mode 100644 backend/services/wallet-service/src/application/event-handlers/deposit-confirmed.handler.ts create mode 100644 backend/services/wallet-service/src/application/event-handlers/index.ts create mode 100644 backend/services/wallet-service/src/infrastructure/kafka/deposit-event-consumer.service.ts create mode 100644 backend/services/wallet-service/src/infrastructure/kafka/event-publisher.service.ts create mode 100644 backend/services/wallet-service/src/infrastructure/kafka/index.ts create mode 100644 backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts diff --git a/backend/services/blockchain-service/package-lock.json b/backend/services/blockchain-service/package-lock.json index 525e9cc9..5bee135a 100644 --- a/backend/services/blockchain-service/package-lock.json +++ b/backend/services/blockchain-service/package-lock.json @@ -13,7 +13,9 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^10.0.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.17", @@ -27,6 +29,8 @@ "ethers": "^6.9.0", "ioredis": "^5.3.2", "kafkajs": "^2.2.4", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "uuid": "^9.0.0" @@ -39,6 +43,7 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", "@types/uuid": "^9.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -1840,6 +1845,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", @@ -1919,6 +1937,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", + "integrity": "sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", @@ -2554,6 +2582,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", @@ -2578,13 +2615,44 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3711,6 +3779,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4537,6 +4611,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7161,6 +7244,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kafkajs": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", @@ -7270,12 +7396,48 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7290,6 +7452,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7913,6 +8081,43 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7990,6 +8195,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8710,7 +8920,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9886,7 +10095,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/backend/services/blockchain-service/package.json b/backend/services/blockchain-service/package.json index 2bb3006f..d1cb0475 100644 --- a/backend/services/blockchain-service/package.json +++ b/backend/services/blockchain-service/package.json @@ -32,10 +32,14 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", "@nestjs/microservices": "^10.0.0", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.17", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "@prisma/client": "^5.7.0", "@scure/bip32": "^1.3.2", "@scure/bip39": "^1.6.0", @@ -56,6 +60,7 @@ "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^6.0.0", "@types/express": "^4.17.17", + "@types/passport-jwt": "^4.0.1", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", diff --git a/backend/services/blockchain-service/src/api/api.module.ts b/backend/services/blockchain-service/src/api/api.module.ts index e19922dd..18632e35 100644 --- a/backend/services/blockchain-service/src/api/api.module.ts +++ b/backend/services/blockchain-service/src/api/api.module.ts @@ -1,10 +1,26 @@ import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { ApplicationModule } from '@/application/application.module'; import { DomainModule } from '@/domain/domain.module'; -import { HealthController, BalanceController, InternalController } from './controllers'; +import { HealthController, BalanceController, InternalController, DepositController } from './controllers'; +import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; @Module({ - imports: [ApplicationModule, DomainModule], - controllers: [HealthController, BalanceController, InternalController], + imports: [ + ApplicationModule, + DomainModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET') || 'default-secret', + signOptions: { expiresIn: '7d' }, + }), + }), + ], + controllers: [HealthController, BalanceController, InternalController, DepositController], + providers: [JwtStrategy], }) export class ApiModule {} diff --git a/backend/services/blockchain-service/src/api/controllers/deposit.controller.ts b/backend/services/blockchain-service/src/api/controllers/deposit.controller.ts new file mode 100644 index 00000000..8b2607c9 --- /dev/null +++ b/backend/services/blockchain-service/src/api/controllers/deposit.controller.ts @@ -0,0 +1,123 @@ +/** + * Deposit Controller + * + * Provides deposit-related endpoints for the mobile app. + * Queries on-chain USDT balances for user's monitored addresses. + */ + +import { Controller, Get, Logger, Inject, UseGuards, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { Request } from 'express'; +import { BalanceQueryService } from '@/application/services/balance-query.service'; +import { ChainTypeEnum } from '@/domain/enums'; +import { + IMonitoredAddressRepository, + MONITORED_ADDRESS_REPOSITORY, +} from '@/domain/repositories/monitored-address.repository.interface'; +import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard'; + +interface JwtPayload { + userId: string; + accountSequence: string; + deviceId: string; +} + +interface UsdtBalanceDto { + chainType: string; + address: string; + balance: string; + rawBalance: string; + decimals: number; +} + +interface DepositBalancesResponseDto { + kava: UsdtBalanceDto | null; + bsc: UsdtBalanceDto | null; +} + +@ApiTags('Deposit') +@Controller('deposit') +export class DepositController { + private readonly logger = new Logger(DepositController.name); + + constructor( + private readonly balanceService: BalanceQueryService, + @Inject(MONITORED_ADDRESS_REPOSITORY) + private readonly monitoredAddressRepo: IMonitoredAddressRepository, + ) {} + + @Get('balances') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '查询用户 USDT 余额' }) + @ApiResponse({ status: 200, description: '余额信息' }) + async getBalances(@Req() req: Request): Promise { + const user = (req as Request & { user: JwtPayload }).user; + const userId = BigInt(user.userId); + + this.logger.log(`Querying deposit balances for user ${userId}`); + + // Get user's monitored addresses + const addresses = await this.monitoredAddressRepo.findByUserId(userId); + + const response: DepositBalancesResponseDto = { + kava: null, + bsc: null, + }; + + // Query balance for each chain + for (const addr of addresses) { + try { + const chainType = addr.chainType; + const addressStr = addr.address.toString(); + const chainTypeStr = chainType.toString(); + + const balance = await this.balanceService.getBalance(chainType, addressStr); + + const balanceDto: UsdtBalanceDto = { + chainType: chainTypeStr, + address: addressStr, + balance: balance.usdtBalance, + rawBalance: this.toRawBalance(balance.usdtBalance, this.getDecimals(chainTypeStr)), + decimals: this.getDecimals(chainTypeStr), + }; + + if (chainTypeStr === ChainTypeEnum.KAVA) { + response.kava = balanceDto; + } else if (chainTypeStr === ChainTypeEnum.BSC) { + response.bsc = balanceDto; + } + } catch (error) { + this.logger.error(`Error querying balance for ${addr.chainType}:${addr.address}`, error); + } + } + + this.logger.log( + `Balance query complete for user ${userId}: ` + + `KAVA=${response.kava?.balance || '0'}, BSC=${response.bsc?.balance || '0'}`, + ); + + return response; + } + + private getDecimals(chainType: string): number { + // USDT decimals by chain + switch (chainType) { + case ChainTypeEnum.KAVA: + return 18; // Our test USDT on KAVA uses 18 decimals + case ChainTypeEnum.BSC: + return 18; // Standard USDT on BSC + default: + return 18; + } + } + + private toRawBalance(formattedBalance: string, decimals: number): string { + try { + const value = parseFloat(formattedBalance); + return BigInt(Math.floor(value * Math.pow(10, decimals))).toString(); + } catch { + return '0'; + } + } +} diff --git a/backend/services/blockchain-service/src/api/controllers/index.ts b/backend/services/blockchain-service/src/api/controllers/index.ts index b547aa16..e499c508 100644 --- a/backend/services/blockchain-service/src/api/controllers/index.ts +++ b/backend/services/blockchain-service/src/api/controllers/index.ts @@ -1,3 +1,4 @@ export * from './health.controller'; export * from './balance.controller'; export * from './internal.controller'; +export * from './deposit.controller'; diff --git a/backend/services/blockchain-service/src/shared/decorators/index.ts b/backend/services/blockchain-service/src/shared/decorators/index.ts new file mode 100644 index 00000000..3f75d993 --- /dev/null +++ b/backend/services/blockchain-service/src/shared/decorators/index.ts @@ -0,0 +1 @@ +export * from './public.decorator'; diff --git a/backend/services/blockchain-service/src/shared/decorators/public.decorator.ts b/backend/services/blockchain-service/src/shared/decorators/public.decorator.ts new file mode 100644 index 00000000..b3845e12 --- /dev/null +++ b/backend/services/blockchain-service/src/shared/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/backend/services/blockchain-service/src/shared/guards/jwt-auth.guard.ts b/backend/services/blockchain-service/src/shared/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..20797af4 --- /dev/null +++ b/backend/services/blockchain-service/src/shared/guards/jwt-auth.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '@/shared/decorators'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } +} diff --git a/backend/services/blockchain-service/src/shared/index.ts b/backend/services/blockchain-service/src/shared/index.ts index 488bd4b0..1951c69d 100644 --- a/backend/services/blockchain-service/src/shared/index.ts +++ b/backend/services/blockchain-service/src/shared/index.ts @@ -1,2 +1,3 @@ export * from './exceptions'; export * from './filters'; +export * from './decorators'; diff --git a/backend/services/blockchain-service/src/shared/strategies/jwt.strategy.ts b/backend/services/blockchain-service/src/shared/strategies/jwt.strategy.ts new file mode 100644 index 00000000..e03998b5 --- /dev/null +++ b/backend/services/blockchain-service/src/shared/strategies/jwt.strategy.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; + +interface JwtPayload { + userId: string; + accountSequence: number; + deviceId: string; + type: 'access' | 'refresh'; + iat: number; + exp: number; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET') || 'default-secret', + }); + } + + async validate(payload: JwtPayload) { + return { + userId: payload.userId, + accountSequence: payload.accountSequence, + deviceId: payload.deviceId, + }; + } +} diff --git a/backend/services/docker-compose.yml b/backend/services/docker-compose.yml index d446c53c..045f4ee3 100644 --- a/backend/services/docker-compose.yml +++ b/backend/services/docker-compose.yml @@ -236,6 +236,9 @@ services: - KAFKA_BROKERS=kafka:29092 - KAFKA_CLIENT_ID=planting-service - KAFKA_GROUP_ID=planting-service-group + - WALLET_SERVICE_URL=http://rwa-wallet-service:3001 + - IDENTITY_SERVICE_URL=http://rwa-identity-service:3000 + - REFERRAL_SERVICE_URL=http://rwa-referral-service:3004 depends_on: postgres: condition: service_healthy @@ -566,6 +569,7 @@ services: - APP_PORT=3012 - API_PREFIX=api/v1 - DATABASE_URL=postgresql://${POSTGRES_USER:-rwa_user}:${POSTGRES_PASSWORD:-rwa_secure_password}@postgres:5432/rwa_blockchain?schema=public + - JWT_SECRET=${JWT_SECRET} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} @@ -573,8 +577,21 @@ services: - KAFKA_BROKERS=kafka:29092 - KAFKA_CLIENT_ID=blockchain-service - KAFKA_GROUP_ID=blockchain-service-group - - KAVA_RPC_URL=https://evm.kava.io - - BSC_RPC_URL=https://bsc-dataseed.binance.org + # 网络模式: mainnet 或 testnet + - NETWORK_MODE=${NETWORK_MODE:-mainnet} + # 主网配置 (NETWORK_MODE=mainnet 时使用) + # - KAVA_RPC_URL=https://evm.kava.io + # - KAVA_CHAIN_ID=2222 + # - KAVA_USDT_CONTRACT=0x919C1c267BC06a7039e03fcc2eF738525769109c + # - BSC_RPC_URL=https://bsc-dataseed.binance.org + # - BSC_CHAIN_ID=56 + # - BSC_USDT_CONTRACT=0x55d398326f99059fF775485246999027B3197955 + # 测试网配置 (NETWORK_MODE=testnet 时使用) + # - KAVA_RPC_URL=https://evm.testnet.kava.io + # - KAVA_CHAIN_ID=2221 + # - BSC_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545 + # - BSC_CHAIN_ID=97 + # - BSC_USDT_CONTRACT=0x337610d27c682E347C9cD60BD4b3b107C9d34dDd depends_on: postgres: condition: service_healthy diff --git a/backend/services/wallet-service/src/api/api.module.ts b/backend/services/wallet-service/src/api/api.module.ts index 4eeff403..edf8d0ed 100644 --- a/backend/services/wallet-service/src/api/api.module.ts +++ b/backend/services/wallet-service/src/api/api.module.ts @@ -8,7 +8,9 @@ import { DepositController, HealthController, } from './controllers'; +import { InternalWalletController } from './controllers/internal-wallet.controller'; import { WalletApplicationService } from '@/application/services'; +import { DepositConfirmedHandler } from '@/application/event-handlers'; import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; @Module({ @@ -27,9 +29,11 @@ import { JwtStrategy } from '@/shared/strategies/jwt.strategy'; LedgerController, DepositController, HealthController, + InternalWalletController, ], providers: [ WalletApplicationService, + DepositConfirmedHandler, JwtStrategy, ], }) diff --git a/backend/services/wallet-service/src/application/event-handlers/deposit-confirmed.handler.ts b/backend/services/wallet-service/src/application/event-handlers/deposit-confirmed.handler.ts new file mode 100644 index 00000000..3289a4f5 --- /dev/null +++ b/backend/services/wallet-service/src/application/event-handlers/deposit-confirmed.handler.ts @@ -0,0 +1,84 @@ +/** + * Deposit Confirmed Event Handler + * + * Handles deposit confirmation events from blockchain-service. + * Credits user wallets when deposits are confirmed on-chain. + */ + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { WalletApplicationService } from '@/application/services/wallet-application.service'; +import { HandleDepositCommand } from '@/application/commands'; +import { + DepositEventConsumerService, + DepositConfirmedPayload, +} from '@/infrastructure/kafka/deposit-event-consumer.service'; +import { ChainType } from '@/domain/value-objects'; + +@Injectable() +export class DepositConfirmedHandler implements OnModuleInit { + private readonly logger = new Logger(DepositConfirmedHandler.name); + + constructor( + private readonly depositEventConsumer: DepositEventConsumerService, + private readonly walletApplicationService: WalletApplicationService, + ) {} + + onModuleInit() { + this.depositEventConsumer.onDepositConfirmed(this.handleDepositConfirmed.bind(this)); + this.logger.log('DepositConfirmedHandler registered'); + } + + async handleDepositConfirmed(payload: DepositConfirmedPayload): Promise { + this.logger.log(`Processing deposit confirmation: txHash=${payload.txHash}`); + + try { + // Map chainType string to ChainType enum + const chainType = this.mapChainType(payload.chainType); + + const command: HandleDepositCommand = { + accountSequence: payload.accountSequence, + userId: payload.userId, + amount: parseFloat(payload.amountFormatted), + chainType, + txHash: payload.txHash, + }; + + await this.walletApplicationService.handleDeposit(command); + + this.logger.log( + `Deposit credited successfully: ` + + `txHash=${payload.txHash}, ` + + `amount=${payload.amountFormatted} USDT, ` + + `userId=${payload.userId}, ` + + `accountSequence=${payload.accountSequence}`, + ); + } catch (error) { + // Check if it's a duplicate transaction error (already processed) + if (error.message?.includes('Duplicate transaction')) { + this.logger.warn( + `Deposit already processed (duplicate): txHash=${payload.txHash}`, + ); + return; + } + + this.logger.error( + `Failed to process deposit: txHash=${payload.txHash}`, + error, + ); + throw error; + } + } + + private mapChainType(chainType: string): ChainType { + const normalized = chainType.toUpperCase(); + switch (normalized) { + case 'KAVA': + return ChainType.KAVA; + case 'BSC': + return ChainType.BSC; + default: + this.logger.warn(`Unknown chain type: ${chainType}, defaulting to KAVA`); + return ChainType.KAVA; + } + } +} diff --git a/backend/services/wallet-service/src/application/event-handlers/index.ts b/backend/services/wallet-service/src/application/event-handlers/index.ts new file mode 100644 index 00000000..384bdf25 --- /dev/null +++ b/backend/services/wallet-service/src/application/event-handlers/index.ts @@ -0,0 +1 @@ +export * from './deposit-confirmed.handler'; diff --git a/backend/services/wallet-service/src/infrastructure/kafka/deposit-event-consumer.service.ts b/backend/services/wallet-service/src/infrastructure/kafka/deposit-event-consumer.service.ts new file mode 100644 index 00000000..b623ef7d --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/kafka/deposit-event-consumer.service.ts @@ -0,0 +1,152 @@ +/** + * Deposit Event Consumer Service for Wallet Service + * + * Consumes deposit confirmed events from blockchain-service via Kafka. + * Credits user wallets when deposits are confirmed on-chain. + */ + +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kafka, Consumer, logLevel, EachMessagePayload } from 'kafkajs'; + +export const DEPOSIT_TOPICS = { + BLOCKCHAIN_DEPOSITS: 'blockchain.deposits', +} as const; + +export interface DepositConfirmedPayload { + depositId: string; + chainType: string; + txHash: string; + toAddress: string; + amount: string; + amountFormatted: string; + confirmations: number; + accountSequence: string; + userId: string; +} + +export type DepositEventHandler = (payload: DepositConfirmedPayload) => Promise; + +@Injectable() +export class DepositEventConsumerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(DepositEventConsumerService.name); + private kafka: Kafka; + private consumer: Consumer; + private isConnected = false; + + private depositConfirmedHandler?: DepositEventHandler; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + const brokers = this.configService.get('KAFKA_BROKERS')?.split(',') || ['localhost:9092']; + const clientId = this.configService.get('KAFKA_CLIENT_ID') || 'wallet-service'; + const groupId = 'wallet-service-deposit-events'; + + this.logger.log(`[INIT] Deposit Event Consumer initializing...`); + this.logger.log(`[INIT] ClientId: ${clientId}`); + this.logger.log(`[INIT] GroupId: ${groupId}`); + this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`); + this.logger.log(`[INIT] Topics: ${Object.values(DEPOSIT_TOPICS).join(', ')}`); + + this.kafka = new Kafka({ + clientId, + brokers, + logLevel: logLevel.WARN, + retry: { + initialRetryTime: 100, + retries: 8, + }, + }); + + this.consumer = this.kafka.consumer({ + groupId, + sessionTimeout: 30000, + heartbeatInterval: 3000, + }); + + try { + this.logger.log(`[CONNECT] Connecting Deposit Event consumer...`); + await this.consumer.connect(); + this.isConnected = true; + this.logger.log(`[CONNECT] Deposit Event consumer connected successfully`); + + await this.consumer.subscribe({ + topics: Object.values(DEPOSIT_TOPICS), + fromBeginning: false, + }); + this.logger.log(`[SUBSCRIBE] Subscribed to deposit topics`); + + await this.startConsuming(); + } catch (error) { + this.logger.error(`[ERROR] Failed to connect Deposit Event consumer`, error); + } + } + + async onModuleDestroy() { + if (this.isConnected) { + await this.consumer.disconnect(); + this.logger.log('Deposit Event consumer disconnected'); + } + } + + /** + * Register handler for deposit confirmed events + */ + onDepositConfirmed(handler: DepositEventHandler): void { + this.depositConfirmedHandler = handler; + this.logger.log(`[REGISTER] DepositConfirmed handler registered`); + } + + private async startConsuming(): Promise { + await this.consumer.run({ + eachMessage: async ({ topic, partition, message }: EachMessagePayload) => { + const offset = message.offset; + this.logger.log(`[RECEIVE] Message received: topic=${topic}, partition=${partition}, offset=${offset}`); + + try { + const value = message.value?.toString(); + if (!value) { + this.logger.warn(`[RECEIVE] Empty message received on ${topic}`); + return; + } + + this.logger.debug(`[RECEIVE] Raw message: ${value.substring(0, 500)}...`); + + const parsed = JSON.parse(value); + const eventType = parsed.eventType; + const payload = parsed.payload || parsed; + + this.logger.log(`[RECEIVE] Event type: ${eventType}`); + + if (eventType === 'blockchain.deposit.confirmed') { + this.logger.log(`[HANDLE] Processing DepositConfirmed event`); + this.logger.log(`[HANDLE] depositId: ${payload.depositId}`); + this.logger.log(`[HANDLE] chainType: ${payload.chainType}`); + this.logger.log(`[HANDLE] txHash: ${payload.txHash}`); + this.logger.log(`[HANDLE] amount: ${payload.amountFormatted}`); + this.logger.log(`[HANDLE] accountSequence: ${payload.accountSequence}`); + this.logger.log(`[HANDLE] userId: ${payload.userId}`); + + if (this.depositConfirmedHandler) { + await this.depositConfirmedHandler(payload as DepositConfirmedPayload); + this.logger.log(`[HANDLE] DepositConfirmed handler completed`); + } else { + this.logger.warn(`[HANDLE] No handler registered for DepositConfirmed`); + } + } else if (eventType === 'blockchain.deposit.detected') { + // Log detected deposits but don't process them yet (wait for confirmation) + this.logger.log(`[SKIP] DepositDetected event received, waiting for confirmation`); + this.logger.log(`[SKIP] txHash: ${payload.txHash}, amount: ${payload.amountFormatted}`); + } else { + this.logger.warn(`[RECEIVE] Unknown event type: ${eventType}`); + } + } catch (error) { + this.logger.error(`[ERROR] Error processing deposit event from ${topic}`, error); + } + }, + }); + + this.logger.log(`[START] Started consuming deposit events`); + } +} diff --git a/backend/services/wallet-service/src/infrastructure/kafka/event-publisher.service.ts b/backend/services/wallet-service/src/infrastructure/kafka/event-publisher.service.ts new file mode 100644 index 00000000..0b5a612f --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/kafka/event-publisher.service.ts @@ -0,0 +1,116 @@ +/** + * Kafka Event Publisher Service for Wallet Service + * + * Publishes domain events to Kafka for cross-service communication. + * Used to notify blockchain-service about withdrawal requests. + */ + +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Kafka, Producer, logLevel } from 'kafkajs'; + +export interface EventPayload { + eventId?: string; + eventType: string; + occurredAt?: Date; + payload: { [key: string]: unknown }; +} + +@Injectable() +export class EventPublisherService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(EventPublisherService.name); + private kafka: Kafka; + private producer: Producer; + private isConnected = false; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + const brokers = this.configService.get('KAFKA_BROKERS')?.split(',') || ['localhost:9092']; + const clientId = this.configService.get('KAFKA_CLIENT_ID') || 'wallet-service'; + + this.logger.log(`[INIT] Event Publisher initializing...`); + this.logger.log(`[INIT] ClientId: ${clientId}`); + this.logger.log(`[INIT] Brokers: ${brokers.join(', ')}`); + + this.kafka = new Kafka({ + clientId, + brokers, + logLevel: logLevel.WARN, + retry: { + initialRetryTime: 100, + retries: 8, + }, + }); + + this.producer = this.kafka.producer(); + + try { + await this.producer.connect(); + this.isConnected = true; + this.logger.log(`[CONNECT] Kafka producer connected successfully`); + } catch (error) { + this.logger.error(`[ERROR] Failed to connect Kafka producer`, error); + } + } + + async onModuleDestroy() { + if (this.isConnected) { + await this.producer.disconnect(); + this.logger.log('Kafka producer disconnected'); + } + } + + /** + * Publish an event to Kafka + */ + async publish(event: EventPayload): Promise { + if (!this.isConnected) { + this.logger.warn(`[PUBLISH] Kafka not connected, skipping event: ${event.eventType}`); + return; + } + + const topic = this.getTopicForEvent(event.eventType); + const eventId = event.eventId || this.generateEventId(); + const occurredAt = event.occurredAt || new Date(); + + const message = { + key: eventId, + value: JSON.stringify({ + eventId, + eventType: event.eventType, + occurredAt: occurredAt.toISOString(), + payload: event.payload, + }), + headers: { + eventType: event.eventType, + source: 'wallet-service', + }, + }; + + try { + await this.producer.send({ + topic, + messages: [message], + }); + + this.logger.log(`[PUBLISH] Published event: ${event.eventType} to topic: ${topic}`); + } catch (error) { + this.logger.error(`[ERROR] Failed to publish event: ${event.eventType}`, error); + throw error; + } + } + + private getTopicForEvent(eventType: string): string { + const topicMap: Record = { + 'wallet.withdrawal.requested': 'wallet.withdrawals', + 'wallet.withdrawal.completed': 'wallet.withdrawals', + 'wallet.withdrawal.failed': 'wallet.withdrawals', + }; + return topicMap[eventType] || 'wallet.events'; + } + + private generateEventId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/backend/services/wallet-service/src/infrastructure/kafka/index.ts b/backend/services/wallet-service/src/infrastructure/kafka/index.ts new file mode 100644 index 00000000..26c10aa3 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/kafka/index.ts @@ -0,0 +1,3 @@ +export * from './kafka.module'; +export * from './event-publisher.service'; +export * from './deposit-event-consumer.service'; diff --git a/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts b/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts new file mode 100644 index 00000000..e12f7df7 --- /dev/null +++ b/backend/services/wallet-service/src/infrastructure/kafka/kafka.module.ts @@ -0,0 +1,10 @@ +import { Module, Global } from '@nestjs/common'; +import { EventPublisherService } from './event-publisher.service'; +import { DepositEventConsumerService } from './deposit-event-consumer.service'; + +@Global() +@Module({ + providers: [EventPublisherService, DepositEventConsumerService], + exports: [EventPublisherService, DepositEventConsumerService], +}) +export class KafkaModule {} diff --git a/frontend/mobile-app/lib/core/services/deposit_service.dart b/frontend/mobile-app/lib/core/services/deposit_service.dart index 8ce0a8eb..080b7c49 100644 --- a/frontend/mobile-app/lib/core/services/deposit_service.dart +++ b/frontend/mobile-app/lib/core/services/deposit_service.dart @@ -176,7 +176,7 @@ class DepositService { Future getUsdtBalances() async { try { debugPrint('查询 USDT 余额...'); - final response = await _apiClient.get('/api/deposit/balances'); + final response = await _apiClient.get('/deposit/balances'); if (response.statusCode == 200) { final data = response.data as Map;