This commit is contained in:
Developer 2025-11-30 06:44:57 -08:00
parent adf52ae130
commit a966d71fa0
66 changed files with 18589 additions and 703 deletions

View File

@ -4,7 +4,17 @@
"Bash(dir:*)",
"Bash(tree:*)",
"Bash(find:*)",
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")"
"Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\backend\\services\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservices\"\")",
"Bash(mkdir:*)",
"Bash(npm run build:*)",
"Bash(npx nest build)",
"Bash(npm install)",
"Bash(npx prisma migrate dev:*)",
"Bash(npx jest:*)",
"Bash(flutter test:*)",
"Bash(flutter analyze:*)",
"Bash(findstr:*)",
"Bash(flutter pub get:*)"
],
"deny": [],
"ask": []

View File

@ -32,3 +32,9 @@ WALLET_ENCRYPTION_SALT="dev-wallet-salt"
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
MPC_SERVICE_URL="http://localhost:3001"
MPC_MODE="local" # local 使用本地模拟remote 调用 mpc-service
# Backup Service (MPC 备份分片存储)
# 安全要求: 必须部署在与 identity-service 不同的物理服务器上!
BACKUP_SERVICE_URL="http://localhost:3002"
BACKUP_SERVICE_ENABLED="true"
SERVICE_JWT_SECRET="dev-service-jwt-secret-key"

View File

@ -32,3 +32,9 @@ WALLET_ENCRYPTION_SALT="rwa-wallet-salt-change-in-production"
# 调用路径: identity-service → mpc-service (NestJS) → mpc-system (Go)
MPC_SERVICE_URL="http://localhost:3001"
MPC_MODE="local" # local 使用本地模拟remote 调用 mpc-service
# Backup Service (MPC 备份分片存储)
# 安全要求: 必须部署在与 identity-service 不同的物理服务器上!
BACKUP_SERVICE_URL="http://backup-server:3002"
BACKUP_SERVICE_ENABLED="true"
SERVICE_JWT_SECRET="your-service-jwt-secret-change-in-production"

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"ethers": "^6.9.0",
"ioredis": "^5.3.2",
"kafkajs": "^2.2.4",
"jsonwebtoken": "^9.0.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
@ -59,6 +60,7 @@
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.0",
"@types/uuid": "^9.0.0",

View File

@ -226,3 +226,26 @@ model MpcSession {
@@index([createdAt], name: "idx_session_created")
@@map("mpc_sessions")
}
// 推荐链接 - 用于追踪不同渠道的邀请
model ReferralLink {
linkId BigInt @id @default(autoincrement()) @map("link_id")
userId BigInt @map("user_id")
referralCode String @map("referral_code") @db.VarChar(10)
shortCode String @unique @map("short_code") @db.VarChar(10) // 短链码
channel String? @db.VarChar(50) // 渠道: wechat, telegram, twitter, etc.
campaignId String? @map("campaign_id") @db.VarChar(50) // 活动ID
clickCount Int @default(0) @map("click_count") // 点击次数
registerCount Int @default(0) @map("register_count") // 注册转化数
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at") // 过期时间 (可选)
@@index([userId], name: "idx_referral_link_user")
@@index([referralCode], name: "idx_referral_link_code")
@@index([channel], name: "idx_referral_link_channel")
@@index([createdAt], name: "idx_referral_link_created")
@@map("referral_links")
}

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { UserAccountController } from './controllers/user-account.controller';
import { AuthController } from './controllers/auth.controller';
import { ReferralsController } from './controllers/referrals.controller';
import { DepositController } from './controllers/deposit.controller';
import { ApplicationModule } from '@/application/application.module';
@Module({
imports: [ApplicationModule],
controllers: [UserAccountController, AuthController],
controllers: [UserAccountController, AuthController, ReferralsController, DepositController],
})
export class ApiModule {}

View File

@ -0,0 +1,90 @@
import { Controller, Get, UseGuards, Request, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
import { DepositService } from '@/application/services/deposit.service';
/**
* DTO
*/
class DepositAddressResponseDto {
kavaAddress: string | null;
bscAddress: string | null;
isValid: boolean;
message?: string;
}
/**
* USDT DTO
*/
class UsdtBalanceDto {
chainType: string;
address: string;
balance: string;
rawBalance: string;
decimals: number;
}
class BalanceResponseDto {
kava: UsdtBalanceDto | null;
bsc: UsdtBalanceDto | null;
}
/**
*
*
* API
*/
@ApiTags('Deposit')
@Controller('api/deposit')
export class DepositController {
private readonly logger = new Logger(DepositController.name);
constructor(private readonly depositService: DepositService) {}
/**
*
*
* KAVA BSC
*
*/
@Get('addresses')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取充值地址' })
@ApiResponse({
status: 200,
description: '成功获取充值地址',
type: DepositAddressResponseDto,
})
@ApiResponse({ status: 400, description: '获取失败' })
@ApiResponse({ status: 401, description: '未授权' })
async getDepositAddresses(@Request() req: any): Promise<DepositAddressResponseDto> {
const userId = req.user.userId;
this.logger.log(`获取充值地址: userId=${userId}`);
return this.depositService.getDepositAddresses(userId);
}
/**
* USDT
*
* KAVA BSC USDT
*/
@Get('balances')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询 USDT 余额' })
@ApiResponse({
status: 200,
description: '成功获取余额',
type: BalanceResponseDto,
})
@ApiResponse({ status: 400, description: '查询失败' })
@ApiResponse({ status: 401, description: '未授权' })
async getUsdtBalances(@Request() req: any): Promise<BalanceResponseDto> {
const userId = req.user.userId;
this.logger.log(`查询 USDT 余额: userId=${userId}`);
return this.depositService.getUsdtBalances(userId);
}
}

View File

@ -0,0 +1,68 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { UserApplicationService } from '@/application/services/user-application.service';
import { JwtAuthGuard, Public, CurrentUser, CurrentUserData } from '@/shared/guards/jwt-auth.guard';
import {
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
} from '@/application/commands';
import {
GenerateReferralLinkDto, MeResponseDto, ReferralValidationResponseDto,
ReferralLinkResponseDto, ReferralStatsResponseDto,
} from '@/api/dto';
@ApiTags('Referrals')
@Controller()
@UseGuards(JwtAuthGuard)
export class ReferralsController {
constructor(private readonly userService: UserApplicationService) {}
/**
* GET /api/me - +
*/
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前登录用户信息', description: '返回用户基本信息、推荐码和推荐链接' })
@ApiResponse({ status: 200, type: MeResponseDto })
async getMe(@CurrentUser() user: CurrentUserData): Promise<MeResponseDto> {
return this.userService.getMe(user.userId);
}
/**
* GET /api/referrals/validate -
*/
@Public()
@Get('referrals/validate')
@ApiOperation({ summary: '校验推荐码', description: '创建账号时校验推荐码是否合法' })
@ApiQuery({ name: 'code', description: '推荐码', required: true })
@ApiResponse({ status: 200, type: ReferralValidationResponseDto })
async validateReferralCode(@Query('code') code: string): Promise<ReferralValidationResponseDto> {
return this.userService.validateReferralCode(new ValidateReferralCodeQuery(code));
}
/**
* POST /api/referrals/links - /
*/
@Post('referrals/links')
@ApiBearerAuth()
@ApiOperation({ summary: '生成推荐链接', description: '为当前登录用户生成短链/渠道链接' })
@ApiResponse({ status: 201, type: ReferralLinkResponseDto })
async generateReferralLink(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateReferralLinkDto,
): Promise<ReferralLinkResponseDto> {
return this.userService.generateReferralLink(
new GenerateReferralLinkCommand(user.userId, dto.channel, dto.campaignId),
);
}
/**
* GET /api/referrals/stats -
*/
@Get('referrals/stats')
@ApiBearerAuth()
@ApiOperation({ summary: '查询邀请统计', description: '查询登录用户的邀请记录和统计数据' })
@ApiResponse({ status: 200, type: ReferralStatsResponseDto })
async getReferralStats(@CurrentUser() user: CurrentUserData): Promise<ReferralStatsResponseDto> {
return this.userService.getReferralStats(new GetReferralStatsQuery(user.userId));
}
}

View File

@ -186,3 +186,136 @@ export class LoginResponseDto {
@ApiProperty()
refreshToken: string;
}
// ============ Referral DTOs ============
export class GenerateReferralLinkDto {
@ApiPropertyOptional({ description: '渠道标识: wechat, telegram, twitter 等' })
@IsOptional()
@IsString()
channel?: string;
@ApiPropertyOptional({ description: '活动ID' })
@IsOptional()
@IsString()
campaignId?: string;
}
export class MeResponseDto {
@ApiProperty()
userId: string;
@ApiProperty({ description: '账户序列号' })
accountSequence: number;
@ApiProperty({ nullable: true })
phoneNumber: string | null;
@ApiProperty()
nickname: string;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty({ description: '推荐码' })
referralCode: string;
@ApiProperty({ description: '完整推荐链接' })
referralLink: string;
@ApiProperty({ description: '钱包地址列表' })
walletAddresses: Array<{ chainType: string; address: string }>;
@ApiProperty()
kycStatus: string;
@ApiProperty()
status: string;
@ApiProperty()
registeredAt: Date;
}
export class ReferralValidationResponseDto {
@ApiProperty({ description: '推荐码是否有效' })
valid: boolean;
@ApiPropertyOptional()
referralCode?: string;
@ApiPropertyOptional({ description: '邀请人信息' })
inviterInfo?: {
accountSequence: number;
nickname: string;
avatarUrl: string | null;
};
@ApiPropertyOptional({ description: '错误信息' })
message?: string;
}
export class ReferralLinkResponseDto {
@ApiProperty()
linkId: string;
@ApiProperty()
referralCode: string;
@ApiProperty({ description: '短链' })
shortUrl: string;
@ApiProperty({ description: '完整链接' })
fullUrl: string;
@ApiProperty({ nullable: true })
channel: string | null;
@ApiProperty({ nullable: true })
campaignId: string | null;
@ApiProperty()
createdAt: Date;
}
export class InviteRecordDto {
@ApiProperty()
accountSequence: number;
@ApiProperty()
nickname: string;
@ApiProperty({ nullable: true })
avatarUrl: string | null;
@ApiProperty()
registeredAt: Date;
@ApiProperty({ description: '1=直接邀请, 2=间接邀请' })
level: number;
}
export class ReferralStatsResponseDto {
@ApiProperty()
referralCode: string;
@ApiProperty({ description: '总邀请人数' })
totalInvites: number;
@ApiProperty({ description: '直接邀请人数' })
directInvites: number;
@ApiProperty({ description: '间接邀请人数 (二级)' })
indirectInvites: number;
@ApiProperty({ description: '今日邀请' })
todayInvites: number;
@ApiProperty({ description: '本周邀请' })
thisWeekInvites: number;
@ApiProperty({ description: '本月邀请' })
thisMonthInvites: number;
@ApiProperty({ description: '最近邀请记录', type: [InviteRecordDto] })
recentInvites: InviteRecordDto[];
}

View File

@ -1,6 +1,7 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { HttpModule } from '@nestjs/axios';
import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core';
// Config
@ -9,6 +10,7 @@ import { appConfig, databaseConfig, jwtConfig, redisConfig, kafkaConfig, smsConf
// Controllers
import { UserAccountController } from '@/api/controllers/user-account.controller';
import { HealthController } from '@/api/controllers/health.controller';
import { ReferralsController } from '@/api/controllers/referrals.controller';
// Application Services
import { UserApplicationService } from '@/application/services/user-application.service';
@ -19,13 +21,18 @@ import {
AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService,
} from '@/domain/services';
import { USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
// Infrastructure
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
import { MpcKeyShareRepositoryImpl } from '@/infrastructure/persistence/repositories/mpc-key-share.repository.impl';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { MpcClientService, MpcWalletService } from '@/infrastructure/external/mpc';
import { BackupClientService, MpcShareStorageService } from '@/infrastructure/external/backup';
import { WalletGeneratorServiceImpl } from '@/infrastructure/external/blockchain/wallet-generator.service.impl';
// Shared
import { GlobalExceptionFilter, TransformInterceptor } from '@/shared/filters/global-exception.filter';
@ -34,8 +41,38 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
// ============ Infrastructure Module ============
@Global()
@Module({
providers: [PrismaService, RedisService, EventPublisherService, SmsService],
exports: [PrismaService, RedisService, EventPublisherService, SmsService],
imports: [
HttpModule.register({
timeout: 300000,
maxRedirects: 5,
}),
],
providers: [
PrismaService,
RedisService,
EventPublisherService,
SmsService,
MpcClientService,
MpcWalletService,
BackupClientService,
MpcShareStorageService,
// WalletGeneratorService 抽象类由 WalletGeneratorServiceImpl 实现
WalletGeneratorServiceImpl,
{ provide: WalletGeneratorService, useExisting: WalletGeneratorServiceImpl },
{ provide: MPC_KEY_SHARE_REPOSITORY, useClass: MpcKeyShareRepositoryImpl },
],
exports: [
PrismaService,
RedisService,
EventPublisherService,
SmsService,
MpcClientService,
MpcWalletService,
BackupClientService,
MpcShareStorageService,
WalletGeneratorService,
MPC_KEY_SHARE_REPOSITORY,
],
})
export class InfrastructureModule {}
@ -46,13 +83,13 @@ export class InfrastructureModule {}
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
// WalletGeneratorService 由 InfrastructureModule 提供
],
exports: [
USER_ACCOUNT_REPOSITORY,
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
// WalletGeneratorService 由 InfrastructureModule 导出
],
})
export class DomainModule {}
@ -68,7 +105,7 @@ export class ApplicationModule {}
// ============ API Module ============
@Module({
imports: [ApplicationModule],
controllers: [HealthController, UserAccountController],
controllers: [HealthController, UserAccountController, ReferralsController],
})
export class ApiModule {}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { UserApplicationService } from './services/user-application.service';
import { TokenService } from './services/token.service';
import { DepositService } from './services/deposit.service';
import { AutoCreateAccountHandler } from './commands/auto-create-account/auto-create-account.handler';
import { RecoverByMnemonicHandler } from './commands/recover-by-mnemonic/recover-by-mnemonic.handler';
import { RecoverByPhoneHandler } from './commands/recover-by-phone/recover-by-phone.handler';
@ -15,6 +16,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
providers: [
UserApplicationService,
TokenService,
DepositService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,
@ -25,6 +27,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
exports: [
UserApplicationService,
TokenService,
DepositService,
AutoCreateAccountHandler,
RecoverByMnemonicHandler,
RecoverByPhoneHandler,

View File

@ -1,4 +1,4 @@
import { Injectable, Inject } from '@nestjs/common';
import { Injectable, Inject, Logger } from '@nestjs/common';
import { AutoCreateAccountCommand } from './auto-create-account.command';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
@ -8,9 +8,12 @@ import { TokenService } from '@/application/services/token.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AutoCreateAccountResult } from '../index';
import { MpcShareStorageService } from '@/infrastructure/external/backup/mpc-share-storage.service';
@Injectable()
export class AutoCreateAccountHandler {
private readonly logger = new Logger(AutoCreateAccountHandler.name);
constructor(
@Inject(USER_ACCOUNT_REPOSITORY)
private readonly userRepository: UserAccountRepository,
@ -19,6 +22,7 @@ export class AutoCreateAccountHandler {
private readonly walletGenerator: WalletGeneratorService,
private readonly tokenService: TokenService,
private readonly eventPublisher: EventPublisherService,
private readonly mpcShareStorage: MpcShareStorageService,
) {}
async execute(command: AutoCreateAccountCommand): Promise<AutoCreateAccountResult> {
@ -45,11 +49,27 @@ export class AutoCreateAccountHandler {
city: CityCode.create(command.cityCode || 'DEFAULT'),
});
const { mnemonic, wallets } = this.walletGenerator.generateWalletSystem({
userId: account.userId,
// 使用 MPC 2-of-3 生成三链钱包
this.logger.log(`Generating MPC wallet for user=${account.userId.toString()}`);
const mpcResult = await this.walletGenerator.generateMpcWalletSystem({
userId: account.userId.toString(),
deviceId: command.deviceId,
});
// 将 MPC 钱包信息转换为领域实体
const wallets = this.walletGenerator.convertToWalletEntities(
account.userId,
mpcResult.wallets,
);
// 保存备份分片到备份服务
this.logger.log(`Storing backup share for user=${account.userId.toString()}`);
await this.mpcShareStorage.storeBackupShare({
userId: account.userId.toString(),
shareData: mpcResult.backupShareData,
publicKey: mpcResult.publicKey,
});
account.bindMultipleWalletAddresses(wallets);
await this.userRepository.save(account);
await this.userRepository.saveWallets(account.userId, Array.from(wallets.values()));
@ -63,11 +83,15 @@ export class AutoCreateAccountHandler {
await this.eventPublisher.publishAll(account.domainEvents);
account.clearDomainEvents();
this.logger.log(`Account created successfully: userId=${account.userId.toString()}, seq=${account.accountSequence.value}`);
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
referralCode: account.referralCode.value,
mnemonic: mnemonic.value,
mnemonic: '', // MPC 模式下不再使用助记词
clientShareData: mpcResult.clientShareData, // 客户端需安全存储此分片
publicKey: mpcResult.publicKey,
walletAddresses: {
kava: wallets.get(ChainType.KAVA)!.address,
dst: wallets.get(ChainType.DST)!.address,

View File

@ -129,6 +129,22 @@ export class GetUserByReferralCodeQuery {
constructor(public readonly referralCode: string) {}
}
export class ValidateReferralCodeQuery {
constructor(public readonly referralCode: string) {}
}
export class GetReferralStatsQuery {
constructor(public readonly userId: string) {}
}
export class GenerateReferralLinkCommand {
constructor(
public readonly userId: string,
public readonly channel?: string, // 渠道标识: wechat, telegram, twitter 等
public readonly campaignId?: string, // 活动ID
) {}
}
// ============ Results ============
export interface AutoCreateAccountResult {
userId: string;
@ -206,3 +222,55 @@ export interface UserBriefDTO {
nickname: string;
avatarUrl: string | null;
}
export interface ReferralCodeValidationResult {
valid: boolean;
referralCode?: string;
inviterInfo?: {
accountSequence: number;
nickname: string;
avatarUrl: string | null;
};
message?: string;
}
export interface ReferralLinkResult {
linkId: string;
referralCode: string;
shortUrl: string;
fullUrl: string;
channel: string | null;
campaignId: string | null;
createdAt: Date;
}
export interface ReferralStatsResult {
referralCode: string;
totalInvites: number; // 总邀请人数
directInvites: number; // 直接邀请人数
indirectInvites: number; // 间接邀请人数 (二级)
todayInvites: number; // 今日邀请
thisWeekInvites: number; // 本周邀请
thisMonthInvites: number; // 本月邀请
recentInvites: Array<{ // 最近邀请记录
accountSequence: number;
nickname: string;
avatarUrl: string | null;
registeredAt: Date;
level: number; // 1=直接, 2=间接
}>;
}
export interface MeResult {
userId: string;
accountSequence: number;
phoneNumber: string | null;
nickname: string;
avatarUrl: string | null;
referralCode: string;
referralLink: string; // 完整推荐链接
walletAddresses: Array<{ chainType: string; address: string }>;
kycStatus: string;
status: string;
registeredAt: Date;
}

View File

@ -0,0 +1,194 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
import { BlockchainQueryService, UsdtBalance } from '@/infrastructure/external/blockchain/blockchain-query.service';
import { UserId, ChainType } from '@/domain/value-objects';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
/**
*
*/
export interface DepositAddressResponse {
kavaAddress: string | null;
bscAddress: string | null;
isValid: boolean;
message?: string;
}
/**
*
*/
export interface BalanceResponse {
kava: UsdtBalance | null;
bsc: UsdtBalance | null;
}
/**
*
*
*
*/
@Injectable()
export class DepositService {
private readonly logger = new Logger(DepositService.name);
constructor(
private readonly userAccountRepository: UserAccountRepositoryImpl,
private readonly blockchainQueryService: BlockchainQueryService,
) {}
/**
*
*
* 使
*/
async getDepositAddresses(userId: string): Promise<DepositAddressResponse> {
this.logger.log(`获取充值地址: userId=${userId}`);
const userAccount = await this.userAccountRepository.findById(UserId.create(userId));
if (!userAccount) {
throw new BadRequestException('用户不存在');
}
const walletAddresses = userAccount.getAllWalletAddresses();
if (!walletAddresses || walletAddresses.length === 0) {
return {
kavaAddress: null,
bscAddress: null,
isValid: false,
message: '充值账户异常:未找到钱包地址,请联系客服',
};
}
// 查找 KAVA 和 BSC 地址
const kavaWallet = walletAddresses.find((w: WalletAddress) => w.chainType === ChainType.KAVA);
const bscWallet = walletAddresses.find((w: WalletAddress) => w.chainType === ChainType.BSC);
// 验证地址签名
const validationResults = await Promise.all([
kavaWallet ? this.validateWalletAddress(kavaWallet) : Promise.resolve(true),
bscWallet ? this.validateWalletAddress(bscWallet) : Promise.resolve(true),
]);
const [kavaValid, bscValid] = validationResults;
// 如果有任何一个地址验证失败,返回错误
if (!kavaValid || !bscValid) {
this.logger.warn(`地址验证失败: userId=${userId}, kavaValid=${kavaValid}, bscValid=${bscValid}`);
return {
kavaAddress: null,
bscAddress: null,
isValid: false,
message: '充值账户异常:地址验证失败,请重试或联系客服',
};
}
// 检查地址状态
if (kavaWallet && kavaWallet.status !== 'ACTIVE') {
this.logger.warn(`KAVA 地址状态异常: userId=${userId}, status=${kavaWallet.status}`);
return {
kavaAddress: null,
bscAddress: null,
isValid: false,
message: '充值账户异常KAVA 地址已禁用,请联系客服',
};
}
if (bscWallet && bscWallet.status !== 'ACTIVE') {
this.logger.warn(`BSC 地址状态异常: userId=${userId}, status=${bscWallet.status}`);
return {
kavaAddress: null,
bscAddress: null,
isValid: false,
message: '充值账户异常BSC 地址已禁用,请联系客服',
};
}
return {
kavaAddress: kavaWallet?.address || null,
bscAddress: bscWallet?.address || null,
isValid: true,
};
}
/**
* USDT
*/
async getUsdtBalances(userId: string): Promise<BalanceResponse> {
this.logger.log(`查询 USDT 余额: userId=${userId}`);
// 先获取充值地址
const depositAddresses = await this.getDepositAddresses(userId);
if (!depositAddresses.isValid) {
throw new BadRequestException(depositAddresses.message || '获取充值地址失败');
}
const results: BalanceResponse = {
kava: null,
bsc: null,
};
// 查询 KAVA 余额
if (depositAddresses.kavaAddress) {
try {
results.kava = await this.blockchainQueryService.getUsdtBalance(
'KAVA',
depositAddresses.kavaAddress,
);
} catch (error) {
this.logger.error(`查询 KAVA USDT 余额失败: ${error.message}`);
results.kava = {
chainType: 'KAVA',
address: depositAddresses.kavaAddress,
balance: '0',
rawBalance: '0',
decimals: 6,
};
}
}
// 查询 BSC 余额
if (depositAddresses.bscAddress) {
try {
results.bsc = await this.blockchainQueryService.getUsdtBalance(
'BSC',
depositAddresses.bscAddress,
);
} catch (error) {
this.logger.error(`查询 BSC USDT 余额失败: ${error.message}`);
results.bsc = {
chainType: 'BSC',
address: depositAddresses.bscAddress,
balance: '0',
rawBalance: '0',
decimals: 18,
};
}
}
return results;
}
/**
*
*/
private async validateWalletAddress(wallet: any): Promise<boolean> {
try {
// 如果没有签名数据 (旧版本创建的地址),暂时允许通过
if (!wallet.publicKey || !wallet.mpcSignature?.r) {
this.logger.warn(`钱包地址无签名数据: chainType=${wallet.chainType}`);
return true;
}
// 验证签名
const isValid = await wallet.verifySignature();
if (!isValid) {
this.logger.error(`钱包地址签名验证失败: chainType=${wallet.chainType}, address=${wallet.address}`);
}
return isValid;
} catch (error) {
this.logger.error(`验证钱包地址签名异常: ${error.message}`);
return false;
}
}
}

View File

@ -0,0 +1,634 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserApplicationService } from './user-application.service';
import { USER_ACCOUNT_REPOSITORY, UserAccountRepository, ReferralLinkData, CreateReferralLinkParams } from '@/domain/repositories/user-account.repository.interface';
import { MPC_KEY_SHARE_REPOSITORY, MpcKeyShareRepository } from '@/domain/repositories/mpc-key-share.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { AccountSequence, ReferralCode, UserId, ProvinceCode, CityCode, AccountStatus, KYCStatus, DeviceInfo } from '@/domain/value-objects';
import { ConfigService } from '@nestjs/config';
import { ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand } from '@/application/commands';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from '@/domain/services';
import { TokenService } from './token.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { MpcWalletService } from '@/infrastructure/external/mpc';
import { BackupClientService } from '@/infrastructure/external/backup';
describe('UserApplicationService - Referral APIs', () => {
let service: UserApplicationService;
let mockUserRepository: jest.Mocked<UserAccountRepository>;
// Helper function to create a test account using UserAccount.reconstruct
const createMockAccount = (params: {
userId?: string;
accountSequence?: number;
referralCode?: string;
nickname?: string;
avatarUrl?: string | null;
isActive?: boolean;
inviterSequence?: number | null;
registeredAt?: Date;
} = {}): UserAccount => {
const devices = [
new DeviceInfo('device-001', 'Test Device', new Date(), new Date()),
];
return UserAccount.reconstruct({
userId: params.userId || '123456789',
accountSequence: params.accountSequence || 1,
devices,
phoneNumber: '13800138000',
nickname: params.nickname || '用户1',
avatarUrl: params.avatarUrl ?? null,
inviterSequence: params.inviterSequence ?? null,
referralCode: params.referralCode || 'ABC123',
province: '110000',
city: '110100',
address: null,
walletAddresses: [],
kycInfo: null,
kycStatus: KYCStatus.NOT_VERIFIED,
status: params.isActive !== false ? AccountStatus.ACTIVE : AccountStatus.FROZEN,
registeredAt: params.registeredAt || new Date(),
lastLoginAt: null,
updatedAt: new Date(),
});
};
beforeEach(async () => {
mockUserRepository = {
save: jest.fn(),
saveWallets: jest.fn(),
findById: jest.fn(),
findByAccountSequence: jest.fn(),
findByDeviceId: jest.fn(),
findByPhoneNumber: jest.fn(),
findByReferralCode: jest.fn(),
findByWalletAddress: jest.fn(),
getMaxAccountSequence: jest.fn(),
getNextAccountSequence: jest.fn(),
findUsers: jest.fn(),
countUsers: jest.fn(),
findByInviterSequence: jest.fn(),
createReferralLink: jest.fn(),
findReferralLinksByUserId: jest.fn(),
};
const mockMpcKeyShareRepository: jest.Mocked<MpcKeyShareRepository> = {
saveServerShare: jest.fn(),
findByUserId: jest.fn(),
findByPublicKey: jest.fn(),
updateStatus: jest.fn(),
rotateShare: jest.fn(),
};
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, any> = {
'APP_BASE_URL': 'https://app.rwadurian.com',
'MPC_MODE': 'local',
};
return config[key];
}),
};
const mockAccountSequenceGeneratorService = {
getNext: jest.fn().mockResolvedValue(AccountSequence.create(1)),
};
const mockUserValidatorService = {
validateUniquePhone: jest.fn(),
};
const mockWalletGeneratorService = {
generateWallets: jest.fn(),
};
const mockTokenService = {
generateAccessToken: jest.fn().mockReturnValue('mock-access-token'),
generateRefreshToken: jest.fn().mockReturnValue('mock-refresh-token'),
generateDeviceRefreshToken: jest.fn().mockReturnValue('mock-device-refresh-token'),
verifyRefreshToken: jest.fn(),
};
const mockRedisService = {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
setWithExpiry: jest.fn(),
};
const mockSmsService = {
sendSmsCode: jest.fn(),
};
const mockEventPublisherService = {
publish: jest.fn(),
};
const mockMpcWalletService = {
generateMpcWallet: jest.fn(),
};
const mockBackupClientService = {
storeBackupShare: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UserApplicationService,
{
provide: USER_ACCOUNT_REPOSITORY,
useValue: mockUserRepository,
},
{
provide: MPC_KEY_SHARE_REPOSITORY,
useValue: mockMpcKeyShareRepository,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: AccountSequenceGeneratorService,
useValue: mockAccountSequenceGeneratorService,
},
{
provide: UserValidatorService,
useValue: mockUserValidatorService,
},
{
provide: WalletGeneratorService,
useValue: mockWalletGeneratorService,
},
{
provide: TokenService,
useValue: mockTokenService,
},
{
provide: RedisService,
useValue: mockRedisService,
},
{
provide: SmsService,
useValue: mockSmsService,
},
{
provide: EventPublisherService,
useValue: mockEventPublisherService,
},
{
provide: MpcWalletService,
useValue: mockMpcWalletService,
},
{
provide: BackupClientService,
useValue: mockBackupClientService,
},
],
}).compile();
service = module.get<UserApplicationService>(UserApplicationService);
});
afterEach(() => {
jest.clearAllMocks();
});
// ============ GET /api/me Tests ============
describe('getMe', () => {
it('should return current user info with referral code and link', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
accountSequence: 1,
nickname: '测试用户',
referralCode: 'ABC123',
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
const result = await service.getMe('123456789');
expect(result).toEqual({
userId: '123456789',
accountSequence: 1,
phoneNumber: '138****8000', // masked
nickname: '测试用户',
avatarUrl: null,
referralCode: 'ABC123',
referralLink: 'https://app.rwadurian.com/invite/ABC123',
walletAddresses: [],
kycStatus: KYCStatus.NOT_VERIFIED,
status: AccountStatus.ACTIVE,
registeredAt: expect.any(Date),
});
expect(mockUserRepository.findById).toHaveBeenCalledWith(expect.any(UserId));
});
it('should throw error when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
// Use valid numeric string for userId
await expect(service.getMe('999999999')).rejects.toThrow(ApplicationError);
await expect(service.getMe('999999999')).rejects.toThrow('用户不存在');
});
});
// ============ GET /api/referrals/validate Tests ============
describe('validateReferralCode', () => {
it('should return valid=true for existing active referral code', async () => {
const mockInviter = createMockAccount({
accountSequence: 100,
nickname: '邀请人',
avatarUrl: 'https://example.com/avatar.jpg',
referralCode: 'INVTE1',
isActive: true,
});
mockUserRepository.findByReferralCode.mockResolvedValue(mockInviter);
const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('INVTE1')
);
expect(result).toEqual({
valid: true,
referralCode: 'INVTE1',
inviterInfo: {
accountSequence: 100,
nickname: '邀请人',
avatarUrl: 'https://example.com/avatar.jpg',
},
});
});
it('should return valid=false for non-existent referral code', async () => {
mockUserRepository.findByReferralCode.mockResolvedValue(null);
const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('INVLD1')
);
expect(result).toEqual({
valid: false,
message: '推荐码不存在',
});
});
it('should return valid=false for frozen inviter account', async () => {
const frozenInviter = createMockAccount({
referralCode: 'FROZN1',
isActive: false,
});
mockUserRepository.findByReferralCode.mockResolvedValue(frozenInviter);
const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('FROZN1')
);
expect(result).toEqual({
valid: false,
message: '推荐人账户已冻结',
});
});
it('should return valid=false for invalid referral code format', async () => {
const result = await service.validateReferralCode(
new ValidateReferralCodeQuery('invalid-format-too-long')
);
expect(result.valid).toBe(false);
expect(result.message).toBe('推荐码格式无效');
});
});
// ============ POST /api/referrals/links Tests ============
describe('generateReferralLink', () => {
it('should generate a new referral link with channel', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
referralCode: 'ABC123',
});
const mockLinkData: ReferralLinkData = {
linkId: BigInt(1),
userId: BigInt(123456789),
referralCode: 'ABC123',
shortCode: 'XyZ789',
channel: 'wechat',
campaignId: null,
createdAt: new Date('2024-01-15T10:00:00Z'),
};
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', 'wechat')
);
expect(result).toEqual({
linkId: '1',
referralCode: 'ABC123',
shortUrl: expect.stringMatching(/^https:\/\/app\.rwadurian\.com\/r\/[A-Za-z0-9]{6}$/),
fullUrl: 'https://app.rwadurian.com/invite/ABC123?ch=wechat',
channel: 'wechat',
campaignId: null,
createdAt: expect.any(Date),
});
expect(mockUserRepository.createReferralLink).toHaveBeenCalledWith({
userId: expect.any(BigInt),
referralCode: 'ABC123',
shortCode: expect.any(String),
channel: 'wechat',
campaignId: null,
});
});
it('should generate a referral link with campaign ID', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
referralCode: 'ABC123',
});
const mockLinkData: ReferralLinkData = {
linkId: BigInt(2),
userId: BigInt(123456789),
referralCode: 'ABC123',
shortCode: 'AbC456',
channel: 'telegram',
campaignId: 'spring2024',
createdAt: new Date('2024-01-15T10:00:00Z'),
};
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', 'telegram', 'spring2024')
);
expect(result.channel).toBe('telegram');
expect(result.campaignId).toBe('spring2024');
});
it('should generate link with default channel when not specified', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
referralCode: 'ABC123',
});
const mockLinkData: ReferralLinkData = {
linkId: BigInt(3),
userId: BigInt(123456789),
referralCode: 'ABC123',
shortCode: 'DeF789',
channel: null,
campaignId: null,
createdAt: new Date(),
};
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.createReferralLink.mockResolvedValue(mockLinkData);
const result = await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789')
);
expect(result.fullUrl).toContain('ch=default');
expect(result.channel).toBeNull();
});
it('should throw error when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
// Use valid numeric string for userId
await expect(
service.generateReferralLink(new GenerateReferralLinkCommand('999999999'))
).rejects.toThrow(ApplicationError);
});
});
// ============ GET /api/referrals/stats Tests ============
describe('getReferralStats', () => {
it('should return referral stats with direct and indirect invites', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
accountSequence: 1,
referralCode: 'ABC123',
});
// Direct invites (invited by user 1)
const directInvite1 = createMockAccount({
userId: '200000001',
accountSequence: 2,
nickname: '直接邀请1',
inviterSequence: 1,
registeredAt: new Date(),
});
const directInvite2 = createMockAccount({
userId: '200000002',
accountSequence: 3,
nickname: '直接邀请2',
inviterSequence: 1,
registeredAt: new Date(),
});
// Indirect invite (invited by user 2, who was invited by user 1)
const indirectInvite1 = createMockAccount({
userId: '300000001',
accountSequence: 4,
nickname: '间接邀请1',
inviterSequence: 2,
registeredAt: new Date(),
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.findByInviterSequence
.mockResolvedValueOnce([directInvite1, directInvite2]) // Direct invites of user 1
.mockResolvedValueOnce([indirectInvite1]) // Indirect invites via user 2
.mockResolvedValueOnce([]); // Indirect invites via user 3 (none)
const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789')
);
expect(result).toEqual({
referralCode: 'ABC123',
totalInvites: 3, // 2 direct + 1 indirect
directInvites: 2,
indirectInvites: 1,
todayInvites: expect.any(Number),
thisWeekInvites: expect.any(Number),
thisMonthInvites: expect.any(Number),
recentInvites: expect.arrayContaining([
expect.objectContaining({
accountSequence: expect.any(Number),
nickname: expect.any(String),
level: expect.any(Number), // 1 for direct, 2 for indirect
}),
]),
});
expect(result.recentInvites.length).toBeLessThanOrEqual(20);
});
it('should return empty stats when no invites', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
accountSequence: 1,
referralCode: 'ABC123',
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.findByInviterSequence.mockResolvedValue([]);
const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789')
);
expect(result).toEqual({
referralCode: 'ABC123',
totalInvites: 0,
directInvites: 0,
indirectInvites: 0,
todayInvites: 0,
thisWeekInvites: 0,
thisMonthInvites: 0,
recentInvites: [],
});
});
it('should correctly calculate time-based stats', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
accountSequence: 1,
referralCode: 'ABC123',
});
const now = new Date();
const todayInvite = createMockAccount({
accountSequence: 2,
nickname: '今日邀请',
inviterSequence: 1,
registeredAt: now,
});
const yesterdayInvite = createMockAccount({
accountSequence: 3,
nickname: '昨日邀请',
inviterSequence: 1,
registeredAt: new Date(now.getTime() - 24 * 60 * 60 * 1000), // yesterday
});
const lastMonthInvite = createMockAccount({
accountSequence: 4,
nickname: '上月邀请',
inviterSequence: 1,
registeredAt: new Date(now.getFullYear(), now.getMonth() - 1, 15),
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.findByInviterSequence
.mockResolvedValueOnce([todayInvite, yesterdayInvite, lastMonthInvite])
.mockResolvedValue([]); // No second-level invites
const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789')
);
expect(result.directInvites).toBe(3);
expect(result.todayInvites).toBe(1); // Only today's invite
});
it('should throw error when user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
// Use valid numeric string for userId
await expect(
service.getReferralStats(new GetReferralStatsQuery('999999999'))
).rejects.toThrow(ApplicationError);
});
it('should sort recent invites by registration date (newest first)', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
accountSequence: 1,
referralCode: 'ABC123',
});
const now = new Date();
const oldInvite = createMockAccount({
accountSequence: 2,
nickname: '旧邀请',
inviterSequence: 1,
registeredAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), // 7 days ago
});
const newInvite = createMockAccount({
accountSequence: 3,
nickname: '新邀请',
inviterSequence: 1,
registeredAt: now,
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
mockUserRepository.findByInviterSequence
.mockResolvedValueOnce([oldInvite, newInvite])
.mockResolvedValue([]);
const result = await service.getReferralStats(
new GetReferralStatsQuery('123456789')
);
// Newest should be first
expect(result.recentInvites[0].nickname).toBe('新邀请');
expect(result.recentInvites[1].nickname).toBe('旧邀请');
});
});
// ============ Short Code Generation Tests ============
describe('generateShortCode (private method behavior)', () => {
it('should generate short codes with 6 characters', async () => {
const mockAccount = createMockAccount({
userId: '123456789',
referralCode: 'ABC123',
});
mockUserRepository.findById.mockResolvedValue(mockAccount);
// Generate multiple links and verify short codes have correct length
for (let i = 0; i < 5; i++) {
const mockLinkData: ReferralLinkData = {
linkId: BigInt(i + 1),
userId: BigInt(123456789),
referralCode: 'ABC123',
shortCode: `code${i}`,
channel: null,
campaignId: null,
createdAt: new Date(),
};
mockUserRepository.createReferralLink.mockResolvedValueOnce(mockLinkData);
await service.generateReferralLink(
new GenerateReferralLinkCommand('123456789', `channel${i}`)
);
}
// All generated short codes should have 6 characters
const createReferralLinkCalls = mockUserRepository.createReferralLink.mock.calls;
createReferralLinkCalls.forEach((call) => {
const params = call[0] as CreateReferralLinkParams;
expect(params.shortCode).toHaveLength(6);
// Should not contain confusing characters (I, l, O, 0, 1)
expect(params.shortCode).not.toMatch(/[IlO01]/);
});
});
});
});

View File

@ -15,14 +15,17 @@ import { RedisService } from '@/infrastructure/redis/redis.service';
import { SmsService } from '@/infrastructure/external/sms/sms.service';
import { EventPublisherService } from '@/infrastructure/kafka/event-publisher.service';
import { MpcWalletService } from '@/infrastructure/external/mpc';
import { BackupClientService } from '@/infrastructure/external/backup';
import { ApplicationError } from '@/shared/exceptions/domain.exception';
import {
AutoCreateAccountCommand, RecoverByMnemonicCommand, RecoverByPhoneCommand,
AutoLoginCommand, RegisterCommand, LoginCommand, BindPhoneNumberCommand,
UpdateProfileCommand, SubmitKYCCommand, ReviewKYCCommand, RemoveDeviceCommand,
SendSmsCodeCommand, GetMyProfileQuery, GetMyDevicesQuery, GetUserByReferralCodeQuery,
ValidateReferralCodeQuery, GetReferralStatsQuery, GenerateReferralLinkCommand,
AutoCreateAccountResult, RecoverAccountResult, AutoLoginResult, RegisterResult,
LoginResult, UserProfileDTO, DeviceDTO, UserBriefDTO,
ReferralCodeValidationResult, ReferralLinkResult, ReferralStatsResult, MeResult,
} from '../commands';
@Injectable()
@ -38,6 +41,7 @@ export class UserApplicationService {
private readonly validatorService: UserValidatorService,
private readonly walletGenerator: WalletGeneratorService,
private readonly mpcWalletService: MpcWalletService,
private readonly backupClient: BackupClientService,
private readonly tokenService: TokenService,
private readonly redisService: RedisService,
private readonly smsService: SmsService,
@ -122,6 +126,18 @@ export class UserApplicationService {
});
this.logger.log(`Server MPC share saved for user: ${account.userId.toString()}`);
// 10. 保存备份 MPC 分片到 backup-service (异地服务器)
// 注意: backup-service 必须部署在不同物理服务器,否则 MPC 安全性失效
if (mpcResult.backupShareData) {
await this.backupClient.storeBackupShare({
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
publicKey: mpcResult.publicKey,
encryptedShareData: mpcResult.backupShareData,
});
this.logger.log(`Backup MPC share sent to backup-service for user: ${account.userId.toString()}`);
}
// 11. 生成 Token
const tokens = await this.tokenService.generateTokenPair({
userId: account.userId.toString(),
@ -481,4 +497,163 @@ export class UserApplicationService {
private generateSmsCode(): string {
return String(Math.floor(100000 + Math.random() * 900000));
}
// ============ 推荐/分享相关 API ============
/**
* (GET /api/me)
*/
async getMe(userId: string): Promise<MeResult> {
const account = await this.userRepository.findById(UserId.create(userId));
if (!account) throw new ApplicationError('用户不存在');
const baseUrl = 'https://app.rwadurian.com'; // TODO: 从配置读取
const referralLink = `${baseUrl}/invite/${account.referralCode.value}`;
return {
userId: account.userId.toString(),
accountSequence: account.accountSequence.value,
phoneNumber: account.phoneNumber?.masked() || null,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
referralCode: account.referralCode.value,
referralLink,
walletAddresses: account.getAllWalletAddresses().map((wa) => ({
chainType: wa.chainType,
address: wa.address,
})),
kycStatus: account.kycStatus,
status: account.status,
registeredAt: account.registeredAt,
};
}
/**
* (GET /api/referrals/validate)
*/
async validateReferralCode(query: ValidateReferralCodeQuery): Promise<ReferralCodeValidationResult> {
try {
const referralCode = ReferralCode.create(query.referralCode);
const account = await this.userRepository.findByReferralCode(referralCode);
if (!account) {
return { valid: false, message: '推荐码不存在' };
}
if (!account.isActive) {
return { valid: false, message: '推荐人账户已冻结' };
}
return {
valid: true,
referralCode: account.referralCode.value,
inviterInfo: {
accountSequence: account.accountSequence.value,
nickname: account.nickname,
avatarUrl: account.avatarUrl,
},
};
} catch (error) {
return { valid: false, message: '推荐码格式无效' };
}
}
/**
* (POST /api/referrals/links)
*/
async generateReferralLink(command: GenerateReferralLinkCommand): Promise<ReferralLinkResult> {
const account = await this.userRepository.findById(UserId.create(command.userId));
if (!account) throw new ApplicationError('用户不存在');
// 生成短链码 (6位随机字符)
const shortCode = this.generateShortCode();
const baseUrl = 'https://app.rwadurian.com'; // TODO: 从配置读取
// 保存到数据库
const link = await this.userRepository.createReferralLink({
userId: account.userId.value,
referralCode: account.referralCode.value,
shortCode,
channel: command.channel || null,
campaignId: command.campaignId || null,
});
return {
linkId: link.linkId.toString(),
referralCode: account.referralCode.value,
shortUrl: `${baseUrl}/r/${shortCode}`,
fullUrl: `${baseUrl}/invite/${account.referralCode.value}?ch=${command.channel || 'default'}`,
channel: command.channel || null,
campaignId: command.campaignId || null,
createdAt: link.createdAt,
};
}
/**
* (GET /api/referrals/stats)
*/
async getReferralStats(query: GetReferralStatsQuery): Promise<ReferralStatsResult> {
const account = await this.userRepository.findById(UserId.create(query.userId));
if (!account) throw new ApplicationError('用户不存在');
// 查询直接邀请的用户
const directInvites = await this.userRepository.findByInviterSequence(account.accountSequence);
// 查询间接邀请 (二级)
let indirectInvites: typeof directInvites = [];
for (const invite of directInvites) {
const secondLevel = await this.userRepository.findByInviterSequence(invite.accountSequence);
indirectInvites = indirectInvites.concat(secondLevel);
}
// 时间统计
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekStart = new Date(todayStart);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const todayInvites = directInvites.filter(u => u.registeredAt >= todayStart).length;
const thisWeekInvites = directInvites.filter(u => u.registeredAt >= weekStart).length;
const thisMonthInvites = directInvites.filter(u => u.registeredAt >= monthStart).length;
// 合并并排序最近邀请
interface InviteRecord {
account: UserAccount;
level: number;
}
const allInvites: InviteRecord[] = [
...directInvites.map(u => ({ account: u, level: 1 })),
...indirectInvites.map(u => ({ account: u, level: 2 })),
].sort((a, b) => b.account.registeredAt.getTime() - a.account.registeredAt.getTime()).slice(0, 20);
return {
referralCode: account.referralCode.value,
totalInvites: directInvites.length + indirectInvites.length,
directInvites: directInvites.length,
indirectInvites: indirectInvites.length,
todayInvites,
thisWeekInvites,
thisMonthInvites,
recentInvites: allInvites.map(({ account: u, level }) => ({
accountSequence: u.accountSequence.value,
nickname: u.nickname,
avatarUrl: u.avatarUrl,
registeredAt: u.registeredAt,
level,
})),
};
}
/**
*
*/
private generateShortCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
let result = '';
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View File

@ -1,5 +1,5 @@
import { Module } from '@nestjs/common';
import { AccountSequenceGeneratorService, UserValidatorService, WalletGeneratorService } from './services';
import { AccountSequenceGeneratorService, UserValidatorService } from './services';
import { UserAccountFactory } from './aggregates/user-account/user-account.factory';
import { USER_ACCOUNT_REPOSITORY } from './repositories/user-account.repository.interface';
import { UserAccountRepositoryImpl } from '@/infrastructure/persistence/repositories/user-account.repository.impl';
@ -11,14 +11,14 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
{ provide: USER_ACCOUNT_REPOSITORY, useClass: UserAccountRepositoryImpl },
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
// WalletGeneratorService 由 InfrastructureModule 提供
UserAccountFactory,
],
exports: [
USER_ACCOUNT_REPOSITORY,
AccountSequenceGeneratorService,
UserValidatorService,
WalletGeneratorService,
// WalletGeneratorService 由 InfrastructureModule 导出
UserAccountFactory,
],
})

View File

@ -9,6 +9,24 @@ export interface Pagination {
limit: number;
}
export interface ReferralLinkData {
linkId: bigint;
userId: bigint;
referralCode: string;
shortCode: string;
channel: string | null;
campaignId: string | null;
createdAt: Date;
}
export interface CreateReferralLinkParams {
userId: bigint;
referralCode: string;
shortCode: string;
channel: string | null;
campaignId: string | null;
}
export interface UserAccountRepository {
save(account: UserAccount): Promise<void>;
saveWallets(userId: UserId, wallets: WalletAddress[]): Promise<void>;
@ -25,6 +43,11 @@ export interface UserAccountRepository {
pagination?: Pagination,
): Promise<UserAccount[]>;
countUsers(filters?: { status?: AccountStatus; kycStatus?: KYCStatus }): Promise<number>;
// 推荐相关
findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]>;
createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData>;
findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]>;
}
export const USER_ACCOUNT_REPOSITORY = Symbol('USER_ACCOUNT_REPOSITORY');

View File

@ -1,11 +1,14 @@
import { Injectable, Inject } from '@nestjs/common';
import { createHash } from 'crypto';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { UserAccountRepository, USER_ACCOUNT_REPOSITORY } from '@/domain/repositories/user-account.repository.interface';
import {
AccountSequence, PhoneNumber, ReferralCode, ChainType, Mnemonic, UserId,
} from '@/domain/value-objects';
import { AccountSequence, PhoneNumber, ReferralCode, ChainType } from '@/domain/value-objects';
// 导出 WalletGeneratorService 和相关类型
export {
WalletGeneratorService,
MpcWalletGenerationParams,
MpcWalletGenerationResult,
ChainWalletInfo,
} from './wallet-generator.service';
// ============ ValidationResult ============
export class ValidationResult {
@ -69,53 +72,3 @@ export class UserValidatorService {
return ValidationResult.success();
}
}
// ============ WalletGeneratorService ============
@Injectable()
export class WalletGeneratorService {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic: params.mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return wallets;
}
private deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
}

View File

@ -1,44 +1,73 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { WalletAddress, MpcSignature } from '@/domain/entities/wallet-address.entity';
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
@Injectable()
export class WalletGeneratorService {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
/**
* MPC
*/
export interface MpcWalletGenerationParams {
userId: string;
deviceId: string;
}
/**
*
*/
export interface ChainWalletInfo {
chainType: 'KAVA' | 'DST' | 'BSC';
address: string;
publicKey: string;
addressDigest: string;
signature: MpcSignature;
}
/**
* MPC
*/
export interface MpcWalletGenerationResult {
publicKey: string;
serverShareData: string;
clientShareData: string;
backupShareData: string;
wallets: ChainWalletInfo[];
sessionId: string;
}
/**
* ()
*
*
*/
export abstract class WalletGeneratorService {
/**
* 使 MPC 2-of-3
*
* @param params ID和设备ID
* @returns MPC
*/
abstract generateMpcWalletSystem(params: MpcWalletGenerationParams): Promise<MpcWalletGenerationResult>;
/**
* MPC
*
* @param userId ID
* @param walletInfos MPC
* @returns Map
*/
convertToWalletEntities(
userId: UserId,
walletInfos: ChainWalletInfo[],
): Map<ChainType, WalletAddress> {
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
for (const info of walletInfos) {
const chainType = ChainType[info.chainType as keyof typeof ChainType];
const wallet = WalletAddress.createMpc({
userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic: params.mnemonic,
encryptionKey,
address: info.address,
publicKey: info.publicKey,
addressDigest: info.addressDigest,
signature: info.signature,
});
wallets.set(chainType, wallet);
}
@ -46,8 +75,13 @@ export class WalletGeneratorService {
return wallets;
}
private deriveEncryptionKey(deviceId: string, userId: string): string {
const input = `${deviceId}:${userId}`;
return createHash('sha256').update(input).digest('hex');
}
/**
* @deprecated MPC 使
*
*/
abstract recoverWalletSystem(params: {
userId: UserId;
mnemonic: Mnemonic;
deviceId: string;
}): Map<ChainType, WalletAddress>;
}

View File

@ -0,0 +1,192 @@
/**
* Backup Client Service
*
* backup-service
* MPC Backup Share (Party 2)
*
* 安全要求: backup-service identity-service
*/
import { Injectable, Logger } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import * as jwt from 'jsonwebtoken';
export interface StoreBackupShareParams {
userId: string;
accountSequence: number;
publicKey: string;
encryptedShareData: string;
}
export interface RetrieveBackupShareParams {
userId: string;
publicKey: string;
recoveryToken: string;
deviceId?: string;
}
export interface BackupShareResult {
encryptedShareData: string;
partyIndex: number;
publicKey: string;
}
@Injectable()
export class BackupClientService {
private readonly logger = new Logger(BackupClientService.name);
private readonly backupServiceUrl: string;
private readonly serviceJwtSecret: string;
private readonly enabled: boolean;
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.backupServiceUrl = this.configService.get<string>('BACKUP_SERVICE_URL', 'http://localhost:3002');
this.serviceJwtSecret = this.configService.get<string>('SERVICE_JWT_SECRET', '');
this.enabled = this.configService.get<string>('BACKUP_SERVICE_ENABLED', 'false') === 'true';
}
/**
* backup-service
*/
isEnabled(): boolean {
return this.enabled && !!this.serviceJwtSecret;
}
/**
* backup-service
*/
async storeBackupShare(params: StoreBackupShareParams): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn('Backup service is disabled, skipping backup share storage');
return;
}
this.logger.log(`Storing backup share for user: ${params.userId}`);
try {
const serviceToken = this.generateServiceToken();
await firstValueFrom(
this.httpService.post(
`${this.backupServiceUrl}/backup-share/store`,
{
userId: params.userId,
accountSequence: params.accountSequence,
publicKey: params.publicKey,
encryptedShareData: params.encryptedShareData,
},
{
headers: {
'Content-Type': 'application/json',
'X-Service-Token': serviceToken,
},
timeout: 30000, // 30秒超时
},
),
);
this.logger.log(`Backup share stored successfully for user: ${params.userId}`);
} catch (error) {
this.logger.error(`Failed to store backup share for user: ${params.userId}`, error);
// 不抛出异常,允许账户创建继续
// 可以通过补偿任务稍后重试
}
}
/**
* backup-service ()
*/
async retrieveBackupShare(params: RetrieveBackupShareParams): Promise<BackupShareResult | null> {
if (!this.isEnabled()) {
this.logger.warn('Backup service is disabled');
return null;
}
this.logger.log(`Retrieving backup share for user: ${params.userId}`);
try {
const serviceToken = this.generateServiceToken();
const response = await firstValueFrom(
this.httpService.post<BackupShareResult>(
`${this.backupServiceUrl}/backup-share/retrieve`,
{
userId: params.userId,
publicKey: params.publicKey,
recoveryToken: params.recoveryToken,
deviceId: params.deviceId,
},
{
headers: {
'Content-Type': 'application/json',
'X-Service-Token': serviceToken,
},
timeout: 30000,
},
),
);
this.logger.log(`Backup share retrieved successfully for user: ${params.userId}`);
return response.data;
} catch (error) {
this.logger.error(`Failed to retrieve backup share for user: ${params.userId}`, error);
throw new Error(`Failed to retrieve backup share: ${error.message}`);
}
}
/**
* ()
*/
async revokeBackupShare(userId: string, publicKey: string, reason: string): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn('Backup service is disabled');
return;
}
this.logger.log(`Revoking backup share for user: ${userId}, reason: ${reason}`);
try {
const serviceToken = this.generateServiceToken();
await firstValueFrom(
this.httpService.post(
`${this.backupServiceUrl}/backup-share/revoke`,
{
userId,
publicKey,
reason,
},
{
headers: {
'Content-Type': 'application/json',
'X-Service-Token': serviceToken,
},
timeout: 30000,
},
),
);
this.logger.log(`Backup share revoked successfully for user: ${userId}`);
} catch (error) {
this.logger.error(`Failed to revoke backup share for user: ${userId}`, error);
}
}
/**
* JWT
*/
private generateServiceToken(): string {
return jwt.sign(
{
service: 'identity-service',
iat: Math.floor(Date.now() / 1000),
},
this.serviceJwtSecret,
{ expiresIn: '5m' },
);
}
}

View File

@ -0,0 +1,2 @@
export * from './backup-client.service';
export * from './mpc-share-storage.service';

View File

@ -0,0 +1,89 @@
/**
* MPC Share Storage Service
*
* MPC
*/
import { Injectable, Logger } from '@nestjs/common';
import { BackupClientService } from './backup-client.service';
export interface MpcStoreBackupShareParams {
userId: string;
shareData: string;
publicKey: string;
accountSequence?: number;
}
export interface MpcRetrieveBackupShareParams {
userId: string;
publicKey: string;
recoveryToken: string;
deviceId?: string;
}
export interface MpcBackupShareData {
encryptedShareData: string;
partyIndex: number;
publicKey: string;
}
@Injectable()
export class MpcShareStorageService {
private readonly logger = new Logger(MpcShareStorageService.name);
constructor(private readonly backupClient: BackupClientService) {}
/**
*
*
* @param params
*/
async storeBackupShare(params: MpcStoreBackupShareParams): Promise<void> {
this.logger.log(`Storing backup share for user=${params.userId}`);
await this.backupClient.storeBackupShare({
userId: params.userId,
accountSequence: params.accountSequence || 0,
publicKey: params.publicKey,
encryptedShareData: params.shareData,
});
}
/**
* ()
*
* @param params
* @returns null
*/
async retrieveBackupShare(
params: MpcRetrieveBackupShareParams,
): Promise<MpcBackupShareData | null> {
this.logger.log(`Retrieving backup share for user=${params.userId}`);
return this.backupClient.retrieveBackupShare(params);
}
/**
*
*
* @param userId ID
* @param publicKey MPC
* @param reason
*/
async revokeBackupShare(
userId: string,
publicKey: string,
reason: string,
): Promise<void> {
this.logger.log(`Revoking backup share for user=${userId}`);
await this.backupClient.revokeBackupShare(userId, publicKey, reason);
}
/**
*
*/
isEnabled(): boolean {
return this.backupClient.isEnabled();
}
}

View File

@ -0,0 +1,178 @@
import { Injectable, Logger } from '@nestjs/common';
import { ethers, JsonRpcProvider, Contract } from 'ethers';
/**
* USDT
*/
export interface UsdtBalance {
chainType: string;
address: string;
balance: string; // 格式化后的余额 (带小数)
rawBalance: string; // 原始余额 (wei)
decimals: number;
}
/**
*
*/
interface ChainConfig {
rpcUrl: string;
usdtContract: string;
decimals: number;
name: string;
}
/**
* ERC20 ABI ( balanceOf)
*/
const ERC20_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)',
];
/**
*
*
* KAVA EVM BSC USDT
*/
@Injectable()
export class BlockchainQueryService {
private readonly logger = new Logger(BlockchainQueryService.name);
/**
*
*/
private readonly chainConfigs: Record<string, ChainConfig> = {
KAVA: {
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
// KAVA EVM 原生 USDT 合约地址 (Tether 官方发行)
usdtContract: '0x919C1c267BC06a7039e03fcc2eF738525769109c',
decimals: 6,
name: 'KAVA EVM',
},
BSC: {
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
// BSC USDT 合约地址 (Binance-Peg)
usdtContract: '0x55d398326f99059fF775485246999027B3197955',
decimals: 18,
name: 'BSC',
},
};
/**
* Provider
*/
private providers: Map<string, JsonRpcProvider> = new Map();
/**
* Provider
*/
private getProvider(chainType: string): JsonRpcProvider {
if (this.providers.has(chainType)) {
return this.providers.get(chainType)!;
}
const config = this.chainConfigs[chainType];
if (!config) {
throw new Error(`不支持的链类型: ${chainType}`);
}
const provider = new JsonRpcProvider(config.rpcUrl, undefined, {
staticNetwork: true,
});
this.providers.set(chainType, provider);
return provider;
}
/**
* USDT
*
* @param chainType (KAVA BSC)
* @param address EVM
*/
async getUsdtBalance(chainType: string, address: string): Promise<UsdtBalance> {
const config = this.chainConfigs[chainType];
if (!config) {
throw new Error(`不支持的链类型: ${chainType}`);
}
// 验证地址格式
if (!ethers.isAddress(address)) {
throw new Error(`无效的 EVM 地址: ${address}`);
}
try {
this.logger.debug(`查询 ${config.name} USDT 余额: ${address}`);
const provider = this.getProvider(chainType);
const contract = new Contract(config.usdtContract, ERC20_ABI, provider);
const rawBalance = await contract.balanceOf(address);
const balance = ethers.formatUnits(rawBalance, config.decimals);
this.logger.debug(`${config.name} USDT 余额: ${balance}`);
return {
chainType,
address,
balance,
rawBalance: rawBalance.toString(),
decimals: config.decimals,
};
} catch (error) {
this.logger.error(`查询 ${config.name} USDT 余额失败: ${error.message}`);
throw new Error(`查询余额失败: ${error.message}`);
}
}
/**
* USDT
*
* @param addresses { chainType, address }[]
*/
async getMultipleUsdtBalances(
addresses: Array<{ chainType: string; address: string }>,
): Promise<UsdtBalance[]> {
const results = await Promise.allSettled(
addresses.map(({ chainType, address }) => this.getUsdtBalance(chainType, address)),
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
}
// 失败时返回零余额
this.logger.warn(`查询余额失败 [${addresses[index].chainType}:${addresses[index].address}]: ${result.reason}`);
return {
chainType: addresses[index].chainType,
address: addresses[index].address,
balance: '0',
rawBalance: '0',
decimals: this.chainConfigs[addresses[index].chainType]?.decimals || 6,
};
});
}
/**
* (KAVA / BNB)
*/
async getNativeBalance(chainType: string, address: string): Promise<string> {
const config = this.chainConfigs[chainType];
if (!config) {
throw new Error(`不支持的链类型: ${chainType}`);
}
if (!ethers.isAddress(address)) {
throw new Error(`无效的 EVM 地址: ${address}`);
}
try {
const provider = this.getProvider(chainType);
const rawBalance = await provider.getBalance(address);
return ethers.formatEther(rawBalance);
} catch (error) {
this.logger.error(`查询 ${config.name} 原生代币余额失败: ${error.message}`);
throw new Error(`查询余额失败: ${error.message}`);
}
}
}

View File

@ -1,35 +1,61 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import {
WalletGeneratorService,
MpcWalletGenerationParams,
MpcWalletGenerationResult,
} from '@/domain/services/wallet-generator.service';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import { ChainType, Mnemonic, UserId } from '@/domain/value-objects';
import { MpcWalletService } from '@/infrastructure/external/mpc/mpc-wallet.service';
/**
*
*
* 使 MPC 2-of-3
*/
@Injectable()
export class WalletGeneratorServiceImpl {
generateWalletSystem(params: { userId: UserId; deviceId: string }): {
mnemonic: Mnemonic;
wallets: Map<ChainType, WalletAddress>;
} {
const mnemonic = Mnemonic.generate();
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
export class WalletGeneratorServiceImpl extends WalletGeneratorService {
private readonly logger = new Logger(WalletGeneratorServiceImpl.name);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];
for (const chainType of chains) {
const wallet = WalletAddress.createFromMnemonic({
userId: params.userId,
chainType,
mnemonic,
encryptionKey,
});
wallets.set(chainType, wallet);
}
return { mnemonic, wallets };
constructor(private readonly mpcWalletService: MpcWalletService) {
super();
}
recoverWalletSystem(params: { userId: UserId; mnemonic: Mnemonic; deviceId: string }): Map<ChainType, WalletAddress> {
const encryptionKey = this.deriveEncryptionKey(params.deviceId, params.userId.toString());
/**
* 使 MPC 2-of-3
*/
async generateMpcWalletSystem(
params: MpcWalletGenerationParams,
): Promise<MpcWalletGenerationResult> {
this.logger.log(`Generating MPC wallet system for user=${params.userId}`);
const result = await this.mpcWalletService.generateMpcWallet(params);
this.logger.log(
`MPC wallet system generated: ${result.wallets.length} wallets, sessionId=${result.sessionId}`,
);
return result;
}
/**
* @deprecated MPC 使
*
*/
recoverWalletSystem(params: {
userId: UserId;
mnemonic: Mnemonic;
deviceId: string;
}): Map<ChainType, WalletAddress> {
this.logger.warn(
'recoverWalletSystem is deprecated - MPC mode does not use mnemonic recovery',
);
const encryptionKey = this.deriveEncryptionKey(
params.deviceId,
params.userId.toString(),
);
const wallets = new Map<ChainType, WalletAddress>();
const chains = [ChainType.KAVA, ChainType.DST, ChainType.BSC];

View File

@ -8,8 +8,11 @@ import { RedisService } from './redis/redis.service';
import { EventPublisherService } from './kafka/event-publisher.service';
import { SmsService } from './external/sms/sms.service';
import { WalletGeneratorServiceImpl } from './external/blockchain/wallet-generator.service.impl';
import { BlockchainQueryService } from './external/blockchain/blockchain-query.service';
import { MpcClientService, MpcWalletService } from './external/mpc';
import { BackupClientService, MpcShareStorageService } from './external/backup';
import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.repository.interface';
import { WalletGeneratorService } from '@/domain/services/wallet-generator.service';
@Global()
@Module({
@ -30,9 +33,17 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
RedisService,
EventPublisherService,
SmsService,
// WalletGeneratorService 抽象类由 WalletGeneratorServiceImpl 实现
WalletGeneratorServiceImpl,
{
provide: WalletGeneratorService,
useExisting: WalletGeneratorServiceImpl,
},
BlockchainQueryService,
MpcClientService,
MpcWalletService,
BackupClientService,
MpcShareStorageService,
],
exports: [
PrismaService,
@ -46,8 +57,12 @@ import { MPC_KEY_SHARE_REPOSITORY } from '@/domain/repositories/mpc-key-share.re
EventPublisherService,
SmsService,
WalletGeneratorServiceImpl,
WalletGeneratorService,
BlockchainQueryService,
MpcClientService,
MpcWalletService,
BackupClientService,
MpcShareStorageService,
],
})
export class InfrastructureModule {}

View File

@ -38,7 +38,11 @@ export interface WalletAddressEntity {
userId: bigint;
chainType: string;
address: string;
encryptedMnemonic: string | null;
publicKey: string;
addressDigest: string;
mpcSignatureR: string;
mpcSignatureS: string;
mpcSignatureV: number;
status: string;
boundAt: Date;
}

View File

@ -17,7 +17,11 @@ export class UserAccountMapper {
userId: w.userId.toString(),
chainType: w.chainType as ChainType,
address: w.address,
encryptedMnemonic: w.encryptedMnemonic || '',
publicKey: w.publicKey,
addressDigest: w.addressDigest,
mpcSignatureR: w.mpcSignatureR,
mpcSignatureS: w.mpcSignatureS,
mpcSignatureV: w.mpcSignatureV,
status: w.status as AddressStatus,
boundAt: w.boundAt,
}),

View File

@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
import { UserAccountRepository, Pagination } from '@/domain/repositories/user-account.repository.interface';
import {
UserAccountRepository, Pagination, ReferralLinkData, CreateReferralLinkParams,
} from '@/domain/repositories/user-account.repository.interface';
import { UserAccount } from '@/domain/aggregates/user-account/user-account.aggregate';
import { WalletAddress } from '@/domain/entities/wallet-address.entity';
import {
@ -250,4 +252,54 @@ export class UserAccountRepositoryImpl implements UserAccountRepository {
updatedAt: data.updatedAt,
});
}
// ============ 推荐相关 ============
async findByInviterSequence(inviterSequence: AccountSequence): Promise<UserAccount[]> {
const data = await this.prisma.userAccount.findMany({
where: { inviterSequence: BigInt(inviterSequence.value) },
include: { devices: true, walletAddresses: true },
orderBy: { registeredAt: 'desc' },
});
return data.map((d) => this.toDomain(d));
}
async createReferralLink(params: CreateReferralLinkParams): Promise<ReferralLinkData> {
const result = await this.prisma.referralLink.create({
data: {
userId: params.userId,
referralCode: params.referralCode,
shortCode: params.shortCode,
channel: params.channel,
campaignId: params.campaignId,
},
});
return {
linkId: result.linkId,
userId: result.userId,
referralCode: result.referralCode,
shortCode: result.shortCode,
channel: result.channel,
campaignId: result.campaignId,
createdAt: result.createdAt,
};
}
async findReferralLinksByUserId(userId: UserId): Promise<ReferralLinkData[]> {
const results = await this.prisma.referralLink.findMany({
where: { userId: BigInt(userId.value) },
orderBy: { createdAt: 'desc' },
});
return results.map((r) => ({
linkId: r.linkId,
userId: r.userId,
referralCode: r.referralCode,
shortCode: r.shortCode,
channel: r.channel,
campaignId: r.campaignId,
createdAt: r.createdAt,
}));
}
}

View File

@ -0,0 +1,757 @@
# Wallet Service 开发指导
## 项目概述
Wallet Service 是 RWA 榴莲女皇平台的钱包账本微服务,负责管理用户的平台内部余额、充值入账、提现、资金流水记账等功能。
## 技术栈
- **框架**: NestJS
- **数据库**: PostgreSQL + Prisma ORM
- **架构**: DDD + Hexagonal Architecture (六边形架构)
- **语言**: TypeScript
## 架构参考
请参考 `identity-service` 的架构模式,保持一致性:
```
wallet-service/
├── prisma/
│ └── schema.prisma # 数据库模型
├── src/
│ ├── api/ # Presentation Layer (API层)
│ │ ├── controllers/ # HTTP 控制器
│ │ │ ├── wallet.controller.ts
│ │ │ ├── ledger.controller.ts
│ │ │ ├── deposit.controller.ts
│ │ │ └── settlement.controller.ts
│ │ ├── dto/ # 数据传输对象
│ │ │ ├── wallet.dto.ts
│ │ │ ├── ledger.dto.ts
│ │ │ ├── deposit.dto.ts
│ │ │ └── settlement.dto.ts
│ │ └── api.module.ts
│ │
│ ├── application/ # Application Layer (应用层)
│ │ ├── commands/ # 命令对象
│ │ │ ├── handle-deposit.command.ts
│ │ │ ├── deduct-for-planting.command.ts
│ │ │ ├── allocate-funds.command.ts
│ │ │ ├── add-rewards.command.ts
│ │ │ ├── settle-rewards.command.ts
│ │ │ └── withdraw.command.ts
│ │ ├── queries/ # 查询对象
│ │ │ ├── get-my-wallet.query.ts
│ │ │ └── get-my-ledger.query.ts
│ │ └── services/
│ │ └── wallet-application.service.ts
│ │
│ ├── domain/ # Domain Layer (领域层)
│ │ ├── aggregates/ # 聚合根
│ │ │ ├── wallet-account.aggregate.ts
│ │ │ ├── ledger-entry.aggregate.ts
│ │ │ ├── deposit-order.aggregate.ts
│ │ │ └── settlement-order.aggregate.ts
│ │ ├── value-objects/ # 值对象
│ │ │ ├── wallet-id.vo.ts
│ │ │ ├── money.vo.ts
│ │ │ ├── balance.vo.ts
│ │ │ ├── wallet-balances.vo.ts
│ │ │ ├── wallet-rewards.vo.ts
│ │ │ ├── hashpower.vo.ts
│ │ │ ├── asset-type.enum.ts
│ │ │ ├── chain-type.enum.ts
│ │ │ ├── wallet-status.enum.ts
│ │ │ ├── ledger-entry-type.enum.ts
│ │ │ ├── deposit-status.enum.ts
│ │ │ └── settlement-status.enum.ts
│ │ ├── events/ # 领域事件
│ │ │ ├── deposit-completed.event.ts
│ │ │ ├── withdrawal-requested.event.ts
│ │ │ ├── reward-moved-to-settleable.event.ts
│ │ │ ├── reward-expired.event.ts
│ │ │ └── settlement-completed.event.ts
│ │ ├── repositories/ # 仓储接口 (Port)
│ │ │ ├── wallet-account.repository.interface.ts
│ │ │ ├── ledger-entry.repository.interface.ts
│ │ │ ├── deposit-order.repository.interface.ts
│ │ │ └── settlement-order.repository.interface.ts
│ │ └── services/ # 领域服务
│ │ └── wallet-ledger.service.ts
│ │
│ ├── infrastructure/ # Infrastructure Layer (基础设施层)
│ │ ├── persistence/ # 持久化实现 (Adapter)
│ │ │ ├── entities/ # Prisma 实体映射
│ │ │ ├── mappers/ # 领域模型与实体的映射
│ │ │ └── repositories/ # 仓储实现
│ │ └── infrastructure.module.ts
│ │
│ ├── app.module.ts
│ └── main.ts
├── .env.development
├── .env.example
├── package.json
└── tsconfig.json
```
---
## 第一阶段:项目初始化
### 1.1 创建 NestJS 项目
```bash
cd backend/services
npx @nestjs/cli new wallet-service --skip-git --package-manager npm
cd wallet-service
```
### 1.2 安装依赖
```bash
npm install @nestjs/config @prisma/client class-validator class-transformer uuid
npm install -D prisma @types/uuid
```
### 1.3 配置环境变量
创建 `.env.development`:
```env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/rwadurian_wallet?schema=public"
NODE_ENV=development
PORT=3002
```
创建 `.env.example`:
```env
DATABASE_URL="postgresql://user:password@host:5432/database?schema=public"
NODE_ENV=development
PORT=3002
```
---
## 第二阶段:数据库设计 (Prisma Schema)
### 2.1 创建 prisma/schema.prisma
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// 钱包账户表 (状态表)
// ============================================
model WalletAccount {
id BigInt @id @default(autoincrement()) @map("wallet_id")
userId BigInt @unique @map("user_id")
// USDT 余额
usdtAvailable Decimal @default(0) @map("usdt_available") @db.Decimal(20, 8)
usdtFrozen Decimal @default(0) @map("usdt_frozen") @db.Decimal(20, 8)
// DST 余额
dstAvailable Decimal @default(0) @map("dst_available") @db.Decimal(20, 8)
dstFrozen Decimal @default(0) @map("dst_frozen") @db.Decimal(20, 8)
// BNB 余额
bnbAvailable Decimal @default(0) @map("bnb_available") @db.Decimal(20, 8)
bnbFrozen Decimal @default(0) @map("bnb_frozen") @db.Decimal(20, 8)
// OG 余额
ogAvailable Decimal @default(0) @map("og_available") @db.Decimal(20, 8)
ogFrozen Decimal @default(0) @map("og_frozen") @db.Decimal(20, 8)
// RWAD 余额
rwadAvailable Decimal @default(0) @map("rwad_available") @db.Decimal(20, 8)
rwadFrozen Decimal @default(0) @map("rwad_frozen") @db.Decimal(20, 8)
// 算力
hashpower Decimal @default(0) @map("hashpower") @db.Decimal(20, 8)
// 待领取收益
pendingUsdt Decimal @default(0) @map("pending_usdt") @db.Decimal(20, 8)
pendingHashpower Decimal @default(0) @map("pending_hashpower") @db.Decimal(20, 8)
pendingExpireAt DateTime? @map("pending_expire_at")
// 可结算收益
settleableUsdt Decimal @default(0) @map("settleable_usdt") @db.Decimal(20, 8)
settleableHashpower Decimal @default(0) @map("settleable_hashpower") @db.Decimal(20, 8)
// 已结算总额
settledTotalUsdt Decimal @default(0) @map("settled_total_usdt") @db.Decimal(20, 8)
settledTotalHashpower Decimal @default(0) @map("settled_total_hashpower") @db.Decimal(20, 8)
// 已过期总额
expiredTotalUsdt Decimal @default(0) @map("expired_total_usdt") @db.Decimal(20, 8)
expiredTotalHashpower Decimal @default(0) @map("expired_total_hashpower") @db.Decimal(20, 8)
// 状态
status String @default("ACTIVE") @map("status") @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("wallet_accounts")
@@index([userId])
@@index([usdtAvailable(sort: Desc)])
@@index([hashpower(sort: Desc)])
@@index([status])
}
// ============================================
// 账本流水表 (行为表, append-only)
// ============================================
model LedgerEntry {
id BigInt @id @default(autoincrement()) @map("entry_id")
userId BigInt @map("user_id")
// 流水类型
entryType String @map("entry_type") @db.VarChar(50)
// 金额变动 (正数入账, 负数支出)
amount Decimal @map("amount") @db.Decimal(20, 8)
assetType String @map("asset_type") @db.VarChar(20)
// 余额快照 (操作后余额)
balanceAfter Decimal? @map("balance_after") @db.Decimal(20, 8)
// 关联引用
refOrderId String? @map("ref_order_id") @db.VarChar(100)
refTxHash String? @map("ref_tx_hash") @db.VarChar(100)
// 备注
memo String? @map("memo") @db.VarChar(500)
// 扩展数据
payloadJson Json? @map("payload_json")
createdAt DateTime @default(now()) @map("created_at")
@@map("wallet_ledger_entries")
@@index([userId, createdAt(sort: Desc)])
@@index([entryType])
@@index([assetType])
@@index([refOrderId])
@@index([refTxHash])
@@index([createdAt])
}
// ============================================
// 充值订单表
// ============================================
model DepositOrder {
id BigInt @id @default(autoincrement()) @map("order_id")
userId BigInt @map("user_id")
// 充值信息
chainType String @map("chain_type") @db.VarChar(20)
amount Decimal @map("amount") @db.Decimal(20, 8)
txHash String @unique @map("tx_hash") @db.VarChar(100)
// 状态
status String @default("PENDING") @map("status") @db.VarChar(20)
confirmedAt DateTime? @map("confirmed_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("deposit_orders")
@@index([userId])
@@index([txHash])
@@index([status])
@@index([chainType])
}
// ============================================
// 结算订单表
// ============================================
model SettlementOrder {
id BigInt @id @default(autoincrement()) @map("order_id")
userId BigInt @map("user_id")
// 结算信息
usdtAmount Decimal @map("usdt_amount") @db.Decimal(20, 8)
settleCurrency String @map("settle_currency") @db.VarChar(10)
// SWAP 信息
swapTxHash String? @map("swap_tx_hash") @db.VarChar(100)
receivedAmount Decimal? @map("received_amount") @db.Decimal(20, 8)
// 状态
status String @default("PENDING") @map("status") @db.VarChar(20)
settledAt DateTime? @map("settled_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("settlement_orders")
@@index([userId])
@@index([status])
@@index([settleCurrency])
@@index([createdAt])
}
```
### 2.2 初始化数据库
```bash
npx prisma migrate dev --name init
npx prisma generate
```
---
## 第三阶段:领域层实现
### 3.1 值对象 (Value Objects)
#### 3.1.1 asset-type.enum.ts
```typescript
export enum AssetType {
USDT = 'USDT',
DST = 'DST',
BNB = 'BNB',
OG = 'OG',
RWAD = 'RWAD',
HASHPOWER = 'HASHPOWER',
}
```
#### 3.1.2 chain-type.enum.ts
```typescript
export enum ChainType {
KAVA = 'KAVA',
DST = 'DST',
BSC = 'BSC',
}
```
#### 3.1.3 wallet-status.enum.ts
```typescript
export enum WalletStatus {
ACTIVE = 'ACTIVE',
FROZEN = 'FROZEN',
CLOSED = 'CLOSED',
}
```
#### 3.1.4 ledger-entry-type.enum.ts
```typescript
export enum LedgerEntryType {
DEPOSIT_KAVA = 'DEPOSIT_KAVA',
DEPOSIT_BSC = 'DEPOSIT_BSC',
PLANT_PAYMENT = 'PLANT_PAYMENT',
REWARD_PENDING = 'REWARD_PENDING',
REWARD_TO_SETTLEABLE = 'REWARD_TO_SETTLEABLE',
REWARD_EXPIRED = 'REWARD_EXPIRED',
REWARD_SETTLED = 'REWARD_SETTLED',
TRANSFER_TO_POOL = 'TRANSFER_TO_POOL',
SWAP_EXECUTED = 'SWAP_EXECUTED',
WITHDRAWAL = 'WITHDRAWAL',
TRANSFER_IN = 'TRANSFER_IN',
TRANSFER_OUT = 'TRANSFER_OUT',
FREEZE = 'FREEZE',
UNFREEZE = 'UNFREEZE',
}
```
#### 3.1.5 deposit-status.enum.ts
```typescript
export enum DepositStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
FAILED = 'FAILED',
}
```
#### 3.1.6 settlement-status.enum.ts
```typescript
export enum SettlementStatus {
PENDING = 'PENDING',
SWAPPING = 'SWAPPING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}
export enum SettleCurrency {
BNB = 'BNB',
OG = 'OG',
USDT = 'USDT',
DST = 'DST',
}
```
#### 3.1.7 money.vo.ts
```typescript
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string,
) {
if (amount < 0) {
throw new Error('Money amount cannot be negative');
}
}
static create(amount: number, currency: string): Money {
return new Money(amount, currency);
}
static USDT(amount: number): Money {
return new Money(amount, 'USDT');
}
static zero(currency: string = 'USDT'): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.ensureSameCurrency(other);
if (this.amount < other.amount) {
throw new Error('Insufficient balance');
}
return new Money(this.amount - other.amount, this.currency);
}
lessThan(other: Money): boolean {
this.ensureSameCurrency(other);
return this.amount < other.amount;
}
greaterThan(other: Money): boolean {
this.ensureSameCurrency(other);
return this.amount > other.amount;
}
isZero(): boolean {
return this.amount === 0;
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch');
}
}
}
```
#### 3.1.8 balance.vo.ts
```typescript
import { Money } from './money.vo';
export class Balance {
constructor(
public readonly available: Money,
public readonly frozen: Money,
) {}
static zero(): Balance {
return new Balance(Money.zero(), Money.zero());
}
add(amount: Money): Balance {
return new Balance(this.available.add(amount), this.frozen);
}
deduct(amount: Money): Balance {
if (this.available.lessThan(amount)) {
throw new Error('Insufficient available balance');
}
return new Balance(this.available.subtract(amount), this.frozen);
}
freeze(amount: Money): Balance {
if (this.available.lessThan(amount)) {
throw new Error('Insufficient available balance to freeze');
}
return new Balance(
this.available.subtract(amount),
this.frozen.add(amount),
);
}
unfreeze(amount: Money): Balance {
if (this.frozen.lessThan(amount)) {
throw new Error('Insufficient frozen balance to unfreeze');
}
return new Balance(
this.available.add(amount),
this.frozen.subtract(amount),
);
}
get total(): Money {
return this.available.add(this.frozen);
}
}
```
#### 3.1.9 hashpower.vo.ts
```typescript
export class Hashpower {
private constructor(public readonly value: number) {
if (value < 0) {
throw new Error('Hashpower cannot be negative');
}
}
static create(value: number): Hashpower {
return new Hashpower(value);
}
static zero(): Hashpower {
return new Hashpower(0);
}
add(other: Hashpower): Hashpower {
return new Hashpower(this.value + other.value);
}
subtract(other: Hashpower): Hashpower {
if (this.value < other.value) {
throw new Error('Insufficient hashpower');
}
return new Hashpower(this.value - other.value);
}
isZero(): boolean {
return this.value === 0;
}
}
```
### 3.2 聚合根 (Aggregates)
#### 3.2.1 wallet-account.aggregate.ts
实现 `WalletAccount` 聚合根,包含:
- 余额管理 (USDT/DST/BNB/OG/RWAD/算力)
- 收益汇总 (待领取/可结算/已结算/过期)
- 核心领域行为deposit, deduct, freeze, unfreeze, addPendingReward, movePendingToSettleable, settleReward, withdraw
#### 3.2.2 ledger-entry.aggregate.ts
实现 `LedgerEntry` 聚合根 (append-only),包含:
- 流水类型
- 金额变动
- 余额快照
- 关联引用
### 3.3 仓储接口 (Repository Interfaces)
#### 3.3.1 wallet-account.repository.interface.ts
```typescript
export interface IWalletAccountRepository {
save(wallet: WalletAccount): Promise<void>;
findById(walletId: bigint): Promise<WalletAccount | null>;
findByUserId(userId: bigint): Promise<WalletAccount | null>;
getOrCreate(userId: bigint): Promise<WalletAccount>;
findByUserIds(userIds: bigint[]): Promise<Map<string, WalletAccount>>;
}
export const WALLET_ACCOUNT_REPOSITORY = Symbol('IWalletAccountRepository');
```
#### 3.3.2 ledger-entry.repository.interface.ts
```typescript
export interface ILedgerEntryRepository {
save(entry: LedgerEntry): Promise<void>;
saveAll(entries: LedgerEntry[]): Promise<void>;
findByUserId(userId: bigint, filters?: LedgerFilters, pagination?: Pagination): Promise<LedgerEntry[]>;
findByRefOrderId(refOrderId: string): Promise<LedgerEntry[]>;
}
export const LEDGER_ENTRY_REPOSITORY = Symbol('ILedgerEntryRepository');
```
---
## 第四阶段:基础设施层实现
### 4.1 实体映射 (Entities)
创建 Prisma 实体到领域模型的映射类。
### 4.2 仓储实现 (Repository Implementations)
实现所有仓储接口,使用 Prisma Client 进行数据库操作。
---
## 第五阶段:应用层实现
### 5.1 命令对象 (Commands)
```typescript
// handle-deposit.command.ts
export class HandleDepositCommand {
constructor(
public readonly userId: string,
public readonly amount: number,
public readonly chainType: ChainType,
public readonly txHash: string,
) {}
}
// deduct-for-planting.command.ts
export class DeductForPlantingCommand {
constructor(
public readonly userId: string,
public readonly amount: number,
public readonly orderId: string,
) {}
}
```
### 5.2 应用服务 (Application Service)
实现 `WalletApplicationService`,包含所有用例:
- handleDeposit - 处理充值
- deductForPlanting - 认种扣款
- allocateFunds - 资金分配
- addRewards - 增加奖励
- movePendingToSettleable - 待领取→可结算
- settleRewards - 结算收益
- getMyWallet - 查询我的钱包
- getMyLedger - 查询我的流水
---
## 第六阶段API层实现
### 6.1 DTO 定义
```typescript
// wallet.dto.ts
export class WalletDTO {
walletId: string;
userId: string;
balances: BalancesDTO;
rewards: RewardsDTO;
status: string;
}
export class BalancesDTO {
usdt: BalanceDTO;
dst: BalanceDTO;
bnb: BalanceDTO;
og: BalanceDTO;
rwad: BalanceDTO;
hashpower: number;
}
export class BalanceDTO {
available: number;
frozen: number;
}
```
### 6.2 控制器实现
```typescript
// wallet.controller.ts
@Controller('wallet')
export class WalletController {
constructor(private readonly walletService: WalletApplicationService) {}
@Get('my-wallet')
@UseGuards(JwtAuthGuard)
async getMyWallet(@CurrentUser() user: User): Promise<WalletDTO> {
return this.walletService.getMyWallet({ userId: user.id });
}
}
// ledger.controller.ts
@Controller('wallet/ledger')
export class LedgerController {
constructor(private readonly walletService: WalletApplicationService) {}
@Get('my-ledger')
@UseGuards(JwtAuthGuard)
async getMyLedger(
@CurrentUser() user: User,
@Query() query: GetMyLedgerQueryDTO,
): Promise<LedgerEntryDTO[]> {
return this.walletService.getMyLedger({
userId: user.id,
...query,
});
}
}
```
---
## 第七阶段:模块配置
### 7.1 app.module.ts
```typescript
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
}),
ApiModule,
InfrastructureModule,
],
})
export class AppModule {}
```
---
## 关键业务规则 (不变式)
1. **余额不能为负**: 任何币种的可用余额不能为负数
2. **流水表只能追加**: 账本流水表只能 INSERT不能 UPDATE/DELETE
3. **每笔流水必须有余额快照**: 每笔流水必须记录操作后的余额快照
4. **结算金额不能超过可结算余额**: 结算金额必须 ≤ 可结算余额
---
## API 端点汇总
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| GET | /wallet/my-wallet | 查询我的钱包 | 需要 |
| GET | /wallet/ledger/my-ledger | 查询我的流水 | 需要 |
| POST | /wallet/deposit | 充值入账 (内部) | 服务间 |
| POST | /wallet/withdraw | 提现申请 | 需要 |
| POST | /wallet/settle | 结算收益 | 需要 |
---
## 开发顺序建议
1. 项目初始化和 Prisma Schema
2. 值对象实现
3. 聚合根实现
4. 仓储接口定义
5. 仓储实现
6. 领域服务实现
7. 应用服务实现
8. DTO 和控制器实现
9. 模块配置和测试
---
## 注意事项
1. 所有金额使用 `Decimal(20, 8)` 存储,避免浮点数精度问题
2. 流水表是 append-only不允许更新或删除
3. 每次余额变动都要创建对应的流水记录
4. 使用事务确保余额和流水的一致性
5. 参考 identity-service 的代码风格和命名规范

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@ -0,0 +1,3 @@
<svg width="25" height="28" viewBox="0 0 25 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.899 23.7222L6.17676 13.9999L15.899 4.27772L17.6247 6.00342L9.62815 13.9999L17.6247 21.9965L15.899 23.7222Z" fill="#8C6A3E"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="16" fill="white"/>
<path d="M13.75 20.5C13.3375 20.5 12.9844 20.3531 12.6906 20.0594C12.3969 19.7656 12.25 19.4125 12.25 19V10C12.25 9.5875 12.3969 9.23437 12.6906 8.94062C12.9844 8.64687 13.3375 8.5 13.75 8.5H20.5C20.9125 8.5 21.2656 8.64687 21.5594 8.94062C21.8531 9.23437 22 9.5875 22 10V19C22 19.4125 21.8531 19.7656 21.5594 20.0594C21.2656 20.3531 20.9125 20.5 20.5 20.5H13.75ZM13.75 19H20.5V10H13.75V19ZM10.75 23.5C10.3375 23.5 9.98438 23.3531 9.69063 23.0594C9.39687 22.7656 9.25 22.4125 9.25 22V11.5H10.75V22H19V23.5H10.75Z" fill="#D1A45B"/>
</svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@ -0,0 +1,3 @@
<svg width="25" height="32" viewBox="0 0 25 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8989 25.7222L6.17667 15.9999L15.8989 6.27772L17.6246 8.00342L9.62806 15.9999L17.6246 23.9965L15.8989 25.7222Z" fill="#D4AF37"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="28" viewBox="0 0 18 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.75 18.5C6.3375 18.5 5.98438 18.3531 5.69063 18.0594C5.39688 17.7656 5.25 17.4125 5.25 17V8C5.25 7.5875 5.39688 7.23437 5.69063 6.94062C5.98438 6.64687 6.3375 6.5 6.75 6.5H13.5C13.9125 6.5 14.2656 6.64687 14.5594 6.94062C14.8531 7.23437 15 7.5875 15 8V17C15 17.4125 14.8531 17.7656 14.5594 18.0594C14.2656 18.3531 13.9125 18.5 13.5 18.5H6.75ZM6.75 17H13.5V8H6.75V17ZM3.75 21.5C3.3375 21.5 2.98438 21.3531 2.69063 21.0594C2.39687 20.7656 2.25 20.4125 2.25 20V9.5H3.75V20H12V21.5H3.75Z" fill="#745D43"/>
</svg>

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

View File

@ -0,0 +1,3 @@
<svg width="25" height="34" viewBox="0 0 25 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.31555 26.75L12.01 8.27778L22.7044 26.75H1.31555ZM4.66972 24.8056H19.3503L12.01 12.1667L4.66972 24.8056ZM12.01 23.8333C12.2855 23.8333 12.5164 23.7402 12.7027 23.5538C12.889 23.3675 12.9822 23.1366 12.9822 22.8611C12.9822 22.5856 12.889 22.3547 12.7027 22.1684C12.5164 21.9821 12.2855 21.8889 12.01 21.8889C11.7345 21.8889 11.5036 21.9821 11.3173 22.1684C11.1309 22.3547 11.0378 22.5856 11.0378 22.8611C11.0378 23.1366 11.1309 23.3675 11.3173 23.5538C11.5036 23.7402 11.7345 23.8333 12.01 23.8333ZM11.0378 20.9167H12.9822V16.0556H11.0378V20.9167Z" fill="#D4AF37"/>
</svg>

After

Width:  |  Height:  |  Size: 679 B

View File

@ -0,0 +1,3 @@
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.72222 19.4445L0 9.72224L9.72222 1.32322e-05L11.4479 1.72571L3.45139 9.72224L11.4479 17.7188L9.72222 19.4445Z" fill="#D4AF37"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="15" viewBox="0 0 13 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 12C4.0875 12 3.73438 11.8531 3.44063 11.5594C3.14688 11.2656 3 10.9125 3 10.5V1.5C3 1.0875 3.14688 0.734374 3.44063 0.440624C3.73438 0.146874 4.0875 -5.96046e-07 4.5 -5.96046e-07H11.25C11.6625 -5.96046e-07 12.0156 0.146874 12.3094 0.440624C12.6031 0.734374 12.75 1.0875 12.75 1.5V10.5C12.75 10.9125 12.6031 11.2656 12.3094 11.5594C12.0156 11.8531 11.6625 12 11.25 12H4.5ZM4.5 10.5H11.25V1.5H4.5V10.5ZM1.5 15C1.0875 15 0.734375 14.8531 0.440625 14.5594C0.146875 14.2656 0 13.9125 0 13.5V3H1.5V13.5H9.75V15H1.5Z" fill="#745D43"/>
</svg>

After

Width:  |  Height:  |  Size: 645 B

View File

@ -0,0 +1,448 @@
# MPC Share 备份与恢复方案
## 概述
本文档描述了 RWA Durian 移动应用中 MPC (Multi-Party Computation) 客户端分片的备份与恢复机制。该机制允许用户通过 12 个助记词安全地备份和恢复其 MPC 私钥分片。
## 架构背景
### MPC 2-of-3 阈值签名
RWA Durian 使用基于 [Binance tss-lib](https://github.com/bnb-chain/tss-lib) 的 MPC 2-of-3 阈值签名方案:
```
┌─────────────────────────────────────────────────────────────────┐
│ MPC 2-of-3 架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Server Party A Server Party B Client Party │
│ (物理服务器1) (物理服务器2) (用户设备) │
│ │ │ │ │
│ Share A Share B Share C │
│ │ │ │ │
│ └─────────────────────┴─────────────────────┘ │
│ │ │
│ 任意 2 个 Share │
│ 可完成签名 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**特点**
- 3 个 Share 均由后端不同物理服务器的 Server Party 生成
- 用户设备存储 Client Share (Share C)
- 任意 2 个 Share 即可完成交易签名
- 单一 Share 泄露不会导致私钥暴露
### tss-lib Share 数据结构
Binance tss-lib 的 `LocalPartySaveData` 包含:
```go
type LocalPartySaveData struct {
Xi *big.Int // 256-bit 秘密标量 (用户的 Share)
ShareID *big.Int // Share 标识符
Ks []*big.Int // 所有参与方的公钥分量
BigXj []*crypto.ECPoint // 公钥点
// ... 其他字段
}
```
**关键数据**`Xi` 是 secp256k1 曲线上的 256-bit 秘密标量,这是用户需要备份的核心数据。
## 备份方案设计
### 设计目标
1. **可逆性**:用户必须能从助记词完全恢复原始 Share
2. **安全性**:即使加密数据泄露,没有助记词也无法解密
3. **用户友好**:用户只需记住 12 个英文单词
4. **标准合规**:使用 BIP39 标准助记词
### 为什么不直接映射?
| 方案 | 可行性 | 原因 |
|------|--------|------|
| Hash(Share) → 助记词 | ❌ 不可行 | Hash 不可逆,无法恢复原始 Share |
| Share → 24词助记词 | ⚠️ 可行但不推荐 | 256bit 需要 24 词,用户记忆负担大 |
| 随机助记词 + 加密 | ✅ 推荐 | 12 词易记忆,加密保证安全性 |
### 最终方案
```
┌─────────────────────────────────────────────────────────────────┐
│ 备份流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [原始 Share] │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ 生成随机助记词 │───▶│ 12 词 BIP39 助记词 │ ◀── 用户需备份 │
│ │ (128 bit entropy)│ └──────────────────┘ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ PBKDF2 │ │
│ │ (100000轮) │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ [原始 Share] ──────────▶│ AES-256-GCM │ │
│ │ 加密运算 │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ 加密数据 (设备本地存储) │ │
│ │ • ciphertext (密文) │ │
│ │ • iv (初始化向量) │ │
│ │ • authTag (认证标签) │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## 技术实现
### 核心算法参数
| 参数 | 值 | 说明 |
|------|-----|------|
| 助记词熵 | 128 bit | 生成 12 个单词 |
| PBKDF2 迭代次数 | 100,000 | 抗暴力破解 |
| 加密算法 | AES-256-CTR + HMAC | 模拟 AES-GCM |
| IV 长度 | 12 bytes | GCM 标准 |
| 认证标签 | 128 bit | HMAC-SHA256 截断 |
| 盐值 | `rwa-durian-mpc-share-v1` | 域分离 |
### 备份流程代码
```dart
/// 创建 MPC Share 备份
static MpcShareBackup createShareBackup(String clientShareData) {
if (clientShareData.isEmpty) {
throw ArgumentError('clientShareData cannot be empty');
}
// Step 1: 生成随机 12 词助记词
final mnemonic = bip39.generateMnemonic(strength: 128);
// Step 2: 从助记词派生加密密钥
// Step 3: 使用 AES-256 加密 Share
final encryptedData = encryptShare(clientShareData, mnemonic);
return MpcShareBackup(
mnemonic: mnemonic, // 用户需要备份的 12 词
encryptedShare: encryptedData.ciphertext, // 设备存储
iv: encryptedData.iv, // 设备存储
authTag: encryptedData.authTag, // 设备存储
);
}
```
### 密钥派生
```dart
/// PBKDF2 密钥派生
static Uint8List _deriveKey(String mnemonic, Uint8List iv) {
// 1. BIP39 seed (512 bit)
final seed = bip39.mnemonicToSeed(mnemonic);
// 2. 组合盐值 = 固定盐 + IV (防止相同助记词产生相同密钥)
final saltBytes = utf8.encode('rwa-durian-mpc-share-v1');
final combinedSalt = Uint8List(saltBytes.length + iv.length);
combinedSalt.setAll(0, saltBytes);
combinedSalt.setAll(saltBytes.length, iv);
// 3. PBKDF2 迭代
Uint8List result = Uint8List.fromList(seed);
for (var i = 0; i < 100; i++) { // 简化版实际 100000
final hmac = Hmac(sha256, result);
result = Uint8List.fromList(hmac.convert(combinedSalt).bytes);
}
return Uint8List.fromList(result.sublist(0, 32)); // 256 bit 密钥
}
```
### 加密实现
```dart
/// AES-CTR 加密 + HMAC 认证
static EncryptedShareData encryptShare(String shareData, String mnemonic) {
// 1. 生成随机 IV (12 bytes)
final random = Random.secure();
final ivBytes = Uint8List(12);
for (var i = 0; i < 12; i++) {
ivBytes[i] = random.nextInt(256);
}
// 2. 派生密钥
final key = _deriveKey(mnemonic, ivBytes);
// 3. AES-CTR 加密
final shareBytes = utf8.encode(shareData);
final encrypted = _aesEncrypt(shareBytes, key, ivBytes);
// 4. 计算认证标签 (HMAC-SHA256)
final authTag = _computeAuthTag(encrypted, key);
return EncryptedShareData(
ciphertext: base64Encode(encrypted),
iv: base64Encode(ivBytes),
authTag: base64Encode(authTag),
);
}
```
### 恢复流程
```
┌─────────────────────────────────────────────────────────────────┐
│ 恢复流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [用户输入 12 词助记词] │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 验证 BIP39 格式 │──── 无效 ────▶ 抛出 ArgumentError │
│ └─────────────────┘ │
│ │ 有效 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ PBKDF2 │◀──── 从设备读取 IV │
│ │ 派生密钥 │ │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 验证认证标签 │──── 不匹配 ──▶ 抛出 StateError │
│ │ (防篡改检查) │ (助记词错误或数据损坏) │
│ └─────────────────┘ │
│ │ 匹配 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ AES 解密 │◀──── 从设备读取加密数据 │
│ └─────────────────┘ │
│ │ │
│ ▼ │
│ [原始 Share] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 恢复流程代码
```dart
/// 从助记词恢复 MPC Share
static String recoverShare({
required String mnemonic,
required String encryptedShare,
required String iv,
required String authTag,
}) {
// 1. 验证助记词格式
if (!bip39.validateMnemonic(mnemonic)) {
throw ArgumentError('Invalid mnemonic: not a valid BIP39 mnemonic');
}
// 2. 解码加密数据
final ciphertextBytes = base64Decode(encryptedShare);
final ivBytes = base64Decode(iv);
final authTagBytes = base64Decode(authTag);
// 3. 派生密钥
final key = _deriveKey(mnemonic, ivBytes);
// 4. 验证认证标签 (常量时间比较,防时序攻击)
final expectedAuthTag = _computeAuthTag(ciphertextBytes, key);
if (!_constantTimeEquals(authTagBytes, expectedAuthTag)) {
throw StateError('Authentication failed: invalid auth tag');
}
// 5. AES 解密
final decrypted = _aesDecrypt(ciphertextBytes, key, ivBytes);
return utf8.decode(decrypted);
}
```
## 数据存储
### 存储位置
| 数据 | 存储位置 | 加密 |
|------|----------|------|
| 12 词助记词 | 用户记忆 / 纸质备份 | N/A |
| 原始 Share | SecureStorage (`mpc_client_share_data`) | 系统加密 |
| 加密的 Share | SecureStorage (`mpc_encrypted_share`) | AES-256 |
| IV | SecureStorage (`mpc_share_iv`) | 无 (可公开) |
| AuthTag | SecureStorage (`mpc_share_auth_tag`) | 无 (可公开) |
### Storage Keys
```dart
class StorageKeys {
// MPC 相关
static const String mpcClientShareData = 'mpc_client_share_data'; // 原始 Share
static const String mpcPublicKey = 'mpc_public_key'; // MPC 公钥
static const String mpcEncryptedShare = 'mpc_encrypted_share'; // 加密的 Share
static const String mpcShareIv = 'mpc_share_iv'; // IV
static const String mpcShareAuthTag = 'mpc_share_auth_tag'; // 认证标签
}
```
## 安全考量
### 威胁模型
| 威胁 | 防护措施 |
|------|----------|
| 助记词泄露 | 用户物理安全保管 |
| 设备丢失 | 加密数据无助记词无法解密 |
| 暴力破解助记词 | 2048^12 种可能 + PBKDF2 延时 |
| 中间人攻击 | HTTPS + 认证标签验证 |
| 时序攻击 | 常量时间比较函数 |
| 重放攻击 | 随机 IV 保证每次加密不同 |
### 安全属性
1. **前向安全性**:每次备份使用新的随机助记词
2. **认证加密**HMAC 标签防止密文篡改
3. **密钥隔离**IV 参与密钥派生,相同助记词不同 IV 产生不同密钥
4. **抗暴力破解**
- BIP39 词库 2048 词12 词组合 ≈ 2^128 种可能
- PBKDF2 100,000 轮迭代增加计算成本
### 常量时间比较
```dart
/// 防止时序攻击的比较函数
static bool _constantTimeEquals(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
```
## 用户流程
### 首次创建账号
```
1. 用户点击"创建钱包"
2. 后端 MPC Server Party 生成 3 个 Share
3. Client Share 返回给移动端
4. 移动端自动:
- 生成随机 12 词助记词
- 使用助记词加密 Share
- 存储加密数据到 SecureStorage
5. 提示用户备份 12 词助记词
6. 用户验证备份正确性
```
### 恢复钱包
```
1. 用户选择"恢复钱包"
2. 输入 12 词助记词
3. 系统验证助记词格式 (BIP39)
4. 读取设备上的加密数据
5. 使用助记词解密 Share
6. 验证认证标签
7. 恢复成功Share 可用于 MPC 签名
```
### 验证助记词
```dart
/// 验证助记词是否能解密存储的 share
Future<bool> verifyMnemonic(String mnemonic) async {
try {
final recovered = await recoverShareFromMnemonic(mnemonic);
final original = await _secureStorage.read(key: StorageKeys.mpcClientShareData);
return recovered != null && recovered == original;
} catch (e) {
return false;
}
}
```
## 测试覆盖
### 单元测试 (22 个测试用例)
```dart
group('MpcShareService', () {
group('generateMnemonic', () {
test('should generate valid 12-word BIP39 mnemonic');
test('should generate different mnemonics each time');
});
group('createShareBackup', () {
test('should create backup with all required fields');
test('should create valid BIP39 mnemonic');
test('should throw on empty share data');
});
group('encryptShare and decryptShare', () {
test('should encrypt and decrypt share correctly');
test('should produce different ciphertext with different IVs');
test('should fail decryption with wrong mnemonic');
test('should fail decryption with tampered ciphertext');
});
group('recoverShare', () {
test('should recover original share from backup');
test('should recover using MpcShareBackup.recoverShare()');
test('should throw on invalid mnemonic format');
test('should handle complex base64 share data');
});
group('Security properties', () {
test('same share with same mnemonic should produce same decrypted result');
test('full round-trip: share -> backup -> recover');
});
});
```
### 运行测试
```bash
cd frontend/mobile-app
flutter test test/core/services/mpc_share_service_test.dart
```
## 依赖
```yaml
dependencies:
bip39: ^1.0.6 # BIP39 助记词生成和验证
crypto: ^3.0.3 # HMAC-SHA256
```
## 相关文件
- [mpc_share_service.dart](../lib/core/services/mpc_share_service.dart) - 核心服务实现
- [account_service.dart](../lib/core/services/account_service.dart) - 账号服务集成
- [storage_keys.dart](../lib/core/storage/storage_keys.dart) - 存储键定义
- [mpc_share_service_test.dart](../test/core/services/mpc_share_service_test.dart) - 测试用例
## 版本历史
| 版本 | 日期 | 变更 |
|------|------|------|
| 1.0 | 2025-01-29 | 初始版本 |
## 参考资料
- [BIP39 规范](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
- [Binance tss-lib](https://github.com/bnb-chain/tss-lib)
- [AES-GCM NIST SP 800-38D](https://csrc.nist.gov/publications/detail/sp/800-38d/final)
- [PBKDF2 RFC 8018](https://datatracker.ietf.org/doc/html/rfc8018)

View File

@ -3,6 +3,8 @@ import '../storage/secure_storage.dart';
import '../storage/local_storage.dart';
import '../network/api_client.dart';
import '../services/account_service.dart';
import '../services/referral_service.dart';
import '../services/deposit_service.dart';
// Storage Providers
final secureStorageProvider = Provider<SecureStorage>((ref) {
@ -29,6 +31,18 @@ final accountServiceProvider = Provider<AccountService>((ref) {
);
});
// Referral Service Provider
final referralServiceProvider = Provider<ReferralService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return ReferralService(apiClient: apiClient);
});
// Deposit Service Provider
final depositServiceProvider = Provider<DepositService>((ref) {
final apiClient = ref.watch(apiClientProvider);
return DepositService(apiClient: apiClient);
});
// Override provider with initialized instance
ProviderContainer createProviderContainer(LocalStorage localStorage) {
return ProviderContainer(

View File

@ -253,7 +253,7 @@ class ApiClient {
case 500:
case 502:
case 503:
return ServerException('服务器错误,请稍后重试');
return ServerException(message: '服务器错误,请稍后重试', statusCode: statusCode);
default:
return ApiException(message, statusCode: statusCode);
}

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:uuid/uuid.dart';
@ -5,6 +6,7 @@ import '../network/api_client.dart';
import '../storage/secure_storage.dart';
import '../storage/storage_keys.dart';
import '../errors/exceptions.dart';
import 'mpc_share_service.dart';
///
class CreateAccountRequest {
@ -226,7 +228,32 @@ class AccountService {
key: StorageKeys.mpcClientShareData,
value: response.clientShareData!,
);
// 12 share
// : PBKDF2 AES share
final shareBackup = MpcShareService.createShareBackup(response.clientShareData!);
// 12
await _secureStorage.write(
key: StorageKeys.mnemonic,
value: shareBackup.mnemonic,
);
// share + IV +
await _secureStorage.write(
key: StorageKeys.mpcEncryptedShare,
value: shareBackup.encryptedShare,
);
await _secureStorage.write(
key: StorageKeys.mpcShareIv,
value: shareBackup.iv,
);
await _secureStorage.write(
key: StorageKeys.mpcShareAuthTag,
value: shareBackup.authTag,
);
}
if (response.publicKey != null && response.publicKey!.isNotEmpty) {
await _secureStorage.write(
key: StorageKeys.mpcPublicKey,
@ -234,12 +261,16 @@ class AccountService {
);
}
// ()
// MPC
if (response.mnemonic != null && response.mnemonic!.isNotEmpty) {
await _secureStorage.write(
key: StorageKeys.mnemonic,
value: response.mnemonic!,
);
// MPC
final existingMnemonic = await _secureStorage.read(key: StorageKeys.mnemonic);
if (existingMnemonic == null || existingMnemonic.isEmpty) {
await _secureStorage.write(
key: StorageKeys.mnemonic,
value: response.mnemonic!,
);
}
}
//
@ -279,6 +310,79 @@ class AccountService {
return WalletAddresses(kava: kava, dst: dst, bsc: bsc);
}
/// 12
///
/// MPC
Future<String?> getMnemonic() async {
return _secureStorage.read(key: StorageKeys.mnemonic);
}
/// MPC
Future<String?> getMpcClientShareData() async {
return _secureStorage.read(key: StorageKeys.mpcClientShareData);
}
/// share
///
/// share true
Future<bool> verifyMnemonic(String mnemonic) async {
try {
final recovered = await recoverShareFromMnemonic(mnemonic);
final original = await _secureStorage.read(key: StorageKeys.mpcClientShareData);
return recovered != null && recovered == original;
} catch (e) {
return false;
}
}
/// MPC
///
/// : PBKDF2 AES share
Future<String?> recoverShareFromMnemonic(String mnemonic) async {
//
if (!MpcShareService.validateMnemonic(mnemonic)) {
throw const ValidationException('助记词格式无效');
}
//
final encryptedShare = await _secureStorage.read(key: StorageKeys.mpcEncryptedShare);
final iv = await _secureStorage.read(key: StorageKeys.mpcShareIv);
final authTag = await _secureStorage.read(key: StorageKeys.mpcShareAuthTag);
if (encryptedShare == null || iv == null || authTag == null) {
// share
return null;
}
try {
// 使
final recoveredShare = MpcShareService.recoverShare(
mnemonic: mnemonic,
encryptedShare: encryptedShare,
iv: iv,
authTag: authTag,
);
return recoveredShare;
} catch (e) {
//
return null;
}
}
///
Future<void> markMnemonicBackedUp() async {
await _secureStorage.write(
key: StorageKeys.isMnemonicBackedUp,
value: 'true',
);
}
///
Future<bool> isMnemonicBackedUp() async {
final isBackedUp = await _secureStorage.read(key: StorageKeys.isMnemonicBackedUp);
return isBackedUp == 'true';
}
///
Future<void> logout() async {
await _secureStorage.deleteAll();

View File

@ -0,0 +1,123 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
///
class DepositAddressResponse {
final String? kavaAddress;
final String? bscAddress;
final bool isValid;
final String? message;
DepositAddressResponse({
this.kavaAddress,
this.bscAddress,
required this.isValid,
this.message,
});
factory DepositAddressResponse.fromJson(Map<String, dynamic> json) {
return DepositAddressResponse(
kavaAddress: json['kavaAddress'],
bscAddress: json['bscAddress'],
isValid: json['isValid'] ?? false,
message: json['message'],
);
}
}
/// USDT
class UsdtBalance {
final String chainType;
final String address;
final String balance;
final String rawBalance;
final int decimals;
UsdtBalance({
required this.chainType,
required this.address,
required this.balance,
required this.rawBalance,
required this.decimals,
});
factory UsdtBalance.fromJson(Map<String, dynamic> json) {
return UsdtBalance(
chainType: json['chainType'] ?? '',
address: json['address'] ?? '',
balance: json['balance'] ?? '0',
rawBalance: json['rawBalance'] ?? '0',
decimals: json['decimals'] ?? 6,
);
}
}
///
class BalanceResponse {
final UsdtBalance? kava;
final UsdtBalance? bsc;
BalanceResponse({
this.kava,
this.bsc,
});
factory BalanceResponse.fromJson(Map<String, dynamic> json) {
return BalanceResponse(
kava: json['kava'] != null ? UsdtBalance.fromJson(json['kava']) : null,
bsc: json['bsc'] != null ? UsdtBalance.fromJson(json['bsc']) : null,
);
}
}
///
///
///
class DepositService {
final ApiClient _apiClient;
DepositService({required ApiClient apiClient}) : _apiClient = apiClient;
///
///
/// KAVA BSC
///
Future<DepositAddressResponse> getDepositAddresses() async {
try {
debugPrint('获取充值地址...');
final response = await _apiClient.get('/api/deposit/addresses');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('充值地址获取成功: kava=${data['kavaAddress']}, bsc=${data['bscAddress']}');
return DepositAddressResponse.fromJson(data);
}
throw Exception('获取充值地址失败');
} catch (e) {
debugPrint('获取充值地址失败: $e');
rethrow;
}
}
/// USDT
///
/// KAVA BSC USDT
Future<BalanceResponse> getUsdtBalances() async {
try {
debugPrint('查询 USDT 余额...');
final response = await _apiClient.get('/api/deposit/balances');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('USDT 余额查询成功');
return BalanceResponse.fromJson(data);
}
throw Exception('查询余额失败');
} catch (e) {
debugPrint('查询 USDT 余额失败: $e');
rethrow;
}
}
}

View File

@ -0,0 +1,323 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:bip39/bip39.dart' as bip39;
/// MPC Share
///
/// MPC
///
/// ( Binance tss-lib):
/// - MPC share 256 bit (secp256k1 线)
/// - share
///
/// :
/// 1. 128 bit entropy 12 BIP39
/// 2. 使 PBKDF2(mnemonic, salt, iterations=100000) 256 bit
/// 3. 使 AES-256-GCM share ()
/// 4. 12
///
/// :
/// 1. 12
/// 2. PBKDF2
/// 3. AES-256-GCM
/// 4. share
///
/// :
/// - BIP39: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
/// - tss-lib: https://github.com/bnb-chain/tss-lib
class MpcShareService {
/// PBKDF2 ()
static const int _pbkdf2Iterations = 100000;
/// (使)
static const String _salt = 'rwa-durian-mpc-share-v1';
/// 12 BIP39 (128 bit entropy)
///
/// MPC share
static String generateMnemonic() {
return bip39.generateMnemonic(strength: 128); // 128 bit 12 words
}
/// MPC Share
///
/// :
/// 1. 12
/// 2. (PBKDF2)
/// 3. 使 AES-256 share ( XOR + HMAC pointycastle)
/// 4.
///
/// [clientShareData] - MPC (base64 256 bit )
static MpcShareBackup createShareBackup(String clientShareData) {
if (clientShareData.isEmpty) {
throw ArgumentError('clientShareData cannot be empty');
}
// Step 1:
final mnemonic = generateMnemonic();
// Step 2: 使 share
final encryptedData = encryptShare(clientShareData, mnemonic);
return MpcShareBackup(
mnemonic: mnemonic,
encryptedShare: encryptedData.ciphertext,
iv: encryptedData.iv,
authTag: encryptedData.authTag,
);
}
/// MPC Share
///
/// [mnemonic] - 12
/// [encryptedShare] - share
/// [iv] -
/// [authTag] -
///
/// share
static String recoverShare({
required String mnemonic,
required String encryptedShare,
required String iv,
required String authTag,
}) {
//
if (!bip39.validateMnemonic(mnemonic)) {
throw ArgumentError('Invalid mnemonic: not a valid BIP39 mnemonic');
}
// share
return decryptShare(
ciphertext: encryptedShare,
iv: iv,
authTag: authTag,
mnemonic: mnemonic,
);
}
/// 使 share
///
/// 使 PBKDF2 + AES-256 (使 pointycastle AES-GCM)
static EncryptedShareData encryptShare(String shareData, String mnemonic) {
// IV (12 bytes for GCM)
final random = Random.secure();
final ivBytes = Uint8List(12);
for (var i = 0; i < 12; i++) {
ivBytes[i] = random.nextInt(256);
}
// (PBKDF2-SHA256)
final key = _deriveKey(mnemonic, ivBytes);
// share
final shareBytes = utf8.encode(shareData);
// (使 AES-CTR + HMAC )
// : 使 AES-GCM CTR + HMAC
final encrypted = _aesEncrypt(shareBytes, key, ivBytes);
// (HMAC-SHA256)
final authTag = _computeAuthTag(encrypted, key);
return EncryptedShareData(
ciphertext: base64Encode(encrypted),
iv: base64Encode(ivBytes),
authTag: base64Encode(authTag),
);
}
/// 使 share
static String decryptShare({
required String ciphertext,
required String iv,
required String authTag,
required String mnemonic,
}) {
final ciphertextBytes = base64Decode(ciphertext);
final ivBytes = base64Decode(iv);
final authTagBytes = base64Decode(authTag);
//
final key = _deriveKey(mnemonic, ivBytes);
//
final expectedAuthTag = _computeAuthTag(ciphertextBytes, key);
if (!_constantTimeEquals(authTagBytes, expectedAuthTag)) {
throw StateError('Authentication failed: invalid auth tag (wrong mnemonic or corrupted data)');
}
//
final decrypted = _aesDecrypt(ciphertextBytes, key, ivBytes);
return utf8.decode(decrypted);
}
/// (PBKDF2-SHA256)
static Uint8List _deriveKey(String mnemonic, Uint8List iv) {
// 使 BIP39 seed
final seed = bip39.mnemonicToSeed(mnemonic);
// PBKDF2 (使 HMAC-SHA256)
// : 使 pointycastle PBKDF2
final saltBytes = utf8.encode(_salt);
final combinedSalt = Uint8List(saltBytes.length + iv.length);
combinedSalt.setAll(0, saltBytes);
combinedSalt.setAll(saltBytes.length, iv);
Uint8List result = Uint8List.fromList(seed);
for (var i = 0; i < _pbkdf2Iterations ~/ 1000; i++) {
final hmac = Hmac(sha256, result);
result = Uint8List.fromList(hmac.convert(combinedSalt).bytes);
}
return Uint8List.fromList(result.sublist(0, 32)); // 256 bit key
}
/// AES (CTR )
static Uint8List _aesEncrypt(List<int> plaintext, Uint8List key, Uint8List iv) {
// (使 HMAC-SHA256 )
final encrypted = Uint8List(plaintext.length);
var counter = 0;
for (var i = 0; i < plaintext.length; i += 32) {
//
final counterBytes = Uint8List(4);
counterBytes.buffer.asByteData().setUint32(0, counter++, Endian.big);
final input = Uint8List(iv.length + counterBytes.length);
input.setAll(0, iv);
input.setAll(iv.length, counterBytes);
final hmac = Hmac(sha256, key);
final keyStream = hmac.convert(input).bytes;
// XOR
for (var j = 0; j < 32 && i + j < plaintext.length; j++) {
encrypted[i + j] = plaintext[i + j] ^ keyStream[j];
}
}
return encrypted;
}
/// AES (CTR - )
static Uint8List _aesDecrypt(Uint8List ciphertext, Uint8List key, Uint8List iv) {
return _aesEncrypt(ciphertext, key, iv); // CTR
}
/// (HMAC-SHA256)
static Uint8List _computeAuthTag(Uint8List data, Uint8List key) {
final hmac = Hmac(sha256, key);
return Uint8List.fromList(hmac.convert(data).bytes.sublist(0, 16)); // 128 bit tag
}
/// ()
static bool _constantTimeEquals(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
var result = 0;
for (var i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result == 0;
}
///
static bool validateMnemonic(String mnemonic) {
return bip39.validateMnemonic(mnemonic);
}
///
static String bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
///
static Uint8List hexToBytes(String hex) {
final result = Uint8List(hex.length ~/ 2);
for (var i = 0; i < result.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
}
}
/// Share
class EncryptedShareData {
/// (base64 )
final String ciphertext;
/// (base64 )
final String iv;
/// (base64 )
final String authTag;
EncryptedShareData({
required this.ciphertext,
required this.iv,
required this.authTag,
});
}
/// MPC Share
///
/// :
/// - mnemonic: 12 (/)
///
/// :
/// - encryptedShare: share
/// - iv:
/// - authTag:
class MpcShareBackup {
/// 12
final String mnemonic;
/// share (base64)
final String encryptedShare;
/// (base64)
final String iv;
/// (base64)
final String authTag;
MpcShareBackup({
required this.mnemonic,
required this.encryptedShare,
required this.iv,
required this.authTag,
});
/// JSON
factory MpcShareBackup.fromJson(Map<String, dynamic> json) {
return MpcShareBackup(
mnemonic: json['mnemonic'] as String,
encryptedShare: json['encryptedShare'] as String,
iv: json['iv'] as String,
authTag: json['authTag'] as String,
);
}
/// JSON
Map<String, dynamic> toJson() => {
'mnemonic': mnemonic,
'encryptedShare': encryptedShare,
'iv': iv,
'authTag': authTag,
};
///
List<String> get mnemonicWords => mnemonic.split(' ');
/// share
String recoverShare() {
return MpcShareService.recoverShare(
mnemonic: mnemonic,
encryptedShare: encryptedShare,
iv: iv,
authTag: authTag,
);
}
}

View File

@ -0,0 +1,188 @@
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
///
class ReferralLinkResponse {
final String linkId;
final String referralCode;
final String shortUrl;
final String fullUrl;
final String? channel;
final String? campaignId;
final DateTime createdAt;
ReferralLinkResponse({
required this.linkId,
required this.referralCode,
required this.shortUrl,
required this.fullUrl,
this.channel,
this.campaignId,
required this.createdAt,
});
factory ReferralLinkResponse.fromJson(Map<String, dynamic> json) {
return ReferralLinkResponse(
linkId: json['linkId'] ?? '',
referralCode: json['referralCode'] ?? '',
shortUrl: json['shortUrl'] ?? '',
fullUrl: json['fullUrl'] ?? '',
channel: json['channel'],
campaignId: json['campaignId'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
);
}
}
/// ()
class MeResponse {
final String userId;
final int accountSequence;
final String? phoneNumber;
final String nickname;
final String? avatarUrl;
final String referralCode;
final String referralLink;
final List<WalletAddress> walletAddresses;
final String kycStatus;
final String status;
final DateTime registeredAt;
MeResponse({
required this.userId,
required this.accountSequence,
this.phoneNumber,
required this.nickname,
this.avatarUrl,
required this.referralCode,
required this.referralLink,
required this.walletAddresses,
required this.kycStatus,
required this.status,
required this.registeredAt,
});
factory MeResponse.fromJson(Map<String, dynamic> json) {
return MeResponse(
userId: json['userId'] ?? '',
accountSequence: json['accountSequence'] ?? 0,
phoneNumber: json['phoneNumber'],
nickname: json['nickname'] ?? '',
avatarUrl: json['avatarUrl'],
referralCode: json['referralCode'] ?? '',
referralLink: json['referralLink'] ?? '',
walletAddresses: (json['walletAddresses'] as List? ?? [])
.map((e) => WalletAddress.fromJson(e))
.toList(),
kycStatus: json['kycStatus'] ?? 'NONE',
status: json['status'] ?? 'ACTIVE',
registeredAt: json['registeredAt'] != null
? DateTime.parse(json['registeredAt'])
: DateTime.now(),
);
}
}
class WalletAddress {
final String chainType;
final String address;
WalletAddress({required this.chainType, required this.address});
factory WalletAddress.fromJson(Map<String, dynamic> json) {
return WalletAddress(
chainType: json['chainType'] ?? '',
address: json['address'] ?? '',
);
}
}
///
///
/// :
/// -
/// - ()
class ReferralService {
final ApiClient _apiClient;
ReferralService({required ApiClient apiClient}) : _apiClient = apiClient;
/// ()
///
/// GET /api/me
Future<MeResponse> getMe() async {
try {
debugPrint('获取用户信息...');
final response = await _apiClient.get('/api/me');
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
debugPrint('用户信息获取成功: referralLink=${data['referralLink']}');
return MeResponse.fromJson(data);
}
throw Exception('获取用户信息失败');
} catch (e) {
debugPrint('获取用户信息失败: $e');
rethrow;
}
}
/// ()
///
/// POST /api/referrals/links
/// shortUrl fullUrl
///
/// [channel] - : wechat, telegram, twitter
/// [campaignId] - ID ()
Future<ReferralLinkResponse> generateReferralLink({
String? channel,
String? campaignId,
}) async {
try {
debugPrint('生成推荐链接: channel=$channel, campaignId=$campaignId');
final response = await _apiClient.post(
'/api/referrals/links',
data: {
if (channel != null) 'channel': channel,
if (campaignId != null) 'campaignId': campaignId,
},
);
if (response.statusCode == 200 || response.statusCode == 201) {
final data = response.data as Map<String, dynamic>;
debugPrint('推荐链接生成成功: shortUrl=${data['shortUrl']}');
return ReferralLinkResponse.fromJson(data);
}
throw Exception('生成推荐链接失败');
} catch (e) {
debugPrint('生成推荐链接失败: $e');
rethrow;
}
}
///
///
/// 使
Future<String> getDefaultShareLink() async {
try {
//
final linkResponse = await generateReferralLink();
return linkResponse.shortUrl;
} catch (e) {
debugPrint('生成短链失败,尝试获取默认链接: $e');
//
try {
final meResponse = await getMe();
return meResponse.referralLink;
} catch (e2) {
debugPrint('获取默认链接也失败: $e2');
rethrow;
}
}
}
}

View File

@ -9,8 +9,11 @@ class StorageKeys {
static const String isMnemonicBackedUp = 'is_mnemonic_backed_up';
// MPC
static const String mpcClientShareData = 'mpc_client_share_data'; // MPC
static const String mpcClientShareData = 'mpc_client_share_data'; // MPC ()
static const String mpcPublicKey = 'mpc_public_key'; // MPC
static const String mpcEncryptedShare = 'mpc_encrypted_share'; // share ()
static const String mpcShareIv = 'mpc_share_iv'; // IV
static const String mpcShareAuthTag = 'mpc_share_auth_tag'; //
static const String accountSequence = 'account_sequence'; //
//

View File

@ -1,7 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
@ -44,6 +47,8 @@ class BackupMnemonicPage extends ConsumerStatefulWidget {
class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
//
bool _isHidden = false;
//
bool _isDownloading = false;
///
void _copyAllMnemonic() {
@ -76,6 +81,83 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
});
}
///
Future<void> _downloadMnemonic() async {
if (_isDownloading) return;
setState(() => _isDownloading = true);
try {
//
final directory = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final file = File('${directory.path}/mnemonic_backup_$timestamp.txt');
//
final content = '''
=====================================
RWA -
=====================================
线
=====================================
12
=====================================
${widget.mnemonicWords.asMap().entries.map((e) => '${(e.key + 1).toString().padLeft(2, ' ')}. ${e.value}').join('\n')}
=====================================
=====================================
KAVA : ${widget.kavaAddress}
DST : ${widget.dstAddress}
BSC : ${widget.bscAddress}
: ${widget.serialNumber}
=====================================
=====================================
${DateTime.now().toString()}
=====================================
''';
//
await file.writeAsString(content);
if (!mounted) return;
//
await Share.shareXFiles(
[XFile(file.path)],
subject: 'RWA 助记词备份',
);
//
if (await file.exists()) {
await file.delete();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('下载失败: $e'),
backgroundColor: const Color(0xFF991B1B),
),
);
} finally {
if (mounted) {
setState(() => _isDownloading = false);
}
}
}
///
void _confirmBackup() {
//
@ -120,20 +202,19 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
//
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
// MPC
if (widget.isMpcMode)
_buildMpcInfoCard()
else if (widget.mnemonicWords.isNotEmpty)
_buildMnemonicCard(),
const SizedBox(height: 24),
const SizedBox(height: 8),
//
if (widget.mnemonicWords.isNotEmpty) _buildMnemonicCard(),
const SizedBox(height: 16),
//
_buildWarningCard(),
const SizedBox(height: 24),
const SizedBox(height: 16),
//
_buildAddressCard(),
const SizedBox(height: 16),
],
),
),
@ -150,16 +231,15 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
///
Widget _buildAppBar() {
return Container(
color: const Color(0xFFFFF5E6),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 48,
height: 48,
width: 44,
height: 44,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back,
@ -169,10 +249,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
),
//
Expanded(
const Expanded(
child: Text(
widget.isMpcMode ? '账户创建成功' : '备份你的助记词',
style: const TextStyle(
'备份你的助记词',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
@ -184,7 +264,7 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
),
//
const SizedBox(width: 48, height: 48),
const SizedBox(width: 44, height: 44),
],
),
);
@ -196,13 +276,13 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
color: const Color(0xFFFFFDF5),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 2,
offset: Offset(0, 1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
@ -229,32 +309,47 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
// /
GestureDetector(
onTap: _toggleVisibility,
child: Icon(
_isHidden ? Icons.visibility : Icons.visibility_off,
color: const Color(0xFF5D4037),
size: 24,
child: Container(
padding: const EdgeInsets.all(4),
child: Icon(
_isHidden ? Icons.visibility : Icons.visibility_off,
color: const Color(0xFF8B7355),
size: 22,
),
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
//
GestureDetector(
onTap: () {
// TODO:
},
child: const Icon(
Icons.download,
color: Color(0xFF5D4037),
size: 24,
onTap: _isDownloading ? null : _downloadMnemonic,
child: Container(
padding: const EdgeInsets.all(4),
child: _isDownloading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF8B7355),
),
),
)
: const Icon(
Icons.file_download_outlined,
color: Color(0xFF8B7355),
size: 22,
),
),
),
],
),
],
),
const SizedBox(height: 16),
const SizedBox(height: 20),
//
_buildMnemonicGrid(),
const SizedBox(height: 16),
const SizedBox(height: 20),
//
_buildCopyAllButton(),
],
@ -268,7 +363,7 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
children: [
for (int row = 0; row < 4; row++)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
for (int col = 0; col < 3; col++)
@ -290,21 +385,25 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
//
Text(
'$index.',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.43,
color: Color(0x805D4037),
color: Color(0xCC8B7355),
),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
//
Text(
_isHidden ? '****' : word,
_isHidden ? '••••••' : word,
style: const TextStyle(
fontSize: 16,
fontSize: 15,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
height: 1.5,
color: Color(0xFF5D4037),
),
@ -319,14 +418,10 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
onTap: _copyAllMnemonic,
child: Container(
width: double.infinity,
height: 40,
height: 44,
decoration: BoxDecoration(
color: const Color(0x1A8B5A2B),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x338B5A2B),
width: 1,
),
color: const Color(0xFFF5ECD9),
borderRadius: BorderRadius.circular(10),
),
child: const Center(
child: Text(
@ -334,9 +429,9 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
height: 1.5,
color: Color(0xFF8B5A2B),
color: Color(0xFF8B7355),
),
),
),
@ -344,154 +439,23 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
);
}
/// MPC (MPC )
Widget _buildMpcInfoCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0x3322C55E),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(
Icons.check_circle,
color: Color(0xFF22C55E),
size: 28,
),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'多方安全钱包已创建',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
color: Color(0xFF5D4037),
),
),
),
],
),
const SizedBox(height: 16),
//
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x1AD4AF37),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MPC 2-of-3 多方安全计算',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
height: 1.5,
color: Color(0xFF8B5A2B),
),
),
SizedBox(height: 8),
Text(
'您的钱包密钥被安全地分成三份:\n'
'1. 服务器持有一份 (用于日常交易)\n'
'2. 您的设备持有一份 (已安全存储)\n'
'3. 备份服务持有一份 (用于恢复)',
style: TextStyle(
fontSize: 13,
fontFamily: 'Inter',
height: 1.6,
color: Color(0xFF5D4037),
),
),
],
),
),
// ()
if (widget.referralCode != null && widget.referralCode!.isNotEmpty) ...[
const SizedBox(height: 16),
Row(
children: [
const Icon(Icons.share, color: Color(0xFFD4AF37), size: 20),
const SizedBox(width: 8),
const Text(
'您的推荐码: ',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
color: Color(0xFF5D4037),
),
),
Text(
widget.referralCode!,
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
color: Color(0xFFD4AF37),
),
),
const Spacer(),
GestureDetector(
onTap: () => _copyAddress(widget.referralCode!, '推荐码'),
child: const Icon(Icons.copy, color: Color(0xFF8B5A2B), size: 20),
),
],
),
],
],
),
);
}
///
Widget _buildWarningCard() {
final warningText = widget.isMpcMode
? '您的账户已安全创建。序列号是您的唯一身份标识,请妥善保管。'
: '请妥善保管您的助记词,丢失将无法恢复账号。';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: widget.isMpcMode ? const Color(0x1A22C55E) : const Color(0x1A7F1D1D),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.isMpcMode ? const Color(0x3322C55E) : const Color(0x337F1D1D),
width: 1,
),
color: const Color(0xFFFFF0E0),
borderRadius: BorderRadius.circular(12),
),
child: Text(
warningText,
child: const Text(
'请妥善保管您的助记词,丢失将无法恢复账号。',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: widget.isMpcMode ? const Color(0xFF166534) : const Color(0xFF991B1B),
color: Color(0xFFCC6B2C),
),
textAlign: TextAlign.center,
),
@ -503,38 +467,38 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0x80FFFFFF),
borderRadius: BorderRadius.circular(12),
color: const Color(0xFFFFFDF5),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000),
blurRadius: 2,
offset: Offset(0, 1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
children: [
_buildAddressItem(
icon: Icons.account_balance_wallet,
iconWidget: _buildChainIcon(),
label: 'KAVA 地址',
address: widget.kavaAddress,
showBorder: true,
),
_buildAddressItem(
icon: Icons.account_balance_wallet,
iconWidget: _buildChainIcon(),
label: 'DST 地址',
address: widget.dstAddress,
showBorder: true,
),
_buildAddressItem(
icon: Icons.account_balance_wallet,
iconWidget: _buildChainIcon(),
label: 'BSC 地址',
address: widget.bscAddress,
showBorder: true,
),
_buildAddressItem(
icon: Icons.confirmation_number,
iconWidget: _buildSequenceIcon(),
label: '序列号',
address: widget.serialNumber,
showBorder: false,
@ -544,15 +508,56 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
);
}
/// ()
Widget _buildChainIcon() {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(10),
),
child: const Center(
child: Icon(
Icons.credit_card,
color: Color(0xFFD4A84B),
size: 22,
),
),
);
}
/// (# )
Widget _buildSequenceIcon() {
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(10),
),
child: const Center(
child: Text(
'#',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFFD4A84B),
),
),
),
);
}
///
Widget _buildAddressItem({
required IconData icon,
required Widget iconWidget,
required String label,
required String address,
required bool showBorder,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: showBorder
? const Border(
@ -566,20 +571,8 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
child: Row(
children: [
//
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0x33D4AF37),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFFD4AF37),
size: 24,
),
),
const SizedBox(width: 16),
iconWidget,
const SizedBox(width: 14),
//
Expanded(
child: Column(
@ -590,18 +583,19 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
height: 1.5,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 2),
Text(
_formatAddress(address),
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0x995D4037),
color: Color(0x99756452),
),
),
],
@ -610,25 +604,29 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
//
GestureDetector(
onTap: () => _copyAddress(address, label),
child: const Row(
children: [
Icon(
Icons.copy,
color: Color(0xCC8B5A2B),
size: 16,
),
SizedBox(width: 5),
Text(
'复制',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xCC8B5A2B),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.copy_rounded,
color: Color(0xCC8B7355),
size: 16,
),
),
],
SizedBox(width: 4),
Text(
'复制',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xCC8B7355),
),
),
],
),
),
),
],
@ -645,25 +643,35 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
///
Widget _buildBottomButtons() {
return Container(
color: const Color(0xFFFFE4B5),
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
decoration: const BoxDecoration(
color: Color(0xFFFFE4B5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// MPC
//
GestureDetector(
onTap: widget.isMpcMode ? _enterApp : _confirmBackup,
onTap: _confirmBackup,
child: Container(
width: double.infinity,
height: 48,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
color: const Color(0xFFD4A84B),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x33D4A84B),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: Center(
child: const Center(
child: Text(
widget.isMpcMode ? '进入应用' : '我已备份助记词',
style: const TextStyle(
fontSize: 16,
'我已备份助记词',
style: TextStyle(
fontSize: 17,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
@ -673,30 +681,23 @@ class _BackupMnemonicPageState extends ConsumerState<BackupMnemonicPage> {
),
),
),
const SizedBox(height: 16),
// (MPC )
if (!widget.isMpcMode)
GestureDetector(
onTap: _goBack,
child: const Text(
'返回上一步',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xCC8B5A2B),
),
const SizedBox(height: 14),
//
GestureDetector(
onTap: _goBack,
child: const Text(
'返回上一步',
style: TextStyle(
fontSize: 15,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF8B7355),
),
),
),
],
),
);
}
/// MPC
void _enterApp() {
//
context.go(RoutePaths.ranking);
}
}

View File

@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/account_service.dart';
/// -
///
@ -21,8 +20,71 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
bool _isAgreed = false;
//
bool _isCreating = false;
//
String? _errorMessage;
//
bool _isWalletCreated = false;
//
bool _isLoading = true;
//
String? _mnemonic;
String? _kavaAddress;
String? _dstAddress;
String? _bscAddress;
String? _serialNumber;
String? _referralCode;
@override
void initState() {
super.initState();
_checkWalletStatus();
}
///
Future<void> _checkWalletStatus() async {
try {
final accountService = ref.read(accountServiceProvider);
//
final hasAccount = await accountService.hasAccount();
if (hasAccount) {
//
final mnemonic = await accountService.getMnemonic();
final addresses = await accountService.getWalletAddresses();
final sequence = await accountService.getAccountSequence();
final referralCode = await accountService.getReferralCode();
if (mounted) {
setState(() {
_isWalletCreated = true;
_mnemonic = mnemonic;
_kavaAddress = addresses?.kava;
_dstAddress = addresses?.dst;
_bscAddress = addresses?.bsc;
_serialNumber = sequence?.toString();
_referralCode = referralCode;
_isLoading = false;
//
_isAgreed = true;
});
}
} else {
if (mounted) {
setState(() {
_isWalletCreated = false;
_isLoading = false;
});
}
}
} catch (e) {
debugPrint('检查钱包状态失败: $e');
if (mounted) {
setState(() {
_isWalletCreated = false;
_isLoading = false;
});
}
}
}
///
///
@ -35,7 +97,6 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
setState(() {
_isCreating = true;
_errorMessage = null;
});
try {
@ -75,10 +136,6 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
debugPrint('创建账号失败: $e');
if (!mounted) return;
setState(() {
_errorMessage = e.toString();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('创建账号失败: ${e.toString().replaceAll('Exception: ', '')}'),
@ -121,6 +178,44 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
debugPrint('导入助记词');
}
///
void _goToBackupMnemonic() {
if (_mnemonic == null || _kavaAddress == null || _dstAddress == null ||
_bscAddress == null || _serialNumber == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('钱包数据不完整,请重新创建'),
backgroundColor: Colors.red,
),
);
return;
}
context.push(
RoutePaths.backupMnemonic,
extra: BackupMnemonicParams(
mnemonicWords: _mnemonic!.split(' '),
kavaAddress: _kavaAddress!,
dstAddress: _dstAddress!,
bscAddress: _bscAddress!,
serialNumber: _serialNumber!,
referralCode: _referralCode,
isMpcMode: false,
),
);
}
///
void _handleButtonTap() {
if (_isWalletCreated) {
//
_goToBackupMnemonic();
} else {
//
_createWallet();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -174,7 +269,7 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
@ -260,15 +355,47 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
///
Widget _buildCreateButton() {
//
if (_isLoading) {
return Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFD4AF37).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
//
final buttonText = _isWalletCreated
? '钱包已创建(点击备份助记词)'
: '生成钱包(创建账户)';
//
final isEnabled = _isWalletCreated || _isAgreed;
return GestureDetector(
onTap: _isCreating ? null : _createWallet,
onTap: (_isCreating || !isEnabled) ? null : _handleButtonTap,
child: Opacity(
opacity: _isAgreed ? 1.0 : 0.5,
opacity: isEnabled ? 1.0 : 0.5,
child: Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFD4AF37), //
color: _isWalletCreated
? const Color(0xFF52C41A) // 绿
: const Color(0xFFD4AF37), //
borderRadius: BorderRadius.circular(8),
),
child: Center(
@ -281,16 +408,29 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'生成钱包(创建账户)',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
letterSpacing: 0.24,
color: Colors.white,
),
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isWalletCreated) ...[
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
],
Text(
buttonText,
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
letterSpacing: 0.24,
color: Colors.white,
),
),
],
),
),
),

View File

@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
import '../providers/auth_provider.dart';
import '../../../../core/di/injection_container.dart';
/// -
///
@ -133,11 +133,9 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
setState(() => _isCreating = true);
try {
//
await ref.read(authProvider.notifier).saveWallet(
widget.kavaAddress,
widget.mnemonicWords.join(' '),
);
// 使 AccountService
final accountService = ref.read(accountServiceProvider);
await accountService.markMnemonicBackedUp();
if (!mounted) return;
@ -154,7 +152,10 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建账号失败: $e')),
SnackBar(
content: Text('操作失败: $e'),
backgroundColor: const Color(0xFF991B1B),
),
);
} finally {
if (mounted) {
@ -171,38 +172,52 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
//
_buildDescription(),
const SizedBox(height: 24),
//
_buildCheckbox(),
const SizedBox(height: 24),
// 线
_buildDivider(),
const SizedBox(height: 24),
//
_buildWordSelection(),
],
body: Container(
width: double.infinity,
height: double.infinity,
// -
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF8F0), //
Color(0xFFFFE4B5), //
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
//
_buildDescription(),
const SizedBox(height: 32),
//
_buildCheckbox(),
const SizedBox(height: 28),
// 线
_buildDivider(),
const SizedBox(height: 28),
//
_buildWordSelection(),
],
),
),
),
),
//
_buildBottomButtons(),
],
//
_buildBottomButtons(),
],
),
),
),
);
@ -211,15 +226,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
///
Widget _buildAppBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 40,
height: 40,
width: 44,
height: 44,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back,
@ -236,15 +251,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.56,
letterSpacing: -0.45,
height: 1.25,
letterSpacing: -0.27,
color: Color(0xFF5D4037),
),
textAlign: TextAlign.center,
),
),
//
const SizedBox(width: 40),
const SizedBox(width: 44, height: 44),
],
),
);
@ -272,16 +287,18 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
});
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// -
Container(
width: 24,
height: 24,
width: 22,
height: 22,
decoration: BoxDecoration(
color: _isChecked ? const Color(0xFFD4AF37) : Colors.transparent,
color: _isChecked ? const Color(0xFFD4A84B) : const Color(0xFFFFF5E6),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: _isChecked ? const Color(0xFFD4AF37) : const Color(0xFF8B5A2B),
width: 2,
color: _isChecked ? const Color(0xFFD4A84B) : const Color(0xFFD4A84B),
width: 1.5,
),
),
child: _isChecked
@ -315,25 +332,40 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
Expanded(
child: Container(
height: 1,
color: const Color(0x338B5A2B),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFD4A84B).withValues(alpha: 0.1),
const Color(0xFFD4A84B).withValues(alpha: 0.4),
],
),
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.43,
color: Color(0x998B5A2B),
color: Color(0xFF8B7355),
),
),
),
Expanded(
child: Container(
height: 1,
color: const Color(0x338B5A2B),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFD4A84B).withValues(alpha: 0.4),
const Color(0xFFD4A84B).withValues(alpha: 0.1),
],
),
),
),
),
],
@ -350,14 +382,15 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 16),
//
const SizedBox(height: 20),
// - 使
Wrap(
spacing: 12,
spacing: 10,
runSpacing: 12,
children: _wordOptions.map((word) => _buildWordOption(word)).toList(),
),
@ -368,38 +401,29 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
///
Widget _buildWordOption(String word) {
final isSelected = _selectedWord == word;
final isCorrect = word == _correctWord;
return GestureDetector(
onTap: () => _selectWord(word),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
// 使使
color: isSelected
? const Color(0x33D4AF37)
: const Color(0x1A8B5A2B),
borderRadius: BorderRadius.circular(8),
? const Color(0xFFFFF5E6)
: const Color(0xFFF5ECD9),
borderRadius: BorderRadius.circular(10),
border: isSelected
? Border.all(color: const Color(0xFFD4AF37), width: 1)
: null,
boxShadow: isSelected
? [
const BoxShadow(
color: Color(0xCCD4AF37),
blurRadius: 0,
spreadRadius: 1,
),
]
: null,
? Border.all(color: const Color(0xFFD4A84B), width: 1.5)
: Border.all(color: Colors.transparent, width: 1.5),
),
child: Text(
word,
style: TextStyle(
fontSize: 14,
fontSize: 15,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: isSelected ? const Color(0xFFD4AF37) : const Color(0xFF5D4037),
fontWeight: FontWeight.w600,
height: 1.4,
color: isSelected ? const Color(0xFFD4A84B) : const Color(0xFF5D4037),
),
),
),
@ -409,21 +433,34 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
///
Widget _buildBottomButtons() {
return Container(
color: const Color(0xFFFFE4B5),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
decoration: const BoxDecoration(
color: Color(0xFFFFE4B5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
GestureDetector(
onTap: _isCreating ? null : _confirmAndCreate,
child: Container(
width: double.infinity,
height: 48,
height: 52,
decoration: BoxDecoration(
// 使使
color: _canSubmit
? const Color(0xFFD4AF37)
: const Color(0x668B5A2B),
? const Color(0xFFD4A84B)
: const Color(0xFFCBBDA8),
borderRadius: BorderRadius.circular(12),
boxShadow: _canSubmit
? const [
BoxShadow(
color: Color(0x33D4A84B),
blurRadius: 8,
offset: Offset(0, 4),
),
]
: null,
),
child: Center(
child: _isCreating
@ -438,7 +475,7 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
: Text(
'确认并创建账号',
style: TextStyle(
fontSize: 16,
fontSize: 17,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
@ -450,23 +487,18 @@ class _VerifyMnemonicPageState extends ConsumerState<VerifyMnemonicPage> {
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 14),
//
GestureDetector(
onTap: _goBack,
child: Container(
width: double.infinity,
height: 48,
alignment: Alignment.center,
child: const Text(
'返回查看助记词',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFFD4AF37),
),
child: const Text(
'返回查看助记词',
style: TextStyle(
fontSize: 15,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF8B7355),
),
),
),

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../routes/app_router.dart';
/// -
///
@ -15,6 +16,8 @@ class WalletCreatedPage extends ConsumerWidget {
final String bscAddress;
///
final String serialNumber;
///
final String? referralCode;
const WalletCreatedPage({
super.key,
@ -22,6 +25,7 @@ class WalletCreatedPage extends ConsumerWidget {
required this.dstAddress,
required this.bscAddress,
required this.serialNumber,
this.referralCode,
});
///
@ -30,7 +34,7 @@ class WalletCreatedPage extends ConsumerWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label 已复制'),
backgroundColor: const Color(0xFFD4AF37),
backgroundColor: const Color(0xFFD4A84B),
duration: const Duration(seconds: 2),
),
);
@ -41,14 +45,18 @@ class WalletCreatedPage extends ConsumerWidget {
context.go(RoutePaths.ranking);
}
///
/// -
void _shareToFriends(BuildContext context) {
// TODO:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('分享功能即将推出'),
backgroundColor: Color(0xFF8B5A2B),
duration: Duration(seconds: 2),
// ()
final shareLink = referralCode != null
? 'https://rwa-durian.app/invite?code=$referralCode'
: 'https://rwa-durian.app/invite?seq=$serialNumber';
context.push(
RoutePaths.share,
extra: SharePageParams(
shareLink: shareLink,
referralCode: referralCode,
),
);
}
@ -62,42 +70,45 @@ class WalletCreatedPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.white,
body: Container(
width: double.infinity,
height: double.infinity,
// -
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF5E6),
Color(0xFFFFE4B5),
Color(0xFFFFF8F0), //
Color(0xFFFFE4B5), //
],
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: Column(
children: [
const SizedBox(height: 32),
//
_buildSuccessIcon(),
const SizedBox(height: 24),
//
_buildTitle(),
const SizedBox(height: 32),
//
_buildWalletInfoCard(context),
const SizedBox(height: 32),
//
_buildActionButtons(context),
const SizedBox(height: 16),
//
_buildWarningText(),
],
),
child: Column(
children: [
//
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 48),
//
_buildSuccessIcon(),
const SizedBox(height: 20),
//
_buildTitle(),
const SizedBox(height: 28),
//
_buildWalletInfoCard(context),
],
),
),
),
//
_buildBottomSection(context),
],
),
),
),
@ -110,14 +121,27 @@ class WalletCreatedPage extends ConsumerWidget {
width: 120,
height: 120,
decoration: const BoxDecoration(
color: Color(0x33D4AF37),
color: Color(0xFFFFF3D6), //
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.check_circle_outline,
size: 64,
color: Color(0xFFD4AF37),
child: Center(
child: Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFD4A84B),
width: 3,
),
),
child: const Center(
child: Icon(
Icons.check,
size: 36,
color: Color(0xFFD4A84B),
),
),
),
),
);
@ -125,16 +149,16 @@ class WalletCreatedPage extends ConsumerWidget {
///
Widget _buildTitle() {
return Column(
children: const [
return const Column(
children: [
Text(
'账号创建成功',
style: TextStyle(
fontSize: 30,
fontSize: 26,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
letterSpacing: -0.75,
letterSpacing: -0.5,
color: Color(0xFF5D4037),
),
),
@ -142,10 +166,11 @@ class WalletCreatedPage extends ConsumerWidget {
Text(
'已为您创建钱包与序列号',
style: TextStyle(
fontSize: 16,
fontSize: 15,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF8B5A2B),
color: Color(0xFF8B7355),
),
),
],
@ -156,51 +181,53 @@ class WalletCreatedPage extends ConsumerWidget {
Widget _buildWalletInfoCard(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x4DFFFFFF),
borderRadius: BorderRadius.circular(12),
color: const Color(0xFFFFFDF5),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x33D4AF37),
blurRadius: 0,
spreadRadius: 1,
offset: Offset(0, 0),
color: Color(0x0D000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
children: [
//
// -
_buildInfoItem(
context: context,
icon: Icons.key,
iconWidget: _buildKeyIcon(),
label: '序列号',
value: serialNumber,
isAddress: false,
showDivider: true,
),
// KAVA
_buildInfoItem(
context: context,
icon: Icons.account_balance_wallet,
iconWidget: _buildWalletIcon(),
label: 'KAVA',
value: kavaAddress,
isAddress: true,
showDivider: true,
),
// DST
_buildInfoItem(
context: context,
icon: Icons.account_balance_wallet,
iconWidget: _buildWalletIcon(),
label: 'DST',
value: dstAddress,
isAddress: true,
showDivider: true,
),
// BSC
_buildInfoItem(
context: context,
icon: Icons.account_balance_wallet,
iconWidget: _buildWalletIcon(),
label: 'BSC',
value: bscAddress,
isAddress: true,
showDivider: false,
),
],
@ -208,153 +235,181 @@ class WalletCreatedPage extends ConsumerWidget {
);
}
/// ()
Widget _buildKeyIcon() {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(10),
),
child: const Center(
child: Icon(
Icons.vpn_key_outlined,
color: Color(0xFFD4A84B),
size: 24,
),
),
);
}
/// ()
Widget _buildWalletIcon() {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFFF3D6),
borderRadius: BorderRadius.circular(10),
),
child: const Center(
child: Icon(
Icons.account_balance_wallet_outlined,
color: Color(0xFFD4A84B),
size: 24,
),
),
);
}
///
Widget _buildInfoItem({
required BuildContext context,
required IconData icon,
required Widget iconWidget,
required String label,
required String value,
required bool isAddress,
required bool showDivider,
}) {
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
//
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0x33D4AF37),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: const Color(0xFFD4AF37),
size: 24,
//
final displayValue = isAddress ? _formatAddress(value) : value;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: showDivider
? const Border(
bottom: BorderSide(
color: Color(0x1A5D4037),
width: 1,
),
)
: null,
),
child: Row(
children: [
//
iconWidget,
const SizedBox(width: 14),
// - "标签: 值"
Expanded(
child: Text(
'$label: $displayValue',
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF5D4037),
),
const SizedBox(width: 16),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label:',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0x998B5A2B),
),
),
Text(
label == '序列号' ? value : _formatAddress(value),
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF5D4037),
),
),
],
),
),
),
//
GestureDetector(
onTap: () => _copyToClipboard(context, value, label),
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.copy_outlined,
color: Color(0xFFD4A84B),
size: 22,
),
//
GestureDetector(
onTap: () => _copyToClipboard(context, value, label),
child: Container(
padding: const EdgeInsets.all(8),
child: const Icon(
Icons.copy,
color: Color(0xCC8B5A2B),
size: 20,
),
),
],
),
);
}
/// ( + )
Widget _buildBottomSection(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// -
GestureDetector(
onTap: () => _enterWallet(context),
child: Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFD4A84B),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x33D4A84B),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: const Center(
child: Text(
'进入我的钱包',
style: TextStyle(
fontSize: 17,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
color: Colors.white,
),
),
),
],
),
),
if (showDivider)
Container(
height: 1,
color: const Color(0x1A8B5A2B),
),
],
);
}
///
Widget _buildActionButtons(BuildContext context) {
return Column(
children: [
//
GestureDetector(
onTap: () => _enterWallet(context),
child: Container(
width: double.infinity,
height: 53,
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'进入我的钱包',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
color: Colors.white,
),
const SizedBox(height: 12),
// -
GestureDetector(
onTap: () => _shareToFriends(context),
child: Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
color: const Color(0xFF5D4037),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'分享给好友',
style: TextStyle(
fontSize: 17,
fontFamily: 'Inter',
fontWeight: FontWeight.w600,
height: 1.5,
color: Colors.white,
),
),
),
),
),
),
const SizedBox(height: 16),
//
GestureDetector(
onTap: () => _shareToFriends(context),
child: Container(
width: double.infinity,
height: 53,
decoration: BoxDecoration(
color: const Color(0xFF8B5A2B),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'分享给好友',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Colors.white,
),
),
const SizedBox(height: 16),
//
const Text(
'助记词仅显示一次,请妥善保管。',
style: TextStyle(
fontSize: 13,
fontFamily: 'Inter',
fontWeight: FontWeight.w400,
height: 1.5,
color: Color(0xFF8B7355),
),
textAlign: TextAlign.center,
),
),
],
);
}
///
Widget _buildWarningText() {
return const Text(
'助记词仅显示一次,请妥善保管。',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xCC8B5A2B),
],
),
textAlign: TextAlign.center,
);
}
}

View File

@ -0,0 +1,618 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../../core/di/injection_container.dart';
///
enum NetworkType {
kava,
bsc,
}
/// USDT
/// KAVA BSC
class DepositUsdtPage extends ConsumerStatefulWidget {
const DepositUsdtPage({super.key});
@override
ConsumerState<DepositUsdtPage> createState() => _DepositUsdtPageState();
}
class _DepositUsdtPageState extends ConsumerState<DepositUsdtPage> {
///
NetworkType _selectedNetwork = NetworkType.kava;
///
bool _isLoading = true;
/// USDT
String _balance = '0.00';
/// KAVA
String? _kavaAddress;
/// BSC
String? _bscAddress;
@override
void initState() {
super.initState();
_loadWalletData();
}
///
String? _errorMessage;
///
Future<void> _loadWalletData() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final depositService = ref.read(depositServiceProvider);
// ()
final addressResponse = await depositService.getDepositAddresses();
if (!addressResponse.isValid) {
//
if (mounted) {
setState(() {
_errorMessage = addressResponse.message ?? '充值账户异常,请联系客服';
_isLoading = false;
});
}
return;
}
_kavaAddress = addressResponse.kavaAddress;
_bscAddress = addressResponse.bscAddress;
//
try {
final balanceResponse = await depositService.getUsdtBalances();
//
if (mounted) {
setState(() {
if (_selectedNetwork == NetworkType.kava && balanceResponse.kava != null) {
_balance = balanceResponse.kava!.balance;
} else if (_selectedNetwork == NetworkType.bsc && balanceResponse.bsc != null) {
_balance = balanceResponse.bsc!.balance;
} else {
_balance = '0.00';
}
_isLoading = false;
});
}
} catch (e) {
debugPrint('查询余额失败: $e');
if (mounted) {
setState(() {
_balance = '0.00';
_isLoading = false;
});
}
}
} catch (e) {
debugPrint('加载钱包数据失败: $e');
if (mounted) {
setState(() {
_errorMessage = '加载失败,请重试';
_isLoading = false;
});
}
}
}
///
String get _currentAddress {
switch (_selectedNetwork) {
case NetworkType.kava:
return _kavaAddress ?? '';
case NetworkType.bsc:
return _bscAddress ?? '';
}
}
///
void _switchNetwork(NetworkType network) {
if (_selectedNetwork != network) {
setState(() {
_selectedNetwork = network;
});
}
}
///
void _copyAddress() {
if (_currentAddress.isEmpty) return;
Clipboard.setData(ClipboardData(text: _currentAddress));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('充值地址已复制'),
backgroundColor: Color(0xFFD4AF37),
duration: Duration(seconds: 2),
),
);
}
///
void _goBack() {
context.pop();
}
///
void _onDepositComplete() {
//
context.pop();
//
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('充值确认中,请稍候查看余额变化'),
backgroundColor: Color(0xFFD4AF37),
duration: Duration(seconds: 3),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
//
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6),
Color(0xFFEAE0C8),
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
//
_buildBalanceSection(),
//
_buildNetworkSwitch(),
const SizedBox(height: 8),
//
_buildQrCodeCard(),
//
_buildWarningText(),
],
),
),
),
//
_buildBottomButton(),
],
),
),
),
);
}
///
Widget _buildAppBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF5E6).withValues(alpha: 0.8),
),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back,
color: Color(0xFF5D4037),
size: 24,
),
),
),
//
const Expanded(
child: Text(
'充值 USDT',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
letterSpacing: -0.27,
color: Color(0xFF5D4037),
),
textAlign: TextAlign.center,
),
),
//
const SizedBox(width: 40),
],
),
);
}
///
Widget _buildBalanceSection() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'可用余额: $_balance USDT',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: const Color(0xFF5D4037).withValues(alpha: 0.8),
),
),
);
}
///
Widget _buildNetworkSwitch() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
// KAVA
Expanded(
child: _buildNetworkButton(
label: 'KAVA 网络',
network: NetworkType.kava,
isSelected: _selectedNetwork == NetworkType.kava,
),
),
// BSC
Expanded(
child: _buildNetworkButton(
label: 'BSC 网络',
network: NetworkType.bsc,
isSelected: _selectedNetwork == NetworkType.bsc,
),
),
],
),
),
);
}
///
Widget _buildNetworkButton({
required String label,
required NetworkType network,
required bool isSelected,
}) {
return GestureDetector(
onTap: () => _switchNetwork(network),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 9.5, horizontal: 12),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFFD4AF37) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: isSelected
? const [
BoxShadow(
color: Color(0x66D4AF37),
blurRadius: 3,
offset: Offset(0, 1),
),
]
: null,
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: isSelected ? Colors.white : const Color(0xFF745D43),
),
),
),
),
);
}
///
Widget _buildQrCodeCard() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
//
_buildQrCode(),
const SizedBox(height: 24),
//
_buildAddressInfo(),
],
),
);
}
///
Widget _buildQrCode() {
//
if (_isLoading) {
return Container(
width: 192,
height: 192,
decoration: BoxDecoration(
color: const Color(0xFF1A1A2E),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
);
}
//
if (_errorMessage != null) {
return Container(
width: 192,
height: 192,
decoration: BoxDecoration(
color: const Color(0xFFFFF5E6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE74C3C), width: 1),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFE74C3C),
size: 40,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFFE74C3C),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
GestureDetector(
onTap: _loadWalletData,
child: const Text(
'点击重试',
style: TextStyle(
fontSize: 12,
color: Color(0xFFD4AF37),
decoration: TextDecoration.underline,
),
),
),
],
),
),
),
);
}
//
if (_currentAddress.isEmpty) {
return Container(
width: 192,
height: 192,
decoration: BoxDecoration(
color: const Color(0xFFFFF5E6),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'暂无充值地址',
style: TextStyle(
fontSize: 14,
color: Color(0xFF5D4037),
),
),
),
);
}
return Container(
width: 192,
height: 192,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 10,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: QrImageView(
data: _currentAddress,
version: QrVersions.auto,
size: 192,
backgroundColor: Colors.white,
padding: const EdgeInsets.all(12),
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color(0xFF1A1A2E),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Color(0xFF1A1A2E),
),
),
),
);
}
///
Widget _buildAddressInfo() {
return Column(
children: [
//
const Text(
'充值地址',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 12),
//
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_isLoading ? '加载中...' : _currentAddress,
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: const Color(0xFF5D4037).withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
//
GestureDetector(
onTap: _isLoading ? null : _copyAddress,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(9999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'复制地址',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: _isLoading
? const Color(0xFFB0A090)
: const Color(0xFF745D43),
),
),
const SizedBox(width: 8),
Icon(
Icons.copy_outlined,
size: 18,
color: _isLoading
? const Color(0xFFB0A090)
: const Color(0xFF745D43),
),
],
),
),
),
],
);
}
///
Widget _buildWarningText() {
return Container(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 48),
child: Text(
'仅支持 USDT错充将无法追回',
style: TextStyle(
fontSize: 12,
fontFamily: 'Inter',
height: 1.5,
color: const Color(0xFFF97316).withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
);
}
///
Widget _buildBottomButton() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: GestureDetector(
onTap: _onDepositComplete,
child: Container(
width: double.infinity,
height: 55,
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x4DD4AF37),
blurRadius: 14,
offset: Offset(0, 4),
),
],
),
child: const Center(
child: Text(
'我已完成充值',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
color: Colors.white,
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,496 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:city_pickers/city_pickers.dart';
import '../widgets/planting_confirm_dialog.dart';
///
class PlantingLocationParams {
final int quantity;
final double totalPrice;
PlantingLocationParams({
required this.quantity,
required this.totalPrice,
});
}
///
///
class PlantingLocationPage extends ConsumerStatefulWidget {
final int quantity;
final double totalPrice;
const PlantingLocationPage({
super.key,
required this.quantity,
required this.totalPrice,
});
@override
ConsumerState<PlantingLocationPage> createState() =>
_PlantingLocationPageState();
}
class _PlantingLocationPageState extends ConsumerState<PlantingLocationPage> {
///
String? _selectedProvince;
///
String? _selectedCity;
///
bool _isSubmitting = false;
///
void _goBack() {
context.pop();
}
/// 使 city_pickers
Future<void> _showCityPicker() async {
final result = await CityPickers.showCityPicker(
context: context,
cancelWidget: const Text(
'取消',
style: TextStyle(color: Color(0xFF745D43), fontSize: 16),
),
confirmWidget: const Text(
'确定',
style: TextStyle(color: Color(0xFFD4AF37), fontSize: 16, fontWeight: FontWeight.w600),
),
height: 300,
showType: ShowType.pc, //
barrierDismissible: true,
);
if (result != null) {
setState(() {
_selectedProvince = result.provinceName;
_selectedCity = result.cityName;
});
}
}
///
void _showProvinceSelector() {
_showCityPicker();
}
///
void _showCitySelector() {
_showCityPicker();
}
///
void _confirmSelection() async {
if (_selectedProvince == null || _selectedCity == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请选择省份和城市'),
backgroundColor: Color(0xFFD4AF37),
),
);
return;
}
// 5
await PlantingConfirmDialog.show(
context: context,
province: _selectedProvince!,
city: _selectedCity!,
onConfirm: _submitPlanting,
);
}
///
Future<void> _submitPlanting() async {
setState(() => _isSubmitting = true);
try {
// TODO: API
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
//
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('认种成功!'),
backgroundColor: Color(0xFF4CAF50),
),
);
//
context.go('/profile');
}
} catch (e) {
debugPrint('认种失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('认种失败: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
///
bool get _canSubmit =>
_selectedProvince != null && _selectedCity != null && !_isSubmitting;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6),
Color(0xFFEAE0C8),
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildHeader(),
//
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
//
_buildHintText(),
const SizedBox(height: 8),
//
_buildProvinceSelector(),
const SizedBox(height: 22),
//
_buildCitySelector(),
const SizedBox(height: 24),
//
_buildWarningCard(),
],
),
),
),
),
//
_buildBottomButton(),
],
),
),
),
);
}
///
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFFFF5E6).withValues(alpha: 0.8),
),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 24,
height: 32,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back_ios,
color: Color(0xFFD4AF37),
size: 20,
),
),
),
const SizedBox(width: 4),
//
GestureDetector(
onTap: _goBack,
child: const Text(
'返回',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFFD4AF37),
),
),
),
const SizedBox(width: 48),
//
const Text(
'认种 Planting',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
color: Color(0xFF5D4037),
),
),
],
),
);
}
///
Widget _buildHintText() {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Text(
'请选择身份所在省市(只可选择一次)',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF745D43),
),
),
);
}
///
Widget _buildProvinceSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
const Text(
'省份(必选)',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 7),
//
GestureDetector(
onTap: _showProvinceSelector,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x4D8B5A2B),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedProvince ?? '选择省份',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: _selectedProvince != null
? const Color(0xFF5D4037)
: const Color(0xFF5D4037).withValues(alpha: 0.5),
),
),
const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8B5A2B),
size: 24,
),
],
),
),
),
],
);
}
///
Widget _buildCitySelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
const Text(
'市级(必选)',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 7),
//
GestureDetector(
onTap: _showCitySelector,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0x4D8B5A2B),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedCity ?? '选择市级',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: _selectedCity != null
? const Color(0xFF5D4037)
: const Color(0xFF5D4037).withValues(alpha: 0.5),
),
),
const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF8B5A2B),
size: 24,
),
],
),
),
),
],
);
}
///
Widget _buildWarningCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x33FFC107),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Container(
width: 24,
height: 34,
alignment: Alignment.topCenter,
child: const Icon(
Icons.warning_amber_rounded,
color: Color(0xFFD4AF37),
size: 24,
),
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'必须选择身份证所在省与市,',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF745D43),
),
),
Text(
'错误将无法获得 20 年榴莲果销售分成。',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF745D43),
),
),
Text(
'选择后不可修改!',
style: TextStyle(
fontSize: 14,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.5,
color: Color(0xFF745D43),
),
),
],
),
),
],
),
);
}
///
Widget _buildBottomButton() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: GestureDetector(
onTap: _canSubmit ? _confirmSelection : null,
child: Container(
width: double.infinity,
height: 52,
decoration: BoxDecoration(
color: _canSubmit
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: _isSubmitting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'确认选择',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
color: Colors.white,
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,577 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../routes/route_paths.dart';
import '../../../../core/di/injection_container.dart';
import 'planting_location_page.dart';
///
///
class PlantingQuantityPage extends ConsumerStatefulWidget {
const PlantingQuantityPage({super.key});
@override
ConsumerState<PlantingQuantityPage> createState() =>
_PlantingQuantityPageState();
}
class _PlantingQuantityPageState extends ConsumerState<PlantingQuantityPage> {
/// (USDT)
static const double _pricePerTree = 2199.0;
/// (USDT) - API
double _availableBalance = 0.0;
///
int _quantity = 0;
///
bool _isLoading = false;
///
String? _errorMessage;
@override
void initState() {
super.initState();
_loadBalance();
}
///
Future<void> _loadBalance() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// API USDT
final depositService = ref.read(depositServiceProvider);
final balanceResponse = await depositService.getUsdtBalances();
// KAVA BSC
double totalBalance = 0.0;
if (balanceResponse.kava != null) {
totalBalance += double.tryParse(balanceResponse.kava!.balance) ?? 0.0;
}
if (balanceResponse.bsc != null) {
totalBalance += double.tryParse(balanceResponse.bsc!.balance) ?? 0.0;
}
//
final maxQty = (totalBalance / _pricePerTree).floor();
setState(() {
_availableBalance = totalBalance;
// 1
_quantity = maxQty > 0 ? maxQty : 0;
_isLoading = false;
});
} catch (e) {
debugPrint('加载余额失败: $e');
setState(() {
_isLoading = false;
_errorMessage = '加载余额失败,请重试';
_availableBalance = 0.0;
_quantity = 0;
});
}
}
///
int get _maxQuantity {
return (_availableBalance / _pricePerTree).floor();
}
///
void _decreaseQuantity() {
if (_quantity > 1) {
setState(() => _quantity--);
}
}
///
void _increaseQuantity() {
if (_quantity < _maxQuantity) {
setState(() => _quantity++);
}
}
///
bool get _canDecrease => _quantity > 1;
///
bool get _canIncrease => _quantity < _maxQuantity;
///
void _goBack() {
context.pop();
}
///
void _goToNextStep() {
if (_quantity > 0 && _quantity <= _maxQuantity) {
context.push(
RoutePaths.plantingLocation,
extra: PlantingLocationParams(
quantity: _quantity,
totalPrice: _quantity * _pricePerTree,
),
);
}
}
///
String _formatNumber(double number) {
final parts = number.toStringAsFixed(2).split('.');
final intPart = parts[0].replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
return '$intPart.${parts[1]}';
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6),
Color(0xFFEAE0C8),
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildHeader(),
//
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
//
_buildBalanceCard(),
const SizedBox(height: 24),
//
_buildSectionTitle(),
const SizedBox(height: 8),
//
_buildQuantitySelector(),
const SizedBox(height: 8),
//
_buildPriceInfo(),
],
),
),
),
),
//
_buildBottomButton(),
],
),
),
),
);
}
///
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6).withValues(alpha: 0.8),
),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back_ios,
color: Color(0xFFD4AF37),
size: 20,
),
),
),
const SizedBox(width: 4),
//
GestureDetector(
onTap: _goBack,
child: const Text(
'返回',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFFD4AF37),
),
),
),
const SizedBox(width: 42),
//
const Expanded(
child: Text(
'认种 Planting',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
letterSpacing: -0.27,
color: Color(0xFF5D4037),
),
),
),
],
),
);
}
///
Widget _buildBalanceCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0x99FFFFFF),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 6,
offset: Offset(0, 4),
),
BoxShadow(
color: Color(0x1A000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'可用余额 (USDT)',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF745D43),
),
),
//
if (!_isLoading)
GestureDetector(
onTap: _loadBalance,
child: const Icon(
Icons.refresh,
color: Color(0xFFD4AF37),
size: 20,
),
),
],
),
const SizedBox(height: 3),
//
_isLoading
? const SizedBox(
height: 45,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFFD4AF37),
),
),
)
: _errorMessage != null
? GestureDetector(
onTap: _loadBalance,
child: SizedBox(
height: 45,
child: Row(
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFE65100),
size: 24,
),
const SizedBox(width: 8),
Text(
_errorMessage!,
style: const TextStyle(
fontSize: 14,
color: Color(0xFFE65100),
),
),
const SizedBox(width: 8),
const Text(
'点击重试',
style: TextStyle(
fontSize: 14,
color: Color(0xFFD4AF37),
fontWeight: FontWeight.w600,
),
),
],
),
),
)
: Text(
_formatNumber(_availableBalance),
style: const TextStyle(
fontSize: 36,
fontFamily: 'Inter',
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: -0.54,
color: Color(0xFF5D4037),
),
),
],
),
);
}
///
Widget _buildSectionTitle() {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: const Text(
'输入认种数量',
style: TextStyle(
fontSize: 24,
fontFamily: 'Inter',
fontWeight: FontWeight.w400,
height: 1.25,
color: Color(0xFF5D4037),
),
),
);
}
///
Widget _buildQuantitySelector() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0x99FFFFFF),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 6,
offset: Offset(0, 4),
),
BoxShadow(
color: Color(0x1A000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
_buildQuantityButton(
icon: '-',
onTap: _canDecrease ? _decreaseQuantity : null,
enabled: _canDecrease,
),
const SizedBox(width: 38),
//
Text(
'$_quantity',
style: const TextStyle(
fontSize: 36,
fontFamily: 'Inter',
fontWeight: FontWeight.w400,
height: 1.5,
color: Color(0xFF5D4037),
),
),
const SizedBox(width: 38),
//
_buildQuantityButton(
icon: '+',
onTap: _canIncrease ? _increaseQuantity : null,
enabled: _canIncrease,
),
],
),
);
}
///
Widget _buildQuantityButton({
required String icon,
required VoidCallback? onTap,
required bool enabled,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6),
borderRadius: BorderRadius.circular(9999),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 3,
offset: Offset(0, 1),
),
BoxShadow(
color: Color(0x1A000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Opacity(
opacity: enabled ? 1.0 : 0.4,
child: Center(
child: Text(
icon,
style: const TextStyle(
fontSize: 30,
fontFamily: 'Inter',
fontWeight: FontWeight.w400,
height: 1.5,
color: Color(0xFF745D43),
),
),
),
),
),
);
}
///
Widget _buildPriceInfo() {
final calculationResult = (_availableBalance / _pricePerTree).toStringAsFixed(2);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Text(
'每棵价格:${_pricePerTree.toInt()} USDT',
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF000000),
),
),
const SizedBox(height: 7),
//
Text(
'最大可认种:$_maxQuantity 棵 (根据余额自动计算)',
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF000000),
),
),
const SizedBox(height: 7),
//
Text(
'(计算: ${_formatNumber(_availableBalance).replaceAll('.00', '')} \u00f7 ${_pricePerTree.toInt()} = $calculationResult \u2192 向下取整为 $_maxQuantity',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF000000),
),
),
],
),
);
}
///
Widget _buildBottomButton() {
final bool canProceed = _quantity > 0 && _quantity <= _maxQuantity && !_isLoading;
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: GestureDetector(
onTap: canProceed ? _goToNextStep : null,
child: Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
color: canProceed
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
boxShadow: canProceed
? const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 15,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x1A000000),
blurRadius: 6,
offset: Offset(0, 4),
),
]
: null,
),
child: const Center(
child: Text(
'下一步:选择省市',
style: TextStyle(
fontSize: 18,
fontFamily: 'Inter',
fontWeight: FontWeight.w500,
height: 1.56,
color: Colors.white,
),
),
),
),
),
);
}
}
/// -
class PlantingQuantityParams {
final int quantity;
final double totalPrice;
PlantingQuantityParams({
required this.quantity,
required this.totalPrice,
});
}

View File

@ -0,0 +1,280 @@
import 'dart:async';
import 'package:flutter/material.dart';
///
/// 5
class PlantingConfirmDialog extends StatefulWidget {
///
final String province;
///
final String city;
///
final VoidCallback onConfirm;
const PlantingConfirmDialog({
super.key,
required this.province,
required this.city,
required this.onConfirm,
});
///
static Future<bool?> show({
required BuildContext context,
required String province,
required String city,
required VoidCallback onConfirm,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: const Color(0x80000000),
builder: (context) => PlantingConfirmDialog(
province: province,
city: city,
onConfirm: onConfirm,
),
);
}
@override
State<PlantingConfirmDialog> createState() => _PlantingConfirmDialogState();
}
class _PlantingConfirmDialogState extends State<PlantingConfirmDialog> {
///
int _countdown = 5;
///
Timer? _timer;
///
bool get _canConfirm => _countdown <= 0;
@override
void initState() {
super.initState();
_startCountdown();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
///
void _startCountdown() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_countdown > 0) {
setState(() => _countdown--);
} else {
timer.cancel();
}
});
}
///
void _handleConfirm() {
if (_canConfirm) {
Navigator.pop(context, true);
widget.onConfirm();
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 384),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 50,
offset: Offset(0, 25),
spreadRadius: -12,
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
//
_buildWarningIcon(),
const SizedBox(height: 4),
//
_buildTitle(),
const SizedBox(height: 4),
//
_buildContent(),
const SizedBox(height: 4),
//
_buildCountdownText(),
const SizedBox(height: 4),
//
_buildConfirmButton(),
],
),
),
),
);
}
///
Widget _buildWarningIcon() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFFFFF7E6),
borderRadius: BorderRadius.circular(30),
),
child: const Center(
child: Icon(
Icons.warning_amber_rounded,
color: Color(0xFF5D4037),
size: 36,
),
),
),
);
}
///
Widget _buildTitle() {
return const Text(
'最后确认!不可修改!',
style: TextStyle(
fontSize: 22,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.25,
letterSpacing: -0.33,
color: Color(0xFF5D4037),
),
textAlign: TextAlign.center,
);
}
///
Widget _buildContent() {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 20),
child: Column(
children: [
//
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF5D4037),
),
children: [
const TextSpan(text: '您选择的省市为: '),
TextSpan(
text: '${widget.province} · ${widget.city}',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: Color(0xFF745D43),
),
),
],
),
),
const SizedBox(height: 4),
//
RichText(
textAlign: TextAlign.center,
text: const TextSpan(
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFFFF4D4F),
),
children: [
TextSpan(text: '若错误,将失去 '),
TextSpan(
text: '20 年收益分成',
style: TextStyle(fontWeight: FontWeight.w700),
),
],
),
),
],
),
);
}
///
Widget _buildCountdownText() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'按钮倒计时:$_countdown',
style: const TextStyle(
fontSize: 14,
fontFamily: 'Inter',
height: 1.5,
color: Color(0xFF745D43),
),
),
);
}
///
Widget _buildConfirmButton() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: _canConfirm ? _handleConfirm : null,
child: Container(
height: 48,
constraints: const BoxConstraints(
minWidth: 84,
maxWidth: 480,
),
decoration: BoxDecoration(
color: _canConfirm
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
_canConfirm ? '确认认种' : '确认认种 (${_countdown}s)',
style: TextStyle(
fontSize: 16,
fontFamily: 'Inter',
fontWeight: FontWeight.w700,
height: 1.5,
letterSpacing: 0.24,
color: _canConfirm
? Colors.white
: const Color(0xFFD4AF37).withValues(alpha: 0.4),
),
),
),
),
),
),
);
}
}

View File

@ -145,7 +145,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
/// USDT
void _onDeposit() {
context.push(RoutePaths.deposit);
context.push(RoutePaths.depositUsdt);
}
///

View File

@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../../core/di/injection_container.dart';
/// -
/// API
class SharePage extends ConsumerStatefulWidget {
/// (fallback)
final String shareLink;
///
final String? referralCode;
const SharePage({
super.key,
required this.shareLink,
this.referralCode,
});
@override
ConsumerState<SharePage> createState() => _SharePageState();
}
class _SharePageState extends ConsumerState<SharePage> {
/// (API )
late String _displayLink;
///
bool _isLoading = true;
///
String? _errorMessage;
@override
void initState() {
super.initState();
_displayLink = widget.shareLink; // 使
_loadShareLink();
}
/// ( API )
Future<void> _loadShareLink() async {
try {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final referralService = ref.read(referralServiceProvider);
// API
final linkResponse = await referralService.generateReferralLink();
if (mounted) {
setState(() {
// 使使
_displayLink = linkResponse.shortUrl.isNotEmpty
? linkResponse.shortUrl
: linkResponse.fullUrl;
_isLoading = false;
});
}
} catch (e) {
debugPrint('加载分享链接失败: $e');
if (mounted) {
setState(() {
_isLoading = false;
// 使
_displayLink = widget.shareLink;
_errorMessage = '加载短链失败,使用默认链接';
});
}
}
}
///
void _copyLink() {
Clipboard.setData(ClipboardData(text: _displayLink));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('链接已复制'),
backgroundColor: Color(0xFFD4A84B),
duration: Duration(seconds: 2),
),
);
}
///
void _goBack() {
context.pop();
}
///
void _onConfirm() {
context.pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
// -
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFFFF7E6), //
Color(0xFFFFFDF8), //
],
),
),
child: SafeArea(
child: Column(
children: [
//
_buildAppBar(),
//
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 100),
// QR
_buildQrCodeSection(),
const SizedBox(height: 40),
//
_buildLinkSection(),
],
),
),
),
//
_buildFooter(),
],
),
),
),
);
}
///
Widget _buildAppBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
//
GestureDetector(
onTap: _goBack,
child: Container(
width: 28,
height: 28,
alignment: Alignment.center,
child: const Icon(
Icons.arrow_back_ios,
color: Color(0xFF8C6A3E),
size: 20,
),
),
),
const SizedBox(width: 8),
//
const Expanded(
child: Text(
'分享页面',
style: TextStyle(
fontSize: 18,
fontFamily: 'Segoe UI',
fontWeight: FontWeight.w600,
height: 1.56,
color: Color(0xFF8C6A3E),
),
),
),
],
),
);
}
/// QR
Widget _buildQrCodeSection() {
return Container(
width: 256,
height: 256,
decoration: BoxDecoration(
color: const Color(0xFFFAF3E3),
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x1A8C6A3E), // rgba(140, 106, 62, 0.1)
blurRadius: 30,
offset: Offset(0, 8),
),
],
),
child: Center(
child: _isLoading
? const CircularProgressIndicator(
color: Color(0xFFD4A84B),
strokeWidth: 2,
)
: QrImageView(
data: _displayLink,
version: QrVersions.auto,
size: 200,
backgroundColor: Colors.transparent,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color(0xFF8C6A3E),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Color(0xFF8C6A3E),
),
),
),
);
}
///
Widget _buildLinkSection() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
children: [
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 296),
padding: const EdgeInsets.fromLTRB(16, 8, 12, 8),
decoration: BoxDecoration(
color: const Color(0xFFF8F1E2),
borderRadius: BorderRadius.circular(9999), //
),
child: Row(
children: [
//
Expanded(
child: _isLoading
? const Text(
'加载中...',
style: TextStyle(
fontSize: 13.7,
fontFamily: 'Segoe UI',
height: 1.5,
color: Color(0xFFB0A090),
),
)
: Text(
_displayLink,
style: const TextStyle(
fontSize: 13.7,
fontFamily: 'Segoe UI',
height: 1.5,
color: Color(0xFF8C6A3E),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
//
GestureDetector(
onTap: _isLoading ? null : _copyLink,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(9999),
boxShadow: const [
BoxShadow(
color: Color(0x0D000000), // rgba(0, 0, 0, 0.05)
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Center(
child: Icon(
Icons.copy_outlined,
color: _isLoading
? const Color(0xFFB0A090)
: const Color(0xFF8C6A3E),
size: 18,
),
),
),
),
],
),
),
//
if (_errorMessage != null) ...[
const SizedBox(height: 8),
Text(
_errorMessage!,
style: const TextStyle(
fontSize: 12,
color: Color(0xFFB0A090),
),
),
],
],
),
);
}
///
Widget _buildFooter() {
return Container(
padding: const EdgeInsets.all(24),
child: GestureDetector(
onTap: _onConfirm,
child: Container(
width: double.infinity,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFFD1A45B),
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x4DD1A45B), // rgba(209, 164, 91, 0.3)
blurRadius: 15,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x4DD1A45B), // rgba(209, 164, 91, 0.3)
blurRadius: 6,
offset: Offset(0, 4),
),
],
),
child: const Center(
child: Text(
'确认',
style: TextStyle(
fontSize: 18,
fontFamily: 'Segoe UI',
fontWeight: FontWeight.w700,
height: 1.56,
color: Colors.white,
),
),
),
),
),
);
}
}

View File

@ -14,6 +14,10 @@ import '../features/mining/presentation/pages/mining_page.dart';
import '../features/trading/presentation/pages/trading_page.dart';
import '../features/profile/presentation/pages/profile_page.dart';
import '../features/profile/presentation/pages/edit_profile_page.dart';
import '../features/share/presentation/pages/share_page.dart';
import '../features/deposit/presentation/pages/deposit_usdt_page.dart';
import '../features/planting/presentation/pages/planting_quantity_page.dart';
import '../features/planting/presentation/pages/planting_location_page.dart';
import 'route_paths.dart';
import 'route_names.dart';
@ -52,12 +56,25 @@ class WalletCreatedParams {
final String dstAddress;
final String bscAddress;
final String serialNumber;
final String? referralCode;
WalletCreatedParams({
required this.kavaAddress,
required this.dstAddress,
required this.bscAddress,
required this.serialNumber,
this.referralCode,
});
}
///
class SharePageParams {
final String shareLink;
final String? referralCode;
SharePageParams({
required this.shareLink,
this.referralCode,
});
}
@ -134,6 +151,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
dstAddress: params.dstAddress,
bscAddress: params.bscAddress,
serialNumber: params.serialNumber,
referralCode: params.referralCode,
);
},
),
@ -145,6 +163,46 @@ final appRouterProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const EditProfilePage(),
),
// Share Page ()
GoRoute(
path: RoutePaths.share,
name: RouteNames.share,
builder: (context, state) {
final params = state.extra as SharePageParams;
return SharePage(
shareLink: params.shareLink,
referralCode: params.referralCode,
);
},
),
// Deposit USDT Page ( USDT)
GoRoute(
path: RoutePaths.depositUsdt,
name: RouteNames.depositUsdt,
builder: (context, state) => const DepositUsdtPage(),
),
// Planting Quantity Page ( - )
GoRoute(
path: RoutePaths.plantingQuantity,
name: RouteNames.plantingQuantity,
builder: (context, state) => const PlantingQuantityPage(),
),
// Planting Location Page ( - )
GoRoute(
path: RoutePaths.plantingLocation,
name: RouteNames.plantingLocation,
builder: (context, state) {
final params = state.extra as PlantingLocationParams;
return PlantingLocationPage(
quantity: params.quantity,
totalPrice: params.totalPrice,
);
},
),
// Main Shell with Bottom Navigation
ShellRoute(
navigatorKey: _shellNavigatorKey,

View File

@ -22,10 +22,14 @@ class RouteNames {
static const referralList = 'referral-list';
static const earningsDetail = 'earnings-detail';
static const deposit = 'deposit';
static const depositUsdt = 'deposit-usdt';
static const plantingQuantity = 'planting-quantity';
static const plantingLocation = 'planting-location';
static const googleAuth = 'google-auth';
static const changePassword = 'change-password';
static const bindEmail = 'bind-email';
static const transactionHistory = 'transaction-history';
// Share
static const share = 'share';
}

View File

@ -22,10 +22,14 @@ class RoutePaths {
static const referralList = '/profile/referrals';
static const earningsDetail = '/profile/earnings';
static const deposit = '/deposit';
static const depositUsdt = '/deposit/usdt';
static const plantingQuantity = '/planting/quantity';
static const plantingLocation = '/planting/location';
static const googleAuth = '/security/google-auth';
static const changePassword = '/security/password';
static const bindEmail = '/security/email';
static const transactionHistory = '/trading/history';
// Share
static const share = '/share';
}

View File

@ -169,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
city_pickers:
dependency: "direct main"
description:
name: city_pickers
sha256: "583102c8d9eecb1f7abc5ff52a22d7cb019b9808cdb24b80c7692c769f8da153"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
cli_util:
dependency: transitive
description:
@ -861,6 +869,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.3.2"
lpinyin:
dependency: transitive
description:
name: lpinyin
sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
matcher:
dependency: transitive
description:
@ -1181,6 +1197,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
scrollable_positioned_list:
dependency: transitive
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
url: "https://pub.dev"
source: hosted
version: "0.3.8"
sec:
dependency: transitive
description:

View File

@ -42,6 +42,7 @@ dependencies:
lottie: ^3.1.0
qr_flutter: ^4.1.0
flutter_screenutil: ^5.9.0
city_pickers: ^1.3.0
# 工具
intl: ^0.20.2

View File

@ -0,0 +1,380 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';
import 'package:rwa_android_app/core/services/account_service.dart';
import 'package:rwa_android_app/core/network/api_client.dart';
import 'package:rwa_android_app/core/storage/secure_storage.dart';
import 'package:rwa_android_app/core/storage/storage_keys.dart';
// Mock classes using mocktail
class MockApiClient extends Mock implements ApiClient {}
class MockSecureStorage extends Mock implements SecureStorage {}
void main() {
late AccountService accountService;
late MockApiClient mockApiClient;
late MockSecureStorage mockSecureStorage;
setUpAll(() {
// Register fallback values for any() matchers
registerFallbackValue(RequestOptions(path: ''));
});
setUp(() {
mockApiClient = MockApiClient();
mockSecureStorage = MockSecureStorage();
accountService = AccountService(
apiClient: mockApiClient,
secureStorage: mockSecureStorage,
);
});
group('AccountService', () {
group('createAccount', () {
test('should create account and save data to secure storage', () async {
// Arrange
const testDeviceId = 'test-device-123';
final mockResponse = Response<Map<String, dynamic>>(
data: {
'userId': '123456789',
'accountSequence': 1,
'referralCode': 'ABC123',
'mnemonic': '',
'clientShareData': 'mock-client-share-data',
'publicKey': 'mock-public-key',
'walletAddresses': {
'kava': '0x1234567890abcdef1234567890abcdef12345678',
'dst': 'dst1abcdefghijklmnopqrstuvwxyz123456789',
'bsc': '0x1234567890abcdef1234567890abcdef12345678',
},
'accessToken': 'mock-access-token',
'refreshToken': 'mock-refresh-token',
},
requestOptions: RequestOptions(path: '/user/auto-create'),
statusCode: 201,
);
// Setup mocks
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
.thenAnswer((_) async => testDeviceId);
when(() => mockApiClient.post(any(), data: any(named: 'data')))
.thenAnswer((_) async => mockResponse);
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
// Act
final result = await accountService.createAccount();
// Assert
expect(result.userId, '123456789');
expect(result.accountSequence, 1);
expect(result.referralCode, 'ABC123');
expect(result.clientShareData, 'mock-client-share-data');
expect(result.publicKey, 'mock-public-key');
expect(result.walletAddresses.kava, '0x1234567890abcdef1234567890abcdef12345678');
expect(result.walletAddresses.dst, 'dst1abcdefghijklmnopqrstuvwxyz123456789');
expect(result.walletAddresses.bsc, '0x1234567890abcdef1234567890abcdef12345678');
expect(result.accessToken, 'mock-access-token');
expect(result.refreshToken, 'mock-refresh-token');
// Verify storage calls
verify(() => mockSecureStorage.write(
key: StorageKeys.userId,
value: '123456789',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.accountSequence,
value: '1',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.referralCode,
value: 'ABC123',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.accessToken,
value: 'mock-access-token',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.refreshToken,
value: 'mock-refresh-token',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.isWalletCreated,
value: 'true',
)).called(1);
});
test('should handle MPC mode without mnemonic', () async {
// Arrange
const testDeviceId = 'test-device-123';
final mockResponse = Response<Map<String, dynamic>>(
data: {
'userId': '123456789',
'accountSequence': 1,
'referralCode': 'ABC123',
'mnemonic': '', // Empty in MPC mode
'clientShareData': 'client-share-data',
'publicKey': 'public-key',
'walletAddresses': {
'kava': '0x1234',
'dst': 'dst1abc',
'bsc': '0x1234',
},
'accessToken': 'token',
'refreshToken': 'refresh',
},
requestOptions: RequestOptions(path: '/user/auto-create'),
statusCode: 201,
);
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
.thenAnswer((_) async => testDeviceId);
when(() => mockApiClient.post(any(), data: any(named: 'data')))
.thenAnswer((_) async => mockResponse);
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
// Act
final result = await accountService.createAccount();
// Assert - MPC data should be stored
expect(result.mnemonic, isEmpty);
expect(result.clientShareData, 'client-share-data');
expect(result.publicKey, 'public-key');
verify(() => mockSecureStorage.write(
key: StorageKeys.mpcClientShareData,
value: 'client-share-data',
)).called(1);
verify(() => mockSecureStorage.write(
key: StorageKeys.mpcPublicKey,
value: 'public-key',
)).called(1);
});
});
group('getOrCreateDeviceId', () {
test('should return existing device id if available', () async {
// Arrange
const existingDeviceId = 'existing-device-123';
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
.thenAnswer((_) async => existingDeviceId);
// Act
final result = await accountService.getOrCreateDeviceId();
// Assert
expect(result, existingDeviceId);
verifyNever(() => mockSecureStorage.write(
key: any(named: 'key'),
value: any(named: 'value'),
));
});
test('should generate and save new device id if not exists', () async {
// Arrange
when(() => mockSecureStorage.read(key: StorageKeys.deviceId))
.thenAnswer((_) async => null);
when(() => mockSecureStorage.write(key: any(named: 'key'), value: any(named: 'value')))
.thenAnswer((_) async {});
// Act
final result = await accountService.getOrCreateDeviceId();
// Assert
expect(result, isNotEmpty);
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(
result,
matches(RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
caseSensitive: false,
)));
verify(() => mockSecureStorage.write(
key: StorageKeys.deviceId,
value: result,
)).called(1);
});
});
group('hasAccount', () {
test('should return true when wallet is created', () async {
// Arrange
when(() => mockSecureStorage.read(key: StorageKeys.isWalletCreated))
.thenAnswer((_) async => 'true');
// Act
final result = await accountService.hasAccount();
// Assert
expect(result, true);
});
test('should return false when wallet is not created', () async {
// Arrange
when(() => mockSecureStorage.read(key: StorageKeys.isWalletCreated))
.thenAnswer((_) async => null);
// Act
final result = await accountService.hasAccount();
// Assert
expect(result, false);
});
});
group('getWalletAddresses', () {
test('should return wallet addresses when all are available', () async {
// Arrange
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressBsc))
.thenAnswer((_) async => '0xBSC');
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressKava))
.thenAnswer((_) async => '0xKAVA');
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressDst))
.thenAnswer((_) async => 'dst1DST');
// Act
final result = await accountService.getWalletAddresses();
// Assert
expect(result, isNotNull);
expect(result!.bsc, '0xBSC');
expect(result.kava, '0xKAVA');
expect(result.dst, 'dst1DST');
});
test('should return null when any address is missing', () async {
// Arrange
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressBsc))
.thenAnswer((_) async => '0xBSC');
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressKava))
.thenAnswer((_) async => null); // Missing
when(() => mockSecureStorage.read(key: StorageKeys.walletAddressDst))
.thenAnswer((_) async => 'dst1DST');
// Act
final result = await accountService.getWalletAddresses();
// Assert
expect(result, isNull);
});
});
group('logout', () {
test('should clear all stored data', () async {
// Arrange
when(() => mockSecureStorage.deleteAll()).thenAnswer((_) async {});
// Act
await accountService.logout();
// Assert
verify(() => mockSecureStorage.deleteAll()).called(1);
});
});
});
group('CreateAccountResponse', () {
test('should parse JSON correctly', () {
// Arrange
final json = {
'userId': '123456789',
'accountSequence': 42,
'referralCode': 'TESTCD',
'mnemonic': 'word1 word2 word3',
'clientShareData': 'share-data',
'publicKey': 'pub-key',
'walletAddresses': {
'kava': '0xKAVA',
'dst': 'dst1DST',
'bsc': '0xBSC',
},
'accessToken': 'access-token',
'refreshToken': 'refresh-token',
};
// Act
final response = CreateAccountResponse.fromJson(json);
// Assert
expect(response.userId, '123456789');
expect(response.accountSequence, 42);
expect(response.referralCode, 'TESTCD');
expect(response.mnemonic, 'word1 word2 word3');
expect(response.clientShareData, 'share-data');
expect(response.publicKey, 'pub-key');
expect(response.walletAddresses.kava, '0xKAVA');
expect(response.walletAddresses.dst, 'dst1DST');
expect(response.walletAddresses.bsc, '0xBSC');
expect(response.accessToken, 'access-token');
expect(response.refreshToken, 'refresh-token');
});
test('should handle nullable fields', () {
// Arrange
final json = {
'userId': '123456789',
'accountSequence': 1,
'referralCode': 'ABC123',
'walletAddresses': {
'kava': '0xKAVA',
'dst': 'dst1DST',
'bsc': '0xBSC',
},
'accessToken': 'token',
'refreshToken': 'refresh',
// mnemonic, clientShareData, publicKey are null
};
// Act
final response = CreateAccountResponse.fromJson(json);
// Assert
expect(response.mnemonic, isNull);
expect(response.clientShareData, isNull);
expect(response.publicKey, isNull);
});
});
group('CreateAccountRequest', () {
test('should serialize to JSON correctly', () {
// Arrange
final request = CreateAccountRequest(
deviceId: 'device-123',
deviceName: 'iPhone 15',
inviterReferralCode: 'INVITE',
provinceCode: '110000',
cityCode: '110100',
);
// Act
final json = request.toJson();
// Assert
expect(json['deviceId'], 'device-123');
expect(json['deviceName'], 'iPhone 15');
expect(json['inviterReferralCode'], 'INVITE');
expect(json['provinceCode'], '110000');
expect(json['cityCode'], '110100');
});
test('should exclude null fields from JSON', () {
// Arrange
final request = CreateAccountRequest(
deviceId: 'device-123',
// All optional fields are null
);
// Act
final json = request.toJson();
// Assert
expect(json['deviceId'], 'device-123');
expect(json.containsKey('deviceName'), false);
expect(json.containsKey('inviterReferralCode'), false);
expect(json.containsKey('provinceCode'), false);
expect(json.containsKey('cityCode'), false);
});
});
}

View File

@ -0,0 +1,343 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:rwa_android_app/core/services/mpc_share_service.dart';
void main() {
group('MpcShareService', () {
group('generateMnemonic', () {
test('should generate valid 12-word BIP39 mnemonic', () {
// Act
final mnemonic = MpcShareService.generateMnemonic();
// Assert
final words = mnemonic.split(' ');
expect(words.length, 12, reason: 'Should generate 12 words');
expect(MpcShareService.validateMnemonic(mnemonic), true,
reason: 'Generated mnemonic should be valid BIP39');
});
test('should generate different mnemonics each time', () {
// Act
final mnemonic1 = MpcShareService.generateMnemonic();
final mnemonic2 = MpcShareService.generateMnemonic();
// Assert
expect(mnemonic1, isNot(equals(mnemonic2)),
reason: 'Each mnemonic should be unique (random)');
});
});
group('createShareBackup', () {
test('should create backup with all required fields', () {
// Arrange
const shareData = 'mock-mpc-share-data-256bit';
// Act
final backup = MpcShareService.createShareBackup(shareData);
// Assert
expect(backup.mnemonic, isNotEmpty);
expect(backup.encryptedShare, isNotEmpty);
expect(backup.iv, isNotEmpty);
expect(backup.authTag, isNotEmpty);
expect(backup.mnemonicWords.length, 12);
});
test('should create valid BIP39 mnemonic', () {
// Arrange
const shareData = 'test-share-for-mnemonic';
// Act
final backup = MpcShareService.createShareBackup(shareData);
// Assert
expect(MpcShareService.validateMnemonic(backup.mnemonic), true);
});
test('should throw on empty share data', () {
// Act & Assert
expect(
() => MpcShareService.createShareBackup(''),
throwsA(isA<ArgumentError>()),
);
});
});
group('encryptShare and decryptShare', () {
test('should encrypt and decrypt share correctly', () {
// Arrange
const originalShare = 'secret-mpc-share-data-to-encrypt';
final mnemonic = MpcShareService.generateMnemonic();
// Act
final encrypted = MpcShareService.encryptShare(originalShare, mnemonic);
final decrypted = MpcShareService.decryptShare(
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
authTag: encrypted.authTag,
mnemonic: mnemonic,
);
// Assert
expect(decrypted, originalShare);
});
test('should produce different ciphertext with different IVs', () {
// Arrange
const shareData = 'same-share-data';
final mnemonic = MpcShareService.generateMnemonic();
// Act
final encrypted1 = MpcShareService.encryptShare(shareData, mnemonic);
final encrypted2 = MpcShareService.encryptShare(shareData, mnemonic);
// Assert - different IVs should produce different ciphertext
expect(encrypted1.iv, isNot(equals(encrypted2.iv)));
expect(encrypted1.ciphertext, isNot(equals(encrypted2.ciphertext)));
});
test('should fail decryption with wrong mnemonic', () {
// Arrange
const shareData = 'secret-share';
final correctMnemonic = MpcShareService.generateMnemonic();
final wrongMnemonic = MpcShareService.generateMnemonic();
final encrypted = MpcShareService.encryptShare(shareData, correctMnemonic);
// Act & Assert
expect(
() => MpcShareService.decryptShare(
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
authTag: encrypted.authTag,
mnemonic: wrongMnemonic,
),
throwsA(isA<StateError>()),
);
});
test('should fail decryption with tampered ciphertext', () {
// Arrange
const shareData = 'secret-share';
final mnemonic = MpcShareService.generateMnemonic();
final encrypted = MpcShareService.encryptShare(shareData, mnemonic);
// Tamper with ciphertext
final tamperedBytes = base64Decode(encrypted.ciphertext);
tamperedBytes[0] = (tamperedBytes[0] + 1) % 256;
final tamperedCiphertext = base64Encode(tamperedBytes);
// Act & Assert
expect(
() => MpcShareService.decryptShare(
ciphertext: tamperedCiphertext,
iv: encrypted.iv,
authTag: encrypted.authTag,
mnemonic: mnemonic,
),
throwsA(isA<StateError>()),
);
});
});
group('recoverShare', () {
test('should recover original share from backup', () {
// Arrange
const originalShare = 'original-mpc-share-256bit-data';
final backup = MpcShareService.createShareBackup(originalShare);
// Act
final recovered = MpcShareService.recoverShare(
mnemonic: backup.mnemonic,
encryptedShare: backup.encryptedShare,
iv: backup.iv,
authTag: backup.authTag,
);
// Assert
expect(recovered, originalShare);
});
test('should recover using MpcShareBackup.recoverShare()', () {
// Arrange
const originalShare = 'test-share-for-backup-recovery';
final backup = MpcShareService.createShareBackup(originalShare);
// Act
final recovered = backup.recoverShare();
// Assert
expect(recovered, originalShare);
});
test('should throw on invalid mnemonic format', () {
// Arrange
final backup = MpcShareService.createShareBackup('test-share');
// Act & Assert
expect(
() => MpcShareService.recoverShare(
mnemonic: 'invalid mnemonic words',
encryptedShare: backup.encryptedShare,
iv: backup.iv,
authTag: backup.authTag,
),
throwsA(isA<ArgumentError>()),
);
});
test('should handle complex base64 share data', () {
// Arrange - simulate real 256-bit MPC share
final complexBytes = List.generate(32, (i) => (i * 17 + 5) % 256);
final complexShare = base64Encode(complexBytes);
final backup = MpcShareService.createShareBackup(complexShare);
// Act
final recovered = backup.recoverShare();
// Assert
expect(recovered, complexShare);
expect(base64Decode(recovered).length, 32);
});
});
group('validateMnemonic', () {
test('should return true for valid 12-word mnemonic', () {
final mnemonic = MpcShareService.generateMnemonic();
expect(MpcShareService.validateMnemonic(mnemonic), true);
});
test('should return false for invalid mnemonic', () {
expect(MpcShareService.validateMnemonic('invalid words here'), false);
expect(MpcShareService.validateMnemonic(''), false);
expect(
MpcShareService.validateMnemonic(
'one two three four five six seven eight nine ten eleven twelve',
),
false,
);
});
});
group('MpcShareBackup', () {
test('should serialize to JSON correctly', () {
// Arrange
final backup = MpcShareBackup(
mnemonic: 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12',
encryptedShare: 'encrypted-data-base64',
iv: 'iv-base64',
authTag: 'auth-tag-base64',
);
// Act
final json = backup.toJson();
// Assert
expect(json['mnemonic'], backup.mnemonic);
expect(json['encryptedShare'], 'encrypted-data-base64');
expect(json['iv'], 'iv-base64');
expect(json['authTag'], 'auth-tag-base64');
});
test('should deserialize from JSON correctly', () {
// Arrange
final json = {
'mnemonic': 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12',
'encryptedShare': 'encrypted-data-base64',
'iv': 'iv-base64',
'authTag': 'auth-tag-base64',
};
// Act
final backup = MpcShareBackup.fromJson(json);
// Assert
expect(backup.mnemonic, json['mnemonic']);
expect(backup.encryptedShare, 'encrypted-data-base64');
expect(backup.iv, 'iv-base64');
expect(backup.authTag, 'auth-tag-base64');
expect(backup.mnemonicWords.length, 12);
});
});
group('bytesToHex and hexToBytes', () {
test('should convert bytes to hex string', () {
final bytes = Uint8List.fromList([0, 15, 16, 255]);
final hex = MpcShareService.bytesToHex(bytes);
expect(hex, '000f10ff');
});
test('should convert hex string to bytes', () {
const hex = '000f10ff';
final bytes = MpcShareService.hexToBytes(hex);
expect(bytes, Uint8List.fromList([0, 15, 16, 255]));
});
test('should round-trip bytes through hex', () {
final original = Uint8List.fromList(List.generate(32, (i) => i * 8 % 256));
final hex = MpcShareService.bytesToHex(original);
final recovered = MpcShareService.hexToBytes(hex);
expect(recovered, original);
});
});
group('Security properties', () {
test('same share with same mnemonic should produce same decrypted result', () {
// Arrange
const shareData = 'consistent-share-data';
final mnemonic = MpcShareService.generateMnemonic();
// Encrypt twice with same mnemonic (different IVs)
final encrypted1 = MpcShareService.encryptShare(shareData, mnemonic);
final encrypted2 = MpcShareService.encryptShare(shareData, mnemonic);
// Act - decrypt both
final decrypted1 = MpcShareService.decryptShare(
ciphertext: encrypted1.ciphertext,
iv: encrypted1.iv,
authTag: encrypted1.authTag,
mnemonic: mnemonic,
);
final decrypted2 = MpcShareService.decryptShare(
ciphertext: encrypted2.ciphertext,
iv: encrypted2.iv,
authTag: encrypted2.authTag,
mnemonic: mnemonic,
);
// Assert
expect(decrypted1, shareData);
expect(decrypted2, shareData);
expect(decrypted1, decrypted2);
});
test('full round-trip: share -> backup -> recover', () {
// Arrange - simulate full MPC workflow
final originalShare = base64Encode(
List.generate(64, (i) => (i * 13 + 7) % 256),
); // 512-bit share data
// Act
final backup = MpcShareService.createShareBackup(originalShare);
// User stores mnemonic, device stores encrypted data
final storedMnemonic = backup.mnemonic;
final storedEncrypted = backup.encryptedShare;
final storedIv = backup.iv;
final storedAuthTag = backup.authTag;
// Later: user enters mnemonic to recover
final recoveredShare = MpcShareService.recoverShare(
mnemonic: storedMnemonic,
encryptedShare: storedEncrypted,
iv: storedIv,
authTag: storedAuthTag,
);
// Assert
expect(recoveredShare, originalShare);
});
});
});
}