Compare commits

...

627 Commits

Author SHA1 Message Date
hailin bb4143d75b fix(trading-service): exclude prisma from tsconfig to fix build output path 2026-01-15 04:46:01 -08:00
hailin d12bbb17be feat(mining-app): add share pool balance display on contribution page
Display real-time share pool balance (积分股池实时余量) in the total
contribution card on the contribution page.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 04:27:48 -08:00
hailin 19428a8cb7 feat(trading-service): sync trading account creation with wallet service
- Add CDC consumer to listen for UserWalletCreated events from mining-wallet-service
- Create trading accounts when user contribution wallets are created (lazy creation)
- Add WalletSystemAccountCreated handler for province/city system accounts
- Add seed script for core system accounts (HQ, operation, cost, pool)
- Keep auth.user.registered listener for V2 new user registration

This ensures trading accounts are created in sync with wallet accounts,
supporting both V2 new users and V1 migrated users.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 04:27:14 -08:00
hailin 183b2bef59 style(mining-app): hide accountSequence and rename phone to ID on profile page
- Remove accountSequence (ID: xxxx) display from profile page
- Rename "手机:" label to "ID:" for phone number display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 03:39:18 -08:00
hailin 1bdb9bb336 style(mining-admin-web): display all numbers with 8 decimal places
Update all formatDecimal, formatNumber, formatPercent, formatCompactNumber
and formatShareAmount calls to use 8 decimal precision for consistent display
across all pages (dashboard, users, reports, system-accounts).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:55:23 -08:00
hailin d7bbb19571 fix(mining-admin-service): correct effective contribution calculation
Effective contribution should equal theoretical total (totalTrees * 22617)
since it includes all parts: personal 70% + operation 12% + province 1% +
city 2% + level 7.5% + bonus 7.5% = 100%.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:20:45 -08:00
hailin 420dfbfd9f fix(mining-admin-web): display theoretical network contribution instead of effective
Changed "全网算力" card to show theoretical total (totalTrees * 22617) instead
of effective contribution. Added effective contribution to subValue for reference.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 02:04:08 -08:00
hailin cfbf1b21f3 feat(dashboard): add detailed contribution breakdown by category
Backend (contribution-service):
- Add getDetailedContributionStats() to repository
- Add getUnallocatedByLevelTier/BonusTier() to repository
- Extend stats API with level/bonus breakdown by tier
- Add getTotalTrees() to synced-data repository

Backend (mining-admin-service):
- Add detailed contribution stats calculation
- Calculate theoretical vs actual values per category
- Return level/bonus breakdown with unlocked/pending amounts

Frontend (mining-admin-web):
- Add ContributionBreakdown component showing:
  - Personal (70%), Operation (12%), Province (1%), City (2%)
  - Level contribution (7.5%) by tier: 1-5, 6-10, 11-15
  - Bonus contribution (7.5%) by tier: T1, T2, T3
- Update DashboardStats type definition
- Integrate breakdown component into dashboard page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:43:37 -08:00
hailin 1f15daa6c5 fix(planting-records): filter only MINING_ENABLED records and fix UI overflow
- Backend: Add status filter to getPlantingLedger and getPlantingSummary
- Frontend: Change Row to Wrap for info items to prevent width overflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:12:07 -08:00
hailin 8ae9e217ff fix(mining-app): fix mining records data parsing from mining-service
Map miningMinute->distributionMinute, minedAmount->shareAmount,
secondDistribution->priceSnapshot to match entity fields

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:02:30 -08:00
hailin 12f8fa67fc feat(mining-admin): add totalTrees, separate level/bonus pending display
- Add totalTrees field from syncedAdoption aggregate
- Rename fields: networkLevelPending, networkBonusPending
- Stats card: show level pending and bonus pending separately
- Add new stats card for total trees count
- Price overview: 2-row layout showing all contribution metrics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:59:32 -08:00
hailin b310fde426 feat(mining-admin): show pending contribution in dashboard
- Add networkPendingContribution and networkBonusPendingContribution to API
- Display combined pending contribution (teamLevel + teamBonus) in stats card
- Replace 'total contribution' with 'pending contribution' in price overview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:46:05 -08:00
hailin 81a58edaca fix(contribution-service): calculate totalContribution correctly in CDC event
Previously, totalContribution was incorrectly set to effectiveContribution.
Now correctly calculated as: personal + teamLevel + teamBonus

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:40:50 -08:00
hailin debc8605df fix(mining-app): rename MiningRecordsPage widget to avoid name conflict
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:33:38 -08:00
hailin dee9c511e5 feat(mining-admin): add total contribution to dashboard stats
- Add networkTotalContribution field to dashboard API response
- Display total hashrate alongside effective hashrate in stats cards
- Update price overview to show both effective and total contribution
- Change grid from 3 to 4 columns in price overview

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:32:29 -08:00
hailin 546c0060da feat(mining-app): add mining records and planting records pages
- Add mining records page showing distribution history with share amounts
- Add planting records page with adoption summary and detailed records
- Remove 推广奖励 and 收益明细 from profile page
- Add planting-ledger API endpoint and data models

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:23:31 -08:00
hailin b81ae634a6 fix(mining-app): hardcode team bonus tiers display to 15
- Profile page: 团队上级 shows '15' instead of actual unlockedBonusTiers
- Contribution page: 已解锁上级 shows '15级' instead of actual value

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:28:02 -08:00
hailin 0cccc0e2cd refactor(mining-app): rename VIP等级 to 团队上级 and 直推人数 to 引荐人数
- Changed "VIP等级" label to "团队上级" in profile stats row
- Changed display value from vipLevel (V3 format) to unlockedBonusTiers (raw number)
- Changed "直推人数" label to "引荐人数" for consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:07:52 -08:00
hailin cd938f4a34 refactor(mining-app): rename team contribution labels
Update contribution page labels:
- "团队层级" → "团队下级"
- "团队奖励" → "团队上级"
- "直推人数" → "引荐人数"
- "已解锁奖励" → "已解锁上级" (with unit "档" → "级")
- "已解锁层级" → "已解锁下级"
- "直推及间推" → "引荐及间推" in subtitle

Update contribution records page labels:
- "团队层级" → "团队下级"
- "团队奖励" → "团队上级"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:58:41 -08:00
hailin 84fa3e5e19 refactor(mining-app): rename 绿积分 to 积分值 across all pages
Replace all occurrences of "绿积分" with "积分值" in:
- trading_page.dart (price display, pool name, input field)
- asset_page.dart (account labels)
- trading_account.dart (entity comment)
- price_info.dart (entity comment)
- market_overview.dart (entity comment)
- DEVELOPMENT_GUIDE.md (documentation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:57:17 -08:00
hailin adeeadb495 fix(mining-app): update profile page - hide items and rename label
- Rename "团队层级" to "团队下级" in stats row
- Hide "实名认证" option from account settings
- Hide "我的邀请码" card section entirely
- Remove unused _buildInvitationCard and _buildActionButton methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:47:14 -08:00
hailin 42a28efe74 fix(mining-app): remove operator account note from expiration card
Remove the "运营账号贡献值永不失效" note from the contribution
expiration countdown card.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:28:31 -08:00
hailin 91b8cca41c feat(mining-app): implement hide/show amounts toggle
- Add hideAmountsProvider to control amount visibility
- Add tap handler to eye icon in total contribution card
- Toggle icon between visibility_outlined and visibility_off_outlined
- Hide amounts with **** when toggled in:
  - Total contribution value
  - Three column stats (personal, team level, team bonus)
  - Today's estimated earnings
  - Contribution detail summary rows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:22:03 -08:00
hailin 02cc79d67a fix(mining-app): reduce bottom padding on navigation pages
Reduce bottom SizedBox from 100 to 24 on all four main navigation
pages (contribution, trading, asset, profile) to eliminate excessive
whitespace when scrolling to bottom.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:17:59 -08:00
hailin 7bc8547a96 fix(mining-app): rename ContributionRecordsListPage to avoid name conflict
- Rename page class from ContributionRecordsPage to ContributionRecordsListPage
- Add typedef RecordsPageData for ContributionRecordsPage data model
- Fix import statements and unused variable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:08:09 -08:00
hailin caffb124d2 feat(mining-app): add contribution records page with category summary
- Create contribution_records_page.dart with full list view
  - Pagination support with page navigation
  - Filter by source type (personal, team level, team bonus)
  - Show detailed info: tree count, base contribution, rate, amount
  - Display effective/expire dates and status badges

- Update contribution_page.dart detail card
  - Show category summary instead of record list
  - Display three categories with icons: personal, team level, team bonus
  - Add navigation to full records page via "查看全部"

- Add route configuration for /contribution-records

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:02:30 -08:00
hailin 141db46356 fix(contribution-service): use real contributionPerTree from rate service
Previously, adoptions were synced with hardcoded contributionPerTree=1,
resulting in contribution values like 0.7 instead of the expected 15831.9.

Now the handler fetches the actual contribution rate from ContributionRateService
based on the adoption date, storing values like:
- Personal (70%): 22617 × 70% = 15831.9
- Team Level (0.5%): 22617 × 0.5% = 113.085
- Team Bonus (2.5%): 22617 × 2.5% = 565.425

Note: Historical data may need migration to apply the correct multiplier.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:01:30 -08:00
hailin f57b0f9c26 chore(mining-app): configure release build
- Add kDebugMode check to LoggingInterceptor to suppress logs in release
- Remove debug print statements from contribution_providers
- Add Play Core proguard rules to fix R8 missing classes error

Build command: flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64
Output:
- app-arm64-v8a-release.apk: 18MB
- app-armeabi-v7a-release.apk: 16MB

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:24:42 -08:00
hailin c852f24a72 fix(auth-service): add 'auth/' prefix to controller routes for Kong compatibility
Kong routes /api/v2/auth/* to auth-service without stripping the path,
so controllers need 'auth/' prefix to match frontend requests:
- SmsController: 'sms' -> 'auth/sms'
- PasswordController: 'password' -> 'auth/password'
- UserController: 'user' -> 'auth/user'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:53:48 -08:00
hailin cb3c7623dc fix(mining-app): fix Riverpod ref usage in router redirect callback
Use cached auth state from AuthNotifier instead of ref.read() to avoid
"Cannot use ref functions after provider changed" exception during rebuild.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:49:52 -08:00
hailin f2692a50ed fix(contribution-service): fix toRecordDto using wrong property name
- Changed `record.finalContribution` to `record.amount` for getting final contribution value
- Added optional chaining to prevent undefined errors
- Added default values for safety

The ContributionRecordAggregate uses `amount` property, not `finalContribution`.
This was causing "Cannot read properties of undefined (reading 'value')" errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:43:14 -08:00
hailin ed9f817fae feat(mining-app): add estimated earnings and contribution stats API
- Add ContributionStats entity and model for network-wide statistics
- Add /api/v2/contribution/stats endpoint
- Implement estimatedEarningsProvider to calculate daily earnings
- Formula: (user contribution / total contribution) × daily allocation
- Update contribution page to display real estimated earnings
- Add debug logs for contribution records API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:37:30 -08:00
hailin 6bcb4af028 feat(mining-app): integrate real APIs for Asset and Profile pages
- Asset page now uses trading-service /asset/my endpoint
- Profile page integrates auth-service /user/profile and contribution-service
- Add new entities: AssetDisplay, PriceInfo, MarketOverview, TradingAccount
- Add corresponding models with JSON parsing
- Create asset_providers and profile_providers for state management
- Update trading_providers with real API integration
- Extend UserState and UserInfo with additional profile fields
- Remove obsolete buy_shares and sell_shares use cases
- Fix compilation errors in get_current_price and trading_page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:22:40 -08:00
hailin 106a287260 fix(mining-service): make health endpoints public 2026-01-14 07:35:42 -08:00
hailin 30dc2f6665 fix(trading-service): make health endpoints public 2026-01-14 07:28:24 -08:00
hailin e1fb70e2ee feat(trading-service): add burn system, Kafka events, and idempotency
- Add trading burn system with black hole, share pool, and price calculation
- Implement per-minute auto burn and sell burn with multiplier
- Add Kafka event publishing via outbox pattern (order, trade, burn events)
- Add user.registered consumer to auto-create trading accounts
- Implement Redis + DB dual idempotency for event processing
- Add price, burn, and asset API controllers
- Add migrations for burn tables and processed events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:15:41 -08:00
hailin f3d4799efc feat(mining-wallet): add UserWalletCreated/Updated events for CDC sync
- Publish UserWalletCreated when a new wallet is created
- Publish UserWalletUpdated when wallet balance changes
- Events sent to cdc.mining-wallet.outbox topic for mining-admin-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:13:34 -08:00
hailin 839feab97d fix(mining-admin): handle CONTRIBUTION_CREDITED event for wallet sync
Add handler for CONTRIBUTION_CREDITED events from mining-wallet-service
to sync user wallet data to synced_user_wallets table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:11:49 -08:00
hailin 465e398040 fix(mining-admin): fix wallet ledger API to match frontend expected format
- Return usdtAvailable, usdtFrozen, pendingUsdt, settleableUsdt,
  settledTotalUsdt, expiredTotalUsdt instead of old field names
- Query SyncedUserWallet table for GREEN_POINTS wallet data
- Use miningAccount.availableBalance for pendingUsdt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:56:24 -08:00
hailin c6c875849a fix(mining-service): make mining API public for service-to-service calls
Add @Public() decorator to MiningController to allow mining-admin-service
to fetch mining records without authentication.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:46:11 -08:00
hailin ce95c40c84 fix(mining-service): listen to correct CDC topic for contribution sync
Changed event handler to:
- Listen to 'cdc.contribution.outbox' topic (CDC/Debezium format)
- Handle 'ContributionAccountUpdated' events instead of 'ContributionCalculated'
- Use effectiveContribution for mining power calculation

This fixes the issue where mining accounts had zero totalContribution
because they weren't receiving contribution sync events.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:30:38 -08:00
hailin e6d966e89f fix(mining-admin): fetch mining records from mining-service
Update getUserMiningRecords to call mining-service API instead of
returning empty records. This enables the admin dashboard to display
actual user mining records.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:14:03 -08:00
hailin 270c17829e fix(mining-admin-service): move mining routes before :category/:key parameter route
NestJS matches routes in definition order. The :category/:key route was
matching mining/status before the specific mining routes. Moved mining
routes before the parameter routes to fix routing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:57:25 -08:00
hailin 289ac0190c fix(mining-admin-service): add logging and fix null data handling in getMiningStatus
- Add debug logging to trace mining service calls
- Return error object instead of null when data is missing
- Include error message in response for debugging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:42:01 -08:00
hailin 467d637ccc fix(mining-admin-web): prevent duplicate /api/v2 in rewrite destination
Clean NEXT_PUBLIC_API_URL to remove trailing /api/v2 if present,
preventing paths like /api/v2/api/v2/configs/mining/status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:37:32 -08:00
hailin c9690b0d36 Revert "fix(mining-admin-web): always use /api proxy instead of direct API URL"
This reverts commit 7a65ab3319.
2026-01-14 04:34:22 -08:00
hailin 7a65ab3319 fix(mining-admin-web): always use /api proxy instead of direct API URL
Browser cannot access Docker internal URLs like http://mining-admin-service:3023.
Always use /api which is proxied by Next.js rewrites to the backend service.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:32:59 -08:00
hailin e99b5347da feat(mining-admin-service): add transfer-enabled API endpoints
Add GET and POST /configs/transfer-enabled endpoints to control
the transfer switch. Routes are placed before :category/:key to
avoid being matched as path parameters.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:22:11 -08:00
hailin 29dd1affe1 fix(mining-admin-web): extract data from response wrapper
mining-admin-service uses TransformInterceptor which wraps all responses
with { success, data, timestamp } format. Frontend needs to access
response.data.data to get the actual data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 04:18:51 -08:00
hailin a15dcafc03 fix(mining-admin-service): 解包mining-service返回的data字段 2026-01-14 04:09:02 -08:00
hailin d404521841 fix(mining-admin-service): 修复mining-service API路径为v2 2026-01-14 03:58:02 -08:00
hailin 09b15da3cb fix(mining-service): Redis锁使用毫秒PX代替秒EX支持小数TTL 2026-01-14 03:52:22 -08:00
hailin 901247366d fix(mining-service): 添加tsconfig include/exclude配置修复构建 2026-01-14 03:48:18 -08:00
hailin 0abc04b9cb fix(mining-service): 添加Dockerfile构建验证步骤 2026-01-14 03:45:51 -08:00
hailin 2b083991d0 feat(mining-service): 添加migration将minuteDistribution改为secondDistribution
支持每秒挖矿分配功能

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 03:40:41 -08:00
hailin 8f616dd45b fix(mining-service): 修复Dockerfile支持prisma seed
- 添加ts-node/typescript到生产环境以支持seed执行
- 启动脚本中添加prisma db seed执行
- 复制tsconfig.json到生产环境

参考mining-wallet-service的Dockerfile配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 03:35:34 -08:00
hailin 1008672af9 Revert "fix(mining-service): 修复Docker构建问题"
This reverts commit f4380604d9.
2026-01-14 03:34:58 -08:00
hailin f4380604d9 fix(mining-service): 修复Docker构建问题
- tsconfig.json 添加 include/exclude 排除 prisma 文件夹
- 添加 .dockerignore 排除 seed.ts
- Dockerfile 添加构建验证

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 03:34:04 -08:00
hailin 3b61f2e095 feat(mining): 实现每秒挖矿分配系统
核心改动:
- 调度器从每分钟改为每秒执行,用户每秒看到挖矿收益
- 每秒更新账户余额,但MiningRecord每分钟汇总写入一次(减少数据量)
- seed自动执行(prisma.seed配置),初始化后isActive=false
- 只有一个手动操作:管理员在后台点击"启动挖矿"

技术细节:
- 每秒分配量:100万/63,072,000秒 ≈ 0.01585 shares/秒
- Redis累积器:每秒挖矿数据累积到Redis,每分钟末写入数据库
- 分布式锁:0.9秒锁定时间,支持多实例部署
- 后台管理界面:添加挖矿状态卡片和激活/停用按钮

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 03:25:47 -08:00
hailin 25608babd6 feat(mining-service): add initialization APIs and seed script
Add admin endpoints:
- GET /admin/status - Get mining system status
- POST /admin/initialize - Initialize mining config (one-time)
- POST /admin/activate - Activate mining distribution

Add prisma seed script for database initialization:
- MiningConfig: 100.02B total shares, 200万 distribution pool
- BlackHole: 100亿 burn target
- MiningEra: First era with 100万 distribution
- PoolAccounts: SHARE_POOL, BLACK_HOLE_POOL, CIRCULATION_POOL

Based on requirements:
- 第一个两年分配100万积分股
- 第二个两年分配50万积分股(减半)
- 100亿通过10年销毁到黑洞

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:36:52 -08:00
hailin bd0f98cfb3 fix(mining-admin-web): fix audit logs page crash
- Use 'all' instead of empty string for SelectItem value (Radix requirement)
- Add null safety for items array with fallback to empty array
- Fix potential undefined access on data.items

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:30:07 -08:00
hailin a2adddbf3d fix(mining-admin): transform dashboard API response to match frontend expected format
Frontend expects flat DashboardStats and RealtimeData interfaces.
Transform backend nested response to:
- totalUsers, adoptedUsers, networkEffectiveContribution, etc.
- currentMinuteDistribution, activeOrders, pendingTrades, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:23:54 -08:00
hailin d6064294d7 refactor(mining-admin): remove initialization feature
System initialization is now handled by seed scripts and CDC sync,
so the manual initialization UI is no longer needed.

Removed:
- Frontend: initialization page and sidebar menu item
- Backend: InitializationController and InitializationService

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:22:23 -08:00
hailin 36c3ada6a6 fix(mining-admin): fix audit logs API path and response format
- Change controller path from /audit-logs to /audit to match frontend
- Transform response to frontend expected format (items, totalPages, etc.)
- Map admin.username to adminUsername field
- Add keyword query parameter support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:18:53 -08:00
hailin 13e94db450 feat(mining-admin): add /reports/daily endpoint for frontend reports page
Add ReportsController with /reports/daily endpoint that maps the
dashboard service data to the format expected by the frontend.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:10:47 -08:00
hailin feb871bcf1 feat(mining-admin): add daily report generation service
Add DailyReportService that:
- Generates daily reports on startup
- Updates reports every hour
- Collects stats from synced tables (users, adoptions, contributions, mining, trading)
- Supports historical report generation for backfilling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 02:03:21 -08:00
hailin 4292d5da66 fix(mining-admin-web): fix TypeScript type for empty mainPools array 2026-01-14 01:55:58 -08:00
hailin a7a2282ba7 fix(mining-admin-web): update account type categorization to match backend
Update categorizeAccounts to use correct account types returned by backend:
- Core accounts: HEADQUARTERS, OPERATION, FEE
- Region accounts: PROVINCE, CITY

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:53:11 -08:00
hailin fa6826dde3 fix(mining-admin): use CDC synced tables for system accounts API
Change SystemAccountsService to read from syncedWalletSystemAccount and
syncedWalletPoolAccount tables instead of local tables. This fixes the
issue where the frontend shows "暂无数据" despite data being synced.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:44:22 -08:00
hailin eff71a6b22 feat(mining-wallet): publish outbox events for system/pool accounts
Add WalletSystemAccountCreated and WalletPoolAccountCreated events:
- seed.ts: publish events when creating HQ/OP/FEE and pool accounts
- contribution-wallet.service.ts: publish events when auto-creating
  province/city system accounts

This enables mining-admin-service to sync system accounts via CDC.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:28:48 -08:00
hailin 0bbb52284c fix(contribution): avoid nested transaction timeout in BonusClaimService
Use unitOfWork.isInTransaction() to detect if already in a transaction
context (called from ContributionCalculationService). If so, reuse the
existing transaction instead of opening a new one, preventing Prisma
interactive transaction timeout errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 01:02:08 -08:00
hailin 7588d18fff fix(mining-wallet): fix province/city creation and add seed on startup
- Use provinceCode directly instead of inferring from cityCode
- Use code as name for province/city records
- Add ts-node to production for seed execution
- Run prisma db seed on container startup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:40:49 -08:00
hailin e6e44d9a43 Revert "fix(mining-wallet): auto-create HEADQUARTERS account, skip DEFAULT province/city"
This reverts commit bf004bab52.
2026-01-14 00:19:12 -08:00
hailin bf004bab52 fix(mining-wallet): auto-create HEADQUARTERS account, skip DEFAULT province/city 2026-01-14 00:18:53 -08:00
hailin a03b883350 fix(mining-wallet): exclude prisma directory from TypeScript compilation 2026-01-14 00:07:58 -08:00
hailin 2a79c83715 feat(contribution): implement TEAM_BONUS backfill when unlock conditions met
When a user's direct referral count reaches 2 or 4, the system now automatically
backfills previously pending TEAM_BONUS (T2/T3) contributions that were allocated
to headquarters while waiting for unlock conditions.

- Add BonusClaimService for handling bonus backfill logic
- Add findPendingBonusByAccountSequence and claimBonusRecords to repository
- Integrate bonus claim into updateReferrerUnlockStatus flow
- Add BonusClaimed event consumer in mining-wallet-service
- Generate ledger records for backfilled contributions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:58:54 -08:00
hailin ef330a2687 feat(mining-wallet): add seed and auto-create province/city accounts
- Add prisma seed to initialize core system accounts (HQ, OP, FEE) and pool accounts
- Auto-create province/city system accounts on-demand during contribution distribution
- Province/city regions are also auto-created if not exist

This ensures:
1. Core accounts exist after deployment (via seed)
2. Province/city accounts are created dynamically as orders come in

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:36:31 -08:00
hailin 6594845d4c fix(mining-wallet): fix Kafka consumers not subscribing to topics
- Change consumers from @Injectable to @Controller for @EventPattern to work
- Move consumers from providers to controllers array in module
- Add subscribe.fromBeginning config to Kafka microservice

The consumers were not receiving messages because NestJS microservices
require @EventPattern handlers to be in @Controller classes, not just
@Injectable services.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:31:31 -08:00
hailin 77b682c8a8 feat(mining-wallet): make initialize endpoints public for internal network calls
Changed system-accounts/initialize and pool-accounts/initialize endpoints from
@AdminOnly to @Public to allow deploy scripts to call them without authentication.
These endpoints are only accessible from internal network.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:22:17 -08:00
hailin 6ec79a6672 fix(deploy): correct CDC sync API URL path
Change from /health/cdc-sync to /api/v2/health/cdc-sync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:26:32 -08:00
hailin 631fe2bf31 fix(contribution-service): reset consumer group offsets to earliest on startup
Use admin.resetOffsets({ earliest: true }) before connecting consumer
to ensure CDC sync always starts from the beginning of Kafka topics,
regardless of previously committed offsets.

This fixes the infinite loop issue where existing consumer groups
had committed offsets at high watermark, causing eachMessage to
never be called.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:14:51 -08:00
hailin d968efcad4 fix(contribution): run CDC sync in background to allow API access during sync
Change CDC consumer startup from blocking await to non-blocking .then()
so HTTP server starts immediately and /health/cdc-sync API is accessible
for deploy script to poll sync status.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:50:59 -08:00
hailin 5a4970d7d9 Revert "fix(contribution): run CDC sync in background to avoid blocking service startup"
This reverts commit 703c12e9f6.
2026-01-13 21:44:18 -08:00
hailin 703c12e9f6 fix(contribution): run CDC sync in background to avoid blocking service startup
- Change await to .then() for cdcConsumer.start()
- Allows HTTP endpoints to be accessible during CDC sync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:44:00 -08:00
hailin 8199bc4d66 feat(contribution): add CDC sync status API and fix deploy script timing
- Add initialSyncCompleted flag to track CDC sequential sync completion
- Add getSyncStatus() method to CDCConsumerService
- Add /health/cdc-sync endpoint to expose sync status
- Update deploy-mining.sh to wait for CDC sync completion before calling publish APIs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:34:58 -08:00
hailin aef6feb2cd fix(contribution): use unique consumer group id for each phase
Previous consumer group had already consumed messages, so fromBeginning
had no effect. Now using timestamp-based unique group id to ensure
fresh consumption from beginning each time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:11:40 -08:00
hailin 22523aba14 revert: restore blocking await for sequential CDC consumption
The previous change was wrong - running sequential consumption in
background defeats its purpose. The whole point is to ensure data
dependency order (users -> referrals -> adoptions) before any other
operations can proceed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:07:57 -08:00
hailin a01fd3aa86 fix(contribution): run sequential CDC consumption in background
Prevents blocking NestJS onModuleInit during CDC sync by running
the sequential consumption in the background with error handling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:07:11 -08:00
hailin d58e8b44ee feat(contribution): implement sequential CDC topic consumption
Implements sequential phase consumption to ensure correct data sync order:
1. User accounts (first)
2. Referral relationships (depends on users)
3. Planting orders (depends on users and referrals)

Each phase must complete before the next starts, guaranteeing 100%
reliable data dependency ordering. After all phases complete, switches
to continuous parallel consumption for real-time updates.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:57:24 -08:00
hailin 30949af577 revert: undo unauthorized ancestor_path and setDirectReferralAdoptedCount changes
Reverts commits:
- 1fbb88f7: setDirectReferralAdoptedCount change
- 471702d5: ancestor_path chain building change

These changes were made without authorization. The original code was correct.
MINING_ENABLED filtering (from dbf97ae4) is preserved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:46:41 -08:00
hailin 1fbb88f773 fix(contribution): use setDirectReferralAdoptedCount for accurate count update
Changed updateReferrerUnlockStatus to:
1. Create account if not exists (for full-reset scenarios)
2. Use setDirectReferralAdoptedCount instead of increment loop
3. This ensures the count is always accurate regardless of processing order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:29:53 -08:00
hailin 5eae4464ef fix(mining-app): remove unnecessary token refresh on app startup
Users were being redirected to login page when clicking navigation
because the background token refresh was failing and clearing user state.

Token refresh should only happen when API returns 401, not on every app launch.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:28:07 -08:00
hailin d43a70de93 feat(mining-admin): implement complete system accounts feature
- Add system account types and display metadata
- Create API layer with getList and getSummary endpoints
- Add React Query hooks for data fetching
- Create AccountCard, AccountsTable, SummaryCards components
- Refactor page with tabs, refresh button, and error handling
- Add Alert UI component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:27:59 -08:00
hailin 471702d562 fix(contribution): use ancestor_path to build upline chain for TEAM_LEVEL distribution
Root cause: CDC sync order issue caused referrerAccountSequence to be null,
resulting in empty ancestor chain and all TEAM_LEVEL contributions going to unallocated.

Changes:
- buildAncestorChainFromReferral: Uses ancestor_path (contains complete user_id chain) to build upline chain
- getDirectReferrer: Gets direct referrer using ancestor_path as fallback
- findAncestorChain: Updated to use ancestor_path when available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:14:46 -08:00
hailin dbf97ae487 fix(contribution-service): filter adoptions by MINING_ENABLED status
Only process adoptions with MINING_ENABLED status for contribution calculation.
This fixes the bug where non-final adoption records (PENDING, PAID, etc.) were
incorrectly being processed, causing duplicate contribution records.

Affected methods:
- findUndistributedAdoptions: only process MINING_ENABLED adoptions
- getDirectReferralAdoptedCount: only count users with MINING_ENABLED adoptions
- getTotalTreesByAccountSequence: only sum trees from MINING_ENABLED adoptions
- getTeamTreesByLevel: only count MINING_ENABLED adoptions
- countUndistributedAdoptions: only count MINING_ENABLED adoptions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 19:48:34 -08:00
hailin fdfc2d6700 fix(contribution): ensure 100% reliable CDC sync to mining-admin-service
- Add ContributionAccountUpdatedEvent for real-time account updates
- Publish outbox events when saving distribution results
- Publish outbox events when updating adopter/referrer unlock status
- Add incremental sync every 10 minutes for recently updated accounts
- Add daily full sync at 4am as final consistency guarantee
- Add findRecentlyUpdated repository method for incremental sync

Three-layer sync guarantee:
1. Real-time: publish events on every account update
2. Incremental: scan accounts updated in last 15 minutes every 10 mins
3. Full sync: publish all accounts daily at 4am

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 19:27:50 -08:00
hailin 3999d7cc51 fix(contribution): 100% sync CDC data and fix calculation trigger timing
- Remove conditional skip logic in CDC handlers
- Always sync all field updates (including status changes)
- Trigger contribution calculation only when status becomes MINING_ENABLED
- Fix user and referral handlers to sync all fields without skipping

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:55:25 -08:00
hailin 20eabbb85f fix(mining-admin): restore MINING_ENABLED status filter for adoption stats
Revert the previous change that removed the status filter. The stats
should only count adoptions with MINING_ENABLED status, as only those
are active for mining. The issue is likely that the status field in
synced_adoptions table doesn't have the correct value.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 01:32:39 -08:00
hailin 65bd4f9b65 fix(mining-admin): remove MINING_ENABLED status filter for adoption stats
The adoption stats were showing 0 because the synced_adoptions table
contains status values directly from 1.0 system (PAID, POOL_INJECTED, etc.)
rather than MINING_ENABLED. Since contribution-service doesn't update the
status after calculating contributions, we now count all synced adoptions.

Changes:
- Remove status filter in getAdoptionStatsForUsers
- Remove status filter in getUserDetail adoption queries
- Remove status filter in getUserAdoptionStats for referral tree
- Add order count display in user detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 01:21:01 -08:00
hailin 2f3a0f3652 feat(mining-admin): display adoption order count in user management
Backend:
- Add personalOrders and teamOrders to adoption stats
- Return order count alongside tree count in user list API

Frontend:
- Add personalAdoptionOrders and teamAdoptionOrders to UserOverview type
- Display format: "树数量(订单数)" e.g. "6(3单)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 01:03:59 -08:00
hailin 56ff8290c1 fix(mining-admin): filter adoption stats by MINING_ENABLED status
Only count adoptions with status='MINING_ENABLED' when calculating:
- Personal adoption count (user list)
- Team adoption count (user list)
- Personal adoption stats (user detail)
- Direct referral adoptions (user detail)
- Team adoptions (user detail)
- Referral tree adoption stats

This fixes incorrect adoption counts that included pending/unconfirmed orders.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:58:01 -08:00
hailin 1d7d38a82c fix(frontend): prevent redirect to dashboard on page refresh
Fix hydration race condition where token check happened before
localStorage was read. Now waits for client-side initialization
before deciding whether to redirect to login.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:25:59 -08:00
hailin f84e8b4700 fix(deploy): use kafka-console-producer for tombstone messages
kafkacat/kcat not available in containers. Switch to kafka-console-producer
with null.marker property to send tombstone messages for Debezium offset deletion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:45:38 -08:00
hailin fe2d4c3bcf fix(deploy): use correct offset topic name (debezium_offsets)
The Debezium Connect container uses OFFSET_STORAGE_TOPIC=debezium_offsets,
not the default connect-offsets. This fix updates the tombstone method
to use the correct topic name.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:29:57 -08:00
hailin 416867b1d5 fix(deploy): delete Debezium connector offsets during full-reset
This fixes an issue where Debezium would skip initial snapshot after
full-reset because the connector offset persisted in Kafka Connect's
internal connect-offsets topic.

The fix adds two strategies to delete connector offsets:
1. REST API method (Kafka Connect 3.6+): DELETE /connectors/<name>/offsets
2. Tombstone method (Kafka Connect 3.5-): Send NULL messages via kafkacat

Reference: https://debezium.io/documentation/faq/#how_to_remove_committed_offsets_for_a_connector

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:17:45 -08:00
hailin 5af39193e4 fix(deploy): delete Kafka outbox topics during full-reset
Old messages in Kafka topics were corrupting the sync because they contained
outdated data from previous resets. Now we delete the outbox topics during
full-reset to ensure clean re-sync.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:38:31 -08:00
hailin 44f235b185 fix(deploy): add processed_events cleanup for mining-admin-service
The full-reset script was missing the cleanup of rwa_mining_admin.processed_events
table, which caused stale idempotency records to prevent re-consumption of
contribution outbox events after reset.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:23:44 -08:00
hailin eba4b3b6e5 feat(mining-app): shimmer placeholder for all pages
Replace skeleton blocks with shimmer text placeholders across all pages:
- Asset page: show full UI with flickering numbers during load
- Trading page: show full UI with flickering market data during load
- Contribution page: show full UI with flickering stats during load
- shimmer_loading.dart: add ShimmerText, DataText, AmountText components

This approach shows the complete UI immediately, with only the dynamic
number values flickering while data loads - much better UX than showing
grey skeleton blocks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:56:22 -08:00
hailin b1525bdfa6 feat(mining-app): improve UX with non-blocking splash and skeleton loading
- Optimize splash page: reduce wait to 500ms, refresh token in background
- Cache SharedPreferences instance to avoid blocking API requests
- Add global 401 handler to auto-redirect to login page
- Create shimmer loading components (ShimmerLoading, ShimmerBox, skeletons)
- Replace CircularProgressIndicator with skeleton screens across all pages
- Add keepAlive + auto-invalidation (5min) to providers to reduce API calls
- Fix trading page: invalidate globalStateProvider after trade for data sync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:48:31 -08:00
hailin 5e16adc1ec fix(mining-admin): use outbox_id for event idempotency key
The outbox_events table uses outbox_id as the primary key column name
(mapped from id in Prisma). When Debezium captures changes, the message
contains outbox_id field, not id. This caused all events to have
undefined eventId, resulting in duplicate detection treating all events
as duplicates after the first one.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:41:54 -08:00
hailin e981e622d4 chore: remove misplaced Android drawable files from admin-web
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:20:56 -08:00
hailin 5447545486 fix(contribution): move calculateForAdoption out of CDC transaction
Root cause: calculateForAdoption uses separate DB connections, which
cannot see uncommitted data in Serializable isolation level, causing
"Adoption not found" errors.

Solution (following Kafka Idempotent Consumer best practice):
- Add TransactionalCDCHandlerWithResult<T> type for handlers with return
- Add withIdempotencyAndCallback() wrapper for post-commit callbacks
- Add registerTransactionalHandlerWithCallback() registration method
- AdoptionSyncedHandler.handle() now returns AdoptionSyncResult
- Contribution calculation runs AFTER transaction commits via callback

Reference: Lydtech Consulting - Kafka Idempotent Consumer Pattern
https://www.lydtechconsulting.com/blog/kafka-idempotent-consumer-transactional-outbox

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:18:21 -08:00
hailin 2a4cb829fe fix(deploy-mining): truncate processed_cdc_events after CDC offset reset
Problem: full-reset script resets Kafka consumer offsets but doesn't
clear the processed_cdc_events table. When migrations run, containers
may start and consume CDC events, inserting records into this table.
After offset reset, the service restarts and detects all events as
"duplicates" because the idempotency records still exist.

Solution: Add TRUNCATE processed_cdc_events step after CDC offset reset
in Step 8, before starting services. This ensures the idempotency table
is in sync with the Kafka offset position.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:00:34 -08:00
hailin 3591271a3b fix(auth-service): add python3/make/g++ to builder stage for bcrypt
The bcrypt package requires native compilation. Added build dependencies
to the builder stage so npm ci can compile bcrypt successfully.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:20:41 -08:00
hailin e00c81153b docs(migrations): add detailed comments for idempotency tables
- Add comments explaining unique key composition:
  - CDC events: (source_topic, offset) = Kafka topic + message offset
  - Outbox events: (source_service, event_id) = service name + outbox ID
- Fix contribution-service migration:
  - Extend source_service column from VARCHAR(50) to VARCHAR(100)
  - Set source_service as NOT NULL to match schema
  - Use snake_case for index name consistency
- Clarify that offset/event_id are NOT database auto-increment IDs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:44:46 -08:00
hailin 41f142124b fix(mining-app): update splash page theme and fix token refresh
- Update splash_page.dart to orange theme (#FF6B00) matching other pages
- Change app name from "榴莲挖矿" to "榴莲生态"
- Fix refreshTokenIfNeeded to properly throw on failure instead of
  silently calling logout (which caused Riverpod ref errors)
- Clear local storage directly on refresh failure without remote API call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:32:31 -08:00
hailin 9037c2da97 feat(auth): implement transactional idempotent CDC consumer for 1.0->2.0 sync
Implements 100% exactly-once semantics for CDC events from 1.0 identity-service
(user_accounts table) to auth-service.

Key changes:
- Add ProcessedCdcEvent model with (sourceTopic, offset) unique constraint
- Implement processWithIdempotency() using Serializable transaction isolation
- All database operations now use the transaction client
- Outbox event creation is also within the same transaction

This ensures that:
1. Each CDC event is processed exactly once
2. Idempotency record and business logic are in the same transaction
3. Outbox event publishing is atomic with data sync
4. Any failure causes complete rollback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:29:42 -08:00
hailin ff67319171 feat(contribution): implement transactional idempotent CDC consumer for 1.0->2.0 sync
Implements 100% exactly-once semantics for CDC events from 1.0 databases
(identity-service, planting-service, referral-service) to contribution-service.

Key changes:
- Add ProcessedCdcEvent model with (sourceTopic, offset) unique constraint
- Add withIdempotency() wrapper using Serializable transaction isolation
- Add registerTransactionalHandler() for handlers requiring idempotency
- Modify CDC handlers to accept external transaction client
- All database operations now use the passed transaction client

This ensures that:
1. Each CDC event is processed exactly once
2. Idempotency record and business logic are in the same transaction
3. Any failure causes complete rollback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:22:47 -08:00
hailin 70135938c4 feat(mining-admin): implement transactional idempotent consumer for 100% exactly-once semantics
- Use Prisma $transaction with Serializable isolation level
- Insert idempotency record FIRST, then execute business logic
- Unique constraint violation (P2002) indicates duplicate event
- All operations atomic - either fully commit or fully rollback
- Modified all handlers to accept transaction client parameter
- Removed old non-atomic isEventProcessed/recordProcessedEvent methods

This ensures 100% data consistency for CDC synchronization, which is
critical for financial data where any error is catastrophic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:11:30 -08:00
hailin 577f626972 fix(cdc): implement idempotent consumer pattern for reliable CDC sync
- Use (sourceTopic, eventId) as composite unique key in processed_events
- Add sourceTopic to ServiceEvent for globally unique idempotency key
- Wrap all handlers with withIdempotency() for duplicate event detection
- Fix ID collision issue between different service outbox tables

This implements the industry-standard CDC exactly-once semantics pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:31:10 -08:00
hailin 82a3c7a2c3 fix(asset-page): fix scroll issue with LayoutBuilder and ConstrainedBox
Wrap content in LayoutBuilder + ConstrainedBox to ensure proper
scrolling behavior when content exceeds viewport height.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:03:20 -08:00
hailin 61da3652f5 fix(deploy): reorder full-reset steps for proper CDC sync
Changes:
1. Delete Debezium connectors BEFORE dropping databases (releases replication slots)
2. Start services BEFORE registering connectors (ensures tables exist and data is synced)
3. Register connectors AFTER services sync from 1.0 CDC (snapshot.mode=initial captures existing data)
4. Add wait time for connectors to initialize before publishing data

Step order: stop services → delete connectors → drop DBs → create DBs → migrate →
start services → wait for sync → register connectors → publish data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:50:07 -08:00
hailin 94d8075970 fix(mining-app): unify color scheme and fix scroll issues
- Update login/register pages to use orange color scheme (#FF6B00)
  matching the navigation pages design
- Fix SafeArea bottom: false on all navigation pages since MainShell
  handles bottom safe area via bottomNavigationBar
- Add AlwaysScrollableScrollPhysics to asset page for consistent scroll
- Increase bottom padding to 100px on all navigation pages to clear
  the navigation bar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:41:41 -08:00
hailin c31d64550b fix(deploy): remove duplicate contribution-records/publish-all call
The full-reset function was calling contribution-records/publish-all API
which caused duplicate records in mining-admin-service because:
- Contribution records are already published to outbox when calculated
- Debezium automatically captures outbox_events and sends to Kafka
- Calling publish-all again creates duplicate events with different IDs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:29:16 -08:00
hailin 1b3704b68d fix(contribution-service): fix property mapping in toDto method
The toDto method was accessing non-existent properties on ContributionAccountAggregate:
- teamLevelContribution -> totalLevelPending
- teamBonusContribution -> totalBonusPending
- totalContribution -> effectiveContribution
- isCalculated -> true (always calculated when account exists)
- lastCalculatedAt -> updatedAt

This was causing "Cannot read properties of undefined (reading 'value')" error
on GET /api/v2/contribution/accounts/{accountSequence}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:17:31 -08:00
hailin 5c76c9f62c refactor(mining-app): remove deprecated HomePage and fix navigation
- Delete old green-themed HomePage and its widgets (asset_card, price_card, quick_actions)
- Remove /home route from router configuration
- Fix SplashPage to redirect to /contribution instead of /home after login
- Now all navigation goes through the new orange-themed UI pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:19:41 -08:00
hailin bfafd6d34c refactor(prisma): consolidate migrations into single init files
Merge multiple incremental migrations into single init migration for each service:

- auth-service: 3 migrations → 1 (user auth, SMS, KYC)
- contribution-service: 4 migrations → 1 (contribution accounts, 15-level hierarchy, 3-tier bonus)
- mining-admin-service: 6 migrations → 1 (admin, CDC sync tables, prisma relation mode)
- mining-service: 1 migration (no change needed, renamed for consistency)
- mining-wallet-service: 3 migrations → 1 (wallet system, removed blockchain tables)
- trading-service: 1 migration (no change needed, renamed for consistency)

All migrations renamed from timestamp format (20260111000000_*) to sequential format (0001_init)
for cleaner migration history.

Note: Requires clearing _prisma_migrations table before applying to existing databases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:04:24 -08:00
hailin 34e22d3c7f revert: restore original Dockerfiles 2026-01-12 10:12:16 -08:00
hailin d68ee398ab fix: add TEAM_BONUS cleanup to startup scripts
Delete incorrect TEAM_BONUS records (where account_sequence !=
source_account_sequence) on each container startup after migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:10:51 -08:00
hailin ff3a614804 fix: remove broken data migration files 2026-01-12 10:08:10 -08:00
hailin 22fe23914f fix(contribution): simplify migration to only delete wrong TEAM_BONUS records
Remove the UPDATE statement that referenced non-existent columns.
The contribution_accounts table uses different field structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:59:11 -08:00
hailin 95e009966e fix(mining-admin): calculate distribution amounts from actual adoption data
Use treeCount * contributionPerTree from adoption record to calculate
the actual distribution amounts (70%, 12%, 1%, 2%, 15%) instead of
deriving from contribution_records table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:55:27 -08:00
hailin e71f2aadfc fix: remove incorrect TEAM_BONUS records given to uplines
TEAM_BONUS should only be given to the adopter themselves, not to their
uplines. This migration deletes all TEAM_BONUS records where
account_sequence != source_account_sequence.

Also updates contribution_accounts totals in contribution-service.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:49:44 -08:00
hailin fe332fdb3f fix(mining-app): remove AuthEventBus to fix Riverpod state race condition
The AuthEventBus was causing "Cannot use ref functions after the
dependency of a provider changed" error when 401 responses triggered
logout during provider rebuilds.

Now 401 handling is done through normal exception flow in splash page
and route guards respond to isLoggedInProvider state changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:44:53 -08:00
hailin add405aa65 feat(mining-app): fix login bugs and connect contribution page to real API
Login fixes:
- Add AuthEventBus for global 401 error handling with auto-logout
- Add route guards with GoRouter redirect to protect authenticated routes
- Remove setMockUser() security vulnerability and legacy login() dead code
- Remove unused AuthInterceptor class

Contribution page:
- Add ContributionRecord entity and model for records API
- Connect contribution details card to GET /accounts/{id}/records endpoint
- Display real team stats (direct referrals, unlocked levels/tiers)
- Calculate expiration countdown from actual record data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:39:23 -08:00
hailin a89f4c829d feat(mining-admin): add migration for distributionSummary column
Add distributionSummary Text column to synced_adoptions table for storing
distribution details calculated during contribution calculation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:33:58 -08:00
hailin 23dabb0219 feat(contribution): display distribution details with actual amounts
Changes:
1. contribution-service:
   - Add distributionSummary field to SyncedAdoption schema
   - Store distribution summary after contribution calculation

2. mining-admin-service:
   - Add distributionSummary field to SyncedAdoption schema
   - Calculate actual distribution from contribution_records table
   - Return distribution details in planting ledger API

3. mining-admin-web:
   - Display distribution details in planting ledger table
   - Show: 70%(amount) personal, 12%(amount) operation,
     1%(amount) province, 2%(amount) city, 15%(amount) team
   - Show team distribution breakdown (distributed vs unallocated)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:23:02 -08:00
hailin 8d97daa524 fix(contribution): correct TEAM_BONUS distribution to adopter instead of referrer
TEAM_BONUS (7.5% = 2.5% × 3 tiers) should be given to the adopter themselves,
not to their direct referrer. The unlock conditions are:
- T1 (2.5%): Self adoption (always unlocked when adopting)
- T2 (2.5%): 2+ direct referrals adopted
- T3 (2.5%): 4+ direct referrals adopted

Also fixed findAncestorChain to correctly include the first ancestor
in the chain instead of skipping it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:13:13 -08:00
hailin 01ff873264 fix(mining-admin): use Prisma relationMode=prisma for CDC sync tables
Switch to Prisma's "prisma" relation mode to handle CDC event ordering issues.
This mode emulates foreign key relations at the Prisma Client layer instead of
creating database-level FK constraints, which is the recommended approach for
CDC scenarios where event arrival order cannot be guaranteed.

Reference: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/relation-mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:33:21 -08:00
hailin ea789f7fec fix(mining-admin): remove CDC sync table foreign key constraints
CDC events arrive asynchronously and order is not guaranteed.
Child records (referrals, accounts) may arrive before parent (users).
This follows CDC best practices: destination tables should not have FK constraints.

Reference: https://estuary.dev/blog/cdc-done-correctly/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:24:10 -08:00
hailin 40fbdec47c fix: add migration_lock.toml for prisma migrations
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:09:34 -08:00
hailin e337a1dda4 feat(mining-admin): add migration for contribution records and network progress tables
- Add synced_contribution_records table for tracking contribution ledger
- Add synced_network_progress table for tracking network-wide stats
- Revert Dockerfile to use prisma migrate deploy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:07:16 -08:00
hailin 1c33dd7bf3 fix(mining-admin): auto-sync schema on container startup
- Change Dockerfile to use `prisma db push` instead of `migrate deploy`
- Add publish steps for contribution records and network progress in full-reset
- This ensures new tables are created automatically when schema changes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:03:38 -08:00
hailin bf5a16939f fix(mining-admin-service): ignore Debezium heartbeat messages
- Add isHeartbeatMessage to detect heartbeat messages (only have ts_ms field)
- Skip processing for heartbeat messages to avoid unnecessary warnings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:44:27 -08:00
hailin 30e1867eb0 fix(mining-admin-service): properly handle Debezium outbox CDC events
- Add isDebeziumOutboxEvent to detect outbox table CDC messages
- Add handleDebeziumOutboxEvent to extract service event from payload.after
- Fix CDC consumer not recognizing events from contribution-service outbox

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:39:27 -08:00
hailin 52c573d507 fix(contribution-service): auto-publish contribution records on calculation
- Change saveMany to return saved records with IDs
- Update saveDistributionResult to use saved records for event publishing
- Contribution records are now automatically synced to mining-admin-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:32:50 -08:00
hailin 04fd7b946a feat(mining-admin-web): update contribution records display to match backend API
- Update ContributionRecord type to match backend response fields (sourceType, baseContribution, distributionRate, etc.)
- Update contribution-records-list component with improved UI showing source type badges, user info, tree count, and expiry status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:30:00 -08:00
hailin dbe9ab223f feat(contribution): fix pending fields update and add network progress tracking
- Fix updateContribution to properly update levelXPending and bonusTierXPending fields
- Add NetworkAdoptionProgress and DailyContributionRate tables for tracking contribution coefficient
- Create ContributionRateService for dynamic rate calculation (base 22617, +0.3% per 100 trees after 1000)
- Add ContributionRecordSynced and NetworkProgressUpdated events for CDC sync
- Add admin endpoints for network progress query and contribution records publishing
- Update mining-admin-service to sync contribution records and network progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:26:32 -08:00
hailin c0d0088b8e feat(contribution-service): enhance CDC event logging for debugging
Add detailed [CDC] prefixed logs to all CDC handlers:
- cdc-consumer.service.ts: log message parsing, handler dispatch
- user-synced.handler.ts: log user sync operations with field details
- adoption-synced.handler.ts: log adoption sync and contribution calc
- referral-synced.handler.ts: log referral relationship sync

All logs use consistent [CDC] prefix for easy filtering.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:41:24 -08:00
hailin 9642901710 fix(mining-wallet-service): remove remaining blockchain references
- Remove HOT_WALLET and COLD_WALLET from initializeCoreAccounts
- Remove BLOCKCHAIN from counterpartyType union

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:34:46 -08:00
hailin 8e30438433 refactor(mining-wallet-service): remove KAVA blockchain integration
- Remove KavaBlockchainService and blockchain.repository
- Remove BlockchainIntegrationService and BlockchainController
- Update health controller to remove blockchain check
- Clean up Prisma schema (remove blockchain models and enums)
- Add migration to drop blockchain-related tables

This functionality will be re-implemented when needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:31:30 -08:00
hailin 025cc6871b fix(mining-wallet-service): 修复模块依赖注入问题
将 Kafka consumers 从 InfrastructureModule 移到 ApplicationModule,
因为 consumers 依赖 application 层的服务 (ContributionWalletService, SystemAccountService)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:20:32 -08:00
hailin 7fe954e563 feat(contribution/wallet): 实现贡献值2.0计算与钱包存储架构
主要变更:
- contribution-service: 添加省市字段到认种同步数据
- contribution-service: 实现分配结果发布服务,通过Outbox模式发布到Kafka
- contribution-service: 更新Outbox调度器,支持4小时最大退避重试
- mining-wallet-service: 添加贡献值消费者,处理分配结果入账
- mining-wallet-service: 添加用户注册消费者,自动创建钱包
- mining-wallet-service: 添加贡献值过期调度器
- mining-wallet-service: 系统账户添加contributionBalance字段

Kafka事件流:
- contribution.distribution.completed: 分配结果事件
- auth.user.registered: 用户注册事件

可靠性保证:
- Outbox模式确保事件可靠发布
- 4小时幂等退避策略(30s,1m,2m,5m,10m,30m,1h,2h,4h)
- Redis+DB双重幂等检查

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:13:18 -08:00
hailin 1b8791fe5d fix(contribution-service): 添加 unlockedBonusTiers 字段到同步事件
事件和 API 返回中缺少 unlockedBonusTiers 字段,导致
mining-admin-service 无法正确显示团队奖励解锁状态

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 05:09:01 -08:00
hailin 180e5ad057 feat(mining-admin): 重构算力构成展示,添加解锁状态
- 后端添加 unlockedBonusTiers 字段同步
- 前端算力构成卡片展示层级解锁(L1-15)和团队奖励解锁(3档)状态
- 移除无用的系统运营/省级/市级字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 04:59:51 -08:00
hailin 4ca4fc9135 fix(mining-admin-web): 适配planting-ledger后端返回数据格式
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 04:12:22 -08:00
hailin bc191791e8 fix(mining-admin-service): getPlantingLedger从synced_adoptions读取真实数据
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 04:02:28 -08:00
hailin 9a34e9d399 feat(mining-admin-web): 引荐关系改为树形可视化布局
- 仿照1.0 admin-web的树形结构
- 显示引荐人链(向上)、总部节点、当前用户
- 递归展开/收起直推下级
- 圆形+-按钮控制展开

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:55:51 -08:00
hailin c141c3f6cd fix: TypeScript null check for originalUserId
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:50:29 -08:00
hailin 9e9a7364b9 fix(mining-admin-service): 实现getReferralTree返回真实推荐关系数据
- 从synced_referrals和synced_adoptions获取数据
- 实现getAncestors获取向上引荐人链
- 实现getDirectReferrals获取直推下级
- 实现getUserAdoptionStats获取认种统计

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:48:59 -08:00
hailin 2025c6ce36 fix(mining-admin): 用户列表API添加认种统计和推荐人信息
- 后端getUsers添加批量查询认种统计和推荐人信息
- 后端formatUserListItem返回adoption和referral字段
- 前端transformUserOverview映射新字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:42:54 -08:00
hailin 3074748d15 fix(mining-admin-web): 修复用户详情数据映射 - 正确映射referral/adoption/team字段
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:37:40 -08:00
hailin 5ad71e2e4b fix(mining-admin-service): 用户列表API添加nickname字段
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:29:38 -08:00
hailin 5cff606e87 feat(mining-admin-service): 完善用户详情API返回字段
- getUserDetail 添加以下字段:
  - nickname: 昵称
  - referral: 推荐人信息 (referrerAccountSequence, referrerNickname, depth)
  - adoption: 认种统计 (personalAdoptionCount, directReferralAdoptions, teamAdoptions)
  - team: 团队统计 (directReferralCount, teamMemberCount)

- 从 synced_referrals 表获取推荐关系
- 从 synced_adoptions 表统计认种数量
- 通过 ancestorPath 计算团队成员和团队认种

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:11:19 -08:00
hailin 7b310c554b fix(migrations): 修复数据库迁移脚本语法
- 移除 IF NOT EXISTS,使用标准 Prisma 迁移格式
- 确保 full-reset 能正确执行迁移

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:07:14 -08:00
hailin 4635fea693 chore(migrations): 添加数据库迁移脚本
- auth-service: 20260112110000_add_nickname_to_synced_legacy_users
  - synced_legacy_users 表添加 nickname 字段

- mining-admin-service: 20260112110000_add_referral_adoption_nickname
  - synced_users 表添加 nickname 字段
  - 创建 synced_referrals 表 (推荐关系)
  - 创建 synced_adoptions 表 (认种记录)
  - 相关索引和外键约束

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 03:02:55 -08:00
hailin 30b04c6376 feat(sync): 完善 CDC 数据同步 - 添加推荐关系、认种记录和昵称字段
- auth-service:
  - SyncedLegacyUser 表添加 nickname 字段
  - LegacyUserMigratedEvent 添加 nickname 参数
  - CDC consumer 同步 nickname 字段
  - SyncedLegacyUserData 接口添加 nickname

- contribution-service:
  - 新增 ReferralSyncedEvent 事件类
  - 新增 AdoptionSyncedEvent 事件类
  - admin.controller 添加 publish-all APIs:
    - POST /admin/referrals/publish-all
    - POST /admin/adoptions/publish-all

- mining-admin-service:
  - SyncedUser 表添加 nickname 字段
  - 新增 SyncedReferral 表 (推荐关系)
  - 新增 SyncedAdoption 表 (认种记录)
  - handleReferralSynced 处理器
  - handleAdoptionSynced 处理器
  - handleLegacyUserMigrated 处理 nickname

- deploy-mining.sh:
  - full_reset 更新为 14 步
  - Step 13: 发布推荐关系
  - Step 14: 发布认种记录

解决 mining-admin-web 缺少昵称、推荐人、认种数据的问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:48:15 -08:00
hailin 11eb1f8a04 fix(postgres): 增加数据库最大连接数到 300
- max_connections: 100 -> 300
- max_replication_slots: 10 -> 20
- max_wal_senders: 10 -> 20

支持更多服务和 Debezium connectors 同时连接

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:29:35 -08:00
hailin a4e1859fd2 fix(debezium): 修复 outbox connector 配置中的数据库凭证
使用实际的用户名和密码替代环境变量占位符,
因为 envsubst 不支持带默认值的变量语法

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:13:07 -08:00
hailin 93c9007045 fix(deploy): 修正 Debezium Connect 默认端口为 8084
docker-compose 中 Debezium Connect 映射到 8084 端口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:11:19 -08:00
hailin cbdb449533 fix(auth): 修复 LegacyUserCdcConsumer 的 OutboxService 依赖注入
- 在 ApplicationModule 中导出 OutboxService
- 在 InfrastructureModule 中使用 forwardRef 导入 ApplicationModule
- 解决循环依赖问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 02:00:21 -08:00
hailin 4cbdf0b503 fix(auth): 修复 CDC consumer 类型错误
使用 ?? 运算符正确处理可选字段:
- update 使用 undefined 保持字段不变
- create 使用 null 明确设置为空值

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 01:45:10 -08:00
hailin 40745ca580 feat(cdc): 完善 2.0 服务数据聚合到 mining-admin-service
1. deploy-mining.sh:
   - 添加 outbox connectors 配置数组 (auth, contribution, mining, trading, wallet)
   - 添加 register_outbox_connectors() 函数自动注册 Debezium 连接器
   - 添加 outbox-register, outbox-status, outbox-delete 命令
   - full-reset 更新为 12 步,包含注册 outbox connectors 和初始数据发布

2. contribution-service:
   - 添加 ContributionAccountSyncedEvent 事件
   - 添加 POST /admin/contribution-accounts/publish-all API 用于初始全量同步

3. mining-admin-service:
   - 添加 ContributionAccountSynced 事件处理(复用 ContributionAccountUpdated 处理器)
   - 添加 ContributionCalculated 事件处理

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 01:41:46 -08:00
hailin 489966fae9 feat(auth): 新 1.0 用户自动发布事件到 mining-admin-service
- auth-service CDC consumer 在同步新用户时自动发布 LegacyUserMigratedEvent
- 只有 op='c' (create) 的新用户才发布事件,snapshot 由 publish-all API 处理
- deploy-mining.sh full-reset 更新步骤编号为 10 步

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 01:25:01 -08:00
hailin 50854f04d5 fix(deploy): 添加 mining-admin-service-cdc-group 到 CDC 重置列表
确保 full-reset 时同时重置 mining-admin-service 的 consumer group,
使其能从头消费所有 outbox 事件。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 01:18:44 -08:00
hailin 0d06080760 fix(mining-admin): 兼容 Debezium outbox 消息格式
问题:Debezium 产生的 outbox 消息使用下划线命名(event_type,
aggregate_type),而代码期望驼峰格式(eventType, aggregateType)

解决方案:
- isServiceEvent() 同时检查两种命名格式
- 新增 normalizeServiceEvent() 转换 Debezium 格式到驼峰格式
- 解析 payload JSON 字符串

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 01:10:24 -08:00
hailin 273f2f1d96 fix(deploy): 在 migration 后再次重置 CDC offset
问题:migration 会启动容器执行迁移,导致 CDC consumer
自动启动并消费消息。在数据库重建后启动服务时,消息
已经被消费完毕。

解决方案:在 migration 后增加 Step 7,停止容器并
再次重置 CDC offset,确保最终启动时能重新消费。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 00:53:55 -08:00
hailin 350ce28c40 fix(deploy): 修复 sync-reset CDC offset 重置失败问题
- sync_reset() 添加 20 秒等待时间,确保 consumer group 变成 inactive
- 添加重试逻辑(最多 3 次,每次间隔 10 秒)
- 使用 grep NEW-OFFSET 检测 offset reset 是否成功

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 00:49:09 -08:00
hailin 24412794e6 fix(deploy-mining): 修正 full-reset 步骤顺序避免 CDC offset 重置失败
- 在 migration 之前重置 CDC offsets(因为 migration 会启动容器)
- 停止服务后等待 15 秒让 Kafka consumer 变成 inactive
- 添加重试机制,最多重试 3 次,每次间隔 10 秒
- 步骤从 6 步改为 7 步

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 00:33:19 -08:00
hailin ff27195be2 fix(cdc): 修复用户同步字段映射和多 consumer group 重置
contribution-service:
- 修复 UserSyncedHandler 使用错误字段名 (data.id -> data.user_id)
- 兼容 CDC snake_case 字段命名 (user_id, account_sequence, phone_number)
- 添加数据验证,跳过无效记录

deploy-mining.sh:
- 添加 auth-service-cdc-group 到 CDC_CONSUMER_GROUPS
- full-reset 现在重置所有 CDC consumer groups
- sync_reset 和 sync_status 支持多个 consumer groups

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:59:39 -08:00
hailin 5cab38c7f1 fix(deploy-mining): 修正 Docker 容器名称和默认凭据
- 修正 POSTGRES_CONTAINER 为 rwa-postgres (匹配 docker-compose.yml)
- 修正 KAFKA_CONTAINER 为 rwa-kafka
- 修正 POSTGRES_USER 为 rwa_user
- 修正 POSTGRES_PASSWORD 为 rwa_secure_password
- 修复 CDC offset 重置命令使用正确的容器名和命令格式

解决 full-reset 无法删除数据库和重置 CDC offset 的问题。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:41:49 -08:00
hailin 5c302bfca8 fix(deploy-mining): 支持 Docker 环境的数据库操作和迁移
- 添加 run_psql helper 函数自动检测 Docker 或本地 psql
- 修改 db_create/db_drop/db_status 使用 docker exec
- 修改 db_migrate 支持通过容器运行 prisma migration
- 修改 health_check/show_stats 支持 Docker 环境

解决在服务器 Docker 环境中 full-reset 失败的问题。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:38:06 -08:00
hailin 5d880f011e fix(debezium): 统一 mining-wallet-outbox-connector 数据库名称
将 database.dbname 从 mining_wallet_db 改为 rwa_mining_wallet,
与 docker-compose.2.0.yml 和 deploy-mining.sh 保持一致。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:31:28 -08:00
hailin 63d73af135 refactor(cdc): 统一使用 Debezium CDC 进行数据同步
1. contribution-service:
   - 添加 identity topic 订阅,全量同步 1.0 用户数据
   - 修改 fromBeginning 为 true,首次启动全量同步

2. mining-admin-service:
   - 将 Outbox 事件改为 Debezium CDC 监听 outbox_events 表
   - 修改 fromBeginning 为 true,首次启动全量同步

3. 新增 5 个 2.0 服务的 Debezium connector 配置:
   - auth-outbox-connector.json
   - contribution-outbox-connector.json
   - mining-outbox-connector.json
   - trading-outbox-connector.json
   - mining-wallet-outbox-connector.json

4. 更新 register-connectors.sh 脚本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:19:34 -08:00
hailin cab36fccf1 fix(docker): 修复 contribution-service 和 mining-admin-service Dockerfile healthcheck 路径
将 healthcheck 路径从 /api/v1/health 改为 /api/v2/health,
与 main.ts 中的 API 前缀保持一致。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:56:26 -08:00
hailin 978dfcb2bf feat(docker): 添加 mining-wallet-service 到 docker-compose.2.0.yml
- 添加 mining-wallet-service 服务配置
  - 端口: 3025
  - 数据库: rwa_mining_wallet
  - Redis DB: 15
  - KAVA 区块链配置
- 更新所有服务的 healthcheck 路径为 /api/v2/health
  - mining-service
  - trading-service
  - mining-admin-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:53:57 -08:00
hailin 83a2800941 refactor(deploy): 移除 mining-admin-web 从 deploy-mining.sh
mining-admin-web 是前端项目,不应该在后端服务部署脚本中管理。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:52:26 -08:00
hailin 3c73d510b1 feat(deploy): 添加 mining-wallet-service 到 deploy-mining.sh
将 mining-wallet-service 加入 2.0 系统管理脚本:

- 添加到 MINING_SERVICES 数组
- 添加别名 wallet -> mining-wallet-service
- 添加数据库 rwa_mining_wallet
- 添加 SERVICE_DB 映射
- 添加端口 3025
- 更新帮助文档

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:51:19 -08:00
hailin a4090cc285 fix(mining-admin-web): 修复 API rewrite 路径为 v2
将 next.config.js 中的 API rewrite 从 /api/v1 改为 /api/v2,
与 mining-admin-service 的实际 API 前缀保持一致。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:45:16 -08:00
hailin f790d2bbe5 refactor(api): 升级 trading-service API 前缀至 v2
将 trading-service 的 API 版本从 v1 升级到 v2,统一 2.0 系统架构:

**trading-service:**
- main.ts: 全局前缀 api/v1 → api/v2
- Dockerfile: 健康检查路径 /api/v1/health → /api/v2/health
- transfer.service.ts: 更新调用 mining-service 的 API 路径
  - /api/v1/mining/accounts/.../transfer-out → /api/v2/...
  - /api/v1/mining/accounts/.../transfer-in → /api/v2/...

此变更使 trading-service 正式成为 2.0 系统的一部分,
与 auth-service、contribution-service、mining-service、
mining-admin-service、mining-wallet-service 保持一致。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:37:17 -08:00
hailin 6d619c0a02 refactor(api): 升级 mining-service 和 mining-wallet-service API 前缀至 v2
将以下服务的 API 版本从 v1 升级到 v2,统一 2.0 系统架构:

**mining-service:**
- main.ts: 全局前缀 api/v1 → api/v2
- Dockerfile: 健康检查路径 /api/v1/health → /api/v2/health

**mining-wallet-service:**
- main.ts: 全局前缀 api/v1 → api/v2
- Dockerfile: 健康检查路径 /api/v1/health → /api/v2/health

此变更使 mining-service 和 mining-wallet-service 正式成为 2.0 系统的一部分,
与 auth-service、contribution-service、mining-admin-service 保持一致。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:31:06 -08:00
hailin 05f98def6d fix(sync): 修复数据同步 API 认证和响应解析
- 为 contribution/mining/trading 服务的 AdminController 添加 @Public 装饰器
- 修复 initialization.service.ts 中响应格式解析,支持 { data: { ... } } 格式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:47:32 -08:00
hailin 6fedebf020 fix(trading-service): 更新 package-lock.json
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:35:33 -08:00
hailin 3fe6bdbbf0 feat(sync): 添加批量同步 API 端点
- 为 contribution-service、mining-service、trading-service 添加 AdminController
- 提供 /admin/accounts/sync 端点用于批量获取账户数据
- 在 mining-admin-service 添加同步 mining/trading 账户的初始化端点
- 添加 sync-all 端点支持一键同步所有数据

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:27:35 -08:00
hailin 033f94c0c2 fix(mining-admin-web): 修复 API 响应格式转换
后端返回 records/pagination 格式,前端期望 items/total/totalPages 格式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 21:14:12 -08:00
hailin 1a7c73e531 feat(mining-admin): 添加用户详情页缺失的 API 端点
- 添加 referral-tree API(返回空推荐关系数据)
- 添加 planting-ledger API(返回空认种数据)
- 添加 wallet-ledger API(返回空钱包流水数据)
- 修复前端 referral-tree 组件空数据处理

注:这些 API 目前返回占位数据,完整数据需要通过 CDC 从各服务同步

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:53:18 -08:00
hailin fc3efe6a27 fix(mining-admin-web): 修复 React hydration 错误 #418 #423
- 修改 Zustand sidebar store 使用 skipHydration 避免 SSR 不匹配
- 移除 Redux auth slice 初始状态中的 localStorage 读取
- 在 providers 中使用 useEffect 初始化客户端状态

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:47:00 -08:00
hailin dc27044dab fix(mining-admin-web): 修复 formatNumber 导致的 hydration 错误
将 toLocaleString 替换为确定性格式化方法,避免服务器和客户端
输出不一致导致的 React hydration 错误 #418 #423

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:37:32 -08:00
hailin e0f529799f fix(mining-admin): 添加 syncAllUsers 和 syncAllContributionAccounts 方法
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:24:48 -08:00
hailin 582beb4f81 feat(cdc): 添加 legacy 用户批量同步功能
auth-service:
- 添加 AdminController 和 AdminSyncService
- POST /admin/legacy-users/publish-all: 为所有 legacy 用户发布事件
- GET /admin/users/sync: 获取所有用户数据供同步

mining-admin-service:
- 添加 user.legacy.migrated 事件处理器
- 添加 sync-users 和 sync-contribution-accounts API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:17:46 -08:00
hailin 49b1571bba fix(cdc): 修复 auth-service 与 mining-admin-service 的 CDC 事件同步
- auth-service: 将 outbox topic 从 auth.events 改为 mining-admin.auth.users
- mining-admin-service: 添加 user.registered 和 user.kyc_verified 事件处理器
- 确保 auth-service 发布的事件能被 mining-admin-service 正确接收和处理

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:51:01 -08:00
hailin e83b3d420c chore(mining-admin-service): 统一API版本为v2
- 将globalPrefix从api/v1改为api/v2
- 更新Swagger文档版本为2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:25:49 -08:00
hailin 99550a2a9d fix(mining-admin-web): 修复用户列表API响应格式不匹配问题
- 添加transformUserOverview和transformUserDetail函数
- 转换后端返回格式(data/pagination)到前端期望格式(items)
- 修复keyword到search的参数名转换
- 解决React hydration错误和数据不显示问题

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:15:55 -08:00
hailin 3fe4f82906 fix(mining-admin-web): 修复用户列表页面空数据和错误处理
- 修复 data.items 可能为 undefined 导致的崩溃
- 添加 API 错误状态显示

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:06:26 -08:00
hailin 2a22d7d669 fix(mining-admin-web): 修复用户列表页面类型错误
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:01:37 -08:00
hailin 0f1b4df583 fix(mining-admin-web): 添加缺失的Badge组件
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:00:15 -08:00
hailin 8fc527b918 feat(mining-admin-web): 复用admin-web用户管理功能
- 更新用户列表:添加头像、个人/团队认种、推荐人、状态徽章
- 更新用户详情:添加头像、KYC状态、认种统计卡片
- 新增引荐关系Tab:展示引荐人链和直推下级树
- 新增认种信息Tab:认种汇总和认种分类账明细
- 新增钱包信息Tab:钱包汇总和钱包分类账明细
- 更新类型定义和API hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:58:48 -08:00
hailin 5dab829995 fix(deploy-mining): 修复rebuild命令不更新容器的问题
rebuild命令之前只调用build,现在会在构建后停止旧容器并启动新容器

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:47:25 -08:00
hailin 7a68668aa9 feat(auth-service): 增强登录错误提示和指数退避锁定机制
- 区分用户不存在和密码错误的提示信息
- 登录失败最多允许6次尝试
- 每次密码错误显示剩余尝试次数
- 超过次数后实现指数退避锁定(1,2,4,8...分钟,最长24小时)
- 锁定时显示剩余等待时间
- 优化mining-app底部导航栏图标间距

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:33:23 -08:00
hailin 608e22a8e7 fix(contribution-service): 修复JWT验证与auth-service不兼容
- 移除 type 字段检查 (auth-service 不生成此字段)
- 修复 JwtPayload 接口与 auth-service 生成的 token 结构一致
- 从 payload.sub 获取 accountSequence

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:18:21 -08:00
hailin 4d5c9e7c49 fix(docker): 为contribution-service添加JWT_SECRET配置
contribution-service需要JWT_SECRET来验证auth-service签发的token。
与auth-service共享相同的密钥配置。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:14:37 -08:00
hailin a0229e653e fix(mining-app): 修复登录后API请求token未更新的问题
问题原因: ApiClient使用内存缓存的token,登录后虽然保存到SharedPreferences,
但ApiClient的内存缓存未更新,导致后续请求仍使用旧token或无token。

解决方案: 移除内存缓存,每次请求都从SharedPreferences读取最新token,
确保登录后立即生效。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:00:33 -08:00
hailin c35d90e94f fix(kong): 修复auth-service路由配置
- 将service URL从 /api/v2 改为根路径
- 设置 strip_path: false 直接透传请求路径
- 保持前后端路径一致: /api/v2/auth/...

问题原因: strip_path: true 会移除 /api/v2/auth,
导致后端收到 /api/v2/login 而不是 /api/v2/auth/login

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:52:53 -08:00
hailin 461b11b310 fix(kong): 修复contribution-service路由配置
- 将service URL从 /api/v2/contributions 改为根路径
- 设置 strip_path: false 直接透传请求路径
- 前端和后端现在都使用 /api/v2/contribution(单数)

前端请求: /api/v2/contribution/accounts/{id}
Kong转发: http://contribution-service:3020/api/v2/contribution/accounts/{id}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:44:49 -08:00
hailin 849f346891 fix(contribution-service): 修复控制器路由路径与前端API不匹配
将控制器路由从 /contributions 改为 /contribution,
与前端 api_endpoints.dart 中定义的路径保持一致。

完整路径: /api/v2/contribution/accounts/{accountSequence}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:42:10 -08:00
hailin ace1e8673b feat(deploy-mining): rebuild命令增加服务重启功能
rebuild命令现在会在构建完成后自动停止旧服务并启动新服务,
而不仅仅是编译镜像。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:18:25 -08:00
hailin a539b33dff fix(contribution-service): 优化账户查询API返回有意义的业务状态
- 新增 ContributionAccountStatus 枚举区分三种状态:
  - ACTIVE: 账户正常,有算力数据
  - INACTIVE: 用户存在但暂无认种记录
  - USER_NOT_FOUND: 账户不存在
- 移除 404 错误响应,统一返回 200 并通过 status 和 message 字段描述状态
- 为不同状态提供友好的中文提示信息

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:13:38 -08:00
hailin 5ee6caa190 fix(contribution-service): 修复Kafka消息BigInt序列化错误
JSON.stringify无法序列化BigInt,添加自定义replacer将BigInt转换为字符串

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:57:21 -08:00
hailin a40e314c94 fix(contribution-service): 修复adoption handler事务嵌套问题
将upsertSyncedAdoption和calculateForAdoption分离为两个独立操作,
避免嵌套事务导致内层事务看不到外层事务尚未提交的数据

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:52:00 -08:00
hailin 5006a5a170 fix(contribution-service): 修复synced_adoptions.status字段长度
1.0 planting_orders.status是VARCHAR(30),2.0需要匹配以避免数据截断错误

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:45:08 -08:00
hailin 5f76108579 fix(contribution-service): 修复CDC消息解析以支持Debezium扁平化格式
Debezium配置了ExtractNewRecordState转换,消息格式是扁平化的:
- 元数据字段使用__前缀(__op, __table, __source_ts_ms, __deleted)
- 业务数据字段直接在根级别
- 修改handleMessage方法正确解析扁平化格式
- 更新CDCEvent接口以匹配实际消息结构

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:39:09 -08:00
hailin 4b55c63e71 fix(contribution-service): 修复CDC同步字段映射,支持完整同步referral数据
主要更改:
1. synced_referrals表增加referrer_user_id和original_user_id字段
   - 1.0的referral_relationships表只有referrer_id(user_id),没有referrer_account_sequence
   - 保存原始user_id以便后续解析推荐人的account_sequence

2. 修复referral-synced.handler字段映射
   - 正确处理1.0的user_id、referrer_id、ancestor_path字段
   - ancestor_path从BigInt[]数组转换为逗号分隔的字符串

3. 修复cdc-event-dispatcher表名注册
   - 使用正确的表名: referral_relationships, planting_orders
   - 移除不需要的user_accounts注册

4. 更新docker-compose.2.0.yml
   - 添加CDC_TOPIC_REFERRALS配置
   - 移除未使用的CDC_TOPIC_PAYMENTS

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:27:01 -08:00
hailin 05a8168a31 fix(contribution-service): 修复CDC同步配置,使用正确的planting-service topic
- 修改CDC topic为cdc.planting.public.planting_orders
- 更新healthcheck使用api/v2
- 更新handler适配planting_orders表字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:18:37 -08:00
hailin a14daae222 fix: update mining-service package-lock.json
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:12:48 -08:00
hailin 0e05139c01 fix(contribution-service): 统一使用api/v2前缀
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:05:42 -08:00
hailin 73381a4376 fix: 修复contribution-service路由映射
- Kong: /api/v2/contribution -> /api/v1/contributions (strip_path)
- mining-app: 添加/accounts前缀匹配controller路径

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:04:21 -08:00
hailin 1256bc2fdd fix(mining-app): 修复auth API路径,移除多余的/auth层
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:58:25 -08:00
hailin 849fa77df0 fix(auth-service): 允许synced_legacy_users的phone和password_hash为空
- 修改schema让phone和passwordHash字段可为空
- 添加migration: 20260111083500_allow_nullable_phone_password
- CDC consumer使用null替代空字符串
- 支持同步没有手机号/密码的系统账户

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:37:02 -08:00
hailin 6caae7c860 fix(auth-service): 跳过无手机号/密码的系统账户CDC同步
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:33:39 -08:00
hailin a749a3b9e1 fix: 修复auth-service CDC配置和API路由
- 修复docker-compose.2.0.yml中CDC_TOPIC_USERS为正确的topic名称
- 添加CDC_ENABLED环境变量
- 更新Kong配置auth-service路由使用strip_path
- 更新mining-app API端点匹配v2服务路由
- 更新mining-app baseUrl指向Kong网关根路径

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:29:16 -08:00
hailin dd77dc65d1 fix(mining-app): 添加assets/images目录
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:12:29 -08:00
hailin 8e2073bc5a feat(mining-app): 添加Android/iOS平台配置和编译支持
- 添加Android平台文件(包名: com.rwadurian.mining_app)
- 添加iOS平台文件
- 配置应用名称为"榴莲挖矿"
- 添加网络权限和明文流量支持
- 配置minSdk为24,启用multidex
- 添加debug/release构建类型
- 创建proguard混淆规则
- 添加环境配置文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 08:06:53 -08:00
hailin 5727192719 feat(mining-app): 重新设计四个主要导航页面UI
- 贡献值页面: 新增贡献值卡片、算力排行榜、收益统计等模块
- 兑换页面: 添加K线图、市场数据、买卖面板等交易界面
- 资产页面: 实现总资产卡片、快捷操作、资产列表、收益统计
- 我的页面: 添加用户头部信息、邀请码、账户设置、记录入口等
- 更新底部导航为: 贡献值、兑换、资产、我的
- 登录/注册后跳转到贡献值页面

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:56:23 -08:00
hailin 26dce24e75 feat(mining-app): 添加找回密码和修改密码功能
- 添加 /password/reset 和 /password/change API 端点
- 在 auth_remote_datasource 中实现 resetPassword 和 changePassword 方法
- 在 user_providers 中添加状态管理方法
- 创建找回密码页面 (forgot_password_page.dart)
- 创建修改密码页面 (change_password_page.dart)
- 添加路由配置
- 在登录页面添加"忘记密码"链接
- 在个人资料页面添加"修改密码"入口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:36:56 -08:00
hailin 36d7b7ebfe fix(docker-compose.2.0): 修复healthcheck路径为/api/v1/health并增加start_period到60s
docker-compose.yml里的healthcheck配置会覆盖Dockerfile里的配置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:25:30 -08:00
hailin 1b425e09c9 fix(contribution-service): 添加@Public装饰器到HealthController以绕过JWT认证
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:02:17 -08:00
hailin 673e5ff772 fix(dockerfiles): 修复2.0服务健康检查路径
- 修正健康检查URL从 /health 到 /api/v1/health(因为设置了全局前缀)
- 增加 start-period 从 10s 到 60s,给服务更多启动时间

受影响服务:
- contribution-service
- mining-service
- mining-admin-service
- trading-service
- mining-wallet-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 06:45:44 -08:00
hailin f26a796244 fix(contribution-service): 修复migration为完整初始化脚本
将增量migration改为完整的初始化migration,包含所有表的CREATE TABLE语句。
原migration使用ALTER TABLE假设表已存在,但这是服务的第一个migration文件。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 06:31:24 -08:00
hailin 6261679f5a feat(contribution-service, mining-service): 添加18级待解锁算力字段和挖矿收益分配表
contribution-service:
- 添加15级层级待解锁字段 (level1-15Pending)
- 添加3档加成待解锁字段 (bonusTier1-3Pending)
- 添加解锁状态追踪字段 (hasAdopted, directReferralAdoptedCount等)
- 重构ContributionAccountAggregate支持新字段结构
- 更新repository和query处理effectiveContribution

mining-service:
- 添加MiningRewardAllocation表追踪每日挖矿收益分配明细
- 添加DailyMiningRewardSummary表汇总账户每日收益
- 添加HeadquartersPendingReward表记录未解锁算力收益归总部明细
- 创建初始migration文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 06:16:15 -08:00
hailin 4bb995f2c2 feat(auth-service,mining-app): 实现完整认证流程和CDC用户同步
auth-service:
- 添加DTO验证装饰器(IsString, IsNotEmpty, Matches, MinLength)
- 添加短信验证码登录(loginBySms)方法
- 修复CDC Consumer字段映射匹配1.0 user_accounts表
- 更新CDC topic为cdc.identity.public.user_accounts

mining-app (Flutter):
- 新增auth_remote_datasource实现真实API调用
- 新增登录页面(密码/短信切换)和注册页面
- 替换splash_page中的mock登录为真实状态检查
- 添加token自动注入拦截器到ApiClient
- 配置生产环境API指向Kong网关

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 05:29:48 -08:00
hailin 9fca17e7ed fix(mining-admin-web): 修复auth API类型定义
更新 TypeScript 类型以匹配后端响应格式:
- ApiResponse<T> 包装器
- LoginData 和 ProfileData 接口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:10:05 -08:00
hailin e28fe56489 fix(mining-admin-web): 适配后端API响应格式
后端返回格式为 { success, data: {...} },
修改 login 和 getProfile 解析 response.data.data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:05:55 -08:00
hailin 25ad627377 feat(mining-admin-service): 添加/auth/profile接口
前端dashboard layout需要获取当前用户信息,添加GET /auth/profile接口

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:01:49 -08:00
hailin 86f2c85f8d fix(mining-admin-service): 修复LoginDto验证装饰器缺失
添加 @IsString() 和 @IsNotEmpty() 装饰器到 LoginDto,
修复 ValidationPipe forbidNonWhitelisted 导致的400错误

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:54:37 -08:00
hailin fe7dda396f fix(kong): 修复mining-admin-service路由映射
使用strip_path:true并将url设为/api/v1,
使Kong路由 /api/v2/mining-admin/* -> 服务 /api/v1/*

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:50:29 -08:00
hailin 341e319fd3 feat(mining-admin-web): 配置生产环境API指向Kong网关
修改:
1. frontend/mining-admin-web/src/lib/api/client.ts
   - 使用环境变量 NEXT_PUBLIC_API_URL 配置 API baseURL
   - 开发环境默认使用 /api 代理

2. frontend/mining-admin-web/.env.production (新增)
   - NEXT_PUBLIC_API_URL=https://rwaapi.szaiai.com/api/v2/mining-admin

3. backend/api-gateway/kong.yml
   - CORS origins 添加 https://madmin.szaiai.com
   - CORS origins 添加 http://localhost:3100

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:28:17 -08:00
hailin 42e6b5c27c feat(gateway): 添加2.0服务路由到Kong和nginx配置
Kong网关:
- 添加contribution-service-v2 (3020)
- 添加mining-service-v2 (3021)
- 添加trading-service-v2 (3022)
- 添加mining-admin-service (3023)
- 添加auth-service-v2 (3024)
- 添加mining-wallet-service (3025)

Nginx (madmin.szaiai.com):
- 添加/api/代理到mining-admin-service:3023
- 支持CORS预检请求

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:22:23 -08:00
hailin 51456373a9 feat(2.0-services): 添加所有服务的初始Prisma migrations
使用 prisma migrate diff 生成初始数据库迁移脚本:
- mining-admin-service: 管理后台相关表及CDC同步表
- auth-service: 用户认证相关表
- trading-service: 交易相关表
- mining-wallet-service: 钱包相关表

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:06:46 -08:00
hailin 7f72c1e1ec fix(2.0-services): 修复InfrastructureModule导出PrismaService问题
NestJS不允许模块导出未在providers中声明的服务。
将exports中的PrismaService改为PrismaModule(因为PrismaModule已导出PrismaService)。

修复服务:
- mining-admin-service
- auth-service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 00:00:50 -08:00
hailin c4ee8ed6a9 fix(2.0-services): 更新package-lock.json并添加bcrypt编译支持
- mining-admin-service: 更新package-lock.json以包含bcrypt依赖
- auth-service: Dockerfile添加python3 make g++用于编译bcrypt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:53:11 -08:00
hailin c032f30f7b fix(mining-admin-service): 添加bcrypt依赖和编译工具
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:49:26 -08:00
hailin 319a787c43 fix(2.0-dockerfiles): 添加openssl解决Prisma兼容性问题
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:46:14 -08:00
hailin 576aad8691 fix(deploy-mining): rebuild默认不使用--no-cache,需显式指定
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:40:08 -08:00
hailin c1de1daea8 fix(2.0-dockerfiles): 使用printf替代echo解决alpine兼容性问题
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:37:29 -08:00
hailin 1c3e7809ad fix(docker-compose.2.0): 使用external连接1.0网络services_rwa-network
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:31:53 -08:00
hailin 86091097a6 fix(docker-compose.2.0): 使用bridge驱动自建网络而非external
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:28:41 -08:00
hailin d3fecc42c1 fix(2.0-services): 优化所有Dockerfile使用--chown避免chown -R
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:23:57 -08:00
hailin 81f8422758 fix(mining-admin-service): 优化Dockerfile使用--chown避免chown -R
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:22:34 -08:00
hailin 0467e17032 fix(docker-compose.2.0): 移除对外部基础设施服务的depends_on
2.0服务使用external network连接1.0的基础设施,不需要depends_on

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:18:20 -08:00
hailin 0a433eca40 . 2026-01-11 15:14:25 +08:00
hailin a36bdcdda5 fix(deploy-mining): 使用docker compose替代npm进行构建和部署
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 23:11:42 -08:00
hailin ca55a81263 feat(mining-wallet-service): 添加独立钱包管理微服务
- 新增 mining-wallet-service 完整实现,100% 与 1.0 系统隔离
- 支持系统账户:总部、运营、省公司、市公司、手续费、热钱包、冷钱包
- 支持池账户:份额池、黑洞池、流通池
- 支持用户钱包:算力钱包、代币存储钱包、绿积分钱包
- 实现用户-区域映射(独立于 1.0)
- 集成 KAVA 区块链:提现、充值、DEX Swap
- 所有交易记录包含交易对手信息(counterparty)
- 使用 Outbox 模式确保事件可靠发布

feat(mining-admin-service): 添加 mining-wallet-service CDC 同步

- 新增 13 个 Synced 同步表接收钱包服务数据
- 新增 wallet-sync.handlers.ts 处理钱包服务事件
- 更新 cdc-sync.service.ts 注册钱包服务事件处理器

chore(mining-service, trading-service): 为池账户添加 counterparty 跟踪字段

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:21:08 -08:00
hailin ee5f841034 fix(outbox): 实现指数退避重试策略,最大延迟3小时
修复Outbox事件发布的重试机制:

1. 更新Prisma Schema (mining-service, trading-service):
   - 添加OutboxStatus枚举 (PENDING, PUBLISHED, FAILED)
   - 添加topic、key、status、retryCount、maxRetries、lastError等字段
   - 添加publishedAt、nextRetryAt时间戳
   - 优化索引 (status, nextRetryAt, createdAt)

2. 更新OutboxRepository (mining-service, trading-service):
   - findPendingEvents(): 查询待处理且到达重试时间的事件
   - markAsPublished(): 标记事件已发布
   - markAsFailed(): 实现指数退避算法 (30s基础, 最大3小时)
   - deletePublished(): 清理已发布的旧事件

3. 更新OutboxScheduler (auth/mining/trading-service):
   - 使用指数退避: 30s, 60s, 120s, 240s, ... 最大10800s (3小时)
   - 记录重试次数和错误信息
   - 达到最大重试次数后标记为FAILED

指数退避公式: delay = min(30s * 2^(retryCount-1), 3h)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:08:57 -08:00
hailin 28ad8c2e2f feat(2.0-services): 为auth/mining/trading服务添加Outbox事件发布机制
- auth-service:
  - 添加Kafka生产者模块和服务
  - 添加Redis服务用于分布式锁
  - 添加OutboxScheduler定时发布Outbox事件到Kafka
  - 更新InfrastructureModule为全局模块

- mining-service:
  - 添加Kafka生产者服务
  - 添加OutboxRepository用于管理Outbox事件
  - 添加OutboxScheduler定时发布事件

- trading-service:
  - 添加Kafka生产者服务
  - 添加OutboxRepository用于管理Outbox事件
  - 添加OutboxScheduler定时发布事件

所有服务的Outbox调度器:
- 每30秒发布待处理的事件到Kafka
- 每天凌晨3点清理7天前已处理的事件
- 使用Redis分布式锁确保多实例部署时只有一个实例处理

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:54:37 -08:00
hailin 15a5fb6c14 feat(mining-admin-service): 添加CDC同步和完整用户管理API
## Prisma Schema 更新
- 添加 CDC 同步表:SyncedUser, SyncedContributionAccount, SyncedMiningAccount, SyncedTradingAccount
- 添加系统数据同步表:SyncedMiningConfig, SyncedDailyMiningStat, SyncedDayKLine, SyncedCirculationPool
- 添加 CDC 进度跟踪:CdcSyncProgress, ProcessedEvent

## CDC 消费者模块
- CdcConsumerService: Kafka 消费者,支持 Debezium CDC 和服务间事件
- CdcSyncService: 同步处理器,从 auth/contribution/mining/trading 服务同步数据

## 新增 API 端点
### 用户管理 (/api/v1/users)
- GET /users - 用户列表(分页、搜索、过滤)
- GET /users/:accountSequence - 用户详情
- GET /users/:accountSequence/contributions - 算力记录
- GET /users/:accountSequence/mining-records - 挖矿记录
- GET /users/:accountSequence/orders - 交易订单

### 系统账户 (/api/v1/system-accounts)
- GET /system-accounts - 系统账户列表
- GET /system-accounts/summary - 系统账户汇总

### 仪表盘增强 (/api/v1/dashboard)
- GET /dashboard - 统计数据(新增用户/算力/挖矿/交易统计)
- GET /dashboard/realtime - 实时数据
- GET /dashboard/stats - 统计数据(别名)

## Docker Compose 更新
- 添加 Kafka 依赖和 CDC topic 配置
- 添加与 auth-service 的依赖关系

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:36:21 -08:00
hailin 2a09fca728 chore(mining-admin-web): 添加.gitignore文件
忽略以下自动生成的文件:
- node_modules/
- .next/
- next-env.d.ts
- *.tsbuildinfo
- .env*.local

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:18:35 -08:00
hailin b0434184ae feat(mining-admin-web): 添加Nginx配置和Let's Encrypt自动证书
- madmin.szaiai.com.conf: Nginx反向代理配置(端口3100)
- install.sh: 一键安装脚本,自动申请Let's Encrypt证书
- 支持HTTP自动重定向到HTTPS
- 配置证书自动续期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:15:50 -08:00
hailin 3f79361fde feat(mining-admin-web): 添加独立部署脚本和docker-compose配置
- deploy.sh: 一键部署脚本(build/start/stop/restart/logs/clean)
- docker-compose.yml: 独立容器化配置,使用rwa-network网络
- 与admin-web的部署方式保持一致

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:06:20 -08:00
hailin 6ffde0f4c6 refactor(docker): 2.0系统共享1.0基础设施并保持完全隔离
- 网络: 使用共享的 rwa-network (external)
- 数据库: 连接 postgres:5432,使用独立数据库名
- Redis: 连接 redis:6379,使用 DB 10-14 分区隔离
- Kafka: 连接 kafka:29092,仅消费 CDC 事件(单向同步)
- 服务依赖: 添加 postgres/redis/kafka 健康检查依赖

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:01:15 -08:00
hailin 821c70bf38 feat(docker): 添加 2.0 系统 Docker 部署支持
为所有 2.0 服务添加 Dockerfile 和 docker-compose 配置:

后端服务:
- contribution-service (3020) - 算力服务
- mining-service (3021) - 挖矿服务
- trading-service (3022) - 交易服务
- mining-admin-service (3023) - 管理后台 API
- auth-service (3024) - 用户认证服务

前端服务:
- mining-admin-web (3100) - 管理后台前端

Docker 配置:
- docker-compose.2.0.yml: 独立的 2.0 系统编排文件
- 多阶段构建优化镜像大小
- 健康检查确保服务可用性
- 服务依赖顺序正确

部署脚本更新:
- deploy-mining.sh 使用 docker-compose.2.0.yml
- 添加 mining-admin-web 服务别名 (web, admin-web)
- 更新服务端口配置

使用方式:
  cd backend/services
  docker-compose -f docker-compose.2.0.yml up -d

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:31:02 -08:00
hailin f7278b6196 feat(auth-service): 添加用户认证服务2.0
实现完整的用户认证服务,支持1.0用户迁移和2.0新用户注册:

功能特性:
- 用户注册(生成V2格式accountSequence: 15位)
- 密码登录(支持V1迁移用户和V2用户)
- V1用户首次登录自动迁移到2.0系统
- 短信验证码发送/验证(注册/登录/重置密码/更换手机号)
- 密码管理(重置密码、修改密码)
- KYC实名认证(提交/审核资料)
- JWT认证(Access Token + Refresh Token)

技术架构:
- DDD六边形架构(Domain/Application/Infrastructure/API)
- Prisma ORM + PostgreSQL
- CDC消费者同步1.0用户数据
- Outbox模式发布领域事件
- NestJS ThrottlerModule限流

数据模型:
- User: 2.0用户表(含KYC字段)
- SyncedLegacyUser: CDC同步的1.0用户(只读)
- RefreshToken: 刷新令牌
- SmsVerification: 短信验证码
- DailySequenceCounter: 每日序号计数器
- OutboxEvent: 发件箱事件

AccountSequence格式:
- V1: D + YYMMDD + 5位序号 = 12字符
- V2: D + YYMMDD + 8位序号 = 15字符

服务端口:3024
数据库:rwa_auth

同时更新deploy-mining.sh添加auth-service支持。

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:50:59 -08:00
hailin c8c2e63da6 feat(mining): 增强部署脚本支持单服务操作
## 新增功能

### 单服务操作
- `up [service]` - 启动全部或指定服务
- `down [service]` - 停止全部或指定服务
- `restart [service]` - 重启全部或指定服务
- `build [service] [--no-cache]` - 构建全部或指定服务
- `rebuild [service]` - 等同于 build --no-cache

### 服务别名
- `contrib`, `contribution` -> contribution-service
- `mining` -> mining-service
- `trading` -> trading-service
- `admin` -> mining-admin-service

### 单服务数据库操作
- `db-create [service]` - 创建全部或指定服务的数据库
- `db-migrate [service]` - 运行全部或指定服务的迁移
- `db-reset [service]` - 重置全部或指定服务的数据库

### 构建功能增强
- `--no-cache` 选项清除 dist/ 和 node_modules/.cache
- 自动运行 npm install
- 自动生成 Prisma client
- 编译 TypeScript

### 状态显示增强
- 显示服务 PID
- 通过端口检测运行状态
- 健康检查端点验证

## 使用示例

```bash
./deploy-mining.sh up mining          # 仅启动 mining-service
./deploy-mining.sh restart contrib    # 重启 contribution-service
./deploy-mining.sh build trading --no-cache  # 清除缓存重新构建
./deploy-mining.sh logs admin 200     # 查看最后200行日志
./deploy-mining.sh db-reset mining    # 仅重置 mining-service 数据库
```

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:12:38 -08:00
hailin 7c3bf4f068 feat(mining): 添加 2.0 挖矿系统独立部署管理脚本
添加 deploy-mining.sh 脚本用于管理 2.0 挖矿生态系统,
该系统与 1.0 完全隔离,可随时重置而不影响 1.0。

## 功能

### 服务管理
- up/down/restart - 启动/停止/重启 2.0 服务
- status - 查看服务状态
- logs [service] - 查看日志
- build - 构建服务

### 数据库管理
- db-create - 创建 2.0 数据库
- db-migrate - 运行 Prisma 迁移
- db-reset - 删除并重建数据库(危险操作)
- db-status - 查看数据库状态

### CDC 同步管理
- sync-reset - 重置 CDC 消费者偏移量到开始位置
- sync-status - 查看 CDC 消费者组状态

### 完整重置
- full-reset - 完整系统重置
  1. 停止所有 2.0 服务
  2. 删除所有 2.0 数据库
  3. 重建数据库
  4. 运行迁移
  5. 重置 CDC 偏移量
  6. 重启服务(从 1.0 重新同步)

### 健康监控
- health - 检查所有组件健康状态
- stats - 显示系统统计信息

## 2.0 服务
- contribution-service (3020)
- mining-service (3021)
- trading-service (3022)
- mining-admin-service (3023)

## 2.0 数据库
- rwa_contribution
- rwa_mining
- rwa_trading
- rwa_mining_admin

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:08:16 -08:00
hailin a17f408653 feat(mining-ecosystem): 添加挖矿生态系统完整微服务与前端
## 概述
为榴莲生态2.0添加完整的挖矿系统,包含3个后端微服务、1个管理后台和1个用户端App。

---

## 后端微服务

### 1. mining-service (挖矿服务) - Port 3021
**核心功能:**
- 积分股每日分配(基于算力快照)
- 每分钟定时销毁(进入黑洞)
- 价格计算:价格 = 积分股池 ÷ (100.02亿 - 黑洞 - 流通池)
- 全局状态管理(黑洞量、流通池、价格)

**关键文件:**
- src/application/services/mining-distribution.service.ts - 挖矿分配核心逻辑
- src/application/schedulers/mining.scheduler.ts - 定时任务调度
- src/domain/services/mining-calculator.service.ts - 分配计算
- src/infrastructure/persistence/repositories/black-hole.repository.ts - 黑洞管理

### 2. trading-service (交易服务) - Port 3022
**核心功能:**
- 积分股买卖撮合
- K线数据生成
- 手续费处理(10%买入/卖出)
- 流通池管理
- 卖出倍数计算:倍数 = (100亿 - 销毁量) ÷ (200万 - 流通池量)

**关键文件:**
- src/domain/services/matching-engine.service.ts - 撮合引擎
- src/application/services/order.service.ts - 订单处理
- src/application/services/transfer.service.ts - 划转服务
- src/domain/aggregates/order.aggregate.ts - 订单聚合根

### 3. mining-admin-service (挖矿管理服务) - Port 3023
**核心功能:**
- 系统配置管理(分配参数、手续费率等)
- 老用户数据初始化
- 系统监控仪表盘
- 审计日志

**关键文件:**
- src/application/services/config.service.ts - 配置管理
- src/application/services/initialization.service.ts - 数据初始化
- src/application/services/dashboard.service.ts - 仪表盘数据

---

## 前端应用

### 1. mining-admin-web (管理后台) - Next.js 14
**技术栈:**
- Next.js 14 + React 18
- TailwindCSS + Radix UI
- React Query + Zustand
- ECharts 图表

**功能模块:**
- 登录认证
- 仪表盘(实时数据、价格走势)
- 用户查询(算力详情、挖矿记录、交易订单)
- 系统配置管理
- 数据初始化任务
- 审计日志查看

### 2. mining-app (用户端App) - Flutter 3.x
**技术栈:**
- Flutter 3.x + Dart
- Riverpod 状态管理
- GoRouter 路由
- Clean Architecture (3层)

**功能模块:**
- 首页资产总览
- 实时收益显示(每秒更新)
- 贡献值展示(个人/团队)
- 积分股买卖交易
- K线图与价格显示
- 个人中心

---

## 架构文档
- docs/mining-ecosystem-architecture.md - 系统架构总览
  - 服务职责与端口分配
  - 数据流向图
  - Kafka Topics 定义
  - 跨服务关联(account_sequence)
  - 配置参数说明
  - 开发顺序建议

---

## .gitignore 更新
- 添加 Flutter/Dart 构建文件忽略
- 添加 iOS/Android 构建产物忽略
- 添加 Next.js 构建目录忽略
- 添加 TypeScript 缓存文件忽略

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:45:46 -08:00
hailin eaead7d4f3 feat(contribution-service): 添加算力管理微服务
## 概述
为榴莲生态2.0添加 contribution-service 微服务,负责算力计算、分配和快照管理。

## 架构设计
- 采用 DDD + Hexagonal Architecture (六边形架构)
- 使用 NestJS 框架 + Prisma ORM
- 通过 Kafka CDC (Debezium) 从 user-service 同步数据
- 使用 accountSequence (而非 userId) 进行跨服务关联

## 核心功能模块

### 1. Domain Layer (领域层)
- ContributionAccountAggregate: 算力账户聚合根
- ContributionRecordAggregate: 算力记录聚合根
- ContributionAmount: 算力金额值对象 (基于 Decimal.js)
- DistributionRate: 分配比例值对象
- ContributionSourceType: 算力来源类型枚举 (PERSONAL/TEAM_LEVEL/TEAM_BONUS)

### 2. Application Layer (应用层)
- ContributionCalculationService: 算力计算核心服务
  - 个人算力: 认种金额 × 10
  - 团队等级奖励: 基于直推有效认种人数
  - 团队极差奖励: 多级分销算法
- SnapshotService: 每日算力快照服务
- CDC Event Handlers: 处理用户、认种、引荐关系同步事件

### 3. Infrastructure Layer (基础设施层)
- Prisma Repositories:
  - ContributionAccountRepository
  - ContributionRecordRepository
  - SyncedDataRepository (同步数据)
  - OutboxRepository (发件箱模式)
  - SystemAccountRepository
  - UnallocatedContributionRepository
- Kafka CDC Consumer: 消费 Debezium CDC 事件
- Redis: 缓存支持
- UnitOfWork: 事务管理

### 4. API Layer (接口层)
- ContributionController: 算力查询接口
- SnapshotController: 快照管理接口
- HealthController: 健康检查

## 数据模型 (Prisma Schema)
- ContributionAccount: 算力账户
- ContributionRecord: 算力记录 (支持过期)
- DailyContributionSnapshot: 每日快照
- SyncedUser/SyncedAdoption/SyncedReferral: CDC 同步数据
- OutboxEvent: 发件箱事件
- SystemContributionAccount: 系统账户
- UnallocatedContribution: 未分配算力

## TypeScript 类型修复
- 修复所有 Repository 接口与实现的类型不匹配
- 修复 ContributionAmount.multiply() 返回值类型
- 修复 isZero getter vs method 问题
- 修复 bigint vs string 类型转换
- 统一使用 items/total 返回格式
- 修复 Prisma schema 字段名映射 (unallocType, contributionBalance 等)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:39:25 -08:00
hailin d9f9ae5122 chore(mobile-app): 更新开屏静态图片
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 10:42:53 -08:00
hailin dd1531fbb8 fix(mobile-app): 移除pubspec.yaml中已删除的splash_frames目录
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 10:36:01 -08:00
hailin 57239e81dd chore(mobile-app): 优化开屏资源和UI文案
- 删除36张帧动画图片减小包体积
- 静态开屏图片从3张改为2张
- 将"权益激活"改为"权益已激活"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 10:29:37 -08:00
hailin bc73b078bd fix(wallet-service): 将最小划转金额从100改为5
与前端配置保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:51:06 -08:00
hailin dab4b0674d fix(mobile-app): 修复引荐列表展开无法显示更多数据的问题
- 使用实际总数作为limit参数请求API
- 添加调试日志便于排查
- 优化:已加载全部数据时直接展开不重复请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:13:07 -08:00
hailin 8eb5b410cc feat(mobile-app): 引荐列表支持展开/收拢功能
- 初始只显示前10条引荐记录
- 超过10人时显示"..."按钮可点击展开全部
- 展开后显示"收起"按钮可点击收拢
- 加载更多时显示loading指示器

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:59:03 -08:00
hailin 4ee355a7cd fix(mobile-app): 开屏图片改为保持原比例不拉伸
使用 BoxFit.contain 替代 BoxFit.cover,
图片保持原比例显示,不足部分用黑边填充。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:51:26 -08:00
hailin 96695575d5 feat(mobile-app): 启用兑换页面的划转功能
移除划转按钮的临时禁用标志,恢复正常功能。
用户点击划转按钮后将跳转到划转页面。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:48:45 -08:00
hailin 414fe95d04 feat(mobile-app): 开屏页改为随机静态图片模式
- 禁用帧动画,改为显示随机静态图片(3张中随机选1张)
- 显示3秒后自动跳转,保留跳过按钮
- 帧动画代码保留备用,可通过 _useStaticImage 开关切换
- 新增 splash_static 目录存放静态图片

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:19:25 -08:00
hailin fabfbb73fe fix(mobile-app): 修复开机动画卡住问题
问题原因:
1. TelemetryConfig.syncFromRemote() URL拼接错误,导致请求无效路径
2. 遥测配置同步使用await阻塞,即使失败也要等待超时

修复内容:
1. 修正URL拼接:apiBaseUrl已包含/api/v1,不再重复添加
2. 将超时时间从10秒缩短为5秒
3. 将遥测配置同步改为非阻塞,不再await等待

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:08:21 -08:00
hailin ca1bc74b2a feat(admin-web): 用户钱包分类账明细添加备注列
在用户管理页面的钱包分类账明细表格中添加"备注"列,
用于显示转账对象信息,如"转账至 D25XXXXXX"或"来自 D25XXXXXX 的转账"。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:20:21 -08:00
hailin 7a4a207bed feat(mobile-app): 增强合同PDF下载可靠性和用户体验
- PDF下载增加10次自动重试机制,使用指数退避策略
- 超时时间延长至300秒,适应大文件和慢网络
- 新增下载进度显示(百分比圆环)
- 失败后显示重试按钮,区分任务加载错误和PDF下载错误
- ApiClient.get方法新增cancelToken和onReceiveProgress参数支持

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:12:21 -08:00
hailin f7b2267583 feat(mobile-app): 临时禁用划转功能
划转功能暂时维护中,点击按钮会显示提示信息。
恢复时将 isTransferDisabled 改为 false 即可。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 02:56:26 -08:00
hailin 990f218051 fix(mobile-app): 修复认种订单解析和状态检查问题
1. 修复 getMyOrders 解析:兼容后端直接返回数组格式
2. 添加 MINING_ENABLED 订单状态枚举和解析
3. 在 ADOPTION_WIZARD 完成检查中包含 miningEnabled 状态

问题原因:
- 后端返回订单列表格式是直接数组 [...],前端期望的是 {items: [...]}
- 后端返回的 MINING_ENABLED 状态未在前端枚举中定义

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 02:49:31 -08:00
hailin 96a84cc281 fix(mobile-app): 修复认种向导完成后无法返回待办页面的问题
问题:认种向导完成后使用 context.go() 跳转到合同签署页面,
替换了整个导航栈,导致合同签署完成后无法返回待办页面继续处理其他待办。

修复:改用 context.push() 跳转到合同签署页面,保留导航栈,
合同签署完成后可以正确返回待办页面。

同时添加了详细的调试日志,便于排查问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 02:43:23 -08:00
hailin 1a617e02f8 fix(mobile-app): 将授权申请页面的'伞下'改为'下' 2026-01-09 02:23:20 -08:00
hailin d79fd9273b fix(admin-service): 修复uploads目录权限问题 2026-01-09 02:14:37 -08:00
hailin d1a52e74a0 fix(mobile-app): 修复认种向导待办操作无法正确标记完成的问题
问题:用户完成认种并签署合同后,ADOPTION_WIZARD待办操作没有被标记为完成,
导致用户被卡在待办操作页面无法进入App。

原因:原来的检查逻辑只检查是否有"待签合同",当用户已签署合同后,
pendingTasks为空,返回false,导致待办操作无法完成。

修复方案:
- 改为检查用户是否有已支付的认种订单(PAID/FUND_ALLOCATED状态)
- 通过比较订单创建时间和待办操作创建时间来判断
- 订单在待办操作之后创建 → 已完成
- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)
- 保留待签合同的备用检查逻辑

影响范围:仅影响ADOPTION_WIZARD待办操作的完成检测

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 02:03:22 -08:00
hailin 00264a721e fix(admin-web): 优化授权页面错误提示,显示后端真实错误信息
问题:创建授权失败时只显示"Request failed with status code 400"
用户无法了解失败的真实原因(如用户未种树、授权冲突等)

修复:
- handleCreate和handleRevoke的catch块优先从err.response.data.message提取后端错误
- 后端已有完善的错误提示如"用户尚未认种任何树,无法授权"
- 前端现在能正确显示这些提示帮助管理员了解真实情况

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:38:00 -08:00
hailin 4d944b06e5 fix(admin-service): 添加uploads目录的volume持久化配置
问题:admin-service重新部署后,上传的APK文件会丢失
原因:主docker-compose.yml中admin-service未配置volume挂载,
      导致容器重建时/app/uploads目录数据丢失

修复:
- 添加admin_uploads_data volume挂载到/app/uploads
- 添加UPLOAD_DIR环境变量
- 在volumes部分声明admin_uploads_data

影响范围:仅影响admin-service的文件存储持久化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:00:31 -08:00
hailin 45736c4daf fix(admin-service): 修复用户数据CDC同步使用userId导致的数据不一致问题
问题原因:
- 旧的Kafka事件消费者和CDC消费者同时运行
- 旧消费者写入的数据userId可能为0
- CDC消费者使用userId作为upsert条件,导致唯一键冲突失败
- 用户的nickname和kycStatus等信息没有正确同步

修复方案:
- upsert方法改用accountSequence作为唯一键
- CDC消费者的handleUpdate使用accountSequence检查和更新
- 更新时同时修复可能错误的userId
- 新增existsByAccountSequence和updateKycStatusByAccountSequence方法

影响范围:
- admin-web用户管理页面现在能正确显示用户昵称和KYC状态
- 新用户注册后数据能正确同步

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:35:30 -08:00
hailin 51114f265d fix(planting-service): 修复合同PDF签署日期显示为UTC时间的问题
合同生成时使用 new Date().toISOString().split('T')[0] 获取日期,
该方法返回UTC时间,导致北京时间凌晨签署的合同显示为前一天日期。

修复方案:新增 getBeijingDateString() 函数,将UTC时间转换为北京时间(UTC+8)

影响范围:仅影响PDF合同上显示的签署日期,不影响数据库时间戳或业务逻辑

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:42:16 -08:00
hailin 641612a5d0 fix(wallet-service): 修复提现订单查询使用 userId 的问题
将提现订单查询从 userId 改为使用 accountSequence:
- getWithdrawals: 使用 findByAccountSequence 替代 findByUserId
- getFiatWithdrawals: 使用 findByAccountSequence 替代 findByUserId
- 新增 withdrawal-order.repository 的 findByAccountSequence 方法

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:24:31 -08:00
hailin 217be89c43 fix(wallet-service): 修复流水查询使用 userId 导致记录丢失的问题
将所有流水查询从 userId 改为使用 accountSequence:
- getMyLedger: 使用 findByAccountSequence 替代 findByUserId
- getLedgerStatistics: 查询改为按 accountSequence
- getLedgerTrend: 查询改为按 accountSequence
- findByAccountSequence: 添加 HIDDEN_ENTRY_TYPES 过滤

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 11:16:04 -08:00
hailin 53df97839d fix(reporting-service): 修复 roleType 可能为 undefined 的类型错误
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:21:36 -08:00
hailin ee6a092a1a fix(authorization-service): 修复授权查询使用错误字段导致省市互斥验证失效
问题:数据库 user_id 列存储的是 accountSequence,但查询时使用 userId.value,
导致查询不到已有授权记录,省市互斥验证被绕过。

修复方法:所有基于 UserId 的查询改为使用 accountSequence 字段:
- findByUserIdAndRoleType
- findByUserIdRoleTypeAndRegion
- findByUserId
- findPendingByUserId
- findAllByUserIdIncludeDeleted

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 10:15:40 -08:00
hailin 347e5ce3de fix(reporting-service): 修复授权事件区域名称显示 undefined
不同授权事件使用不同的字段名:
- province 事件:provinceCode/provinceName
- city 事件:cityCode/cityName
- community 事件:communityName
- 通用:regionCode/regionName

现在正确处理所有变体,避免显示 "undefined undefined 完成授权"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:51:11 -08:00
hailin 1676e82cc6 fix(admin-service): 认种汇总和分类账只显示已支付订单
过滤掉 CREATED、PROVINCE_CITY_CONFIRMED、CANCELLED 状态的订单,
只统计已支付及之后的订单(PAID, FUND_ALLOCATED, POOL_SCHEDULED, POOL_INJECTED, MINING_ENABLED)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:37:28 -08:00
hailin 2553d05902 chore(api-gateway): 提升速率限制100倍
- minute: 100 → 10000
- hour: 5000 → 500000

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:19:50 -08:00
hailin b5105d6bd1 fix(snapshot): 修改 Services PostgreSQL 用户名为 rwa_user
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 09:05:22 -08:00
hailin bc38ec6ec0 feat(wallet-service): 三层保护机制确保内部转账接收方钱包存在
新增三层保护机制:
1. 用户注册时:监听 identity.UserAccountCreated 事件自动创建钱包
2. 发起转账时:检测内部转账后调用 ensureWalletExists() 预创建钱包
3. 链上确认时:原有 upsert 逻辑兜底(保持不变)

新增文件:
- identity-event-consumer.service.ts: 消费 identity 用户注册事件
- user-account-created.handler.ts: 处理用户注册事件创建钱包

新增 API:
- POST /wallets/ensure-wallet: 确保单个钱包存在
- POST /wallets/ensure-wallets: 批量确保钱包存在

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 07:33:47 -08:00
hailin 68841abbf4 fix(reporting-service): 修复错误的 Kafka topic 订阅
- 充值事件:blockchain.deposit.credited → wallet.acks (过滤 wallet.deposit.credited)
- 权益事件:authorization.benefit.applied → 整合到 authorization-events (过滤 benefit.activated)

原来订阅的 topic 不存在,导致事件无法消费。现已修复为正确的 topic。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 06:01:08 -08:00
hailin 38fff077dd feat(reporting-service): 新增多种活动事件类型
扩展仪表板"最近活动"功能,新增以下活动类型:

活动类型新增:
- kyc_submitted: KYC认证提交
- kyc_approved: KYC认证通过
- kyc_rejected: KYC认证拒绝
- contract_signed: 合同签署
- deposit: 充值到账
- withdrawal: 提现成功
- benefit_applied: 权益申请

监听的 Kafka Topics:
- identity.KYCSubmitted
- identity.KYCApproved
- identity.KYCRejected
- contract.signed
- blockchain.deposit.credited
- wallet.withdrawals (仅处理 completed 事件)
- authorization.benefit.applied

所有新增事件处理器均使用幂等创建,防止重复记录。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:51:26 -08:00
hailin 65bd6a857f fix(reporting-service): 添加活动记录幂等性处理
问题:Kafka 消息重试或重复消费时,同一事件会被记录多次活动,
导致"最近活动"显示重复条目,统计数据也会被重复累加。

修复:
1. 仓储层新增 exists() 和 createIfNotExists() 方法
2. 所有事件消费者改用幂等创建,仅首次创建时累加统计
3. 添加数据库唯一约束 uk_sa_entity_activity 作为最后防线
4. 迁移脚本会自动清理历史重复数据

影响的事件:
- identity.UserAccountCreated
- identity.UserAccountAutoCreated
- authorization-events
- planting.order.paid
- reporting.report.generated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:46:43 -08:00
hailin c65d02ebea fix(admin-web): 仅在没有上级时显示总部节点
- 有上级时显示祖先节点列表
- 没有上级时才显示"总部"节点

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:31:07 -08:00
hailin a86116fef4 fix(admin-service): 修复祖先节点数据获取依赖 user_query_view 的问题
问题:当祖先用户存在于 referral_query_view 但不存在于
user_query_view 时(CDC 同步延迟),祖先节点的
accountSequence 和统计数据无法正确获取。

修复:
- 改用 referralAccountSequences(从 referrals 获取)
  替代 userAccountSequences(从 users 获取)
- 确保即使用户基本信息未同步,仍能获取正确的认种统计

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:30:00 -08:00
hailin ca337bcdb7 fix(admin-service): 优先从 referralQueryView 获取祖先 accountSequence
修复祖先节点可能缺少 accountSequence 的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:23:46 -08:00
hailin 9050a4adca fix(admin-web): 引荐关系树总部节点始终显示在顶端
- 总部节点始终显示在引荐人链最顶端
- 总部节点不可点击(cursor: default)
- 连接符方向改为向下(↓)表示引荐关系

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:19:01 -08:00
hailin e2b2c17d38 feat(admin-web): 用户详情页引荐关系树优化
- 引荐关系节点显示团队认种量:本人认种 / 团队认种
- 无上级引荐人时显示"总部"节点
- 角色标签优化:社区权益→部门权益,区域→部门名称/城市名称
- 后端 ReferralNode 添加 teamAdoptionCount 字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 05:12:28 -08:00
hailin f55ac7e9cb feat(admin-web): 用户详情页显示权益考核记录
- 后端 admin-service 添加 getBenefitAssessments 查询方法
- 更新 AuthorizationDetailResponseDto 包含 benefitAssessments
- 前端用户详情页授权信息 Tab 新增权益考核记录表格
- 显示字段:考核月份、角色、区域、完成/需求、权益操作、
  权益状态变化、有效期至、结果
- 添加权益操作类型样式:已激活(绿)、已续期(蓝)、已停用(红)、无变化(灰)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 04:42:51 -08:00
hailin 178c484f04 feat(admin-service): 添加 BenefitAssessmentRecord CDC 同步
- 新增 BenefitAssessmentQueryView schema 和 migration
- 扩展 AuthorizationCdcConsumerService 处理 benefit_assessment_records 表
- 更新 Debezium authorization-connector 添加新表同步

CDC 同步字段:
- authorization_id, user_id, account_sequence
- role_type, region_code, region_name
- assessment_month, month_index
- monthly_target, cumulative_target
- trees_completed, trees_required
- benefit_action_taken, previous/new_benefit_status
- new_valid_until, result, remarks, assessed_at

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 04:22:37 -08:00
hailin 8f5b4df3d1 feat(authorization-service): 新增权益考核记录表 BenefitAssessmentRecord
新增独立的权益有效性考核记录表,与火柴人排名(MonthlyAssessment)分离:

Schema & Migration:
- 新增 BenefitAssessmentRecord 表存储权益考核历史
- 新增 BenefitActionType 枚举(ACTIVATED/RENEWED/DEACTIVATED/NO_CHANGE)
- 记录考核月份、目标、完成数、权益状态变化等信息

领域层:
- 新增 BenefitAssessmentRecord 聚合根
- 新增 IBenefitAssessmentRecordRepository 接口

应用层:
- 修改 processExpiredCommunityBenefits 保存考核记录
- 修改 processExpiredCityCompanyBenefits 保存考核记录
- 修改 processExpiredProvinceCompanyBenefits 保存考核记录
- 修改 processExpiredAuthCityBenefits 保存考核记录(新增,原无记录)
- 修改 processExpiredAuthProvinceBenefits 保存考核记录(新增,原无记录)

此改动 100% 不影响原有业务逻辑:
- 原有 MonthlyAssessment 表继续用于火柴人排名
- 仅在权益考核执行完成后追加保存记录到新表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 04:15:07 -08:00
hailin 53bc39b65b refactor(admin-web): 更新授权角色类型标签
- COMMUNITY -> 社区权益
- AUTH_PROVINCE_COMPANY -> 省区域
- PROVINCE_COMPANY -> 省团队
- AUTH_CITY_COMPANY -> 市区域
- CITY_COMPANY -> 市团队

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 01:42:22 -08:00
hailin 6a58a55997 fix(admin-web): 月度考核按授权ID过滤并显示区域名称
- 改用 authorization_id 匹配考核记录,而非 roleType
- 同类型但已撤销的角色考核不再显示
- 新增"区域"列显示角色对应的区域名称(如胜门、广州)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:19:09 -08:00
hailin c9626ac82b fix(admin-web): 月度考核排除已撤销角色
只显示 status !== 'REVOKED' 的角色对应的考核记录

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:11:34 -08:00
hailin 1ebfee7228 fix(admin-web): 月度考核只显示用户实际拥有角色的记录
根据 authData.roles 中的角色类型过滤 assessments,
避免显示用户没有的角色(如只有市授权但显示省考核)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:10:25 -08:00
hailin d1e04152dc feat(admin-service): 实现团队本省/本市认种量实时统计
- getBatchUserStats 新增 provinceAdoptionCount 和 cityAdoptionCount
- 根据用户认种订单中的省市信息,统计团队成员同省/同市的认种量
- 百分比改为相对于该用户团队总认种量计算

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:07:33 -08:00
hailin 8298f2e371 chore(admin-web): 移除用户列表页团队总注册地址量列
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:00:04 -08:00
hailin 23994a23be feat(admin-service): 用户列表页使用实时统计数据
- 添加 getBatchUserStats 批量查询方法
- user.controller 注入 userDetailRepository
- listUsers 接口使用实时统计替代预计算字段

实时查询的字段:
- personalAdoptionCount: 个人认种量
- teamAddressCount: 团队地址数
- teamAdoptionCount: 团队认种量

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:56:07 -08:00
hailin 6a05150017 fix(admin-service): 用户详情页引荐人数改用实时查询
之前 directReferralCount 使用 CDC 同步的预计算字段(值为 0),
现在改为调用 getDirectReferralCount 实时查询

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:51:16 -08:00
hailin da5bb98cb7 fix(admin-service): 修复团队统计查询使用错误的 userId
user_query_view 和 referral_query_view 的 userId 不一致:
- user_query_view.user_id = 10 (identity-service CDC)
- referral_query_view.user_id = 25122700001 (referral-service CDC)

修复:
- getTeamStats 改为从 referralQueryView 获取 userId
- 直接用 accountSequence 查询团队认种量,避免再次关联

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:47:23 -08:00
hailin bab30dbeba refactor(admin-service): 团队认种量改用 PlantingOrderQueryView
统一使用 PlantingOrderQueryView 计算团队认种量,
与个人认种量保持一致的数据源,避免 CDC 同步不一致问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:42:30 -08:00
hailin 97bcaa2dfc fix(admin-service): 修复 referrerId 可能为 null 的 TypeScript 错误
- getAncestors 和 getDirectReferrals 中的 directReferralCounts
  groupBy 结果 referrerId 字段可能为 null
- 添加 filter 过滤 null 值,使用非空断言通过类型检查

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:34:17 -08:00
hailin 58e3e34373 fix(admin-web): 引荐关系树初始加载时自动展开直推下级
问题:改为递归组件后,当前用户的直推下级不再显示

解决:
- 添加 useEffect 监听 referralTree 数据
- 数据加载完成后自动将直推下级放入 expandedNodes
- 这样页面初始加载时就会显示直推下级

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:32:08 -08:00
hailin d303cf076b fix(admin-service): 引荐关系树实时统计本人认种和直推数量
问题:
- 引荐节点的"本人认种"和"引荐"显示为0
- user_query_view.personal_adoption_count 和
  referral_query_view.direct_referral_count 没有被CDC更新

解决方案:
- getAncestors: 实时统计每个祖先的认种订单数和直推数
- getDirectReferrals: 实时统计每个下级的认种订单数和直推数
- getReferralTree API: 实时获取当前用户的统计数据
- 新增 getDirectReferralCount 方法

实时统计方式:
- 本人认种 = planting_order_query_view 中状态为 MINING_ENABLED 的订单数
- 直推数量 = referral_query_view 中 referrer_id 等于该用户的记录数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:30:38 -08:00
hailin 3ed72499a0 feat(admin-web): 引荐关系树支持递归展开每个节点
- 创建 ReferralNodeItem 递归组件
- 每个有下级的节点都显示"+"按钮
- 点击"+"异步加载并展开该节点的下级
- 展开后按钮变为"-",点击收起
- 加载中显示"..."
- 子节点也支持递归展开,可无限层级

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:24:21 -08:00
hailin fff386c000 feat(admin-web): 优化引荐关系树展示
- "认种"改名为"本人认种"
- 直推下级默认收起状态
- 有直推下级时在当前用户节点下方显示"+"按钮
- 点击"+"展开显示直推下级,按钮变为"-"
- 点击"-"收起直推下级

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:14:39 -08:00
hailin f0bf4d8c5d fix(admin-web): 认种明细省市列显示真实名称
- 导出 CITY_CODE_NAMES 常量供外部使用
- 添加 getRegionName 函数转换区域代码为名称
- 认种分类账明细中省市列使用真实名称替代数字代码
  例如:450000/451200 -> 广西壮族自治区/柳州市

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:09:42 -08:00
hailin a39ee76e9a fix(admin-web): 用户详情页移除无用字段
- 钱包汇总:移除 DST 可用、算力 字段
- 认种分类账明细:移除 状态 列

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 22:06:11 -08:00
hailin 2781ffccc1 fix(postgres): 增加 max_wal_senders 到 10 支持更多 CDC connector
- max_replication_slots: 4 -> 10
- max_wal_senders: 4 -> 10
- 修复 authorization-connector FAILED 的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:53:25 -08:00
hailin 475acf71cc fix(admin-service): 修复钱包金额显示被除以10^8的问题
- decimalToString 改用 toString() 替代 toFixed(8)
- Prisma Decimal 的 toFixed 会导致精度错误
- 删除调试日志代码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:46:29 -08:00
hailin 7176bbd5c2 feat(admin-service): 用户详情页统计数据改用真实数据
- 移除前端"活跃引荐"统计卡片
- 添加 getPersonalAdoptionCount 方法从 PlantingPositionQueryView 获取个人认种量
- 添加 getTeamStats 方法计算团队地址数和团队认种量
- 修改 getFullDetail 使用新方法获取真实统计数据
- 团队地址数通过 ancestorPath 查询所有下级用户
- 团队认种量汇总所有团队成员的 effectiveTreeCount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:23:18 -08:00
hailin eccc637a02 fix(admin-service): 修复金额显示单位错误
移除错误的 1e8 乘除转换:
- 数据库存储的是实际金额 (Decimal(20,8)),不需要缩放
- decimalToBigint → decimalToString: 直接格式化为字符串
- 移除 controller 中不再需要的 formatDecimal 方法
- 更新接口类型: bigint → string (金额相关字段)

影响的接口:
- PlantingSummary.totalAmount
- PlantingLedgerItem.totalAmount
- WalletSummary 所有余额字段
- WalletLedgerItem.amount/balanceAfter
- SystemAccountLedger.amount/balanceAfter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:15:28 -08:00
hailin 1c5fd9eaad fix(admin-service): 修复 BigInt 序列化错误
日志输出时不再使用 JSON.stringify 序列化包含 BigInt 的对象,
改为直接输出关键字段值,避免 "Do not know how to serialize a BigInt" 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:03:41 -08:00
hailin 59469055c7 refactor(admin-web): 用户详情页术语统一为"引荐"
- "直推" → "引荐"
- "推荐人" → "引荐人"
- "推荐关系" → "引荐关系"

涉及修改:
- 统计卡片:直推人数 → 引荐人数,活跃直推 → 活跃引荐
- 引荐人信息区域标签
- Tab 标签名称
- 引荐关系树标题和节点标签
- 空状态提示文案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:56:32 -08:00
hailin 5f6ecf9670 fix(admin): 用户详情页 USDT 改为绿积分 + 添加调试日志
前端修改:
- 钱包汇总: USDT 可用/冻结 → 绿积分 可用/冻结
- 认种汇总: 总金额 (USDT) → 总金额 (绿积分)
- 资产类型标签: USDT → 绿积分

后端修改:
- UserDetailController: 添加认种/钱包查询日志
- UserDetailQueryRepositoryImpl: 添加数据库查询日志
- 日志包含 accountSequence、查询结果、数据条数等信息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:53:30 -08:00
hailin f15bdeaef8 fix(admin-service): 用户详情查询改用 accountSequence
- 钱包/认种查询从 userId 改为 accountSequence 作为关联键
- 修复用户详情页钱包数据显示为0的问题
- accountSequence 是全局唯一业务标识,关联更可靠

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:43:36 -08:00
hailin b49776fadb fix(admin-service): 注册 CDC 消费者到 AppModule
CDC 数据无法同步的原因是消费者服务未在 AppModule 中注册。
添加以下 CDC 消费者:
- CdcConsumerService (identity 用户数据)
- ReferralCdcConsumerService (推荐关系)
- WalletCdcConsumerService (钱包账户和流水)
- PlantingCdcConsumerService (认种订单和持仓)
- AuthorizationCdcConsumerService (授权角色和考核)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:20:54 -08:00
hailin 3d31e8beb9 feat(admin): 实现用户详情页面
前端 (admin-web):
- 新增用户详情页面 /users/[id]
- 实现推荐关系树可视化,支持点击节点切换视角
- 添加认种分类账Tab,显示汇总和订单明细
- 添加钱包分类账Tab,显示余额汇总和流水明细
- 添加授权信息Tab,显示角色、月度考核和系统账户流水
- 用户列表"查看详情"改为 Link 导航到详情页

后端 (admin-service):
- 新增 UserDetailController 提供详情页API
- 新增 UserDetailQueryRepository 查询CDC同步的数据
- API: GET /admin/users/:seq/full-detail
- API: GET /admin/users/:seq/referral-tree
- API: GET /admin/users/:seq/planting-ledger
- API: GET /admin/users/:seq/wallet-ledger
- API: GET /admin/users/:seq/authorization-detail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 20:10:01 -08:00
hailin d293ec10e4 fix(admin-web): 移除用户管理中的编辑功能
系统设计原则:用户数据只能由用户本人修改,管理员不能编辑

移除的功能:
- 页面顶部的"批量编辑"按钮
- 表格行操作中的"编辑"按钮
- handleBatchEdit 事件处理函数
- API 端点: UPDATE, DELETE, BATCH_UPDATE

保留的功能:
- 查看用户列表
- 查看用户详情
- 导出 Excel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:39:04 -08:00
hailin 83f84b9d7c feat(admin-service): 添加 CDC 分类账流水同步
新增 wallet/planting/authorization 服务的 CDC 数据同步:

状态表同步:
- WalletAccountQueryView: 钱包账户余额状态
- WithdrawalOrderQueryView: 提现订单状态
- FiatWithdrawalOrderQueryView: 法币提现订单
- PlantingOrderQueryView: 认种订单状态
- PlantingPositionQueryView: 持仓状态
- ContractSigningTaskQueryView: 合同签约任务
- AuthorizationRoleQueryView: 授权角色
- MonthlyAssessmentQueryView: 月度考核
- SystemAccountQueryView: 系统账户余额

分类账流水同步:
- WalletLedgerEntryView: 钱包流水分类账
- FundAllocationView: 认种资金分配记录
- SystemAccountLedgerView: 系统账户流水

其他:
- Debezium Connect 端口改为 8084 避免冲突
- 更新连接器配置添加流水表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:29:27 -08:00
hailin c4cec836d9 feat(admin-service): 添加 referral-service CDC 数据同步
- 新增 ReferralQueryView schema 和 migration
- 新增 ReferralCdcConsumerService 消费推荐关系变更
- 配置 referral-postgres-connector 用于 Debezium CDC
- 更新 deploy.sh 自动注册 referral connector
- 更新 init-databases.sh 配置 rwa_referral 逻辑复制权限

CDC 同步的字段:
- user_id, account_sequence, referrer_id
- my_referral_code, used_referral_code
- ancestor_path, depth
- direct_referral_count, active_direct_count

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:57:58 -08:00
hailin 6b55b69d0d fix(leaderboard-service): 修复健康检查 API 路径
将 Dockerfile 和 docker-compose.yml 中的健康检查路径从
/api/health 修改为 /api/v1/health,与实际 API 路由保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:48:34 -08:00
hailin 7d3483b565 fix(referral-service): 修复 Kafka 消费异常被吞掉的问题
- kafka.service.ts: 抛出异常让 KafkaJS 触发重试
- user-registered.handler.ts: 传播异常到 KafkaService

修复前处理失败的消息不会重试,导致推荐关系可能丢失

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:47:05 -08:00
hailin b5ebf8a615 feat(admin-service): 实现 Debezium CDC 数据同步
- 新增 CdcConsumerService 消费 PostgreSQL WAL 变更事件
- 配置 Debezium Connect 服务和 PostgreSQL 逻辑复制
- 更新 deploy.sh 支持 Debezium 启动和连接器管理
- 新增 identity-postgres-connector 配置同步 user_accounts 表
- 保留原有 Outbox 机制用于业务领域事件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 18:35:53 -08:00
hailin cc17f6a38e fix(admin-web): 修复系统账户余额统计不一致问题
- 账户余额改为 usdtAvailable + settleableUsdt,与累计收入统计保持一致
- 解决社区权益进入 settleableUsdt 导致的余额与累计收入不匹配问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:19:35 -08:00
hailin 667b240915 fix(admin-web): 重命名热钱包余额标签
- 公共账户 → 网络因子
- 因子 → 加速因子

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 07:04:25 -08:00
hailin 84aa8181a9 fix(reporting-service): 兼容 referral-service 的认种事件格式
- planting.order.paid 事件现在由 referral-service 发送
- 消息格式为 { eventName, data: {...} },与原 planting-service 格式不同
- 添加兼容逻辑,同时支持两种格式
- 修复今日认种统计为0的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:58:33 -08:00
hailin c7c793f128 fix(reporting-service): 账户余额改为 累计收入 - 累计转出
- 所有固定账户:账户余额 = 累计收入 - 累计转出
- 总部储蓄(HQ_COMMUNITY):累计收入 = ledger收入 + 过期收益
- 统一计算公式,确保数据一致性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:41:28 -08:00
hailin 842bc42579 fix(reporting-service): 总部储蓄账户余额也需累加过期收益
- 账户余额改为 usdtBalance + expiredRewardsTotal
- 与累计收入的计算方式保持一致
- 过期的分享权益会进入 S0000000001,余额和收入都应包含

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:40:20 -08:00
hailin a719294dda fix(blockchain-service): 添加系统账户转出事件主题订阅
- 消费者添加订阅 wallet.system-withdrawals 主题
- 解决系统账户转出订单一直处于 FROZEN 状态的问题
- 事件现在可以被 blockchain-service 消费并处理链上转账

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:17:57 -08:00
hailin 38fa1f807d fix(admin-web): 修复 React hooks 规则违规问题
- 将所有 useCallback hooks 移动到条件返回之前
- React 要求所有 hooks 必须在任何条件返回之前调用
- 解决客户端异常 "a client-side exception has occurred" 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:13:09 -08:00
hailin 603f41f9ca fix(admin-web): 修复固定账户组件空值检查和 hooks 顺序
- 添加 data 空值检查防止 undefined 错误
- 将 useState hooks 移到条件返回之前(React hooks 规则)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:05:42 -08:00
hailin c22ce4ecc4 fix(reporting-service): 将过期收益累加到总部储蓄账户统计
- 修改 assembleFixedAccounts 方法接受过期收益总额参数
- 将过期分享收益累加到 HQ_COMMUNITY (S0000000001) 的累计收入中
- 添加日志记录累加过程

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:53:51 -08:00
hailin db833fdf45 fix(wallet-service): 修复系统划转请求 DTO 验证错误
- 为 SystemWithdrawalRequestDTO 添加 class-validator 装饰器
- 添加 @ApiProperty 装饰器用于 Swagger 文档
- 使用 @Type(() => Number) 自动转换 amount 类型
- 简化验证逻辑,移除冗余的手动验证

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:52:36 -08:00
hailin 02fa87f6c8 fix(admin-web): 过滤掉不存在的固定系统账户
- 添加 filter 过滤掉 data 为 null 的账户
- 修复空白卡片显示问题(账户数据不存在时不显示卡片)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:41:58 -08:00
hailin 04a23a30a4 fix(admin-web): 修复系统划转服务响应解析路径
- 所有方法改为使用 .data.data 解析响应数据
- API响应结构为 { success, data: { code, message, data } }
- 修复 "e.map is not a function" 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:32:48 -08:00
hailin 4f2f808484 feat(wallet-service): 实现 Outbox Pattern 保证系统划转事件发布的可靠性
实现内容:
- 添加 OutboxEvent 模型到 schema.prisma
- 创建 OutboxRepository 服务处理事件持久化
- 创建 OutboxPublisherService 后台轮询发布事件到 Kafka
- 修改 SystemWithdrawalApplicationService 将事件写入事务内
- 添加数据库迁移文件创建 outbox_events 表

技术细节:
- 业务数据和事件数据在同一个数据库事务中写入
- 后台任务每秒轮询 outbox_events 表,发布 PENDING 状态事件
- 事件发布后标记为 SENT,等待消费方确认后标记为 CONFIRMED
- 超时未确认的事件自动重试(指数退避策略)
- 保证事件不丢失,即使 Kafka 暂时不可用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:15:07 -08:00
hailin 28f1e26400 fix(admin-web): 修复系统账户划转API调用问题
问题分析:
1. ACCOUNT_NAME endpoint 使用路径参数,但后端使用 query 参数
2. apiClient 响应拦截器已解包 response.data,service 解析路径多一层
3. SystemAccount 接口 name 字段与后端 accountName 不匹配

修复内容:
- endpoints.ts: ACCOUNT_NAME 改为基础路径,通过 params 传参
- systemWithdrawalService.ts:
  - getAccounts() 解析路径从 data.data 改为 data
  - getAccountName() 使用 query 参数方式调用
  - request() 解析路径修正
- system-withdrawal.types.ts: SystemAccount.name -> accountName

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:55:20 -08:00
hailin d16ad81d62 fix(wallet-service): 完善系统账户转出分类账memo信息
- memo 格式更新为包含完整信息:
  - 接收方账户ID
  - 接收方姓名
  - 接收方钱包地址(缩略显示)
  - 操作员姓名/ID
  - 备注(如有)
- payloadJson 新增 toAddress 字段存储完整地址

示例: "转账至 D25010100001 (张三) | 地址: 0x1234...5678 | 操作员: 管理员A | 备注: 奖励发放"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:52:00 -08:00
hailin 6350b36e1a feat(reporting, admin-web): 添加省/市区域账户累计转出统计
- 后端 reporting-service: RegionAccountInfo 和汇总接口添加 totalTransferred 字段
- 后端 wallet-service client: 更新 AllSystemAccountsResponse 类型定义
- 前端 admin-web: SystemAccountsTab 组件显示累计转出列
- 前端类型: RegionAccountsSummary 添加 totalTransferred 字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:45:56 -08:00
hailin 27e64819b7 feat(wallet-service): 省/市区域账户增加累计转出字段
- provinceAccounts 和 cityAccounts 返回结构增加 totalTransferred 字段
- 完善分类账统计,与固定系统账户保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:39:47 -08:00
hailin 07fe3e3140 fix(admin-web): 修复划转记录API响应解析
- apiClient 响应拦截器已解包 response.data
- 修正取值路径为 response.data 而非 response.data.data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:29:02 -08:00
hailin 06fc8aa5d9 fix(admin-web): 修复划转记录页面报错问题
- 适配后端返回格式:orders -> items, pageSize -> limit
- 解决 Cannot read properties of undefined (reading 'length') 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:28:07 -08:00
hailin 230e0d98b6 chore(admin-web): 隐藏分类账明细Tab
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:21:15 -08:00
hailin 78fa354117 fix(wallet-service): 修复系统账户余额统计不一致问题
- 账户余额改为 usdtAvailable + settleableUsdt,与累计收入统计保持一致
- 解决社区权益进入 settleableUsdt 导致的余额与累计收入不匹配问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:15:42 -08:00
hailin 5c7cb616a7 feat(wallet-service): 添加运营1和积分股池到系统划转账户列表
- 添加 S0000000002 (运营1) 和 S0000000004 (积分股池) 到允许转出白名单
- 更新系统账户名称映射与前端保持一致
- 为 S0000000006 手续费归集账户添加兼容逻辑,当余额为0时从提现订单表统计历史手续费
- 优化过期奖励处理,按分配类型分别记录流水便于明细查看

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 03:44:46 -08:00
hailin 4f55d86050 feat(mobile-app): 更新客服联系方式
- 客服微信1: liulianhuanghou1
- 客服微信2: liulianhuanghou2
- 客服QQ1: 1502109619
- 客服QQ2: 2171447109

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 03:10:50 -08:00
hailin fa7b45ec2f fix(wallet-service, admin-web): 修复系统账户划转金额类型问题
- wallet-service: 支持 amount 为字符串或数字类型,添加类型转换
- admin-web: 改进错误处理,正确提取 Axios 错误消息

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 03:01:52 -08:00
hailin 1b237778ee feat(mobile-app): 添加联系客服功能
在个人中心设置菜单中添加"联系客服"入口,点击后显示弹窗,
用户可以查看客服的QQ号和微信号,并支持一键复制到剪贴板。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:56:59 -08:00
hailin efb428ef31 fix(admin-web): 调换运营1和运营2名称
S0000000002 → 运营1
S0000000003 → 运营2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:42:47 -08:00
hailin 6f52956c42 fix(admin-web): 修正系统账户显示名称映射
S0000000001 → 总部储蓄 (原运营1)
S0000000003 → 运营1 (原总部储蓄)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:37:45 -08:00
hailin 9a4c984bd2 fix(admin-web): 添加 SystemAccountDTO.accountSequence 类型字段
后端 wallet-service 返回的固定账户数据包含 accountSequence 字段,
前端类型定义缺少该字段导致编译失败。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:27:36 -08:00
hailin 1354055f09 fix(admin-web): 修复固定账户明细显示错误
问题:点击某账户卡片时显示其他账户的明细
原因:前端硬编码了 accountSequence 与字段名的对应关系,与后端映射不一致

修复:从后端返回数据中读取真实的 accountSequence,而不是硬编码
- accounts 数组现在从 data.xxx.accountSequence 动态获取序列号
- 更新类型定义注释,说明序列号由后端分配

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:24:09 -08:00
hailin eb40b658f6 revert(wallet-service): 回滚 fixedAccountTypes 映射修改
撤销错误的账户类型映射修改,恢复原始映射:
- S0000000001 → HQ_COMMUNITY
- S0000000002 → COST_ACCOUNT
- S0000000003 → OPERATION_ACCOUNT

修改后端映射会影响 reward-service、authorization-service 等多个服务。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:22:22 -08:00
hailin d28723f141 fix(wallet-service): 修正固定账户类型映射顺序
修复 getAllSystemAccounts 中 fixedAccountTypes 映射错误:
- S0000000001 应为 COST_ACCOUNT (运营1),而非 HQ_COMMUNITY
- S0000000002 应为 OPERATION_ACCOUNT (运营2),而非 COST_ACCOUNT
- S0000000003 应为 HQ_COMMUNITY (总部储蓄),而非 OPERATION_ACCOUNT

此修复确保固定账户的余额、收入和分类账明细正确对应。
同时移除前端调试日志。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:20:41 -08:00
hailin 7766a9caba style(admin-web): 隐藏仪表板热钱包余额的单位标签
移除公共账户和因子卡片的 dUSDT 和 KAVA 单位显示,保持界面简洁一致。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:19:19 -08:00
hailin fa0fd3adb3 debug(admin-web): 添加分类账数据匹配调试日志
添加 console.log 输出,用于调试固定账户明细数据匹配问题。
请在浏览器开发者工具控制台查看输出,确认后端返回的数据格式。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:13:00 -08:00
hailin 7744abf57d fix(reporting-service): 修复 DashboardController 的依赖注入问题
在 ApiModule 中导入 RedisModule,使 HotWalletBalanceCacheService
可以被 DashboardController 注入使用。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:12:24 -08:00
hailin 8dba325499 feat(dashboard): 添加热钱包余额实时显示(公共账户/因子)
- blockchain-service: 扩展调度器同时缓存 dUSDT 和 KAVA 原生代币余额到 Redis
  - Redis Key: hot_wallet:dusdt_balance:KAVA, hot_wallet:native_balance:KAVA
  - 每5秒更新,TTL 30秒

- reporting-service: 添加热钱包余额读取服务和 API
  - 新增 HotWalletBalanceCacheService 从 Redis 读取缓存
  - 新增 GET /v1/dashboard/hot-wallet-balance 接口

- admin-web: 仪表板添加热钱包余额显示
  - 公共账户显示 dUSDT 余额
  - 因子显示 KAVA 原生代币余额
  - 每15秒自动刷新

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:04:21 -08:00
hailin e1aec6c2c3 refactor(admin-web): 固定账户明细改为在公共区域显示
将固定系统账户的分类账明细从每个卡片内部展开改为在卡片网格下方
的公共区域显示。点击任一账户的"查看明细"按钮,明细表格在下方
完整展示,提供更好的阅读体验。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:58:30 -08:00
hailin d6b3f04612 feat(admin-web): 固定系统账户卡片添加查看分类账明细按钮
在固定系统账户卡片下方添加"查看明细"按钮,点击后展开显示
该账户的分类账流水记录,包括时间、类型、金额、余额和备注。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:50:03 -08:00
hailin d53b1c7499 fix(admin-web): 修复 ExpiredRewardsSection 中 ApiResponse 类型使用错误
ApiResponse<T> 类型只有 code, message, data 属性,没有 success
将 response.success && response.data 改为 response.data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:44:37 -08:00
hailin 95cb3510aa fix(reward-service): 修复 getExpiredRewardsEntries 的 userId 类型转换
将 bigint 类型的 userId 转换为 number 类型以匹配返回类型定义

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:38:50 -08:00
hailin e9c0196d68 feat(admin-web): 添加过期收益明细查询功能
- reward-service: 添加 getExpiredRewardsEntries API 查询过期收益明细
- reporting-service: 添加过期收益明细转发接口和类型定义
- admin-web: 过期收益统计区域新增"查看明细"按钮
  - 支持分页浏览过期收益记录
  - 支持按权益类型筛选
  - 显示过期时间、用户ID、账户、权益类型、金额、订单号

回滚方式:删除各服务中标注 [2026-01-07] 的代码块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:36:31 -08:00
hailin 90ca62b594 fix(admin-web): 修正系统账户名称映射
- S0000000001: 运营1
- S0000000002: 运营2
- S0000000003: 总部储蓄
- S0000000004: 积分股池

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:14:24 -08:00
hailin 463e70131d feat(admin-web): 市级区域账户显示真实城市名称
添加 CITY_CODE_NAMES 映射,包含全国所有地级市的行政区划代码。
修改 getAccountDisplayName 函数,优先查找城市名称映射,
使市级账户显示如"汕头市 (8440500)"而非"广东市级"。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 22:05:24 -08:00
hailin fd602e104d fix(admin-web): fix region account display name format
Previous format showed meaningless city code like "浙江01市 (330100)".
Now shows cleaner format: "浙江市级 (330100)" for city-level accounts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:57:43 -08:00
hailin e01c7efc3c fix(admin-web): 修复区域账户显示省市名称
- getAccountDisplayName 支持6位区域代码(如 330100)
- 330100 → 浙江01市 (330100)
- 440600 → 广东06市 (440600)
- RegionAccountsSection 使用 regionCode 解析名称,不依赖后端 regionName

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:46:49 -08:00
hailin 2a1d6a6bcc feat(admin-web): 系统划转页面也显示账户正式名称
- 账户卡片使用 getAccountDisplayName 显示名称和编码
- 订单列表源/目标账户显示正式名称
- 订单详情弹窗显示正式名称
- 确认划转弹窗显示正式名称

回滚方式:恢复原显示方式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:44:34 -08:00
hailin 8c29603f5a fix(reporting-service): use field-level @unique for statsDate in schema
Root cause: @@unique([field], name: "xxx") requires { xxx: { field } } syntax
in findUnique/upsert, but code used { field } directly.

Fix: Change to @unique(map: "uk_realtime_stats_date") on the field itself.
This keeps the same database index name while allowing { statsDate } syntax.

No migration needed - only Prisma client type generation changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:38:13 -08:00
hailin 4e201d3a66 fix(reporting-service): use findFirst + update instead of upsert for realtimeStats
Replace upsert with findFirst + create/update pattern to avoid Prisma
unique constraint syntax issues. The @@unique constraint with a custom
name doesn't allow direct field-based queries in findUnique/upsert.

This approach maintains the same behavior without schema changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:36:26 -08:00
hailin 2d9d6ceed7 feat(admin-web): 系统账户显示正式名称并保留编码
- 添加 SYSTEM_ACCOUNT_NAMES 映射:S0000000001-S0000000006 映射到正式名称
- 添加 PROVINCE_CODE_NAMES 映射:中国省份行政区划代码映射
- 添加 getAccountDisplayName 函数:统一显示格式 "名称 (编码)"
- FixedAccountsSection: 固定账户显示为 "总部账户 (S0000000001)" 格式
- RegionAccountsSection: 区域账户合并显示名称和编码
- LedgerAccountCard: 分类账卡片显示完整账户信息
- FeeAccountSection: 手续费归集账户显示正式名称
- RewardTypeSummarySection: 收益明细显示账户正式名称
- OfflineSettlementSection: 面对面结算明细显示账户正式名称

回滚方式:恢复 imports,删除映射常量和 getAccountDisplayName 函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:29:14 -08:00
hailin ce1d342269 fix(reporting-service): use named unique constraint for realtimeStats queries
Prisma requires using the named unique constraint (uk_realtime_stats_date)
in where clauses for findUnique and upsert operations. This fixes the
PrismaClientValidationError that was occurring when processing planting
order events.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:25:24 -08:00
hailin d400290652 fix(reporting-service): 修复统计 incrementPlanting 对 undefined 参数的处理
当 planting.order.paid 事件中 treeCount 为 undefined 时:
- GlobalStatsRepository: 使用 ?? 0 提供默认值
- RealtimeStatsRepository: 使用 ?? 0 提供默认值
- 避免 Prisma upsert 因 undefined increment 值而报错

问题原因:planting-service 发送的事件数据中 treeCount 可能为 undefined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:18:26 -08:00
hailin ead1aac60c fix(admin-web): add operatorId to system-withdrawal request
The wallet-service API requires operatorId parameter but frontend
was not sending it, causing 400 error. Now includes operatorId
and operatorName from current logged-in user.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:16:49 -08:00
hailin 272b4ffdbf feat(wallet-service): 添加手续费归集统计的历史数据兼容
当 FEE_COLLECTION 流水为空时,自动从提现订单表查询历史手续费:
- getFeeCollectionSummary: 从 withdrawal_orders 和 fiat_withdrawal_orders 聚合统计
- getFeeCollectionEntries: 从两个订单表查询明细列表,支持分页和类型筛选
- 按月统计使用 UNION ALL 合并两种提现订单数据
- 明细记录添加备注说明区分来源(区块链/法币)

回滚方式:删除 fallback 代码块和两个私有方法

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:11:37 -08:00
hailin 4dcdfb8a3c fix(wallet/reporting): 修复手续费归集统计 API 的数据库表名和响应解包问题
- wallet-service: 修复 getFeeCollectionSummary 中原生 SQL 使用错误表名
  - 将 ledger_entries 改为 wallet_ledger_entries(Prisma 映射表名)
- reporting-service: 修复 getFeeCollectionSummary/Entries 响应解包
  - wallet-service 返回 { success, data, timestamp } 格式需要解包 data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:04:02 -08:00
hailin 4e5d9685a1 feat(admin-web): 添加面对面结算明细列表功能
- wallet-service: 新增 getOfflineSettlementEntries 方法和 API
- reporting-service: 新增客户端方法和 API 转发
- admin-web: 添加明细列表组件和样式,支持展开/收起

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:40:12 -08:00
hailin 9953f0eee5 fix(reporting-service): 修复面对面结算数据解包问题
wallet-service 返回 { success, data, timestamp } 包装格式,
getOfflineSettlementSummary 需要用 response.data.data 解包才能获取真正的数据。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:23:54 -08:00
hailin 4df9895863 feat(admin-web): 完善系统账户报表收益统计显示
- 分享引荐收益汇总:显示所有状态(PENDING/SETTLEABLE/SETTLED/EXPIRED)的完整数据
- 面对面结算:改为从 wallet_ledger_entries 表查询 SPECIAL_DEDUCTION 类型
- 新增按状态分组统计表格和详细分类卡片

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:11:09 -08:00
hailin 2c8263754f fix(wallet-service): fix system-withdrawal API route prefix to match Kong gateway
Changed controller route from 'system-withdrawal' to 'wallets/system-withdrawal'
to align with Kong's /api/v1/wallets/* routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:53:54 -08:00
hailin 305514b246 feat(admin-web): 仪表板改用 planting-service 源数据
统计卡片和趋势图不再使用 reporting-service,直接使用 planting-service 的源数据:

- 统计卡片:总认种量、总订单数、今日认种、本月认种
- 趋势图:支持 7天/30天/90天 切换
- 新增 usePlantingTrendForDashboard hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:47:44 -08:00
hailin b947fe8205 feat(admin-web): add system account transfer management page
- Add system-transfer page with transfer form and order history
- Add SystemWithdrawalService for API calls
- Add useSystemWithdrawal hooks for React Query integration
- Add system-withdrawal types definitions
- Add navigation menu item for system transfer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:30:35 -08:00
hailin fe8e9a9bb6 fix(planting-service): 修复趋势数据查询表名错误
表名应为 planting_orders(复数),不是 planting_order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:26:36 -08:00
hailin 64bd82b77b feat(wallet/blockchain/identity): implement system account withdrawal feature
- Add SystemWithdrawalApplicationService to handle system account transfers
- Add SystemWithdrawalController with endpoints for request, query, and account listing
- Add SystemWithdrawalStatusHandler to process blockchain confirmation/failure events
- Add SystemWithdrawalRequestedHandler in blockchain-service to execute ERC20 transfers
- Add getUserByAccountSequence endpoint in identity-service for user lookup
- Support dynamic memo generation based on actual source account name
- Dual-sided ledger entries for system account transfers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:22:15 -08:00
hailin fa1931b3b6 feat(planting-service, admin-web): 实现认种趋势图表功能
后端变更 (planting-service):
- 添加 getTrendData API 接口支持按时间维度(日/周/月/季度/年)查询趋势数据
- 添加 TrendPeriod 类型和 TrendDataPoint 接口
- 实现 repository 层的趋势数据聚合查询

前端变更 (admin-web):
- 添加趋势数据 API 端点和类型定义
- 使用 recharts 实现折线图展示认种棵数和订单数趋势
- 支持日/周/月/季度/年度时间维度切换
- 添加加载状态、错误状态和空数据状态处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:21:11 -08:00
hailin 4f3660f05e feat(statistics): 恢复榴莲树认种数量趋势图表
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:01:37 -08:00
hailin 24bcc45d5a refactor(statistics): 删除认种统计页面不相关的mock功能
删除以下mock数据和功能:
- 榴莲树认种数量趋势图表
- 龙虎榜与排名统计
- 区域认种数据统计
- 省/市公司运营统计
- 收益明细与来源

保留核心认种统计:
- 榴莲树认种总量(含积分)
- 今日认种数量(含积分)
- 本月认种数量(含积分)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:00:50 -08:00
hailin 36a83397a8 revert: 撤销对 authorization/identity/reporting 服务的修改
这些服务不需要同步手续费账户的定义,wallet-service 独立处理。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:48:42 -08:00
hailin 2be9a2d9c2 feat(statistics): 认种统计改为真实数据并显示积分
后端变更(planting-service):
- 添加 getMonthStats() 方法获取本月认种统计
- 更新 GlobalStatsResult 接口添加 monthStats 字段
- 添加 MonthStatsDto 响应类型

前端变更(admin-web):
- 更新 PlantingGlobalStats 类型定义
- statistics 页面调用真实 API 获取认种统计
- 显示认种总量、今日、本月的棵数和积分

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:36:46 -08:00
hailin 898521d236 fix: 同步手续费归集账户到所有相关服务
- identity-service: seed.ts 添加 S0000000005 和 S0000000006
- authorization-service: 枚举添加 SHARE_RIGHT_POOL 和 FEE_COLLECTION
- authorization-service: 初始化固定账户列表添加新账户
- reporting-service: 修复账户类型映射 (PLATFORM_FEE -> FEE_COLLECTION)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:30:45 -08:00
hailin 84fb6b8500 fix(reward-service): 修复字段名错误 sourceOrderId → sourceOrderNo
数据库中的字段名是 sourceOrderNo,修复 getRewardEntriesByType 和
getFeeEntriesDetailed 方法中的字段映射错误。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:27:25 -08:00
hailin 4b5270f130 feat(admin-web): 添加系统账户收益类型详细明细列表功能
为系统账户报表中的5个收益类型汇总Tab添加详细明细查看功能:
- 手续费账户汇总:点击"查看详细明细"展开手续费记录列表
- 省团队收益汇总:展开省团队权益记录列表
- 市团队收益汇总:展开市团队权益记录列表
- 分享引荐收益汇总:展开分享权益记录列表
- 社区收益汇总:展开社区权益记录列表

后端变更:
- reward-service: 添加 getRewardEntriesByType、getFeeEntriesDetailed 方法
- reward-service: 添加 /statistics/reward-entries-by-type、/statistics/fee-entries-detailed 接口
- reporting-service: 添加对应的聚合接口

前端变更:
- 添加 RewardEntryDTO、RewardEntriesResponse 类型定义
- 添加 getRewardEntriesByType、getFeeEntriesDetailed API 方法
- FeeAccountSection、RewardTypeSummarySection 组件添加详细明细列表展开功能
- 添加分页支持(每页20条)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:19:15 -08:00
hailin 283553a474 fix(wallet-service): 统一系统账户 seed migration
- 将 S0000000005 和 S0000000006 添加到初始 seed migration
- 简化 S0000000006 migration 格式与其他账户保持一致
- 新环境初始化时所有系统账户一次性创建

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:12:17 -08:00
hailin b9911ab460 feat(wallet-service): 实现手续费归集账户功能
- 新增系统账户 S0000000006 (user_id=-6) 用于归集提现手续费
- 新增 FEE_COLLECTION 流水类型记录手续费归集
- 区块链提现完成时使用 UnitOfWork 事务归集手续费
- 法币提现完成时在事务中归集手续费
- WithdrawalOrderRepository 添加事务支持
- 所有手续费归集操作使用乐观锁保护

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:10:41 -08:00
hailin 99b725db0a feat(admin-web): 添加系统账户收益类型汇总统计功能
在数据统计-系统账户中新增5个统计Tab:
- 手续费账户汇总:统计成本费、运营费、总部社区基础费、RWAD底池注入
- 省团队收益汇总:统计省团队权益收益
- 市团队收益汇总:统计市团队权益收益
- 分享引荐收益汇总:统计分享权益收益
- 社区收益汇总:统计社区权益收益

后端变更:
- reward-service: 添加 getRewardsSummaryByType、getAllRewardTypeSummaries 方法
- reporting-service: 聚合收益类型汇总统计接口

前端变更:
- 添加 RewardTypeSummary、FeeAccountSummary 类型定义
- 添加 getRewardTypeSummaries API 方法
- 添加 FeeAccountSection、RewardTypeSummarySection 组件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 08:54:35 -08:00
hailin bbafe58e86 fix(wallet-service): update init migration memo column to TEXT
Ensure new database installations use TEXT type for memo column from the start.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 08:30:07 -08:00
hailin 069c549bc4 feat(wallet-service): add migration for memo column type change to TEXT
Change wallet_ledger_entries.memo from VARCHAR(500) to TEXT to support longer settlement memos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 08:27:12 -08:00
hailin bf1c8d2228 feat(wallet-service): 实现 Unit of Work 模式保证 settleToBalance 事务原子性
- 新增 UnitOfWork 接口和实现,使用 Prisma Interactive Transaction
- 修改 IWalletAccountRepository 和 ILedgerEntryRepository 接口支持可选事务参数
- 修改仓库实现,支持在事务中执行数据库操作
- 修改 settleToBalance 方法使用 UnitOfWork,确保钱包更新和流水记录原子性
- 注册 UnitOfWorkService 到 InfrastructureModule

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 07:50:02 -08:00
hailin 7dc25b75d2 revert: 回滚 settleToBalance 的直接 Prisma 实现,准备用 Unit of Work 模式重新实现 2026-01-06 07:07:27 -08:00
hailin 4c6e64a604 fix(wallet-service): settleToBalance 添加乐观锁防止并发冲突 2026-01-06 06:56:50 -08:00
hailin 65cb574f59 fix(wallet-service): 添加钱包状态检查,确保只有 ACTIVE 钱包可结算 2026-01-06 06:50:08 -08:00
hailin 5204d24c88 fix(wallet-service): 修复 settleToBalance 方法缺少事务保护的严重 Bug
问题原因:
settleToBalance 方法先执行 wallet.save() 更新账户余额,再执行
ledgerRepo.save() 写入流水记录。两个操作不在同一个事务中。

当流水写入失败时(如 memo 字段超过 VarChar(500) 限制),账户余额
已经被修改,但流水记录未写入,导致数据不一致。

具体案例:
用户 D25122700023 点击结算时,memo 内容超长(66笔奖励详情),
wallet-service 先把 settleable_usdt 转入 usdt_available,然后
写流水失败。账户余额被改但没有对应流水。

修复内容:
1. settleToBalance: 使用 prisma.$transaction 确保原子性
   - 账户余额更新和流水记录在同一事务中
   - 任一操作失败整个事务回滚
2. schema: memo 字段从 VarChar(500) 改为 Text 类型,无长度限制

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 06:40:09 -08:00
hailin 573e58c89b fix(wallet-service): 统一奖励分配到 settleable_usdt,与 reward-service 保持一致
问题原因:
wallet-service 对不同类型奖励的分配方式不一致:
- SHARE_RIGHT: 正确使用 addSettleableReward() → settleable_usdt
- CITY_TEAM_RIGHT/COMMUNITY_RIGHT: 错误使用 addAvailableBalance() → usdt_available

这导致 reward-service 记录的 SETTLEABLE 奖励总额与 wallet-service 的
settleable_usdt 字段不匹配。用户 D25122700024 的案例中:
- reward-service: 3条奖励共 4464 USDT (SHARE_RIGHT 3600 + CITY_TEAM_RIGHT 288 + COMMUNITY_RIGHT 576)
- wallet-service: settleable_usdt = 3600 (仅 SHARE_RIGHT)
差额 864 USDT 被错误地放入了 usdt_available

修复内容:
1. allocateCommunityRight: 改用 addSettleableReward() 替代 addAvailableBalance()
2. allocateToRegionAccount: 改用 addSettleableReward() 替代 addAvailableBalance()
3. 流水类型统一使用 REWARD_TO_SETTLEABLE 替代 SYSTEM_ALLOCATION
4. 日志和备注更新以反映新的分配方式

设计原则:
- reward-service 是奖励的权威来源
- wallet-service 应跟随 reward-service 的设计
- 所有奖励都应进入 settleable_usdt,用户主动结算后才转入 usdt_available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 03:49:31 -08:00
hailin ec71121907 fix(reward-service): 修复 WalletServiceClient 未正确解析 wallet-service 响应格式的 Bug
问题原因:
wallet-service 使用全局 TransformInterceptor 拦截器,会将所有响应包装成:
{ success: true, data: { success: boolean, ... }, timestamp: "..." }

原代码直接读取外层的 success 字段(始终为 true),导致即使业务失败
(内层 data.success = false)也被误判为成功。

具体案例:
用户 D25122700024 点击结算时,wallet-service 因余额不足返回:
{ success: true, data: { success: false, error: "Insufficient..." }, ... }
reward-service 误读为成功,导致奖励被标记为 SETTLED 但钱包余额未变更。

修复内容:
1. settleToBalance: 解析 response_data.data 获取真实业务结果
2. confirmPlantingDeduction: 同上
3. allocateFunds: 同上

所有方法现在会:
- 使用 response_data.data || response_data 兼容包装和非包装格式
- 严格检查 data.success !== true 来判断业务是否成功
- 失败时记录详细错误日志

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 03:38:00 -08:00
hailin 8b80e45524 fix(authorization): 火柴人排名过滤已撤销授权的考核记录
- findRankingsByMonthAndRegion 和 findRankingsByMonthAndRoleType 增加过滤条件
- 排除 authorization.status = 'REVOKED' 的记录
- 解决同一用户因有多条授权记录(含已撤销)而重复显示的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:55:23 -08:00
hailin 5419b15bf1 fix(mobile-app): 已结算数据改为从流水统计API获取
- 从 wallet-service 的 getLedgerStatistics() 获取 REWARD_SETTLED 类型的总金额
- 与流水明细中的结算记录统计来自同一数据源,确保数据一致性
- 添加调试日志对比 summary 和流水统计的数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:38:37 -08:00
hailin 81ad8adf93 fix(mobile-app): 用户资料页术语修改
- 直推 → 引荐
- 伞下 → 同伴
- 个人认种 → 本人认种
- 团队认种 → 同伴认种
- 推荐人 → 引荐人

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:11:36 -08:00
hailin 2a31e1ba6d Revert "feat(mobile-app): 用户资料页添加"同伴认种"标题和快捷标签"
This reverts commit d274444ca9.
2026-01-05 19:06:43 -08:00
hailin d274444ca9 feat(mobile-app): 用户资料页添加"同伴认种"标题和快捷标签
- 在统计卡片上方添加"同伴认种"标题(紫色)
- 在统计卡片下方添加"引荐"、"同伴"、"本人"快捷标签

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:35:21 -08:00
hailin e6da0cbb05 fix(mobile-app): 修复 Token 刷新并发竞态导致的意外过期问题
- 添加 Token 刷新锁,确保多个 401 请求只触发一次刷新
- 添加过期通知去重,避免重复弹出登录过期提示
- 增强 deviceId 校验,缺失时记录日志
- 添加详细调试日志便于排查问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 07:20:31 -08:00
hailin 6c78e22000 fix(authorization): 添加火柴人排名调试日志
添加详细日志显示返回的每条记录的区域信息,便于调试过滤问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 06:23:57 -08:00
hailin bdc6ba524f fix(authorization): 火柴人排名改为按区域过滤
修改排名逻辑,只显示获得相同省/市公司授权的用户排名。

- 后端 getStickmanRanking 改用 findRankingsByMonthAndRegion
- 简化实时创建评估逻辑,只为当前区域创建
- 更新前端注释说明

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 05:56:30 -08:00
hailin 2136b7a144 feat(mobile-app): 添加待办操作轮询机制
解决老版本 App 升级后不重启导致无法激活待办事项的问题。

- 新增 PendingActionPollingService 定时轮询服务(每4秒检查)
- App启动时无待办则启动轮询,有待办则直接进入待办页面
- 轮询检测到待办后自动停止并跳转,防止重入问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 05:36:18 -08:00
hailin 3b3342de5c feat(wallet-service): 添加内部转账入账修复脚本
新增一次性修复脚本用于补录因接收方钱包未创建导致入账失败的内部转账。

脚本特性:
- DRY_RUN 模式:默认只检查不执行,需手动改为 false 才真正修复
- 完整验证:订单状态、类型、接收方信息、txHash
- 幂等性检查:确认接收方没有 TRANSFER_IN 流水
- 转出方验证:确认转出方有 TRANSFER_OUT 流水(已扣款)
- 乐观锁:使用 version 字段防止并发修改
- 审计追踪:payloadJson.dataFix=true 标记修复操作
- 详细日志:每步操作都有时间戳和日志级别

使用方法:
1. 在 wallet-service 容器内执行 DRY_RUN 检查
2. 确认无误后将 DRY_RUN 改为 false 再次执行

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 05:06:27 -08:00
hailin ac0e73afac feat(wallet/blockchain): 热钱包余额预检查及接收方钱包自动创建
1. blockchain-service: 新增热钱包 dUSDT 余额定时更新调度器
   - 每 5 秒查询热钱包在 KAVA 链上的 dUSDT 余额
   - 更新到 Redis DB 0,key 格式: hot_wallet:dusdt_balance:{chainType}
   - TTL 30 秒,服务故障时缓存自动过期

2. wallet-service: 新增热钱包余额缓存服务
   - 从 Redis DB 0 读取热钱包余额缓存
   - 严格模式:无法获取余额或余额不足时拒绝转账
   - 提示信息:"财务系统审计中,请稍后再试"

3. wallet-service: 转账确认时自动创建接收方钱包
   - 解决接收方钱包不存在导致入账失败的问题
   - 使用 upsert 避免并发创建冲突
   - 在同一事务中完成创建和入账

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 04:31:52 -08:00
hailin 191b37a5de fix(admin-web): add null checks to prevent crash in system account report tabs
面对面结算和过期收益Tab在数据为空时会崩溃,添加空值检查修复此问题。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:15:14 -08:00
hailin 66ace25935 fix(reporting): remove userId dependency in planting.order.paid handler
- Change userId to optional in PlantingOrderPaidEvent interface
- Add accountSequence field for user identification
- Remove relatedUserId from activity creation (was causing BigInt error)
- Store accountSequence in metadata instead

Fixes: TypeError: Cannot convert undefined to a BigInt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 00:05:56 -08:00
hailin 0f3c26c6fa fix(admin-web): update account names and change USDT to 绿积分
- Rename account labels:
  - 成本账户 → 总部储备
  - 运营账户 → 运营账户1
  - 总部社区 → 运营账户2
  - RWAD待发放池 → 积分股池
- Change all USDT displays to 绿积分 throughout the system account report
- Add getAssetTypeLabel function for asset type mapping in ledger details

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:58:16 -08:00
hailin 44a1023cdd feat(admin-web): add ledger detail display for system accounts
- Add getAllLedger API method in systemAccountReportService
- Add LedgerEntryDTO, FixedAccountLedger, RegionAccountLedger types
- Add ALL_LEDGER endpoint
- Update SystemAccountsTab with ledger detail tab
- Add expandable card UI for viewing account ledger entries
- Add styles for ledger cards and tables

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:45:17 -08:00
hailin c3c15b7880 fix(wallet-service): remove invalid nested $queryRaw in getOfflineSettlementSummary
删除使用嵌套 $queryRaw 进行条件拼接的错误查询,保留简化版本。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:37:57 -08:00
hailin 49cdeb4aef fix(reporting-service): fix planting.order.paid event message format
planting-service 发送的消息是直接数据格式,不包含 payload 包装,
修正 ActivityEventConsumerController 以适配实际消息格式。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:32:32 -08:00
hailin 229dff1a9d feat(system-accounts): add ledger detail API for all system accounts
新增所有系统账户的分类账明细查询功能:
- wallet-service: 添加 getSystemAccountLedger 和 getAllSystemAccountsLedger 方法
- wallet-service: 添加 /statistics/system-account-ledger 和 /statistics/all-system-accounts-ledger API
- reporting-service: 添加 /all-ledger 端点透传分类账数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:30:38 -08:00
hailin 56f2fd206d fix(reporting-service): extract data from wrapped API response
wallet-service API 返回 { success, data } 格式,需要解析 response.data.data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:20:38 -08:00
hailin 6d5c5f7e4c fix(reporting-service): add /api/v1 prefix to wallet and reward service API calls
修复 reporting-service 调用 wallet-service 和 reward-service 时的 404 错误,
所有内部 HTTP 调用路径添加 /api/v1 全局前缀。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:18:10 -08:00
hailin 838d5c1d3b feat(reporting): fix system account report to use wallet-service data
The system account balances were showing 0 because data was being fetched
from authorization-service.system_accounts table instead of the actual
wallet-service.wallet_accounts table where funds are stored.

Changes:
- wallet-service: Add getAllSystemAccounts() method to query all system
  accounts (fixed S*, province 9*, city 8*) with actual balances
- wallet-service: Add /wallets/statistics/all-system-accounts API endpoint
- reporting-service: Update SystemAccountReportApplicationService to fetch
  data from wallet-service instead of authorization-service
- reporting-service: Fix default service URLs to use correct container names
  and ports (rwa-wallet-service:3001, rwa-reward-service:3005)
- docker-compose: Add WALLET_SERVICE_URL and REWARD_SERVICE_URL env vars
  for reporting-service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 23:10:20 -08:00
hailin 83384ff198 feat(scripts): add system snapshot backup and restore tool
Add comprehensive Docker volume backup/restore script supporting:
- PostgreSQL online logical backup (pg_dumpall)
- Redis BGSAVE triggered backup
- Kafka/Zookeeper volume backup
- Multiple restore options (logical/physical/selective)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:44:29 -08:00
hailin 1c4def2867 feat(kong): add system-account-reports route to reporting-service
Add Kong route for the new system account reports API endpoint
at /api/v1/system-account-reports, forwarding to reporting-service.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:27:55 -08:00
hailin e95316c5f4 fix(authorization-service): register SystemAccountApplicationService in AppModule
Add missing dependency injection for SystemAccountApplicationService
which is required by InternalAuthorizationController for system account
report statistics API.

- Import SystemAccountRepositoryImpl and SYSTEM_ACCOUNT_REPOSITORY
- Register SystemAccountApplicationService as provider
- Register SYSTEM_ACCOUNT_REPOSITORY with SystemAccountRepositoryImpl

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:22:02 -08:00
hailin 6e395ce58c feat(reporting): add system account report aggregation feature
## Changes
- Add system account report aggregation APIs in reporting-service
- Add internal statistics APIs in wallet-service, reward-service, authorization-service
- Add system accounts tab in admin-web statistics page
- Enhanced metadata in reward entries for traceability

## Backend Changes
- wallet-service: Add offline settlement summary and system accounts balances APIs
- reward-service: Add expired rewards summary API
- authorization-service: Add fixed accounts list, region accounts summary APIs
- reporting-service: Add HTTP clients and aggregation service for system account reports

## Frontend Changes
- admin-web: Add SystemAccountsTab component with fixed accounts, region summaries,
  offline settlement stats, and expired rewards display

## Rollback Instructions
Each file includes rollback comments with [2026-01-04] tag marking new additions.
To rollback: delete files marked as new, remove code sections marked with date comments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:06:58 -08:00
hailin 99b2b10ba0 fix(mobile-app): always fetch deposit address from server in deposit_service
Remove local storage cache priority to avoid returning wrong address
after account switching. Always fetch from server API to ensure the
address belongs to the currently logged-in user.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:17:41 -08:00
hailin 04545c86a5 fix(mobile-app): fetch wallet address from server API instead of local storage
The wallet address displayed in long-press mode was incorrectly showing
another user's address from local storage cache. Now fetches the correct
address from the /me API endpoint for the currently logged-in user.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:00:33 -08:00
hailin cb35f21661 feat(mobile-app): improve empty state display for offline settlement deduction
When there are no settlement records to deduct, show a more informative message:
- If user has balance from deposits/transfers: explain it's not from earnings
- If user has no balance: explain there are no settlement records

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 08:18:27 -08:00
hailin 8d97ed2720 fix(wallet-service): convert BigInt to string for JSON serialization in getUnprocessedSettlements
The entry.id field is BigInt type from Prisma which cannot be JSON serialized directly.
Convert to string for API response and back to BigInt when storing to database.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 07:46:46 -08:00
hailin 599e0ba281 refactor(admin-web): default to offline settlement mode for special deduction
Change default mode from "指定金额扣减" to "全额线下结算扣减"
to match batch create behavior where empty/0 amount means offline settlement.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 07:30:33 -08:00
hailin f94083df36 feat(admin-web): support offline settlement in batch create
When batch creating special deductions:
- Amount empty or 0: auto-switch to offline settlement mode
- Amount > 0: normal deduction mode (requires reason)
- Add hint text in batch create modal for special deduction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 07:27:36 -08:00
hailin 21c8f1906a feat(admin-web): integrate planting-service stats API for dashboard
Use planting-service's reliable database aggregation for total planting count
instead of reporting-service's Kafka event-driven statistics.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 07:04:39 -08:00
hailin 251fee4f1e feat(wallet-service): add offline settlement deduction feature
Add new functionality for admins to automatically deduct all settled
earnings when creating special deductions with amount=0, marking
each record to prevent duplicate deductions.

- Add OfflineSettlementDeduction model to track deducted records
- Add API endpoints for querying unprocessed settlements and executing batch deduction
- Add mode selection UI in admin-web pending-actions
- Add offline settlement card display in mobile-app special deduction page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:56:39 -08:00
hailin 46b68e8652 feat(planting-service): add global stats API for data verification
Add new endpoint GET /api/v1/planting/stats/global to query planting
statistics directly from the database, providing reliable data source
for verifying reporting-service statistics.

New features:
- GlobalPlantingStats: total tree count, order count, amount
- StatusDistribution: breakdown by order status (PAID to MINING_ENABLED)
- TodayStats: daily statistics with tree count, order count, amount

Implementation:
- Pure additive changes, no modifications to existing code
- Read-only aggregate queries using Prisma aggregate/groupBy
- No database schema changes required

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:55:08 -08:00
hailin 8148f7a52a fix(leaderboard-service): add @IsIn validator to UpdateLeaderboardSwitchDto
The 'type' field was missing validation decorator, causing 400 Bad Request
when ValidationPipe with forbidNonWhitelisted was enabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 04:11:05 -08:00
hailin aa58b9e745 fix(leaderboard-service): fix AdminGuard role case sensitivity
The AdminAccount table stores roles in lowercase (admin, super_admin),
but AdminGuard was checking for uppercase (ADMIN, SUPER_ADMIN).
This caused 403 Forbidden errors for authenticated admin users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 04:07:23 -08:00
hailin cb59a964dd fix(leaderboard-service): change global prefix from 'api' to 'api/v1'
Match the global prefix convention used by all other services.
This fixes Kong routing 404 errors for /api/v1/leaderboard/* endpoints.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 04:01:09 -08:00
hailin ea93bafe7e fix(leaderboard): add REFERRAL_SERVICE_URL to docker-compose
The leaderboard-service needs to connect to referral-service for
team statistics data. Without this environment variable, it falls
back to localhost:3004 which fails inside Docker network.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:47:00 -08:00
hailin 0d14cc2197 fix(mobile-app): correct leaderboard status API path
The API base URL already includes /api/v1, so the path should be
/leaderboard/status instead of /leaderboard-service/api/v1/leaderboard/status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:45:55 -08:00
hailin dacefa2b51 feat(leaderboard): add toggle control for mobile-app ranking page
- Add public /leaderboard/status endpoint (no auth required)
- Add LeaderboardService in mobile-app to fetch board status
- Update RankingPage to show "待开启" when board is disabled
- Connect admin-web leaderboard page to real API
- Board toggle now takes effect immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:35:57 -08:00
hailin 52afe72f17 fix(authorization): migration should drop both constraint and index
The original migration only used DROP CONSTRAINT which failed silently
because Prisma created an INDEX instead. Added DROP INDEX as well to
handle both cases in future deployments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 03:14:00 -08:00
hailin 0991d5d484 fix(authorization): allow querying REVOKED records despite deletedAt being set
撤销授权时会同时设置 status=REVOKED 和 deletedAt(软删除),
导致 findByStatus(REVOKED) 因为 deletedAt IS NULL 条件永远返回空。
修改为查询 REVOKED 状态时不过滤 deletedAt。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:52:43 -08:00
hailin 5026661fa8 chore(planting): update contract PDF template to release version
Signature field position: x=449.51, y=140.18 (moved further right and up).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:45:28 -08:00
hailin bdc3cdd75e chore(planting): update contract PDF template to v1.2
Moved signature button field further right (x=435.60) and down (y=113.51).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:13:48 -08:00
hailin bc1d4a62c6 fix(authorization): add Transform decorator to parse includeRevoked query param
查询参数都是字符串类型,需要将 'true' 转换为布尔值 true,
否则后端无法正确处理 includeRevoked 参数,导致已撤销的授权记录不显示。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:07:49 -08:00
hailin c8f2d5edff chore(planting): update contract PDF template to v1.1
Updated signature field position to x=427.60 for better alignment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:48:34 -08:00
hailin 0753f036bd fix(admin-web): always fetch all authorization records including revoked
Changed to always include revoked records in API query, filtering is done
on frontend side. This ensures all historical records are visible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:24:48 -08:00
hailin 258aff8bf7 fix(admin-web): update AuthorizationStatus type to use AUTHORIZED
Changed type definition from 'ACTIVE' to 'AUTHORIZED' to match backend API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:21:03 -08:00
hailin f77ecff659 fix(admin-web): use AUTHORIZED instead of ACTIVE for authorization status
The backend returns status as 'AUTHORIZED'/'REVOKED' but frontend was
checking for 'ACTIVE'. Fixed all status comparisons to use correct value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 01:18:35 -08:00
hailin af0b9d38c0 Revert "fix(authorization): exclude revoked records when checking existing authorization"
This reverts commit ec528a7226.
2026-01-04 01:08:28 -08:00
hailin ec528a7226 fix(authorization): exclude revoked records when checking existing authorization
The findByAccountSequenceAndRoleType query now excludes REVOKED status,
allowing users to be re-authorized after their authorization was revoked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:59:37 -08:00
hailin 190bf8257b feat(mobile-app): hide transaction hash in ledger detail page
Hidden txHash display in both transfer details and withdrawal details
as it's not necessary for end users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:51:10 -08:00
hailin 30cb245301 refactor: rename "总部社区" to "总部" across backend services
Changed display name from "总部社区" to "总部" in:
- authorization-service
- identity-service seed
- leaderboard-service seed and entity

Note: Existing database records need manual update if already seeded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:34:36 -08:00
hailin 67c7d9149c fix(planting): move signature field right to avoid overlapping text
Moved the signature field from x=415 to x=470 in the PDF template
to prevent the signature image from covering the "乙方(签字/盖章):" text.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:28:07 -08:00
hailin 4ba86ea618 fix(admin-web): correct API response parsing in authorizationService
The apiClient interceptor already unwraps response.data, so we should
access .data instead of .data.data to get the actual business data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:18:02 -08:00
hailin 16d895d460 debug: add logging to queryAuthorizations 2026-01-04 00:12:43 -08:00
hailin ef6b2ceb22 fix(authorization): show all authorized users in admin list including those in assessment period
Previously used findAllActive() which only returned users with benefitActive=true,
causing users still in assessment period to be hidden. Now uses findByStatus()
to show all AUTHORIZED users regardless of benefit activation status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:04:24 -08:00
hailin f5afb65df8 fix(planting): center signature image on the signature field
Calculate signature position based on field center instead of left-bottom
corner, so the signature image is properly centered within the field area.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:00:13 -08:00
hailin f0f44aeb39 feat(mobile-app): show all nodes in team tree with horizontal scroll
Remove the ellipsis logic that hides nodes when there are too many.
Now all nodes are displayed and users can scroll horizontally to see them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:52:44 -08:00
hailin ef80a2f23b fix(planting): remove signature button field before flatten to avoid gray background
The signature button field has a gray background that covers the drawn
signature image when the form is flattened. Now we remove the signature
field after drawing the signature image to prevent this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:45:44 -08:00
hailin 439dcb95ac feat(mobile-app): rename "社区" to "部门" in profile page and add SPECIAL_DEDUCTION display name
- Change all "社区" labels to "部门" in profile page (所属部门, 上级部门, 下级部门, 部门权益考核, 部门贡献奖励)
- Add SPECIAL_DEDUCTION entry type display name as "面对面结算" in ledger

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:44:16 -08:00
hailin 083c0fd540 fix(planting): draw signature directly on page instead of using form field
The PDF signature field is only 92x51 points, which causes signatures to
appear too small or invisible. Changed to use drawImage() directly on
the page at the field's position with a larger size (150x80 max).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:28:18 -08:00
hailin 5ad21ee097 fix(mobile-app): adjust signature image ratio to match PDF field
The signature image was 600x200 (3:1 ratio) but the PDF signature
field is 92x51 (1.8:1 ratio). This caused the signature to be scaled
down to only 60% of the field height, making it appear too small.

Changed signature image dimensions to 460x255 (~1.8:1) to better
match the PDF field proportions and maximize signature size.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:22:16 -08:00
hailin 50f960ecea fix(authorization): allow admin tokens without accountSequence field
Admin JWT tokens from identity-service don't include the accountSequence
field (only userId, email, role, type). This caused a 400 error with
message "管理员账户序列号不能为空" when admins tried to grant authorizations.

Changes:
- Update AdminUserId value object to make accountSequence optional
- Use 'ADMIN' as default value when accountSequence is not provided
- Update all controller methods to handle optional accountSequence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:57:56 -08:00
hailin 4a3658e770 chore(planting): update contract PDF template to v1
更新认种合同PDF模板为v1版本

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:43:15 -08:00
hailin 825b80b319 fix(planting): match PDF form field names to template
修改代码中的表单字段名以匹配PDF模板中的实际字段名:
- totalAmount → RmbAmount
- totalAmountChinese → SpellChineseFormatNumber
- greenPointsAmount → GreenAmount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:33:14 -08:00
hailin 1345b97303 feat(authorization): implement grant authorization functionality
在授权管理页面实现创建授权功能:
- 导入所有授权创建 hooks (社区/省公司/市公司/省团队/市团队)
- 添加 extractUserId 函数从 accountSequence 提取 userId (去掉首字母)
- 实现 handleCreate 函数根据授权类型调用对应 API
- 添加创建过程中的加载状态显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:21:39 -08:00
hailin 9c17140b33 feat(contract): update contract template with amount fields
更新合同模板和 PDF 生成服务,支持动态计算金额字段。

## 合同模板更新
- 替换为新版联合种植协议模板(3页,带公章)
- 新增表单字段:totalAmount、totalAmountChinese、greenPointsAmount

## PDF 生成服务更新
- 新增单价常量:
  - PRICE_PER_TREE_CNY = 17414.1(人民币含税价)
  - PRICE_PER_TREE_GREEN_POINTS = 15831(绿积分价格)
- 新增 numberToChineseAmount() 函数:数字转中文大写金额
- 更新 ContractPdfData 接口:新增可选字段 totalAmount、greenPointsAmount
- 更新 fillFormFields():根据认种棵数自动计算金额
- 移除坐标定位填充方式,仅使用表单字段方式
- 所有表单字段现为必需,缺少时抛出明确错误

## 金额计算逻辑
- 人民币金额 = 棵数 × 17414.1
- 绿积分金额 = 棵数 × 15831
- 大写金额自动生成(如:壹万柒仟肆佰壹拾肆元壹角)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:21:37 -08:00
hailin 17b9c09381 feat(ledger): add detailed ledger entry views with source tracking
实现账本流水详情功能,支持点击查看各类型流水的详细信息。

## reward-service 后端

### 数据库
- 新增 `source_account_sequence` 字段到 `reward_ledger_entries` 表
- 添加索引 `idx_source_account_seq` 提升查询性能
- 字段可空,兼容历史数据

### 领域层
- `RewardSource` 值对象新增 `sourceAccountSequence` 属性
- `RewardCalculationService` 传递 `sourceAccountSequence`

### 应用层
- 新增 `getSettlementHistory` 方法查询结算历史
- 新增 `SettlementRecordRepository` 仓储实现

### API层
- 新增 `GET /settlements/history` 接口
- 新增 `SettlementHistoryQueryDTO` 和 `SettlementHistoryDTO`

## mobile-app 前端

### 服务层
- `RewardService` 新增结算历史相关模型和方法:
  - `SettlementHistoryItem` 结算记录模型
  - `SettlementRewardEntry` 关联奖励条目模型
  - `getSettlementHistory()` 获取结算历史

- `WalletService` 新增:
  - `LedgerEntry.payloadJson` 字段及辅助方法
  - `counterpartyAccountSequence` 获取转账对手方ID
  - `counterpartyUserId` 获取转账对手方用户ID
  - `transferFee` 获取转账手续费

### 账本详情页
- 结算流水详情:显示结算金额、币种、涉及奖励明细(含来源用户)
- 提现流水详情:显示提现订单信息、状态、手续费等
- 转账流水详情:显示转入来源/转出目标用户信息

### 交互优化
- REWARD_SETTLED、WITHDRAWAL、TRANSFER_IN、TRANSFER_OUT 类型可点击
- 使用底部弹窗展示详情,支持滚动查看长列表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:09:17 -08:00
hailin 35a812c058 feat(authorization): add admin authorization management API and real data integration
Backend (authorization-service):
- Add QueryAuthorizationsDto for query parameters (roleType, keyword, includeRevoked, page, limit)
- Add queryAuthorizations method to fetch all authorizations with user info
- Add GET /admin/authorizations endpoint for listing authorizations
- Add POST /admin/authorizations/:id/revoke endpoint for revoking authorization

Frontend (admin-web):
- Add authorization.types.ts with RoleType, Authorization, and request types
- Add authorizationService.ts for API calls (list, revoke, grant operations)
- Add useAuthorizations.ts React Query hooks
- Update authorization page to use real API data instead of mock data
- Add loading/error states, pagination, and revoke reason display
- Add new styles for loading, error, pagination, and date columns

The authorization management page now displays all authorized users
from the database with support for filtering by role type, status,
and keyword search.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:50:10 -08:00
hailin e08959263a fix(mobile-app): 修复待办操作完成后无法正确返回的问题
问题:
- 合同签署成功后使用 context.go('/profile') 直接跳转
- 导致从待办操作或待签合同列表 push 进来时收不到返回值
- 认种向导完成后同样使用 go 跳转,不会返回

修复:
1. contract_signing_page.dart
   - 签署成功后检查 canPop() 判断是否可以返回
   - 如果可以 pop(从待办操作/待签列表进入),返回 pop(true)
   - 否则(从认种流程/KYC流程进入),跳转 go('/profile')

2. pending_actions_page.dart
   - ADOPTION_WIZARD 添加后置检查(通过待签合同判断认种是否完成)
   - 在 _checkIfAlreadyCompleted 中添加 ADOPTION_WIZARD 检查逻辑

兼容性:
- 不影响正常的认种流程(使用 go 进入合同签署)
- 不影响 KYC 流程(使用 go 进入合同签署)
- 待签合同列表页面正常工作

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:21:26 -08:00
hailin d81e230639 refactor(admin-web): 简化授权管理页面,独立共管钱包功能
将授权管理页面的共管钱包功能独立成单独页面,并简化授权管理页面:

授权管理页面简化:
- 移除共管钱包部分(已独立)
- 移除后端不支持的复杂配置表单(考核规则、阶梯目标等)
- 保留核心功能:授权列表、筛选、创建授权、撤销授权
- 添加创建授权对话框(用户+类型+地区+跳过考核期)
- 添加撤销授权对话框(带原因输入)
- 支持5种授权类型:社区、省团队、正式省公司、市团队、正式市公司

共管钱包独立页面:
- 新建 /co-managed-wallet 页面
- 复用现有 CoManagedWalletSection 组件
- 侧边栏添加"共管钱包"菜单项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 18:09:19 -08:00
hailin dcd6f2ce18 fix: 修复特殊扣减API路径和批量创建用户ID解析问题
1. mobile-app: 修复特殊扣减API路径重复问题
   - 将 /api/v1/wallets/special-deduction/execute 改为 /wallets/special-deduction/execute
   - 因为 ApiClient baseURL 已包含 /api/v1 前缀

2. admin-web: 批量创建待办操作支持中文逗号分隔
   - 正则表达式从 /[\n,]/ 改为 /[\n,,]/
   - 同时支持换行、英文逗号、中文逗号作为分隔符

3. identity-service: 添加用户查找调试日志
   - 在 findUserByIdOrSequence 方法中添加日志
   - 便于排查用户ID查找失败的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:54:27 -08:00
hailin d5fee8d8c6 feat(trading): enable one-click settlement button
开放兑换页面的"一键结算"功能:

- 有可结算收益时:显示"一键结算",按钮可点击(金色)
- 无可结算收益时:显示"暂无可结算收益",按钮禁用(半透明)
- 结算中:显示加载动画,防止重复点击
- 使用 rewardService.settleToBalance() API 执行结算
- 结算成功后自动刷新页面数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 07:38:57 -08:00
hailin dfdd8ed65a feat(pending-actions): add special deduction feature for admin-created user actions
实现特殊扣减功能,允许管理员为用户创建扣减待办操作,由用户在移动端确认执行。

## 后端 (wallet-service)

### 领域层
- 新增 `SPECIAL_DEDUCTION` 到 LedgerEntryType 枚举
  用于记录特殊扣减的账本流水类型

### 应用层
- 新增 `executeSpecialDeduction` 方法
  - 验证用户钱包存在性
  - 检查余额是否充足
  - 乐观锁控制并发
  - 扣减余额并记录账本流水
  - 返回操作结果和新余额

### API层
- 新增内部API: POST /api/v1/wallets/special-deduction/execute
  供移动端调用执行特殊扣减操作

## 前端 (admin-web)

### 类型定义
- 新增 `SPECIAL_DEDUCTION` 到 ACTION_CODES
- 新增 `SpecialDeductionParams` 接口定义扣减参数
  - amount: 扣减金额
  - reason: 扣减原因

### 页面
- 更新待办操作管理页面
  - 当选择 SPECIAL_DEDUCTION 时显示扣减金额和原因输入框
  - 验证扣减金额必须大于0
  - 验证扣减原因不能为空

### 样式
- 新增特殊扣减表单区域样式

## 前端 (mobile-app)

### 服务层
- 新增 `executeSpecialDeduction` 方法到 WalletService
- 新增 `SpecialDeductionResult` 结果类
- 新增 `specialDeduction` 到 PendingActionCode 枚举

### 页面
- 新增 `SpecialDeductionPage` 特殊扣减确认页面
  - 显示扣减金额和管理员备注
  - 显示当前余额和扣减后余额
  - 余额不足时禁用确认按钮
  - 温馨提示说明操作性质

- 更新 `PendingActionsPage`
  - 处理 SPECIAL_DEDUCTION 类型的待办操作
  - 从 actionParams 解析 amount 和 reason
  - 导航到特殊扣减确认页面

## 工作流程

1. 管理员在 admin-web 创建 SPECIAL_DEDUCTION 待办操作
   - 选择目标用户
   - 输入扣减金额
   - 输入扣减原因

2. 用户在 mobile-app 待办操作列表看到该操作

3. 用户点击后进入特殊扣减确认页面
   - 查看扣减详情
   - 确认余额充足
   - 点击确认执行扣减

4. 后端执行扣减并记录账本流水

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 07:04:46 -08:00
hailin a609600cd8 feat(fiat-withdrawal): add complete fiat withdrawal system
实现完整的法币提现功能,支持银行卡、支付宝、微信三种收款方式。
此功能与现有的区块链划转功能完全独立,互不影响。

## 后端 (wallet-service)

### 数据库
- 新增 `fiat_withdrawal_orders` 表存储法币提现订单
- 与现有 `withdrawal_orders` 表(区块链划转)完全分离
- 添加完整索引支持高效查询

### 领域层
- 新增 `FiatWithdrawalStatus` 枚举(与 WithdrawalStatus 独立)
  - 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED
  - 或 REJECTED / FAILED / CANCELLED
- 新增 `PaymentMethod` 枚举: BANK_CARD / ALIPAY / WECHAT
- 新增 `FiatWithdrawalOrder` 聚合根
- 新增 `IFiatWithdrawalOrderRepository` 仓储接口
- 新增 `FIAT_WITHDRAWAL` 账本流水类型

### 应用层
- 新增 `FiatWithdrawalApplicationService` 处理业务逻辑
  - 发送短信验证码
  - 申请法币提现(冻结余额)
  - 提交审核
  - 审核通过/驳回
  - 开始打款
  - 完成打款

### API层
- 新增 `FiatWithdrawalController` 提供用户端API
  - POST /wallet/fiat-withdrawal/send-sms - 发送验证码
  - POST /wallet/fiat-withdrawal - 申请提现
  - GET /wallet/fiat-withdrawal - 获取提现记录
- 新增内部API供管理端调用
  - GET /api/v1/wallets/fiat-withdrawals - 查询订单
  - POST /api/v1/wallets/fiat-withdrawals/:orderNo/review - 审核
  - POST /api/v1/wallets/fiat-withdrawals/:orderNo/start-payment - 开始打款
  - POST /api/v1/wallets/fiat-withdrawals/:orderNo/complete-payment - 完成打款

## 前端 (admin-web)

- 新增法币提现审核管理页面 `/withdrawals`
- 支持按状态分 Tab 查看订单
- 支持审核通过/驳回
- 支持打款操作
- 支持查看订单详情

## 前端 (mobile-app)

- 新增 `WithdrawFiatPage` 法币提现页面
  - 支持选择银行卡/支付宝/微信
  - 输入收款账户信息
- 新增 `WithdrawFiatConfirmPage` 确认页面
  - 短信验证码验证
  - 密码验证
- 在 `WalletService` 中添加法币提现相关方法和模型

## 重要说明

此功能与现有的区块链划转功能 (withdraw_usdt_page.dart) 完全独立:
- 独立的数据库表
- 独立的聚合根
- 独立的状态枚举
- 独立的API端点
- 独立的前端页面

原有的区块链划转功能保持不变,不受任何影响。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 06:39:11 -08:00
hailin d614d18e97 Revert "feat(withdrawal): implement fiat withdrawal with bank/alipay/wechat"
This reverts commit 288d894746.
2026-01-03 05:44:43 -08:00
hailin 288d894746 feat(withdrawal): implement fiat withdrawal with bank/alipay/wechat
Add complete fiat withdrawal feature that allows users to withdraw
green credits (绿积分) to their bank card, Alipay, or WeChat account
with 1:1 CNY conversion. Key changes:

Backend (wallet-service):
- Update Prisma schema with fiat withdrawal fields (paymentMethod,
  bankName, bankCardNo, cardHolderName, alipay*, wechat*, review fields)
- Rewrite withdrawal status enum for fiat flow: PENDING → FROZEN →
  REVIEWING → APPROVED → PAYING → COMPLETED (or REJECTED/FAILED)
- Add PaymentMethod enum: BANK_CARD, ALIPAY, WECHAT
- Update WithdrawalOrderAggregate with new fiat withdrawal methods
- Add review/payment workflow methods in WalletApplicationService
- Add internal API endpoints for admin withdrawal management
- Remove blockchain withdrawal event handler (no longer needed)

Frontend (admin-web):
- Add withdrawal review management page at /withdrawals
- Add tabs for reviewing/approved/paying order states
- Add withdrawal service and React Query hooks
- Add types for withdrawal orders and payment methods
- Add sidebar menu item for withdrawal review

Frontend (mobile-app):
- Add withdrawFiat() method to WalletService
- Add PaymentMethod enum with BANK_CARD/ALIPAY/WECHAT
- Create new WithdrawFiatPage for fiat withdrawal input
- Create WithdrawFiatConfirmPage with SMS + password verification
- Add routes for /withdraw/fiat and /withdraw/fiat/confirm
- Keep existing withdraw/usdt (划转) pages unchanged

Note: The existing withdraw_usdt_page.dart is for point-to-point
transfer (划转), which is a different feature from fiat withdrawal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 05:28:05 -08:00
hailin 036696878f feat(settlement): implement settle-to-balance with detailed source tracking
Add complete settlement-to-balance feature that transfers settleable
earnings directly to wallet USDT balance (no currency swap). Key changes:

Backend (wallet-service):
- Add SettleToBalanceCommand for settlement operations
- Add settleToBalance method to WalletAccountAggregate
- Add settleToBalance application service with ledger recording
- Add internal API endpoint POST /api/v1/wallets/settle-to-balance

Backend (reward-service):
- Add settleToBalance client method for wallet-service communication
- Add settleRewardsToBalance application service method
- Add user-facing API endpoint POST /rewards/settle-to-balance
- Build detailed settlement memo with source user tracking per reward

Frontend (mobile-app):
- Add SettleToBalanceResult model class
- Add settleToBalance() method to RewardService
- Update pending_actions_page to handle SETTLE_REWARDS action
- Add completion detection via settleableUsdt balance check

Settlement memo now includes detailed breakdown by right type with
source user accountSequence for each reward entry, e.g.:
  结算 1000.00 绿积分到钱包余额
  涉及 5 笔奖励
    - SHARE_RIGHT: 500.00 绿积分
        来自 D2512120001: 288.00 绿积分
        来自 D2512120002: 212.00 绿积分

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 04:29:38 -08:00
hailin cbbef170e8 feat(pending-actions): display accountSequence alongside userId
- Add accountSequence field to PendingActionResponseDto
- Add helper methods to fetch accountSequence from UserAccount
- Update queryActions and getAction to include accountSequence
- Update admin-web table and detail view to show both fields
- accountSequence displayed prominently, userId shown as secondary info

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:33:03 -08:00
hailin 13dd42d2be fix(mobile-app): fix pending action completion detection
- Change FORCE_KYC check from isCompleted to level1.verified
  (FORCE_KYC only requires real-name verification, not all KYC levels)
- Add post-navigation re-check for FORCE_KYC and BIND_PHONE actions
  (handles cases where user completes action but page doesn't return true)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:09:35 -08:00
hailin c5c4e1667e fix(mobile-app): fix layout constraint error in pending actions page
Wrap ElevatedButton in SizedBox(width: 72) to prevent
BoxConstraints infinite width error in Row layout.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 21:02:15 -08:00
hailin f5f0ff2822 fix(mobile-app): correctly parse nested API response for pending actions
The API returns a nested structure {success, data: {code, data: [...]}}
but the service was only checking for {actions: [...]} format.

Now correctly extracts the actions list from data.data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:57:17 -08:00
hailin f7913cd04e chore: temporarily disable KYC and contract check logs
Comment out debugPrint statements in pending actions and contract
check services to reduce log noise during development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:51:13 -08:00
hailin 789d921fd7 fix(pending-actions): support accountSequence for user lookup
Allow admin to create pending actions using accountSequence (e.g.,
D25122700022) instead of requiring numeric userId.

- Add findUserByIdOrSequence helper method
- Update createAction to use helper
- Update batchCreateActions to use helper
- Update queryActions to support accountSequence filter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:39:54 -08:00
hailin 47a7e4a4da feat(pending-actions): enhance multi-select creation and add pre-check
Admin Web:
- Redesign create modal to support multi-select action types
- Add drag-and-drop ordering for execution sequence
- Auto-calculate priority based on order (first = highest)
- Add @dnd-kit dependencies for sortable functionality

Flutter Mobile App:
- Add pre-check logic before executing pending actions
- Auto-complete FORCE_KYC if KYC already verified
- Auto-complete BIND_PHONE if phone already bound
- Skip unnecessary user interactions for completed tasks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:23:15 -08:00
hailin 06d3489b49 fix(admin-web): fix nested data access in pendingActionService
API returns nested structure: { success, data: { code, message, data: {...} } }
After apiClient interceptor unwraps response.data, we still need to access
.data.data to get the actual business data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:18:54 -08:00
hailin ed463d67ab fix(admin-web): fix API response data access in pendingActionService
The apiClient interceptor already unwraps response.data, so the service
was accessing .data on the already-unwrapped response. Fixed by properly
casting the response type to access the nested data field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:08:57 -08:00
hailin 8c8a049f77 fix(admin-web): handle undefined data in dashboard hooks
Add null-safe access and fallback to empty arrays to prevent
"Cannot read properties of undefined" errors when API returns
unexpected data structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:05:54 -08:00
hailin 2e7de8a1ef feat(contracts): add DurianUSDT ERC-20 token contract
Add fixed-supply ERC-20 token contract for Durian USDT (dUSDT):
- Total supply: 1 trillion tokens with 6 decimals
- No minting capability - all tokens minted at deployment
- Includes compile and deploy scripts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:02:19 -08:00
hailin 582e80b750 fix(pending-actions): add @Public() decorator to AdminPendingActionController
Skip JWT auth for admin pending-actions endpoints since admin-web
authenticates through a different mechanism (admin-service tokens).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:00:24 -08:00
hailin ff038f31f9 fix(pending-actions): fix API response handling and add Kong route
- Fix pending_action_service.dart to access response.data instead of response
- Add Kong route for /api/v1/admin/pending-actions to identity-service

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:33:58 -08:00
hailin 28e0396a65 feat(pending-actions): add user pending actions system
Add a fully optional pending actions system that allows admins to configure
specific tasks that users must complete after login.

Backend (identity-service):
- Add UserPendingAction model to Prisma schema
- Add migration for user_pending_actions table
- Add PendingActionService with full CRUD operations
- Add user-facing API (GET list, POST complete)
- Add admin API (CRUD, batch create)

Admin Web:
- Add pending actions management page
- Support single/batch create, edit, cancel, delete
- View action details including completion time
- Filter by userId, actionCode, status

Flutter Mobile App:
- Add PendingActionService and PendingActionCheckService
- Add PendingActionsPage for forced task execution
- Integrate into splash_page login flow
- Users must complete all pending tasks in priority order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:22:51 -08:00
hailin 04a8c56ad6 fix(identity): use correct Aliyun API for ID card verification
Change API from Id2MetaStandardVerify to Id2MetaVerify for two-factor
identity verification (name + ID card number). The previous API was
returning error 440 (no permission).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:05:08 -08:00
hailin e2cf3c3d7e fix(admin-service): 修复通知查询时publishedAt为null的问题
问题:当 publishedAt 为 NULL(表示立即发布)时,Prisma 的
`publishedAt: { lte: now }` 条件不匹配,导致通知无法显示

修复:将查询条件改为 OR 逻辑:
- publishedAt 为 null(立即发布)
- publishedAt <= now(定时发布且已到时间)

影响的方法:
- findNotificationsForUser
- countUnreadForUser
- markAllAsRead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 05:56:43 -08:00
hailin fea0b42223 fix(admin-service): 修复维护拦截器路径检测和错误处理
问题:添加系统维护检测后站内通知功能失效

修复:
1. 使用 request.url 获取完整路径(包含 /api/v1 前缀)
2. 同时支持带前缀和不带前缀的路径检测
3. 添加 try-catch 错误处理,数据库错误时放行请求而非阻断
4. 添加日志记录便于调试

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 05:01:32 -08:00
hailin c392142562 feat(blockchain): 切换到dUSDT(绿积分)合约 - KAVA主网
合约信息:
- 地址: 0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
- 名称: Durian USDT (dUSDT)
- 精度: 6位
- 网络: KAVA EVM Mainnet (Chain ID: 2222)
- 链接: https://kavascan.com/address/0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3

修改:
- blockchain.config.ts: 更新默认合约地址
- chain-config.service.ts: 更新默认合约地址
- docker-compose.yml: NETWORK_MODE改为mainnet,配置KAVA主网
- .env.example: 更新合约地址和注释
- KAVA_NETWORK.md: 标注dUSDT为当前使用合约

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 04:31:11 -08:00
hailin 8173e1f973 feat: "同僚"改为"同伴" + KYC从三要素改为二要素
mobile-app:
- profile_page.dart: 将所有"同僚"改为"同伴"

identity-service:
- 层级1实名认证从三要素(姓名+身份证+手机号)改为二要素(姓名+身份证号)
- 使用阿里云 Id2MetaStandardVerify API
- 二要素验证直接调用真实API,不使用mock
- 保留三要素验证方法(verifyIdCardThreeFactor)备用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 04:20:42 -08:00
hailin 47e4ef2b33 feat(android): add share export and import functionality
Add ability to backup wallet shares to files and restore from backups:

- Add ShareBackup data class in Models.kt for backup format
- Add exportShareBackup() and importShareBackup() in TssRepository
- Add export/import state and methods in MainViewModel
- Add file picker integration in MainActivity using ActivityResultContracts
- Add import FAB button in WalletsScreen
- Export saves as .tss-backup file with address and timestamp in filename
- Import validates backup format and checks for duplicate wallets

The backup file contains all necessary data to restore a wallet share:
sessionId, publicKey, encryptedShare, threshold, partyIndex, address.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 03:35:37 -08:00
hailin 9f33e375d0 fix(android): add @OptIn annotation for experimental FilterChip API
Add @OptIn(ExperimentalMaterial3Api::class) to TransferInputScreen
composable to fix compilation error for FilterChip and FilterChipDefaults.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 03:06:32 -08:00
hailin 9b9612bd5f feat(token): add Green Points (绿积分) ERC-20 token support
Add support for the dUSDT token "绿积分" (Green Points) on both Android
and Electron applications:

Android changes:
- Add TokenType enum and GreenPointsToken config in Models.kt
- Implement ERC-20 balance fetching and transfer encoding in TssRepository
- Update TransactionUtils with ERC-20 transfer support
- Add dual balance display (KAVA + 绿积分) in WalletsScreen
- Add token type selector in TransferScreen

Electron changes:
- Add TokenType and GREEN_POINTS_TOKEN config in transaction.ts
- Implement fetchGreenPointsBalance and ERC-20 transfer encoding
- Update Home.tsx with dual balance display and token selector
- Add token selector styles in Home.module.css

Token contract: 0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3 (Kava mainnet)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 03:01:05 -08:00
hailin b3822e48eb fix(android): decode Base64 signature before broadcasting transaction
The TSS native bridge returns signatures in Base64 format, but the
broadcast function expected hex format. Added Base64 decoding in
broadcastTransaction() to properly parse r, s, v components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:44:44 -08:00
hailin 2365a50b1b feat(tss): add real-time round progress from msg.Type() parsing
Extract current round number from tss-lib message type string using
regex pattern `Round(\d+)`. This enables real-time progress updates
(1/4, 2/4... for keygen, 1/9, 2/9... for signing) instead of only
showing completion status.

Changes across all three platforms:
- tss-wasm/main.go: Add extractRoundFromMessageType() and call
  OnProgress with parsed round on each outgoing message
- service-party-android/tsslib/tsslib.go: Same implementation for
  Android gomobile binding
- service-party-app/tss-party/main.go: Same implementation for
  Electron subprocess, with isKeygen parameter to distinguish
  keygen (4 rounds) vs signing (9 rounds)

Safe fallback: Returns 0 if parsing fails, which doesn't affect
protocol execution - only UI display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:41:51 -08:00
hailin f8de55e671 fix(android): reset isLoading after signing completes to enable broadcast button
The broadcast button was disabled because isLoading remained true after
signing completed. Added isLoading = false reset in startSigningProcess
after waitForSignature succeeds or fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:21:30 -08:00
hailin 001f0ac480 fix(android): remove 0x prefix from messageHash before TSS sign
TSS native library expects pure hex string without 0x prefix.
Fix both startSigning (initiator) and executeSignAsJoiner (joiner) functions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:04:18 -08:00
hailin 0bd764e1d1 fix(android): ensure session event subscription active before creating sign session
Add ensureSessionEventSubscriptionActive() call at the start of createSignSession()
to prevent race condition where session_started event arrives before subscription
is ready. Also add debug logging for _signSessionId and pendingSignInitiatorInfo
in event callback to help diagnose sign initiator event matching issues.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:45:18 -08:00
hailin ecd7a2a2dc fix(android): clear pendingSessionId in resetSessionStatus to fix stale session matching
The resetSessionStatus() function was not clearing pendingSessionId,
causing events from new sessions to be ignored because pendingSessionId
still held the old session ID.

Added:
- Clear pendingSessionId = null in resetSessionStatus()
- Clear _currentSession.value = null in resetSessionStatus()
- Added debug logging for session state clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:11:24 -08:00
hailin e865153e8e fix(android): refresh session event subscription when joining sign session
The session event gRPC stream may silently disconnect without triggering
onError or onCompleted callbacks. This causes session_started events to
be lost, preventing the sign process from starting.

Changes:
- Add ensureSessionEventSubscriptionActive() to refresh event subscription
- Call it in joinSignSessionViaGrpc for sign joiner
- Call it in createSignSession for sign initiator after auto-join

This ensures a fresh event stream connection before waiting for events.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:47:13 -08:00
hailin da76037d04 fix(tss-wasm): correct signing rounds from 6 to 9
GG20 signing protocol has 9 rounds, not 6. This aligns WASM with
Electron (tss-party/main.go:717) and Android (tsslib/tsslib.go:477).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:24:47 -08:00
hailin 16e1e9159c fix(android): sign initiator event handling to match Electron flow
Changes:
- Add sign initiator handling for participant_joined events in MainViewModel
- Add sign initiator handling for session_started events in MainViewModel
- Add sign initiator handling for all_joined events in MainViewModel
- Set pendingSessionId in TssRepository.createSignSession for event matching
- Refactor initiateSignSession to wait for session_started instead of starting immediately
- Add PendingSignInitiatorInfo data class to store pending sign info
- Add sessionAlreadyInProgress flag to SignSessionResult for immediate trigger case

This fixes the issue where sign initiator couldn't detect when other parties
joined the signing session, making Android flow 100% consistent with Electron.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:14:32 -08:00
hailin fd56de5c00 fix(android): enable real-time progress updates for keygen/sign rounds
Connect TssNativeBridge.progress Flow to UI through:
- Add progressCallback in TssRepository with startProgressCollection/stopProgressCollection
- Subscribe to native bridge progress in keygen and sign methods
- Add setProgressCallback in MainViewModel to update appropriate round state
- Progress now flows: Go Native → TssNativeBridge → TssRepository → MainViewModel → UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:45:31 -08:00
hailin 3576af0f25 feat(android): add 5-minute countdown timer UI for keygen/sign sessions
Displays remaining time during the 5-minute polling timeout:
- Shows countdown in CreateWalletScreen (SessionScreen)
- Shows countdown in JoinKeygenScreen (JoiningScreen, KeygenProgressScreen)
- Shows countdown in CoSignJoinScreen (JoiningScreen, SigningProgressScreen)
- Format: mm:ss with Timer icon in tertiary container card
- Countdown starts on all_joined event and stops on session start/cancel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:33:20 -08:00
hailin b30017f3a7 fix(android): prevent memory leaks from detached coroutine scopes
Critical fixes to prevent app crashes when screens are kept open for extended periods:

- Add repositoryScope with SupervisorJob for structured concurrency in TssRepository
- Replace detached CoroutineScope(Dispatchers.IO).launch with repositoryScope.launch:
  - Session event subscription (line 206)
  - Session status polling (line 291)
  - Message routing (line 1508)
- Add cleanup() method to properly cancel all jobs and repositoryScope
- Update disconnect() to also cancel sessionStatusPollingJob
- Update MainViewModel.onCleared() to call repository.cleanup()

This ensures all background coroutines are properly cancelled when the ViewModel
is cleared, preventing memory accumulation over time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 19:05:13 -08:00
hailin fc86af918f feat(android): add 5-minute polling timeout mechanism for keygen/sign
Implements Electron's checkAndTriggerKeygen() polling fallback:
- Adds polling every 2 seconds with 5-minute timeout
- Triggers keygen/sign via synthetic session_started event on in_progress status
- Handles gRPC stream disconnection when app goes to background
- Shows timeout error in UI via existing error mechanism

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:51:02 -08:00
hailin ad79679ee2 feat(ui): add QR code display for invite code in signing session
Android TransferScreen:
- Add QR code display above invite code text
- Import QRCodeWriter and related components
- Add generateInviteQRCode helper function
- Update hint text to mention scanning

Electron CoSignSession:
- Import QRCodeSVG from qrcode.react
- Add QR code above invite code text with proper styling
- Center QR code and update hint text

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:37:18 -08:00
hailin ed55be2b86 fix(android): transfer flow improvements and message_hash format fix
Transfer Screen improvements:
- Add QR code scanning for recipient address (using zxing library)
- Support EIP-681 URI format (ethereum:0x...) and plain address
- Remove password requirement - TSS wallets don't need passwords
- Remove unused onScanQrCode callback parameter

WalletsScreen changes:
- Simplify onTransfer callback to only pass shareId
- Remove TransferDialog - now navigates directly to TransferScreen
- Remove unused state variables (showTransferDialog, transferWallet)

Bug fix:
- Remove 0x prefix from message_hash before sending to API
- Backend expects pure hex, not 0x-prefixed hex string

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:18:29 -08:00
hailin 480251b85f fix(android): remove incorrect Participant import
Participant class is already imported via domain.model.* wildcard import,
no need for separate import from data.repository (where it doesn't exist).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:03:31 -08:00
hailin ee1cfe082d fix(android): resolve compilation errors for walletName and Participant
- TssRepository: Use address-based wallet name since ShareRecordEntity
  doesn't have wallet_name field (unlike Electron's ShareRecord)
- MainViewModel: Add missing Participant import and simplify type reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:00:36 -08:00
hailin 04eeadf7a7 fix(android): co-sign flow consistency with Electron + state reset
Changes:
- Fix Android state not resetting after successful keygen/join
  - Add resetSessionStatus() method in TssRepository
  - Call reset on success navigation in MainActivity

- Make Android co-sign flow 100% consistent with Electron:
  - Get keygen session status for participants list
  - Filter out co-managed-party-* (server backup parties)
  - Auto-join via gRPC after creating sign session
  - Start message routing BEFORE signing (prepareForSign)
  - Use gRPC response partyIndex instead of local share
  - Use original keygen thresholdN instead of signingParties.size
  - Pass parties list in join sign flow

- Update SignSessionInfoResponse to include parties array
- Update validateSignInviteCode to parse parties from API response

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:52:13 -08:00
hailin 7346b3518a fix(electron): auto-navigate to home after keygen completion
Previously, after keygen completed, the Session page would just update the
status to 'completed' but not navigate away. Users had to manually click
the "Return Home" button. This could result in a white screen if the button
wasn't visible or clickable.

Now the page auto-navigates to home after 2 seconds, giving users time to
see the completion status and public key before redirecting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:22:42 -08:00
hailin 549b21f298 fix(message-router): prevent subscription race condition on gRPC reconnect
When a party re-subscribes (e.g., Android reconnects), the old gRPC stream's
defer Unsubscribe() was accidentally removing the NEW subscription from the
subscribers map, causing the party to miss session_started events.

Fix:
- Subscribe() now returns the channel to the caller
- Unsubscribe() now takes the channel and only removes if it matches
- This prevents older streams from removing newer subscriptions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 10:04:11 -08:00
hailin b7fc488dcf feat(android): add persistent partyId storage matching Electron behavior
- Add AppSettingEntity and AppSettingDao to Database.kt for key-value storage
- Add database migration (version 1 → 2) to create app_settings table
- Modify TssRepository.registerParty() to load/create partyId from database
- PartyId is now persisted across app restarts, matching Electron's getOrCreatePartyId()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:50:30 -08:00
hailin e2451874ea fix(android): add logging for session event subscription debugging
- Add warning log when parties miss session event broadcast (message-router)
- Add logging for subscribeSessionEvents to detect null asyncStub
- Add sessionStatusPollingJob field for future fallback polling mechanism

This helps diagnose why Android parties are not receiving session_started events.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:23:38 -08:00
hailin eb3f71fa2e fix(android): fix session_started event race condition with pendingSessionId
Problem:
- Android initiator/joiner could miss session_started events due to race condition
- Events arriving between joinSession() and _currentSession.value assignment were ignored
- This caused keygen timeout because parties never started the TSS protocol

Solution:
- Add pendingSessionId field set BEFORE joinSession() call
- Modify startSessionEventSubscription() to match events against both activeSession and pendingSessionId
- Clear pendingSessionId on session completion, failure, or cancellation

This ensures session_started events are correctly processed even if they arrive
before _currentSession is fully initialized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 09:09:52 -08:00
hailin cc56b8fadf fix(android): fetch session status after creation to show all participants
- Add getPartyId() method to TssRepository
- Call getSessionStatus after createKeygenSession to fetch all participants
  including server-party-co-managed that have already auto-joined
- This matches Electron's behavior of calling getSessionStatus on session page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:51:49 -08:00
hailin f305a8cd97 feat(session): broadcast participant_joined event via gRPC for real-time UI updates
Backend changes (session-coordinator):
- Add PublishParticipantJoined method to JoinSessionMessageRouterClient interface
- Implement PublishParticipantJoined in MessageRouterClient to broadcast events
- Call PublishParticipantJoined in join_session.go after participant joins
- Add detailed logging for debugging event broadcast

Android changes (service-party-android):
- Add detailed logging in TssRepository for session event handling
- Add detailed logging in MainViewModel for participant_joined processing
- Log activeSession state, event matching, and participant updates

This enables the initiator's waiting screen to receive real-time updates
when participants join the session, matching the expected behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:34:47 -08:00
hailin 13d1e58b84 fix(android): change QR scanner to portrait orientation
- Created PortraitCaptureActivity that extends CaptureActivity
- Registered it in AndroidManifest.xml with screenOrientation="portrait"
- Updated JoinKeygenScreen and CoSignJoinScreen to use the portrait activity
- Also simplified keygen join logic to match Electron exactly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:16:41 -08:00
hailin d90c722c7d fix(android): simplify keygen join to match Electron behavior exactly
Removed polling fallback and simplified to match Electron's design:
- If joinSession returns sessionStatus="in_progress", trigger keygen immediately
- Otherwise wait for session_started gRPC event

Added debug log to show sessionStatus value for troubleshooting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:14:26 -08:00
hailin 50cb10d6a8 fix(android): improve session_started polling with multiple attempts
Changed from single 2-second delay to 5 attempts at 500ms intervals.
This provides faster detection while covering a longer window (2.5 seconds total).

The polling loop:
- Checks every 500ms for up to 5 times
- Stops immediately if keygen is already triggered
- Stops if session context changes (user cancelled/navigated away)

This handles the case where the last joiner triggers session_started
but cannot receive the event themselves.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:10:15 -08:00
hailin c3d5da46f7 fix(android): add polling fallback for session_started race condition
When multiple Android devices join a keygen session nearly simultaneously,
the last joiner may miss the session_started gRPC event because it's sent
before the device has fully set up its event subscription.

This fix adds a 2-second delayed polling check after join to detect if
the session has already started. If the session is in_progress and we
haven't started keygen yet, trigger it via polling instead of relying
solely on the session_started event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:08:18 -08:00
hailin 136a5ba851 fix(android): change address format from Cosmos to EVM and fix balance query
Changes:
- Change address derivation from deriveKavaAddress to deriveEvmAddress
  in TssRepository.kt (3 locations)
- Add AddressUtils.isEvmAddress() and getEvmAddress() helper methods
  to handle both old Cosmos and new EVM address formats
- Fix balance query for old wallets by deriving EVM address from
  public key when needed (MainViewModel.fetchBalanceForShare)
- Add retry logic for optimistic lock conflicts in join_session.go
  to prevent party_index collision during concurrent joins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 07:48:52 -08:00
hailin 444b720f8d feat(android): strengthen gRPC connection reliability
Major improvements to Android gRPC client:
- Add automatic reconnection with exponential backoff (1s to 30s)
- Add heartbeat mechanism with failure detection (30s interval, 3 failures trigger reconnect)
- Add stream version tracking to filter stale callbacks
- Add channel state monitoring (every 5s)
- Add per-call deadline instead of one-time deadline for stubs
- Add SharedFlow for connection events (Connected, Disconnected, Reconnecting, Reconnected, PendingMessages)
- Add callback exception handling for robustness
- Add stream recovery after reconnection via callback mechanism

TssRepository changes:
- Save message routing params for recovery after reconnect
- Expose grpcConnectionEvents SharedFlow for UI notifications
- Auto-restore event subscriptions after reconnection

Other changes:
- Add QR code to Electron Create page for mobile scanning
- Auto version increment from version.properties
- SettingsScreen shows BuildConfig version info
- CreateWalletScreen tracks hasEnteredSession state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 06:44:42 -08:00
hailin a3ee831193 fix(android): remove device_info from joinSession to match Electron behavior
The server validates device_type and only accepts specific values.
Electron doesn't send device_info at all, which passes validation.
Match that behavior for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 05:26:45 -08:00
hailin 06e374e747 fix(android): use TLS for gRPC connections on port 443
The app was crashing with FRAME_SIZE_ERROR because the gRPC client
was using plaintext mode when connecting to port 443 (TLS endpoint).
This caused the client to receive encrypted data that it couldn't parse.

Fix: Use useTransportSecurity() for port 443, usePlaintext() for other ports.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 05:07:36 -08:00
hailin d8be40b8b0 feat(android): update theme to dark gray & gold, fix JoinKeygen/CoSign flows
Theme changes:
- Replace green theme with dark gray & gold color scheme
- Primary color: Gold (#D4AF37)
- Background: Dark gray (#1A1A1A)
- Surface: Medium gray (#2D2D2D)
- Disable dynamic colors to enforce custom theme
- Default to dark theme for best visual impact
- Update success indicators from green to gold across screens

JoinKeygen flow fixes (100% Electron compatible):
- Add onResetState callback for proper state reset
- Cancel in confirm/joining/progress resets to input state (stays on page)
- Two-step flow: joinKeygenSessionViaGrpc + executeKeygenAsJoiner
- Wait for session_started event before executing keygen

CoSign flow fixes (100% Electron compatible):
- Add onResetState callback and QR scanner support
- Add three-button layout (Cancel, Back, Join) in select_share step
- Two-step flow: joinSignSessionViaGrpc + executeSignAsJoiner
- If session already in_progress, trigger sign immediately (Solution B)
- Wait for session_started event otherwise

Repository changes:
- Add joinKeygenSessionViaGrpc and executeKeygenAsJoiner methods
- Add joinSignSessionViaGrpc and executeSignAsJoiner methods
- Add JoinKeygenViaGrpcResult and JoinSignViaGrpcResult data classes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 04:35:00 -08:00
hailin 2b0920f9b1 fix(android): add copy feedback and explorer link to wallet detail
Matching Electron app functionality:

1. Copy address button:
   - Shows "✓ 已复制" feedback after copying
   - Auto-resets after 2 seconds

2. Explorer link button (new):
   - Opens address in Kava block explorer
   - Uses correct URL based on network type:
     - Mainnet: kavascan.com
     - Testnet: testnet.kavascan.com

Changes:
- WalletsScreen: Added networkType parameter
- WalletDetailDialog: Added copy feedback state and explorer button
- MainActivity: Pass networkType to WalletsScreen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 03:32:33 -08:00
hailin f7de1e8d09 fix(electron): fix wallet detail modal buttons
1. Copy address button:
   - Changed from alert() to visual feedback (shows "✓ 已复制")
   - Feedback auto-hides after 2 seconds

2. Explorer link button:
   - Was hardcoded to testnet (true)
   - Now uses getCurrentNetwork() to determine correct explorer URL
   - Links to kavascan.com for mainnet, testnet.kavascan.com for testnet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 03:29:54 -08:00
hailin 77bbb43eb5 fix(electron): sync network status display with Settings in real-time
Previously, the network badge (testnet/mainnet) in Layout sidebar only
loaded once on mount and didn't update when user changed network in
Settings page.

Changes:
- Layout.tsx: Read network from localStorage first (consistent with
  transaction.ts), then fallback to Electron API
- Layout.tsx: Listen for 'storage' event (cross-tab) and custom
  'kava-network-change' event (same-tab) to update display
- Settings.tsx: Dispatch custom event when switching networks so
  Layout can update immediately

Android app doesn't have this issue - it uses StateFlow which
automatically triggers re-renders when settings change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 03:24:19 -08:00
hailin fd5f4d10ed fix(transfer): MAX button now deducts gas fee from balance
Both Electron and Android apps now calculate the maximum transferable
amount by subtracting estimated gas fees from the balance:

Electron (Home.tsx):
- Added calculateMaxAmount() async function that fetches gas price
- Uses 21000 gas limit for simple transfers
- Shows loading state while calculating

Android (TransferScreen.kt):
- Added calculateMaxTransferAmount() in TransactionUtils
- Uses coroutine to fetch gas price asynchronously
- Shows "..." while calculating, falls back to balance on error

Both implementations:
- Add 10% buffer to gas price for safety
- Round down to 6 decimal places
- Show error if balance insufficient for gas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 03:08:01 -08:00
hailin 7f66ed0ebe fix(electron): sync network setting to localStorage when switching networks
The network toggle in Settings was saving to database via electron API
but getCurrentNetwork() in transaction.ts reads from localStorage.
This caused the balance display to use wrong RPC endpoint after switching.

Now syncs to localStorage when switching networks to ensure consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 02:58:02 -08:00
hailin 5f484f6579 fix(electron): use dynamic network config for balance queries
Previously Home.tsx hardcoded testnet RPC for balance queries.
Now uses getCurrentRpcUrl() to respect user's network setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 02:46:39 -08:00
hailin c239ac65ee fix(android): simplify build-apk.bat with official gomobile setup
Key changes:
- Add `go get -d golang.org/x/mobile/cmd/gomobile` step (official recommended)
- This adds golang.org/x/mobile dependency to go.mod, fixing "unable to import bind" error
- Remove complex Go 1.22 version detection logic (no longer needed)
- Simplify gomobile installation flow
- Update tsslib/go.mod with proper golang.org/x/mobile dependency

The fix follows the official Go Mobile documentation:
https://go.dev/wiki/Mobile

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:53:04 -08:00
hailin 543bee6d26 fix(android): add -androidapi 21 flag to gomobile bind
This ensures compatibility with modern NDK versions that don't
support older Android API levels. API 21 (Android 5.0) is the
minimum supported by current NDK versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:14:52 -08:00
hailin 9a4dd9729c fix(android): correct tsslib path in build-apk.bat
The tsslib source code is located in service-party-android/tsslib/,
not in libs/tsslib/. Updated the path and output location accordingly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:59:43 -08:00
hailin d5325efa2a fix(android): properly handle GOPATH/bin for gomobile in build-apk.bat
Changes:
- Get GOPATH using 'go env GOPATH' command
- Add GOPATH/bin to PATH if not already present
- Check for gomobile.exe directly in GOBIN directory
- Use full path to gomobile.exe for init and bind commands
- Add verification that gomobile was installed correctly

This fixes the issue where gomobile is installed but not found
because GOPATH/bin is not in the system PATH.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:57:04 -08:00
hailin 131c14742c feat(android): auto-build tsslib.aar if missing in build-apk.bat
When tsslib.aar is not found, the build script now automatically:
1. Checks if Go is installed
2. Installs gomobile if not present (go install golang.org/x/mobile/cmd/gomobile@latest)
3. Initializes gomobile if needed
4. Runs go mod tidy in the tsslib directory
5. Builds tsslib.aar using gomobile bind

This allows building APKs on any machine with Go installed, without
needing to manually compile the TSS library first.

Requirements:
- Go installed and in PATH
- Android NDK (installed via Android SDK)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:48:55 -08:00
hailin 8541c83bf5 fix(android): remove quotes from ANDROID_HOME path in build-apk.bat
When ANDROID_HOME environment variable contains quotes (e.g., set with
quotes in system settings), the generated local.properties file would
have an invalid path like 'sdk.dir=C:/Android"'.

This fix strips any surrounding quotes from ANDROID_HOME before using
it to create local.properties, ensuring valid SDK path format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:43:51 -08:00
hailin c5f52190ef feat(android): add Android SDK auto-detection to build-apk.bat
When local.properties is missing, the build script now automatically:
- Checks ANDROID_HOME environment variable first
- Scans common Windows SDK locations:
  - %LOCALAPPDATA%\Android\Sdk
  - %USERPROFILE%\AppData\Local\Android\Sdk
  - C:\Android\Sdk
  - C:\Android
- Creates local.properties with the detected SDK path
- Displays helpful error message if SDK is not found

This allows the build script to work on machines without manual
configuration, making it easier to build APKs on different systems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:42:06 -08:00
hailin 4d62316d17 feat(android): add build-apk.bat script for easy APK building
Add Windows batch script for building Android APKs:
- build-apk.bat debug   - Build debug APK only
- build-apk.bat release - Build release APK only
- build-apk.bat         - Build both debug and release APKs
- build-apk.bat clean   - Clean build files
- build-apk.bat help    - Show usage help

Output locations:
- Debug: app/build/outputs/apk/debug/app-debug.apk
- Release: app/build/outputs/apk/release/app-release.apk

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:35:28 -08:00
hailin 7b6d6de801 feat(android): add Android TSS Party app with full API implementation
Major changes:
- Add complete Android app (service-party-android) with Jetpack Compose UI
- Implement real account-service API calls for keygen and sign sessions:
  - POST /api/v1/co-managed/sessions (create keygen session)
  - GET /api/v1/co-managed/sessions/by-invite-code/{code} (validate invite)
  - POST /api/v1/co-managed/sessions/{id}/join (join keygen session)
  - POST /api/v1/co-managed/sign (create sign session)
  - GET /api/v1/co-managed/sign/by-invite-code/{code} (validate sign invite)
  - POST /api/v1/co-managed/sign/{id}/join (join sign session)
- Add QR code generation and scanning for session invites
- Remove password requirement (use empty string)
- Add floating action button for wallet creation
- Add network type aware explorer links (mainnet/testnet)

Network configuration:
- Change default network to Kava mainnet for both Electron and Android apps
- Electron: main.ts, transaction.ts, Settings.tsx, Layout.tsx
- Android: Models.kt (NetworkType.MAINNET default)

Features:
- Full TSS keygen and sign protocol via gomobile bindings
- gRPC message routing for multi-party communication
- Cross-platform compatibility with service-party-app (Electron)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 23:27:29 -08:00
hailin ff995a827b fix(grpc-client): add connection check and better error handling in subscribeMessages
Additional safeguards to prevent "CANCELLED: Cancelled on client" error:

1. Add `this.connected` check at the start of subscribeMessages()
2. Set messageStream to null after canceling old stream
3. Wrap new stream creation in try-catch to handle creation errors
4. Add logging for ignored cancel errors

These changes ensure that:
- subscribeMessages won't proceed if connection is lost
- Old stream is fully cleaned up before creating new one
- Errors during stream creation are properly caught and logged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:09:45 -08:00
hailin 66a718ea72 fix(electron): properly cleanup gRPC message stream after keygen/sign
Root cause: After keygen/sign completion, the gRPC message stream was not
unsubscribed. On the second operation, prepareForSign/prepareForKeygen
would try to cancel the stale stream, causing "CANCELLED: Cancelled on client".

Changes in tss-handler.ts:
- Add grpcClient.unsubscribeMessages() in all cleanup paths:
  - participateKeygen close handler
  - participateKeygen error handler
  - participateSign close handler
  - participateSign error handler
  - cancel() method
- Reset sessionId and partyId in all cleanup paths

Changes in main.ts:
- Add reconnection logic in app 'activate' event for macOS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:07:43 -08:00
hailin d051178801 fix(electron): add gRPC connection check before subscribing to messages
The app was crashing with "CANCELLED: Cancelled on client" error when
opening the app a second time. This happened because:

1. When window was reopened, old gRPC streams were in cancelled state
2. prepareForSign/prepareForKeygen tried to subscribe on cancelled streams
3. The error was unhandled and crashed the app

Changes:
- Add isConnected() check in prepareForSign() and prepareForKeygen()
- Throw meaningful error when gRPC client is not connected
- Wrap all prepareFor* calls in try-catch in main.ts
- Return user-friendly error message instead of crashing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:04:19 -08:00
hailin c0229a1139 fix(transaction): use eth_gasPrice RPC for Legacy transaction gas estimation
- Changed getGasPrice() to use eth_gasPrice RPC method instead of calculating
  from baseFeePerGas (which is for EIP-1559 transactions)
- Added 10% buffer to gas price to ensure transaction gets included
- Updated Home.tsx to use gasPrice instead of maxFeePerGas for display

KAVA doesn't support EIP-1559, so we must use Legacy (Type 0) transactions
with gasPrice from eth_gasPrice RPC.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:48:41 -08:00
hailin 0f8e9cf228 fix(transaction): use Legacy (Type 0) transaction format for KAVA
KAVA EVM does not support EIP-1559 dynamic fee transactions.
Changed from EIP-1559 (Type 2) to Legacy (Type 0) format:

- prepareTransaction: Use [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
- finalizeTransaction: Use EIP-155 v calculation (chainId * 2 + 35 + recoveryId)
- Remove type prefix (0x02) as Legacy transactions don't need it
- Update Home.tsx and CoSignSession.tsx to use gasPrice instead of maxFeePerGas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:45:38 -08:00
hailin d18733deb1 fix(tss-party): include recovery ID in signature output for EVM transactions
The signature was 64 bytes (r + s) but EVM transactions need 65 bytes (r + s + v).
Now the recovery ID is appended to the signature so the frontend can correctly
parse and broadcast the transaction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:37:53 -08:00
hailin b5512d421c fix(tss): convert threshold to tss-lib format (threshold-1) in all keygen and signing
TSS-lib convention: threshold=t means (t+1) signers required.
User expectation: "2-of-3" means 2 signers needed.

Before this fix:
- Keygen used thresholdT directly (e.g., 2)
- TSS-lib interpreted as needing 3 signers (2+1)
- 2-of-3 wallet was actually 3-of-3!

After this fix:
- Both keygen and signing use (thresholdT-1)
- For 2-of-3: tss-lib threshold=1, needs 1+1=2 signers ✓

Files changed:
- tss-party/main.go: keygen and signing both use thresholdT-1
- tss-wasm/main.go: keygen and signing both use thresholdT-1
- pkg/tss/keygen.go: uses config.Threshold-1
- pkg/tss/signing.go: uses config.Threshold-1

BREAKING CHANGE: Existing wallets created before this fix used wrong
threshold and need to be regenerated. New wallets will work correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:19:58 -08:00
hailin 51c0f59924 fix(tss): remove threshold-1 in signing to match keygen exactly
The signing code was using thresholdT-1 while keygen was using thresholdT,
causing Lagrange coefficient mismatch and "U doesn't equal T" error in round 9.

Root cause: commit d0c504dc added -1 to signing threshold to "match user expectation",
but this broke the keygen/sign consistency that TSS-lib requires.

Changes:
- tss-party/main.go: Sign now uses thresholdT (same as keygen)
- pkg/tss/signing.go: Add logging, emphasize threshold must match keygen
- tss-wasm/main.go: Add comment about threshold consistency

NOTE: This fix maintains backward compatibility with existing wallets.
No wallet regeneration is needed.

ROLLBACK: If this causes issues, revert to commit before this one.
Previous signing threshold was thresholdT-1 (commit d0c504dc).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 12:08:40 -08:00
hailin 4a00c8066a fix(tss-party): fix debug logging slice bounds error
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:46:46 -08:00
hailin 7a82a56ae5 debug(tss-party): add detailed key matching logs
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:41:41 -08:00
hailin 3564f30f27 debug(tss-party): add logging for BuildLocalSaveDataSubset
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:35:56 -08:00
hailin 7ab28dced0 fix(tss): use BuildLocalSaveDataSubset for threshold signing with party subsets
When signing with fewer parties than keygen (e.g., 2-of-3 signing with only 2 parties),
the TSS-lib requires filtered save data containing only the participating parties.

Without this fix, signing fails with "U doesn't equal T" error because:
- Keygen creates save data for all N parties (e.g., 3 parties with indices 0, 1, 2)
- Sign uses only T parties (e.g., 2 parties with indices 1, 2)
- TSS-lib internal index validation fails due to mismatch

Changes:
- pkg/tss/signing.go: Use len(sortedPartyIDs) for partyCount and call BuildLocalSaveDataSubset
- tss-party/main.go: Add BuildLocalSaveDataSubset call for Electron app
- tss-wasm/main.go: Add BuildLocalSaveDataSubset call for WASM builds

This fix is backward compatible - when all parties participate, the subset equals the original data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:25:22 -08:00
hailin 24ff1409d0 Revert "fix(co-keygen): convert threshold at storage time to match tss-lib convention"
This reverts commit 4dcc7d37ba.
2025-12-31 10:24:25 -08:00
hailin 4dcc7d37ba fix(co-keygen): convert threshold at storage time to match tss-lib convention
User says "3-of-5" meaning 3 signers needed.
tss-lib threshold t means t+1 signers required.
Now we store t-1 at session creation (like persistent-only does).

Changes:
- co_managed_handler.go: tssThresholdT = req.ThresholdT - 1
- tss-party/main.go: remove -1 from sign (now consistent with keygen)

BREAKING: Existing co-managed wallets must be regenerated.
ROLLBACK: Revert this commit if signing still fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:14:21 -08:00
hailin b876c9dfba fix(co-sign): use actual signer count instead of keygen N in NewParameters
The tss.NewParameters() expects the party count to match the number of
parties in peerCtx. For signing, this should be len(sortedPartyIDs)
(actual signing participants), not thresholdN (original keygen parties).

This fixes the "U doesn't equal T" error in round 9 when doing 3-of-5
co-managed signing with parties at indices 2,3,4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 09:06:59 -08:00
hailin b231667aba fix(grpc): prevent stream race condition from triggering reconnection
When switching message/event streams, the old stream's 'end' or 'error'
events could fire after the new stream was created. Since activeMessageSubscription
was already updated to the new session, the old stream's events would
incorrectly trigger reconnection, causing TSS message routing to fail.

Fix:
- Remove event listeners from old stream before canceling
- Use closure to capture current stream reference
- Check if event is from current active stream before triggering reconnect

This fixes the "Not connected" error during co-sign TSS message routing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 08:57:44 -08:00
hailin 1708a03aaf fix(session): distinguish keygen vs sign in CanStart() and AllPartiesReady()
- Keygen/co-keygen: must have exactly N participants joined
- Sign (co-sign/persistent): only check all registered participants joined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 08:34:40 -08:00
hailin d0c504dcf3 fix(co-sign): adjust threshold for tss-lib (t-1) to match user expectation
User says 3-of-5 meaning 3 signers needed, but tss-lib threshold t means t+1 signers.
Pass thresholdT-1 so tss-lib needs (t-1)+1 = t signers, matching user expectation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 08:19:27 -08:00
hailin 54121fa494 revert: undo incorrect threshold conversion that broke keygen
Reverts e81757ad - the threshold conversion was wrong.
Keygen works with original thresholdT/thresholdN parameters.
The signing issue needs a different fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 07:58:36 -08:00
hailin e81757ad83 fix(co-sign): convert user-friendly threshold to tss-lib format
- Rename thresholdT/thresholdN to requiredSigners/totalParties in Create.tsx
- Add parameter conversion in main.ts: threshold_t = requiredSigners - 1
- In tss-lib, threshold t means t+1 parties needed to sign
- For 3-of-5: requiredSigners=3 → threshold_t=2 (t+1=3 signers)
- externalCount = requiredSigners (user parties)
- persistentCount = totalParties - requiredSigners (server parties)
- Backward compatible with legacy thresholdT/thresholdN format

BREAKING: Existing co-managed wallets need re-keygen with new params

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 07:44:17 -08:00
hailin ca69ebc839 fix(co-sign): use keygen N and T for TSS signing parameters
The TSS signing was failing with "U doesn't equal T" error because
tss-party was passing incorrect parameters to tss.NewParameters():
- Was: len(sortedPartyIDs)=3 (signing participants), thresholdT-1=2
- Now: thresholdN=5 (keygen N), thresholdT=3 (keygen T)

This matches how pkg/tss/signing.go creates parameters in server-party,
which uses TotalParties=N and Threshold=T from the original keygen.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 07:01:59 -08:00
hailin 5ebdd4d592 fix(co-sign): add threshold_n to CreateSignSession API response
Add keygenThresholdN to the CreateSignSession response so frontend
can access the original N value from keygen session. This is required
for proper TSS operation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:36:38 -08:00
hailin 75b15acda2 docs: add BREAKING CHANGE warnings for co-sign modifications
Add detailed comments to warn about changes that affect persistent sign flow:
- session_coordinator.go: ValidateSessionCreation now allows T <= count <= N for sign
- mpc_session.go: CanStart/AllPartiesReady now check registered participants, not N
- session_coordinator_client.go: ThresholdN now uses keygenThresholdN instead of len(parties)

Each comment includes:
- Original code behavior
- New code behavior
- How to revert if persistent sign breaks
- Related files list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:23:39 -08:00
hailin 94ab63db30 fix(co-sign): allow T to N participants for sign sessions
- Change ValidateSessionCreation to accept T <= participantCount <= N for sign sessions
- Co-managed sign uses exactly T parties
- Persistent sign uses T+1 parties
- Both now pass validation with correct keygenThresholdN

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:19:57 -08:00
hailin 99fa003b12 fix(co-sign): fix session start logic to check all registered participants
- CanStart(): Check if all registered participants have joined, not based on T/N
- AddParticipant(): Keep N as max limit (API handles T vs T+1 validation)
- AllPartiesReady(): Check all registered participants, not based on T/N
- This approach works for both co-managed (T parties) and persistent (T+1 parties) signing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:09:14 -08:00
hailin a09e163704 fix(co-sign): fix CanStart() to check T parties for sign sessions
- For keygen sessions: require all N parties to join before starting
- For sign sessions: require only T parties to join before starting
- This fixes session_started event not being triggered for signing sessions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 06:01:40 -08:00
hailin 2a95dd107f fix(co-sign): allow signing sessions with t participants instead of n
- Modify ValidateSessionCreation to differentiate between keygen and sign sessions
- For keygen: require participantCount == threshold.N() (all parties must participate)
- For sign: require participantCount == threshold.T() (only t parties needed)
- This fixes "session is full" error when creating signing session with 3 parties but n=5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 05:45:05 -08:00
hailin 042212eae6 fix(co-sign): use keygen session threshold_n for TSS signing
- Query keygen session from mpc_sessions table to get correct threshold_n
- Pass keygenThresholdN to CreateSigningSessionAuto instead of len(parties)
- Return parties list and correct threshold values in GetSignSessionByInviteCode
- This fixes TSS signing failure "U doesn't equal T" caused by mismatched n values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 05:31:02 -08:00
hailin e284a46e83 fix(co-sign): pass complete parties list to joinSession
Problem: Participants joining early only got incomplete participant list
from other_parties (only those who had joined), causing partyIndex mismatch.

Solution:
- Add parties field to SessionInfo (from validateInviteCode response)
- Pass parties to joinSession call from frontend
- Backend joinSession uses params.parties (complete list) instead of
  result.other_parties (incomplete list)
- Add debug logging to track participant list state

Now all participants have the complete parties list with correct partyIndex.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 04:34:48 -08:00
hailin 8193549aba fix(co-sign): update participants list from session_started event
- Add logic in handleCoSignStart to update participants from event.selectedParties
- Fix initiator immediate trigger to use other_parties + self instead of incomplete participants list
- Add debug logging for participant list updates
- Ensures all parties have correct participant list before TSS signing starts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 04:13:29 -08:00
hailin 742419c0bf fix(layout): change sidebar sign link to new CoSignJoin page
Change /sign to /cosign/join so participants use the correct page
with auto-join functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 03:53:51 -08:00
hailin da189ca3d4 feat(co-sign): add debug logs for auto-join flow in CoSignJoin
Add console.log statements to trace the auto-join logic:
- Log loaded shares with sessionId
- Log auto-select share matching check
- Log auto-join conditions and share match status
- Log validateInviteCode results including joinToken
- Log handleJoinSession parameters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 03:33:10 -08:00
hailin cd63643ba4 fix(account): exclude failed sessions when looking up sign session by invite code
When multiple sign sessions share the same invite code (due to retries),
the query now:
1. Excludes failed sessions (status != 'failed')
2. Orders by created_at DESC to get the most recent session
3. Limits to 1 result

This prevents participants from seeing an old failed session's status
when they look up the invite code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 02:09:18 -08:00
hailin 138650d943 fix(sign): use threshold_n from API response instead of parties.length
The validateSigningSession handler was using parties.length for threshold.n
which returned 0 when parties array was empty. Now correctly uses the
threshold_n value returned from the backend API.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 02:07:05 -08:00
hailin 9f898ccf44 fix(sign): remove password validation check in handleJoinSigning
Password is optional - remove the validation that required password
to be non-empty before joining a sign session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:58:29 -08:00
hailin 227d04bde3 fix(sign): make password optional for joining sign session
Password field was required to enable the join button, but password
is optional when the share was created without encryption.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:49:56 -08:00
hailin c1e32a8c04 fix(co-sign): fix threshold_n display and add missing fields in GetSignSessionByInviteCode
- Add threshold_n to GetSignSessionByInviteCodeResponse interface
- Fix main.ts to use result.threshold_n instead of result.parties?.length
- Add message_hash, joined_count, join_token to GetSignSessionByInviteCode response
- Generate join token for sign session lookup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:47:08 -08:00
hailin 4d65b8dd83 feat(co-sign): add invite code display in CoSignSession page
- Add invite_code retrieval in GetSignSessionStatus (backend)
- Add inviteCode to cosign:getSessionStatus response (frontend IPC)
- Add inviteCode to SessionState and display UI in CoSignSession

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:37:11 -08:00
hailin cfbda7bbc7 fix(co-sign): validate exactly t parties for t-of-n signing
For threshold signing, exactly t parties are required:
- 3-of-5 → 3 parties
- 2-of-3 → 2 parties
- 4-of-7 → 4 parties

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:21:12 -08:00
hailin ebbc483b35 fix(co-sign): use keygen session participants with correct party_index for signing
- Fetch keygen session status from backend to get accurate party_index
- Filter out co-managed-party-* (server persistent parties) from signing
- Only temporary/external user parties participate in signing
- For 3-of-5 wallet: 3 user parties sign, 2 co-managed parties are backup only

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 01:12:07 -08:00
hailin 4089b9da6c fix(service-party-app): use API response for co-sign session status display
- Use API's participants field instead of parties
- Use API's threshold_t and threshold_n instead of activeCoSignSession
- Show participant status from API response
- Update GetSignSessionStatusResponse interface

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:17:14 -08:00
hailin c1e749e532 fix(co-sign): return join_tokens map for initiator auto-join
- Add join_tokens (map[partyID]token) to CreateSignSession response
- Keep join_token for backward compatibility
- Update frontend to use join_tokens[partyId] for initiator auto-join

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:54:20 -08:00
hailin cd1d2cf8d2 feat(account): add GET /sign/:sessionId endpoint for co-sign session status
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:41:05 -08:00
hailin b688b0176e fix(service-party-app): serialize BigInt to string for sessionStorage
BigInt cannot be serialized by JSON.stringify. Convert gasLimit,
maxFeePerGas, maxPriorityFeePerGas, and value to strings before
storing in sessionStorage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:16:38 -08:00
hailin 879fc3a816 feat(service-party-app): add transfer functionality with co-sign integration
Add complete KAVA transfer feature to the wallet home page:

Frontend (React):
- Home.tsx: Add transfer modal with address/amount input, transaction
  confirmation, and co-sign session initiation
- Home.module.css: Transfer modal styles (form, confirm, error states)
- CoSignSession.tsx: Add transaction broadcast after signing completion,
  with block explorer link

Utils:
- transaction.ts: EIP-1559 transaction building, RLP encoding, Keccak-256
  hashing, nonce/gas fetching, transaction broadcast via JSON-RPC

Flow: Wallet -> Transfer Modal -> Prepare TX -> Confirm -> Co-Sign ->
      Sign Session -> Broadcast -> Block Explorer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:08:03 -08:00
hailin ebea74e57b feat(service-party-app): implement co-sign multi-party signing
Add complete co-sign functionality for multi-party transaction signing:

Frontend (React):
- CoSignCreate.tsx: Create signing session with share selection
- CoSignJoin.tsx: Join signing session via invite code
- CoSignSession.tsx: Monitor signing progress and results
- Add routes in App.tsx for new pages

Backend (Electron):
- main.ts: Add IPC handlers for co-sign operations
- tss-handler.ts: Add participateSign() for TSS signing
- preload.ts: Expose cosign API to renderer
- account-client.ts: Add sign session API types

TSS Party (Go):
- main.go: Implement 'sign' command for GG20 signing protocol
- integration_test.go: Add comprehensive tests for signing flow

Infrastructure:
- docker-compose.windows.yml: Expose gRPC port 50051

This is a pure additive change that does not affect existing
persistent role keygen/sign functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 18:36:11 -08:00
1104 changed files with 199675 additions and 2191 deletions

View File

@ -483,7 +483,299 @@
"Bash(git cherry-pick:*)",
"Bash(git stash:*)",
"Bash(docker compose build:*)",
"Bash(git log:*)"
"Bash(git log:*)",
"Bash(git tag -a v0.3.0-pre-transfer -m \"$\\(cat <<''EOF''\nPre-transfer development checkpoint\n\nCompleted features:\n- Co-keygen: Multi-party key generation with TSS \\(GG20\\)\n- Service-party-app: Electron desktop application\n - Create shared wallet \\(keygen initiator\\)\n - Join wallet creation \\(keygen participant\\)\n - Wallet management \\(list, export, delete\\)\n - Kava network switch \\(mainnet/testnet\\)\n - EVM address derivation and balance display\n\nNot yet implemented:\n- Co-sign: Multi-party transaction signing\n- Transfer functionality\n\nThis tag marks the stable state before transfer feature development.\nEOF\n\\)\")",
"Bash(tasklist:*)",
"Bash(docker port:*)",
"Bash(docker rm:*)",
"Bash(netstat:*)",
"Bash(start \"\" \"C:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\release\\\\win-unpacked\\\\榴莲皇后绿积分共管账户服务.exe\")",
"Bash(go test:*)",
"Bash(./tss-party.exe sign:*)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --oneline --all)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian diff --name-only HEAD~5..HEAD)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --all --oneline --grep=\"co-sign\\\\|co-managed\\\\|CoManaged\")",
"Bash(git -C /c/Users/dong/Desktop/rwadurian show e038f178 --stat)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian show e114723a --stat)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian show c457d158 -- backend/mpc-system/services/account/adapters/input/http/account_handler.go)",
"Bash(git -C /c/Users/dong/Desktop/rwadurian log --oneline -- backend/mpc-system/services/account/adapters/input/http/account_handler.go)",
"Bash(git rev-list:*)",
"Bash(dir /d \"C:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(service-party-app\\): implement co-sign multi-party signing\n\nAdd complete co-sign functionality for multi-party transaction signing:\n\nFrontend \\(React\\):\n- CoSignCreate.tsx: Create signing session with share selection\n- CoSignJoin.tsx: Join signing session via invite code\n- CoSignSession.tsx: Monitor signing progress and results\n- Add routes in App.tsx for new pages\n\nBackend \\(Electron\\):\n- main.ts: Add IPC handlers for co-sign operations\n- tss-handler.ts: Add participateSign\\(\\) for TSS signing\n- preload.ts: Expose cosign API to renderer\n- account-client.ts: Add sign session API types\n\nTSS Party \\(Go\\):\n- main.go: Implement ''sign'' command for GG20 signing protocol\n- integration_test.go: Add comprehensive tests for signing flow\n\nInfrastructure:\n- docker-compose.windows.yml: Expose gRPC port 50051\n\nThis is a pure additive change that does not affect existing\npersistent role keygen/sign functionality.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(service-party-app\\): add transfer functionality with co-sign integration\n\nAdd complete KAVA transfer feature to the wallet home page:\n\nFrontend \\(React\\):\n- Home.tsx: Add transfer modal with address/amount input, transaction\n confirmation, and co-sign session initiation\n- Home.module.css: Transfer modal styles \\(form, confirm, error states\\)\n- CoSignSession.tsx: Add transaction broadcast after signing completion,\n with block explorer link\n\nUtils:\n- transaction.ts: EIP-1559 transaction building, RLP encoding, Keccak-256\n hashing, nonce/gas fetching, transaction broadcast via JSON-RPC\n\nFlow: Wallet -> Transfer Modal -> Prepare TX -> Confirm -> Co-Sign ->\n Sign Session -> Broadcast -> Block Explorer\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(powershell -Command:*)",
"Bash(powershell -Command \"\n$content = Get-Content ''main.ts'' -Raw\n\n# 修改 threshold 部分\n$old1 = @''\n threshold: {\n t: activeCoSignSession?.threshold?.t || 0,\n n: activeCoSignSession?.threshold?.n || 0,\n },\n''@\n\n$new1 = @''\n threshold: {\n // 优先使用 API 返回的阈值,回退到 activeCoSignSession\n t: result?.threshold_t || activeCoSignSession?.threshold?.t || 0,\n n: result?.threshold_n || activeCoSignSession?.threshold?.n || 0,\n },\n''@\n\n$content = $content.Replace\\($old1, $new1\\)\n\n# 修改 participants 部分\n$old2 = ''participants: result?.parties?.map\\(\\(p: { party_id: string; party_index: number }, idx: number\\) => \\({''\n$new2 = ''participants: \\(\\(result as { participants?: Array<{ party_id: string; party_index: number; status: string }> }\\)?.participants || []\\).map\\(\\(p, idx\\) => \\({''\n\n$content = $content.Replace\\($old2, $new2\\)\n\n# 修改 status 部分\n$old3 = \"\" status: ''ready'',\"\"\n$new3 = \"\" status: p.status || ''waiting'',\"\"\n\n$content = $content.Replace\\($old3, $new3\\)\n\n# 修改结尾部分\n$old4 = '' }\\)\\) || [],''\n$new4 = '' }\\)\\),''\n\n$content = $content.Replace\\($old4, $new4\\)\n\nSet-Content ''main.ts'' -Value $content -NoNewline\nWrite-Output ''Done''\n\")",
"Bash(node fix_main.js:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(co-sign\\): add debug logs for auto-join flow in CoSignJoin\n\nAdd console.log statements to trace the auto-join logic:\n- Log loaded shares with sessionId\n- Log auto-select share matching check\n- Log auto-join conditions and share match status\n- Log validateInviteCode results including joinToken\n- Log handleJoinSession parameters\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(co-sign\\): use keygen session threshold_n for TSS signing\n\n- Query keygen session from mpc_sessions table to get correct threshold_n\n- Pass keygenThresholdN to CreateSigningSessionAuto instead of len\\(parties\\)\n- Return parties list and correct threshold values in GetSignSessionByInviteCode\n- This fixes TSS signing failure \"U doesn 't equal T\" caused by mismatched n values\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(Get-Item \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\bin\\\\win32-x64\\\\tss-party.exe\")",
"Bash(Select-Object Name, LastWriteTime, Length)",
"Bash(Get-Item \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-app\\\\release\\\\win-unpacked\\\\resources\\\\bin\\\\tss-party.exe\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(tss\\): use BuildLocalSaveDataSubset for threshold signing with party subsets\n\nWhen signing with fewer parties than keygen \\(e.g., 2-of-3 signing with only 2 parties\\),\nthe TSS-lib requires filtered save data containing only the participating parties.\n\nWithout this fix, signing fails with \"U doesn 't equal T\" error because:\n- Keygen creates save data for all N parties \\(e.g., 3 parties with indices 0, 1, 2\\)\n- Sign uses only T parties \\(e.g., 2 parties with indices 1, 2\\)\n- TSS-lib internal index validation fails due to mismatch\n\nChanges:\n- pkg/tss/signing.go: Use len\\(sortedPartyIDs\\) for partyCount and call BuildLocalSaveDataSubset\n- tss-party/main.go: Add BuildLocalSaveDataSubset call for Electron app\n- tss-wasm/main.go: Add BuildLocalSaveDataSubset call for WASM builds\n\nThis fix is backward compatible - when all parties participate, the subset equals the original data.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(dir \"c:\\\\Android\")",
"Bash(dir \"c:\\\\android-sdk\")",
"Bash(dir \"%LOCALAPPDATA%\\\\Android\\\\Sdk\")",
"Bash(cmd /c \"echo %LOCALAPPDATA%\")",
"Bash(powershell:*)",
"Bash(dir \"C:\\\\Users\\\\dong\\\\AppData\\\\Local\\\\Android\\\\Sdk\")",
"Bash(dir /b C: 2)",
"Bash(gradle --version:*)",
"Bash(chmod:*)",
"Bash(java -version:*)",
"Bash(./gradlew assembleDebug:*)",
"Bash(go version:*)",
"Bash(export PATH=\"$PATH:/c/Users/dong/go/bin\")",
"Bash(gomobile version:*)",
"Bash(export ANDROID_HOME=\"/c/Android\")",
"Bash(gomobile init:*)",
"Bash(go install:*)",
"Bash(go get:*)",
"Bash(cmd /c \"gradlew.bat assembleDebug --no-daemon 2>&1\")",
"Bash(./gradlew.bat assembleDebug:*)",
"Bash(wc:*)",
"Bash(./gradlew assembleRelease:*)",
"Bash(./gradlew clean:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add Android TSS Party app with full API implementation\n\nMajor changes:\n- Add complete Android app \\(service-party-android\\) with Jetpack Compose UI\n- Implement real account-service API calls for keygen and sign sessions:\n - POST /api/v1/co-managed/sessions \\(create keygen session\\)\n - GET /api/v1/co-managed/sessions/by-invite-code/{code} \\(validate invite\\)\n - POST /api/v1/co-managed/sessions/{id}/join \\(join keygen session\\)\n - POST /api/v1/co-managed/sign \\(create sign session\\)\n - GET /api/v1/co-managed/sign/by-invite-code/{code} \\(validate sign invite\\)\n - POST /api/v1/co-managed/sign/{id}/join \\(join sign session\\)\n- Add QR code generation and scanning for session invites\n- Remove password requirement \\(use empty string\\)\n- Add floating action button for wallet creation\n- Add network type aware explorer links \\(mainnet/testnet\\)\n\nNetwork configuration:\n- Change default network to Kava mainnet for both Electron and Android apps\n- Electron: main.ts, transaction.ts, Settings.tsx, Layout.tsx\n- Android: Models.kt \\(NetworkType.MAINNET default\\)\n\nFeatures:\n- Full TSS keygen and sign protocol via gomobile bindings\n- gRPC message routing for multi-party communication\n- Cross-platform compatibility with service-party-app \\(Electron\\)\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(cmd /c \"build-apk.bat help\")",
"Bash(go clean:*)",
"Bash(gomobile bind:*)",
"Bash(GOPROXY=https://proxy.golang.org,direct go get:*)",
"Bash(go mod download:*)",
"Bash(go env:*)",
"Bash(cmd /c \"set GOFLAGS=-mod=mod && go get golang.org/x/mobile/bind && go mod tidy && gomobile bind -v -target=android -androidapi 21 -o ..\\\\app\\\\libs\\\\tsslib.aar .\")",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" download)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" version)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@latest)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@latest)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@v0.0.0-20250807114141-395d808d53cd)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@v0.0.0-20250808145247-395d808d53cd)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gomobile@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)",
"Bash(adb devices:*)",
"Bash(adb logcat:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add 5-minute polling timeout mechanism for keygen/sign\n\nImplements Electron''s checkAndTriggerKeygen\\(\\) polling fallback:\n- Adds polling every 2 seconds with 5-minute timeout\n- Triggers keygen/sign via synthetic session_started event on in_progress status\n- Handles gRPC stream disconnection when app goes to background\n- Shows timeout error in UI via existing error mechanism\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(go list:*)",
"Bash(adb install:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(tss\\): add real-time round progress from msg.Type\\(\\) parsing\n\nExtract current round number from tss-lib message type string using\nregex pattern `Round\\(\\\\d+\\)`. This enables real-time progress updates\n\\(1/4, 2/4... for keygen, 1/9, 2/9... for signing\\) instead of only\nshowing completion status.\n\nChanges across all three platforms:\n- tss-wasm/main.go: Add extractRoundFromMessageType\\(\\) and call\n OnProgress with parsed round on each outgoing message\n- service-party-android/tsslib/tsslib.go: Same implementation for\n Android gomobile binding\n- service-party-app/tss-party/main.go: Same implementation for\n Electron subprocess, with isKeygen parameter to distinguish\n keygen \\(4 rounds\\) vs signing \\(9 rounds\\)\n\nSafe fallback: Returns 0 if parsing fails, which doesn''t affect\nprotocol execution - only UI display.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(node --input-type=module -e:*)",
"Bash(npx solc:*)",
"Bash(node /c/Users/dong/Desktop/rwadurian/contracts/deploy.mjs:*)",
"Bash(npm init:*)",
"Bash(node deploy.mjs:*)",
"Bash(npx solcjs@0.8.19:*)",
"Bash(node compile.mjs:*)",
"Bash(node verify-sig.mjs:*)",
"Bash(node deploy-ethers.mjs:*)",
"Bash(node transfer-all.mjs:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add share export and import functionality\n\nAdd ability to backup wallet shares to files and restore from backups:\n\n- Add ShareBackup data class in Models.kt for backup format\n- Add exportShareBackup\\(\\) and importShareBackup\\(\\) in TssRepository\n- Add export/import state and methods in MainViewModel\n- Add file picker integration in MainActivity using ActivityResultContracts\n- Add import FAB button in WalletsScreen\n- Export saves as .tss-backup file with address and timestamp in filename\n- Import validates backup format and checks for duplicate wallets\n\nThe backup file contains all necessary data to restore a wallet share:\nsessionId, publicKey, encryptedShare, threshold, partyIndex, address.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\identity-service\\\\src\\\\api\\\\controllers\\\\*.ts\")",
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\identity-service\\\\src\\\\infrastructure\\\\persistence\\\\repositories\\\\*.ts\")",
"Bash(head:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(pending-actions\\): add user pending actions system\n\nAdd a fully optional pending actions system that allows admins to configure\nspecific tasks that users must complete after login.\n\nBackend \\(identity-service\\):\n- Add UserPendingAction model to Prisma schema\n- Add migration for user_pending_actions table\n- Add PendingActionService with full CRUD operations\n- Add user-facing API \\(GET list, POST complete\\)\n- Add admin API \\(CRUD, batch create\\)\n\nAdmin Web:\n- Add pending actions management page\n- Support single/batch create, edit, cancel, delete\n- View action details including completion time\n- Filter by userId, actionCode, status\n\nFlutter Mobile App:\n- Add PendingActionService and PendingActionCheckService\n- Add PendingActionsPage for forced task execution\n- Integrate into splash_page login flow\n- Users must complete all pending tasks in priority order\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(npm run type-check:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(settlement\\): implement settle-to-balance with detailed source tracking\n\nAdd complete settlement-to-balance feature that transfers settleable\nearnings directly to wallet USDT balance \\(no currency swap\\). Key changes:\n\nBackend \\(wallet-service\\):\n- Add SettleToBalanceCommand for settlement operations\n- Add settleToBalance method to WalletAccountAggregate\n- Add settleToBalance application service with ledger recording\n- Add internal API endpoint POST /api/v1/wallets/settle-to-balance\n\nBackend \\(reward-service\\):\n- Add settleToBalance client method for wallet-service communication\n- Add settleRewardsToBalance application service method\n- Add user-facing API endpoint POST /rewards/settle-to-balance\n- Build detailed settlement memo with source user tracking per reward\n\nFrontend \\(mobile-app\\):\n- Add SettleToBalanceResult model class\n- Add settleToBalance\\(\\) method to RewardService\n- Update pending_actions_page to handle SETTLE_REWARDS action\n- Add completion detection via settleableUsdt balance check\n\nSettlement memo now includes detailed breakdown by right type with\nsource user accountSequence for each reward entry, e.g.:\n 结算 1000.00 绿积分到钱包余额\n 涉及 5 笔奖励\n - SHARE_RIGHT: 500.00 绿积分\n 来自 D2512120001: 288.00 绿积分\n 来自 D2512120002: 212.00 绿积分\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(withdrawal\\): implement fiat withdrawal with bank/alipay/wechat\n\nAdd complete fiat withdrawal feature that allows users to withdraw\ngreen credits \\(绿积分\\) to their bank card, Alipay, or WeChat account\nwith 1:1 CNY conversion. Key changes:\n\nBackend \\(wallet-service\\):\n- Update Prisma schema with fiat withdrawal fields \\(paymentMethod,\n bankName, bankCardNo, cardHolderName, alipay*, wechat*, review fields\\)\n- Rewrite withdrawal status enum for fiat flow: PENDING → FROZEN →\n REVIEWING → APPROVED → PAYING → COMPLETED \\(or REJECTED/FAILED\\)\n- Add PaymentMethod enum: BANK_CARD, ALIPAY, WECHAT\n- Update WithdrawalOrderAggregate with new fiat withdrawal methods\n- Add review/payment workflow methods in WalletApplicationService\n- Add internal API endpoints for admin withdrawal management\n- Remove blockchain withdrawal event handler \\(no longer needed\\)\n\nFrontend \\(admin-web\\):\n- Add withdrawal review management page at /withdrawals\n- Add tabs for reviewing/approved/paying order states\n- Add withdrawal service and React Query hooks\n- Add types for withdrawal orders and payment methods\n- Add sidebar menu item for withdrawal review\n\nFrontend \\(mobile-app\\):\n- Add withdrawFiat\\(\\) method to WalletService\n- Add PaymentMethod enum with BANK_CARD/ALIPAY/WECHAT\n- Create new WithdrawFiatPage for fiat withdrawal input\n- Create WithdrawFiatConfirmPage with SMS + password verification\n- Add routes for /withdraw/fiat and /withdraw/fiat/confirm\n- Keep existing withdraw/usdt \\(划转\\) pages unchanged\n\nNote: The existing withdraw_usdt_page.dart is for point-to-point\ntransfer \\(划转\\), which is a different feature from fiat withdrawal.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git grep:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(fiat-withdrawal\\): add complete fiat withdrawal system\n\n实现完整的法币提现功能支持银行卡、支付宝、微信三种收款方式。\n此功能与现有的区块链划转功能完全独立互不影响。\n\n## 后端 \\(wallet-service\\)\n\n### 数据库\n- 新增 `fiat_withdrawal_orders` 表存储法币提现订单\n- 与现有 `withdrawal_orders` 表\\(区块链划转\\)完全分离\n- 添加完整索引支持高效查询\n\n### 领域层\n- 新增 `FiatWithdrawalStatus` 枚举(与 WithdrawalStatus 独立)\n - 流程: PENDING -> FROZEN -> REVIEWING -> APPROVED -> PAYING -> COMPLETED\n - 或 REJECTED / FAILED / CANCELLED\n- 新增 `PaymentMethod` 枚举: BANK_CARD / ALIPAY / WECHAT\n- 新增 `FiatWithdrawalOrder` 聚合根\n- 新增 `IFiatWithdrawalOrderRepository` 仓储接口\n- 新增 `FIAT_WITHDRAWAL` 账本流水类型\n\n### 应用层\n- 新增 `FiatWithdrawalApplicationService` 处理业务逻辑\n - 发送短信验证码\n - 申请法币提现(冻结余额)\n - 提交审核\n - 审核通过/驳回\n - 开始打款\n - 完成打款\n\n### API层\n- 新增 `FiatWithdrawalController` 提供用户端API\n - POST /wallet/fiat-withdrawal/send-sms - 发送验证码\n - POST /wallet/fiat-withdrawal - 申请提现\n - GET /wallet/fiat-withdrawal - 获取提现记录\n- 新增内部API供管理端调用\n - GET /api/v1/wallets/fiat-withdrawals - 查询订单\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/review - 审核\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/start-payment - 开始打款\n - POST /api/v1/wallets/fiat-withdrawals/:orderNo/complete-payment - 完成打款\n\n## 前端 \\(admin-web\\)\n\n- 新增法币提现审核管理页面 `/withdrawals`\n- 支持按状态分 Tab 查看订单\n- 支持审核通过/驳回\n- 支持打款操作\n- 支持查看订单详情\n\n## 前端 \\(mobile-app\\)\n\n- 新增 `WithdrawFiatPage` 法币提现页面\n - 支持选择银行卡/支付宝/微信\n - 输入收款账户信息\n- 新增 `WithdrawFiatConfirmPage` 确认页面\n - 短信验证码验证\n - 密码验证\n- 在 `WalletService` 中添加法币提现相关方法和模型\n\n## 重要说明\n\n此功能与现有的区块链划转功能 \\(withdraw_usdt_page.dart\\) 完全独立:\n- 独立的数据库表\n- 独立的聚合根\n- 独立的状态枚举\n- 独立的API端点\n- 独立的前端页面\n\n原有的区块链划转功能保持不变不受任何影响。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(pending-actions\\): add special deduction feature for admin-created user actions\n\n实现特殊扣减功能允许管理员为用户创建扣减待办操作由用户在移动端确认执行。\n\n## 后端 \\(wallet-service\\)\n\n### 领域层\n- 新增 `SPECIAL_DEDUCTION` 到 LedgerEntryType 枚举\n 用于记录特殊扣减的账本流水类型\n\n### 应用层\n- 新增 `executeSpecialDeduction` 方法\n - 验证用户钱包存在性\n - 检查余额是否充足\n - 乐观锁控制并发\n - 扣减余额并记录账本流水\n - 返回操作结果和新余额\n\n### API层\n- 新增内部API: POST /api/v1/wallets/special-deduction/execute\n 供移动端调用执行特殊扣减操作\n\n## 前端 \\(admin-web\\)\n\n### 类型定义\n- 新增 `SPECIAL_DEDUCTION` 到 ACTION_CODES\n- 新增 `SpecialDeductionParams` 接口定义扣减参数\n - amount: 扣减金额\n - reason: 扣减原因\n\n### 页面\n- 更新待办操作管理页面\n - 当选择 SPECIAL_DEDUCTION 时显示扣减金额和原因输入框\n - 验证扣减金额必须大于0\n - 验证扣减原因不能为空\n\n### 样式\n- 新增特殊扣减表单区域样式\n\n## 前端 \\(mobile-app\\)\n\n### 服务层\n- 新增 `executeSpecialDeduction` 方法到 WalletService\n- 新增 `SpecialDeductionResult` 结果类\n- 新增 `specialDeduction` 到 PendingActionCode 枚举\n\n### 页面\n- 新增 `SpecialDeductionPage` 特殊扣减确认页面\n - 显示扣减金额和管理员备注\n - 显示当前余额和扣减后余额\n - 余额不足时禁用确认按钮\n - 温馨提示说明操作性质\n\n- 更新 `PendingActionsPage`\n - 处理 SPECIAL_DEDUCTION 类型的待办操作\n - 从 actionParams 解析 amount 和 reason\n - 导航到特殊扣减确认页面\n\n## 工作流程\n\n1. 管理员在 admin-web 创建 SPECIAL_DEDUCTION 待办操作\n - 选择目标用户\n - 输入扣减金额\n - 输入扣减原因\n\n2. 用户在 mobile-app 待办操作列表看到该操作\n\n3. 用户点击后进入特殊扣减确认页面\n - 查看扣减详情\n - 确认余额充足\n - 点击确认执行扣减\n\n4. 后端执行扣减并记录账本流水\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git check-ignore:*)",
"Bash(git hash-object:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(planting\\): draw signature directly on page instead of using form field\n\nThe PDF signature field is only 92x51 points, which causes signatures to\nappear too small or invisible. Changed to use drawImage\\(\\) directly on\nthe page at the field''s position with a larger size \\(150x80 max\\).\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(pnpm exec tsc:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): add offline settlement deduction feature\n\nAdd new functionality for admins to automatically deduct all settled\nearnings when creating special deductions with amount=0, marking\neach record to prevent duplicate deductions.\n\n- Add OfflineSettlementDeduction model to track deducted records\n- Add API endpoints for querying unprocessed settlements and executing batch deduction\n- Add mode selection UI in admin-web pending-actions\n- Add offline settlement card display in mobile-app special deduction page\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): convert BigInt to string for JSON serialization in getUnprocessedSettlements\n\nThe entry.id field is BigInt type from Prisma which cannot be JSON serialized directly.\nConvert to string for API response and back to BigInt when storing to database.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): improve empty state display for offline settlement deduction\n\nWhen there are no settlement records to deduct, show a more informative message:\n- If user has balance from deposits/transfers: explain it''s not from earnings\n- If user has no balance: explain there are no settlement records\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(xargs:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet/blockchain\\): 热钱包余额预检查及接收方钱包自动创建\n\n1. blockchain-service: 新增热钱包 dUSDT 余额定时更新调度器\n - 每 5 秒查询热钱包在 KAVA 链上的 dUSDT 余额\n - 更新到 Redis DB 0key 格式: hot_wallet:dusdt_balance:{chainType}\n - TTL 30 秒,服务故障时缓存自动过期\n\n2. wallet-service: 新增热钱包余额缓存服务\n - 从 Redis DB 0 读取热钱包余额缓存\n - 严格模式:无法获取余额或余额不足时拒绝转账\n - 提示信息:\"财务系统审计中,请稍后再试\"\n\n3. wallet-service: 转账确认时自动创建接收方钱包\n - 解决接收方钱包不存在导致入账失败的问题\n - 使用 upsert 避免并发创建冲突\n - 在同一事务中完成创建和入账\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加内部转账入账修复脚本\n\n新增一次性修复脚本用于补录因接收方钱包未创建导致入账失败的内部转账。\n\n脚本特性\n- DRY_RUN 模式:默认只检查不执行,需手动改为 false 才真正修复\n- 完整验证订单状态、类型、接收方信息、txHash\n- 幂等性检查:确认接收方没有 TRANSFER_IN 流水\n- 转出方验证:确认转出方有 TRANSFER_OUT 流水(已扣款)\n- 乐观锁:使用 version 字段防止并发修改\n- 审计追踪payloadJson.dataFix=true 标记修复操作\n- 详细日志:每步操作都有时间戳和日志级别\n\n使用方法\n1. 在 wallet-service 容器内执行 DRY_RUN 检查\n2. 确认无误后将 DRY_RUN 改为 false 再次执行\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 添加待办操作轮询机制\n\n解决老版本 App 升级后不重启导致无法激活待办事项的问题。\n\n- 新增 PendingActionPollingService 定时轮询服务每4秒检查\n- App启动时无待办则启动轮询有待办则直接进入待办页面\n- 轮询检测到待办后自动停止并跳转,防止重入问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 用户资料页添加\"同伴认种\"标题和快捷标签\n\n- 在统计卡片上方添加\"同伴认种\"标题(紫色)\n- 在统计卡片下方添加\"引荐\"、\"同伴\"、\"本人\"快捷标签\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 用户资料页术语修改\n\n- 直推 → 引荐\n- 伞下 → 同伴\n- 个人认种 → 本人认种\n- 团队认种 → 同伴认种\n- 推荐人 → 引荐人\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 已结算数据改为从流水统计API获取\n\n- 从 wallet-service 的 getLedgerStatistics\\(\\) 获取 REWARD_SETTLED 类型的总金额\n- 与流水明细中的结算记录统计来自同一数据源,确保数据一致性\n- 添加调试日志对比 summary 和流水统计的数据\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(authorization\\): 火柴人排名过滤已撤销授权的考核记录\n\n- findRankingsByMonthAndRegion 和 findRankingsByMonthAndRoleType 增加过滤条件\n- 排除 authorization.status = ''REVOKED'' 的记录\n- 解决同一用户因有多条授权记录(含已撤销)而重复显示的问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 修复 WalletServiceClient 未正确解析 wallet-service 响应格式的 Bug\n\n问题原因:\nwallet-service 使用全局 TransformInterceptor 拦截器,会将所有响应包装成:\n{ success: true, data: { success: boolean, ... }, timestamp: \"...\" }\n\n原代码直接读取外层的 success 字段(始终为 true导致即使业务失败\n内层 data.success = false也被误判为成功。\n\n具体案例:\n用户 D25122700024 点击结算时wallet-service 因余额不足返回:\n{ success: true, data: { success: false, error: \"Insufficient...\" }, ... }\nreward-service 误读为成功,导致奖励被标记为 SETTLED 但钱包余额未变更。\n\n修复内容:\n1. settleToBalance: 解析 response_data.data 获取真实业务结果\n2. confirmPlantingDeduction: 同上\n3. allocateFunds: 同上\n\n所有方法现在会:\n- 使用 response_data.data || response_data 兼容包装和非包装格式\n- 严格检查 data.success !== true 来判断业务是否成功\n- 失败时记录详细错误日志\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 统一奖励分配到 settleable_usdt与 reward-service 保持一致\n\n问题原因:\nwallet-service 对不同类型奖励的分配方式不一致:\n- SHARE_RIGHT: 正确使用 addSettleableReward\\(\\) → settleable_usdt\n- CITY_TEAM_RIGHT/COMMUNITY_RIGHT: 错误使用 addAvailableBalance\\(\\) → usdt_available\n\n这导致 reward-service 记录的 SETTLEABLE 奖励总额与 wallet-service 的\nsettleable_usdt 字段不匹配。用户 D25122700024 的案例中:\n- reward-service: 3条奖励共 4464 USDT \\(SHARE_RIGHT 3600 + CITY_TEAM_RIGHT 288 + COMMUNITY_RIGHT 576\\)\n- wallet-service: settleable_usdt = 3600 \\(仅 SHARE_RIGHT\\)\n差额 864 USDT 被错误地放入了 usdt_available\n\n修复内容:\n1. allocateCommunityRight: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n2. allocateToRegionAccount: 改用 addSettleableReward\\(\\) 替代 addAvailableBalance\\(\\)\n3. 流水类型统一使用 REWARD_TO_SETTLEABLE 替代 SYSTEM_ALLOCATION\n4. 日志和备注更新以反映新的分配方式\n\n设计原则:\n- reward-service 是奖励的权威来源\n- wallet-service 应跟随 reward-service 的设计\n- 所有奖励都应进入 settleable_usdt用户主动结算后才转入 usdt_available\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ls \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\reward-service\\\\prisma\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendservicesreward-serviceprisma\"\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复 settleToBalance 方法缺少事务保护的严重 Bug\n\n问题原因:\nsettleToBalance 方法先执行 wallet.save\\(\\) 更新账户余额,再执行\nledgerRepo.save\\(\\) 写入流水记录。两个操作不在同一个事务中。\n\n当流水写入失败时如 memo 字段超过 VarChar\\(500\\) 限制),账户余额\n已经被修改但流水记录未写入导致数据不一致。\n\n具体案例:\n用户 D25122700023 点击结算时memo 内容超长66笔奖励详情\nwallet-service 先把 settleable_usdt 转入 usdt_available然后\n写流水失败。账户余额被改但没有对应流水。\n\n修复内容:\n1. settleToBalance: 使用 prisma.$transaction 确保原子性\n - 账户余额更新和流水记录在同一事务中\n - 任一操作失败整个事务回滚\n2. schema: memo 字段从 VarChar\\(500\\) 改为 Text 类型,无长度限制\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 实现 Unit of Work 模式保证 settleToBalance 事务原子性\n\n- 新增 UnitOfWork 接口和实现,使用 Prisma Interactive Transaction\n- 修改 IWalletAccountRepository 和 ILedgerEntryRepository 接口支持可选事务参数\n- 修改仓库实现,支持在事务中执行数据库操作\n- 修改 settleToBalance 方法使用 UnitOfWork确保钱包更新和流水记录原子性\n- 注册 UnitOfWorkService 到 InfrastructureModule\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\wallet-service\\\\prisma\\\\migrations\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianbackendserviceswallet-serviceprismamigrations \")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-web\\): 添加系统账户收益类型汇总统计功能\n\n在数据统计-系统账户中新增5个统计Tab\n- 手续费账户汇总统计成本费、运营费、总部社区基础费、RWAD底池注入\n- 省团队收益汇总:统计省团队权益收益\n- 市团队收益汇总:统计市团队权益收益\n- 分享引荐收益汇总:统计分享权益收益\n- 社区收益汇总:统计社区权益收益\n\n后端变更\n- reward-service: 添加 getRewardsSummaryByType、getAllRewardTypeSummaries 方法\n- reporting-service: 聚合收益类型汇总统计接口\n\n前端变更\n- 添加 RewardTypeSummary、FeeAccountSummary 类型定义\n- 添加 getRewardTypeSummaries API 方法\n- 添加 FeeAccountSection、RewardTypeSummarySection 组件\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 实现手续费归集账户功能\n\n- 新增系统账户 S0000000006 \\(user_id=-6\\) 用于归集提现手续费\n- 新增 FEE_COLLECTION 流水类型记录手续费归集\n- 区块链提现完成时使用 UnitOfWork 事务归集手续费\n- 法币提现完成时在事务中归集手续费\n- WithdrawalOrderRepository 添加事务支持\n- 所有手续费归集操作使用乐观锁保护\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(backend/services/blockchain-service/src/application/application.module.ts )",
"Bash(backend/services/blockchain-service/src/application/event-handlers/system-withdrawal-requested.handler.ts )",
"Bash(backend/services/blockchain-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts )",
"Bash(backend/services/wallet-service/src/api/api.module.ts )",
"Bash(backend/services/wallet-service/src/api/controllers/index.ts )",
"Bash(backend/services/wallet-service/src/api/controllers/system-withdrawal.controller.ts )",
"Bash(backend/services/wallet-service/src/application/services/index.ts )",
"Bash(backend/services/wallet-service/src/application/services/system-withdrawal-application.service.ts )",
"Bash(backend/services/wallet-service/src/application/event-handlers/system-withdrawal-status.handler.ts )",
"Bash(backend/services/wallet-service/src/infrastructure/external/identity/identity-client.service.ts )",
"Bash(backend/services/wallet-service/src/infrastructure/kafka/withdrawal-event-consumer.service.ts)",
"Bash(backend/services/planting-service/src/api/controllers/planting-stats.controller.ts )",
"Bash(backend/services/planting-service/src/api/dto/response/planting-stats.response.ts )",
"Bash(backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts )",
"Bash(backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts )",
"Bash(frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/statistics/page.tsx )",
"Bash(frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/statistics/statistics.module.scss )",
"Bash(frontend/admin-web/src/services/dashboardService.ts )",
"Bash(frontend/admin-web/src/types/dashboard.types.ts)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet/blockchain/identity\\): implement system account withdrawal feature\n\n- Add SystemWithdrawalApplicationService to handle system account transfers\n- Add SystemWithdrawalController with endpoints for request, query, and account listing\n- Add SystemWithdrawalStatusHandler to process blockchain confirmation/failure events\n- Add SystemWithdrawalRequestedHandler in blockchain-service to execute ERC20 transfers\n- Add getUserByAccountSequence endpoint in identity-service for user lookup\n- Support dynamic memo generation based on actual source account name\n- Dual-sided ledger entries for system account transfers\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(frontend/admin-web/src/hooks/index.ts )",
"Bash(frontend/admin-web/src/hooks/useSystemWithdrawal.ts )",
"Bash(frontend/admin-web/src/services/systemWithdrawalService.ts )",
"Bash(frontend/admin-web/src/types/system-withdrawal.types.ts )",
"Bash(\"frontend/admin-web/src/app/\\(dashboard\\)/system-transfer/\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-web\\): add system account transfer management page\n\n- Add system-transfer page with transfer form and order history\n- Add SystemWithdrawalService for API calls\n- Add useSystemWithdrawal hooks for React Query integration\n- Add system-withdrawal types definitions\n- Add navigation menu item for system transfer\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(PGPASSWORD=rwa_dev_password psql:*)",
"Bash(where psql:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reporting-service\\): 修复面对面结算数据解包问题\n\nwallet-service 返回 { success, data, timestamp } 包装格式,\ngetOfflineSettlementSummary 需要用 response.data.data 解包才能获取真正的数据。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet/reporting\\): 修复手续费归集统计 API 的数据库表名和响应解包问题\n\n- wallet-service: 修复 getFeeCollectionSummary 中原生 SQL 使用错误表名\n - 将 ledger_entries 改为 wallet_ledger_entriesPrisma 映射表名)\n- reporting-service: 修复 getFeeCollectionSummary/Entries 响应解包\n - wallet-service 返回 { success, data, timestamp } 格式需要解包 data\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加手续费归集统计的历史数据兼容\n\n当 FEE_COLLECTION 流水为空时,自动从提现订单表查询历史手续费:\n- getFeeCollectionSummary: 从 withdrawal_orders 和 fiat_withdrawal_orders 聚合统计\n- getFeeCollectionEntries: 从两个订单表查询明细列表,支持分页和类型筛选\n- 按月统计使用 UNION ALL 合并两种提现订单数据\n- 明细记录添加备注说明区分来源(区块链/法币)\n\n回滚方式删除 fallback 代码块和两个私有方法\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(dir /s /b *.yml)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 添加联系客服功能\n\n在个人中心设置菜单中添加\"联系客服\"入口,点击后显示弹窗,\n用户可以查看客服的QQ号和微信号并支持一键复制到剪贴板。\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service, admin-web\\): 修复系统账户划转金额类型问题\n\n- wallet-service: 支持 amount 为字符串或数字类型,添加类型转换\n- admin-web: 改进错误处理,正确提取 Axios 错误消息\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 更新客服联系方式\n\n- 客服微信1: liulianhuanghou1\n- 客服微信2: liulianhuanghou2\n- 客服QQ1: 1502109619\n- 客服QQ2: 2171447109\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 添加运营1和积分股池到系统划转账户列表\n\n- 添加 S0000000002 \\(运营1\\) 和 S0000000004 \\(积分股池\\) 到允许转出白名单\n- 更新系统账户名称映射与前端保持一致\n- 为 S0000000006 手续费归集账户添加兼容逻辑当余额为0时从提现订单表统计历史手续费\n- 优化过期奖励处理,按分配类型分别记录流水便于明细查看\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复系统账户余额统计不一致问题\n\n- 账户余额改为 usdtAvailable + settleableUsdt与累计收入统计保持一致\n- 解决社区权益进入 settleableUsdt 导致的余额与累计收入不匹配问题\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(npx eslint:*)",
"Bash(backend/services/admin-service/src/infrastructure/kafka/cdc-consumer.service.ts )",
"Bash(backend/services/admin-service/src/infrastructure/kafka/index.ts )",
"Bash(backend/services/admin-service/src/infrastructure/kafka/kafka.module.ts )",
"Bash(backend/services/deploy.sh )",
"Bash(backend/services/docker-compose.yml )",
"Bash(backend/services/scripts/init-databases.sh )",
"Bash(backend/services/scripts/debezium/)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 实现 Debezium CDC 数据同步\n\n- 新增 CdcConsumerService 消费 PostgreSQL WAL 变更事件\n- 配置 Debezium Connect 服务和 PostgreSQL 逻辑复制\n- 更新 deploy.sh 支持 Debezium 启动和连接器管理\n- 新增 identity-postgres-connector 配置同步 user_accounts 表\n- 保留原有 Outbox 机制用于业务领域事件\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(referral-service\\): 修复 Kafka 消费异常被吞掉的问题\n\n- kafka.service.ts: 抛出异常让 KafkaJS 触发重试\n- user-registered.handler.ts: 传播异常到 KafkaService\n\n修复前处理失败的消息不会重试导致推荐关系可能丢失\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(leaderboard-service\\): 修复健康检查 API 路径\n\n将 Dockerfile 和 docker-compose.yml 中的健康检查路径从\n/api/health 修改为 /api/v1/health与实际 API 路由保持一致\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(backend/services/admin-service/prisma/migrations/20250107100000_add_referral_query_view/ )",
"Bash(backend/services/admin-service/src/infrastructure/kafka/referral-cdc-consumer.service.ts )",
"Bash(backend/services/scripts/debezium/referral-connector.json)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 添加 referral-service CDC 数据同步\n\n- 新增 ReferralQueryView schema 和 migration\n- 新增 ReferralCdcConsumerService 消费推荐关系变更\n- 配置 referral-postgres-connector 用于 Debezium CDC\n- 更新 deploy.sh 自动注册 referral connector\n- 更新 init-databases.sh 配置 rwa_referral 逻辑复制权限\n\nCDC 同步的字段:\n- user_id, account_sequence, referrer_id\n- my_referral_code, used_referral_code\n- ancestor_path, depth\n- direct_referral_count, active_direct_count\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(admin-service\\): 添加 CDC 分类账流水同步\n\n新增 wallet/planting/authorization 服务的 CDC 数据同步:\n\n状态表同步:\n- WalletAccountQueryView: 钱包账户余额状态\n- WithdrawalOrderQueryView: 提现订单状态\n- FiatWithdrawalOrderQueryView: 法币提现订单\n- PlantingOrderQueryView: 认种订单状态\n- PlantingPositionQueryView: 持仓状态\n- ContractSigningTaskQueryView: 合同签约任务\n- AuthorizationRoleQueryView: 授权角色\n- MonthlyAssessmentQueryView: 月度考核\n- SystemAccountQueryView: 系统账户余额\n\n分类账流水同步:\n- WalletLedgerEntryView: 钱包流水分类账\n- FundAllocationView: 认种资金分配记录\n- SystemAccountLedgerView: 系统账户流水\n\n其他:\n- Debezium Connect 端口改为 8084 避免冲突\n- 更新连接器配置添加流水表\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash($env:DATABASE_URL=\"postgresql://test:test@localhost:5432/test\")",
"Bash(DATABASE_URL=\"postgresql://test:test@localhost:5432/test\" npx prisma validate:*)",
"Bash(DATABASE_URL=\"postgresql://test:test@localhost:5432/test\" npx prisma format:*)",
"Bash(timeout 60 npx tsc:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(wallet-service\\): 三层保护机制确保内部转账接收方钱包存在\n\n新增三层保护机制\n1. 用户注册时:监听 identity.UserAccountCreated 事件自动创建钱包\n2. 发起转账时:检测内部转账后调用 ensureWalletExists\\(\\) 预创建钱包\n3. 链上确认时:原有 upsert 逻辑兜底(保持不变)\n\n新增文件\n- identity-event-consumer.service.ts: 消费 identity 用户注册事件\n- user-account-created.handler.ts: 处理用户注册事件创建钱包\n\n新增 API\n- POST /wallets/ensure-wallet: 确保单个钱包存在\n- POST /wallets/ensure-wallets: 批量确保钱包存在\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add -A)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复合同PDF签署日期显示为UTC时间的问题\n\n合同生成时使用 new Date\\(\\).toISOString\\(\\).split\\(''T''\\)[0] 获取日期,\n该方法返回UTC时间导致北京时间凌晨签署的合同显示为前一天日期。\n\n修复方案新增 getBeijingDateString\\(\\) 函数将UTC时间转换为北京时间\\(UTC+8\\)\n\n影响范围仅影响PDF合同上显示的签署日期不影响数据库时间戳或业务逻辑\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin main)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" tag -a v1.0.0 -m \"$\\(cat <<''EOF''\nRelease v1.0.0 - 正式发布\n\n主要功能:\n- 用户身份认证与KYC实名认证\n- 榴莲树认种与合同签署系统\n- 钱包与资产管理USDT/绿积分/算力)\n- 推荐关系与团队管理\n- 收益分配与奖励系统\n- 排行榜系统\n- 后台管理系统\n- MPC多方计算钱包\n- 区块链服务KAVA链\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" push origin v1.0.0)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复用户数据CDC同步使用userId导致的数据不一致问题\n\n问题原因:\n- 旧的Kafka事件消费者和CDC消费者同时运行\n- 旧消费者写入的数据userId可能为0\n- CDC消费者使用userId作为upsert条件导致唯一键冲突失败\n- 用户的nickname和kycStatus等信息没有正确同步\n\n修复方案:\n- upsert方法改用accountSequence作为唯一键\n- CDC消费者的handleUpdate使用accountSequence检查和更新\n- 更新时同时修复可能错误的userId\n- 新增existsByAccountSequence和updateKycStatusByAccountSequence方法\n\n影响范围:\n- admin-web用户管理页面现在能正确显示用户昵称和KYC状态\n- 新用户注册后数据能正确同步\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff backend/services/docker-compose.yml)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add backend/services/docker-compose.yml)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 添加uploads目录的volume持久化配置\n\n问题admin-service重新部署后上传的APK文件会丢失\n原因主docker-compose.yml中admin-service未配置volume挂载\n 导致容器重建时/app/uploads目录数据丢失\n\n修复\n- 添加admin_uploads_data volume挂载到/app/uploads\n- 添加UPLOAD_DIR环境变量\n- 在volumes部分声明admin_uploads_data\n\n影响范围仅影响admin-service的文件存储持久化\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/admin-web/src/app/\\\\\\(dashboard\\\\\\)/authorization/page.tsx)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(admin-web\\): 优化授权页面错误提示,显示后端真实错误信息\n\n问题创建授权失败时只显示\"Request failed with status code 400\"\n用户无法了解失败的真实原因如用户未种树、授权冲突等\n\n修复\n- handleCreate和handleRevoke的catch块优先从err.response.data.message提取后端错误\n- 后端已有完善的错误提示如\"用户尚未认种任何树,无法授权\"\n- 前端现在能正确显示这些提示帮助管理员了解真实情况\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" diff frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" checkout -- frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" add frontend/mobile-app/lib/features/pending_actions/presentation/pages/pending_actions_page.dart)",
"Bash(git -C \"c:/Users/dong/Desktop/rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mobile-app\\): 修复认种向导待办操作无法正确标记完成的问题\n\n问题用户完成认种并签署合同后ADOPTION_WIZARD待办操作没有被标记为完成\n导致用户被卡在待办操作页面无法进入App。\n\n原因原来的检查逻辑只检查是否有\"待签合同\",当用户已签署合同后,\npendingTasks为空返回false导致待办操作无法完成。\n\n修复方案\n- 改为检查用户是否有已支付的认种订单PAID/FUND_ALLOCATED状态\n- 通过比较订单创建时间和待办操作创建时间来判断\n- 订单在待办操作之后创建 → 已完成\n- 订单在待办操作之前但相差不超过24小时 → 也认为已完成(兼容延迟)\n- 保留待签合同的备用检查逻辑\n\n影响范围仅影响ADOPTION_WIZARD待办操作的完成检测\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(contribution-service\\): 添加算力管理微服务\n\n## 概述\n为榴莲生态2.0添加 contribution-service 微服务,负责算力计算、分配和快照管理。\n\n## 架构设计\n- 采用 DDD + Hexagonal Architecture \\(六边形架构\\)\n- 使用 NestJS 框架 + Prisma ORM\n- 通过 Kafka CDC \\(Debezium\\) 从 user-service 同步数据\n- 使用 accountSequence \\(而非 userId\\) 进行跨服务关联\n\n## 核心功能模块\n\n### 1. Domain Layer \\(领域层\\)\n- ContributionAccountAggregate: 算力账户聚合根\n- ContributionRecordAggregate: 算力记录聚合根\n- ContributionAmount: 算力金额值对象 \\(基于 Decimal.js\\)\n- DistributionRate: 分配比例值对象\n- ContributionSourceType: 算力来源类型枚举 \\(PERSONAL/TEAM_LEVEL/TEAM_BONUS\\)\n\n### 2. Application Layer \\(应用层\\)\n- ContributionCalculationService: 算力计算核心服务\n - 个人算力: 认种金额 × 10\n - 团队等级奖励: 基于直推有效认种人数\n - 团队极差奖励: 多级分销算法\n- SnapshotService: 每日算力快照服务\n- CDC Event Handlers: 处理用户、认种、引荐关系同步事件\n\n### 3. Infrastructure Layer \\(基础设施层\\)\n- Prisma Repositories: \n - ContributionAccountRepository\n - ContributionRecordRepository\n - SyncedDataRepository \\(同步数据\\)\n - OutboxRepository \\(发件箱模式\\)\n - SystemAccountRepository\n - UnallocatedContributionRepository\n- Kafka CDC Consumer: 消费 Debezium CDC 事件\n- Redis: 缓存支持\n- UnitOfWork: 事务管理\n\n### 4. API Layer \\(接口层\\)\n- ContributionController: 算力查询接口\n- SnapshotController: 快照管理接口\n- HealthController: 健康检查\n\n## 数据模型 \\(Prisma Schema\\)\n- ContributionAccount: 算力账户\n- ContributionRecord: 算力记录 \\(支持过期\\)\n- DailyContributionSnapshot: 每日快照\n- SyncedUser/SyncedAdoption/SyncedReferral: CDC 同步数据\n- OutboxEvent: 发件箱事件\n- SystemContributionAccount: 系统账户\n- UnallocatedContribution: 未分配算力\n\n## TypeScript 类型修复\n- 修复所有 Repository 接口与实现的类型不匹配\n- 修复 ContributionAmount.multiply\\(\\) 返回值类型\n- 修复 isZero getter vs method 问题\n- 修复 bigint vs string 类型转换\n- 统一使用 items/total 返回格式\n- 修复 Prisma schema 字段名映射 \\(unallocType, contributionBalance 等\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-ecosystem\\): 添加挖矿生态系统完整微服务与前端\n\n## 概述\n为榴莲生态2.0添加完整的挖矿系统包含3个后端微服务、1个管理后台和1个用户端App。\n\n---\n\n## 后端微服务\n\n### 1. mining-service \\(挖矿服务\\) - Port 3021\n**核心功能:**\n- 积分股每日分配(基于算力快照)\n- 每分钟定时销毁(进入黑洞)\n- 价格计算:价格 = 积分股池 ÷ \\(100.02亿 - 黑洞 - 流通池\\)\n- 全局状态管理(黑洞量、流通池、价格)\n\n**关键文件:**\n- src/application/services/mining-distribution.service.ts - 挖矿分配核心逻辑\n- src/application/schedulers/mining.scheduler.ts - 定时任务调度\n- src/domain/services/mining-calculator.service.ts - 分配计算\n- src/infrastructure/persistence/repositories/black-hole.repository.ts - 黑洞管理\n\n### 2. trading-service \\(交易服务\\) - Port 3022\n**核心功能:**\n- 积分股买卖撮合\n- K线数据生成\n- 手续费处理10%买入/卖出)\n- 流通池管理\n- 卖出倍数计算:倍数 = \\(100亿 - 销毁量\\) ÷ \\(200万 - 流通池量\\)\n\n**关键文件:**\n- src/domain/services/matching-engine.service.ts - 撮合引擎\n- src/application/services/order.service.ts - 订单处理\n- src/application/services/transfer.service.ts - 划转服务\n- src/domain/aggregates/order.aggregate.ts - 订单聚合根\n\n### 3. mining-admin-service \\(挖矿管理服务\\) - Port 3023\n**核心功能:**\n- 系统配置管理(分配参数、手续费率等)\n- 老用户数据初始化\n- 系统监控仪表盘\n- 审计日志\n\n**关键文件:**\n- src/application/services/config.service.ts - 配置管理\n- src/application/services/initialization.service.ts - 数据初始化\n- src/application/services/dashboard.service.ts - 仪表盘数据\n\n---\n\n## 前端应用\n\n### 1. mining-admin-web \\(管理后台\\) - Next.js 14\n**技术栈:**\n- Next.js 14 + React 18\n- TailwindCSS + Radix UI\n- React Query + Zustand\n- ECharts 图表\n\n**功能模块:**\n- 登录认证\n- 仪表盘(实时数据、价格走势)\n- 用户查询(算力详情、挖矿记录、交易订单)\n- 系统配置管理\n- 数据初始化任务\n- 审计日志查看\n\n### 2. mining-app \\(用户端App\\) - Flutter 3.x\n**技术栈:**\n- Flutter 3.x + Dart\n- Riverpod 状态管理\n- GoRouter 路由\n- Clean Architecture \\(3层\\)\n\n**功能模块:**\n- 首页资产总览\n- 实时收益显示(每秒更新)\n- 贡献值展示(个人/团队)\n- 积分股买卖交易\n- K线图与价格显示\n- 个人中心\n\n---\n\n## 架构文档\n- docs/mining-ecosystem-architecture.md - 系统架构总览\n - 服务职责与端口分配\n - 数据流向图\n - Kafka Topics 定义\n - 跨服务关联account_sequence\n - 配置参数说明\n - 开发顺序建议\n\n---\n\n## .gitignore 更新\n- 添加 Flutter/Dart 构建文件忽略\n- 添加 iOS/Android 构建产物忽略\n- 添加 Next.js 构建目录忽略\n- 添加 TypeScript 缓存文件忽略\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining\\): 添加 2.0 挖矿系统独立部署管理脚本\n\n添加 deploy-mining.sh 脚本用于管理 2.0 挖矿生态系统,\n该系统与 1.0 完全隔离,可随时重置而不影响 1.0。\n\n## 功能\n\n### 服务管理\n- up/down/restart - 启动/停止/重启 2.0 服务\n- status - 查看服务状态\n- logs [service] - 查看日志\n- build - 构建服务\n\n### 数据库管理\n- db-create - 创建 2.0 数据库\n- db-migrate - 运行 Prisma 迁移\n- db-reset - 删除并重建数据库(危险操作)\n- db-status - 查看数据库状态\n\n### CDC 同步管理\n- sync-reset - 重置 CDC 消费者偏移量到开始位置\n- sync-status - 查看 CDC 消费者组状态\n\n### 完整重置\n- full-reset - 完整系统重置\n 1. 停止所有 2.0 服务\n 2. 删除所有 2.0 数据库\n 3. 重建数据库\n 4. 运行迁移\n 5. 重置 CDC 偏移量\n 6. 重启服务(从 1.0 重新同步)\n\n### 健康监控\n- health - 检查所有组件健康状态\n- stats - 显示系统统计信息\n\n## 2.0 服务\n- contribution-service \\(3020\\)\n- mining-service \\(3021\\)\n- trading-service \\(3022\\)\n- mining-admin-service \\(3023\\)\n\n## 2.0 数据库\n- rwa_contribution\n- rwa_mining\n- rwa_trading\n- rwa_mining_admin\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(npx prisma format:*)",
"Bash(while read svc)",
"Bash(do echo \"=== $svc ===\")",
"Bash(for svc in admin-service auth-service authorization-service backup-service blockchain-service contribution-service identity-service leaderboard-service mining-admin-service mining-service mpc-service planting-service presence-service referral-service reporting-service reward-service trading-service wallet-service)",
"Bash(ssh ceshi@14.215.128.96 \"curl -s -o /dev/null -w ''%{http_code}'' https://madmin.szaiai.com/ --connect-timeout 10\")",
"Bash(curl -s -o /dev/null -w '%{http_code}' https://madmin.szaiai.com/ --connect-timeout 15)",
"Bash(ssh ceshi@14.215.128.96 \"docker network ls | grep rwa\")",
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/backend/services && git pull && ./deploy-mining.sh rebuild mining-admin-service --no-cache\")",
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\mining-admin-service\\\\src\\\\*.ts\")",
"Bash(DATABASE_URL=\"postgresql://user:pass@localhost:5432/db\" npx prisma migrate:*)",
"Bash(ssh ceshi@103.39.231.231 \"ls -la /etc/nginx/sites-enabled/ && cat /etc/nginx/sites-available/rwaapi.szaiai.com 2>/dev/null | head -100\")",
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && git pull && cat .env.production\")",
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && docker compose down && docker compose build --no-cache && docker compose up -d\")",
"Bash(ssh ceshi@14.215.128.96 \"docker ps | grep -E ''mining-admin|rwa-mining''\")",
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && grep -A10 ''mining-admin-service'' backend/api-gateway/kong.yml | head -15\")",
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian/backend/api-gateway && docker compose exec kong kong reload 2>/dev/null || docker exec kong kong reload\")",
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/health 2>/dev/null || echo ''Service not reachable''\")",
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://192.168.1.111:3023/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"test\"\"}''\")",
"Bash(ssh ceshi@103.39.231.231 \"cd /home/ceshi/rwadurian && git pull && docker exec kong kong reload\")",
"Bash(ssh ceshi@103.39.231.231 \"docker ps | grep -i kong\")",
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/login -X POST -H ''Content-Type: application/json'' -d ''{\"\"username\"\":\"\"admin\"\",\"\"password\"\":\"\"admin123\"\"}''\")",
"Bash(ssh ceshi@103.39.231.231 \"curl -s http://localhost:8000/api/v2/mining-admin/auth/profile -H ''Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3ODRlNTA0MS1hYTM2LTQ0ZTctYTM1NS0yY2I2ZjYwYmY1YmIiLCJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6IlNVUEVSX0FETUlOIiwiaWF0IjoxNzY4MTIyMjc3LCJleHAiOjE3NjgyMDg2Nzd9.XL0i0_tQlybkT9ktLIP90WQZDujPbbARL20h6fLmeRE''\")",
"Bash(user \")",
"mcp__UIPro__getCodeFromUIProPlugin",
"Bash(flutter create:*)",
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth?schema=public\" npx prisma migrate dev:*)",
"Bash(curl -s http://103.118.40.14:8001/routes/contribution-v2-api)",
"Bash(curl -s http://103.118.40.14:8001/services/contribution-service-v2)",
"Bash(ssh ceshi@103.39.231.231 \"cd /data/rwadurian/backend/api-gateway && git pull origin main && docker-compose restart kong\")",
"Bash(ssh ceshi@103.39.231.231 \"ls -la /data/ 2>/dev/null || ls -la / | grep -E ''data|home|opt''\")",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJEMjUxMjI3MDAwMjIiLCJwaG9uZSI6IjE4OTI2NzYyNzIxIiwic291cmNlIjoiVjEiLCJpYXQiOjE3NjgxODM5NTIsImV4cCI6MTc2ODc4ODc1Mn0.Uq6TCFWHO64fD_MUP2IoBJzaXo99HDcp0H5s5A14EXQ\")",
"Bash(ssh ceshi@103.39.231.231 \"ssh ceshi@192.168.1.111 ''cd /home/durian/rwadurian && git pull && cd backend/services && ./deploy.sh rebuild auth-service''\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-admin-web\\): 复用admin-web用户管理功能\n\n- 更新用户列表:添加头像、个人/团队认种、推荐人、状态徽章\n- 更新用户详情添加头像、KYC状态、认种统计卡片\n- 新增引荐关系Tab展示引荐人链和直推下级树\n- 新增认种信息Tab认种汇总和认种分类账明细\n- 新增钱包信息Tab钱包汇总和钱包分类账明细\n- 更新类型定义和API hooks\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ssh ceshi@14.215.128.96 \"cd /home/ceshi/rwadurian/frontend/mining-admin-web && git pull && ls -la deploy.sh\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" diff)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" push origin main)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-admin-web/next.config.js)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-admin-web\\): 修复 API rewrite 路径为 v2\n\n将 next.config.js 中的 API rewrite 从 /api/v1 改为 /api/v2\n与 mining-admin-service 的实际 API 前缀保持一致。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" log --oneline -3)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add backend/services/deploy-mining.sh)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfeat\\(deploy\\): 添加 mining-wallet-service 到 deploy-mining.sh\n\n将 mining-wallet-service 加入 2.0 系统管理脚本:\n\n- 添加到 MINING_SERVICES 数组\n- 添加别名 wallet -> mining-wallet-service\n- 添加数据库 rwa_mining_wallet\n- 添加 SERVICE_DB 映射\n- 添加端口 3025\n- 更新帮助文档\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nrefactor\\(deploy\\): 移除 mining-admin-web 从 deploy-mining.sh\n\nmining-admin-web 是前端项目,不应该在后端服务部署脚本中管理。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add backend/services/contribution-service/Dockerfile backend/services/mining-admin-service/Dockerfile)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(docker\\): 修复 contribution-service 和 mining-admin-service Dockerfile healthcheck 路径\n\n将 healthcheck 路径从 /api/v1/health 改为 /api/v2/health\n与 main.ts 中的 API 前缀保持一致。\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" log --oneline -5)",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && git pull origin main\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services/auth-service && npm run build 2>&1 | tail -20\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild auth-service 2>&1 | tail -50\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild contribution-service 2>&1 | tail -50\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh rebuild mining-admin-service 2>&1 | tail -50\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/services && ./deploy-mining.sh status\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(auth\\): 修复 LegacyUserCdcConsumer 的 OutboxService 依赖注入\n\n- 在 ApplicationModule 中导出 OutboxService\n- 在 InfrastructureModule 中使用 forwardRef 导入 ApplicationModule\n- 解决循环依赖问题\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(deploy\\): 修正 Debezium Connect 默认端口为 8084\n\ndocker-compose 中 Debezium Connect 映射到 8084 端口\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(debezium\\): 修复 outbox connector 配置中的数据库凭证\n\n使用实际的用户名和密码替代环境变量占位符\n因为 envsubst 不支持带默认值的变量语法\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c \"\"\nSELECT ''synced_users'' as table_name, COUNT\\(*\\) as count FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts\nUNION ALL SELECT ''synced_mining_configs'', COUNT\\(*\\) FROM synced_mining_configs\nUNION ALL SELECT ''synced_circulation_pools'', COUNT\\(*\\) FROM synced_circulation_pools\nUNION ALL SELECT ''synced_system_contributions'', COUNT\\(*\\) FROM synced_system_contributions\nUNION ALL SELECT ''synced_daily_mining_stats'', COUNT\\(*\\) FROM synced_daily_mining_stats\nUNION ALL SELECT ''synced_day_klines'', COUNT\\(*\\) FROM synced_day_klines;\n\"\"\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -t -c \"\"\nSELECT ''synced_users'' as tbl, COUNT\\(*\\) FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts;\n\"\"\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT count\\(*\\) FROM pg_stat_activity;\"\"\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker restart rwa-postgres && sleep 10 && docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -t -c \"\"\nSELECT ''synced_users'' as tbl, COUNT\\(*\\) FROM synced_users\nUNION ALL SELECT ''synced_contribution_accounts'', COUNT\\(*\\) FROM synced_contribution_accounts\nUNION ALL SELECT ''synced_mining_accounts'', COUNT\\(*\\) FROM synced_mining_accounts\nUNION ALL SELECT ''synced_trading_accounts'', COUNT\\(*\\) FROM synced_trading_accounts;\n\"\"\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT datname, count\\(*\\) FROM pg_stat_activity GROUP BY datname ORDER BY count DESC;\"\"\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SHOW max_connections;\"\" && docker exec rwa-postgres psql -U rwa_user -d postgres -c \"\"SELECT count\\(*\\) as current_connections FROM pg_stat_activity;\"\"\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(postgres\\): 增加数据库最大连接数到 300\n\n- max_connections: 100 -> 300\n- max_replication_slots: 10 -> 20 \n- max_wal_senders: 10 -> 20\n\n支持更多服务和 Debezium connectors 同时连接\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''SELECT * FROM synced_users LIMIT 2;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''SELECT * FROM synced_contribution_accounts LIMIT 2;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT account_sequence, has_adopted, direct_referral_adopted_count, unlocked_level_depth FROM contribution_accounts LIMIT 5;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT account_sequence, adopter_count FROM synced_users LIMIT 5;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''\\\\d synced_users''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT * FROM synced_adoptions LIMIT 3;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_contribution -c ''SELECT * FROM synced_referrals LIMIT 3;''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c ''\\\\d synced_users''\")",
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" ceshi@192.168.1.111 \"docker exec rwa-postgres psql -U rwa_user -d rwa_mining_admin -c \"\"SELECT table_name FROM information_schema.tables WHERE table_schema=''public'' ORDER BY table_name;\"\"\")",
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\contribution-service\\\\src\\\\domain\\\\events\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sync\\): 完善 CDC 数据同步 - 添加推荐关系、认种记录和昵称字段\n\n- auth-service:\n - SyncedLegacyUser 表添加 nickname 字段\n - LegacyUserMigratedEvent 添加 nickname 参数\n - CDC consumer 同步 nickname 字段\n - SyncedLegacyUserData 接口添加 nickname\n\n- contribution-service:\n - 新增 ReferralSyncedEvent 事件类\n - 新增 AdoptionSyncedEvent 事件类\n - admin.controller 添加 publish-all APIs:\n - POST /admin/referrals/publish-all\n - POST /admin/adoptions/publish-all\n\n- mining-admin-service:\n - SyncedUser 表添加 nickname 字段\n - 新增 SyncedReferral 表 \\(推荐关系\\)\n - 新增 SyncedAdoption 表 \\(认种记录\\)\n - handleReferralSynced 处理器\n - handleAdoptionSynced 处理器\n - handleLegacyUserMigrated 处理 nickname\n\n- deploy-mining.sh:\n - full_reset 更新为 14 步\n - Step 13: 发布推荐关系\n - Step 14: 发布认种记录\n\n解决 mining-admin-web 缺少昵称、推荐人、认种数据的问题\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")",
"Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")",
"Bash(set DATABASE_URL=postgresql://user:pass@localhost:5432/db)",
"Bash(cmd /c \"set DATABASE_URL=postgresql://user:pass@localhost:5432/db && npx prisma migrate dev --name add_nickname_to_synced_legacy_users --create-only\")",
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-app\\): fix login bugs and connect contribution page to real API\n\nLogin fixes:\n- Add AuthEventBus for global 401 error handling with auto-logout\n- Add route guards with GoRouter redirect to protect authenticated routes\n- Remove setMockUser\\(\\) security vulnerability and legacy login\\(\\) dead code\n- Remove unused AuthInterceptor class\n\nContribution page:\n- Add ContributionRecord entity and model for records API\n- Connect contribution details card to GET /accounts/{id}/records endpoint\n- Display real team stats \\(direct referrals, unlocked levels/tiers\\)\n- Calculate expiration countdown from actual record data\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(dependency of a provider changed\" error when 401 responses triggered\nlogout during provider rebuilds.\n\nNow 401 handling is done through normal exception flow in splash page\nand route guards respond to isLoggedInProvider state changes.\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(ssh ceshi@rwa-colocation-1-lan:*)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" diff frontend/mining-app/lib/presentation/pages/)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/pages/asset/asset_page.dart frontend/mining-app/lib/presentation/pages/auth/login_page.dart frontend/mining-app/lib/presentation/pages/auth/register_page.dart frontend/mining-app/lib/presentation/pages/contribution/contribution_page.dart frontend/mining-app/lib/presentation/pages/profile/profile_page.dart frontend/mining-app/lib/presentation/pages/trading/trading_page.dart)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): unify color scheme and fix scroll issues\n\n- Update login/register pages to use orange color scheme \\(#FF6B00\\)\n matching the navigation pages design\n- Fix SafeArea bottom: false on all navigation pages since MainShell\n handles bottom safe area via bottomNavigationBar\n- Add AlwaysScrollableScrollPhysics to asset page for consistent scroll\n- Increase bottom padding to 100px on all navigation pages to clear\n the navigation bar\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" push)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/pages/splash/splash_page.dart frontend/mining-app/lib/presentation/providers/user_providers.dart)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
"Bash(git rm:*)",
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")",
"Bash(ssh -o ConnectTimeout=10 ceshi@14.215.128.96 'find /home/ceshi/rwadurian/frontend/mining-admin-web -name \"\"*.tsx\"\" -o -name \"\"*.ts\"\" | xargs grep -l \"\"用户管理\\\\|users\"\" 2>/dev/null | head -10')",
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\")",
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3021/api/v2/admin/status\")",
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\buy_shares.dart\")",
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\sell_shares.dart\")",
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\presentation\\\\pages\"\" 2>/dev/null || dir /b \"c:UsersdongDesktoprwadurianfrontendmining-applibpresentationpages \")",
"Bash(cd:*)"
],
"deny": [],
"ask": []

127
.gitignore vendored
View File

@ -2,3 +2,130 @@ nul
# Claude Code settings
.claude/
# Dependencies
node_modules/
.pnp/
.pnp.js
# Build outputs
dist/
build/
out/
.next/
.nuxt/
.output/
# Environment files
.env
.env.local
.env.*.local
*.env
# IDE
.idea/
.vscode/
*.swp
*.swo
*.sublime-*
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Test coverage
coverage/
.nyc_output/
# Cache
.cache/
*.cache
.eslintcache
.stylelintcache
.turbo/
# Prisma
prisma/migrations/**/migration_lock.toml
# TypeScript
*.tsbuildinfo
# Flutter/Dart
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
*.iml
*.ipr
*.iws
.idea/
*.lock
pubspec.lock
# iOS
ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework
ios/Flutter/Flutter.podspec
ios/Flutter/App.framework
ios/Flutter/engine/
ios/Flutter/Generated.xcconfig
**/ios/Flutter/.last_build_id
**/ios/Podfile.lock
# Android
android/.gradle/
android/captures/
android/gradlew
android/gradlew.bat
android/local.properties
**/android/app/debug
**/android/app/profile
**/android/app/release
*.apk
*.aab
*.dex
*.class
*.jks
*.keystore
# macOS
macos/Flutter/GeneratedPluginRegistrant.swift
macos/Flutter/ephemeral/
# Windows
windows/flutter/generated_plugin_registrant.cc
windows/flutter/generated_plugin_registrant.h
windows/flutter/generated_plugins.cmake
# Linux
linux/flutter/generated_plugin_registrant.cc
linux/flutter/generated_plugin_registrant.h
linux/flutter/generated_plugins.cmake
# Web
web/favicon.png
web/icons/
# Temporary files
*.tmp
*.temp
*.swp
*~
# Package lock files (keep for reproducible builds)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml

View File

@ -48,6 +48,10 @@ services:
paths:
- /api/v1/identity/health
strip_path: true
- name: identity-admin-pending-actions
paths:
- /api/v1/admin/pending-actions
strip_path: false
# ---------------------------------------------------------------------------
# Wallet Service - 钱包服务
@ -173,6 +177,11 @@ services:
paths:
- /api/v1/export
strip_path: false
# [2026-01-04] 新增:系统账户报表路由
- name: reporting-system-accounts
paths:
- /api/v1/system-account-reports
strip_path: false
# ---------------------------------------------------------------------------
# Authorization Service - 授权服务
@ -261,6 +270,105 @@ services:
- /api/v1/co-managed
strip_path: false
# ===========================================================================
# RWA 2.0 Services - 新架构微服务
# ===========================================================================
# ---------------------------------------------------------------------------
# Contribution Service 2.0 - 算力服务
# 前端路径: /api/v2/contribution/...
# 后端路径: /api/v2/contribution/... (strip_path: false, 直接透传)
# ---------------------------------------------------------------------------
- name: contribution-service-v2
url: http://192.168.1.111:3020
routes:
- name: contribution-v2-api
paths:
- /api/v2/contribution
strip_path: false
- name: contribution-v2-health
paths:
- /api/v2/contribution/health
strip_path: false
# ---------------------------------------------------------------------------
# Mining Service 2.0 - 挖矿服务
# ---------------------------------------------------------------------------
- name: mining-service-v2
url: http://192.168.1.111:3021
routes:
- name: mining-v2-api
paths:
- /api/v2/mining
strip_path: false
- name: mining-v2-health
paths:
- /api/v2/mining/health
strip_path: false
# ---------------------------------------------------------------------------
# Trading Service 2.0 - 交易服务
# ---------------------------------------------------------------------------
- name: trading-service-v2
url: http://192.168.1.111:3022
routes:
- name: trading-v2-api
paths:
- /api/v2/trading
strip_path: false
- name: trading-v2-health
paths:
- /api/v2/trading/health
strip_path: false
# ---------------------------------------------------------------------------
# Mining Admin Service 2.0 - 挖矿管理后台服务
# ---------------------------------------------------------------------------
- name: mining-admin-service
url: http://192.168.1.111:3023/api/v1
routes:
- name: mining-admin-api
paths:
- /api/v2/mining-admin
strip_path: true
- name: mining-admin-health
paths:
- /api/v2/mining-admin/health
strip_path: true
# ---------------------------------------------------------------------------
# Auth Service 2.0 - 用户认证服务
# 前端路径: /api/v2/auth/...
# 后端路径: /api/v2/auth/... (strip_path: false, 直接透传)
# ---------------------------------------------------------------------------
- name: auth-service-v2
url: http://192.168.1.111:3024
routes:
- name: auth-v2-api
paths:
- /api/v2/auth
strip_path: false
- name: auth-v2-health
paths:
- /api/v2/auth/health
strip_path: false
# ---------------------------------------------------------------------------
# Mining Wallet Service 2.0 - 挖矿钱包服务
# ---------------------------------------------------------------------------
- name: mining-wallet-service
url: http://192.168.1.111:3025
routes:
- name: mining-wallet-api
paths:
- /api/v2/mining-wallet
strip_path: false
- name: mining-wallet-health
paths:
- /api/v2/mining-wallet/health
strip_path: false
# =============================================================================
# Plugins - 全局插件配置
# =============================================================================
@ -270,10 +378,12 @@ plugins:
config:
origins:
- "https://rwaadmin.szaiai.com"
- "https://madmin.szaiai.com"
- "https://update.szaiai.com"
- "https://app.rwadurian.com"
- "http://localhost:3000"
- "http://localhost:3020"
- "http://localhost:3100"
methods:
- GET
- POST
@ -298,8 +408,8 @@ plugins:
# 请求限流
- name: rate-limiting
config:
minute: 100
hour: 5000
minute: 10000
hour: 500000
policy: local
# 请求日志

View File

@ -159,6 +159,7 @@ services:
dockerfile: services/message-router/Dockerfile
container_name: mpc-message-router
ports:
- "50051:50051" # gRPC for party connections
- "8082:8080"
environment:
MPC_SERVER_GRPC_PORT: 50051

View File

@ -117,8 +117,11 @@ func NewKeygenSession(
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (Threshold-1) to TSS-lib
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), config.Threshold)
tssThreshold := config.Threshold - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
return &KeygenSession{
config: config,

View File

@ -132,10 +132,15 @@ func NewSigningSession(
keygenIndexToSortedIndex, selfParty.PartyID)
// Create peer context and parameters
// IMPORTANT: Use TotalParties from keygen, not len(sortedPartyIDs) which is current signers
// For 2-of-3: threshold=2, TotalParties=3, but only 2 parties might participate in signing
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// This MUST match keygen exactly! Both use (Threshold-1)
// The BuildLocalSaveDataSubset call in Start() will filter the save data to match
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, config.TotalParties, config.Threshold)
tssThreshold := config.Threshold - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
fmt.Printf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from config.Threshold=%d) party_id=%s\n",
len(sortedPartyIDs), tssThreshold, config.Threshold, selfParty.PartyID)
// Convert message hash to big.Int
msgHash := new(big.Int).SetBytes(messageHash)
@ -167,8 +172,17 @@ func (s *SigningSession) Start(ctx context.Context) (*SigningResult, error) {
s.started = true
s.mu.Unlock()
// Create local party for signing
s.localParty = signing.NewLocalParty(s.messageHash, s.params, *s.saveData, s.outCh, s.endCh)
// CRITICAL: Build a subset of the save data for the current signing parties
// When signing with fewer parties than keygen (e.g., 2-of-3 signing with only 2 parties),
// we must filter the save data to only include the participating parties' data.
// This ensures TSS-lib's internal indices match the actual signers.
subsetSaveData := keygen.BuildLocalSaveDataSubset(*s.saveData, s.tssPartyIDs)
fmt.Printf("[TSS-SIGN] Built save data subset for %d signing parties (original keygen had %d parties) party_id=%s\n",
len(s.tssPartyIDs), len(s.saveData.Ks), s.selfParty.PartyID)
// Create local party for signing with the SUBSET save data
s.localParty = signing.NewLocalParty(s.messageHash, s.params, subsetSaveData, s.outCh, s.endCh)
// Start the local party
go func() {

View File

@ -833,9 +833,11 @@ func (h *AccountHTTPHandler) CreateSigningSession(c *gin.Context) {
zap.String("keygen_session_id", accountOutput.Account.KeygenSessionID.String()))
}
// CRITICAL: Pass keygenThresholdN (original n from keygen) for correct TSS math
resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto(
ctx,
int32(accountOutput.Account.ThresholdT),
int32(accountOutput.Account.ThresholdN),
signingParties,
messageHash,
600, // 10 minutes expiry

View File

@ -60,6 +60,7 @@ func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) {
// Sign session routes (new - does not affect existing functionality)
coManaged.POST("/sign", h.CreateSignSession)
coManaged.GET("/sign/:sessionId", h.GetSignSessionStatus)
coManaged.GET("/sign/by-invite-code/:inviteCode", h.GetSignSessionByInviteCode)
}
}
@ -498,14 +499,40 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
return
}
// Validate party count >= threshold + 1
if len(req.Parties) < req.ThresholdT+1 {
// Validate party count == threshold_t (for t-of-n signing, exactly t parties are needed)
if len(req.Parties) != req.ThresholdT {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("need at least %d parties for threshold %d", req.ThresholdT+1, req.ThresholdT),
"error": fmt.Sprintf("need exactly %d parties for threshold %d, got %d", req.ThresholdT, req.ThresholdT, len(req.Parties)),
})
return
}
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// CRITICAL: Query keygen session to get the original threshold_n
// This is required for TSS signing to work correctly - the n value must match keygen
var keygenThresholdN, keygenThresholdT int
if h.db != nil {
err = h.db.QueryRowContext(ctx, `
SELECT threshold_n, threshold_t
FROM mpc_sessions
WHERE id = $1
`, req.KeygenSessionID).Scan(&keygenThresholdN, &keygenThresholdT)
if err != nil {
logger.Error("Failed to query keygen session for threshold values",
zap.String("keygen_session_id", req.KeygenSessionID),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup keygen session"})
return
}
} else {
logger.Error("Database connection not available for keygen session lookup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
return
}
// Generate invite code for sign session
inviteCode := generateInviteCode()
@ -518,22 +545,22 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
}
}
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger.Info("Creating co-managed sign session",
zap.String("keygen_session_id", req.KeygenSessionID),
zap.String("wallet_name", req.WalletName),
zap.Int("threshold_t", req.ThresholdT),
zap.Int("num_parties", len(req.Parties)),
zap.Int("keygen_threshold_n", keygenThresholdN),
zap.Int("keygen_threshold_t", keygenThresholdT),
zap.Int("signing_threshold_t", req.ThresholdT),
zap.Int("num_signing_parties", len(req.Parties)),
zap.String("invite_code", inviteCode))
// Create signing session
// Note: delegateUserShare is nil for co-managed wallets (no delegate party)
// CRITICAL: Pass keygenThresholdN (original n from keygen) for correct TSS math
resp, err := h.sessionCoordinatorClient.CreateSigningSessionAuto(
ctx,
int32(req.ThresholdT),
int32(keygenThresholdN),
parties,
messageHash,
86400, // 24 hour expiry
@ -562,7 +589,7 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
}
}
// Get wildcard join token for participants
// Get wildcard token for backward compatibility (join_token field)
wildcardToken := ""
if token, ok := resp.JoinTokens["*"]; ok {
wildcardToken = token
@ -571,23 +598,94 @@ func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
logger.Info("Co-managed sign session created successfully",
zap.String("session_id", resp.SessionID),
zap.String("invite_code", inviteCode),
zap.Int("num_parties", len(resp.SelectedParties)))
zap.Int("num_parties", len(resp.SelectedParties)),
zap.Int("num_join_tokens", len(resp.JoinTokens)))
c.JSON(http.StatusCreated, gin.H{
"session_id": resp.SessionID,
"keygen_session_id": req.KeygenSessionID,
"wallet_name": req.WalletName,
"invite_code": inviteCode,
"join_token": wildcardToken,
"threshold_t": req.ThresholdT,
"selected_parties": resp.SelectedParties,
"status": "waiting_for_participants",
"current_participants": 0,
"session_id": resp.SessionID,
"keygen_session_id": req.KeygenSessionID,
"wallet_name": req.WalletName,
"invite_code": inviteCode,
"join_token": wildcardToken, // Backward compatible: wildcard token (may be empty)
"join_tokens": resp.JoinTokens, // New: all join tokens (map[partyID]token)
"threshold_n": keygenThresholdN, // Original N from keygen (required for TSS)
"threshold_t": req.ThresholdT,
"selected_parties": resp.SelectedParties,
"status": "waiting_for_participants",
"current_participants": 0,
"required_participants": len(req.Parties),
"expires_at": resp.ExpiresAt,
"expires_at": resp.ExpiresAt,
})
}
// GetSignSessionStatus handles getting the status of a co-managed sign session
func (h *CoManagedHTTPHandler) GetSignSessionStatus(c *gin.Context) {
sessionID := c.Param("sessionId")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"})
return
}
// Validate session ID format
if _, err := uuid.Parse(sessionID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_id format"})
return
}
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
if err != nil {
logger.Error("Failed to get sign session status", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get invite_code from database
var inviteCode string
if h.db != nil {
row := h.db.QueryRowContext(ctx, `SELECT invite_code FROM mpc_sessions WHERE id = $1`, sessionID)
row.Scan(&inviteCode) // Ignore error, invite_code is optional
}
result := gin.H{
"session_id": sessionID,
"status": resp.Status,
"session_type": resp.SessionType,
"threshold_t": resp.ThresholdT,
"threshold_n": resp.ThresholdN,
"completed_parties": resp.CompletedParties,
"total_parties": resp.TotalParties,
}
// Add invite_code if available
if inviteCode != "" {
result["invite_code"] = inviteCode
}
// Add signature if sign completed
if resp.SessionType == "sign" && len(resp.Signature) > 0 {
result["signature"] = hex.EncodeToString(resp.Signature)
}
// Include participants with party_index
if len(resp.Participants) > 0 {
participants := make([]gin.H, len(resp.Participants))
for i, p := range resp.Participants {
participants[i] = gin.H{
"party_id": p.PartyID,
"party_index": p.PartyIndex,
"status": p.Status,
}
}
result["participants"] = participants
}
c.JSON(http.StatusOK, result)
}
// GetSignSessionByInviteCode handles looking up a sign session by its invite code
// This is a completely new endpoint that does not affect existing functionality
func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
@ -611,16 +709,19 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
var sessionID string
var walletName string
var keygenSessionID string
var thresholdN, thresholdT int
var status string
var expiresAt time.Time
var messageHash []byte
// Query sign session basic info
err := h.db.QueryRowContext(ctx, `
SELECT id, COALESCE(wallet_name, ''), COALESCE(keygen_session_id::text, ''),
threshold_n, threshold_t, status, expires_at
status, expires_at, COALESCE(message_hash, '')
FROM mpc_sessions
WHERE invite_code = $1 AND session_type = 'sign'
`, inviteCode).Scan(&sessionID, &walletName, &keygenSessionID, &thresholdN, &thresholdT, &status, &expiresAt)
WHERE invite_code = $1 AND session_type = 'sign' AND status != 'failed'
ORDER BY created_at DESC
LIMIT 1
`, inviteCode).Scan(&sessionID, &walletName, &keygenSessionID, &status, &expiresAt, &messageHash)
if err != nil {
if err == sql.ErrNoRows {
@ -645,6 +746,60 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
return
}
// Get threshold_n and threshold_t from the KEYGEN session (the authoritative source)
// This is critical for TSS signing to work correctly
var keygenThresholdN, keygenThresholdT int
if keygenSessionID != "" {
err = h.db.QueryRowContext(ctx, `
SELECT threshold_n, threshold_t
FROM mpc_sessions
WHERE id = $1
`, keygenSessionID).Scan(&keygenThresholdN, &keygenThresholdT)
if err != nil {
logger.Error("Failed to query keygen session for threshold values",
zap.String("keygen_session_id", keygenSessionID),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup keygen session"})
return
}
} else {
logger.Error("Sign session has no keygen_session_id",
zap.String("session_id", sessionID))
c.JSON(http.StatusInternalServerError, gin.H{"error": "sign session missing keygen reference"})
return
}
// Get the signing parties list from the sign session's participants table
// These are the parties that were selected for this signing session
var parties []gin.H
rows, err := h.db.QueryContext(ctx, `
SELECT party_id, party_index
FROM participants
WHERE session_id = $1
ORDER BY party_index
`, sessionID)
if err != nil {
logger.Error("Failed to query sign session participants",
zap.String("session_id", sessionID),
zap.Error(err))
// Continue without parties list, frontend will fallback
} else {
defer rows.Close()
for rows.Next() {
var partyID string
var partyIndex int
if err := rows.Scan(&partyID, &partyIndex); err != nil {
logger.Warn("Failed to scan participant row",
zap.Error(err))
continue
}
parties = append(parties, gin.H{
"party_id": partyID,
"party_index": partyIndex,
})
}
}
// Get session status from coordinator
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
if err != nil {
@ -656,28 +811,54 @@ func (h *CoManagedHTTPHandler) GetSignSessionByInviteCode(c *gin.Context) {
"session_id": sessionID,
"keygen_session_id": keygenSessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"message_hash": hex.EncodeToString(messageHash),
"threshold_n": keygenThresholdN,
"threshold_t": keygenThresholdT,
"status": status,
"joined_count": 0,
"expires_at": expiresAt.UnixMilli(),
"parties": parties,
})
return
}
// Generate join token for this session (wildcard token that allows any party to join)
var joinToken string
if h.jwtService != nil {
sessionUUID, parseErr := uuid.Parse(sessionID)
if parseErr == nil {
token, err := h.jwtService.GenerateJoinToken(sessionUUID, "*", time.Hour) // Wildcard party ID, 1 hour expiry
if err != nil {
logger.Warn("Failed to generate join token for sign session",
zap.String("session_id", sessionID),
zap.Error(err))
} else {
joinToken = token
}
}
}
logger.Info("Found sign session for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID),
zap.String("wallet_name", walletName))
zap.String("wallet_name", walletName),
zap.String("keygen_session_id", keygenSessionID),
zap.Int("keygen_threshold_n", keygenThresholdN),
zap.Int("keygen_threshold_t", keygenThresholdT),
zap.Int("parties_count", len(parties)),
zap.Bool("has_join_token", joinToken != ""))
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"keygen_session_id": keygenSessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"message_hash": hex.EncodeToString(messageHash),
"threshold_n": keygenThresholdN,
"threshold_t": keygenThresholdT,
"status": statusResp.Status,
"completed_parties": statusResp.CompletedParties,
"total_parties": statusResp.TotalParties,
"joined_count": statusResp.CompletedParties,
"expires_at": expiresAt.UnixMilli(),
"join_token": joinToken,
"parties": parties,
})
}

View File

@ -137,9 +137,19 @@ type SigningPartyInfo struct {
// CreateSigningSessionAuto creates a new signing session with automatic party selection
// Coordinator will select parties from the provided party info (from account shares)
// delegateUserShare is required if any of the parties is a delegate party
// keygenThresholdN is the original threshold_n from the keygen session (required for TSS math)
//
// BREAKING CHANGE WARNING (for co-sign feature, commit 042212ea):
// Original code: ThresholdN = int32(len(parties)) - used participant count as N
// New code: ThresholdN = keygenThresholdN - uses original N from keygen session
// This change affects PERSISTENT SIGN flow. The original approach made threshold_n
// equal to participant count (T+1), which worked with the old N-based validation.
// If issues arise with persistent sign, REVERT to: ThresholdN: int32(len(parties))
// Related files: session_coordinator.go, mpc_session.go, account_handler.go
func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
ctx context.Context,
thresholdT int32,
keygenThresholdN int32,
parties []SigningPartyInfo,
messageHash []byte,
expiresInSeconds int64,
@ -155,9 +165,11 @@ func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
}
}
// CRITICAL: Use keygenThresholdN (original n from keygen), NOT len(parties)
// TSS signing requires the same n value used during keygen for correct mathematical operations
req := &coordinatorpb.CreateSessionRequest{
SessionType: "sign",
ThresholdN: int32(len(parties)),
ThresholdN: keygenThresholdN,
ThresholdT: thresholdT,
Participants: pbParticipants,
MessageHash: messageHash,
@ -174,12 +186,14 @@ func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
}
logger.Info("Sending CreateSigningSession gRPC request with delegate user share",
zap.Int32("threshold_t", thresholdT),
zap.Int("num_parties", len(parties)),
zap.Int32("keygen_threshold_n", keygenThresholdN),
zap.Int("num_signing_parties", len(parties)),
zap.String("delegate_party_id", delegateUserShare.DelegatePartyID))
} else {
logger.Info("Sending CreateSigningSession gRPC request",
zap.Int32("threshold_t", thresholdT),
zap.Int("num_parties", len(parties)))
zap.Int32("keygen_threshold_n", keygenThresholdN),
zap.Int("num_signing_parties", len(parties)))
}
resp, err := c.client.CreateSession(ctx, req)

View File

@ -350,8 +350,10 @@ func (s *MessageRouterServer) SubscribeSessionEvents(
zap.String("party_id", req.PartyId))
// Subscribe to events
eventCh := s.eventBroadcaster.Subscribe(req.PartyId)
defer s.eventBroadcaster.Unsubscribe(req.PartyId)
// The channel is used for identity check in Unsubscribe to prevent
// accidentally removing a newer subscription when this stream exits
eventCh, _ := s.eventBroadcaster.Subscribe(req.PartyId)
defer s.eventBroadcaster.Unsubscribe(req.PartyId, eventCh)
// Stream events
for {

View File

@ -2,8 +2,11 @@ package domain
import (
"sync"
"time"
pb "github.com/rwadurian/mpc-system/api/grpc/router/v1"
"github.com/rwadurian/mpc-system/pkg/logger"
"go.uber.org/zap"
)
// SessionEventBroadcaster manages session event subscriptions and broadcasting
@ -20,32 +23,51 @@ func NewSessionEventBroadcaster() *SessionEventBroadcaster {
}
// Subscribe subscribes a party to session events
// If the party already has an active subscription, the old channel is closed first
// to prevent memory leaks and ensure clean reconnection
func (b *SessionEventBroadcaster) Subscribe(partyID string) <-chan *pb.SessionEvent {
// Returns the channel for receiving events and a unique subscription ID
// The subscription ID is used to safely unsubscribe without affecting newer subscriptions
func (b *SessionEventBroadcaster) Subscribe(partyID string) (<-chan *pb.SessionEvent, int64) {
b.mu.Lock()
defer b.mu.Unlock()
// Close existing channel if party is re-subscribing (e.g., after reconnect)
// This will cause the old gRPC stream to exit cleanly
if oldCh, exists := b.subscribers[partyID]; exists {
close(oldCh)
logger.Debug("Closed old subscription channel for re-subscribing party",
zap.String("party_id", partyID))
}
// Create buffered channel for this subscriber
ch := make(chan *pb.SessionEvent, 100)
b.subscribers[partyID] = ch
return ch
// Generate a unique subscription ID (using current time in nanoseconds)
subscriptionID := time.Now().UnixNano()
return ch, subscriptionID
}
// Unsubscribe removes a party's subscription
func (b *SessionEventBroadcaster) Unsubscribe(partyID string) {
// Unsubscribe removes a party's subscription only if the channel matches
// This prevents a race condition where a newer subscription is accidentally removed
// when an old gRPC stream exits after the party has already re-subscribed
func (b *SessionEventBroadcaster) Unsubscribe(partyID string, ch <-chan *pb.SessionEvent) {
b.mu.Lock()
defer b.mu.Unlock()
if ch, exists := b.subscribers[partyID]; exists {
close(ch)
delete(b.subscribers, partyID)
if currentCh, exists := b.subscribers[partyID]; exists {
// Only delete if the channel matches (i.e., this is still our subscription)
// If the channel doesn't match, a newer subscription has been created
// and we should not delete it
if currentCh == ch {
// Don't close the channel here - it was already closed by Subscribe
// when the new subscription was created, or we're the last one
delete(b.subscribers, partyID)
logger.Debug("Unsubscribed party from session events",
zap.String("party_id", partyID))
} else {
logger.Debug("Skipping unsubscribe - channel mismatch (newer subscription exists)",
zap.String("party_id", partyID))
}
}
}
@ -69,16 +91,34 @@ func (b *SessionEventBroadcaster) BroadcastToParties(event *pb.SessionEvent, par
b.mu.RLock()
defer b.mu.RUnlock()
sentCount := 0
missedParties := []string{}
for _, partyID := range partyIDs {
if ch, exists := b.subscribers[partyID]; exists {
// Non-blocking send
select {
case ch <- event:
sentCount++
default:
// Channel full, skip this subscriber
missedParties = append(missedParties, partyID+" (channel full)")
}
} else {
// Party not subscribed - this is a problem for session_started events!
missedParties = append(missedParties, partyID+" (not subscribed)")
}
}
// Log if any parties were missed (helps debug event delivery issues)
if len(missedParties) > 0 {
logger.Warn("Some parties missed session event broadcast",
zap.String("event_type", event.EventType),
zap.String("session_id", event.SessionId),
zap.Int("sent_count", sentCount),
zap.Int("missed_count", len(missedParties)),
zap.Strings("missed_parties", missedParties))
}
}
// SubscriberCount returns the number of active subscribers

View File

@ -0,0 +1,99 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
app/build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/modules.xml
.idea/misc.xml
.idea/vcs.xml
# Keystore files (DO NOT COMMIT production keystores)
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# Kotlin
.kotlin/
# OS-specific files
.DS_Store
Thumbs.db
*.swp
*~
# Signing configs - don't commit
signing.properties
keystore.properties
# Auto-generated version file
app/version.properties

View File

@ -0,0 +1,101 @@
# TSS Party Android
Android 版本的 TSS (Threshold Signature Scheme) Party 应用,用于多方共管钱包的密钥生成和签名。
## 项目结构
```
service-party-android/
├── app/ # Android 应用模块
│ ├── src/main/
│ │ ├── java/com/durian/tssparty/
│ │ │ ├── data/ # 数据层
│ │ │ │ ├── local/ # 本地存储 (Room, TSS Bridge)
│ │ │ │ ├── remote/ # 远程通信 (gRPC)
│ │ │ │ └── repository/ # 数据仓库
│ │ │ ├── domain/model/ # 领域模型
│ │ │ ├── presentation/ # UI 层
│ │ │ │ ├── screens/ # Compose 屏幕
│ │ │ │ └── viewmodel/ # ViewModels
│ │ │ ├── di/ # Hilt 依赖注入
│ │ │ ├── ui/theme/ # Material Theme
│ │ │ └── util/ # 工具类
│ │ ├── proto/ # gRPC Proto 文件
│ │ └── res/ # Android 资源
│ └── libs/ # TSS 原生库 (.aar)
├── tsslib/ # Go TSS 库源码
│ ├── tsslib.go # gomobile 绑定
│ ├── go.mod
│ ├── build.sh # Linux/macOS 构建脚本
│ └── build.bat # Windows 构建脚本
└── gradle/ # Gradle Wrapper
```
## 技术栈
- **UI**: Jetpack Compose + Material 3
- **架构**: MVVM + Repository Pattern
- **依赖注入**: Hilt
- **数据库**: Room
- **网络**: gRPC (protobuf-lite)
- **TSS 核心**: Go + gomobile (BnB Chain tss-lib v2)
## 构建步骤
### 1. 构建 TSS 原生库 (可选,需要 Go 环境)
```bash
# 安装 gomobile
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
# 构建 Android AAR
cd tsslib
./build.sh # Linux/macOS
# 或
build.bat # Windows
```
这将在 `app/libs/` 生成 `tsslib.aar`
> **注意**: 当前版本使用 Kotlin stub 实现,无需编译 Go 库即可构建 APK。
> 实际运行需要真正的 `tsslib.aar`
### 2. 构建 APK
```bash
# Debug 版本
./gradlew assembleDebug
# Release 版本 (需要签名配置)
./gradlew assembleRelease
```
APK 输出路径: `app/build/outputs/apk/debug/app-debug.apk`
## 功能
1. **加入 Keygen 会话** - 扫描/输入邀请码,参与多方密钥生成
2. **查看钱包** - 显示已创建的共管钱包列表
3. **签名交易** - 使用密钥份额参与多方签名
4. **设置** - 配置 Message Router 服务器地址
## 配置
默认服务器配置:
- Message Router: `localhost:50051`
- Kava RPC: `https://evm.kava.io`
## 与 Electron 版本的对应关系
| Electron 版本 | Android 版本 |
|---------------|--------------|
| `electron/main.ts` | `TssNativeBridge.kt` + `GrpcClient.kt` |
| `electron/preload.ts` | `TssRepository.kt` |
| `src/pages/*.tsx` | `presentation/screens/*.kt` |
| `tss-party/` (Go 子进程) | `tsslib/` (gomobile .aar) |
| sql.js | Room Database |
## 许可证
MIT

View File

@ -0,0 +1,200 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.protobuf")
kotlin("kapt")
}
// Auto-increment version code from file
val versionFile = file("version.properties")
val versionProps = Properties()
if (versionFile.exists()) {
versionProps.load(versionFile.inputStream())
}
val autoVersionCode = (versionProps.getProperty("VERSION_CODE")?.toIntOrNull() ?: 0) + 1
val autoVersionName = "1.0.${autoVersionCode}"
// Save new version code
versionProps.setProperty("VERSION_CODE", autoVersionCode.toString())
versionFile.outputStream().use { versionProps.store(it, "Auto-generated version properties") }
android {
namespace = "com.durian.tssparty"
compileSdk = 34
defaultConfig {
applicationId = "com.durian.tssparty"
minSdk = 26
targetSdk = 34
versionCode = autoVersionCode
versionName = autoVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// NDK configuration for TSS native library
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
}
signingConfigs {
create("release") {
// Use debug keystore for now - replace with production keystore for real release
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = false // Disable minification for easier debugging
isShrinkResources = false
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.6"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
sourceSets {
getByName("main") {
// Include the compiled TSS .aar library
jniLibs.srcDirs("libs")
}
}
}
// Protobuf configuration for gRPC
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.1"
}
plugins {
create("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:1.60.0"
}
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
task.plugins {
create("grpc") {
option("lite")
}
}
}
}
}
dependencies {
// TSS Library (gomobile generated)
implementation(files("libs/tsslib.aar"))
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.7.6")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.48.1")
kapt("com.google.dagger:hilt-android-compiler:2.48.1")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room Database
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// gRPC
implementation("io.grpc:grpc-okhttp:1.60.0")
implementation("io.grpc:grpc-protobuf-lite:1.60.0")
implementation("io.grpc:grpc-stub:1.60.0")
implementation("io.grpc:grpc-kotlin-stub:1.4.1")
implementation("com.google.protobuf:protobuf-kotlin-lite:3.25.1")
// Networking
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// JSON
implementation("com.google.code.gson:gson:2.10.1")
// QR Code
implementation("com.google.zxing:core:3.5.2")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
// Crypto
implementation("org.bouncycastle:bcprov-jdk18on:1.77")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
kapt {
correctErrorTypes = true
}

View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# Keep gRPC classes
-keep class io.grpc.** { *; }
-keep class com.google.protobuf.** { *; }
-keep class com.durian.tssparty.grpc.** { *; }
# Keep tsslib (gomobile generated)
-keep class tsslib.** { *; }
# Keep Hilt generated classes
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
# Keep Room entities
-keep class com.durian.tssparty.data.local.** { *; }
# Gson
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.durian.tssparty.domain.model.** { *; }

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Network permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Camera permission for QR code scanning (optional) -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".TssPartyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TssParty"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.TssParty">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Portrait-only QR code scanner activity -->
<activity
android:name=".presentation.screens.PortraitCaptureActivity"
android:screenOrientation="portrait"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
</application>
</manifest>

View File

@ -0,0 +1,526 @@
package com.durian.tssparty
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.durian.tssparty.domain.model.AppReadyState
import com.durian.tssparty.domain.model.ShareBackup
import com.durian.tssparty.domain.model.TokenType
import com.durian.tssparty.presentation.components.BottomNavItem
import com.durian.tssparty.presentation.components.TssBottomNavigation
import com.durian.tssparty.presentation.screens.*
import com.durian.tssparty.presentation.viewmodel.MainViewModel
import com.durian.tssparty.presentation.viewmodel.ConnectionTestResult as ViewModelConnectionTestResult
import com.durian.tssparty.ui.theme.TssPartyTheme
import dagger.hilt.android.AndroidEntryPoint
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TssPartyTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
TssPartyApp(
onCopyToClipboard = { text ->
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("邀请码", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(this, "邀请码已复制", Toast.LENGTH_SHORT).show()
}
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TssPartyApp(
viewModel: MainViewModel = hiltViewModel(),
onCopyToClipboard: (String) -> Unit = {}
) {
val navController = rememberNavController()
val appState by viewModel.appState.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val shares by viewModel.shares.collectAsState()
val sessionStatus by viewModel.sessionStatus.collectAsState()
val settings by viewModel.settings.collectAsState()
val createdInviteCode by viewModel.createdInviteCode.collectAsState()
val balances by viewModel.balances.collectAsState()
val walletBalances by viewModel.walletBalances.collectAsState()
val currentSessionId by viewModel.currentSessionId.collectAsState()
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
val currentRound by viewModel.currentRound.collectAsState()
val publicKey by viewModel.publicKey.collectAsState()
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
// Transfer state
val preparedTx by viewModel.preparedTx.collectAsState()
val signSessionId by viewModel.signSessionId.collectAsState()
val signInviteCode by viewModel.signInviteCode.collectAsState()
val signParticipants by viewModel.signParticipants.collectAsState()
val signCurrentRound by viewModel.signCurrentRound.collectAsState()
val signature by viewModel.signature.collectAsState()
val txHash by viewModel.txHash.collectAsState()
// Join keygen state
val joinSessionInfo by viewModel.joinSessionInfo.collectAsState()
val joinKeygenParticipants by viewModel.joinKeygenParticipants.collectAsState()
val joinKeygenRound by viewModel.joinKeygenRound.collectAsState()
val joinKeygenPublicKey by viewModel.joinKeygenPublicKey.collectAsState()
// CoSign state
val coSignSessionInfo by viewModel.coSignSessionInfo.collectAsState()
val coSignParticipants by viewModel.coSignParticipants.collectAsState()
val coSignRound by viewModel.coSignRound.collectAsState()
val coSignSignature by viewModel.coSignSignature.collectAsState()
// Settings test connection results
val messageRouterTestResult by viewModel.messageRouterTestResult.collectAsState()
val accountServiceTestResult by viewModel.accountServiceTestResult.collectAsState()
val kavaApiTestResult by viewModel.kavaApiTestResult.collectAsState()
// Export/Import state
val exportResult by viewModel.exportResult.collectAsState()
val importResult by viewModel.importResult.collectAsState()
// Current transfer wallet
var transferWalletId by remember { mutableStateOf<Long?>(null) }
// Export/Import file handling
val context = LocalContext.current
var pendingExportJson by remember { mutableStateOf<String?>(null) }
var pendingExportAddress by remember { mutableStateOf<String?>(null) }
// File picker for saving backup
val createDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
) { uri: Uri? ->
uri?.let { targetUri ->
pendingExportJson?.let { json ->
try {
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
outputStream.write(json.toByteArray(Charsets.UTF_8))
}
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
}
pendingExportJson = null
pendingExportAddress = null
}
}
}
// File picker for importing backup
val openDocumentLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
uri?.let { sourceUri ->
try {
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
val json = inputStream.bufferedReader().readText()
viewModel.importShareBackup(json)
}
} catch (e: Exception) {
Toast.makeText(context, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
// Handle export result - trigger file save dialog
LaunchedEffect(pendingExportJson) {
pendingExportJson?.let { json ->
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}"
createDocumentLauncher.launch(fileName)
}
}
// Handle import result - show toast
LaunchedEffect(importResult) {
importResult?.let { result ->
when {
result.isSuccess -> {
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
viewModel.clearExportImportResult()
}
result.error != null -> {
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
viewModel.clearExportImportResult()
}
}
}
}
// Track if startup is complete
var startupComplete by remember { mutableStateOf(false) }
// Handle success messages
LaunchedEffect(uiState.successMessage) {
if (uiState.successMessage != null) {
// Navigate back to wallets on success
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
// Reset all session states so next time user enters a fresh state
viewModel.resetSessionState()
viewModel.resetJoinKeygenState()
viewModel.resetCoSignState()
viewModel.resetTransferState()
viewModel.clearSuccess()
viewModel.clearCreatedInviteCode()
}
}
// Show startup check screen if not complete
if (!startupComplete) {
StartupCheckScreen(
appState = appState,
onEnterApp = { startupComplete = true },
onRetry = { viewModel.checkAllServices() }
)
return
}
// Main app with bottom navigation
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: BottomNavItem.Wallets.route
Scaffold(
bottomBar = {
TssBottomNavigation(
currentRoute = currentRoute,
onNavigate = { item ->
navController.navigate(item.route) {
// Pop up to the start destination to avoid building up a large stack
popUpTo(BottomNavItem.Wallets.route) {
saveState = true
}
// Avoid multiple copies of the same destination
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = BottomNavItem.Wallets.route,
modifier = Modifier.padding(paddingValues)
) {
// Tab 1: My Wallets (我的钱包)
composable(BottomNavItem.Wallets.route) {
// Fetch balances when entering wallets screen
LaunchedEffect(shares) {
viewModel.fetchAllBalances()
}
WalletsScreen(
shares = shares,
isConnected = uiState.isConnected,
balances = balances,
walletBalances = walletBalances,
networkType = settings.networkType,
onDeleteShare = { viewModel.deleteShare(it) },
onRefreshBalance = { address -> viewModel.fetchBalance(address) },
onTransfer = { shareId ->
transferWalletId = shareId
navController.navigate("transfer/$shareId")
},
onExportBackup = { shareId, _ ->
// Get address for filename
val share = shares.find { it.id == shareId }
pendingExportAddress = share?.address
// Export and save to file
viewModel.exportShareBackup(shareId) { json ->
pendingExportJson = json
}
},
onImportBackup = {
// Open file picker to select backup file
openDocumentLauncher.launch(arrayOf("*/*"))
},
onCreateWallet = {
navController.navigate(BottomNavItem.Create.route)
}
)
}
// Transfer Screen
composable("transfer/{shareId}") { backStackEntry ->
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull()
val wallet = shareId?.let { viewModel.getWalletById(it) }
if (wallet != null) {
TransferScreen(
wallet = wallet,
balance = balances[wallet.address],
walletBalance = walletBalances[wallet.address],
sessionStatus = sessionStatus,
participants = signParticipants,
currentRound = signCurrentRound,
totalRounds = 9,
preparedTx = preparedTx,
signSessionId = signSessionId,
inviteCode = signInviteCode,
signature = signature,
txHash = txHash,
isLoading = uiState.isLoading,
error = uiState.error,
networkType = settings.networkType,
rpcUrl = settings.kavaRpcUrl,
onPrepareTransaction = { toAddress, amount, tokenType ->
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
},
onConfirmTransaction = {
viewModel.initiateSignSession(shareId, "")
},
onCopyInviteCode = {
signInviteCode?.let { onCopyToClipboard(it) }
},
onBroadcastTransaction = {
viewModel.broadcastTransaction()
},
onCancel = {
viewModel.resetTransferState()
viewModel.clearError()
navController.popBackStack()
},
onBackToWallets = {
viewModel.resetTransferState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
}
// Tab 2: Create Wallet (创建钱包)
composable(BottomNavItem.Create.route) {
CreateWalletScreen(
isLoading = uiState.isLoading,
error = uiState.error,
inviteCode = createdInviteCode,
sessionId = currentSessionId,
sessionStatus = sessionStatus,
hasEnteredSession = hasEnteredSession,
participants = sessionParticipants,
currentRound = currentRound,
totalRounds = 9,
publicKey = publicKey,
countdownSeconds = uiState.countdownSeconds,
onCreateSession = { name, t, n, participantName ->
viewModel.createKeygenSession(name, t, n, participantName)
},
onCopyInviteCode = {
createdInviteCode?.let { onCopyToClipboard(it) }
},
onEnterSession = {
viewModel.enterSession()
},
onCancel = {
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetSessionState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onBackToHome = {
viewModel.resetSessionState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 3: Join Keygen (加入创建)
composable(BottomNavItem.JoinKeygen.route) {
// Convert JoinKeygenSessionInfo to JoinSessionInfo for the screen
val screenSessionInfo = joinSessionInfo?.let {
JoinSessionInfo(
sessionId = it.sessionId,
walletName = it.walletName,
thresholdT = it.thresholdT,
thresholdN = it.thresholdN,
initiator = it.initiator,
currentParticipants = it.currentParticipants,
totalParticipants = it.totalParticipants
)
}
JoinKeygenScreen(
sessionStatus = sessionStatus,
isLoading = uiState.isLoading,
error = uiState.error,
sessionInfo = screenSessionInfo,
participants = joinKeygenParticipants,
currentRound = joinKeygenRound,
totalRounds = 9,
publicKey = joinKeygenPublicKey,
countdownSeconds = uiState.countdownSeconds,
onValidateInviteCode = { inviteCode ->
viewModel.validateInviteCode(inviteCode)
},
onJoinKeygen = { inviteCode, password ->
viewModel.joinKeygen(inviteCode, password)
},
onCancel = {
// Cancel from input screen - navigate away
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetJoinKeygenState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onResetState = {
// Reset from confirm/joining/progress screens - stay on page
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetJoinKeygenState()
},
onBackToHome = {
viewModel.resetJoinKeygenState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 4: Co-Sign (参与签名)
composable(BottomNavItem.CoSign.route) {
// Convert CoSignSessionInfo to SignSessionInfo for the screen
val screenSignSessionInfo = coSignSessionInfo?.let {
SignSessionInfo(
sessionId = it.sessionId,
keygenSessionId = it.keygenSessionId,
walletName = it.walletName,
messageHash = it.messageHash,
thresholdT = it.thresholdT,
thresholdN = it.thresholdN,
currentParticipants = it.currentParticipants
)
}
CoSignJoinScreen(
shares = shares,
sessionStatus = sessionStatus,
isLoading = uiState.isLoading,
error = uiState.error,
signSessionInfo = screenSignSessionInfo,
participants = coSignParticipants,
currentRound = coSignRound,
totalRounds = 9,
signature = coSignSignature,
countdownSeconds = uiState.countdownSeconds,
onValidateInviteCode = { inviteCode ->
viewModel.validateSignInviteCode(inviteCode)
},
onJoinSign = { inviteCode, shareId, password ->
viewModel.joinSign(inviteCode, shareId, password)
},
onCancel = {
// Cancel from input screen - navigate away
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetCoSignState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
},
onResetState = {
// Reset from select_share/joining/signing screens - stay on page
viewModel.cancelSession()
viewModel.clearError()
viewModel.resetCoSignState()
},
onBackToHome = {
viewModel.resetCoSignState()
navController.navigate(BottomNavItem.Wallets.route) {
popUpTo(BottomNavItem.Wallets.route) { inclusive = true }
}
}
)
}
// Tab 5: Settings (设置)
composable(BottomNavItem.Settings.route) {
// Convert ViewModel ConnectionTestResult to Screen ConnectionTestResult
val screenMessageRouterStatus: ConnectionTestResult? = messageRouterTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
val screenAccountServiceStatus: ConnectionTestResult? = accountServiceTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
val screenKavaApiStatus: ConnectionTestResult? = kavaApiTestResult?.let {
ConnectionTestResult(
success = it.success,
message = it.message,
latency = it.latency
)
}
SettingsScreen(
settings = settings,
isConnected = uiState.isConnected,
messageRouterStatus = screenMessageRouterStatus,
accountServiceStatus = screenAccountServiceStatus,
kavaApiStatus = screenKavaApiStatus,
onSaveSettings = { newSettings ->
viewModel.updateSettings(newSettings)
},
onTestMessageRouter = { url ->
viewModel.testMessageRouter(url)
},
onTestAccountService = { url ->
viewModel.testAccountService(url)
},
onTestKavaApi = { url ->
viewModel.testKavaApi(url)
}
)
}
}
}
}

View File

@ -0,0 +1,7 @@
package com.durian.tssparty
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TssPartyApplication : Application()

View File

@ -0,0 +1,104 @@
package com.durian.tssparty.data.local
import androidx.room.*
import kotlinx.coroutines.flow.Flow
/**
* Entity for storing TSS share records
*/
@Entity(tableName = "share_records")
data class ShareRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "public_key")
val publicKey: String,
@ColumnInfo(name = "encrypted_share")
val encryptedShare: String,
@ColumnInfo(name = "threshold_t")
val thresholdT: Int,
@ColumnInfo(name = "threshold_n")
val thresholdN: Int,
@ColumnInfo(name = "party_index")
val partyIndex: Int,
@ColumnInfo(name = "address")
val address: String,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis()
)
/**
* DAO for share records
*/
@Dao
interface ShareRecordDao {
@Query("SELECT * FROM share_records ORDER BY created_at DESC")
fun getAllShares(): Flow<List<ShareRecordEntity>>
@Query("SELECT * FROM share_records WHERE id = :id")
suspend fun getShareById(id: Long): ShareRecordEntity?
@Query("SELECT * FROM share_records WHERE session_id = :sessionId")
suspend fun getShareBySessionId(sessionId: String): ShareRecordEntity?
@Query("SELECT * FROM share_records WHERE address = :address")
suspend fun getShareByAddress(address: String): ShareRecordEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShare(share: ShareRecordEntity): Long
@Delete
suspend fun deleteShare(share: ShareRecordEntity)
@Query("DELETE FROM share_records WHERE id = :id")
suspend fun deleteShareById(id: Long)
@Query("SELECT COUNT(*) FROM share_records")
suspend fun getShareCount(): Int
}
/**
* Entity for storing app settings (like persistent partyId)
*/
@Entity(tableName = "app_settings")
data class AppSettingEntity(
@PrimaryKey
val key: String,
@ColumnInfo(name = "value")
val value: String
)
/**
* DAO for app settings
*/
@Dao
interface AppSettingDao {
@Query("SELECT value FROM app_settings WHERE `key` = :key")
suspend fun getValue(key: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setValue(setting: AppSettingEntity)
}
/**
* Room database
*/
@Database(
entities = [ShareRecordEntity::class, AppSettingEntity::class],
version = 2,
exportSchema = false
)
abstract class TssDatabase : RoomDatabase() {
abstract fun shareRecordDao(): ShareRecordDao
abstract fun appSettingDao(): AppSettingDao
}

View File

@ -0,0 +1,172 @@
package com.durian.tssparty.data.local
import com.durian.tssparty.domain.model.KeygenResult
import com.durian.tssparty.domain.model.Participant
import com.durian.tssparty.domain.model.SignResult
import com.durian.tssparty.domain.model.TssOutgoingMessage
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.withContext
import tsslib.MessageCallback
import tsslib.Tsslib
import javax.inject.Inject
import javax.inject.Singleton
/**
* Bridge between Kotlin and Go TSS library via gomobile bindings
*/
@Singleton
class TssNativeBridge @Inject constructor(
private val gson: Gson
) {
private val _outgoingMessages = Channel<TssOutgoingMessage>(Channel.BUFFERED)
val outgoingMessages: Flow<TssOutgoingMessage> = _outgoingMessages.receiveAsFlow()
private val _progress = Channel<Pair<Int, Int>>(Channel.BUFFERED)
val progress: Flow<Pair<Int, Int>> = _progress.receiveAsFlow()
private val _errors = Channel<String>(Channel.BUFFERED)
val errors: Flow<String> = _errors.receiveAsFlow()
private val _logs = Channel<String>(Channel.BUFFERED)
val logs: Flow<String> = _logs.receiveAsFlow()
private val callback = object : MessageCallback {
override fun onOutgoingMessage(messageJSON: String) {
try {
val message = gson.fromJson(messageJSON, TssOutgoingMessage::class.java)
_outgoingMessages.trySend(message)
} catch (e: Exception) {
_errors.trySend("Failed to parse outgoing message: ${e.message}")
}
}
override fun onProgress(round: Long, totalRounds: Long) {
_progress.trySend(Pair(round.toInt(), totalRounds.toInt()))
}
override fun onError(errorMessage: String) {
_errors.trySend(errorMessage)
}
override fun onLog(message: String) {
_logs.trySend(message)
}
}
/**
* Start a keygen session
*/
suspend fun startKeygen(
sessionId: String,
partyId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
participants: List<Participant>,
password: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val participantsJson = gson.toJson(participants)
Tsslib.startKeygen(
sessionId,
partyId,
partyIndex.toLong(),
thresholdT.toLong(),
thresholdN.toLong(),
participantsJson,
password,
callback
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Start a sign session
*/
suspend fun startSign(
sessionId: String,
partyId: String,
partyIndex: Int,
thresholdT: Int,
thresholdN: Int,
participants: List<Participant>,
messageHash: String,
shareData: String,
password: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val participantsJson = gson.toJson(participants)
Tsslib.startSign(
sessionId,
partyId,
partyIndex.toLong(),
thresholdT.toLong(),
thresholdN.toLong(),
participantsJson,
messageHash,
shareData,
password,
callback
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Send incoming message from another party
*/
suspend fun sendIncomingMessage(
fromPartyIndex: Int,
isBroadcast: Boolean,
payload: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
Tsslib.sendIncomingMessage(fromPartyIndex.toLong(), isBroadcast, payload)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Wait for keygen result
*/
suspend fun waitForKeygenResult(password: String): Result<KeygenResult> = withContext(Dispatchers.IO) {
try {
val resultJson = Tsslib.waitForKeygenResult(password)
val result = gson.fromJson(resultJson, KeygenResult::class.java)
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Wait for sign result
*/
suspend fun waitForSignResult(): Result<SignResult> = withContext(Dispatchers.IO) {
try {
val resultJson = Tsslib.waitForSignResult()
val result = gson.fromJson(resultJson, SignResult::class.java)
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Cancel current session
*/
fun cancelSession() {
Tsslib.cancelSession()
}
}

View File

@ -0,0 +1,89 @@
package com.durian.tssparty.di
import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.TssDatabase
import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient
import com.durian.tssparty.data.repository.TssRepository
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
// Migration from version 1 to 2: add app_settings table
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `app_settings` (" +
"`key` TEXT NOT NULL PRIMARY KEY, " +
"`value` TEXT NOT NULL)"
)
}
}
@Provides
@Singleton
fun provideGson(): Gson {
return GsonBuilder().create()
}
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): TssDatabase {
return Room.databaseBuilder(
context,
TssDatabase::class.java,
"tss_party.db"
)
.addMigrations(MIGRATION_1_2)
.build()
}
@Provides
@Singleton
fun provideShareRecordDao(database: TssDatabase): ShareRecordDao {
return database.shareRecordDao()
}
@Provides
@Singleton
fun provideAppSettingDao(database: TssDatabase): AppSettingDao {
return database.appSettingDao()
}
@Provides
@Singleton
fun provideGrpcClient(): GrpcClient {
return GrpcClient()
}
@Provides
@Singleton
fun provideTssNativeBridge(gson: Gson): TssNativeBridge {
return TssNativeBridge(gson)
}
@Provides
@Singleton
fun provideTssRepository(
grpcClient: GrpcClient,
tssNativeBridge: TssNativeBridge,
shareRecordDao: ShareRecordDao,
appSettingDao: AppSettingDao
): TssRepository {
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao)
}
}

View File

@ -0,0 +1,58 @@
package com.durian.tssparty.domain.model
/**
* Application ready state
*/
enum class AppReadyState {
INITIALIZING,
READY,
ERROR
}
/**
* Service check status
*/
data class ServiceStatus(
val isOnline: Boolean = false,
val message: String = "",
val latency: Long? = null
)
/**
* Environment state - tracks all service statuses
*/
data class EnvironmentState(
val database: ServiceStatus = ServiceStatus(),
val messageRouter: ServiceStatus = ServiceStatus(),
val kavaApi: ServiceStatus = ServiceStatus()
)
/**
* Operation progress for keygen/sign
*/
data class OperationProgress(
val isActive: Boolean = false,
val type: OperationType = OperationType.NONE,
val sessionId: String? = null,
val currentRound: Int = 0,
val totalRounds: Int = 0,
val status: String = ""
)
enum class OperationType {
NONE,
KEYGEN,
SIGN
}
/**
* Global app state (similar to Zustand store in Electron version)
*/
data class AppState(
val appReady: AppReadyState = AppReadyState.INITIALIZING,
val appError: String? = null,
val environment: EnvironmentState = EnvironmentState(),
val operation: OperationProgress = OperationProgress(),
val partyId: String? = null,
val walletCount: Int = 0
)

View File

@ -0,0 +1,234 @@
package com.durian.tssparty.domain.model
import com.google.gson.annotations.SerializedName
/**
* Participant in a TSS session
*/
data class Participant(
@SerializedName("partyId")
val partyId: String,
@SerializedName("partyIndex")
val partyIndex: Int,
@SerializedName("name")
val name: String = ""
)
/**
* TSS Session information
*/
data class TssSession(
val sessionId: String,
val sessionType: SessionType,
val thresholdT: Int,
val thresholdN: Int,
val participants: List<Participant>,
val status: SessionStatus,
val inviteCode: String? = null,
val messageHash: String? = null,
val createdAt: Long = System.currentTimeMillis()
)
enum class SessionType {
KEYGEN,
SIGN
}
enum class SessionStatus {
WAITING,
IN_PROGRESS,
COMPLETED,
FAILED
}
/**
* Result of key generation
*/
data class KeygenResult(
@SerializedName("publicKey")
val publicKey: String, // base64 encoded
@SerializedName("encryptedShare")
val encryptedShare: String // base64 encoded
)
/**
* Result of signing
*/
data class SignResult(
@SerializedName("signature")
val signature: String, // base64 encoded (r || s || v, 65 bytes)
@SerializedName("recoveryId")
val recoveryId: Int
)
/**
* Outgoing TSS message
*/
data class TssOutgoingMessage(
@SerializedName("type")
val type: String,
@SerializedName("isBroadcast")
val isBroadcast: Boolean,
@SerializedName("toParties")
val toParties: List<String>?,
@SerializedName("payload")
val payload: String // base64 encoded
)
/**
* Share record stored in local database
*/
data class ShareRecord(
val id: Long = 0,
val sessionId: String,
val publicKey: String,
val encryptedShare: String,
val thresholdT: Int,
val thresholdN: Int,
val partyIndex: Int,
val address: String,
val createdAt: Long = System.currentTimeMillis()
)
/**
* Account balance information
*/
data class AccountBalance(
val address: String,
val balance: String,
val denom: String = "ukava"
)
/**
* Sign session request
*/
data class SignSessionRequest(
val sessionId: String,
val messageHash: String, // hex encoded
val participants: List<Participant>
)
/**
* Settings
* Matches service-party-app settings structure
*/
data class AppSettings(
val messageRouterUrl: String = "mpc-grpc.szaiai.com:443",
val accountServiceUrl: String = "https://rwaapi.szaiai.com",
val kavaRpcUrl: String = "https://evm.kava.io",
val networkType: NetworkType = NetworkType.MAINNET
)
enum class NetworkType {
MAINNET,
TESTNET
}
/**
* Token type for transfers
*/
enum class TokenType {
KAVA, // Native KAVA token
GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token
}
/**
* Green Points (绿积分) Token Contract Configuration
* dUSDT - Fixed supply ERC-20 token on Kava EVM
*/
object GreenPointsToken {
const val CONTRACT_ADDRESS = "0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3"
const val NAME = "绿积分"
const val SYMBOL = "dUSDT"
const val DECIMALS = 6
// ERC-20 function signatures (first 4 bytes of keccak256 hash)
const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address)
const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256)
const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256)
const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address)
const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply()
}
/**
* Wallet balance containing both native and token balances
*/
data class WalletBalance(
val address: String,
val kavaBalance: String = "0", // Native KAVA balance
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance
)
/**
* Share backup data for export/import
* Contains all necessary information to restore a wallet share
*/
data class ShareBackup(
@SerializedName("version")
val version: Int = 1, // Backup format version for future compatibility
@SerializedName("sessionId")
val sessionId: String,
@SerializedName("publicKey")
val publicKey: String, // base64 encoded
@SerializedName("encryptedShare")
val encryptedShare: String, // base64 encoded, encrypted with user password
@SerializedName("thresholdT")
val thresholdT: Int,
@SerializedName("thresholdN")
val thresholdN: Int,
@SerializedName("partyIndex")
val partyIndex: Int,
@SerializedName("address")
val address: String,
@SerializedName("createdAt")
val createdAt: Long,
@SerializedName("exportedAt")
val exportedAt: Long = System.currentTimeMillis()
) {
companion object {
const val FILE_EXTENSION = "tss-backup"
const val MIME_TYPE = "application/octet-stream"
/**
* Create backup from ShareRecord
*/
fun fromShareRecord(share: ShareRecord): ShareBackup {
return ShareBackup(
sessionId = share.sessionId,
publicKey = share.publicKey,
encryptedShare = share.encryptedShare,
thresholdT = share.thresholdT,
thresholdN = share.thresholdN,
partyIndex = share.partyIndex,
address = share.address,
createdAt = share.createdAt
)
}
}
/**
* Convert backup to ShareRecord for database storage
*/
fun toShareRecord(): ShareRecord {
return ShareRecord(
id = 0, // Will be auto-generated
sessionId = sessionId,
publicKey = publicKey,
encryptedShare = encryptedShare,
thresholdT = thresholdT,
thresholdN = thresholdN,
partyIndex = partyIndex,
address = address,
createdAt = createdAt
)
}
}

View File

@ -0,0 +1,85 @@
package com.durian.tssparty.presentation.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Navigation destinations for bottom tabs
*/
sealed class BottomNavItem(
val route: String,
val title: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
data object Wallets : BottomNavItem(
route = "wallets",
title = "我的钱包",
selectedIcon = Icons.Filled.Lock,
unselectedIcon = Icons.Outlined.Lock
)
data object Create : BottomNavItem(
route = "create",
title = "创建钱包",
selectedIcon = Icons.Filled.Add,
unselectedIcon = Icons.Outlined.Add
)
data object JoinKeygen : BottomNavItem(
route = "join_keygen",
title = "加入创建",
selectedIcon = Icons.Filled.Handshake,
unselectedIcon = Icons.Outlined.Handshake
)
data object CoSign : BottomNavItem(
route = "cosign",
title = "参与签名",
selectedIcon = Icons.Filled.Create,
unselectedIcon = Icons.Outlined.Create
)
data object Settings : BottomNavItem(
route = "settings",
title = "设置",
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings
)
}
val bottomNavItems = listOf(
BottomNavItem.Wallets,
BottomNavItem.JoinKeygen,
BottomNavItem.CoSign,
BottomNavItem.Settings
)
@Composable
fun TssBottomNavigation(
currentRoute: String,
onNavigate: (BottomNavItem) -> Unit
) {
NavigationBar {
bottomNavItems.forEach { item ->
val selected = currentRoute == item.route ||
(item == BottomNavItem.Wallets && currentRoute.startsWith("wallet_detail"))
NavigationBarItem(
icon = {
Icon(
imageVector = if (selected) item.selectedIcon else item.unselectedIcon,
contentDescription = item.title
)
},
label = { Text(item.title) },
selected = selected,
onClick = { onNavigate(item) }
)
}
}
}

View File

@ -0,0 +1,258 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.ShareRecord
import com.durian.tssparty.domain.model.WalletBalance
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
shares: List<ShareRecord>,
walletBalances: Map<String, WalletBalance>,
isConnected: Boolean,
onNavigateToJoin: () -> Unit,
onNavigateToSign: (Long) -> Unit,
onNavigateToSettings: () -> Unit,
onDeleteShare: (Long) -> Unit,
onRefreshBalances: () -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("TSS Party") },
actions = {
// Refresh button
IconButton(onClick = onRefreshBalances) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
// Connection status indicator
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = if (isConnected) "Connected" else "Disconnected",
tint = if (isConnected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onNavigateToSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToJoin) {
Icon(Icons.Default.Add, contentDescription = "Join Session")
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
Text(
text = "My Wallets",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(16.dp))
if (shares.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No wallets yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to join a keygen session",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(shares) { share ->
WalletCard(
share = share,
walletBalance = walletBalances[share.address],
onSign = { onNavigateToSign(share.id) },
onDelete = { onDeleteShare(share.id) }
)
}
}
}
}
}
}
@Composable
fun WalletCard(
share: ShareRecord,
walletBalance: WalletBalance?,
onSign: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Address",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = share.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Balance section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = walletBalance?.kavaBalance ?: "Loading...",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
}
// Green Points balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = walletBalance?.greenPointsBalance ?: "Loading...",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${share.thresholdT}-of-${share.thresholdN}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "Party #${share.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { showDeleteDialog = true }) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Delete")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = onSign) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Sign")
}
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Wallet") },
text = { Text("Are you sure you want to delete this wallet? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
}

View File

@ -0,0 +1,847 @@
package com.durian.tssparty.presentation.screens
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
/**
* Session info returned from validateInviteCode API
* Matches service-party-app SessionInfo type
*/
data class JoinSessionInfo(
val sessionId: String,
val walletName: String,
val thresholdT: Int,
val thresholdN: Int,
val initiator: String,
val currentParticipants: Int,
val totalParticipants: Int
)
/**
* Format countdown seconds to mm:ss display
*/
private fun formatCountdown(seconds: Long): String {
if (seconds < 0) return ""
val minutes = seconds / 60
val secs = seconds % 60
return "%d:%02d".format(minutes, secs)
}
/**
* JoinKeygen screen matching service-party-app/src/renderer/src/pages/Join.tsx
* Simplified flow without password: input confirm joining
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinKeygenScreen(
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
sessionInfo: JoinSessionInfo? = null,
participants: List<String> = emptyList(),
currentRound: Int = 0,
totalRounds: Int = 9,
publicKey: String? = null,
countdownSeconds: Long = -1L, // 5-minute countdown: -1 = not counting, >0 = remaining seconds
onValidateInviteCode: (inviteCode: String) -> Unit,
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
onCancel: () -> Unit,
onResetState: () -> Unit = {}, // Reset ViewModel state without navigating
onBackToHome: () -> Unit = {}
) {
var inviteCode by remember { mutableStateOf("") }
var validationError by remember { mutableStateOf<String?>(null) }
// 3-step flow: input → confirm → joining
var step by remember { mutableStateOf("input") }
var autoJoinAttempted by remember { mutableStateOf(false) }
// Handle session info received (validation success)
LaunchedEffect(sessionInfo) {
if (sessionInfo != null && step == "input") {
step = "confirm"
}
}
// Auto-join when we have session info (password is empty string)
LaunchedEffect(step, sessionInfo, autoJoinAttempted, isLoading) {
if (step == "confirm" && sessionInfo != null && !autoJoinAttempted && !isLoading && error == null) {
autoJoinAttempted = true
step = "joining"
onJoinKeygen(inviteCode, "") // Empty password
}
}
// Handle session status changes
LaunchedEffect(sessionStatus) {
when (sessionStatus) {
SessionStatus.IN_PROGRESS -> {
step = "progress"
}
SessionStatus.COMPLETED -> {
step = "completed"
}
SessionStatus.FAILED -> {
if (step == "joining") {
step = "confirm"
}
}
else -> {}
}
}
// Reset auto-join on error
LaunchedEffect(error) {
if (error != null && step == "joining") {
step = "confirm"
autoJoinAttempted = false
}
}
// Reset to input state (used by cancel buttons in confirm/joining/progress screens)
// This resets UI state to input screen WITHOUT navigating away from JoinKeygen page
val resetToInput: () -> Unit = {
step = "input"
inviteCode = ""
validationError = null
autoJoinAttempted = false
onResetState() // Clear ViewModel state (sessionInfo, joinToken, etc.) without navigating
}
when (step) {
"input" -> InputScreen(
inviteCode = inviteCode,
isLoading = isLoading,
error = error,
validationError = validationError,
onInviteCodeChange = { inviteCode = it },
onValidateCode = {
when {
inviteCode.isBlank() -> validationError = "请输入邀请码"
else -> {
validationError = null
onValidateInviteCode(inviteCode)
}
}
},
onCancel = onCancel // In input state, cancel navigates away
)
"confirm" -> ConfirmScreen(
sessionInfo = sessionInfo,
isLoading = isLoading,
error = error,
onBack = {
step = "input"
autoJoinAttempted = false
},
onRetry = {
autoJoinAttempted = false
},
onCancel = resetToInput // Reset to input state, stay on page
)
"joining" -> JoiningScreen(
countdownSeconds = countdownSeconds,
onCancel = resetToInput
) // Reset to input state, stay on page
"progress" -> KeygenProgressScreen(
sessionStatus = sessionStatus,
participants = participants,
currentRound = currentRound,
totalRounds = totalRounds,
countdownSeconds = countdownSeconds,
onCancel = resetToInput // Reset to input state, stay on page
)
"completed" -> KeygenCompletedScreen(
publicKey = publicKey,
onBackToHome = onBackToHome
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InputScreen(
inviteCode: String,
isLoading: Boolean,
error: String?,
validationError: String?,
onInviteCodeChange: (String) -> Unit,
onValidateCode: () -> Unit,
onCancel: () -> Unit
) {
val context = LocalContext.current
// QR Scanner launcher
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract()
) { result ->
if (result.contents != null) {
// Parse the scanned content (could be invite code or deep link)
val scannedContent = result.contents
val extractedCode = if (scannedContent.startsWith("tssparty://join/")) {
scannedContent.removePrefix("tssparty://join/")
} else {
scannedContent
}
onInviteCodeChange(extractedCode)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "加入共管钱包",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "扫描二维码或输入邀请码加入多方钱包创建会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Scan QR Button
Card(
onClick = {
val options = ScanOptions().apply {
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
setPrompt("扫描邀请二维码")
setCameraId(0)
setBeepEnabled(true)
setBarcodeImageEnabled(false)
setOrientationLocked(true)
setCaptureActivity(PortraitCaptureActivity::class.java)
}
scanLauncher.launch(options)
},
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "扫描二维码",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Divider with "或"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Divider(modifier = Modifier.weight(1f))
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(modifier = Modifier.height(24.dp))
// Invite Code Input
OutlinedTextField(
value = inviteCode,
onValueChange = onInviteCodeChange,
label = { Text("邀请码") },
placeholder = { Text("粘贴邀请码") },
leadingIcon = {
Icon(Icons.Default.Key, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading
)
Spacer(modifier = Modifier.height(16.dp))
// Info card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "请向会话发起者获取邀请二维码或邀请码",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Error display
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
validationError?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Spacer(modifier = Modifier.weight(1f))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text("取消")
}
Button(
onClick = onValidateCode,
modifier = Modifier.weight(1f),
enabled = !isLoading && inviteCode.isNotBlank()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("验证中...")
} else {
Text("加入会话")
}
}
}
}
}
@Composable
private fun ConfirmScreen(
sessionInfo: JoinSessionInfo?,
isLoading: Boolean,
error: String?,
onBack: () -> Unit,
onRetry: () -> Unit,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "确认会话信息",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(24.dp))
// Session info card
if (sessionInfo != null) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
InfoRow("钱包名称", sessionInfo.walletName)
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("阈值设置", "${sessionInfo.thresholdT}-of-${sessionInfo.thresholdN}")
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("发起者", sessionInfo.initiator)
Divider(modifier = Modifier.padding(vertical = 8.dp))
InfoRow("当前参与者", "${sessionInfo.currentParticipants} / ${sessionInfo.totalParticipants}")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Error or auto-joining state
if (error != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("取消")
}
Button(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Text("重试")
}
}
} else {
// Auto-joining state
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "正在自动加入会话...",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(24.dp))
// Cancel button during auto-join
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
}
@Composable
private fun JoiningScreen(
countdownSeconds: Long = -1L,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "正在加入会话...",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "请稍候,正在连接到其他参与者",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Countdown timer (if counting down)
if (countdownSeconds > 0) {
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "等待启动: ${formatCountdown(countdownSeconds)}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Cancel button
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
@Composable
private fun KeygenProgressScreen(
sessionStatus: SessionStatus,
participants: List<String>,
currentRound: Int,
totalRounds: Int,
countdownSeconds: Long = -1L,
onCancel: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header
Text(
text = "密钥生成中",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请保持应用在前台,直到密钥生成完成",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Progress indicator
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(24.dp))
// Progress card
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "协议进度",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = "$currentRound / $totalRounds",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = if (totalRounds > 0) currentRound.toFloat() / totalRounds else 0f,
modifier = Modifier.fillMaxWidth()
)
}
}
// Countdown timer (if counting down - waiting for keygen to start)
if (countdownSeconds > 0) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "等待密钥生成启动",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Text(
text = formatCountdown(countdownSeconds),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Participants card
if (participants.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "参与方 (${participants.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
participants.forEach { participant ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = participant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Cancel button
OutlinedButton(onClick = onCancel) {
Icon(Icons.Default.Cancel, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("取消")
}
}
}
@Composable
private fun KeygenCompletedScreen(
publicKey: String?,
onBackToHome: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Success icon
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "密钥生成成功!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "您的钱包已创建成功,可以在「我的钱包」中查看",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// Public key info
if (publicKey != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "公钥",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${publicKey.take(20)}...${publicKey.takeLast(20)}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onBackToHome,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Home, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("返回首页")
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}

View File

@ -0,0 +1,229 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JoinScreen(
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
onJoinKeygen: (inviteCode: String, password: String) -> Unit,
onCancel: () -> Unit,
onBack: () -> Unit
) {
var inviteCode by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var passwordError by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Join Keygen") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isLoading) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Instructions
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Enter the invite code shared by the session creator and set a password to protect your key share.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
// Invite code input
OutlinedTextField(
value = inviteCode,
onValueChange = { inviteCode = it },
label = { Text("Invite Code") },
placeholder = { Text("session-id:join-token") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.QrCode, contentDescription = null)
}
)
// Password input
OutlinedTextField(
value = password,
onValueChange = {
password = it
passwordError = null
},
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password"
)
}
}
)
// Confirm password
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
passwordError = null
},
label = { Text("Confirm Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
isError = passwordError != null,
supportingText = passwordError?.let { { Text(it) } }
)
// Error message
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Progress indicator
if (isLoading) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (sessionStatus) {
SessionStatus.WAITING -> "Waiting for other parties..."
SessionStatus.IN_PROGRESS -> "Generating keys..."
SessionStatus.COMPLETED -> "Completed!"
SessionStatus.FAILED -> "Failed"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isLoading) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
} else {
Button(
onClick = {
if (password != confirmPassword) {
passwordError = "Passwords do not match"
return@Button
}
if (password.length < 4) {
passwordError = "Password must be at least 4 characters"
return@Button
}
if (inviteCode.isBlank()) {
return@Button
}
onJoinKeygen(inviteCode.trim(), password)
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteCode.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Join Keygen")
}
}
}
}
}
}

View File

@ -0,0 +1,9 @@
package com.durian.tssparty.presentation.screens
import com.journeyapps.barcodescanner.CaptureActivity
/**
* Portrait-only barcode capture activity
* Used to force the QR scanner to use portrait orientation
*/
class PortraitCaptureActivity : CaptureActivity()

View File

@ -0,0 +1,542 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.durian.tssparty.BuildConfig
import com.durian.tssparty.domain.model.AppSettings
import com.durian.tssparty.domain.model.NetworkType
/**
* Connection test result
*/
data class ConnectionTestResult(
val success: Boolean,
val message: String,
val latency: Long? = null
)
/**
* Settings screen matching service-party-app/src/pages/Settings.tsx
* Full implementation with test connection buttons and Account Service URL
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
settings: AppSettings,
isConnected: Boolean,
messageRouterStatus: ConnectionTestResult? = null,
accountServiceStatus: ConnectionTestResult? = null,
kavaApiStatus: ConnectionTestResult? = null,
onSaveSettings: (AppSettings) -> Unit,
onTestMessageRouter: (String) -> Unit = {},
onTestAccountService: (String) -> Unit = {},
onTestKavaApi: (String) -> Unit = {}
) {
var messageRouterUrl by remember { mutableStateOf(settings.messageRouterUrl) }
var accountServiceUrl by remember { mutableStateOf(settings.accountServiceUrl) }
var kavaRpcUrl by remember { mutableStateOf(settings.kavaRpcUrl) }
var networkType by remember { mutableStateOf(settings.networkType) }
var hasChanges by remember { mutableStateOf(false) }
// Test connection states
var isTestingMessageRouter by remember { mutableStateOf(false) }
var isTestingAccountService by remember { mutableStateOf(false) }
var isTestingKavaApi by remember { mutableStateOf(false) }
// Local test results (for display)
var localMessageRouterResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
var localAccountServiceResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
var localKavaApiResult by remember { mutableStateOf<ConnectionTestResult?>(null) }
// Update local results when props change
LaunchedEffect(messageRouterStatus) {
if (messageRouterStatus != null) {
localMessageRouterResult = messageRouterStatus
isTestingMessageRouter = false
}
}
LaunchedEffect(accountServiceStatus) {
if (accountServiceStatus != null) {
localAccountServiceResult = accountServiceStatus
isTestingAccountService = false
}
}
LaunchedEffect(kavaApiStatus) {
if (kavaApiStatus != null) {
localKavaApiResult = kavaApiStatus
isTestingKavaApi = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header
Text(
text = "设置",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "配置应用程序连接和网络设置",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Connection status overview
Card(
colors = CardDefaults.cardColors(
containerColor = if (isConnected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = if (isConnected) "应用就绪" else "连接异常",
fontWeight = FontWeight.Medium,
color = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = if (isConnected) "所有服务正常运行" else "请检查网络设置",
style = MaterialTheme.typography.bodySmall,
color = if (isConnected)
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: Connection Settings
Text(
text = "连接设置",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Message Router URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "消息路由服务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "TSS 多方计算消息中继服务器",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = messageRouterUrl,
onValueChange = {
messageRouterUrl = it
hasChanges = true
localMessageRouterResult = null
},
label = { Text("服务地址") },
placeholder = { Text("mpc-grpc.szaiai.com:443") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Cloud, contentDescription = null)
}
)
Button(
onClick = {
isTestingMessageRouter = true
localMessageRouterResult = null
onTestMessageRouter(messageRouterUrl)
},
enabled = !isTestingMessageRouter && messageRouterUrl.isNotBlank()
) {
if (isTestingMessageRouter) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localMessageRouterResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Account Service URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "账户服务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "会话管理和账户 API 服务",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = accountServiceUrl,
onValueChange = {
accountServiceUrl = it
hasChanges = true
localAccountServiceResult = null
},
label = { Text("API 地址") },
placeholder = { Text("https://rwaapi.szaiai.com") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Api, contentDescription = null)
}
)
Button(
onClick = {
isTestingAccountService = true
localAccountServiceResult = null
onTestAccountService(accountServiceUrl)
},
enabled = !isTestingAccountService && accountServiceUrl.isNotBlank()
) {
if (isTestingAccountService) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localAccountServiceResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: Blockchain Network
Text(
text = "区块链网络",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "选择要连接的 Kava 区块链网络",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilterChip(
selected = networkType == NetworkType.MAINNET,
onClick = {
networkType = NetworkType.MAINNET
kavaRpcUrl = "https://evm.kava.io"
hasChanges = true
localKavaApiResult = null
},
label = { Text("主网 (Kava)") },
leadingIcon = if (networkType == NetworkType.MAINNET) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
} else null
)
FilterChip(
selected = networkType == NetworkType.TESTNET,
onClick = {
networkType = NetworkType.TESTNET
kavaRpcUrl = "https://evm.testnet.kava.io"
hasChanges = true
localKavaApiResult = null
},
label = { Text("测试网") },
leadingIcon = if (networkType == NetworkType.TESTNET) {
{ Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
} else null
)
}
Spacer(modifier = Modifier.height(16.dp))
// Kava RPC URL
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Kava RPC 节点",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "区块链交易和查询 API",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = kavaRpcUrl,
onValueChange = {
kavaRpcUrl = it
hasChanges = true
localKavaApiResult = null
},
label = { Text("RPC 地址") },
placeholder = { Text("https://evm.kava.io") },
modifier = Modifier.weight(1f),
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Link, contentDescription = null)
}
)
Button(
onClick = {
isTestingKavaApi = true
localKavaApiResult = null
onTestKavaApi(kavaRpcUrl)
},
enabled = !isTestingKavaApi && kavaRpcUrl.isNotBlank()
) {
if (isTestingKavaApi) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("测试")
}
}
}
// Test result
localKavaApiResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (result.success) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null,
tint = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = result.message + (result.latency?.let { " (${it}ms)" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (result.success)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Section: About
Text(
text = "关于",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
AboutRow("应用名称", "TSS Party")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("版本", BuildConfig.VERSION_NAME)
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("版本号", BuildConfig.VERSION_CODE.toString())
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("构建类型", if (BuildConfig.DEBUG) "Debug" else "Release")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("TSS 协议", "GG20")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("区块链", "Kava EVM")
Divider(modifier = Modifier.padding(vertical = 8.dp))
AboutRow("项目", "RWADurian MPC System")
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.weight(1f))
// Save button
Button(
onClick = {
onSaveSettings(
AppSettings(
messageRouterUrl = messageRouterUrl,
accountServiceUrl = accountServiceUrl,
kavaRpcUrl = kavaRpcUrl,
networkType = networkType
)
)
hasChanges = false
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = hasChanges
) {
Icon(Icons.Default.Save, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("保存设置")
}
}
}
@Composable
private fun AboutRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}

View File

@ -0,0 +1,199 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.domain.model.SessionStatus
import com.durian.tssparty.domain.model.ShareRecord
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignScreen(
share: ShareRecord?,
sessionStatus: SessionStatus,
isLoading: Boolean,
error: String?,
onJoinSign: (inviteCode: String, shareId: Long, password: String) -> Unit,
onCancel: () -> Unit,
onBack: () -> Unit
) {
var inviteCode by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Sign Transaction") },
navigationIcon = {
IconButton(onClick = onBack, enabled = !isLoading) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Wallet info
share?.let { s ->
Card {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Signing with wallet",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = s.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${s.thresholdT}-of-${s.thresholdN} • Party #${s.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
// Invite code input
OutlinedTextField(
value = inviteCode,
onValueChange = { inviteCode = it },
label = { Text("Sign Session Code") },
placeholder = { Text("session-id:join-token") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
leadingIcon = {
Icon(Icons.Default.QrCode, contentDescription = null)
}
)
// Password input
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password"
)
}
}
)
// Error message
error?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
// Progress indicator
if (isLoading) {
Card {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = when (sessionStatus) {
SessionStatus.WAITING -> "Waiting for other parties..."
SessionStatus.IN_PROGRESS -> "Signing in progress..."
SessionStatus.COMPLETED -> "Signed successfully!"
SessionStatus.FAILED -> "Signing failed"
},
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isLoading) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
} else {
Button(
onClick = {
share?.let {
onJoinSign(inviteCode.trim(), it.id, password)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = share != null && inviteCode.isNotBlank() && password.isNotBlank()
) {
Icon(Icons.Default.Edit, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Sign")
}
}
}
}
}
}

View File

@ -0,0 +1,273 @@
package com.durian.tssparty.presentation.screens
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.durian.tssparty.domain.model.AppReadyState
import com.durian.tssparty.domain.model.AppState
import com.durian.tssparty.domain.model.ServiceStatus
@Composable
fun StartupCheckScreen(
appState: AppState,
onEnterApp: () -> Unit,
onRetry: () -> Unit
) {
val canEnter = appState.appReady == AppReadyState.READY || appState.appReady == AppReadyState.ERROR
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// App Logo/Icon
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(24.dp))
// App Title
Text(
text = "TSS Party",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "多方安全计算钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(48.dp))
// Service Check Cards
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "服务状态",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(16.dp))
// Database Status
ServiceCheckItem(
icon = Icons.Default.Storage,
title = "本地数据库",
status = appState.environment.database,
extraInfo = if (appState.walletCount > 0) "${appState.walletCount} 个钱包" else null
)
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Message Router Status
ServiceCheckItem(
icon = Icons.Default.Cloud,
title = "消息路由服务",
status = appState.environment.messageRouter,
extraInfo = appState.partyId?.take(8)?.let { "Party: $it..." }
)
Divider(modifier = Modifier.padding(vertical = 12.dp))
// Kava API Status
ServiceCheckItem(
icon = Icons.Default.Language,
title = "Kava 区块链",
status = appState.environment.kavaApi,
extraInfo = appState.environment.kavaApi.latency?.let { "${it}ms" }
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Status Message
when (appState.appReady) {
AppReadyState.INITIALIZING -> {
Row(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "正在检查服务...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AppReadyState.READY -> {
Text(
text = "所有服务就绪",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
AppReadyState.ERROR -> {
Text(
text = appState.appError ?: "部分服务不可用",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Action Buttons
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (appState.appReady == AppReadyState.ERROR) {
OutlinedButton(
onClick = onRetry,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("重试")
}
}
Button(
onClick = onEnterApp,
enabled = canEnter,
modifier = Modifier.weight(1f)
) {
Text(
text = when (appState.appReady) {
AppReadyState.READY -> "进入应用"
AppReadyState.ERROR -> "继续使用"
else -> "加载中..."
}
)
if (canEnter) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
}
@Composable
private fun ServiceCheckItem(
icon: ImageVector,
title: String,
status: ServiceStatus,
extraInfo: String? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with status indicator
Box {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
// Status dot
Box(
modifier = Modifier
.size(12.dp)
.align(Alignment.BottomEnd)
.clip(CircleShape)
.background(
when {
status.message.isEmpty() -> Color.Gray
status.isOnline -> Color(0xFFD4AF37) // Gold for success
else -> Color(0xFFFF5722)
}
)
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
if (status.message.isNotEmpty()) {
Text(
text = status.message,
style = MaterialTheme.typography.bodySmall,
color = if (status.isOnline)
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.error
)
}
}
// Extra info (wallet count, latency, etc.)
extraInfo?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}

View File

@ -0,0 +1,716 @@
package com.durian.tssparty.presentation.screens
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
// Note: Some unused imports kept for ExportBackupDialog which still uses password
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import android.content.Intent
import android.net.Uri
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import com.durian.tssparty.domain.model.ShareRecord
import com.durian.tssparty.domain.model.WalletBalance
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletsScreen(
shares: List<ShareRecord>,
isConnected: Boolean,
balances: Map<String, String> = emptyMap(),
walletBalances: Map<String, WalletBalance> = emptyMap(),
networkType: NetworkType = NetworkType.MAINNET,
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
onTransfer: ((shareId: Long) -> Unit)? = null,
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
onImportBackup: (() -> Unit)? = null,
onCreateWallet: (() -> Unit)? = null
) {
var selectedWallet by remember { mutableStateOf<ShareRecord?>(null) }
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Header with connection status
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "我的钱包",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
// Connection status
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = if (isConnected) "已连接" else "未连接",
tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (isConnected) "已连接" else "离线",
style = MaterialTheme.typography.bodySmall,
color = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${shares.size} 个钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
if (shares.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无钱包",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "使用「创建钱包」发起新钱包",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
Text(
text = "或使用「加入创建」参与他人的会话",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
// Wallet list
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
) {
items(shares) { share ->
WalletItemCard(
share = share,
balance = balances[share.address],
walletBalance = walletBalances[share.address],
onViewDetails = { selectedWallet = share },
onTransfer = {
onTransfer?.invoke(share.id)
},
onDelete = { onDeleteShare(share.id) }
)
}
}
}
}
// Floating Action Buttons - Import and Create
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Import button (smaller, secondary)
if (onImportBackup != null) {
FloatingActionButton(
onClick = onImportBackup,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Icon(
imageVector = Icons.Default.Upload,
contentDescription = "导入备份"
)
}
}
// Create wallet button (primary)
if (onCreateWallet != null) {
FloatingActionButton(
onClick = onCreateWallet,
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "创建钱包",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
// Wallet detail dialog
selectedWallet?.let { wallet ->
WalletDetailDialog(
wallet = wallet,
networkType = networkType,
onDismiss = { selectedWallet = null },
onTransfer = {
selectedWallet = null
onTransfer?.invoke(wallet.id)
},
onExport = onExportBackup?.let { export ->
{ password -> export(wallet.id, password) }
}
)
}
}
@Composable
private fun WalletItemCard(
share: ShareRecord,
balance: String? = null,
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onViewDetails() }
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Header with threshold badge
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Threshold badge
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${share.thresholdT}-of-${share.thresholdN}",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
// Party index
Text(
text = "参与者 #${share.partyIndex}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.height(12.dp))
// Address
Text(
text = "地址",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = share.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(12.dp))
// Balance display - now shows both KAVA and Green Points
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// KAVA balance
Column {
Text(
text = "KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AccountBalance,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null || balance != null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
// Green Points (绿积分) balance
Column(horizontalAlignment = Alignment.End) {
Text(
text = GreenPointsToken.NAME,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF4CAF50) // Green color for Green Points
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = walletBalance?.greenPointsBalance ?: "加载中...",
style = MaterialTheme.typography.bodyMedium,
color = if (walletBalance != null)
Color(0xFF4CAF50)
else
MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider()
Spacer(modifier = Modifier.height(8.dp))
// Actions
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = onViewDetails) {
Icon(
Icons.Default.QrCode,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("详情")
}
TextButton(onClick = onTransfer) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("转账")
}
TextButton(
onClick = { showDeleteDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("删除")
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = { Text("删除钱包") },
text = {
Text("确定要删除这个钱包吗?此操作无法撤销,删除后您将无法使用此密钥份额参与签名。")
},
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteDialog = false
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("删除")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("取消")
}
}
)
}
}
@Composable
private fun WalletDetailDialog(
wallet: ShareRecord,
networkType: NetworkType = NetworkType.MAINNET,
onDismiss: () -> Unit,
onTransfer: () -> Unit,
onExport: ((String) -> Unit)?
) {
val clipboardManager = LocalClipboardManager.current
val context = androidx.compose.ui.platform.LocalContext.current
val scope = rememberCoroutineScope()
var showExportDialog by remember { mutableStateOf(false) }
var copySuccess by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// QR Code
val qrBitmap = remember(wallet.address) {
generateQRCode(wallet.address, 240)
}
qrBitmap?.let { bitmap ->
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Threshold badge
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "${wallet.thresholdT}-of-${wallet.thresholdN} 多签钱包",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(16.dp))
// Address
Text(
text = "Kava EVM 地址",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = wallet.address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Action buttons row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Copy button
OutlinedButton(
onClick = {
clipboardManager.setText(AnnotatedString(wallet.address))
copySuccess = true
scope.launch {
delay(2000)
copySuccess = false
}
},
modifier = Modifier.weight(1f)
) {
Icon(
if (copySuccess) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(if (copySuccess) "已复制" else "复制地址")
}
// Explorer button
OutlinedButton(
onClick = {
val baseUrl = if (networkType == NetworkType.TESTNET) {
"https://testnet.kavascan.com"
} else {
"https://kavascan.com"
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$baseUrl/address/${wallet.address}"))
context.startActivity(intent)
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.OpenInNew,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("浏览器")
}
}
Spacer(modifier = Modifier.height(16.dp))
Divider()
Spacer(modifier = Modifier.height(16.dp))
// Info rows
InfoRow("门限设置", "${wallet.thresholdT}-of-${wallet.thresholdN}")
InfoRow("您的序号", "#${wallet.partyIndex}")
InfoRow("会话ID", wallet.sessionId.take(16) + "...")
Spacer(modifier = Modifier.height(24.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onTransfer,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Send,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("转账")
}
if (onExport != null) {
OutlinedButton(
onClick = { showExportDialog = true },
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("导出")
}
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onDismiss) {
Text("关闭")
}
}
}
}
// Export dialog
if (showExportDialog && onExport != null) {
ExportBackupDialog(
onDismiss = { showExportDialog = false },
onConfirm = { password ->
onExport(password)
showExportDialog = false
}
)
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun ExportBackupDialog(
onDismiss: () -> Unit,
onConfirm: (password: String) -> Unit
) {
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(Icons.Default.Download, contentDescription = null)
},
title = { Text("导出备份") },
text = {
Column {
Text("导出加密备份文件,请妥善保管。")
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null
)
}
},
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(password) },
enabled = password.isNotBlank()
) {
Text("导出")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消")
}
}
)
}
/**
* Generate QR code bitmap
*/
private fun generateQRCode(content: String, size: Int): Bitmap? {
return try {
val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
}
}
bitmap
} catch (e: Exception) {
null
}
}

View File

@ -0,0 +1,100 @@
package com.durian.tssparty.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Dark Gray & Gold Theme Colors
private val Gold = Color(0xFFD4AF37) // Classic gold
private val GoldLight = Color(0xFFFFD966) // Light gold
private val GoldDark = Color(0xFFB8960C) // Dark gold
private val DarkGray = Color(0xFF1A1A1A) // Deep dark gray
private val MediumGray = Color(0xFF2D2D2D) // Medium dark gray
private val LightGray = Color(0xFF3D3D3D) // Lighter gray for surfaces
private val TextGray = Color(0xFFB0B0B0) // Gray for secondary text
private val DarkColorScheme = darkColorScheme(
primary = Gold,
onPrimary = Color.Black,
primaryContainer = GoldDark,
onPrimaryContainer = Color.White,
secondary = GoldLight,
onSecondary = Color.Black,
secondaryContainer = LightGray,
onSecondaryContainer = GoldLight,
tertiary = GoldLight,
onTertiary = Color.Black,
background = DarkGray,
onBackground = Color.White,
surface = MediumGray,
onSurface = Color.White,
surfaceVariant = LightGray,
onSurfaceVariant = TextGray,
outline = Color(0xFF5A5A5A),
outlineVariant = Color(0xFF404040),
error = Color(0xFFCF6679),
onError = Color.Black
)
private val LightColorScheme = lightColorScheme(
primary = GoldDark,
onPrimary = Color.White,
primaryContainer = GoldLight,
onPrimaryContainer = Color.Black,
secondary = Gold,
onSecondary = Color.Black,
secondaryContainer = Color(0xFFFFF3CD),
onSecondaryContainer = GoldDark,
tertiary = GoldDark,
onTertiary = Color.White,
background = Color(0xFFF5F5F5),
onBackground = Color(0xFF2D2D2D),
surface = Color.White,
onSurface = Color(0xFF2D2D2D),
surfaceVariant = Color(0xFFE8E8E8),
onSurfaceVariant = Color(0xFF5A5A5A),
outline = Color(0xFFB0B0B0),
outlineVariant = Color(0xFFD0D0D0),
error = Color(0xFFB00020),
onError = Color.White
)
@Composable
fun TssPartyTheme(
darkTheme: Boolean = true, // Default to dark theme for dark gray & gold look
dynamicColor: Boolean = false, // Disable dynamic colors to use our custom theme
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// Use dark background color for status bar to match the dark theme
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,31 @@
package com.durian.tssparty.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@ -0,0 +1,207 @@
package com.durian.tssparty.util
import org.bouncycastle.jcajce.provider.digest.Keccak
import org.bouncycastle.jcajce.provider.digest.RIPEMD160
import org.bouncycastle.jcajce.provider.digest.SHA256
import java.security.MessageDigest
/**
* Utility functions for address derivation
*/
object AddressUtils {
/**
* Derive Kava address from compressed public key
* Kava uses Bech32 with "kava" prefix
*/
fun deriveKavaAddress(compressedPubKey: ByteArray): String {
// 1. Decompress public key if compressed (33 bytes -> 65 bytes)
val uncompressedPubKey = if (compressedPubKey.size == 33) {
decompressPublicKey(compressedPubKey)
} else {
compressedPubKey
}
// 2. For Cosmos/Kava: SHA256 -> RIPEMD160
val sha256 = SHA256.Digest().digest(compressedPubKey)
val ripemd160 = RIPEMD160.Digest().digest(sha256)
// 3. Bech32 encode with "kava" prefix
return Bech32.encode("kava", convertBits(ripemd160, 8, 5, true))
}
/**
* Check if address is in EVM format (0x...)
*/
fun isEvmAddress(address: String): Boolean {
return address.startsWith("0x") && address.length == 42
}
/**
* Get EVM address - either returns the address if already EVM format,
* or derives it from the public key
*/
fun getEvmAddress(address: String, publicKeyBase64: String): String {
return if (isEvmAddress(address)) {
address
} else {
// Derive EVM address from public key
val publicKeyBytes = android.util.Base64.decode(publicKeyBase64, android.util.Base64.NO_WRAP)
deriveEvmAddress(publicKeyBytes)
}
}
/**
* Derive EVM address from public key (for Kava EVM compatibility)
*/
fun deriveEvmAddress(compressedPubKey: ByteArray): String {
// 1. Decompress if needed
val uncompressedPubKey = if (compressedPubKey.size == 33) {
decompressPublicKey(compressedPubKey)
} else {
compressedPubKey
}
// 2. Take last 64 bytes (remove 0x04 prefix)
val pubKeyNoPrefix = if (uncompressedPubKey.size == 65) {
uncompressedPubKey.sliceArray(1..64)
} else {
uncompressedPubKey
}
// 3. Keccak256 hash
val keccak = Keccak.Digest256().digest(pubKeyNoPrefix)
// 4. Take last 20 bytes
val addressBytes = keccak.sliceArray(12..31)
// 5. Hex encode with 0x prefix
return "0x" + addressBytes.toHexString()
}
/**
* Decompress a compressed secp256k1 public key
*/
private fun decompressPublicKey(compressed: ByteArray): ByteArray {
require(compressed.size == 33) { "Invalid compressed public key size" }
val prefix = compressed[0].toInt() and 0xFF
require(prefix == 0x02 || prefix == 0x03) { "Invalid compression prefix" }
val x = compressed.sliceArray(1..32)
// secp256k1 curve parameters
val p = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F".toBigInteger(16)
val xBigInt = x.toBigInteger()
// y² = x³ + 7 (mod p)
val ySquared = (xBigInt.pow(3) + 7.toBigInteger()).mod(p)
// Calculate y using modular square root
var y = ySquared.modPow((p + 1.toBigInteger()) / 4.toBigInteger(), p)
// Check parity
val isOdd = prefix == 0x03
if (y.testBit(0) != isOdd) {
y = p - y
}
// Build uncompressed key: 0x04 || x || y
val result = ByteArray(65)
result[0] = 0x04
val xBytes = x
val yBytes = y.toByteArray32()
System.arraycopy(xBytes, 0, result, 1, 32)
System.arraycopy(yBytes, 0, result, 33, 32)
return result
}
/**
* Convert between bit groups for Bech32
*/
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray {
var acc = 0
var bits = 0
val result = mutableListOf<Byte>()
val maxv = (1 shl toBits) - 1
for (value in data) {
val v = value.toInt() and 0xFF
acc = (acc shl fromBits) or v
bits += fromBits
while (bits >= toBits) {
bits -= toBits
result.add(((acc shr bits) and maxv).toByte())
}
}
if (pad && bits > 0) {
result.add(((acc shl (toBits - bits)) and maxv).toByte())
}
return result.toByteArray()
}
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
private fun ByteArray.toBigInteger(): java.math.BigInteger {
return java.math.BigInteger(1, this)
}
private fun java.math.BigInteger.toByteArray32(): ByteArray {
val bytes = this.toByteArray()
return when {
bytes.size == 32 -> bytes
bytes.size > 32 -> bytes.sliceArray((bytes.size - 32) until bytes.size)
else -> ByteArray(32 - bytes.size) + bytes
}
}
}
/**
* Bech32 encoding utilities
*/
object Bech32 {
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
fun encode(hrp: String, data: ByteArray): String {
val combined = data.map { it.toInt() and 0xFF }.toIntArray()
val checksum = createChecksum(hrp, combined)
val result = StringBuilder(hrp).append("1")
for (d in combined) result.append(CHARSET[d])
for (d in checksum) result.append(CHARSET[d])
return result.toString()
}
private fun polymod(values: IntArray): Int {
var chk = 1
for (v in values) {
val top = chk shr 25
chk = ((chk and 0x1ffffff) shl 5) xor v
for (i in 0..4) {
if ((top shr i) and 1 == 1) {
chk = chk xor GENERATOR[i]
}
}
}
return chk
}
private fun hrpExpand(hrp: String): IntArray {
val result = IntArray(hrp.length * 2 + 1)
for (i in hrp.indices) {
result[i] = hrp[i].code shr 5
result[i + hrp.length + 1] = hrp[i].code and 31
}
result[hrp.length] = 0
return result
}
private fun createChecksum(hrp: String, data: IntArray): IntArray {
val values = hrpExpand(hrp) + data + intArrayOf(0, 0, 0, 0, 0, 0)
val polymod = polymod(values) xor 1
return IntArray(6) { (polymod shr (5 * (5 - it))) and 31 }
}
}

View File

@ -0,0 +1,629 @@
package com.durian.tssparty.util
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.TokenType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.bouncycastle.jcajce.provider.digest.Keccak
import java.math.BigDecimal
import java.math.BigInteger
import java.util.concurrent.TimeUnit
/**
* Transaction utilities for Kava EVM
* Matches service-party-app/src/utils/transaction.ts
*/
object TransactionUtils {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Chain IDs
const val KAVA_TESTNET_CHAIN_ID = 2221
const val KAVA_MAINNET_CHAIN_ID = 2222
/**
* Prepared transaction ready for signing
*/
data class PreparedTransaction(
val nonce: BigInteger,
val gasPrice: BigInteger,
val gasLimit: BigInteger,
val to: String,
val from: String,
val value: BigInteger,
val data: ByteArray = ByteArray(0),
val chainId: Int,
val signHash: String, // Hash to be signed (hex with 0x prefix)
val rawTxForSigning: ByteArray // RLP encoded tx for signing
)
/**
* Transaction parameters for preparation
*/
data class TransactionParams(
val from: String,
val to: String,
val amount: String, // In KAVA or token units (not wei)
val rpcUrl: String,
val chainId: Int = KAVA_TESTNET_CHAIN_ID,
val tokenType: TokenType = TokenType.KAVA // Token type for transfer
)
/**
* Prepare a transaction for signing
* Gets nonce, gas price, estimates gas, and calculates sign hash
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分)
*/
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
try {
// 1. Get nonce
val nonce = getNonce(params.from, params.rpcUrl).getOrThrow()
// 2. Get gas price
val gasPrice = getGasPrice(params.rpcUrl).getOrThrow()
// 3. Prepare transaction based on token type
val (toAddress, valueWei, txData) = when (params.tokenType) {
TokenType.KAVA -> {
// Native KAVA transfer
Triple(params.to, kavaToWei(params.amount), ByteArray(0))
}
TokenType.GREEN_POINTS -> {
// ERC-20 token transfer (绿积分)
// To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded
val tokenAmount = greenPointsToRaw(params.amount)
val transferData = encodeErc20Transfer(params.to, tokenAmount)
Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData)
}
}
// 4. Estimate gas
val gasLimit = estimateGasWithData(
from = params.from,
to = toAddress,
value = valueWei,
data = txData,
rpcUrl = params.rpcUrl
).getOrElse {
// Default gas limits
when (params.tokenType) {
TokenType.KAVA -> BigInteger.valueOf(21000)
TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
}
}
// 5. RLP encode for signing (Legacy Type 0 format)
// Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
val rawTxForSigning = rlpEncodeForSigning(
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = toAddress,
value = valueWei,
data = txData,
chainId = params.chainId
)
// 6. Calculate Keccak-256 hash
val signHash = keccak256(rawTxForSigning)
Result.success(PreparedTransaction(
nonce = nonce,
gasPrice = gasPrice,
gasLimit = gasLimit,
to = toAddress,
from = params.from,
value = valueWei,
data = txData,
chainId = params.chainId,
signHash = "0x" + signHash.toHexString(),
rawTxForSigning = rawTxForSigning
))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Encode ERC-20 transfer(address,uint256) function call
*/
private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
// Function selector: transfer(address,uint256) = 0xa9059cbb
val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray()
// Encode recipient address (padded to 32 bytes)
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray()
// Encode amount (padded to 32 bytes)
val amountHex = amount.toString(16).padStart(64, '0')
val paddedAmount = amountHex.hexToByteArray()
return selector + paddedAddress + paddedAmount
}
/**
* Convert Green Points amount to raw units (6 decimals)
*/
fun greenPointsToRaw(amount: String): BigInteger {
val decimal = BigDecimal(amount)
val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6
return rawDecimal.toBigInteger()
}
/**
* Convert raw units to Green Points display amount
*/
fun rawToGreenPoints(raw: BigInteger): String {
val rawDecimal = BigDecimal(raw)
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
return displayDecimal.toPlainString()
}
/**
* Finalize transaction with signature
* Returns the signed raw transaction hex string ready for broadcast
*/
fun finalizeTransaction(
preparedTx: PreparedTransaction,
r: ByteArray,
s: ByteArray,
recoveryId: Int
): String {
// Calculate EIP-155 v value
// v = chainId * 2 + 35 + recovery_id
val v = preparedTx.chainId * 2 + 35 + recoveryId
// RLP encode signed transaction
// Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
val signedTx = rlpEncodeSigned(
nonce = preparedTx.nonce,
gasPrice = preparedTx.gasPrice,
gasLimit = preparedTx.gasLimit,
to = preparedTx.to,
value = preparedTx.value,
data = preparedTx.data,
v = BigInteger.valueOf(v.toLong()),
r = BigInteger(1, r),
s = BigInteger(1, s)
)
return "0x" + signedTx.toHexString()
}
/**
* Broadcast signed transaction to the network
*/
suspend fun broadcastTransaction(signedTx: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_sendRawTransaction",
"params": ["$signedTx"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
val errorMsg = json.get("error").asJsonObject.get("message").asString
return@withContext Result.failure(Exception(errorMsg))
}
val txHash = json.get("result").asString
Result.success(txHash)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Get transaction receipt (for confirmation)
*/
suspend fun getTransactionReceipt(txHash: String, rpcUrl: String): Result<TransactionReceipt?> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_getTransactionReceipt",
"params": ["$txHash"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
val errorMsg = json.get("error").asJsonObject.get("message").asString
return@withContext Result.failure(Exception(errorMsg))
}
val result = json.get("result")
if (result.isJsonNull) {
// Transaction not yet mined
return@withContext Result.success(null)
}
val receipt = result.asJsonObject
Result.success(TransactionReceipt(
transactionHash = receipt.get("transactionHash").asString,
blockNumber = receipt.get("blockNumber").asString,
status = receipt.get("status").asString == "0x1",
gasUsed = BigInteger(receipt.get("gasUsed").asString.removePrefix("0x"), 16)
))
} catch (e: Exception) {
Result.failure(e)
}
}
data class TransactionReceipt(
val transactionHash: String,
val blockNumber: String,
val status: Boolean,
val gasUsed: BigInteger
)
// ========== RPC Methods ==========
private suspend fun getNonce(address: String, rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_getTransactionCount",
"params": ["$address", "pending"],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexNonce = json.get("result").asString
Result.success(BigInteger(hexNonce.removePrefix("0x"), 16))
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun getGasPrice(rpcUrl: String): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_gasPrice",
"params": [],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexGasPrice = json.get("result").asString
Result.success(BigInteger(hexGasPrice.removePrefix("0x"), 16))
} catch (e: Exception) {
Result.failure(e)
}
}
private suspend fun estimateGas(
from: String,
to: String,
value: BigInteger,
rpcUrl: String
): Result<BigInteger> = withContext(Dispatchers.IO) {
estimateGasWithData(from, to, value, ByteArray(0), rpcUrl)
}
private suspend fun estimateGasWithData(
from: String,
to: String,
value: BigInteger,
data: ByteArray,
rpcUrl: String
): Result<BigInteger> = withContext(Dispatchers.IO) {
try {
val valueHex = "0x" + value.toString(16)
val dataHex = if (data.isEmpty()) "" else "\"data\": \"0x${data.toHexString()}\","
val requestBody = """
{
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [{
"from": "$from",
"to": "$to",
"value": "$valueHex"${if (dataHex.isNotEmpty()) ",\n ${dataHex.trimEnd(',')}" else ""}
}],
"id": 1
}
""".trimIndent()
val request = Request.Builder()
.url(rpcUrl)
.post(requestBody.toRequestBody(jsonMediaType))
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response"))
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
if (json.has("error")) {
return@withContext Result.failure(Exception(json.get("error").asJsonObject.get("message").asString))
}
val hexGas = json.get("result").asString
// Add 10% buffer
val gas = BigInteger(hexGas.removePrefix("0x"), 16)
val gasWithBuffer = gas.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
Result.success(gasWithBuffer)
} catch (e: Exception) {
Result.failure(e)
}
}
// ========== Utility Methods ==========
fun kavaToWei(kava: String): BigInteger {
val decimal = BigDecimal(kava)
val weiDecimal = decimal.multiply(BigDecimal("1000000000000000000"))
return weiDecimal.toBigInteger()
}
fun weiToKava(wei: BigInteger): String {
val weiDecimal = BigDecimal(wei)
val kavaDecimal = weiDecimal.divide(BigDecimal("1000000000000000000"), 6, java.math.RoundingMode.DOWN)
return kavaDecimal.toPlainString()
}
fun weiToGwei(wei: BigInteger): String {
val weiDecimal = BigDecimal(wei)
val gweiDecimal = weiDecimal.divide(BigDecimal("1000000000"), 2, java.math.RoundingMode.DOWN)
return gweiDecimal.toPlainString()
}
/**
* Calculate maximum transferable amount after deducting gas fee
* @param balance Current balance in KAVA
* @param rpcUrl RPC endpoint URL
* @return Maximum amount in KAVA string, or "0" if insufficient balance
*/
suspend fun calculateMaxTransferAmount(balance: String, rpcUrl: String): Result<String> = withContext(Dispatchers.IO) {
try {
val balanceKava = BigDecimal(balance)
if (balanceKava <= BigDecimal.ZERO) {
return@withContext Result.success("0")
}
// Get current gas price
val gasPriceResult = getGasPrice(rpcUrl)
val gasPrice = gasPriceResult.getOrElse {
// Default to 1 gwei if failed
BigInteger.valueOf(1000000000)
}
// Add 10% buffer to gas price
val gasPriceWithBuffer = gasPrice.multiply(BigInteger.valueOf(110)).divide(BigInteger.valueOf(100))
// Simple transfer gas limit is 21000
val gasLimit = BigInteger.valueOf(21000)
val gasFee = gasPriceWithBuffer.multiply(gasLimit)
// Convert gas fee to KAVA
val gasFeeKava = BigDecimal(gasFee).divide(BigDecimal("1000000000000000000"), 8, java.math.RoundingMode.UP)
// Calculate max amount = balance - gas fee
val maxAmount = balanceKava.subtract(gasFeeKava)
if (maxAmount <= BigDecimal.ZERO) {
return@withContext Result.success("0")
}
// Round down to 6 decimal places
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
Result.success(formattedMax)
} catch (e: Exception) {
// Fallback: use default gas estimate (21000 * 1 gwei = 0.000021 KAVA)
try {
val balanceKava = BigDecimal(balance)
val defaultGasFee = BigDecimal("0.000021")
val maxAmount = balanceKava.subtract(defaultGasFee)
if (maxAmount <= BigDecimal.ZERO) {
Result.success("0")
} else {
val formattedMax = maxAmount.setScale(6, java.math.RoundingMode.DOWN).stripTrailingZeros().toPlainString()
Result.success(formattedMax)
}
} catch (e2: Exception) {
Result.failure(e)
}
}
}
private fun keccak256(data: ByteArray): ByteArray {
val keccak = Keccak.Digest256()
return keccak.digest(data)
}
// ========== RLP Encoding ==========
/**
* RLP encode transaction for signing (EIP-155)
* Format: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
*/
private fun rlpEncodeForSigning(
nonce: BigInteger,
gasPrice: BigInteger,
gasLimit: BigInteger,
to: String,
value: BigInteger,
data: ByteArray,
chainId: Int
): ByteArray {
val items = listOf(
rlpEncodeInteger(nonce),
rlpEncodeInteger(gasPrice),
rlpEncodeInteger(gasLimit),
rlpEncodeAddress(to),
rlpEncodeInteger(value),
rlpEncodeBytes(data),
rlpEncodeInteger(BigInteger.valueOf(chainId.toLong())),
rlpEncodeInteger(BigInteger.ZERO),
rlpEncodeInteger(BigInteger.ZERO)
)
return rlpEncodeList(items)
}
/**
* RLP encode signed transaction
* Format: [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
*/
private fun rlpEncodeSigned(
nonce: BigInteger,
gasPrice: BigInteger,
gasLimit: BigInteger,
to: String,
value: BigInteger,
data: ByteArray,
v: BigInteger,
r: BigInteger,
s: BigInteger
): ByteArray {
val items = listOf(
rlpEncodeInteger(nonce),
rlpEncodeInteger(gasPrice),
rlpEncodeInteger(gasLimit),
rlpEncodeAddress(to),
rlpEncodeInteger(value),
rlpEncodeBytes(data),
rlpEncodeInteger(v),
rlpEncodeInteger(r),
rlpEncodeInteger(s)
)
return rlpEncodeList(items)
}
private fun rlpEncodeInteger(value: BigInteger): ByteArray {
if (value == BigInteger.ZERO) {
return byteArrayOf(0x80.toByte())
}
val bytes = value.toByteArray()
// Remove leading zero if present
val trimmed = if (bytes[0] == 0.toByte() && bytes.size > 1) {
bytes.copyOfRange(1, bytes.size)
} else {
bytes
}
return rlpEncodeBytes(trimmed)
}
private fun rlpEncodeAddress(address: String): ByteArray {
val cleanAddress = address.removePrefix("0x")
val bytes = cleanAddress.hexToByteArray()
return rlpEncodeBytes(bytes)
}
private fun rlpEncodeBytes(bytes: ByteArray): ByteArray {
return when {
bytes.size == 1 && bytes[0].toInt() and 0xFF < 0x80 -> bytes
bytes.size <= 55 -> {
val result = ByteArray(1 + bytes.size)
result[0] = (0x80 + bytes.size).toByte()
System.arraycopy(bytes, 0, result, 1, bytes.size)
result
}
else -> {
val lengthBytes = bytes.size.toBigInteger().toByteArray().let { arr ->
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
}
val result = ByteArray(1 + lengthBytes.size + bytes.size)
result[0] = (0xB7 + lengthBytes.size).toByte()
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
System.arraycopy(bytes, 0, result, 1 + lengthBytes.size, bytes.size)
result
}
}
}
private fun rlpEncodeList(items: List<ByteArray>): ByteArray {
val concatenated = items.fold(ByteArray(0)) { acc, item -> acc + item }
return when {
concatenated.size <= 55 -> {
val result = ByteArray(1 + concatenated.size)
result[0] = (0xC0 + concatenated.size).toByte()
System.arraycopy(concatenated, 0, result, 1, concatenated.size)
result
}
else -> {
val lengthBytes = concatenated.size.toBigInteger().toByteArray().let { arr ->
if (arr[0] == 0.toByte() && arr.size > 1) arr.copyOfRange(1, arr.size) else arr
}
val result = ByteArray(1 + lengthBytes.size + concatenated.size)
result[0] = (0xF7 + lengthBytes.size).toByte()
System.arraycopy(lengthBytes, 0, result, 1, lengthBytes.size)
System.arraycopy(concatenated, 0, result, 1 + lengthBytes.size, concatenated.size)
result
}
}
}
// ========== Extension Functions ==========
private fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
private fun String.hexToByteArray(): ByteArray {
val len = this.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(this[i], 16) shl 4) + Character.digit(this[i + 1], 16)).toByte()
i += 2
}
return data
}
}

View File

@ -0,0 +1,215 @@
syntax = "proto3";
package mpc.router.v1;
option java_package = "com.durian.tssparty.grpc";
option java_outer_classname = "MessageRouterProto";
option java_multiple_files = true;
// MessageRouter service handles MPC message routing
service MessageRouter {
// RouteMessage routes a message from one party to others
rpc RouteMessage(RouteMessageRequest) returns (RouteMessageResponse);
// SubscribeMessages subscribes to messages for a party (streaming)
rpc SubscribeMessages(SubscribeMessagesRequest) returns (stream MPCMessage);
// GetPendingMessages retrieves pending messages (polling alternative)
rpc GetPendingMessages(GetPendingMessagesRequest) returns (GetPendingMessagesResponse);
// AcknowledgeMessage acknowledges receipt of a message
rpc AcknowledgeMessage(AcknowledgeMessageRequest) returns (AcknowledgeMessageResponse);
// RegisterParty registers a party with the message router
rpc RegisterParty(RegisterPartyRequest) returns (RegisterPartyResponse);
// Heartbeat sends a heartbeat to keep the party alive
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
// SubscribeSessionEvents subscribes to session lifecycle events
rpc SubscribeSessionEvents(SubscribeSessionEventsRequest) returns (stream SessionEvent);
// JoinSession joins a session (proxied to Session Coordinator)
rpc JoinSession(JoinSessionRequest) returns (JoinSessionResponse);
// MarkPartyReady marks a party as ready
rpc MarkPartyReady(MarkPartyReadyRequest) returns (MarkPartyReadyResponse);
// ReportCompletion reports protocol completion
rpc ReportCompletion(ReportCompletionRequest) returns (ReportCompletionResponse);
// GetSessionStatus gets session status
rpc GetSessionStatus(GetSessionStatusRequest) returns (GetSessionStatusResponse);
}
message RouteMessageRequest {
string session_id = 1;
string from_party = 2;
repeated string to_parties = 3;
int32 round_number = 4;
string message_type = 5;
bytes payload = 6;
}
message RouteMessageResponse {
bool success = 1;
string message_id = 2;
}
message SubscribeMessagesRequest {
string session_id = 1;
string party_id = 2;
}
message MPCMessage {
string message_id = 1;
string session_id = 2;
string from_party = 3;
bool is_broadcast = 4;
int32 round_number = 5;
string message_type = 6;
bytes payload = 7;
int64 created_at = 8;
}
message GetPendingMessagesRequest {
string session_id = 1;
string party_id = 2;
int64 after_timestamp = 3;
}
message GetPendingMessagesResponse {
repeated MPCMessage messages = 1;
}
message NotificationChannel {
string email = 1;
string phone = 2;
string push_token = 3;
}
message RegisterPartyRequest {
string party_id = 1;
string party_role = 2;
string version = 3;
NotificationChannel notification = 4;
}
message RegisterPartyResponse {
bool success = 1;
string message = 2;
int64 registered_at = 3;
}
message SubscribeSessionEventsRequest {
string party_id = 1;
repeated string event_types = 2;
}
message SessionEvent {
string event_id = 1;
string event_type = 2;
string session_id = 3;
int32 threshold_n = 4;
int32 threshold_t = 5;
repeated string selected_parties = 6;
map<string, string> join_tokens = 7;
bytes message_hash = 8;
int64 created_at = 9;
int64 expires_at = 10;
}
message AcknowledgeMessageRequest {
string message_id = 1;
string party_id = 2;
string session_id = 3;
bool success = 4;
string error_message = 5;
}
message AcknowledgeMessageResponse {
bool success = 1;
string message = 2;
}
message HeartbeatRequest {
string party_id = 1;
int64 timestamp = 2;
}
message HeartbeatResponse {
bool success = 1;
int64 server_timestamp = 2;
int32 pending_messages = 3;
}
message DeviceInfo {
string device_type = 1;
string device_id = 2;
string platform = 3;
string app_version = 4;
}
message PartyInfo {
string party_id = 1;
int32 party_index = 2;
DeviceInfo device_info = 3;
}
message SessionInfo {
string session_id = 1;
string session_type = 2;
int32 threshold_n = 3;
int32 threshold_t = 4;
bytes message_hash = 5;
string status = 6;
string keygen_session_id = 7;
}
message JoinSessionRequest {
string session_id = 1;
string party_id = 2;
string join_token = 3;
DeviceInfo device_info = 4;
}
message JoinSessionResponse {
bool success = 1;
SessionInfo session_info = 2;
repeated PartyInfo other_parties = 3;
int32 party_index = 4;
}
message MarkPartyReadyRequest {
string session_id = 1;
string party_id = 2;
}
message MarkPartyReadyResponse {
bool success = 1;
bool all_ready = 2;
}
message ReportCompletionRequest {
string session_id = 1;
string party_id = 2;
bytes public_key = 3;
bytes signature = 4;
}
message ReportCompletionResponse {
bool success = 1;
bool all_completed = 2;
}
message GetSessionStatusRequest {
string session_id = 1;
}
message GetSessionStatusResponse {
string session_id = 1;
string status = 2;
int32 threshold_n = 3;
int32 threshold_t = 4;
repeated PartyInfo participants = 5;
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple wallet icon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 L78,30 C80.2,30 82,31.8 82,34 L82,74 C82,76.2 80.2,78 78,78 L30,78 C27.8,78 26,76.2 26,74 L26,34 C26,31.8 27.8,30 30,30 L54,30 Z M54,26 L30,26 C25.6,26 22,29.6 22,34 L22,74 C22,78.4 25.6,82 30,82 L78,82 C82.4,82 86,78.4 86,74 L86,34 C86,29.6 82.4,26 78,26 L54,26 Z"/>
<!-- Key symbol -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,44 C58.4,44 62,47.6 62,52 C62,54.8 60.6,57.2 58.4,58.6 L58.4,66 L50,66 L50,58.6 C47.4,57.2 46,54.8 46,52 C46,47.6 49.6,44 54,44 Z M54,48 C51.8,48 50,49.8 50,52 C50,53.4 50.8,54.6 52,55.2 L52,62 L56,62 L56,55.2 C57.2,54.6 58,53.4 58,52 C58,49.8 56.2,48 54,48 Z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/green_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/green_primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="green_primary">#4CAF50</color>
<color name="green_dark">#388E3C</color>
<color name="green_light">#81C784</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TSS Party</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TssParty" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/green_primary</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</full-backup-content>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
<exclude domain="database" path="tss_party.db"/>
</device-transfer>
</data-extraction-rules>

View File

@ -0,0 +1,290 @@
@echo off
setlocal enabledelayedexpansion
echo ========================================
echo TSS Party Android APK Builder
echo ========================================
echo.
:: Check if gradlew exists
if not exist "gradlew.bat" (
echo [ERROR] gradlew.bat not found!
echo Please run this script from the service-party-android directory.
pause
exit /b 1
)
:: Check and create local.properties if needed
if not exist "local.properties" (
echo [INFO] local.properties not found, attempting to detect Android SDK...
:: Try common SDK locations
set SDK_FOUND=0
:: Check ANDROID_HOME environment variable first
if defined ANDROID_HOME (
:: Remove any surrounding quotes from ANDROID_HOME
set "ANDROID_HOME_CLEAN=!ANDROID_HOME:"=!"
if exist "!ANDROID_HOME_CLEAN!\platform-tools" (
set "SDK_PATH_CLEAN=!ANDROID_HOME_CLEAN:\=/!"
echo sdk.dir=!SDK_PATH_CLEAN!> local.properties
echo [INFO] Created local.properties with ANDROID_HOME: !ANDROID_HOME_CLEAN!
set SDK_FOUND=1
)
)
:: Try common Windows locations
if !SDK_FOUND!==0 (
for %%P in (
"%LOCALAPPDATA%\Android\Sdk"
"%USERPROFILE%\AppData\Local\Android\Sdk"
"C:\Android\Sdk"
"C:\Android"
"C:\Users\%USERNAME%\Android\Sdk"
) do (
if exist "%%~P\platform-tools" (
set "SDK_PATH=%%~P"
set "SDK_PATH=!SDK_PATH:\=/!"
echo sdk.dir=!SDK_PATH!> local.properties
echo [INFO] Created local.properties with SDK path: %%~P
set SDK_FOUND=1
goto :sdk_found
)
)
)
:sdk_found
if !SDK_FOUND!==0 (
echo [ERROR] Android SDK not found!
echo.
echo Please do one of the following:
echo 1. Set ANDROID_HOME environment variable to your SDK path
echo 2. Create local.properties file with: sdk.dir=C:/path/to/android/sdk
echo 3. Install Android Studio which includes the SDK
echo.
echo Common SDK locations:
echo - %LOCALAPPDATA%\Android\Sdk
echo - C:\Android\Sdk
echo.
pause
exit /b 1
)
)
echo [INFO] Using SDK from local.properties
type local.properties
echo.
:: Check and build tsslib.aar if needed
if not exist "app\libs\tsslib.aar" (
echo [INFO] tsslib.aar not found, attempting to build TSS library...
echo.
:: Check if Go is installed
where go >nul 2>nul
if !errorlevel! neq 0 (
echo [ERROR] Go is not installed or not in PATH!
echo Please install Go from https://golang.org/dl/
pause
exit /b 1
)
:: Get GOPATH for bin directory
for /f "tokens=*" %%G in ('go env GOPATH') do set "GOPATH_DIR=%%G"
if not defined GOPATH_DIR set "GOPATH_DIR=%USERPROFILE%\go"
set "GOBIN_DIR=!GOPATH_DIR!\bin"
:: Add GOPATH/bin to PATH if not already there
echo !PATH! | findstr /i /c:"!GOBIN_DIR!" >nul 2>nul
if !errorlevel! neq 0 (
echo [INFO] Adding !GOBIN_DIR! to PATH...
set "PATH=!PATH!;!GOBIN_DIR!"
)
:: Show Go version
for /f "tokens=3" %%V in ('go version') do set "GO_VERSION=%%V"
echo [INFO] Go version: !GO_VERSION!
:: Get the tsslib directory path (inside service-party-android)
set "TSSLIB_DIR=tsslib"
if not exist "!TSSLIB_DIR!\go.mod" (
echo [ERROR] TSS library source not found at !TSSLIB_DIR!
echo Please ensure the tsslib source code exists.
pause
exit /b 1
)
:: IMPORTANT: Add gomobile dependency to go.mod (official recommended step)
echo [INFO] Adding gomobile dependency to go.mod...
pushd "!TSSLIB_DIR!"
go get -d golang.org/x/mobile/cmd/gomobile
if !errorlevel! neq 0 (
echo [WARNING] go get gomobile failed, continuing anyway...
)
popd
:: Install gomobile
echo [INFO] Installing gomobile...
go install golang.org/x/mobile/cmd/gomobile@latest
if !errorlevel! neq 0 (
echo [ERROR] Failed to install gomobile!
pause
exit /b 1
)
:: Verify gomobile exists
if not exist "!GOBIN_DIR!\gomobile.exe" (
echo [ERROR] gomobile was not installed correctly!
echo Please check your Go installation and GOPATH.
echo Expected location: !GOBIN_DIR!\gomobile.exe
pause
exit /b 1
)
echo [INFO] Initializing gomobile...
"!GOBIN_DIR!\gomobile.exe" init
if !errorlevel! neq 0 (
echo [WARNING] gomobile init failed, but continuing...
)
echo [INFO] Building tsslib.aar with gomobile...
pushd "!TSSLIB_DIR!"
:: Build the AAR
:: Use -androidapi 21 to ensure compatibility with modern NDK
"!GOBIN_DIR!\gomobile.exe" bind -target=android -androidapi 21 -o "..\app\libs\tsslib.aar" .
if !errorlevel! neq 0 (
echo [ERROR] gomobile bind failed!
popd
pause
exit /b 1
)
popd
if exist "app\libs\tsslib.aar" (
echo [SUCCESS] tsslib.aar built successfully!
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
) else (
echo [ERROR] tsslib.aar was not created!
pause
exit /b 1
)
echo.
) else (
echo [INFO] tsslib.aar found, skipping TSS library build
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
echo.
)
:: Parse command line arguments
set BUILD_TYPE=all
if "%1"=="debug" set BUILD_TYPE=debug
if "%1"=="release" set BUILD_TYPE=release
if "%1"=="clean" set BUILD_TYPE=clean
if "%1"=="help" goto :show_help
:: Show build type
echo Build type: %BUILD_TYPE%
echo.
:: Clean build
if "%BUILD_TYPE%"=="clean" (
echo [1/1] Cleaning build files...
call gradlew.bat clean --no-daemon
if !errorlevel! neq 0 (
echo [ERROR] Clean failed!
pause
exit /b 1
)
echo.
echo [SUCCESS] Clean completed!
goto :end
)
:: Build Debug APK
if "%BUILD_TYPE%"=="debug" goto :build_debug
if "%BUILD_TYPE%"=="all" goto :build_debug
goto :check_release
:build_debug
echo [1/2] Building Debug APK...
call gradlew.bat assembleDebug --no-daemon
if !errorlevel! neq 0 (
echo [ERROR] Debug build failed!
pause
exit /b 1
)
echo [SUCCESS] Debug APK built successfully!
echo Location: app\build\outputs\apk\debug\app-debug.apk
echo.
:check_release
if "%BUILD_TYPE%"=="debug" goto :show_results
:: Build Release APK
:build_release
echo [2/2] Building Release APK...
call gradlew.bat assembleRelease --no-daemon
if !errorlevel! neq 0 (
echo [ERROR] Release build failed!
pause
exit /b 1
)
echo [SUCCESS] Release APK built successfully!
echo Location: app\build\outputs\apk\release\app-release.apk
echo.
:show_results
echo ========================================
echo Build Results
echo ========================================
echo.
:: Check and show Debug APK
if exist "app\build\outputs\apk\debug\app-debug.apk" (
for %%F in ("app\build\outputs\apk\debug\app-debug.apk") do (
echo [DEBUG APK]
echo Path: %%~fF
echo Size: %%~zF bytes
)
echo.
)
:: Check and show Release APK
if exist "app\build\outputs\apk\release\app-release.apk" (
for %%F in ("app\build\outputs\apk\release\app-release.apk") do (
echo [RELEASE APK]
echo Path: %%~fF
echo Size: %%~zF bytes
)
echo.
)
echo ========================================
echo Build completed successfully!
echo ========================================
goto :end
:show_help
echo.
echo Usage: build-apk.bat [option]
echo.
echo Options:
echo debug - Build debug APK only
echo release - Build release APK only
echo all - Build both debug and release APKs (default)
echo clean - Clean build files
echo help - Show this help message
echo.
echo Examples:
echo build-apk.bat - Build both APKs
echo build-apk.bat debug - Build debug APK only
echo build-apk.bat release - Build release APK only
echo build-apk.bat clean - Clean project
echo.
:end
echo.
pause

View File

@ -0,0 +1,18 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
id("com.google.dagger.hilt.android") version "2.48.1" apply false
id("com.google.protobuf") version "0.9.4" apply false
}
buildscript {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean", Delete::class) {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,115 @@
# Durian USDT (dUSDT) 代币合约
## 合约概述
Durian USDT 是一个部署在 Kava EVM 主网上的固定供应量 ERC-20 代币。该合约**完全禁止增发**,所有代币在部署时一次性铸造给部署者地址。
## 合约详情
| 项目 | 值 |
|------|-----|
| 合约地址 | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` |
| 代币名称 | Durian USDT |
| 代币符号 | dUSDT |
| 精度 (Decimals) | 6 |
| 总供应量 | 1,000,000,000,000 dUSDT (1万亿) |
| 总供应量 (最小单位) | 1,000,000,000,000,000,000 (10^18) |
## 网络信息
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Mainnet |
| Chain ID | 2222 |
| RPC URL | https://evm.kava.io |
| 区块浏览器 | https://kavascan.com |
| 原生代币 | KAVA |
## 持有人/管理人信息
| 项目 | 值 |
|------|-----|
| 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
| 初始 dUSDT 余额 | 1,000,000,000,000 dUSDT (全部) |
> **安全警告**: 私钥必须妥善保管,切勿泄露给他人。
## 部署信息
| 项目 | 值 |
|------|-----|
| 部署时间 | 2026-01-02 |
| 部署交易哈希 | `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d` |
| Solidity 版本 | 0.8.19 |
| EVM 版本 | Paris (无 PUSH0 操作码) |
| 优化 | 启用 (runs: 200) |
## 合约特性
### 固定供应量 - 无增发机制
该合约的核心特性是**完全禁止增发**
1. **无 mint 函数**: 合约代码中不存在任何铸造新代币的函数
2. **无 owner/admin 权限**: 合约没有特权角色,无人能修改供应量
3. **供应量在构造函数中固定**: 所有代币在部署时一次性创建
4. **totalSupply 是 constant**: 总供应量声明为常量,无法修改
### 支持的 ERC-20 标准函数
| 函数 | 描述 |
|------|------|
| `name()` | 返回代币名称 "Durian USDT" |
| `symbol()` | 返回代币符号 "dUSDT" |
| `decimals()` | 返回精度 6 |
| `totalSupply()` | 返回总供应量 |
| `balanceOf(address)` | 查询地址余额 |
| `transfer(address, uint256)` | 转账 |
| `approve(address, uint256)` | 授权 |
| `allowance(address, address)` | 查询授权额度 |
| `transferFrom(address, address, uint256)` | 授权转账 |
## 查看链接
- 合约: https://kavascan.com/address/0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3
- 持有人: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
- 部署交易: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d
## 在钱包中添加代币
在 MetaMask 或其他钱包中添加自定义代币:
1. 网络: Kava EVM (Chain ID: 2222)
2. 合约地址: `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3`
3. 代币符号: `dUSDT`
4. 精度: `6`
## 文件说明
| 文件 | 描述 |
|------|------|
| `DurianUSDT.sol` | Solidity 源代码 |
| `DurianUSDT.abi` | 合约 ABI (Application Binary Interface) |
| `DurianUSDT.bin` | 编译后的字节码 |
| `CONTRACT_INFO.md` | 本文档 |
## 代码审计要点
该合约经过精简设计,关键安全特性:
1. **无 owner 模式**: 没有特权地址可以执行管理操作
2. **无升级机制**: 合约不可升级,代码永久固定
3. **无暂停功能**: 转账功能无法被暂停
4. **无黑名单功能**: 没有地址可以被限制转账
5. **使用 unchecked 块**: 在已验证的情况下使用,节省 gas
## 与标准 USDT 的对比
| 特性 | dUSDT | 标准 USDT |
|------|-------|----------|
| 精度 | 6 | 6 |
| 可增发 | 否 | 是 |
| 可暂停 | 否 | 是 |
| 黑名单功能 | 否 | 是 |
| 中心化管理 | 否 | 是 |

View File

@ -0,0 +1,229 @@
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "spender",
"type": "address"
}
],
"name": "allowance",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "approve",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "decimals",
"outputs": [
{
"internalType": "uint8",
"name": "",
"type": "uint8"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transfer",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "nonpayable",
"type": "function"
}
]

View File

@ -0,0 +1 @@
608060405234801561001057600080fd5b5033600081815260208181526040808320670de0b6b3a76400009081905590519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a36106fb8061006d6000396000f3fe608060405234801561001057600080fd5b50600436106100935760003560e01c8063313ce56711610066578063313ce5671461012b57806370a082311461014557806395d89b411461016e578063a9059cbb14610192578063dd62ed3e146101a557600080fd5b806306fdde0314610098578063095ea7b3146100d857806318160ddd146100fb57806323b872dd14610118575b600080fd5b6100c26040518060400160405280600b81526020016a111d5c9a585b881554d11560aa1b81525081565b6040516100cf91906105a0565b60405180910390f35b6100eb6100e636600461060a565b6101de565b60405190151581526020016100cf565b61010a670de0b6b3a764000081565b6040519081526020016100cf565b6100eb610126366004610634565b6102a0565b610133600681565b60405160ff90911681526020016100cf565b61010a610153366004610670565b6001600160a01b031660009081526020819052604090205490565b6100c260405180604001604052806005815260200164191554d11560da1b81525081565b6100eb6101a036600461060a565b61049a565b61010a6101b3366004610692565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60006001600160a01b03831661023b5760405162461bcd60e51b815260206004820152601760248201527f417070726f766520746f207a65726f206164647265737300000000000000000060448201526064015b60405180910390fd5b3360008181526001602090815260408083206001600160a01b03881680855290835292819020869055518581529192917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a350600192915050565b60006001600160a01b0384166102f85760405162461bcd60e51b815260206004820152601a60248201527f5472616e736665722066726f6d207a65726f20616464726573730000000000006044820152606401610232565b6001600160a01b0383166103495760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b6001600160a01b0384166000908152602081905260409020548211156103a85760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b6001600160a01b03841660009081526001602090815260408083203384529091529020548211156104145760405162461bcd60e51b8152602060048201526016602482015275496e73756666696369656e7420616c6c6f77616e636560501b6044820152606401610232565b6001600160a01b03848116600081815260208181526040808320805488900390559387168083528483208054880190558383526001825284832033845282529184902080548790039055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b60006001600160a01b0383166104ed5760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b336000908152602081905260409020548211156105435760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b33600081815260208181526040808320805487900390556001600160a01b03871680845292819020805487019055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910161028f565b600060208083528351808285015260005b818110156105cd578581018301518582016040015282016105b1565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461060557600080fd5b919050565b6000806040838503121561061d57600080fd5b610626836105ee565b946020939093013593505050565b60008060006060848603121561064957600080fd5b610652846105ee565b9250610660602085016105ee565b9150604084013590509250925092565b60006020828403121561068257600080fd5b61068b826105ee565b9392505050565b600080604083850312156106a557600080fd5b6106ae836105ee565b91506106bc602084016105ee565b9050925092905056fea264697066735822122028c97073f6e7db0ad943d101cb6873b31c3eb19bcea3eda83148447ab676a5ee64736f6c63430008130033

View File

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
/**
* @title DurianUSDT
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
*
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
* All tokens are minted to the deployer at construction time.
*/
contract DurianUSDT {
string public constant name = "Durian USDT";
string public constant symbol = "dUSDT";
uint8 public constant decimals = 6;
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev Constructor - mints entire fixed supply to deployer
* No mint function exists - supply is permanently fixed
*/
constructor() {
_balances[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply);
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
unchecked {
_balances[msg.sender] -= amount;
_balances[to] += amount;
}
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
unchecked {
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
}
emit Transfer(from, to, amount);
return true;
}
}

View File

@ -0,0 +1,120 @@
# Kava EVM 网络配置
## 主网配置
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Mainnet |
| Chain ID | 2222 |
| Currency Symbol | KAVA |
| RPC URL | https://evm.kava.io |
| WebSocket URL | wss://wevm.kava.io |
| 区块浏览器 | https://kavascan.com |
## 测试网配置
| 项目 | 值 |
|------|-----|
| 网络名称 | Kava EVM Testnet |
| Chain ID | 2221 |
| Currency Symbol | KAVA |
| RPC URL | https://evm.testnet.kava.io |
| 区块浏览器 | https://testnet.kavascan.com |
| 水龙头 | https://faucet.kava.io |
## RPC 端点列表
### 主网 RPC
```
https://evm.kava.io
https://kava-evm.publicnode.com
https://kava.api.onfinality.io/public
https://evm.kava.chainstacklabs.com
```
### WebSocket (主网)
```
wss://wevm.kava.io
wss://kava-evm.publicnode.com
```
## Gas 配置
| 项目 | 值 |
|------|-----|
| Gas Price | ~1 Gwei (动态) |
| 合约部署 Gas Limit | ~500,000 - 1,000,000 |
| 代币转账 Gas Limit | ~65,000 |
| 原生转账 Gas Limit | ~21,000 |
## 在 MetaMask 中添加网络
### 主网
1. 打开 MetaMask
2. 点击网络选择器 > 添加网络
3. 填写以下信息:
- 网络名称: `Kava EVM`
- RPC URL: `https://evm.kava.io`
- Chain ID: `2222`
- 货币符号: `KAVA`
- 区块浏览器: `https://kavascan.com`
### 测试网
1. 打开 MetaMask
2. 点击网络选择器 > 添加网络
3. 填写以下信息:
- 网络名称: `Kava EVM Testnet`
- RPC URL: `https://evm.testnet.kava.io`
- Chain ID: `2221`
- 货币符号: `KAVA`
- 区块浏览器: `https://testnet.kavascan.com`
## Kava 双地址系统
Kava 网络支持两种地址格式:
| 类型 | 格式 | 示例 |
|------|------|------|
| Cosmos 地址 | kava1... | `kava1...abc` |
| EVM 地址 | 0x... | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
同一个私钥可以派生出两种地址,它们共享相同的余额。
## EVM 兼容性
Kava EVM 兼容以太坊 EVM支持:
- Solidity 智能合约
- ERC-20/ERC-721/ERC-1155 代币标准
- Web3.js / ethers.js
- MetaMask 等以太坊钱包
### 注意事项
- Kava EVM **不支持 PUSH0 操作码** (Shanghai 升级的特性)
- 编译合约时需要使用 `evmVersion: "paris"` 或更早版本
- 推荐使用 Solidity 0.8.19 或更早版本
## 常用合约地址
### 主网
| 代币 | 地址 |
|------|------|
| WKAVA (Wrapped KAVA) | `0xc86c7C0eFbd6A49B35E8714C5f59D99De09A225b` |
| USDT (官方) | `0x919C1c267BC06a7039e03fcc2eF738525769109c` |
| USDC | `0xfA9343C3897324496A05fC75abeD6bAC29f8A40f` |
| DAI | `0x765277EebeCA2e31912C9946eAe1021199B39C61` |
| **dUSDT (绿积分)** | `0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3` ⭐ 当前使用 |
## 资源链接
- 官网: https://www.kava.io/
- 文档: https://docs.kava.io/
- GitHub: https://github.com/Kava-Labs
- 区块浏览器: https://kavascan.com/
- Discord: https://discord.com/invite/kQzh3Uv

View File

@ -0,0 +1,83 @@
# 钱包密钥信息
> **重要安全警告**: 本文件包含私钥,仅供内部使用。切勿将此文件提交到公开仓库或分享给他人。
## 管理员钱包
该钱包用于部署和管理 dUSDT 代币合约。
### 地址信息
| 项目 | 值 |
|------|-----|
| EVM 地址 | `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` |
| 私钥 | `0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a` |
### 余额信息
| 代币 | 余额 | 备注 |
|------|------|------|
| KAVA | ~0.45 KAVA | 用于支付 Gas 费用 |
| dUSDT | 1,000,000,000,000 | 1万亿全部供应量 |
### 查看链接
- 地址: https://kavascan.com/address/0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
## 导入钱包
### MetaMask
1. 打开 MetaMask
2. 点击账户图标 > 导入账户
3. 选择类型: 私钥
4. 粘贴私钥: `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
5. 点击导入
### ethers.js
```javascript
import { ethers } from 'ethers';
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
const provider = new ethers.JsonRpcProvider('https://evm.kava.io');
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Address:', wallet.address);
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
```
### Android/Kotlin
```kotlin
// 使用 Web3j 或其他库
val privateKey = "886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a"
val credentials = Credentials.create(privateKey)
println("Address: ${credentials.address}")
// Output: 0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E
```
## 地址派生
该地址是通过以下步骤从私钥派生的:
1. 私钥 (32 bytes): `886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
2. 公钥 (65 bytes, uncompressed): `047e0b2f84204a2f859f51be78e09af3c504e9525f49d8ab1c537ab9c2a4deb28c3b16870449f50b9b79e959649a78144a5329958a95f6697534be0156b421588b`
3. Keccak-256(公钥[1:65])
4. 取后 20 bytes: `4f7e78d6b7c5fc502ec7039848690f08c8970f1e`
5. 添加 0x 前缀: `0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E` (含校验和)
## 安全建议
1. **备份私钥**: 将私钥安全存储在离线环境中
2. **不要分享**: 永远不要将私钥分享给任何人
3. **不要提交**: 确保 .gitignore 包含此文件
4. **硬件钱包**: 考虑将大额资产转移到硬件钱包
5. **多签**: 对于生产环境,考虑使用多签钱包
## 相关交易
### 合约部署交易
- 交易哈希: `0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d`
- 查看: https://kavascan.com/tx/0xa73d7dce17723bc5a9d62767c515dadf4ccccc26327ed5637958ce817edd671d

View File

@ -0,0 +1,11 @@
# Project-wide Gradle settings
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# Android settings
android.useAndroidX=true
android.nonTransitiveRClass=true
# Kotlin settings
kotlin.code.style=official

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -0,0 +1,184 @@
#!/bin/sh
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX://www.opengroup.org/onlinepubs/009695399/utilities/xcu_chap02.html
#
# (2) You need a Java Runtime Environment (JRE) to run Gradle.
#
##############################################################################
#
# Gradle start up script for POSIX
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MSYS* | MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kass://www.gradle.org/
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Annoying
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Annoying
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell://www.gnu.org/software/bash/manual/html_node/Quoting.html
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

View File

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TSSPartyAndroid"
include(":app")

View File

@ -0,0 +1,23 @@
@echo off
REM Build TSS library for Android using gomobile
echo === Building TSS Library for Android ===
REM Check if gomobile is available
where gomobile >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Installing gomobile...
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
)
REM Download dependencies
echo Downloading Go dependencies...
go mod tidy
REM Build for Android
echo Building Android AAR...
gomobile bind -target=android -androidapi=26 -o ..\app\libs\tsslib.aar .
echo === Build complete! ===
echo Output: ..\app\libs\tsslib.aar

View File

@ -0,0 +1,24 @@
#!/bin/bash
# Build TSS library for Android using gomobile
set -e
echo "=== Building TSS Library for Android ==="
# Check if gomobile is installed
if ! command -v gomobile &> /dev/null; then
echo "Installing gomobile..."
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
fi
# Download dependencies
echo "Downloading Go dependencies..."
go mod tidy
# Build for Android
echo "Building Android AAR..."
gomobile bind -target=android -androidapi=26 -o ../app/libs/tsslib.aar .
echo "=== Build complete! ==="
echo "Output: ../app/libs/tsslib.aar"

View File

@ -0,0 +1,37 @@
module github.com/rwadurian/tsslib
go 1.24.0
require github.com/bnb-chain/tss-lib/v2 v2.0.2
require (
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/btcsuite/btcd v0.23.4 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/ipfs/go-log v1.0.5 // indirect
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
// Replace to fix tss-lib dependency issue with ed25519
replace github.com/agl/ed25519 => github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412

View File

@ -0,0 +1,272 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI=
github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQEmUy9g=
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik=
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/jsonindent v0.0.0-20171116142732-447bf004320b/go.mod h1:SXIpH2WO0dyF5YBc6Iq8jc8TEJYe1Fk2Rc1EVYUdIgY=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.2 h1:VYWnrP5fXmz1MXvjuUvcBrXSjGE6xjON+axB/UrpO3E=
github.com/otiai10/mint v1.3.2/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11 h1:7x5D/2dkkr27Tgh4WFuX+iCS6OzuE5YJoqJzeqM+5mc=
github.com/otiai10/primes v0.0.0-20210501021515-f1b2be525a11/go.mod h1:1DmRMnU78i/OVkMnHzvhXSi4p8IhYUmtLJWhyOavJc0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20250106192035-c31d5b91ecc3 h1:8LrYkH99trX3onYF3dT9frUSRDXokkceG+9tHBaDAFQ=
golang.org/x/mobile v0.0.0-20250106192035-c31d5b91ecc3/go.mod h1:sY92m3V/rTEa4JCJ1FkKHK978K6wxOSX1PStMYo+6wI=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

View File

@ -0,0 +1,657 @@
// Package tsslib provides TSS (Threshold Signature Scheme) functionality for Android
// This package is designed to be compiled with gomobile for Android integration via JNI
//
// Based on the verified tss-party implementation from service-party-app (Electron version)
package tsslib
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"regexp"
"strconv"
"sync"
"time"
"github.com/bnb-chain/tss-lib/v2/common"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
"github.com/bnb-chain/tss-lib/v2/tss"
)
// Regex to extract round number from tss-lib message type
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
var roundRegex = regexp.MustCompile(`Round(\d+)`)
// MessageCallback is the interface for receiving TSS protocol messages
// Android side implements this interface to handle message routing
type MessageCallback interface {
// OnOutgoingMessage is called when TSS needs to send a message to other parties
// messageJSON contains: type, isBroadcast, toParties, payload (base64)
OnOutgoingMessage(messageJSON string)
// OnProgress is called to report protocol progress
OnProgress(round, totalRounds int)
// OnError is called when an error occurs
OnError(errorMessage string)
// OnLog is called for debug logging
OnLog(message string)
}
// Participant represents a party in the TSS protocol
type Participant struct {
PartyID string `json:"partyId"`
PartyIndex int `json:"partyIndex"`
}
// tssSession manages a TSS keygen or signing session
type tssSession struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
callback MessageCallback
localParty tss.Party
partyIndexMap map[int]*tss.PartyID
errCh chan error
keygenResultCh chan *keygen.LocalPartySaveData
signResultCh chan *common.SignatureData
isKeygen bool
}
var (
currentSession *tssSession
sessionMu sync.Mutex
)
// StartKeygen initiates a new key generation session
// This is the entry point called from Android via JNI
func StartKeygen(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participantsJSON, password string,
callback MessageCallback,
) error {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
return fmt.Errorf("a session is already in progress")
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
return fmt.Errorf("failed to parse participants: %w", err)
}
if len(participants) != thresholdN {
return fmt.Errorf("participant count mismatch: got %d, expected %d", len(participants), thresholdN)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
session := &tssSession{
ctx: ctx,
cancel: cancel,
callback: callback,
partyIndexMap: make(map[int]*tss.PartyID),
errCh: make(chan error, 1),
keygenResultCh: make(chan *keygen.LocalPartySaveData, 1),
isKeygen: true,
}
// Create TSS party IDs - same as verified Electron version
tssPartyIDs := make([]*tss.PartyID, len(participants))
var selfTSSID *tss.PartyID
for i, p := range participants {
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs[i] = partyKey
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
cancel()
return fmt.Errorf("self party not found in participants")
}
// Sort party IDs
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Build party index map for incoming messages
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
session.partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
peerCtx := tss.NewPeerContext(sortedPartyIDs)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
callback.OnLog(fmt.Sprintf("[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
// Create channels
outCh := make(chan tss.Message, thresholdN*10)
endCh := make(chan *keygen.LocalPartySaveData, 1)
// Create local party
localParty := keygen.NewLocalParty(params, outCh, endCh)
session.localParty = localParty
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
session.errCh <- err
}
}()
// Handle outgoing messages
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
session.handleOutgoingMessage(msg)
}
}
}()
// Handle completion
go func() {
select {
case <-ctx.Done():
callback.OnError("session timeout or cancelled")
case err := <-session.errCh:
callback.OnError(fmt.Sprintf("keygen error: %v", err))
case saveData := <-endCh:
session.keygenResultCh <- saveData
}
}()
currentSession = session
return nil
}
// StartSign initiates a new signing session
// Based on verified executeSign from Electron version
func StartSign(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participantsJSON, messageHashHex, shareDataBase64, password string,
callback MessageCallback,
) error {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
return fmt.Errorf("a session is already in progress")
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(participantsJSON), &participants); err != nil {
return fmt.Errorf("failed to parse participants: %w", err)
}
// Note: For signing, participant count equals threshold T (not N)
// because only T parties participate in signing
if len(participants) != thresholdT {
return fmt.Errorf("participant count mismatch: got %d, expected %d (threshold T)", len(participants), thresholdT)
}
// Decode and decrypt share data
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
if err != nil {
return fmt.Errorf("failed to decode share data: %w", err)
}
shareBytes, err := decryptShare(encryptedShare, password)
if err != nil {
return fmt.Errorf("failed to decrypt share: %w", err)
}
// Parse keygen save data
var keygenData keygen.LocalPartySaveData
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
return fmt.Errorf("failed to parse keygen data: %w", err)
}
// Decode message hash
messageHash, err := hex.DecodeString(messageHashHex)
if err != nil {
return fmt.Errorf("failed to decode message hash: %w", err)
}
if len(messageHash) != 32 {
return fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
}
msgBigInt := new(big.Int).SetBytes(messageHash)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
session := &tssSession{
ctx: ctx,
cancel: cancel,
callback: callback,
partyIndexMap: make(map[int]*tss.PartyID),
errCh: make(chan error, 1),
signResultCh: make(chan *common.SignatureData, 1),
isKeygen: false,
}
// Create TSS party IDs for signing participants
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
var selfTSSID *tss.PartyID
for _, p := range participants {
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs = append(tssPartyIDs, partyKey)
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
cancel()
return fmt.Errorf("self party not found in participants")
}
// Sort party IDs (important for tss-lib)
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Build party index map for incoming messages
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
session.partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// This MUST match keygen exactly!
peerCtx := tss.NewPeerContext(sortedPartyIDs)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
callback.OnLog(fmt.Sprintf("[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT))
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Original keygenData has %d parties (Ks length)", len(keygenData.Ks)))
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Building subset for %d signing parties", len(sortedPartyIDs)))
// CRITICAL: Build a subset of the keygen save data for the current signing parties
// This is required when signing with a subset of the original keygen participants.
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
callback.OnLog(fmt.Sprintf("[TSS-SIGN] Subset keygenData has %d parties (Ks length)", len(subsetKeygenData.Ks)))
// Create channels
outCh := make(chan tss.Message, thresholdT*10)
endCh := make(chan *common.SignatureData, 1)
// Create local party for signing with the SUBSET keygen data
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
session.localParty = localParty
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
session.errCh <- err
}
}()
// Handle outgoing messages
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
session.handleOutgoingMessage(msg)
}
}
}()
// Handle completion
go func() {
select {
case <-ctx.Done():
callback.OnError("session timeout or cancelled")
case err := <-session.errCh:
callback.OnError(fmt.Sprintf("sign error: %v", err))
case sigData := <-endCh:
session.signResultCh <- sigData
}
}()
currentSession = session
return nil
}
// SendIncomingMessage delivers a message from another party to the current session
func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 string) error {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return fmt.Errorf("no active session")
}
session.mu.Lock()
defer session.mu.Unlock()
fromParty, ok := session.partyIndexMap[fromPartyIndex]
if !ok {
return fmt.Errorf("unknown party index: %d", fromPartyIndex)
}
payload, err := base64.StdEncoding.DecodeString(payloadBase64)
if err != nil {
return fmt.Errorf("failed to decode payload: %w", err)
}
parsedMsg, err := tss.ParseWireMessage(payload, fromParty, isBroadcast)
if err != nil {
return fmt.Errorf("failed to parse message: %w", err)
}
go func() {
_, err := session.localParty.Update(parsedMsg)
if err != nil {
// Only send fatal errors
if !isDuplicateError(err) {
session.errCh <- err
}
}
}()
return nil
}
// WaitForKeygenResult blocks until keygen completes and returns the result as JSON
func WaitForKeygenResult(password string) (string, error) {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return "", fmt.Errorf("no active session")
}
if !session.isKeygen {
return "", fmt.Errorf("current session is not a keygen session")
}
// Track progress - GG20 keygen has 4 rounds
totalRounds := 4
select {
case <-session.ctx.Done():
return "", session.ctx.Err()
case saveData := <-session.keygenResultCh:
// Keygen completed successfully
session.callback.OnProgress(totalRounds, totalRounds)
// Get public key - same as Electron version
pubKey := saveData.ECDSAPub.ToECDSAPubKey()
pubKeyBytes := make([]byte, 33)
pubKeyBytes[0] = 0x02 + byte(pubKey.Y.Bit(0))
xBytes := pubKey.X.Bytes()
copy(pubKeyBytes[33-len(xBytes):], xBytes)
// Serialize and encrypt save data
saveDataBytes, err := json.Marshal(saveData)
if err != nil {
return "", fmt.Errorf("failed to serialize save data: %w", err)
}
// Encrypt with password (same as Electron version)
encryptedShare := encryptShare(saveDataBytes, password)
result := struct {
PublicKey string `json:"publicKey"`
EncryptedShare string `json:"encryptedShare"`
}{
PublicKey: base64.StdEncoding.EncodeToString(pubKeyBytes),
EncryptedShare: base64.StdEncoding.EncodeToString(encryptedShare),
}
resultJSON, _ := json.Marshal(result)
// Clean up session
session.cancel()
sessionMu.Lock()
currentSession = nil
sessionMu.Unlock()
return string(resultJSON), nil
}
}
// WaitForSignResult blocks until signing completes and returns the result as JSON
func WaitForSignResult() (string, error) {
sessionMu.Lock()
session := currentSession
sessionMu.Unlock()
if session == nil {
return "", fmt.Errorf("no active session")
}
if session.isKeygen {
return "", fmt.Errorf("current session is not a sign session")
}
// Track progress - GG20 signing has 9 rounds
totalRounds := 9
select {
case <-session.ctx.Done():
return "", session.ctx.Err()
case sigData := <-session.signResultCh:
// Signing completed successfully
session.callback.OnProgress(totalRounds, totalRounds)
// Construct signature: R (32 bytes) || S (32 bytes)
rBytes := sigData.R
sBytes := sigData.S
signature := make([]byte, 64)
copy(signature[32-len(rBytes):32], rBytes)
copy(signature[64-len(sBytes):64], sBytes)
// Recovery ID for Ethereum-style signatures
recoveryID := int(sigData.SignatureRecovery[0])
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
// This is needed for EVM transaction signing
signatureWithV := make([]byte, 65)
copy(signatureWithV, signature)
signatureWithV[64] = byte(recoveryID)
result := struct {
Signature string `json:"signature"`
RecoveryID int `json:"recoveryId"`
}{
Signature: base64.StdEncoding.EncodeToString(signatureWithV),
RecoveryID: recoveryID,
}
resultJSON, _ := json.Marshal(result)
// Clean up session
session.cancel()
sessionMu.Lock()
currentSession = nil
sessionMu.Unlock()
return string(resultJSON), nil
}
}
// CancelSession cancels the current session
func CancelSession() {
sessionMu.Lock()
defer sessionMu.Unlock()
if currentSession != nil {
currentSession.cancel()
currentSession = nil
}
}
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
// Returns 0 if parsing fails (safe fallback).
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
func extractRoundFromMessageType(msgType string) int {
matches := roundRegex.FindStringSubmatch(msgType)
if len(matches) >= 2 {
if round, err := strconv.Atoi(matches[1]); err == nil {
return round
}
}
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
}
func (s *tssSession) handleOutgoingMessage(msg tss.Message) {
msgBytes, _, err := msg.WireBytes()
if err != nil {
return
}
var toParties []string
if !msg.IsBroadcast() {
for _, to := range msg.GetTo() {
toParties = append(toParties, to.Id)
}
}
outMsg := struct {
Type string `json:"type"`
IsBroadcast bool `json:"isBroadcast"`
ToParties []string `json:"toParties,omitempty"`
Payload string `json:"payload"`
}{
Type: "outgoing",
IsBroadcast: msg.IsBroadcast(),
ToParties: toParties,
Payload: base64.StdEncoding.EncodeToString(msgBytes),
}
data, _ := json.Marshal(outMsg)
s.callback.OnOutgoingMessage(string(data))
// Extract current round from message type and send progress update
totalRounds := 4 // GG20 keygen has 4 rounds
if !s.isKeygen {
totalRounds = 9 // GG20 signing has 9 rounds
}
currentRound := extractRoundFromMessageType(msg.Type())
s.callback.OnProgress(currentRound, totalRounds)
}
func isDuplicateError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "duplicate") || contains(errStr, "already received")
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// encryptShare encrypts the share data with password
// Same implementation as Electron version for compatibility
func encryptShare(data []byte, password string) []byte {
// TODO: Use proper AES-256-GCM encryption
// For now, just prepend a marker and the password hash
// This is NOT secure - just a placeholder (same as Electron version)
result := make([]byte, len(data)+32)
copy(result[:32], hashPassword(password))
copy(result[32:], data)
return result
}
// decryptShare decrypts the share data with password
// Same implementation as Electron version for compatibility
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
// Match the encryption format: first 32 bytes are password hash, rest is data
if len(encryptedData) < 32 {
return nil, fmt.Errorf("encrypted data too short")
}
// Verify password (simple check - matches encryptShare)
expectedHash := hashPassword(password)
actualHash := encryptedData[:32]
// Simple comparison
match := true
for i := 0; i < 32; i++ {
if expectedHash[i] != actualHash[i] {
match = false
break
}
}
if !match {
return nil, fmt.Errorf("incorrect password")
}
return encryptedData[32:], nil
}
// hashPassword creates a simple hash of the password
// Same implementation as Electron version for compatibility
func hashPassword(password string) []byte {
// Simple hash - should use PBKDF2 or Argon2 in production
hash := make([]byte, 32)
for i := 0; i < len(password) && i < 32; i++ {
hash[i] = password[i]
}
return hash
}

View File

@ -77,6 +77,26 @@ let activeKeygenSession: ActiveKeygenSession | null = null;
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
let keygenInProgressSessionId: string | null = null;
// ===========================================================================
// Co-Sign 相关状态
// ===========================================================================
// 当前正在进行的 Co-Sign 会话信息
interface ActiveCoSignSession {
sessionId: string;
partyIndex: number;
participants: Array<{ partyId: string; partyIndex: number; name: string }>;
threshold: { t: number; n: number };
walletName: string;
messageHash: string;
shareId: string;
sharePassword: string;
}
let activeCoSignSession: ActiveCoSignSession | null = null;
// Co-Sign 幂等性保护:追踪正在进行的签名会话 ID
let signInProgressSessionId: string | null = null;
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
// 当事件到达时,前端可能还在页面导航中,尚未订阅
interface SessionEventData {
@ -394,8 +414,8 @@ async function initServices() {
}
});
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认测试网)
const kavaNetwork = database.getSetting('kava_network') || 'testnet';
// 初始化 Kava 交易服务 (从数据库读取网络设置,默认网)
const kavaNetwork = database.getSetting('kava_network') || 'mainnet';
const kavaConfig = kavaNetwork === 'mainnet' ? KAVA_MAINNET_TX_CONFIG : KAVA_TESTNET_TX_CONFIG;
kavaTxService = new KavaTxService(kavaConfig);
debugLog.info('kava', `Kava network: ${kavaNetwork}`);
@ -619,6 +639,343 @@ async function handleKeygenComplete(result: KeygenResult) {
}
}
// ===========================================================================
// Co-Sign 相关处理函数
// ===========================================================================
// 检查并触发 Co-Sign在收到 all_joined 事件后调用)
async function checkAndTriggerCoSign(sessionId: string) {
console.log('[CO-SIGN] checkAndTriggerCoSign called with sessionId:', sessionId);
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
console.log('[CO-SIGN] No matching active co-sign session for', sessionId);
return;
}
console.log('[CO-SIGN] Active session found:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
threshold: activeCoSignSession.threshold,
participantCount: activeCoSignSession.participants.length,
});
// 如果 TSS 已经在运行,不重复触发
if (tssHandler?.getIsRunning()) {
console.log('[CO-SIGN] TSS already running, skip check');
return;
}
const pollIntervalMs = 2000; // 2秒轮询间隔
const maxWaitMs = 5 * 60 * 1000; // 5分钟超时
const startTime = Date.now();
console.log('[CO-SIGN] Starting to poll session status...');
debugLog.info('main', `Starting to poll co-sign session status for ${sessionId}`);
while (Date.now() - startTime < maxWaitMs) {
// 检查会话是否仍然有效
if (!activeCoSignSession || activeCoSignSession.sessionId !== sessionId) {
debugLog.warn('main', 'Active co-sign session changed, stopping poll');
return;
}
// 如果 TSS 已经在运行,停止轮询
if (tssHandler?.getIsRunning()) {
debugLog.info('main', 'TSS started running, stopping poll');
return;
}
try {
// 获取签名会话状态
const status = await accountClient?.getSignSessionStatus(sessionId);
if (!status) {
debugLog.warn('main', 'Failed to get sign session status, will retry');
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
continue;
}
const expectedT = activeCoSignSession.threshold.t;
const currentParticipants = status.joined_count || 0;
debugLog.debug('main', `Sign session ${sessionId} status: ${status.status}, participants: ${currentParticipants}/${expectedT}`);
// 检查是否满足启动条件
const hasAllParticipants = currentParticipants >= expectedT;
const statusReady = status.status === 'in_progress' ||
status.status === 'all_joined' ||
status.status === 'waiting_for_sign';
console.log('[CO-SIGN] Check conditions:', {
hasAllParticipants,
statusReady,
currentParticipants,
expectedT,
status: status.status,
});
if (hasAllParticipants && statusReady) {
console.log('[CO-SIGN] Conditions met! Triggering sign...');
debugLog.info('main', `All ${expectedT} participants joined (status: ${status.status}), triggering sign...`);
// 更新参与者列表
if (status.parties && status.parties.length > 0) {
const myPartyId = grpcClient?.getPartyId();
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
for (const p of status.parties) {
const existing = activeCoSignSession.participants.find(ep => ep.partyId === p.party_id);
updatedParticipants.push({
partyId: p.party_id,
partyIndex: p.party_index,
name: existing?.name || (p.party_id === myPartyId ? '我' : `参与方 ${p.party_index + 1}`),
});
}
activeCoSignSession.participants = updatedParticipants;
const myInfo = updatedParticipants.find(p => p.partyId === myPartyId);
if (myInfo) {
activeCoSignSession.partyIndex = myInfo.partyIndex;
}
}
const selectedParties = activeCoSignSession.participants.map(p => p.partyId);
await handleCoSignStart({
eventType: 'session_started',
sessionId: sessionId,
thresholdT: activeCoSignSession.threshold.t,
thresholdN: activeCoSignSession.threshold.n,
selectedParties: selectedParties,
});
return;
}
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
} catch (error) {
debugLog.error('main', `Failed to check sign session status: ${error}, will retry`);
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
// 超时
debugLog.error('main', `Timeout: failed to start sign within 5 minutes for session ${sessionId}`);
if (mainWindow) {
mainWindow.webContents.send(`cosign:events:${sessionId}`, {
type: 'sign_start_timeout',
error: '启动签名超时,请重试',
});
}
}
// 处理 Co-Sign 会话开始事件 - 触发签名
async function handleCoSignStart(event: {
eventType: string;
sessionId: string;
thresholdT: number;
thresholdN: number;
selectedParties: string[];
}) {
console.log('[CO-SIGN] handleCoSignStart called:', {
eventType: event.eventType,
sessionId: event.sessionId,
thresholdT: event.thresholdT,
thresholdN: event.thresholdN,
selectedParties: event.selectedParties?.length,
});
if (!activeCoSignSession) {
console.log('[CO-SIGN] No active co-sign session, ignoring');
debugLog.debug('main', 'No active co-sign session, ignoring sign start event');
return;
}
if (activeCoSignSession.sessionId !== event.sessionId) {
debugLog.debug('main', `Session ID mismatch: expected ${activeCoSignSession.sessionId}, got ${event.sessionId}`);
return;
}
// 幂等性保护
if (signInProgressSessionId === event.sessionId) {
debugLog.debug('main', `Sign already in progress for session ${event.sessionId}, skipping duplicate trigger`);
return;
}
if (tssHandler?.getIsRunning()) {
debugLog.debug('main', 'TSS already running, skipping');
return;
}
if (!tssHandler || !('participateSign' in tssHandler)) {
debugLog.error('tss', 'TSS handler not initialized or does not support signing');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'TSS handler not initialized',
});
return;
}
// 标记签名开始
signInProgressSessionId = event.sessionId;
// 打印当前 activeCoSignSession.participants 状态
console.log('[CO-SIGN] Current activeCoSignSession.participants before update:',
activeCoSignSession.participants.map(p => ({
partyId: p.partyId.substring(0, 8),
partyIndex: p.partyIndex,
}))
);
console.log('[CO-SIGN] event.selectedParties:', event.selectedParties?.map(id => id.substring(0, 8)));
// 从 event.selectedParties 更新参与者列表
// 优先使用 activeCoSignSession.participants 中的 partyIndex来自 signingParties 或 other_parties
if (event.selectedParties && event.selectedParties.length > 0) {
const myPartyId = grpcClient?.getPartyId();
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
event.selectedParties.forEach((partyId) => {
// 查找已有的参与者信息
const existingParticipant = activeCoSignSession?.participants.find(p => p.partyId === partyId);
if (existingParticipant) {
// 使用已有的 partyIndex
updatedParticipants.push({
partyId: partyId,
partyIndex: existingParticipant.partyIndex,
name: partyId === myPartyId ? '我' : existingParticipant.name,
});
} else {
// 找不到已有信息,这不应该发生 - 记录警告
console.warn(`[CO-SIGN] WARNING: Party ${partyId.substring(0, 8)} not found in activeCoSignSession.participants!`);
// 不使用 fallback index直接跳过这会导致参与者数量不足签名会失败
// 这样可以更早发现问题
}
});
// 检查参与者数量是否符合预期
if (updatedParticipants.length !== event.selectedParties.length) {
console.error(`[CO-SIGN] ERROR: Participant count mismatch! Expected ${event.selectedParties.length}, got ${updatedParticipants.length}`);
}
// 按 partyIndex 排序
updatedParticipants.sort((a, b) => a.partyIndex - b.partyIndex);
activeCoSignSession.participants = updatedParticipants;
console.log('[CO-SIGN] Updated participants from session_started event:', updatedParticipants.map(p => ({
partyId: p.partyId.substring(0, 8),
partyIndex: p.partyIndex,
})));
}
// 获取 share 数据
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
if (!share) {
debugLog.error('main', 'Failed to get share data');
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
type: 'failed',
error: 'Failed to get share data',
});
signInProgressSessionId = null;
return;
}
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
sessionId: activeCoSignSession.sessionId,
partyId: grpcClient?.getPartyId(),
partyIndex: activeCoSignSession.partyIndex,
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
threshold: activeCoSignSession.threshold,
messageHash: activeCoSignSession.messageHash.substring(0, 16) + '...',
});
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
try {
const result = await (tssHandler as TSSHandler).participateSign(
activeCoSignSession.sessionId,
grpcClient?.getPartyId() || '',
activeCoSignSession.partyIndex,
activeCoSignSession.participants,
activeCoSignSession.threshold,
activeCoSignSession.messageHash,
share.raw_share,
activeCoSignSession.sharePassword
);
if (result.success) {
debugLog.info('tss', 'Sign completed successfully');
await handleCoSignComplete(result);
} else {
debugLog.error('tss', `Sign failed: ${result.error}`);
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession.sessionId}`, {
type: 'failed',
error: result.error || 'Sign failed',
});
signInProgressSessionId = null;
}
} catch (error) {
debugLog.error('tss', `Sign error: ${(error as Error).message}`);
mainWindow?.webContents.send(`cosign:events:${activeCoSignSession?.sessionId}`, {
type: 'failed',
error: (error as Error).message,
});
signInProgressSessionId = null;
}
}
// 处理 Co-Sign 完成 - 保存签名并报告完成
async function handleCoSignComplete(result: { success: boolean; signature: Buffer; error?: string }) {
if (!activeCoSignSession || !database || !grpcClient) {
debugLog.error('main', 'Missing required components for sign completion');
return;
}
const sessionId = activeCoSignSession.sessionId;
const partyId = grpcClient.getPartyId();
try {
const signatureHex = result.signature.toString('hex');
// 1. 更新签名历史
database.updateSigningHistory(sessionId, {
status: 'completed',
signature: signatureHex,
});
debugLog.info('main', `Signature saved: ${signatureHex.substring(0, 32)}...`);
// 2. 报告完成给 session-coordinator
const allCompleted = await grpcClient.reportCompletion(
sessionId,
partyId || '',
result.signature
);
debugLog.info('grpc', `Reported sign completion to session-coordinator, all_completed: ${allCompleted}`);
// 3. 通知前端
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
type: 'completed',
signature: signatureHex,
allCompleted: allCompleted,
});
// 4. 清理活跃会话和幂等性标志
activeCoSignSession = null;
signInProgressSessionId = null;
debugLog.info('main', 'Co-Sign session completed and cleaned up');
} catch (error) {
debugLog.error('main', `Failed to handle sign completion: ${error}`);
mainWindow?.webContents.send(`cosign:events:${sessionId}`, {
type: 'failed',
error: (error as Error).message,
});
signInProgressSessionId = null;
}
}
// 连接并注册到 Message Router
async function connectAndRegisterToMessageRouter() {
if (!grpcClient || !database) {
@ -688,26 +1045,53 @@ async function connectAndRegisterToMessageRouter() {
// 转发事件到前端
mainWindow?.webContents.send(`session:events:${event.session_id}`, eventData);
// 根据事件类型处理
// 根据事件类型处理 - 区分 Keygen 和 Co-Sign 会话
const isCoSignSession = activeCoSignSession?.sessionId === event.session_id;
const isKeygenSession = activeKeygenSession?.sessionId === event.session_id;
if (event.event_type === 'all_joined') {
// 收到 all_joined 事件表示所有参与方都已加入
// 此时启动 5 分钟倒计时,在此期间完成 keygen 启动
debugLog.info('main', `Received all_joined event for session ${event.session_id}, starting 5-minute keygen countdown`);
debugLog.info('main', `Received all_joined event for session ${event.session_id}, isCoSign=${isCoSignSession}, isKeygen=${isKeygenSession}`);
// 使用 setImmediate 确保不阻塞事件处理
setImmediate(() => {
checkAndTriggerKeygen(event.session_id);
});
if (isCoSignSession) {
// Co-Sign 会话:转发到 cosign 频道并触发签名
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
setImmediate(() => {
checkAndTriggerCoSign(event.session_id);
});
} else if (isKeygenSession) {
// Keygen 会话:启动 5 分钟倒计时
setImmediate(() => {
checkAndTriggerKeygen(event.session_id);
});
}
} else if (event.event_type === 'session_started') {
// session_started 事件表示 keygen 可以开始了(所有人已准备就绪)
// 直接触发 keygen
await handleSessionStart({
eventType: event.event_type,
sessionId: event.session_id,
thresholdN: event.threshold_n,
thresholdT: event.threshold_t,
selectedParties: event.selected_parties,
});
// session_started 事件表示可以开始了
if (isCoSignSession) {
// Co-Sign 会话
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
await handleCoSignStart({
eventType: event.event_type,
sessionId: event.session_id,
thresholdN: event.threshold_n,
thresholdT: event.threshold_t,
selectedParties: event.selected_parties,
});
} else if (isKeygenSession) {
// Keygen 会话
await handleSessionStart({
eventType: event.event_type,
sessionId: event.session_id,
thresholdN: event.threshold_n,
thresholdT: event.threshold_t,
selectedParties: event.selected_parties,
});
}
} else if (event.event_type === 'participant_joined') {
// 参与者加入事件 - 也需要区分会话类型
if (isCoSignSession) {
mainWindow?.webContents.send(`cosign:events:${event.session_id}`, eventData);
}
}
});
} catch (error) {
@ -790,8 +1174,13 @@ function setupIpcHandlers() {
// 这确保在其他方开始发送 TSS 消息时,我们已经准备好接收和缓冲
// 即使 keygen 进程还没启动,消息也不会丢失
if (tssHandler && 'prepareForKeygen' in tssHandler) {
debugLog.info('tss', `Preparing for keygen: subscribing to messages for session ${sessionId}`);
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(sessionId, partyId);
try {
debugLog.info('tss', `Preparing for keygen: subscribing to messages for session ${sessionId}`);
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(sessionId, partyId);
} catch (prepareErr) {
debugLog.error('tss', `Failed to prepare for keygen: ${(prepareErr as Error).message}`);
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
}
}
// 方案 B: 检查 JoinSession 响应中的 session 状态
@ -903,8 +1292,13 @@ function setupIpcHandlers() {
// 关键步骤:立即预订阅消息流
if (tssHandler && 'prepareForKeygen' in tssHandler) {
debugLog.info('tss', `Initiator preparing for keygen: subscribing to messages for session ${result.session_id}`);
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(result.session_id, partyId);
try {
debugLog.info('tss', `Initiator preparing for keygen: subscribing to messages for session ${result.session_id}`);
(tssHandler as { prepareForKeygen: (sessionId: string, partyId: string) => void }).prepareForKeygen(result.session_id, partyId);
} catch (prepareErr) {
debugLog.error('tss', `Failed to prepare for keygen: ${(prepareErr as Error).message}`);
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
}
}
// 方案 B: 检查 JoinSession 响应中的 session 状态
@ -1071,11 +1465,12 @@ function setupIpcHandlers() {
messageHash: result?.message_hash,
threshold: {
t: result?.threshold_t,
n: result?.parties?.length || 0,
n: result?.threshold_n,
},
currentParticipants: result?.joined_count || 0,
status: result?.status,
parties: result?.parties,
initiator: '发起者',
},
};
} catch (error) {
@ -1206,6 +1601,383 @@ function setupIpcHandlers() {
return accountClient?.getBaseUrl() || 'https://rwaapi.szaiai.com';
});
// ===========================================================================
// Co-Sign 相关 IPC 处理器
// ===========================================================================
// 创建 Co-Sign 会话
ipcMain.handle('cosign:createSession', async (_event, params: {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}) => {
try {
// 获取当前 party ID
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
// 从本地获取 share 信息
const share = database?.getShare(params.shareId, params.sharePassword);
if (!share) {
return { success: false, error: 'Share 不存在或密码错误' };
}
// 从后端获取 keygen 会话的参与者信息(包含正确的 party_index
const keygenStatus = await accountClient?.getSessionStatus(share.session_id);
if (!keygenStatus?.participants || keygenStatus.participants.length === 0) {
return { success: false, error: '无法获取 keygen 会话的参与者信息' };
}
// 过滤掉 co-managed-party-*(服务器持久方),只保留 temporary/external 用户方
// 只有用户方持有签名私钥份额co-managed-party 是备份/恢复用的
const signingParties = keygenStatus.participants
.filter(p => !p.party_id.startsWith('co-managed-party-'))
.map(p => ({
party_id: p.party_id,
party_index: p.party_index,
}));
console.log('[CO-SIGN] Signing parties (excluding co-managed-party):', {
total_keygen_participants: keygenStatus.participants.length,
signing_parties_count: signingParties.length,
signing_parties: signingParties.map(p => ({ id: p.party_id, index: p.party_index })),
});
if (signingParties.length < share.threshold_t) {
return {
success: false,
error: `签名参与方不足: 需要 ${share.threshold_t} 个,但只有 ${signingParties.length} 个用户方`
};
}
// 创建签名会话
const result = await accountClient?.createSignSession({
keygen_session_id: share.session_id,
wallet_name: share.wallet_name,
message_hash: params.messageHash,
parties: signingParties,
threshold_t: share.threshold_t,
initiator_name: params.initiatorName || '发起者',
});
if (!result?.session_id) {
return { success: false, error: '创建签名会话失败: 未返回会话ID' };
}
// 创建签名历史记录
database?.createSigningHistory({
shareId: params.shareId,
sessionId: result.session_id,
messageHash: params.messageHash,
});
// 发起方自动加入会话
// 支持新格式 join_tokens (map[partyID]token) 和旧格式 join_token (单一通配符 token)
const joinToken = result.join_tokens?.[partyId] || (result as { join_token?: string }).join_token;
if (joinToken) {
console.log('[CO-SIGN] Initiator auto-joining session...');
const joinResult = await grpcClient?.joinSession(result.session_id, partyId, joinToken);
if (joinResult?.success) {
// 设置活跃的 Co-Sign 会话信息
// 使用 signingParties 初始化完整的参与者列表(包含正确的 partyIndex
const signParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = signingParties.map(p => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: p.party_id === partyId ? (params.initiatorName || '发起者') : `参与方 ${p.party_index + 1}`,
}));
console.log('[CO-SIGN] Initiator signParticipants (from signingParties):', signParticipants.map(p => ({
partyId: p.partyId.substring(0, 8),
partyIndex: p.partyIndex,
})));
activeCoSignSession = {
sessionId: result.session_id,
partyIndex: joinResult.party_index,
participants: signParticipants,
threshold: {
t: share.threshold_t,
n: share.threshold_n,
},
walletName: share.wallet_name,
messageHash: params.messageHash,
shareId: params.shareId,
sharePassword: params.sharePassword,
};
console.log('[CO-SIGN] Initiator active session set:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
threshold: activeCoSignSession.threshold,
});
// 预订阅消息流
if (tssHandler && 'prepareForSign' in tssHandler) {
try {
debugLog.info('tss', `Initiator preparing for sign: subscribing to messages for session ${result.session_id}`);
(tssHandler as TSSHandler).prepareForSign(result.session_id, partyId);
} catch (prepareErr) {
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
}
}
// 检查会话状态
const sessionStatus = joinResult.session_info?.status;
debugLog.info('main', `Initiator JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
// 从 joinResult.other_parties 获取其他参与者,加上发起者自己
// 因为此时 activeCoSignSession.participants 只有发起者自己
const otherParties = joinResult.other_parties || [];
const selectedParties = [partyId, ...otherParties.map((p: { party_id: string }) => p.party_id)];
console.log('[CO-SIGN] Initiator using other_parties + self:', selectedParties.map((id: string) => id.substring(0, 8)));
setImmediate(async () => {
await handleCoSignStart({
eventType: 'session_started',
sessionId: result.session_id,
thresholdT: share.threshold_t,
thresholdN: share.threshold_n,
selectedParties: selectedParties,
});
});
}
} else {
console.warn('[CO-SIGN] Initiator failed to join session');
}
}
return {
success: true,
sessionId: result.session_id,
inviteCode: result.invite_code,
walletName: share.wallet_name,
expiresAt: result.expires_at,
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 验证 Co-Sign 邀请码
ipcMain.handle('cosign:validateInviteCode', async (_event, { code }) => {
try {
debugLog.info('account', `Validating co-sign invite code: ${code}`);
const result = await accountClient?.getSignSessionByInviteCode(code);
if (!result?.session_id) {
return { success: false, error: '无效的邀请码' };
}
return {
success: true,
sessionInfo: {
sessionId: result.session_id,
keygenSessionId: result.keygen_session_id,
walletName: result.wallet_name,
messageHash: result.message_hash,
threshold: {
t: result.threshold_t,
n: result.threshold_n,
},
status: result.status,
currentParticipants: result.joined_count || 0,
parties: result.parties,
},
joinToken: result.join_token,
};
} catch (error) {
debugLog.error('account', `Failed to validate co-sign invite code: ${(error as Error).message}`);
return { success: false, error: (error as Error).message };
}
});
// 加入 Co-Sign 会话
ipcMain.handle('cosign:joinSession', async (_event, params: {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
parties?: Array<{ party_id: string; party_index: number }>;
}) => {
try {
const partyId = grpcClient?.getPartyId();
if (!partyId) {
return { success: false, error: '请先连接到消息路由器' };
}
// 验证 share
const share = database?.getShare(params.shareId, params.sharePassword);
if (!share) {
return { success: false, error: 'Share 不存在或密码错误' };
}
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
if (result?.success) {
// 设置活跃的 Co-Sign 会话
// 优先使用 params.parties来自 validateInviteCode包含所有预期参与者
// 而不是 result.other_parties只包含已加入的参与者
let participants: Array<{ partyId: string; partyIndex: number; name: string }>;
if (params.parties && params.parties.length > 0) {
// 使用完整的 parties 列表
participants = params.parties.map(p => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: p.party_id === partyId ? '我' : `参与方 ${p.party_index + 1}`,
}));
console.log('[CO-SIGN] Participant using params.parties (complete list):', participants.map(p => ({
partyId: p.partyId.substring(0, 8),
partyIndex: p.partyIndex,
})));
} else {
// Fallback: 使用 other_parties + 自己(可能不完整)
console.warn('[CO-SIGN] WARNING: params.parties not available, using other_parties (may be incomplete)');
participants = result.other_parties?.map((p: { party_id: string; party_index: number }, idx: number) => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: `参与方 ${idx + 1}`,
})) || [];
// 添加自己
participants.push({
partyId: partyId,
partyIndex: result.party_index,
name: '我',
});
}
// 按 partyIndex 排序
participants.sort((a, b) => a.partyIndex - b.partyIndex);
activeCoSignSession = {
sessionId: params.sessionId,
partyIndex: result.party_index,
participants: participants,
threshold: params.threshold,
walletName: params.walletName || share.wallet_name,
messageHash: params.messageHash,
shareId: params.shareId,
sharePassword: params.sharePassword,
};
console.log('[CO-SIGN] Active session set:', {
sessionId: activeCoSignSession.sessionId,
partyIndex: activeCoSignSession.partyIndex,
participantCount: activeCoSignSession.participants.length,
threshold: activeCoSignSession.threshold,
});
// 创建签名历史记录
database?.createSigningHistory({
shareId: params.shareId,
sessionId: params.sessionId,
messageHash: params.messageHash,
});
// 预订阅消息流
if (tssHandler && 'prepareForSign' in tssHandler) {
try {
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
} catch (prepareErr) {
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
}
}
// 检查会话状态
const sessionStatus = result.session_info?.status;
debugLog.info('main', `JoinSession response: status=${sessionStatus}`);
if (sessionStatus === 'in_progress') {
debugLog.info('main', 'Session already in_progress, triggering sign immediately');
// 使用 activeCoSignSession.participants已从 other_parties + 自己构建)
const selectedParties = activeCoSignSession?.participants.map(p => p.partyId) || [];
console.log('[CO-SIGN] Participant using activeCoSignSession.participants:', selectedParties.map((id: string) => id.substring(0, 8)));
setImmediate(async () => {
await handleCoSignStart({
eventType: 'session_started',
sessionId: params.sessionId,
thresholdT: params.threshold.t,
thresholdN: params.threshold.n,
selectedParties: selectedParties,
});
});
}
return { success: true, data: result };
} else {
return { success: false, error: '加入会话失败' };
}
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 获取 Co-Sign 会话状态
ipcMain.handle('cosign:getSessionStatus', async (_event, { sessionId }) => {
try {
const result = await accountClient?.getSignSessionStatus(sessionId);
// API 返回的是 participants 字段
const apiParticipants = (result as { participants?: Array<{ party_id: string; party_index: number; status: string }> })?.participants || [];
return {
success: true,
session: {
sessionId: result?.session_id,
status: result?.status,
joinedCount: result?.joined_count,
inviteCode: (result as { invite_code?: string })?.invite_code || '',
threshold: {
t: result?.threshold_t || activeCoSignSession?.threshold?.t || 0,
n: result?.threshold_n || activeCoSignSession?.threshold?.n || 0,
},
participants: apiParticipants.map((p, idx) => ({
partyId: p.party_id,
partyIndex: p.party_index,
name: activeCoSignSession?.participants?.find(ap => ap.partyId === p.party_id)?.name || `参与方 ${idx + 1}`,
status: p.status || 'waiting',
})),
messageHash: activeCoSignSession?.messageHash || '',
walletName: activeCoSignSession?.walletName || '',
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 订阅 Co-Sign 会话事件
ipcMain.on('cosign:subscribeSessionEvents', (_event, { sessionId }) => {
debugLog.debug('main', `Frontend subscribing to co-sign session events: ${sessionId}`);
// 获取并发送缓存的事件
const cachedEvents = getAndClearCachedEvents(sessionId);
if (cachedEvents.length > 0) {
debugLog.info('main', `Sending ${cachedEvents.length} cached events to frontend for co-sign session ${sessionId}`);
for (const event of cachedEvents) {
mainWindow?.webContents.send(`cosign:events:${sessionId}`, event);
}
}
});
// 取消订阅 Co-Sign 会话事件
ipcMain.on('cosign:unsubscribeSessionEvents', (_event, { sessionId }) => {
debugLog.debug('main', `Frontend unsubscribing from co-sign session events: ${sessionId}`);
});
// ===========================================================================
// Share 存储相关 (SQLite)
// ===========================================================================
@ -1686,9 +2458,18 @@ app.whenReady().then(async () => {
}
createWindow();
app.on('activate', () => {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
// macOS: 重新激活时检查并恢复 gRPC 连接
if (grpcClient && !grpcClient.isConnected()) {
debugLog.info('grpc', 'App activated, reconnecting to Message Router...');
try {
await connectAndRegisterToMessageRouter();
} catch (err) {
debugLog.error('grpc', `Failed to reconnect on activate: ${(err as Error).message}`);
}
}
}
});
});

View File

@ -119,11 +119,12 @@ export interface CreateSignSessionResponse {
session_id: string;
invite_code: string;
keygen_session_id: string;
message_hash: string;
wallet_name: string;
threshold_t: number;
parties: SignPartyInfo[];
selected_parties: string[];
expires_at: number;
join_token: string;
join_token?: string; // Backward compatible: wildcard token (may be empty)
join_tokens: Record<string, string>; // New: all join tokens (map[partyID]token)
}
export interface GetSignSessionByInviteCodeResponse {
@ -132,11 +133,28 @@ export interface GetSignSessionByInviteCodeResponse {
wallet_name: string;
message_hash: string;
threshold_t: number;
threshold_n: number;
status: string;
invite_code: string;
expires_at: number;
parties: SignPartyInfo[];
joined_count: number;
join_token?: string;
}
export interface GetSignSessionStatusResponse {
session_id: string;
status: string;
session_type: string;
threshold_t: number;
threshold_n: number;
completed_parties: number;
total_parties: number;
joined_count?: number;
parties?: SignPartyInfo[];
participants?: Array<{ party_id: string; party_index: number; status: string }>;
message_hash?: string;
signature?: string;
}
// 错误响应
@ -313,6 +331,16 @@ export class AccountClient {
);
}
/**
* Sign
*/
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
return this.request<GetSignSessionStatusResponse>(
'GET',
`/api/v1/co-managed/sign/${sessionId}`
);
}
// ===========================================================================
// 健康检查
// ===========================================================================

View File

@ -473,10 +473,12 @@ export class GrpcClient extends EventEmitter {
// 标记已订阅(用于重连后恢复)
this.eventStreamSubscribed = true;
// 取消现有流
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
if (this.eventStream) {
const oldStream = this.eventStream;
oldStream.removeAllListeners();
try {
this.eventStream.cancel();
oldStream.cancel();
} catch (e) {
// 忽略
}
@ -485,6 +487,9 @@ export class GrpcClient extends EventEmitter {
this.eventStream = (this.client as grpc.Client & { subscribeSessionEvents: (req: unknown) => grpc.ClientReadableStream<SessionEvent> })
.subscribeSessionEvents({ party_id: partyId });
// 保存当前流的引用,用于在事件处理器中检查是否是当前活跃的流
const currentStream = this.eventStream;
this.eventStream.on('data', (event: SessionEvent) => {
this.emit('sessionEvent', event);
});
@ -493,6 +498,12 @@ export class GrpcClient extends EventEmitter {
console.error('[gRPC] Session event stream error:', err.message);
this.emit('streamError', err);
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
if (currentStream !== this.eventStream) {
console.log('[gRPC] Ignoring error from old event stream');
return;
}
// 非主动取消的错误触发重连
if (!err.message.includes('CANCELLED') && this.shouldReconnect) {
this.triggerReconnect('Event stream error');
@ -503,6 +514,12 @@ export class GrpcClient extends EventEmitter {
console.log('[gRPC] Session event stream ended');
this.emit('streamEnd');
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
if (currentStream !== this.eventStream) {
console.log('[gRPC] Ignoring end from old event stream');
return;
}
// 流结束也触发重连
if (this.shouldReconnect && this.eventStreamSubscribed) {
this.triggerReconnect('Event stream ended');
@ -533,23 +550,39 @@ export class GrpcClient extends EventEmitter {
throw new Error('Not connected');
}
if (!this.connected) {
throw new Error('gRPC client not connected');
}
// 保存订阅状态(用于重连后恢复)
this.activeMessageSubscription = { sessionId, partyId };
// 取消现有流
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
if (this.messageStream) {
const oldStream = this.messageStream;
oldStream.removeAllListeners();
try {
this.messageStream.cancel();
oldStream.cancel();
} catch (e) {
// 忽略
console.log('[gRPC] Ignored error while canceling old message stream:', (e as Error).message);
}
this.messageStream = null;
}
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
.subscribeMessages({
session_id: sessionId,
party_id: partyId,
});
try {
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
.subscribeMessages({
session_id: sessionId,
party_id: partyId,
});
} catch (e) {
console.error('[gRPC] Failed to create message stream:', (e as Error).message);
this.activeMessageSubscription = null;
throw e;
}
// 保存当前流的引用,用于在事件处理器中检查是否是当前活跃的流
const currentStream = this.messageStream;
this.messageStream.on('data', (message: MPCMessage) => {
this.emit('mpcMessage', message);
@ -559,6 +592,12 @@ export class GrpcClient extends EventEmitter {
console.error('[gRPC] Message stream error:', err.message);
this.emit('messageStreamError', err);
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
if (currentStream !== this.messageStream) {
console.log('[gRPC] Ignoring error from old message stream');
return;
}
// 非主动取消的错误触发重连
if (!err.message.includes('CANCELLED') && this.shouldReconnect && this.activeMessageSubscription) {
this.triggerReconnect('Message stream error');
@ -569,6 +608,12 @@ export class GrpcClient extends EventEmitter {
console.log('[gRPC] Message stream ended');
this.emit('messageStreamEnd');
// 只有当前活跃的流才触发重连,防止旧流的事件干扰
if (currentStream !== this.messageStream) {
console.log('[gRPC] Ignoring end from old message stream');
return;
}
// 流结束也触发重连
if (this.shouldReconnect && this.activeMessageSubscription) {
this.triggerReconnect('Message stream ended');

View File

@ -123,6 +123,12 @@ export class TSSHandler extends EventEmitter {
* @param partyId party ID
*/
prepareForKeygen(sessionId: string, partyId: string): void {
// 检查 gRPC 连接状态
if (!this.grpcClient.isConnected()) {
console.error('[TSS] Cannot prepare for keygen: gRPC client not connected');
throw new Error('gRPC client not connected');
}
// 如果已经为同一个 session 准备过,跳过
if (this.isPrepared && this.sessionId === sessionId) {
console.log('[TSS] Already prepared for same session, skip');
@ -284,8 +290,11 @@ export class TSSHandler extends EventEmitter {
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
// 清理消息监听器,防止下次 keygen 时重复注册
this.sessionId = null;
this.partyId = null;
// 清理消息监听器和 gRPC 流订阅,防止下次 keygen 时出错
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
if (code === 0 && resultData) {
try {
@ -321,14 +330,19 @@ export class TSSHandler extends EventEmitter {
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
// 清理消息监听器
this.sessionId = null;
this.partyId = null;
// 清理消息监听器和 gRPC 流订阅
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
reject(err);
});
} catch (err) {
this.isRunning = false;
this.isPrepared = false;
this.sessionId = null;
this.partyId = null;
reject(err);
}
});
@ -477,6 +491,253 @@ export class TSSHandler extends EventEmitter {
this.messageBuffer = [];
}
// ===========================================================================
// Co-Sign 相关方法 - 与 Keygen 完全隔离的签名功能
// ===========================================================================
/**
* - joinSession
* prepareForKeygen
*
* @param sessionId ID
* @param partyId party ID
*/
prepareForSign(sessionId: string, partyId: string): void {
// 检查 gRPC 连接状态
if (!this.grpcClient.isConnected()) {
console.error('[TSS-SIGN] Cannot prepare for sign: gRPC client not connected');
throw new Error('gRPC client not connected');
}
// 如果已经为同一个 session 准备过,跳过
if (this.isPrepared && this.sessionId === sessionId) {
console.log('[TSS-SIGN] Already prepared for same session, skip');
return;
}
// 如果为不同的 session 准备过,先取消旧的订阅
if (this.isPrepared && this.sessionId !== sessionId) {
console.log(`[TSS-SIGN] Switching from session ${this.sessionId?.substring(0, 8)}... to ${sessionId.substring(0, 8)}...`);
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
this.messageBuffer = [];
}
console.log(`[TSS-SIGN] Preparing for sign: session=${sessionId.substring(0, 8)}..., party=${partyId.substring(0, 8)}...`);
this.sessionId = sessionId;
this.partyId = partyId;
this.isPrepared = true;
this.messageBuffer = [];
// 立即订阅消息流,开始缓冲消息
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
this.grpcClient.subscribeMessages(sessionId, partyId);
console.log('[TSS-SIGN] Message subscription started, buffering enabled');
}
/**
*
*/
cancelSignPrepare(): void {
if (!this.isPrepared) {
return;
}
console.log('[TSS-SIGN] Canceling sign prepare');
this.isPrepared = false;
this.messageBuffer = [];
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
this.sessionId = null;
this.partyId = null;
}
/**
* Co-Sign
*
* @param sessionId ID
* @param partyId party ID
* @param partyIndex
* @param participants (T )
* @param threshold { t: 签名阈值, n: keygen时的总参与方数 }
* @param messageHash (hex )
* @param shareData share (base64 )
* @param sharePassword share
*/
async participateSign(
sessionId: string,
partyId: string,
partyIndex: number,
participants: ParticipantInfo[],
threshold: { t: number; n: number },
messageHash: string,
shareData: string,
sharePassword: string
): Promise<SignResult> {
if (this.isRunning) {
throw new Error('TSS protocol already running');
}
// 检查是否已经预订阅
const wasPrepared = this.isPrepared && this.sessionId === sessionId;
const bufferedCount = this.messageBuffer.length;
console.log(`[TSS-SIGN] Starting sign: wasPrepared=${wasPrepared}, bufferedMessages=${bufferedCount}`);
this.sessionId = sessionId;
this.partyId = partyId;
this.partyIndex = partyIndex;
this.participants = participants;
this.isRunning = true;
this.isProcessReady = false;
// 注意:不清空消息缓冲,保留预订阅阶段收到的消息
// 构建 party index map
this.partyIndexMap.clear();
for (const p of participants) {
this.partyIndexMap.set(p.partyId, p.partyIndex);
}
return new Promise((resolve, reject) => {
try {
const binaryPath = this.getTSSBinaryPath();
console.log(`[TSS-SIGN] Binary path: ${binaryPath}`);
console.log(`[TSS-SIGN] Binary exists: ${fs.existsSync(binaryPath)}`);
// 构建参与者列表 JSON
const participantsJson = JSON.stringify(participants);
console.log(`[TSS-SIGN] Participants: ${participantsJson}`);
console.log(`[TSS-SIGN] partyIndex=${partyIndex}, threshold=${threshold.t}-of-${threshold.n}`);
console.log(`[TSS-SIGN] messageHash=${messageHash.substring(0, 16)}...`);
// 启动 TSS 子进程 - sign 命令
const args = [
'sign',
'--session-id', sessionId,
'--party-id', partyId,
'--party-index', partyIndex.toString(),
'--threshold-t', threshold.t.toString(),
'--threshold-n', threshold.n.toString(),
'--participants', participantsJson,
'--message-hash', messageHash,
'--share-data', shareData,
'--password', sharePassword,
];
console.log(`[TSS-SIGN] Spawning: ${binaryPath} sign ...`);
this.tssProcess = spawn(binaryPath, args);
let resultData = '';
let stderrData = '';
// 如果没有预订阅,现在订阅消息
if (!wasPrepared) {
console.log('[TSS-SIGN] Subscribing to messages (not prepared before)');
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
this.grpcClient.subscribeMessages(sessionId, partyId);
} else {
console.log(`[TSS-SIGN] Using existing subscription, ${bufferedCount} messages buffered`);
}
// 处理标准输出 (JSON 消息)
this.tssProcess.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(line => line.trim());
// 收到第一条输出时,标记进程就绪并发送缓冲的消息
if (!this.isProcessReady && this.tssProcess?.stdin) {
this.isProcessReady = true;
console.log(`[TSS-SIGN] Process ready, flushing ${this.messageBuffer.length} buffered messages`);
this.flushMessageBuffer();
}
for (const line of lines) {
try {
const message: TSSMessage = JSON.parse(line);
this.handleTSSMessage(message);
if (message.type === 'result') {
resultData = line;
}
} catch {
// 非 JSON 输出,记录日志
console.log('[TSS-SIGN]', line);
}
}
});
// 处理标准错误
this.tssProcess.stderr?.on('data', (data: Buffer) => {
const errorText = data.toString();
stderrData += errorText;
console.error('[TSS-SIGN stderr]', errorText);
});
// 处理进程退出
this.tssProcess.on('close', (code) => {
const completedSessionId = this.sessionId;
this.isRunning = false;
this.isProcessReady = false;
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
this.sessionId = null;
this.partyId = null;
// 清理消息监听器和 gRPC 流订阅
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
if (code === 0 && resultData) {
try {
const result: TSSMessage = JSON.parse(resultData);
if (result.payload) {
// 成功完成后清理该会话的已处理消息记录
if (this.database && completedSessionId) {
this.database.clearProcessedMessages(completedSessionId);
}
resolve({
success: true,
signature: Buffer.from(result.payload, 'base64'),
});
} else {
reject(new Error(result.error || 'Sign failed: no result data'));
}
} catch (e) {
reject(new Error(`Failed to parse sign result: ${e}`));
}
} else {
const errorMsg = stderrData.trim() || `Sign process exited with code ${code}`;
console.error(`[TSS-SIGN] Process failed: code=${code}, stderr=${stderrData}`);
reject(new Error(errorMsg));
}
});
// 处理进程错误
this.tssProcess.on('error', (err) => {
this.isRunning = false;
this.isProcessReady = false;
this.isPrepared = false;
this.messageBuffer = [];
this.tssProcess = null;
this.sessionId = null;
this.partyId = null;
// 清理消息监听器和 gRPC 流订阅
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
reject(err);
});
} catch (err) {
this.isRunning = false;
this.isPrepared = false;
this.sessionId = null;
this.partyId = null;
reject(err);
}
});
}
/**
*
*/
@ -489,7 +750,11 @@ export class TSSHandler extends EventEmitter {
this.isProcessReady = false;
this.isPrepared = false;
this.messageBuffer = [];
this.sessionId = null;
this.partyId = null;
// 清理消息监听器和 gRPC 流订阅
this.grpcClient.removeAllListeners('mpcMessage');
this.grpcClient.unsubscribeMessages();
}
/**

View File

@ -63,7 +63,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
register: (partyId: string, role: string) =>
ipcRenderer.invoke('grpc:register', { partyId, role }),
// 签名相关
// 签名相关 (旧版 - persistent 签名使用)
validateSigningSession: (code: string) =>
ipcRenderer.invoke('grpc:validateSigningSession', { code }),
@ -88,6 +88,53 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
},
// ===========================================================================
// Co-Sign 相关 - 全新的多方协作签名 API
// ===========================================================================
cosign: {
// 创建 Co-Sign 会话
createSession: (params: {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}) => ipcRenderer.invoke('cosign:createSession', params),
// 验证邀请码
validateInviteCode: (code: string) =>
ipcRenderer.invoke('cosign:validateInviteCode', { code }),
// 加入 Co-Sign 会话
joinSession: (params: {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
}) => ipcRenderer.invoke('cosign:joinSession', params),
// 获取会话状态
getSessionStatus: (sessionId: string) =>
ipcRenderer.invoke('cosign:getSessionStatus', { sessionId }),
// 订阅会话事件
subscribeSessionEvents: (sessionId: string, callback: (event: unknown) => void) => {
const channel = `cosign:events:${sessionId}`;
const listener = (_event: unknown, data: unknown) => callback(data);
eventSubscriptions.set(channel, listener);
ipcRenderer.on(channel, listener);
ipcRenderer.send('cosign:subscribeSessionEvents', { sessionId });
return () => {
ipcRenderer.removeListener(channel, listener);
eventSubscriptions.delete(channel);
ipcRenderer.send('cosign:unsubscribeSessionEvents', { sessionId });
};
},
},
// ===========================================================================
// Account 服务相关 (HTTP API)
// ===========================================================================

View File

@ -9,6 +9,10 @@ import Create from './pages/Create';
import Session from './pages/Session';
import Sign from './pages/Sign';
import Settings from './pages/Settings';
// Co-Sign 页面
import CoSignCreate from './pages/CoSignCreate';
import CoSignJoin from './pages/CoSignJoin';
import CoSignSession from './pages/CoSignSession';
function App() {
const [startupComplete, setStartupComplete] = useState(false);
@ -37,12 +41,20 @@ function App() {
<Layout>
<Routes>
<Route path="/" element={<Home />} />
{/* Keygen 路由 */}
<Route path="/join" element={<Join />} />
<Route path="/join/:inviteCode" element={<Join />} />
<Route path="/create" element={<Create />} />
<Route path="/session/:sessionId" element={<Session />} />
{/* 旧版签名路由 (persistent) */}
<Route path="/sign" element={<Sign />} />
<Route path="/sign/:sessionId" element={<Sign />} />
{/* Co-Sign 路由 */}
<Route path="/cosign/create" element={<CoSignCreate />} />
<Route path="/cosign/join" element={<CoSignJoin />} />
<Route path="/cosign/join/:inviteCode" element={<CoSignJoin />} />
<Route path="/cosign/session/:sessionId" element={<CoSignSession />} />
{/* 设置 */}
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -11,14 +11,14 @@ const navItems = [
{ path: '/', label: '我的钱包', icon: '🔐' },
{ path: '/create', label: '创建钱包', icon: '' },
{ path: '/join', label: '加入创建', icon: '🤝' },
{ path: '/sign', label: '参与签名', icon: '✍️' },
{ path: '/cosign/join', label: '参与签名', icon: '🔥' },
{ path: '/settings', label: '设置', icon: '⚙️' },
];
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const [isRefreshing, setIsRefreshing] = useState(false);
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
const { environment, operation, checkAllServices, appReady } = useAppStore();
@ -26,9 +26,41 @@ export default function Layout({ children }: LayoutProps) {
useEffect(() => {
checkAllServices();
// 获取当前 Kava 网络
window.electronAPI?.kava.getNetwork().then(result => {
setKavaNetwork(result.network);
});
const loadNetwork = () => {
// 优先从 localStorage 读取(与 transaction.ts 保持一致)
const storedNetwork = localStorage.getItem('kava_network') as 'mainnet' | 'testnet' | null;
if (storedNetwork) {
setKavaNetwork(storedNetwork);
} else {
// 后备:从 Electron API 读取
window.electronAPI?.kava.getNetwork().then(result => {
setKavaNetwork(result.network);
});
}
};
loadNetwork();
// 监听 localStorage 变化(当其他标签页切换网络时触发)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'kava_network' && e.newValue) {
setKavaNetwork(e.newValue as 'mainnet' | 'testnet');
}
};
// 监听同一窗口的自定义事件Settings 页面切换网络时触发)
const handleCustomNetworkChange = (e: Event) => {
const customEvent = e as CustomEvent<{ network: 'mainnet' | 'testnet' }>;
setKavaNetwork(customEvent.detail.network);
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener('kava-network-change', handleCustomNetworkChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('kava-network-change', handleCustomNetworkChange);
};
}, [checkAllServices]);
const handleRefresh = async () => {

View File

@ -0,0 +1,265 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from './Create.module.css';
interface Share {
id: string;
walletName: string;
publicKey: string;
threshold: { t: number; n: number };
createdAt: string;
}
interface CreateCoSignResult {
success: boolean;
sessionId?: string;
inviteCode?: string;
error?: string;
}
export default function CoSignCreate() {
const navigate = useNavigate();
const [shares, setShares] = useState<Share[]>([]);
const [selectedShareId, setSelectedShareId] = useState('');
const [sharePassword, setSharePassword] = useState('');
const [messageHash, setMessageHash] = useState('');
const [participantName, setParticipantName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CreateCoSignResult | null>(null);
const [step, setStep] = useState<'config' | 'creating' | 'created'>('config');
// 加载本地 shares
useEffect(() => {
const loadShares = async () => {
try {
const result = await window.electronAPI.storage.listShares();
// 兼容不同返回格式
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
setShares(shareList);
if (shareList.length > 0) {
setSelectedShareId(shareList[0].id);
}
} catch (err) {
console.error('Failed to load shares:', err);
}
};
loadShares();
}, []);
const handleCreateSession = async () => {
if (!selectedShareId) {
setError('请选择一个钱包');
return;
}
if (!messageHash.trim()) {
setError('请输入待签名的消息哈希');
return;
}
if (!/^[0-9a-fA-F]{64}$/.test(messageHash.trim())) {
setError('消息哈希必须是 64 位十六进制字符串 (32 字节)');
return;
}
if (!participantName.trim()) {
setParticipantName('发起者');
}
setStep('creating');
setIsLoading(true);
setError(null);
try {
const createResult = await window.electronAPI.cosign.createSession({
shareId: selectedShareId,
sharePassword: sharePassword,
messageHash: messageHash.trim().toLowerCase(),
initiatorName: participantName.trim() || '发起者',
});
if (createResult.success) {
setResult(createResult);
setStep('created');
} else {
setError(createResult.error || '创建签名会话失败');
setStep('config');
}
} catch (err) {
setError('创建签名会话失败,请检查网络连接');
setStep('config');
} finally {
setIsLoading(false);
}
};
const handleCopyInviteCode = async () => {
if (result?.inviteCode) {
try {
await navigator.clipboard.writeText(result.inviteCode);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
const handleGoToSession = () => {
if (result?.sessionId) {
navigate(`/cosign/session/${result.sessionId}`);
}
};
const selectedShare = shares.find(s => s.id === selectedShareId);
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
{step === 'config' && (
<div className={styles.form}>
{/* 签名说明 */}
<div className={styles.infoBox}>
<div className={styles.infoIcon}>i</div>
<div className={styles.infoContent}>
<strong></strong>
<ul className={styles.infoList}>
<li><strong></strong>: 使</li>
<li><strong></strong>: SHA256 </li>
<li><strong></strong>: </li>
<li><strong></strong>: </li>
</ul>
</div>
</div>
{/* 选择钱包 */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
{shares.length === 0 ? (
<p className={styles.hint}></p>
) : (
<select
value={selectedShareId}
onChange={(e) => setSelectedShareId(e.target.value)}
className={styles.input}
disabled={isLoading}
>
{shares.map(share => (
<option key={share.id} value={share.id}>
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
</option>
))}
</select>
)}
{selectedShare && (
<p className={styles.hint}>
: {selectedShare.publicKey.substring(0, 16)}...
</p>
)}
</div>
{/* 钱包密码 */}
<div className={styles.inputGroup}>
<label className={styles.label}> ()</label>
<input
type="password"
value={sharePassword}
onChange={(e) => setSharePassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.input}
disabled={isLoading}
/>
</div>
{/* 消息哈希 */}
<div className={styles.inputGroup}>
<label className={styles.label}> (Hex)</label>
<input
type="text"
value={messageHash}
onChange={(e) => setMessageHash(e.target.value)}
placeholder="64位十六进制字符串如: a1b2c3d4..."
className={styles.input}
disabled={isLoading}
/>
<p className={styles.hint}>
SHA256 (32 = 64 )
</p>
</div>
{/* 参与者名称 */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<input
type="text"
value={participantName}
onChange={(e) => setParticipantName(e.target.value)}
placeholder="输入您的名称(其他参与者可见)"
className={styles.input}
disabled={isLoading}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => navigate('/')}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={handleCreateSession}
disabled={isLoading || shares.length === 0}
>
</button>
</div>
</div>
)}
{step === 'creating' && (
<div className={styles.creating}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
{step === 'created' && result && (
<div className={styles.form}>
<div className={styles.successIcon}>OK</div>
<h3 className={styles.successTitle}></h3>
<div className={styles.inviteSection}>
<label className={styles.label}></label>
<div className={styles.inviteCodeWrapper}>
<code className={styles.inviteCode}>{result.inviteCode}</code>
<button
className={styles.copyButton}
onClick={handleCopyInviteCode}
>
</button>
</div>
<p className={styles.hint}>
使
</p>
</div>
<div className={styles.actions}>
<button
className={styles.primaryButton}
onClick={handleGoToSession}
>
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,410 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import styles from './Join.module.css';
interface Share {
id: string;
walletName: string;
publicKey: string;
sessionId: string;
threshold: { t: number; n: number };
}
interface SignPartyInfo {
party_id: string;
party_index: number;
}
interface SessionInfo {
sessionId: string;
keygenSessionId: string;
walletName: string;
messageHash: string;
threshold: { t: number; n: number };
status: string;
currentParticipants: number;
parties?: SignPartyInfo[];
}
interface ValidateResult {
success: boolean;
error?: string;
sessionInfo?: SessionInfo;
joinToken?: string;
}
export default function CoSignJoin() {
const { inviteCode } = useParams<{ inviteCode?: string }>();
const navigate = useNavigate();
const [code, setCode] = useState(inviteCode || '');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [joinToken, setJoinToken] = useState<string | null>(null);
const [step, setStep] = useState<'input' | 'select_share' | 'joining'>('input');
// Share 选择相关
const [shares, setShares] = useState<Share[]>([]);
const [selectedShareId, setSelectedShareId] = useState('');
const [sharePassword, setSharePassword] = useState('');
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
// 加载本地 shares
useEffect(() => {
const loadShares = async () => {
try {
const result = await window.electronAPI.storage.listShares();
// 兼容不同返回格式
const shareList = Array.isArray(result) ? result : ((result as any)?.data || []);
console.log('[CoSignJoin] Loaded shares:', shareList.map((s: Share) => ({
id: s.id,
sessionId: s.sessionId,
walletName: s.walletName,
})));
setShares(shareList);
} catch (err) {
console.error('Failed to load shares:', err);
}
};
loadShares();
}, []);
// 如果 URL 中有邀请码,自动验证
useEffect(() => {
if (inviteCode) {
handleValidateCode(inviteCode);
}
}, [inviteCode]);
// 自动选择匹配的 share
useEffect(() => {
if (sessionInfo && shares.length > 0 && !selectedShareId) {
// 尝试找到匹配的 share基于 keygen session ID
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
console.log('[CoSignJoin] Auto-select share check:', {
keygenSessionId: sessionInfo.keygenSessionId,
sharesSessionIds: shares.map(s => s.sessionId),
matchingShare: matchingShare ? { id: matchingShare.id, sessionId: matchingShare.sessionId } : null,
});
if (matchingShare) {
console.log('[CoSignJoin] Auto-selecting matching share:', matchingShare.id);
setSelectedShareId(matchingShare.id);
} else if (shares.length === 1) {
// 如果只有一个 share自动选择
console.log('[CoSignJoin] Auto-selecting only share:', shares[0].id);
setSelectedShareId(shares[0].id);
} else {
console.log('[CoSignJoin] No matching share found, user must select manually');
}
}
}, [sessionInfo, shares, selectedShareId]);
// 自动加入
useEffect(() => {
console.log('[CoSignJoin] Auto-join check:', {
step,
hasSessionInfo: !!sessionInfo,
hasJoinToken: !!joinToken,
selectedShareId,
autoJoinAttempted,
isLoading,
sharesCount: shares.length,
});
if (
step === 'select_share' &&
sessionInfo &&
joinToken &&
selectedShareId &&
!autoJoinAttempted &&
!isLoading
) {
// 找到匹配的 share 且未尝试过自动加入,则自动加入
const matchingShare = shares.find(s => s.sessionId === sessionInfo.keygenSessionId);
console.log('[CoSignJoin] Auto-join conditions met, checking share match:', {
keygenSessionId: sessionInfo.keygenSessionId,
matchingShareId: matchingShare?.id,
selectedShareId,
isMatch: matchingShare && matchingShare.id === selectedShareId,
});
if (matchingShare && matchingShare.id === selectedShareId) {
console.log('[CoSignJoin] Auto-joining session...');
setAutoJoinAttempted(true);
handleJoinSession();
} else {
console.log('[CoSignJoin] Share mismatch, not auto-joining');
}
}
}, [step, sessionInfo, joinToken, selectedShareId, autoJoinAttempted, isLoading, shares]);
const handleValidateCode = async (codeToValidate: string) => {
console.log('[CoSignJoin] handleValidateCode called:', codeToValidate);
if (!codeToValidate.trim()) {
setError('请输入邀请码');
return;
}
setIsLoading(true);
setError(null);
try {
const result: ValidateResult = await window.electronAPI.cosign.validateInviteCode(codeToValidate);
console.log('[CoSignJoin] validateInviteCode result:', {
success: result.success,
sessionInfo: result.sessionInfo,
hasJoinToken: !!result.joinToken,
joinTokenPreview: result.joinToken?.substring(0, 20) + '...',
error: result.error,
});
if (result.success && result.sessionInfo) {
setSessionInfo(result.sessionInfo);
if (result.joinToken) {
setJoinToken(result.joinToken);
} else {
console.warn('[CoSignJoin] WARNING: No joinToken in response!');
}
setStep('select_share');
} else {
setError(result.error || '无效的邀请码');
}
} catch (err) {
console.error('[CoSignJoin] validateInviteCode error:', err);
setError('验证邀请码失败,请检查网络连接');
} finally {
setIsLoading(false);
}
};
const handleJoinSession = async () => {
console.log('[CoSignJoin] handleJoinSession called:', {
hasSessionInfo: !!sessionInfo,
hasJoinToken: !!joinToken,
selectedShareId,
});
if (!sessionInfo) {
setError('会话信息不完整');
return;
}
if (!joinToken) {
setError('未获取到加入令牌,请重新验证邀请码');
return;
}
if (!selectedShareId) {
setError('请选择一个钱包');
return;
}
setStep('joining');
setIsLoading(true);
setError(null);
console.log('[CoSignJoin] Calling cosign.joinSession with:', {
sessionId: sessionInfo.sessionId,
shareId: selectedShareId,
walletName: sessionInfo.walletName,
messageHash: sessionInfo.messageHash,
threshold: sessionInfo.threshold,
parties: sessionInfo.parties,
});
try {
const result = await window.electronAPI.cosign.joinSession({
sessionId: sessionInfo.sessionId,
shareId: selectedShareId,
sharePassword: sharePassword,
joinToken: joinToken,
walletName: sessionInfo.walletName,
messageHash: sessionInfo.messageHash,
threshold: sessionInfo.threshold,
parties: sessionInfo.parties,
});
console.log('[CoSignJoin] joinSession result:', result);
if (result.success) {
navigate(`/cosign/session/${sessionInfo.sessionId}`);
} else {
setError(result.error || '加入会话失败');
setStep('select_share');
}
} catch (err) {
setError('加入会话失败,请重试');
setStep('select_share');
} finally {
setIsLoading(false);
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
setCode(text.trim());
} catch (err) {
console.error('Failed to read clipboard:', err);
}
};
const selectedShare = shares.find(s => s.id === selectedShareId);
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
{step === 'input' && (
<div className={styles.form}>
<div className={styles.inputGroup}>
<label className={styles.label}></label>
<div className={styles.inputWrapper}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="粘贴签名邀请码"
className={styles.input}
disabled={isLoading}
/>
<button
className={styles.pasteButton}
onClick={handlePaste}
disabled={isLoading}
>
</button>
</div>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => navigate('/')}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={() => handleValidateCode(code)}
disabled={isLoading || !code.trim()}
>
{isLoading ? '验证中...' : '下一步'}
</button>
</div>
</div>
)}
{step === 'select_share' && sessionInfo && (
<div className={styles.form}>
<div className={styles.sessionInfo}>
<h3 className={styles.sessionTitle}></h3>
<div className={styles.infoGrid}>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue} style={{ fontFamily: 'monospace', fontSize: '12px' }}>
{sessionInfo.messageHash.substring(0, 16)}...
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}></span>
<span className={styles.infoValue}>
{sessionInfo.currentParticipants} / {sessionInfo.threshold.t}
</span>
</div>
</div>
</div>
{/* 选择本地 share */}
<div className={styles.inputGroup}>
<label className={styles.label}></label>
{shares.length === 0 ? (
<p className={styles.hint}></p>
) : (
<select
value={selectedShareId}
onChange={(e) => setSelectedShareId(e.target.value)}
className={styles.input}
disabled={isLoading}
>
<option value="">...</option>
{shares.map(share => (
<option key={share.id} value={share.id}>
{share.walletName} ({share.threshold.t}-of-{share.threshold.n})
{share.sessionId === sessionInfo.keygenSessionId ? ' [匹配]' : ''}
</option>
))}
</select>
)}
{selectedShare && (
<p className={styles.hint}>
: {selectedShare.publicKey.substring(0, 16)}...
</p>
)}
</div>
{/* 钱包密码 */}
<div className={styles.inputGroup}>
<label className={styles.label}> ()</label>
<input
type="password"
value={sharePassword}
onChange={(e) => setSharePassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.input}
disabled={isLoading}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={() => {
setStep('input');
setSessionInfo(null);
setJoinToken(null);
setSelectedShareId('');
setError(null);
setAutoJoinAttempted(false);
}}
disabled={isLoading}
>
</button>
<button
className={styles.primaryButton}
onClick={handleJoinSession}
disabled={isLoading || !selectedShareId}
>
{isLoading ? '加入中...' : '加入签名'}
</button>
</div>
</div>
)}
{step === 'joining' && (
<div className={styles.joining}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,636 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import styles from './Session.module.css';
import {
finalizeTransaction,
broadcastTransaction,
type PreparedTransaction,
} from '../utils/transaction';
// 从 sessionStorage 获取的交易信息
interface TransactionInfo {
preparedTx: PreparedTransaction;
to: string;
amount: string;
from: string;
walletName: string;
}
interface Participant {
partyId: string;
name: string;
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
}
interface SessionState {
sessionId: string;
walletName: string;
messageHash: string;
inviteCode?: string;
threshold: { t: number; n: number };
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
participants: Participant[];
currentRound: number;
totalRounds: number;
signature?: string;
error?: string;
}
export default function CoSignSession() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const [session, setSession] = useState<SessionState | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 交易广播相关状态
const [txInfo, setTxInfo] = useState<TransactionInfo | null>(null);
const [broadcastStep, setBroadcastStep] = useState<'idle' | 'broadcasting' | 'success' | 'error'>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [broadcastError, setBroadcastError] = useState<string | null>(null);
const fetchSessionStatus = useCallback(async () => {
if (!sessionId) return;
try {
const result = await window.electronAPI.cosign.getSessionStatus(sessionId);
if (result.success && result.session) {
setSession({
sessionId: result.session.sessionId || sessionId,
walletName: result.session.walletName || '',
messageHash: result.session.messageHash || '',
inviteCode: result.session.inviteCode || '',
threshold: result.session.threshold || { t: 0, n: 0 },
status: mapStatus(result.session.status),
participants: (result.session.participants || []).map(p => ({
...p,
status: mapParticipantStatus(p.status),
})),
currentRound: 0,
totalRounds: 9, // GG20 签名有 9 轮
});
} else {
setError(result.error || '获取会话状态失败');
}
} catch (err) {
setError('获取会话状态失败');
} finally {
setIsLoading(false);
}
}, [sessionId]);
// 映射参与者状态
const mapParticipantStatus = (status: string): Participant['status'] => {
switch (status) {
case 'waiting':
case 'pending':
return 'waiting';
case 'ready':
case 'joined':
return 'ready';
case 'processing':
case 'signing':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
return 'failed';
default:
return 'waiting';
}
};
// 映射后端状态到前端状态
const mapStatus = (status: string): 'waiting' | 'ready' | 'processing' | 'completed' | 'failed' => {
switch (status) {
case 'pending':
case 'waiting':
return 'waiting';
case 'all_joined':
case 'ready':
return 'ready';
case 'in_progress':
case 'signing':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
case 'expired':
return 'failed';
default:
return 'waiting';
}
};
// 加载交易信息
useEffect(() => {
if (sessionId) {
const storedTxInfo = sessionStorage.getItem(`tx_${sessionId}`);
if (storedTxInfo) {
try {
const parsed = JSON.parse(storedTxInfo);
// 恢复 bigint 类型 (Legacy 交易使用 gasPrice)
if (parsed.preparedTx) {
parsed.preparedTx.gasLimit = BigInt(parsed.preparedTx.gasLimit);
parsed.preparedTx.gasPrice = BigInt(parsed.preparedTx.gasPrice);
parsed.preparedTx.value = BigInt(parsed.preparedTx.value);
}
setTxInfo(parsed);
} catch (err) {
console.error('Failed to parse transaction info:', err);
}
}
}
}, [sessionId]);
useEffect(() => {
fetchSessionStatus();
// 订阅会话事件
const unsubscribe = window.electronAPI.cosign.subscribeSessionEvents(
sessionId!,
(event: any) => {
console.log('[CoSignSession] Received event:', event);
if (event.type === 'participant_joined') {
setSession(prev => prev ? {
...prev,
participants: event.participant
? [...prev.participants, event.participant]
: prev.participants,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'status_changed' || event.type === 'all_joined') {
setSession(prev => prev ? {
...prev,
status: event.status ? mapStatus(event.status) : prev.status,
} : null);
// 刷新状态
fetchSessionStatus();
} else if (event.type === 'progress') {
setSession(prev => prev ? {
...prev,
status: 'processing',
currentRound: event.round || prev.currentRound,
totalRounds: event.totalRounds || prev.totalRounds,
} : null);
} else if (event.type === 'completed') {
setSession(prev => prev ? {
...prev,
status: 'completed',
signature: event.signature,
} : null);
} else if (event.type === 'failed' || event.type === 'sign_start_timeout') {
setSession(prev => prev ? {
...prev,
status: 'failed',
error: event.error,
} : null);
}
}
);
return () => {
unsubscribe();
};
}, [sessionId, fetchSessionStatus]);
const getStatusText = (status: string) => {
switch (status) {
case 'waiting': return '等待参与方';
case 'ready': return '准备就绪';
case 'processing': return '签名进行中';
case 'completed': return '签名完成';
case 'failed': return '签名失败';
default: return status;
}
};
const getStatusClass = (status: string) => {
switch (status) {
case 'waiting': return styles.statusWaiting;
case 'ready': return styles.statusReady;
case 'processing': return styles.statusProcessing;
case 'completed': return styles.statusCompleted;
case 'failed': return styles.statusFailed;
default: return '';
}
};
const getParticipantStatusIcon = (status: string) => {
switch (status) {
case 'waiting': return '...';
case 'ready': return 'OK';
case 'processing': return '*';
case 'completed': return 'OK';
case 'failed': return 'X';
default: return '-';
}
};
const handleCopySignature = async () => {
if (session?.signature) {
try {
await navigator.clipboard.writeText(session.signature);
} catch (err) {
console.error('Failed to copy:', err);
}
}
};
// 解析签名
const parseSignature = (signatureHex: string): { r: string; s: string; v: number } | null => {
try {
// 签名格式: r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes
const sig = signatureHex.startsWith('0x') ? signatureHex.slice(2) : signatureHex;
if (sig.length !== 130) {
console.error('Invalid signature length:', sig.length);
return null;
}
const r = sig.slice(0, 64);
const s = sig.slice(64, 128);
const v = parseInt(sig.slice(128, 130), 16);
// EIP-1559 recovery id is 0 or 1
const recoveryId = v >= 27 ? v - 27 : v;
return { r, s, v: recoveryId };
} catch (err) {
console.error('Failed to parse signature:', err);
return null;
}
};
// 广播交易
const handleBroadcastTransaction = async () => {
if (!session?.signature || !txInfo) return;
setBroadcastStep('broadcasting');
setBroadcastError(null);
try {
// 解析签名
const parsedSig = parseSignature(session.signature);
if (!parsedSig) {
throw new Error('无法解析签名');
}
// 构建最终交易
const signedTx = finalizeTransaction(txInfo.preparedTx, parsedSig);
// 广播交易
const hash = await broadcastTransaction(signedTx);
setTxHash(hash);
setBroadcastStep('success');
// 清除 sessionStorage 中的交易信息
sessionStorage.removeItem(`tx_${sessionId}`);
} catch (err) {
setBroadcastError((err as Error).message);
setBroadcastStep('error');
}
};
// 获取区块浏览器交易 URL
const getTxExplorerUrl = (hash: string): string => {
// 从 transaction.ts 获取当前网络
const isTestnet = typeof window !== 'undefined' &&
window.localStorage?.getItem('kava_network') !== 'mainnet';
const baseUrl = isTestnet
? 'https://testnet.kavascan.com'
: 'https://kavascan.com';
return `${baseUrl}/tx/${hash}`;
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
</div>
);
}
if (error || !session) {
return (
<div className={styles.container}>
<div className={styles.error}>
<div className={styles.errorIcon}>!</div>
<h3></h3>
<p>{error || '无法获取会话信息'}</p>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>{session.walletName || '多方签名'}</h1>
<p className={styles.sessionId}> ID: {session.sessionId?.substring(0, 16)}...</p>
</div>
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
{getStatusText(session.status)}
</span>
</div>
<div className={styles.content}>
{/* 邀请码 - 等待状态时显示 */}
{session.inviteCode && session.status === 'waiting' && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
{/* QR Code */}
<div style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 'var(--spacing-md)',
}}>
<div style={{
padding: 'var(--spacing-md)',
backgroundColor: 'white',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-color)',
}}>
<QRCodeSVG
value={session.inviteCode}
size={180}
level="M"
includeMargin={false}
/>
</div>
</div>
<div className={styles.publicKeyWrapper}>
<code className={styles.publicKey}>{session.inviteCode}</code>
<button
className={styles.copyButton}
onClick={async () => {
try {
await navigator.clipboard.writeText(session.inviteCode!);
} catch (err) {
console.error('Failed to copy:', err);
}
}}
>
</button>
</div>
<p style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: 'var(--spacing-xs)', textAlign: 'center' }}>
</p>
</div>
)}
{/* 消息哈希 */}
{session.messageHash && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<code style={{
display: 'block',
padding: '8px 12px',
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{session.messageHash}
</code>
</div>
)}
{/* 进度部分 */}
{session.status === 'processing' && (
<div className={styles.progress}>
<div className={styles.progressHeader}>
<span></span>
<span>{session.currentRound} / {session.totalRounds}</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
></div>
</div>
</div>
)}
{/* 参与方列表 */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
({(session.participants || []).length} / {session.threshold?.t || 0})
</h3>
<div className={styles.participantList}>
{(session.participants || []).map((participant, index) => (
<div key={participant.partyId || index} className={styles.participant}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{index + 1}</span>
<span className={styles.participantName}>{participant.name || `参与方 ${index + 1}`}</span>
</div>
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
{getParticipantStatusIcon(participant.status)}
</span>
</div>
))}
{Array.from({ length: Math.max(0, (session.threshold?.t || 0) - (session.participants || []).length) }).map((_, index) => (
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
<div className={styles.participantInfo}>
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
<span className={styles.participantName}>...</span>
</div>
<span className={styles.participantStatus}>...</span>
</div>
))}
</div>
</div>
{/* 阈值信息 */}
{session.threshold && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.thresholdInfo}>
<span className={styles.thresholdBadge}>
{session.threshold.t}-of-{session.threshold.n}
</span>
<span className={styles.thresholdText}>
{session.threshold.t}
</span>
</div>
</div>
)}
{/* 完成状态 */}
{session.status === 'completed' && session.signature && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}></h3>
<div className={styles.publicKeyWrapper}>
<code className={styles.publicKey}>{session.signature}</code>
<button className={styles.copyButton} onClick={handleCopySignature}>
</button>
</div>
<p className={styles.successMessage}>
OK
</p>
{/* 交易广播部分 */}
{txInfo && (
<div style={{ marginTop: 'var(--spacing-lg)' }}>
<h4 style={{
fontSize: '14px',
fontWeight: 600,
color: 'var(--text-primary)',
marginBottom: 'var(--spacing-sm)',
}}>
</h4>
<div style={{
backgroundColor: 'var(--background-color)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
marginBottom: 'var(--spacing-md)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px' }}>
{txInfo.to.slice(0, 10)}...{txInfo.to.slice(-8)}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}></span>
<span style={{ fontFamily: 'monospace', fontSize: '13px', fontWeight: 600 }}>
{txInfo.amount} KAVA
</span>
</div>
</div>
{broadcastStep === 'idle' && (
<button
className={styles.primaryButton}
onClick={handleBroadcastTransaction}
style={{ width: '100%' }}
>
广
</button>
)}
{broadcastStep === 'broadcasting' && (
<div style={{
textAlign: 'center',
padding: 'var(--spacing-md)',
color: 'var(--text-secondary)',
}}>
<div className={styles.spinner} style={{ margin: '0 auto var(--spacing-sm)' }}></div>
<p>广...</p>
</div>
)}
{broadcastStep === 'success' && txHash && (
<div style={{
backgroundColor: 'rgba(40, 167, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#28a745',
marginBottom: 'var(--spacing-sm)',
}}>OK</div>
<p style={{
color: '#28a745',
fontWeight: 600,
marginBottom: 'var(--spacing-sm)',
}}>
广!
</p>
<div style={{
backgroundColor: 'white',
padding: 'var(--spacing-sm)',
borderRadius: 'var(--radius-sm)',
marginBottom: 'var(--spacing-md)',
}}>
<code style={{
fontSize: '12px',
wordBreak: 'break-all',
fontFamily: 'monospace',
}}>
{txHash}
</code>
</div>
<a
href={getTxExplorerUrl(txHash)}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: 'var(--spacing-sm) var(--spacing-md)',
backgroundColor: '#28a745',
color: 'white',
borderRadius: 'var(--radius-md)',
textDecoration: 'none',
fontSize: '14px',
}}
>
</a>
</div>
)}
{broadcastStep === 'error' && (
<div style={{
backgroundColor: 'rgba(220, 53, 69, 0.1)',
borderRadius: 'var(--radius-md)',
padding: 'var(--spacing-md)',
textAlign: 'center',
}}>
<div style={{
fontSize: '24px',
color: '#dc3545',
marginBottom: 'var(--spacing-sm)',
}}>!</div>
<p style={{ color: '#dc3545', marginBottom: 'var(--spacing-sm)' }}>
广: {broadcastError}
</p>
<button
className={styles.secondaryButton}
onClick={() => setBroadcastStep('idle')}
>
</button>
</div>
)}
</div>
)}
</div>
)}
{/* 失败状态 */}
{session.status === 'failed' && session.error && (
<div className={styles.section}>
<div className={styles.failureMessage}>
<span className={styles.failureIcon}>!</span>
<span>{session.error}</span>
</div>
</div>
)}
</div>
<div className={styles.footer}>
<button className={styles.primaryButton} onClick={() => navigate('/')}>
</button>
</div>
</div>
</div>
);
}

View File

@ -318,3 +318,28 @@
.copyButton:hover {
background-color: var(--primary-light);
}
/* QR Code Section */
.qrSection {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg);
background-color: var(--background-color);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
}
.qrCodeWrapper {
padding: var(--spacing-md);
background-color: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.qrHint {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import styles from './Create.module.css';
interface CreateSessionResult {
@ -220,6 +221,21 @@ export default function Create() {
<div className={styles.successIcon}></div>
<h3 className={styles.successTitle}></h3>
{/* QR Code for mobile scanning */}
<div className={styles.qrSection}>
<div className={styles.qrCodeWrapper}>
<QRCodeSVG
value={result.inviteCode || ''}
size={180}
level="M"
includeMargin={true}
bgColor="#ffffff"
fgColor="#000000"
/>
</div>
<p className={styles.qrHint}>使 App </p>
</div>
<div className={styles.inviteSection}>
<label className={styles.label}></label>
<div className={styles.inviteCodeWrapper}>

View File

@ -377,14 +377,20 @@
/* Balance Section */
.balanceSection {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
gap: var(--spacing-sm);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md);
}
.balanceRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.balanceLabel {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
@ -404,3 +410,241 @@
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}
/* Transfer Button */
.transferButton {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
margin-right: var(--spacing-sm);
}
.transferButton:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
/* Transfer Modal */
.transferModal {
background-color: var(--surface-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 480px;
width: 90%;
overflow: hidden;
}
.transferWalletInfo {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
text-align: center;
}
.transferWalletName {
font-size: 16px;
font-weight: 600;
color: white;
margin-bottom: var(--spacing-xs);
}
.transferWalletBalance {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
font-family: monospace;
}
.transferNetwork {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-top: var(--spacing-xs);
}
.transferForm {
width: 100%;
}
.transferInputGroup {
margin-bottom: var(--spacing-md);
}
.transferLabel {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
}
.transferInput {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
font-family: monospace;
background-color: var(--background-color);
color: var(--text-primary);
box-sizing: border-box;
}
.transferInput:focus {
outline: none;
border-color: var(--primary-color);
}
.transferInput::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.transferAmountWrapper {
display: flex;
gap: var(--spacing-sm);
}
.transferAmountWrapper .transferInput {
flex: 1;
}
.maxButton {
padding: var(--spacing-sm) var(--spacing-md);
background-color: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
border-radius: var(--radius-md);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.maxButton:hover {
background-color: var(--primary-color);
color: white;
}
/* Token Type Selector */
.tokenTypeSelector {
display: flex;
gap: var(--spacing-sm);
}
.tokenTypeButton {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tokenTypeButton:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.tokenTypeActive {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.tokenTypeActive:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
color: white;
}
.transferError {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: 13px;
margin-bottom: var(--spacing-md);
text-align: center;
}
.transferActions {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-lg);
}
.transferActions .primaryButton,
.transferActions .secondaryButton {
flex: 1;
}
.transferPreparing {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
/* Transfer Confirm */
.transferConfirm {
width: 100%;
}
.confirmTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-md);
text-align: center;
}
.confirmDetails {
background-color: var(--background-color);
border-radius: var(--radius-md);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.confirmRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-color);
}
.confirmRow:last-child {
border-bottom: none;
}
.confirmLabel {
font-size: 13px;
color: var(--text-secondary);
}
.confirmValue {
font-size: 13px;
color: var(--text-primary);
font-family: monospace;
font-weight: 500;
}
.confirmNote {
font-size: 12px;
color: var(--text-secondary);
background-color: rgba(102, 126, 234, 0.1);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
line-height: 1.5;
}
.confirmNote strong {
color: var(--primary-color);
}

View File

@ -3,6 +3,18 @@ import { useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import styles from './Home.module.css';
import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address';
import {
prepareTransaction,
isValidAddress,
isValidAmount,
getCurrentNetwork,
getCurrentRpcUrl,
getGasPrice,
fetchGreenPointsBalance,
GREEN_POINTS_TOKEN,
type PreparedTransaction,
type TokenType,
} from '../utils/transaction';
interface ShareItem {
id: string;
@ -19,12 +31,10 @@ interface ShareItem {
interface ShareWithAddress extends ShareItem {
evmAddress?: string;
kavaBalance?: string;
greenPointsBalance?: string;
balanceLoading?: boolean;
}
// Kava Testnet EVM RPC endpoint
const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
/**
* KAVA
* @param address EVM
@ -32,7 +42,8 @@ const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
*/
async function fetchKavaBalance(address: string): Promise<string> {
try {
const response = await fetch(KAVA_TESTNET_RPC, {
const rpcUrl = getCurrentRpcUrl();
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -65,6 +76,76 @@ export default function Home() {
const [selectedShare, setSelectedShare] = useState<ShareWithAddress | null>(null);
const [showQrModal, setShowQrModal] = useState(false);
// 转账相关状态
const [showTransferModal, setShowTransferModal] = useState(false);
const [transferShare, setTransferShare] = useState<ShareWithAddress | null>(null);
const [transferTo, setTransferTo] = useState('');
const [transferAmount, setTransferAmount] = useState('');
const [transferPassword, setTransferPassword] = useState('');
const [transferTokenType, setTransferTokenType] = useState<TokenType>('KAVA');
const [transferStep, setTransferStep] = useState<'input' | 'confirm' | 'preparing' | 'error'>('input');
const [transferError, setTransferError] = useState<string | null>(null);
const [preparedTx, setPreparedTx] = useState<PreparedTransaction | null>(null);
const [isCalculatingMax, setIsCalculatingMax] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// 计算扣除 Gas 费后的最大可转账金额
const calculateMaxAmount = async () => {
if (!transferShare?.evmAddress) return;
setIsCalculatingMax(true);
try {
if (transferTokenType === 'GREEN_POINTS') {
// For token transfers, use the full token balance (gas is paid in KAVA)
const balance = transferShare.greenPointsBalance || '0';
setTransferAmount(balance);
setTransferError(null);
} else {
// For KAVA transfers, deduct gas fee
const balance = parseFloat(transferShare.kavaBalance || '0');
if (balance <= 0) {
setTransferAmount('0');
return;
}
// 获取当前 gas 价格
const { maxFeePerGas } = await getGasPrice();
// 简单转账的 gas 限制是 21000
const gasLimit = BigInt(21000);
const gasFee = maxFeePerGas * gasLimit;
// 转换为 KAVA (18 位小数)
const gasFeeKava = Number(gasFee) / 1e18;
// 计算最大可转账金额 = 余额 - Gas费
const maxAmount = balance - gasFeeKava;
if (maxAmount <= 0) {
setTransferError('余额不足以支付 Gas 费');
setTransferAmount('0');
} else {
// 保留 6 位小数,向下取整避免精度问题
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
setTransferError(null);
}
}
} catch (error) {
console.error('Failed to calculate max amount:', error);
if (transferTokenType === 'GREEN_POINTS') {
setTransferAmount(transferShare.greenPointsBalance || '0');
} else {
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
const balance = parseFloat(transferShare.kavaBalance || '0');
const maxAmount = Math.max(0, balance - defaultGasFee);
const formattedMax = Math.floor(maxAmount * 1000000) / 1000000;
setTransferAmount(formattedMax.toString());
}
} finally {
setIsCalculatingMax(false);
}
};
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
const sharesWithAddresses: ShareWithAddress[] = [];
for (const share of shareList) {
@ -79,13 +160,17 @@ export default function Home() {
return sharesWithAddresses;
}, []);
// 单独获取所有钱包的余额
// 单独获取所有钱包的余额 (KAVA 和 绿积分)
const fetchAllBalances = useCallback(async (sharesWithAddrs: ShareWithAddress[]) => {
const updatedShares = await Promise.all(
sharesWithAddrs.map(async (share) => {
if (share.evmAddress) {
const kavaBalance = await fetchKavaBalance(share.evmAddress);
return { ...share, kavaBalance, balanceLoading: false };
// Fetch both balances in parallel
const [kavaBalance, greenPointsBalance] = await Promise.all([
fetchKavaBalance(share.evmAddress),
fetchGreenPointsBalance(share.evmAddress),
]);
return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false };
}
return { ...share, balanceLoading: false };
})
@ -185,9 +270,137 @@ export default function Home() {
setShowQrModal(true);
};
const handleCopyAddress = (address: string) => {
navigator.clipboard.writeText(address);
alert('地址已复制到剪贴板');
const handleCopyAddress = async (address: string) => {
try {
await navigator.clipboard.writeText(address);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy address:', err);
}
};
// 打开转账模态框
const handleOpenTransfer = (share: ShareWithAddress) => {
setTransferShare(share);
setTransferTo('');
setTransferAmount('');
setTransferPassword('');
setTransferTokenType('KAVA');
setTransferStep('input');
setTransferError(null);
setPreparedTx(null);
setShowTransferModal(true);
};
// 关闭转账模态框
const handleCloseTransfer = () => {
setShowTransferModal(false);
setTransferShare(null);
setPreparedTx(null);
};
// 验证转账输入
const validateTransferInput = (): string | null => {
if (!transferTo.trim()) {
return '请输入收款地址';
}
if (!isValidAddress(transferTo.trim())) {
return '收款地址格式无效';
}
if (!transferAmount.trim()) {
return '请输入转账金额';
}
if (!isValidAmount(transferAmount.trim())) {
return '转账金额无效';
}
const amount = parseFloat(transferAmount);
const balance = parseFloat(
transferTokenType === 'GREEN_POINTS'
? (transferShare?.greenPointsBalance || '0')
: (transferShare?.kavaBalance || '0')
);
if (amount > balance) {
return '余额不足';
}
return null;
};
// 准备交易
const handlePrepareTransaction = async () => {
const error = validateTransferInput();
if (error) {
setTransferError(error);
return;
}
setTransferStep('preparing');
setTransferError(null);
try {
const prepared = await prepareTransaction({
from: transferShare!.evmAddress!,
to: transferTo.trim().toLowerCase(),
value: transferAmount.trim(),
tokenType: transferTokenType,
});
setPreparedTx(prepared);
setTransferStep('confirm');
} catch (err) {
setTransferError('准备交易失败: ' + (err as Error).message);
setTransferStep('error');
}
};
// 发起签名会话
const handleInitiateCoSign = async () => {
if (!preparedTx || !transferShare) return;
try {
// 调用 co-sign API 创建签名会话
const result = await window.electronAPI.cosign.createSession({
shareId: transferShare.id,
sharePassword: transferPassword,
messageHash: preparedTx.signHash,
initiatorName: '发起者',
});
if (result.success && result.sessionId) {
// 保存交易信息到 sessionStorage以便签名完成后使用
// 注意: BigInt 无法直接 JSON 序列化,需要转换为字符串
const txToStore = {
preparedTx: {
...preparedTx,
gasLimit: preparedTx.gasLimit.toString(),
gasPrice: preparedTx.gasPrice.toString(),
value: preparedTx.value.toString(),
},
to: transferTo,
amount: transferAmount,
from: transferShare.evmAddress,
walletName: transferShare.walletName,
tokenType: transferTokenType,
};
sessionStorage.setItem(`tx_${result.sessionId}`, JSON.stringify(txToStore));
// 关闭模态框并跳转到签名会话页面
handleCloseTransfer();
navigate(`/cosign/session/${result.sessionId}`);
} else {
setTransferError(result.error || '创建签名会话失败');
setTransferStep('error');
}
} catch (err) {
setTransferError('创建签名会话失败: ' + (err as Error).message);
setTransferStep('error');
}
};
// 格式化 gas 费用显示
const formatGasFee = (gasLimit: bigint, gasPrice: bigint): string => {
const maxFee = gasLimit * gasPrice;
const feeKava = Number(maxFee) / 1e18;
return feeKava.toFixed(8).replace(/\.?0+$/, '');
};
const formatDate = (dateStr: string) => {
@ -273,17 +486,29 @@ export default function Home() {
</div>
)}
{/* KAVA 余额显示 */}
{/* 余额显示 - KAVA 和 绿积分 */}
{share.evmAddress && (
<div className={styles.balanceSection}>
<span className={styles.balanceLabel}>KAVA </span>
<span className={styles.balanceValue}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.kavaBalance || '0'} KAVA</>
)}
</span>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel}>KAVA</span>
<span className={styles.balanceValue}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.kavaBalance || '0'}</>
)}
</span>
</div>
<div className={styles.balanceRow}>
<span className={styles.balanceLabel} style={{ color: '#4CAF50' }}>{GREEN_POINTS_TOKEN.name}</span>
<span className={styles.balanceValue} style={{ color: '#4CAF50' }}>
{share.balanceLoading ? (
<span className={styles.balanceLoading}>...</span>
) : (
<>{share.greenPointsBalance || '0'}</>
)}
</span>
</div>
</div>
)}
@ -309,6 +534,14 @@ export default function Home() {
)}
</div>
<div className={styles.cardFooter}>
{share.evmAddress && (
<button
className={`${styles.actionButton} ${styles.transferButton}`}
onClick={() => handleOpenTransfer(share)}
>
</button>
)}
<button
className={styles.actionButton}
onClick={() => handleExport(share.id)}
@ -327,6 +560,203 @@ export default function Home() {
</div>
)}
{/* 转账模态框 */}
{showTransferModal && transferShare && (
<div className={styles.modalOverlay} onClick={handleCloseTransfer}>
<div className={styles.transferModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}></h2>
<button
className={styles.modalClose}
onClick={handleCloseTransfer}
>
×
</button>
</div>
<div className={styles.modalBody}>
{/* 钱包信息 */}
<div className={styles.transferWalletInfo}>
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
<div className={styles.transferWalletBalance}>
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}
</div>
<div className={styles.transferNetwork}>
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
</div>
</div>
{transferStep === 'input' && (
<div className={styles.transferForm}>
{/* 代币类型选择 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
<div className={styles.tokenTypeSelector}>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'KAVA' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('KAVA'); setTransferAmount(''); }}
>
KAVA
</button>
<button
className={`${styles.tokenTypeButton} ${transferTokenType === 'GREEN_POINTS' ? styles.tokenTypeActive : ''}`}
onClick={() => { setTransferTokenType('GREEN_POINTS'); setTransferAmount(''); }}
style={transferTokenType === 'GREEN_POINTS' ? { backgroundColor: '#4CAF50', borderColor: '#4CAF50' } : {}}
>
{GREEN_POINTS_TOKEN.name}
</button>
</div>
</div>
{/* 收款地址 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}></label>
<input
type="text"
value={transferTo}
onChange={(e) => setTransferTo(e.target.value)}
placeholder="0x..."
className={styles.transferInput}
/>
</div>
{/* 转账金额 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}>
({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'})
</label>
<div className={styles.transferAmountWrapper}>
<input
type="text"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
placeholder="0.0"
className={styles.transferInput}
/>
<button
className={styles.maxButton}
onClick={calculateMaxAmount}
disabled={isCalculatingMax}
>
{isCalculatingMax ? '...' : 'MAX'}
</button>
</div>
</div>
{/* 钱包密码 */}
<div className={styles.transferInputGroup}>
<label className={styles.transferLabel}> ()</label>
<input
type="password"
value={transferPassword}
onChange={(e) => setTransferPassword(e.target.value)}
placeholder="如果设置了密码,请输入"
className={styles.transferInput}
/>
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={handleCloseTransfer}
>
</button>
<button
className={styles.primaryButton}
onClick={handlePrepareTransaction}
>
</button>
</div>
</div>
)}
{transferStep === 'preparing' && (
<div className={styles.transferPreparing}>
<div className={styles.spinner}></div>
<p>...</p>
</div>
)}
{transferStep === 'confirm' && preparedTx && (
<div className={styles.transferConfirm}>
<h3 className={styles.confirmTitle}></h3>
<div className={styles.confirmDetails}>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue}>{formatAddress(transferTo, 8, 6)}</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}></span>
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}> Gas </span>
<span className={styles.confirmValue}>
~{formatGasFee(preparedTx.gasLimit, preparedTx.gasPrice)} KAVA
</span>
</div>
<div className={styles.confirmRow}>
<span className={styles.confirmLabel}>Nonce</span>
<span className={styles.confirmValue}>{preparedTx.nonce}</span>
</div>
</div>
<div className={styles.confirmNote}>
<strong>:</strong> {transferShare.threshold.t}-of-{transferShare.threshold.n}
{transferShare.threshold.t}
</div>
{transferError && (
<div className={styles.transferError}>{transferError}</div>
)}
<div className={styles.transferActions}>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
<button
className={styles.primaryButton}
onClick={handleInitiateCoSign}
>
</button>
</div>
</div>
)}
{transferStep === 'error' && (
<div className={styles.transferError}>
<p>{transferError}</p>
<button
className={styles.secondaryButton}
onClick={() => setTransferStep('input')}
>
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* 二维码弹窗 */}
{showQrModal && selectedShare && (
<div className={styles.modalOverlay} onClick={() => setShowQrModal(false)}>
@ -360,10 +790,10 @@ export default function Home() {
className={styles.primaryButton}
onClick={() => handleCopyAddress(selectedShare.evmAddress || '')}
>
{copySuccess ? '✓ 已复制' : '复制地址'}
</button>
<a
href={getKavaExplorerUrl(selectedShare.evmAddress || '', true)}
href={getKavaExplorerUrl(selectedShare.evmAddress || '', getCurrentNetwork() === 'testnet')}
target="_blank"
rel="noopener noreferrer"
className={styles.secondaryButton}

View File

@ -70,6 +70,10 @@ export default function Session() {
status: 'completed',
publicKey: event.publicKey,
} : null);
// Auto-navigate to home after a short delay to show completion status
setTimeout(() => {
navigate('/');
}, 2000);
} else if (event.type === 'failed') {
setSession(prev => prev ? {
...prev,

View File

@ -18,7 +18,7 @@ export default function Settings() {
autoBackup: false,
backupPath: '',
});
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('testnet');
const [kavaNetwork, setKavaNetwork] = useState<'mainnet' | 'testnet'>('mainnet');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@ -185,6 +185,10 @@ export default function Settings() {
const result = await window.electronAPI.kava.switchNetwork('testnet');
if (result.success) {
setKavaNetwork('testnet');
// 同步到 localStorage 供前端工具函数使用
localStorage.setItem('kava_network', 'testnet');
// 触发自定义事件通知 Layout 更新网络状态显示
window.dispatchEvent(new CustomEvent('kava-network-change', { detail: { network: 'testnet' } }));
setMessage({ type: 'success', text: '已切换到 Kava 测试网' });
}
}}
@ -197,6 +201,10 @@ export default function Settings() {
const result = await window.electronAPI.kava.switchNetwork('mainnet');
if (result.success) {
setKavaNetwork('mainnet');
// 同步到 localStorage 供前端工具函数使用
localStorage.setItem('kava_network', 'mainnet');
// 触发自定义事件通知 Layout 更新网络状态显示
window.dispatchEvent(new CustomEvent('kava-network-change', { detail: { network: 'mainnet' } }));
setMessage({ type: 'success', text: '已切换到 Kava 主网' });
}
}}

View File

@ -115,10 +115,7 @@ export default function Sign() {
setError('请选择要使用的钱包份额');
return;
}
if (!password) {
setError('请输入解锁密码');
return;
}
// 密码是可选的,如果创建时没有设置密码,这里也不需要输入
setIsLoading(true);
setError(null);
@ -351,7 +348,7 @@ export default function Sign() {
<button
className={styles.primaryButton}
onClick={handleJoinSigning}
disabled={isLoading || !password}
disabled={isLoading}
>
{isLoading ? '加入中...' : '参与签名'}
</button>

View File

@ -410,6 +410,94 @@ interface KavaHealthCheckResult {
// Electron API 接口
// ===========================================================================
// ===========================================================================
// Co-Sign 相关类型
// ===========================================================================
interface CoSignSessionInfo {
sessionId: string;
keygenSessionId: string;
walletName: string;
messageHash: string;
threshold: { t: number; n: number };
status: string;
currentParticipants: number;
parties?: Array<{ party_id: string; party_index: number }>;
}
interface CreateCoSignSessionParams {
shareId: string;
sharePassword: string;
messageHash: string;
initiatorName?: string;
}
interface CreateCoSignSessionResult {
success: boolean;
sessionId?: string;
inviteCode?: string;
walletName?: string;
expiresAt?: string;
error?: string;
}
interface ValidateCoSignInviteCodeResult {
success: boolean;
sessionInfo?: CoSignSessionInfo;
joinToken?: string;
error?: string;
}
interface JoinCoSignSessionParams {
sessionId: string;
shareId: string;
sharePassword: string;
joinToken: string;
walletName?: string;
messageHash: string;
threshold: { t: number; n: number };
parties?: Array<{ party_id: string; party_index: number }>;
}
interface JoinCoSignSessionResult {
success: boolean;
data?: unknown;
error?: string;
}
interface GetCoSignSessionStatusResult {
success: boolean;
session?: {
sessionId: string;
status: string;
joinedCount: number;
threshold: { t: number; n: number };
participants: Array<{
partyId: string;
partyIndex: number;
name: string;
status: string;
}>;
messageHash: string;
walletName: string;
};
error?: string;
}
interface CoSignSessionEvent {
type: 'participant_joined' | 'status_changed' | 'all_joined' | 'progress' | 'completed' | 'failed' | 'sign_start_timeout';
participant?: {
partyId: string;
name: string;
status: string;
};
status?: string;
round?: number;
totalRounds?: number;
signature?: string;
error?: string;
}
interface ElectronAPI {
// Account 服务相关 (HTTP API)
account: {
@ -521,6 +609,15 @@ interface ElectronAPI {
unsubscribeLogs: () => void;
log: (level: string, source: string, message: string) => void;
};
// Co-Sign 相关 (多方协作签名)
cosign: {
createSession: (params: CreateCoSignSessionParams) => Promise<CreateCoSignSessionResult>;
validateInviteCode: (code: string) => Promise<ValidateCoSignInviteCodeResult>;
joinSession: (params: JoinCoSignSessionParams) => Promise<JoinCoSignSessionResult>;
getSessionStatus: (sessionId: string) => Promise<GetCoSignSessionStatusResult>;
subscribeSessionEvents: (sessionId: string, callback: (event: CoSignSessionEvent) => void) => () => void;
};
}
declare global {

View File

@ -0,0 +1,685 @@
/**
* Kava EVM
* EIP-1559
* KAVA ERC-20 (绿)
*/
// Kava EVM Chain IDs
export const KAVA_CHAIN_ID = {
mainnet: 2222,
testnet: 2221,
};
// RPC URLs
export const KAVA_RPC_URL = {
mainnet: 'https://evm.kava.io',
testnet: 'https://evm.testnet.kava.io',
};
// Token types
export type TokenType = 'KAVA' | 'GREEN_POINTS';
// Green Points (绿积分) Token Configuration
export const GREEN_POINTS_TOKEN = {
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
name: '绿积分',
symbol: 'dUSDT',
decimals: 6,
// ERC-20 function selectors
balanceOfSelector: '0x70a08231',
transferSelector: '0xa9059cbb',
};
// 当前网络配置 (从 localStorage 读取或使用默认值)
export function getCurrentNetwork(): 'mainnet' | 'testnet' {
if (typeof window !== 'undefined' && window.localStorage) {
const stored = localStorage.getItem('kava_network');
if (stored === 'mainnet' || stored === 'testnet') {
return stored;
}
}
return 'mainnet'; // 默认主网
}
export function getCurrentChainId(): number {
return KAVA_CHAIN_ID[getCurrentNetwork()];
}
export function getCurrentRpcUrl(): string {
return KAVA_RPC_URL[getCurrentNetwork()];
}
/**
*
*/
export interface TransactionParams {
to: string; // 收款地址
value: string; // 转账金额 (KAVA 或代币单位, 字符串以支持大数)
from: string; // 发送地址
nonce?: number; // 可选,自动获取
gasLimit?: bigint; // 可选,默认 21000
maxFeePerGas?: bigint; // 可选,自动获取
maxPriorityFeePerGas?: bigint; // 可选,自动获取
data?: string; // 可选,合约调用数据
tokenType?: TokenType; // 可选,默认 KAVA
}
/**
* ()
* 使 Legacy (Type 0) KAVA EIP-1559
*/
export interface PreparedTransaction {
chainId: number;
nonce: number;
gasPrice: bigint; // Legacy 使用 gasPrice 而不是 maxFeePerGas
gasLimit: bigint;
to: string;
value: bigint;
data: string;
// 用于签名的哈希
signHash: string;
// 原始交易数据 (用于签名后广播)
rawTxForSigning: string;
// 为了兼容性保留这些字段
maxPriorityFeePerGas?: bigint;
maxFeePerGas?: bigint;
accessList?: unknown[];
}
/**
* ( 0x )
*/
function toHex(value: number | bigint): string {
const hex = value.toString(16);
return '0x' + hex;
}
/**
* Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
if (cleanHex.length === 0) return new Uint8Array(0);
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
bytes[i / 2] = parseInt(cleanHex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Uint8Array
*/
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* RLP
* 参考: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/
*/
function rlpEncode(input: unknown): Uint8Array {
if (typeof input === 'string') {
// 处理十六进制字符串
if (input.startsWith('0x')) {
const bytes = hexToBytes(input);
return rlpEncodeBytes(bytes);
}
// 普通字符串
const bytes = new TextEncoder().encode(input);
return rlpEncodeBytes(bytes);
}
if (typeof input === 'number' || typeof input === 'bigint') {
if (input === 0 || input === 0n) {
return rlpEncodeBytes(new Uint8Array(0));
}
// 转换为最小字节表示
let hex = input.toString(16);
if (hex.length % 2 !== 0) hex = '0' + hex;
return rlpEncodeBytes(hexToBytes(hex));
}
if (input instanceof Uint8Array) {
return rlpEncodeBytes(input);
}
if (Array.isArray(input)) {
// 编码列表
const encoded = input.map(item => rlpEncode(item));
const totalLength = encoded.reduce((acc, item) => acc + item.length, 0);
if (totalLength <= 55) {
const result = new Uint8Array(1 + totalLength);
result[0] = 0xc0 + totalLength;
let offset = 1;
for (const item of encoded) {
result.set(item, offset);
offset += item.length;
}
return result;
} else {
const lengthBytes = encodeLength(totalLength);
const result = new Uint8Array(1 + lengthBytes.length + totalLength);
result[0] = 0xf7 + lengthBytes.length;
result.set(lengthBytes, 1);
let offset = 1 + lengthBytes.length;
for (const item of encoded) {
result.set(item, offset);
offset += item.length;
}
return result;
}
}
throw new Error('Unsupported RLP input type');
}
function rlpEncodeBytes(bytes: Uint8Array): Uint8Array {
if (bytes.length === 1 && bytes[0] < 0x80) {
// 单字节值直接返回
return bytes;
}
if (bytes.length <= 55) {
const result = new Uint8Array(1 + bytes.length);
result[0] = 0x80 + bytes.length;
result.set(bytes, 1);
return result;
}
const lengthBytes = encodeLength(bytes.length);
const result = new Uint8Array(1 + lengthBytes.length + bytes.length);
result[0] = 0xb7 + lengthBytes.length;
result.set(lengthBytes, 1);
result.set(bytes, 1 + lengthBytes.length);
return result;
}
function encodeLength(length: number): Uint8Array {
let hex = length.toString(16);
if (hex.length % 2 !== 0) hex = '0' + hex;
return hexToBytes(hex);
}
/**
* Keccak-256 ( address.ts )
*/
async function keccak256(data: Uint8Array): Promise<Uint8Array> {
const RC = [
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an,
0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an,
0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n,
0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n,
0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
];
const ROTC = [
[0, 36, 3, 41, 18],
[1, 44, 10, 45, 2],
[62, 6, 43, 15, 61],
[28, 55, 25, 21, 56],
[27, 20, 39, 8, 14],
];
function rotl64(x: bigint, n: number): bigint {
return ((x << BigInt(n)) | (x >> BigInt(64 - n))) & 0xffffffffffffffffn;
}
function keccakF(state: bigint[][]): void {
for (let round = 0; round < 24; round++) {
const C: bigint[] = [];
for (let x = 0; x < 5; x++) {
C[x] = state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4];
}
const D: bigint[] = [];
for (let x = 0; x < 5; x++) {
D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1);
}
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
state[x][y] ^= D[x];
}
}
const B: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
B[y][(2 * x + 3 * y) % 5] = rotl64(state[x][y], ROTC[x][y]);
}
}
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
state[x][y] = B[x][y] ^ (~B[(x + 1) % 5][y] & B[(x + 2) % 5][y]);
}
}
state[0][0] ^= RC[round];
}
}
const rate = 136;
const outputLen = 32;
const state: bigint[][] = Array(5).fill(null).map(() => Array(5).fill(0n));
const padded = new Uint8Array(Math.ceil((data.length + 1) / rate) * rate);
padded.set(data);
padded[data.length] = 0x01;
padded[padded.length - 1] |= 0x80;
for (let i = 0; i < padded.length; i += rate) {
for (let j = 0; j < rate && i + j < padded.length; j += 8) {
const x = Math.floor(j / 8) % 5;
const y = Math.floor(Math.floor(j / 8) / 5);
let lane = 0n;
for (let k = 0; k < 8 && i + j + k < padded.length; k++) {
lane |= BigInt(padded[i + j + k]) << BigInt(k * 8);
}
state[x][y] ^= lane;
}
keccakF(state);
}
const output = new Uint8Array(outputLen);
for (let i = 0; i < outputLen; i += 8) {
const x = Math.floor(i / 8) % 5;
const y = Math.floor(Math.floor(i / 8) / 5);
const lane = state[x][y];
for (let k = 0; k < 8 && i + k < outputLen; k++) {
output[i + k] = Number((lane >> BigInt(k * 8)) & 0xffn);
}
}
return output;
}
/**
* KAVA wei (18 )
*/
export function kavaToWei(kava: string): bigint {
const parts = kava.split('.');
const whole = BigInt(parts[0] || '0');
let fraction = parts[1] || '';
// 补齐或截断到 18 位
if (fraction.length > 18) {
fraction = fraction.substring(0, 18);
} else {
fraction = fraction.padEnd(18, '0');
}
return whole * BigInt(10 ** 18) + BigInt(fraction);
}
/**
* wei KAVA
*/
export function weiToKava(wei: bigint): string {
const weiStr = wei.toString().padStart(19, '0');
const whole = weiStr.slice(0, -18) || '0';
const fraction = weiStr.slice(-18).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* 绿 (6 decimals)
*/
export function greenPointsToRaw(amount: string): bigint {
const parts = amount.split('.');
const whole = BigInt(parts[0] || '0');
let fraction = parts[1] || '';
// 补齐或截断到 6 位
if (fraction.length > 6) {
fraction = fraction.substring(0, 6);
} else {
fraction = fraction.padEnd(6, '0');
}
return whole * BigInt(10 ** 6) + BigInt(fraction);
}
/**
* 绿
*/
export function rawToGreenPoints(raw: bigint): string {
const rawStr = raw.toString().padStart(7, '0');
const whole = rawStr.slice(0, -6) || '0';
const fraction = rawStr.slice(-6).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
}
/**
* 绿 (ERC-20)
*/
export async function fetchGreenPointsBalance(address: string): Promise<string> {
try {
const rpcUrl = getCurrentRpcUrl();
// Encode balanceOf(address) call data
// Function selector: 0x70a08231
// Address parameter: padded to 32 bytes
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0');
const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress;
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_call',
params: [
{
to: GREEN_POINTS_TOKEN.contractAddress,
data: callData,
},
'latest',
],
id: 1,
}),
});
const data = await response.json();
if (data.result && data.result !== '0x') {
const balanceRaw = BigInt(data.result);
return rawToGreenPoints(balanceRaw);
}
return '0';
} catch (error) {
console.error('Failed to fetch Green Points balance:', error);
return '0';
}
}
/**
* Encode ERC-20 transfer function call
*/
function encodeErc20Transfer(to: string, amount: bigint): string {
// Function selector: transfer(address,uint256) = 0xa9059cbb
const selector = GREEN_POINTS_TOKEN.transferSelector;
// Encode recipient address (padded to 32 bytes)
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
// Encode amount (padded to 32 bytes)
const amountHex = amount.toString(16).padStart(64, '0');
return selector + paddedAddress + amountHex;
}
/**
* RPC nonce
*/
export async function getNonce(address: string): Promise<number> {
const rpcUrl = getCurrentRpcUrl();
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getTransactionCount',
params: [address, 'pending'],
id: 1,
}),
});
const data = await response.json();
if (data.error) {
throw new Error(`获取 nonce 失败: ${data.error.message}`);
}
return parseInt(data.result, 16);
}
/**
* RPC gas
* 使 eth_gasPrice Legacy gasPrice
*/
export async function getGasPrice(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> {
const rpcUrl = getCurrentRpcUrl();
// 使用 eth_gasPrice 获取建议的 gas 价格
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_gasPrice',
params: [],
id: 1,
}),
});
const data = await response.json();
let gasPrice: bigint;
if (data.result) {
gasPrice = BigInt(data.result);
// 增加 10% 以确保交易能被打包
gasPrice = gasPrice * 110n / 100n;
} else {
// 默认 1 gwei
gasPrice = BigInt(1000000000);
}
// 为了兼容性,同时返回 maxFeePerGas (实际上用作 gasPrice)
return { maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice };
}
/**
* gas
*/
export async function estimateGas(params: { from: string; to: string; value: string; data?: string; tokenType?: TokenType }): Promise<bigint> {
const rpcUrl = getCurrentRpcUrl();
const tokenType = params.tokenType || 'KAVA';
// For token transfers, we need different params
let txParams: { from: string; to: string; value: string; data?: string };
if (tokenType === 'GREEN_POINTS') {
// ERC-20 transfer: to is contract, value is 0, data is transfer call
const tokenAmount = greenPointsToRaw(params.value);
const transferData = encodeErc20Transfer(params.to, tokenAmount);
txParams = {
from: params.from,
to: GREEN_POINTS_TOKEN.contractAddress,
value: '0x0',
data: transferData,
};
} else {
// Native KAVA transfer
txParams = {
from: params.from,
to: params.to,
value: toHex(kavaToWei(params.value)),
data: params.data || '0x',
};
}
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_estimateGas',
params: [txParams],
id: 1,
}),
});
const data = await response.json();
if (data.error) {
// 如果估算失败,使用默认值
console.warn('Gas 估算失败,使用默认值:', data.error);
return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
}
return BigInt(data.result);
}
/**
* Legacy (Type 0)
* KAVA EIP-1559使 Legacy
*
* KAVA ERC-20 (绿)
*/
export async function prepareTransaction(params: TransactionParams): Promise<PreparedTransaction> {
const chainId = getCurrentChainId();
const tokenType = params.tokenType || 'KAVA';
// 获取或使用提供的参数
const nonce = params.nonce ?? await getNonce(params.from);
const gasPriceData = await getGasPrice();
const gasPrice = params.maxFeePerGas ?? gasPriceData.maxFeePerGas;
const gasLimit = params.gasLimit ?? await estimateGas({
from: params.from,
to: params.to,
value: params.value,
data: params.data,
tokenType,
});
// Prepare transaction based on token type
let toAddress: string;
let value: bigint;
let data: string;
if (tokenType === 'GREEN_POINTS') {
// ERC-20 token transfer
// To address is the contract, value is 0
// Data is transfer(recipient, amount) encoded
const tokenAmount = greenPointsToRaw(params.value);
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase();
value = BigInt(0);
data = encodeErc20Transfer(params.to, tokenAmount);
} else {
// Native KAVA transfer
toAddress = params.to.toLowerCase();
value = kavaToWei(params.value);
data = params.data || '0x';
}
// Legacy 交易字段顺序 (EIP-155)
// [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]
// 最后三个字段用于 EIP-155 replay protection
const txFields = [
nonce,
gasPrice,
gasLimit,
toAddress,
value,
data,
chainId,
0, // EIP-155: v placeholder
0, // EIP-155: r placeholder
];
// RLP 编码交易字段
const encodedTx = rlpEncode(txFields);
// 计算签名哈希 (不需要类型前缀Legacy 交易直接哈希)
const signHashBytes = await keccak256(encodedTx);
const signHash = bytesToHex(signHashBytes);
return {
chainId,
nonce,
gasPrice,
gasLimit,
to: toAddress,
value,
data,
signHash,
rawTxForSigning: bytesToHex(encodedTx),
};
}
/**
* 使广
* 使 Legacy (Type 0)
*/
export function finalizeTransaction(
preparedTx: PreparedTransaction,
signature: { r: string; s: string; v: number }
): string {
// EIP-155: v = chainId * 2 + 35 + recovery_id
// recovery_id 是 0 或 1
const v = preparedTx.chainId * 2 + 35 + signature.v;
// Legacy 签名后的交易字段
// [nonce, gasPrice, gasLimit, to, value, data, v, r, s]
const signedTxFields = [
preparedTx.nonce,
preparedTx.gasPrice,
preparedTx.gasLimit,
preparedTx.to,
preparedTx.value,
preparedTx.data,
v,
'0x' + signature.r,
'0x' + signature.s,
];
const encodedSignedTx = rlpEncode(signedTxFields);
// Legacy 交易不需要类型前缀
return '0x' + bytesToHex(encodedSignedTx);
}
/**
* 广
*/
export async function broadcastTransaction(signedTx: string): Promise<string> {
const rpcUrl = getCurrentRpcUrl();
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_sendRawTransaction',
params: [signedTx],
id: 1,
}),
});
const data = await response.json();
if (data.error) {
throw new Error(`广播交易失败: ${data.error.message}`);
}
return data.result; // 返回交易哈希
}
/**
* ()
*/
export async function getTransactionReceipt(txHash: string): Promise<unknown | null> {
const rpcUrl = getCurrentRpcUrl();
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getTransactionReceipt',
params: [txHash],
id: 1,
}),
});
const data = await response.json();
return data.result;
}
/**
*
*/
export function isValidAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
/**
*
*/
export function isValidAmount(amount: string): boolean {
if (!amount || amount.trim() === '') return false;
const num = parseFloat(amount);
return !isNaN(num) && num > 0;
}

View File

@ -6,7 +6,10 @@ require github.com/bnb-chain/tss-lib/v2 v2.0.2
require (
github.com/agl/ed25519 v0.0.0-20200225211852-fd4d107ace12 // indirect
github.com/btcsuite/btcd v0.23.4 // indirect
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 // indirect
github.com/btcsuite/btcutil v1.0.2 // indirect
github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@ -20,6 +23,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/sys v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

View File

@ -7,6 +7,7 @@ github.com/bnb-chain/tss-lib/v2 v2.0.2 h1:dL2GJFCSYsYQ0bHkGll+hNM2JWsC1rxDmJJJQE
github.com/bnb-chain/tss-lib/v2 v2.0.2/go.mod h1:s4LRfEqj89DhfNb+oraW0dURt5LtOHWXb9Gtkghn0L8=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.4 h1:IzV6qqkfwbItOS/sg/aDfPDsjPP8twrCOE2R93hxMlQ=
github.com/btcsuite/btcd v0.23.4/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
@ -15,9 +16,11 @@ github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
@ -144,6 +147,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=

View File

@ -0,0 +1,674 @@
// integration_test.go - Integration test for the complete co-sign flow
// Tests: session creation, joining, waiting, events, and signing
package main
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"os"
"os/exec"
"sync"
"testing"
"time"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/tss"
)
const (
// Backend service URLs (from docker-compose.windows.yml)
accountServiceURL = "http://localhost:4000"
grpcRouterAddr = "localhost:50051"
)
// API Response types for co-managed keygen
type CreateCoManagedSessionRequest struct {
WalletName string `json:"wallet_name"`
ThresholdT int `json:"threshold_t"`
ThresholdN int `json:"threshold_n"`
InitiatorPartyID string `json:"initiator_party_id"`
InitiatorName string `json:"initiator_name,omitempty"`
PersistentCount int `json:"persistent_count"`
}
type CreateCoManagedSessionResponse struct {
SessionID string `json:"session_id"`
InviteCode string `json:"invite_code"`
WalletName string `json:"wallet_name"`
ThresholdT int `json:"threshold_t"`
ThresholdN int `json:"threshold_n"`
ExpiresAt int64 `json:"expires_at"`
JoinToken string `json:"join_token"`
PartyID string `json:"party_id"`
PartyIndex int `json:"party_index"`
}
// API Response types for co-managed sign
type CreateSignSessionRequest struct {
KeygenSessionID string `json:"keygen_session_id"`
WalletName string `json:"wallet_name"`
MessageHash string `json:"message_hash"`
Parties []SignPartyInfo `json:"parties"`
ThresholdT int `json:"threshold_t"`
InitiatorName string `json:"initiator_name,omitempty"`
}
type CreateSignSessionResponse struct {
SessionID string `json:"session_id"`
InviteCode string `json:"invite_code"`
KeygenSessionID string `json:"keygen_session_id"`
MessageHash string `json:"message_hash"`
ThresholdT int `json:"threshold_t"`
ExpiresAt int64 `json:"expires_at"`
JoinToken string `json:"join_token"`
}
type GetSignSessionResponse struct {
SessionID string `json:"session_id"`
KeygenSessionID string `json:"keygen_session_id"`
WalletName string `json:"wallet_name"`
MessageHash string `json:"message_hash"`
ThresholdT int `json:"threshold_t"`
Status string `json:"status"`
InviteCode string `json:"invite_code"`
ExpiresAt int64 `json:"expires_at"`
Parties []SignPartyInfo `json:"parties"`
JoinedCount int `json:"joined_count"`
JoinToken string `json:"join_token,omitempty"`
}
type SignPartyInfo struct {
PartyID string `json:"party_id"`
PartyIndex int `json:"party_index"`
}
// TestAccountServiceHealth tests if account service is available
func TestAccountServiceHealth(t *testing.T) {
resp, err := http.Get(accountServiceURL + "/health")
if err != nil {
t.Fatalf("Failed to connect to account service: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("Account service unhealthy, status: %d", resp.StatusCode)
}
t.Log("Account service is healthy")
}
// TestCreateSignSession tests creating a new sign session
func TestCreateSignSession(t *testing.T) {
// This requires an existing keygen session ID
// For testing, we'll use a mock one
keygenSessionID := "test-keygen-session-" + fmt.Sprintf("%d", time.Now().UnixNano())
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
reqBody := CreateSignSessionRequest{
KeygenSessionID: keygenSessionID,
MessageHash: messageHash,
InitiatorName: "test-initiator",
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sign",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create sign session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Logf("Note: This test requires an existing keygen session in the database")
t.Skipf("Sign session creation returned status %d (expected existing keygen session)", resp.StatusCode)
}
var result CreateSignSessionResponse
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
t.Logf("Session created: ID=%s, InviteCode=%s", result.SessionID, result.InviteCode)
}
// TestGetSessionByInviteCode tests retrieving session info by invite code
func TestGetSessionByInviteCode(t *testing.T) {
// This would need a valid invite code from a real session
inviteCode := "TEST123"
resp, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + inviteCode)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Get session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode == 404 {
t.Skip("No session found with invite code (expected for test)")
}
}
// TestCoManagedKeygenSessionFlow tests the keygen session creation and join flow
func TestCoManagedKeygenSessionFlow(t *testing.T) {
t.Log("=== Co-Managed Keygen Session Flow Test ===")
// Step 1: Create a keygen session
t.Log("Step 1: Creating keygen session...")
reqBody := CreateCoManagedSessionRequest{
WalletName: "test-wallet-" + fmt.Sprintf("%d", time.Now().UnixNano()),
ThresholdT: 1, // 2-of-3: t=1 means t+1=2 signers needed
ThresholdN: 3,
InitiatorPartyID: "test-initiator-party",
InitiatorName: "Test User",
PersistentCount: 0, // No server parties for this test
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sessions",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Create session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
}
var createResp CreateCoManagedSessionResponse
if err := json.Unmarshal(body, &createResp); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
t.Logf("Session created: ID=%s, InviteCode=%s", createResp.SessionID, createResp.InviteCode)
// Step 2: Get session by invite code
t.Log("Step 2: Getting session by invite code...")
resp2, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/by-invite-code/" + createResp.InviteCode)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
t.Logf("Get session response (status %d): %s", resp2.StatusCode, string(body2))
if resp2.StatusCode != 200 {
t.Fatalf("Failed to get session by invite code, status: %d", resp2.StatusCode)
}
// Step 3: Get session status
t.Log("Step 3: Getting session status...")
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sessions/" + createResp.SessionID)
if err != nil {
t.Fatalf("Failed to get session status: %v", err)
}
defer resp3.Body.Close()
body3, _ := io.ReadAll(resp3.Body)
t.Logf("Session status response (status %d): %s", resp3.StatusCode, string(body3))
if resp3.StatusCode != 200 {
t.Fatalf("Failed to get session status, status: %d", resp3.StatusCode)
}
t.Log("Keygen session flow test passed!")
}
// TestCoManagedSignSessionFlow tests the full sign session flow
func TestCoManagedSignSessionFlow(t *testing.T) {
t.Log("=== Co-Managed Sign Session Flow Test ===")
// First, create a keygen session to get a valid keygen_session_id
t.Log("Step 1: Creating keygen session...")
keygenReq := CreateCoManagedSessionRequest{
WalletName: "test-wallet-for-sign-" + fmt.Sprintf("%d", time.Now().UnixNano()),
ThresholdT: 1,
ThresholdN: 3,
InitiatorPartyID: "test-party-0",
InitiatorName: "Test User",
PersistentCount: 0,
}
jsonBody, _ := json.Marshal(keygenReq)
resp, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sessions",
"application/json",
bytes.NewBuffer(jsonBody),
)
if err != nil {
t.Fatalf("Failed to create keygen session: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
t.Logf("Keygen session response (status %d): %s", resp.StatusCode, string(body))
if resp.StatusCode != 200 && resp.StatusCode != 201 {
t.Fatalf("Failed to create keygen session, status: %d", resp.StatusCode)
}
var keygenResp CreateCoManagedSessionResponse
if err := json.Unmarshal(body, &keygenResp); err != nil {
t.Fatalf("Failed to parse keygen response: %v", err)
}
// Step 2: Create a sign session using the keygen session ID
// Note: For threshold T, we need T+1 parties to sign
// Backend validates that parties.length >= threshold_t + 1
t.Log("Step 2: Creating sign session...")
signReq := CreateSignSessionRequest{
KeygenSessionID: keygenResp.SessionID,
WalletName: keygenReq.WalletName,
MessageHash: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
ThresholdT: 1, // Threshold T=1 means T+1=2 parties needed to sign
Parties: []SignPartyInfo{
{PartyID: "test-party-0", PartyIndex: 0},
{PartyID: "test-party-1", PartyIndex: 1},
},
InitiatorName: "Test User",
}
jsonBody2, _ := json.Marshal(signReq)
resp2, err := http.Post(
accountServiceURL+"/api/v1/co-managed/sign",
"application/json",
bytes.NewBuffer(jsonBody2),
)
if err != nil {
t.Fatalf("Failed to create sign session: %v", err)
}
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
t.Logf("Sign session response (status %d): %s", resp2.StatusCode, string(body2))
if resp2.StatusCode != 200 && resp2.StatusCode != 201 {
t.Fatalf("Failed to create sign session, status: %d", resp2.StatusCode)
}
var signResp CreateSignSessionResponse
if err := json.Unmarshal(body2, &signResp); err != nil {
t.Fatalf("Failed to parse sign response: %v", err)
}
t.Logf("Sign session created: ID=%s, InviteCode=%s", signResp.SessionID, signResp.InviteCode)
// Step 3: Get sign session by invite code
t.Log("Step 3: Getting sign session by invite code...")
resp3, err := http.Get(accountServiceURL + "/api/v1/co-managed/sign/by-invite-code/" + signResp.InviteCode)
if err != nil {
t.Fatalf("Failed to get sign session: %v", err)
}
defer resp3.Body.Close()
body3, _ := io.ReadAll(resp3.Body)
t.Logf("Get sign session response (status %d): %s", resp3.StatusCode, string(body3))
if resp3.StatusCode != 200 {
t.Fatalf("Failed to get sign session by invite code, status: %d", resp3.StatusCode)
}
var getSignResp GetSignSessionResponse
if err := json.Unmarshal(body3, &getSignResp); err != nil {
t.Fatalf("Failed to parse get sign session response: %v", err)
}
t.Logf("Sign session status: %s, JoinedCount: %d, ThresholdT: %d",
getSignResp.Status, getSignResp.JoinedCount, getSignResp.ThresholdT)
t.Log("Sign session flow test passed!")
}
// TestFullSignFlow tests the complete signing flow with mock data
func TestFullSignFlow(t *testing.T) {
t.Log("=== Full Co-Sign Flow Test ===")
// Step 1: Generate mock key shares (simulating keygen result)
// NOTE: In tss-lib, threshold parameter means "t" where you need t+1 parties to sign.
// So for 2-of-3, we use threshold=1 (meaning 1+1=2 parties needed to sign)
t.Log("Step 1: Generating mock key shares for 2-of-3 scheme...")
thresholdT := 1 // t value: need t+1=2 parties to sign
totalN := 3 // total parties
shares, err := generateMockKeyShares(thresholdT, totalN)
if err != nil {
t.Fatalf("Failed to generate key shares: %v", err)
}
t.Logf("Generated %d key shares", len(shares))
// Step 2: Prepare signing parameters
messageHash := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
sessionID := fmt.Sprintf("test-sign-session-%d", time.Now().UnixNano())
password := "test-password"
// Step 3: Start signing with 2 parties (index 0 and 1)
// For tss-party.exe, we pass the signing threshold (t+1=2) and total (n=3)
t.Log("Step 2: Starting sign process with 2 parties...")
signingParties := []int{0, 1}
// Pass thresholdT+1=2 as the signing threshold to tss-party.exe
signature, err := runSigningProcess(shares, signingParties, messageHash, sessionID, password, thresholdT+1, totalN, t)
if err != nil {
t.Fatalf("Signing failed: %v", err)
}
t.Logf("Step 3: Signing complete!")
t.Logf("Signature (hex): %x", signature)
t.Logf("Signature (base64): %s", base64.StdEncoding.EncodeToString(signature))
}
// generateMockKeyShares generates key shares using tss-lib directly
func generateMockKeyShares(threshold, total int) ([]*keygen.LocalPartySaveData, error) {
fmt.Println("[Keygen] Starting key generation for", total, "parties with threshold", threshold)
partyIDs := make([]*tss.PartyID, total)
for i := 0; i < total; i++ {
partyIDs[i] = tss.NewPartyID(
fmt.Sprintf("party-%d", i),
fmt.Sprintf("party-%d", i),
big.NewInt(int64(i+1)),
)
}
sortedPartyIDs := tss.SortPartyIDs(partyIDs)
peerCtx := tss.NewPeerContext(sortedPartyIDs)
outChannels := make([]chan tss.Message, total)
endChannels := make([]chan *keygen.LocalPartySaveData, total)
parties := make([]tss.Party, total)
for i := 0; i < total; i++ {
outChannels[i] = make(chan tss.Message, total*20)
endChannels[i] = make(chan *keygen.LocalPartySaveData, 1)
params := tss.NewParameters(tss.S256(), peerCtx, sortedPartyIDs[i], total, threshold)
parties[i] = keygen.NewLocalParty(params, outChannels[i], endChannels[i])
}
// Start all parties
var wg sync.WaitGroup
errChan := make(chan error, total)
for i := 0; i < total; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if err := parties[idx].Start(); err != nil {
errChan <- fmt.Errorf("party %d start error: %w", idx, err)
}
}(i)
}
results := make([]*keygen.LocalPartySaveData, total)
var resultsMu sync.Mutex
resultCount := 0
done := make(chan struct{})
// Message routing
go func() {
for {
select {
case <-done:
return
default:
for i := 0; i < total; i++ {
select {
case msg := <-outChannels[i]:
wire, _, _ := msg.WireBytes()
if msg.IsBroadcast() {
for j := 0; j < total; j++ {
if j != i {
go func(destIdx int) {
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), true)
parties[destIdx].Update(parsed)
}(j)
}
}
} else {
for _, to := range msg.GetTo() {
for j := 0; j < total; j++ {
if sortedPartyIDs[j].Id == to.Id {
go func(destIdx int) {
parsed, _ := tss.ParseWireMessage(wire, msg.GetFrom(), false)
parties[destIdx].Update(parsed)
}(j)
break
}
}
}
}
case result := <-endChannels[i]:
resultsMu.Lock()
results[i] = result
resultCount++
fmt.Printf("[Keygen] Party %d completed\n", i)
if resultCount == total {
close(done)
}
resultsMu.Unlock()
default:
}
}
time.Sleep(5 * time.Millisecond)
}
}
}()
wg.Wait()
select {
case <-done:
fmt.Println("[Keygen] All parties completed successfully")
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("keygen timeout")
}
select {
case err := <-errChan:
return nil, err
default:
}
return results, nil
}
// runSigningProcess runs the signing process using tss-party.exe
func runSigningProcess(
shares []*keygen.LocalPartySaveData,
signingPartyIndices []int,
messageHash, sessionID, password string,
thresholdT, thresholdN int,
t *testing.T,
) ([]byte, error) {
// Build participants
participants := make([]Participant, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
participants[i] = Participant{
PartyID: fmt.Sprintf("party-%d", idx),
PartyIndex: idx,
}
}
participantsJSON, _ := json.Marshal(participants)
// Prepare encrypted shares
encryptedShares := make([]string, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
shareBytes, _ := json.Marshal(shares[idx])
encrypted := encryptShare(shareBytes, password)
encryptedShares[i] = base64.StdEncoding.EncodeToString(encrypted)
}
// Find tss-party executable
exePath := "./tss-party.exe"
if _, err := os.Stat(exePath); os.IsNotExist(err) {
exePath = "./tss-party"
if _, err := os.Stat(exePath); os.IsNotExist(err) {
return nil, fmt.Errorf("tss-party executable not found")
}
}
t.Logf("Using executable: %s", exePath)
// Start processes
processes := make([]*exec.Cmd, len(signingPartyIndices))
stdinPipes := make([]io.WriteCloser, len(signingPartyIndices))
stdoutPipes := make([]io.ReadCloser, len(signingPartyIndices))
for i, idx := range signingPartyIndices {
args := []string{
"sign",
"-session-id", sessionID,
"-party-id", fmt.Sprintf("party-%d", idx),
"-party-index", fmt.Sprintf("%d", idx),
"-threshold-t", fmt.Sprintf("%d", thresholdT),
"-threshold-n", fmt.Sprintf("%d", thresholdN),
"-participants", string(participantsJSON),
"-message-hash", messageHash,
"-share-data", encryptedShares[i],
"-password", password,
}
t.Logf("[Party %d] Starting with args: sign -session-id %s -party-id party-%d ...", idx, sessionID, idx)
cmd := exec.Command(exePath, args...)
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
processes[i] = cmd
stdinPipes[i] = stdin
stdoutPipes[i] = stdout
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start party %d: %w", idx, err)
}
}
// Message routing between parties
var wg sync.WaitGroup
results := make([][]byte, len(signingPartyIndices))
errors := make([]error, len(signingPartyIndices))
// Create a mutex for stdin writes
stdinMutexes := make([]*sync.Mutex, len(signingPartyIndices))
for i := range stdinMutexes {
stdinMutexes[i] = &sync.Mutex{}
}
for i := range signingPartyIndices {
wg.Add(1)
go func(partyIdx int) {
defer wg.Done()
scanner := bufio.NewScanner(stdoutPipes[partyIdx])
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
t.Logf("[Party %d] Invalid JSON: %s", signingPartyIndices[partyIdx], line)
continue
}
switch msg.Type {
case "progress":
t.Logf("[Party %d] Progress: round %d/%d", signingPartyIndices[partyIdx], msg.Round, msg.TotalRounds)
case "outgoing":
// tss-party.exe outputs "outgoing" messages that need to be routed to other parties
t.Logf("[Party %d] Outgoing message (broadcast=%v, toParties=%v)", signingPartyIndices[partyIdx], msg.IsBroadcast, msg.ToParties)
// Route to other parties
for j := range signingPartyIndices {
if j != partyIdx {
// If not broadcast, check if this party is in the ToParties list
if !msg.IsBroadcast && len(msg.ToParties) > 0 {
targetPartyID := fmt.Sprintf("party-%d", signingPartyIndices[j])
found := false
for _, to := range msg.ToParties {
if to == targetPartyID {
found = true
break
}
}
if !found {
continue
}
}
// tss-party.exe expects "incoming" messages
msgToSend := Message{
Type: "incoming",
IsBroadcast: msg.IsBroadcast,
Payload: msg.Payload,
FromPartyIndex: signingPartyIndices[partyIdx],
}
data, _ := json.Marshal(msgToSend)
stdinMutexes[j].Lock()
stdinPipes[j].Write(append(data, '\n'))
stdinMutexes[j].Unlock()
}
}
case "result":
t.Logf("[Party %d] Got result!", signingPartyIndices[partyIdx])
signature, _ := base64.StdEncoding.DecodeString(msg.Payload)
results[partyIdx] = signature
case "error":
t.Logf("[Party %d] Error: %s", signingPartyIndices[partyIdx], msg.Error)
errors[partyIdx] = fmt.Errorf("party error: %s", msg.Error)
}
}
if err := scanner.Err(); err != nil {
t.Logf("[Party %d] Scanner error: %v", signingPartyIndices[partyIdx], err)
}
}(i)
}
// Wait for processes to complete
for i, cmd := range processes {
if err := cmd.Wait(); err != nil {
t.Logf("[Party %d] Process exit error: %v", signingPartyIndices[i], err)
}
}
wg.Wait()
// Check for errors
for i, err := range errors {
if err != nil {
return nil, fmt.Errorf("party %d error: %w", signingPartyIndices[i], err)
}
}
// Return first result
for _, result := range results {
if result != nil {
return result, nil
}
}
return nil, fmt.Errorf("no signature received")
}

View File

@ -8,20 +8,30 @@ import (
"bufio"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"math/big"
"os"
"os/signal"
"regexp"
"strconv"
"sync"
"syscall"
"time"
"github.com/bnb-chain/tss-lib/v2/common"
"github.com/bnb-chain/tss-lib/v2/ecdsa/keygen"
"github.com/bnb-chain/tss-lib/v2/ecdsa/signing"
"github.com/bnb-chain/tss-lib/v2/tss"
)
// Regex to extract round number from tss-lib message type
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
var roundRegex = regexp.MustCompile(`Round(\d+)`)
// Message types for IPC
type Message struct {
Type string `json:"type"`
@ -118,9 +128,73 @@ func runKeygen() {
}
func runSign() {
// TODO: Implement signing
sendError("Signing not implemented yet")
os.Exit(1)
// Parse sign flags
fs := flag.NewFlagSet("sign", flag.ExitOnError)
sessionID := fs.String("session-id", "", "Session ID")
partyID := fs.String("party-id", "", "Party ID")
partyIndex := fs.Int("party-index", 0, "Party index (0-based)")
thresholdT := fs.Int("threshold-t", 0, "Threshold T")
thresholdN := fs.Int("threshold-n", 0, "Threshold N (total parties in keygen)")
participantsJSON := fs.String("participants", "[]", "Participants JSON array")
messageHash := fs.String("message-hash", "", "Message hash to sign (hex encoded)")
shareData := fs.String("share-data", "", "Encrypted share data (base64 encoded)")
password := fs.String("password", "", "Password to decrypt share")
if err := fs.Parse(os.Args[2:]); err != nil {
sendError(fmt.Sprintf("Failed to parse flags: %v", err))
os.Exit(1)
}
// Validate required fields
if *sessionID == "" || *partyID == "" || *thresholdT == 0 || *thresholdN == 0 {
sendError("Missing required parameters")
os.Exit(1)
}
if *messageHash == "" {
sendError("Missing message hash")
os.Exit(1)
}
if *shareData == "" {
sendError("Missing share data")
os.Exit(1)
}
// Parse participants
var participants []Participant
if err := json.Unmarshal([]byte(*participantsJSON), &participants); err != nil {
sendError(fmt.Sprintf("Failed to parse participants: %v", err))
os.Exit(1)
}
// Note: For signing, participant count equals threshold T (not N)
// because only T parties participate in signing
if len(participants) != *thresholdT {
sendError(fmt.Sprintf("Participant count mismatch: got %d, expected %d (threshold T)", len(participants), *thresholdT))
os.Exit(1)
}
// Run sign protocol
result, err := executeSign(
*sessionID,
*partyID,
*partyIndex,
*thresholdT,
*thresholdN,
participants,
*messageHash,
*shareData,
*password,
)
if err != nil {
sendError(fmt.Sprintf("Sign failed: %v", err))
os.Exit(1)
}
// Send result
sendSignResult(result.Signature, result.RecoveryID, *partyIndex)
}
type keygenResult struct {
@ -128,6 +202,11 @@ type keygenResult struct {
EncryptedShare []byte
}
type signResult struct {
Signature []byte
RecoveryID int
}
func executeKeygen(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
@ -169,8 +248,14 @@ func executeKeygen(
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
fmt.Fprintf(os.Stderr, "[TSS-KEYGEN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)\n",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT)
// Create channels
outCh := make(chan tss.Message, thresholdN*10)
@ -212,7 +297,7 @@ func executeKeygen(
if !ok {
return
}
handleOutgoingMessage(msg)
handleOutgoingMessage(msg, true) // isKeygen = true
}
}
}()
@ -243,7 +328,7 @@ func executeKeygen(
}
}()
// Track progress
// Track progress (final completion reporting)
totalRounds := 4 // GG20 keygen has 4 rounds
// Wait for completion
@ -279,7 +364,20 @@ func executeKeygen(
}
}
func handleOutgoingMessage(msg tss.Message) {
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
// Returns 0 if parsing fails (safe fallback).
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
func extractRoundFromMessageType(msgType string) int {
matches := roundRegex.FindStringSubmatch(msgType)
if len(matches) >= 2 {
if round, err := strconv.Atoi(matches[1]); err == nil {
return round
}
}
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
}
func handleOutgoingMessage(msg tss.Message, isKeygen bool) {
msgBytes, _, err := msg.WireBytes()
if err != nil {
return
@ -301,6 +399,14 @@ func handleOutgoingMessage(msg tss.Message) {
data, _ := json.Marshal(outMsg)
fmt.Println(string(data))
// Extract current round from message type and send progress update
totalRounds := 4 // GG20 keygen has 4 rounds
if !isKeygen {
totalRounds = 9 // GG20 signing has 9 rounds
}
currentRound := extractRoundFromMessageType(msg.Type())
sendProgress(currentRound, totalRounds)
}
func handleIncomingMessage(
@ -404,3 +510,266 @@ func sendResult(publicKey, encryptedShare []byte, partyIndex int) {
data, _ := json.Marshal(msg)
fmt.Println(string(data))
}
func sendSignResult(signature []byte, recoveryID int, partyIndex int) {
// Append recovery ID to signature (r + s + v = 64 + 1 = 65 bytes)
// This is needed for EVM transaction signing
signatureWithV := make([]byte, len(signature)+1)
copy(signatureWithV, signature)
signatureWithV[len(signature)] = byte(recoveryID)
msg := Message{
Type: "result",
Payload: base64.StdEncoding.EncodeToString(signatureWithV),
PartyIndex: partyIndex,
}
data, _ := json.Marshal(msg)
fmt.Println(string(data))
}
func decryptShare(encryptedData []byte, password string) ([]byte, error) {
// Match the encryption format: first 32 bytes are password hash, rest is data
if len(encryptedData) < 32 {
return nil, fmt.Errorf("encrypted data too short")
}
// Verify password (simple check - matches encryptShare)
expectedHash := hashPassword(password)
actualHash := encryptedData[:32]
// Simple comparison
match := true
for i := 0; i < 32; i++ {
if expectedHash[i] != actualHash[i] {
match = false
break
}
}
if !match {
return nil, fmt.Errorf("incorrect password")
}
return encryptedData[32:], nil
}
func executeSign(
sessionID, partyID string,
partyIndex, thresholdT, thresholdN int,
participants []Participant,
messageHashHex string,
shareDataBase64 string,
password string,
) (*signResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Handle signals for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
// Decode and decrypt share data
encryptedShare, err := base64.StdEncoding.DecodeString(shareDataBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode share data: %w", err)
}
shareBytes, err := decryptShare(encryptedShare, password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt share: %w", err)
}
// Parse keygen save data
var keygenData keygen.LocalPartySaveData
if err := json.Unmarshal(shareBytes, &keygenData); err != nil {
return nil, fmt.Errorf("failed to parse keygen data: %w", err)
}
// Decode message hash
messageHash, err := hex.DecodeString(messageHashHex)
if err != nil {
return nil, fmt.Errorf("failed to decode message hash: %w", err)
}
if len(messageHash) != 32 {
return nil, fmt.Errorf("message hash must be 32 bytes, got %d", len(messageHash))
}
msgBigInt := new(big.Int).SetBytes(messageHash)
// Create TSS party IDs for signing participants
// IMPORTANT: For tss-lib signing, we must reconstruct the party IDs in the same way
// as during keygen. The signing subset (T parties) must use their original keys from keygen.
//
// The keygenData.Ks contains the public keys for all N parties from keygen.
// We need to create party IDs that match the original keygen party structure,
// but only include the T parties that are participating in this signing session.
// Create party IDs only for the signing participants
tssPartyIDs := make([]*tss.PartyID, 0, len(participants))
var selfTSSID *tss.PartyID
for _, p := range participants {
// Use the keygen key at this party's index
// The party key in tss-lib uses the key's big.Int representation
partyKey := tss.NewPartyID(
p.PartyID,
fmt.Sprintf("party-%d", p.PartyIndex),
big.NewInt(int64(p.PartyIndex+1)),
)
tssPartyIDs = append(tssPartyIDs, partyKey)
if p.PartyID == partyID {
selfTSSID = partyKey
}
}
if selfTSSID == nil {
return nil, fmt.Errorf("self party not found in participants")
}
// Sort party IDs (important for tss-lib)
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
// This MUST match keygen exactly!
// For 2-of-3: thresholdT=2, tss-lib threshold=1, signers_needed=1+1=2 ✓
peerCtx := tss.NewPeerContext(sortedPartyIDs)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
fmt.Fprintf(os.Stderr, "[TSS-SIGN] NewParameters: partyCount=%d, tssThreshold=%d (from thresholdT=%d, means %d signers needed)\n",
len(sortedPartyIDs), tssThreshold, thresholdT, thresholdT)
// CRITICAL: Build a subset of the keygen save data for the current signing parties
// This is required when signing with a subset of the original keygen participants.
// BuildLocalSaveDataSubset filters the Ks, BigXj, NTildej, H1j, H2j, and PaillierPKs
// arrays to only include data for the participating signers.
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Original keygenData has %d parties (Ks length)\n", len(keygenData.Ks))
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Building subset for %d signing parties\n", len(sortedPartyIDs))
// Debug: print keygenData.Ks keys
for i, k := range keygenData.Ks {
keyHex := hex.EncodeToString(k.Bytes())
fmt.Fprintf(os.Stderr, "[TSS-SIGN] keygenData.Ks[%d] = %s\n", i, keyHex)
}
// Debug: print sortedPartyIDs keys
for i, p := range sortedPartyIDs {
keyHex := hex.EncodeToString(p.Key)
idPrefix := p.Id
if len(idPrefix) > 8 {
idPrefix = idPrefix[:8]
}
fmt.Fprintf(os.Stderr, "[TSS-SIGN] sortedPartyIDs[%d]: Id=%s, Moniker=%s, Key=%s\n",
i, idPrefix, p.Moniker, keyHex)
}
subsetKeygenData := keygen.BuildLocalSaveDataSubset(keygenData, sortedPartyIDs)
fmt.Fprintf(os.Stderr, "[TSS-SIGN] Subset keygenData has %d parties (Ks length)\n", len(subsetKeygenData.Ks))
// Create channels
outCh := make(chan tss.Message, thresholdT*10)
endCh := make(chan *common.SignatureData, 1)
errCh := make(chan error, 1)
// Create local party for signing with the SUBSET keygen data
localParty := signing.NewLocalParty(msgBigInt, params, subsetKeygenData, outCh, endCh)
// Build party index map for incoming messages
partyIndexMap := make(map[int]*tss.PartyID)
for _, p := range sortedPartyIDs {
for _, orig := range participants {
if orig.PartyID == p.Id {
partyIndexMap[orig.PartyIndex] = p
break
}
}
}
// Start the local party
go func() {
if err := localParty.Start(); err != nil {
errCh <- err
}
}()
// Handle outgoing messages
var outWg sync.WaitGroup
outWg.Add(1)
go func() {
defer outWg.Done()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-outCh:
if !ok {
return
}
handleOutgoingMessage(msg, false) // isKeygen = false (signing)
}
}
}()
// Handle incoming messages from stdin
var inWg sync.WaitGroup
inWg.Add(1)
go func() {
defer inWg.Done()
scanner := bufio.NewScanner(os.Stdin)
// 增加 buffer 大小到 1MB
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return
default:
}
var msg Message
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
continue
}
if msg.Type == "incoming" {
handleIncomingMessage(msg, localParty, partyIndexMap, errCh)
}
}
}()
// Track progress - GG20 signing has 9 rounds
totalRounds := 9
// Wait for completion
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errCh:
return nil, err
case sigData := <-endCh:
// Signing completed successfully
sendProgress(totalRounds, totalRounds)
// Construct signature in DER format or raw R||S format
// sigData contains R, S, and recovery ID
rBytes := sigData.R
sBytes := sigData.S
// Create raw signature: R (32 bytes) || S (32 bytes)
signature := make([]byte, 64)
copy(signature[32-len(rBytes):32], rBytes)
copy(signature[64-len(sBytes):64], sBytes)
// Recovery ID for Ethereum-style signatures
recoveryID := int(sigData.SignatureRecovery[0])
return &signResult{
Signature: signature,
RecoveryID: recoveryID,
}, nil
}
}

View File

@ -171,3 +171,32 @@ func (c *MessageRouterClient) PublishSessionStarted(
return c.PublishSessionEvent(ctx, event)
}
// PublishParticipantJoined publishes a participant_joined event to all parties in the session
// This notifies the initiator and other participants that a new party has joined
func (c *MessageRouterClient) PublishParticipantJoined(
ctx context.Context,
sessionID string,
partyID string,
selectedParties []string,
joinedAt int64,
) error {
logger.Info("Publishing participant_joined event to Message Router",
zap.String("session_id", sessionID),
zap.String("joined_party_id", partyID),
zap.Strings("notify_parties", selectedParties),
zap.Int64("joined_at", joinedAt))
event := &router.SessionEvent{
EventId: uuid.New().String(),
EventType: "participant_joined",
SessionId: sessionID,
SelectedParties: selectedParties,
CreatedAt: joinedAt,
// Note: We could add a custom field for the joined party ID, but for now
// the event itself indicates someone joined. The initiator can refresh
// their participant list via API if needed.
}
return c.PublishSessionEvent(ctx, event)
}

View File

@ -2,6 +2,8 @@ package use_cases
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
@ -16,6 +18,9 @@ import (
"go.uber.org/zap"
)
// Maximum retries for optimistic lock conflicts during join session
const joinSessionMaxRetries = 3
// JoinSessionMessageRouterClient defines the interface for publishing session events via gRPC
type JoinSessionMessageRouterClient interface {
PublishSessionStarted(
@ -27,6 +32,16 @@ type JoinSessionMessageRouterClient interface {
joinTokens map[string]string,
startedAt int64,
) error
// PublishParticipantJoined broadcasts a participant_joined event to all parties in the session
// This allows the initiator's waiting screen to update in real-time when participants join
PublishParticipantJoined(
ctx context.Context,
sessionID string,
partyID string,
selectedParties []string,
joinedAt int64,
) error
}
// JoinSessionUseCase implements the join session use case
@ -54,11 +69,35 @@ func NewJoinSessionUseCase(
}
}
// Execute executes the join session use case
// Execute executes the join session use case with retry logic for optimistic lock conflicts
func (uc *JoinSessionUseCase) Execute(
ctx context.Context,
inputData input.JoinSessionInput,
) (*input.JoinSessionOutput, error) {
return uc.executeWithRetry(ctx, inputData, 0)
}
// executeWithRetry executes the join session with retry logic for optimistic lock conflicts
func (uc *JoinSessionUseCase) executeWithRetry(
ctx context.Context,
inputData input.JoinSessionInput,
retry int,
) (*input.JoinSessionOutput, error) {
if retry >= joinSessionMaxRetries {
logger.Error("max retries exceeded for optimistic lock in join session",
zap.String("session_id", inputData.SessionID.String()),
zap.String("party_id", inputData.PartyID),
zap.Int("retry_count", retry))
return nil, fmt.Errorf("max retries exceeded: %w", entities.ErrOptimisticLockConflict)
}
if retry > 0 {
logger.Info("retrying join session due to optimistic lock conflict",
zap.String("session_id", inputData.SessionID.String()),
zap.String("party_id", inputData.PartyID),
zap.Int("retry_attempt", retry))
}
// Debug: log token info
tokenLen := len(inputData.JoinToken)
tokenPreview := ""
@ -102,7 +141,7 @@ func (uc *JoinSessionUseCase) Execute(
return nil, err
}
// 3. Load session
// 3. Load session (fresh read for each retry attempt)
session, err := uc.sessionRepo.FindByUUID(ctx, sessionID)
if err != nil {
return nil, err
@ -229,24 +268,67 @@ func (uc *JoinSessionUseCase) Execute(
}
}
// 8. Save updated session
// 8. Save updated session (with optimistic lock retry)
if err := uc.sessionRepo.Update(ctx, session); err != nil {
// Check if this is an optimistic lock conflict - if so, retry
if errors.Is(err, entities.ErrOptimisticLockConflict) {
logger.Warn("optimistic lock conflict detected in join session, retrying",
zap.String("session_id", session.ID.String()),
zap.String("party_id", inputData.PartyID),
zap.Int("retry_attempt", retry+1))
return uc.executeWithRetry(ctx, inputData, retry+1)
}
return nil, err
}
// 9. Publish participant joined event
// 9. Publish participant joined event to internal message broker
joinedAt := time.Now().UnixMilli()
event := output.ParticipantJoinedEvent{
SessionID: session.ID.String(),
PartyID: inputData.PartyID,
JoinedAt: time.Now().UnixMilli(),
JoinedAt: joinedAt,
}
if err := uc.eventPublisher.PublishEvent(ctx, output.TopicParticipantJoined, event); err != nil {
logger.Error("failed to publish participant joined event",
logger.Error("failed to publish participant joined event to internal broker",
zap.String("session_id", session.ID.String()),
zap.String("party_id", inputData.PartyID),
zap.Error(err))
}
// 9.1 Publish participant joined event via gRPC to message-router (for real-time UI updates)
// This notifies the initiator and other participants that a new party has joined
if uc.messageRouterClient != nil {
// Get all party IDs in the session to notify them
allPartyIDs := session.GetPartyIDs()
logger.Info("Broadcasting participant_joined event via gRPC",
zap.String("session_id", session.ID.String()),
zap.String("joined_party_id", inputData.PartyID),
zap.Strings("notify_parties", allPartyIDs),
zap.Int("total_participants", len(session.Participants)))
if err := uc.messageRouterClient.PublishParticipantJoined(
ctx,
session.ID.String(),
inputData.PartyID,
allPartyIDs,
joinedAt,
); err != nil {
logger.Error("failed to publish participant joined event to message router",
zap.String("session_id", session.ID.String()),
zap.String("party_id", inputData.PartyID),
zap.Error(err))
} else {
logger.Info("Successfully published participant_joined event to message router",
zap.String("session_id", session.ID.String()),
zap.String("joined_party_id", inputData.PartyID),
zap.Int("notify_count", len(allPartyIDs)))
}
} else {
logger.Warn("messageRouterClient is nil, cannot broadcast participant_joined event",
zap.String("session_id", session.ID.String()),
zap.String("party_id", inputData.PartyID))
}
// 10. Build response with other parties info
otherParties := session.GetOtherParties(partyID)
partyInfos := make([]input.PartyInfo, len(otherParties))

View File

@ -98,6 +98,9 @@ func NewMPCSession(
// AddParticipant adds a participant to the session
func (s *MPCSession) AddParticipant(p *Participant) error {
// For sign sessions, the max participant check is handled at the API level
// (co-managed uses T, persistent uses T+1)
// Here we just prevent exceeding N which is the absolute maximum
if len(s.Participants) >= s.Threshold.N() {
return ErrSessionFull
}
@ -140,18 +143,31 @@ func (s *MPCSession) UpdateParticipantStatus(partyID value_objects.PartyID, stat
// CanStart checks if all participants have joined and the session can start
func (s *MPCSession) CanStart() bool {
if len(s.Participants) != s.Threshold.N() {
return false
// For keygen sessions (including co-managed keygen): must have exactly N participants
if s.SessionType.IsKeygen() {
if len(s.Participants) != s.Threshold.N() {
return false
}
readyCount := 0
for _, p := range s.Participants {
if p.IsJoined() || p.IsReady() {
readyCount++
}
}
return readyCount == s.Threshold.N()
}
readyCount := 0
// For sign sessions: check all registered participants have joined
// The number of participants was determined at session creation time (T or T+1)
if len(s.Participants) == 0 {
return false
}
for _, p := range s.Participants {
// Accept participants in either joined or ready status
if p.IsJoined() || p.IsReady() {
readyCount++
if !p.IsJoined() && !p.IsReady() {
return false
}
}
return readyCount == s.Threshold.N()
return true
}
// Start transitions the session to in_progress
@ -257,7 +273,21 @@ func (s *MPCSession) MarkPartyReady(partyID string) error {
// AllPartiesReady checks if all participants are ready
func (s *MPCSession) AllPartiesReady() bool {
if len(s.Participants) != s.Threshold.N() {
// For keygen sessions: must have exactly N participants
if s.SessionType.IsKeygen() {
if len(s.Participants) != s.Threshold.N() {
return false
}
for _, p := range s.Participants {
if !p.IsReady() && !p.IsCompleted() {
return false
}
}
return true
}
// For sign sessions: check all registered participants are ready
if len(s.Participants) == 0 {
return false
}
for _, p := range s.Participants {

View File

@ -27,9 +27,33 @@ func (s *SessionCoordinatorService) ValidateSessionCreation(
return entities.ErrInvalidSessionType
}
// Allow either exact participant count (pre-registered) or 0 (dynamic joining)
if participantCount != 0 && participantCount != threshold.N() {
return entities.ErrSessionFull
// Validate participant count based on session type
// For keygen: all n parties must participate (participantCount == n or 0 for dynamic)
// For sign: at least t parties required, can have up to n (participantCount >= t && <= n)
// - Co-managed sign uses exactly T parties
// - Persistent sign uses T+1 parties
//
// BREAKING CHANGE WARNING (for co-sign feature, commit 94ab63db):
// Original code: participantCount == threshold.N() for ALL session types
// New code: T <= participantCount <= N for sign sessions
// This change affects PERSISTENT SIGN flow because we now pass keygenThresholdN
// instead of len(parties) as threshold_n in CreateSigningSessionAuto.
// If issues arise with persistent sign, REVERT to: participantCount == threshold.N()
// Related files: session_coordinator_client.go, account_handler.go, mpc_session.go
if participantCount != 0 {
if sessionType == entities.SessionTypeSign {
// Signing session: participant count must be at least t (threshold)
// and at most n (total parties from keygen)
if participantCount < threshold.T() || participantCount > threshold.N() {
return entities.ErrSessionFull
}
} else {
// Keygen session: participant count should equal threshold n
// (all parties must participate in key generation)
if participantCount != threshold.N() {
return entities.ErrSessionFull
}
}
}
if sessionType == entities.SessionTypeSign && len(messageHash) == 0 {

View File

@ -10,6 +10,8 @@ import (
"encoding/json"
"fmt"
"math/big"
"regexp"
"strconv"
"sync"
"syscall/js"
@ -19,6 +21,11 @@ import (
"github.com/bnb-chain/tss-lib/v2/tss"
)
// Regex to extract round number from tss-lib message type
// Message types look like: "binance.tsslib.ecdsa.keygen.KGRound1Message"
// or "binance.tsslib.ecdsa.signing.SignRound3Message"
var roundRegex = regexp.MustCompile(`Round(\d+)`)
// Global state for active sessions
var (
activeSessions = make(map[string]*TSSSession)
@ -160,8 +167,11 @@ func startKeygen(this js.Value, args []js.Value) interface{} {
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// User says "2-of-3" meaning 2 signers needed, so we pass (thresholdT-1) to TSS-lib
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
// Build party index map
session.PartyIndexMap = make(map[int]*tss.PartyID)
@ -281,8 +291,11 @@ func startSigning(this js.Value, args []js.Value) interface{} {
sortedPartyIDs := tss.SortPartyIDs(tssPartyIDs)
// Create peer context and parameters
// IMPORTANT: TSS-lib threshold convention: threshold=t means (t+1) signers required
// This MUST match keygen exactly! Both use (thresholdT-1)
peerCtx := tss.NewPeerContext(sortedPartyIDs)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), thresholdT)
tssThreshold := thresholdT - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
// Build party index map
session.PartyIndexMap = make(map[int]*tss.PartyID)
@ -298,8 +311,12 @@ func startSigning(this js.Value, args []js.Value) interface{} {
// Create message hash as big.Int
msgHashBig := new(big.Int).SetBytes(messageHash)
// Create local signing party
session.LocalParty = signing.NewLocalParty(msgHashBig, params, saveData, session.OutCh, session.EndChSign)
// CRITICAL: Build a subset of the keygen save data for the current signing parties
// This is required when signing with a subset of the original keygen participants.
subsetSaveData := keygen.BuildLocalSaveDataSubset(saveData, sortedPartyIDs)
// Create local signing party with the SUBSET save data
session.LocalParty = signing.NewLocalParty(msgHashBig, params, subsetSaveData, session.OutCh, session.EndChSign)
// Store session
sessionMutex.Lock()
@ -391,11 +408,24 @@ func stopSession(this js.Value, args []js.Value) interface{} {
return createSuccessResult(nil)
}
// extractRoundFromMessageType parses the round number from a tss-lib message type string.
// Returns 0 if parsing fails (safe fallback).
// Example: "binance.tsslib.ecdsa.keygen.KGRound2Message1" -> 2
func extractRoundFromMessageType(msgType string) int {
matches := roundRegex.FindStringSubmatch(msgType)
if len(matches) >= 2 {
if round, err := strconv.Atoi(matches[1]); err == nil {
return round
}
}
return 0 // Safe fallback - doesn't affect protocol, just shows 0 in UI
}
// handleOutgoingMessages processes messages from the TSS protocol
func (s *TSSSession) handleOutgoingMessages() {
totalRounds := 4
totalRounds := 4 // GG20 keygen has 4 rounds
if !s.IsKeygen {
totalRounds = 6 // Signing has 6 rounds
totalRounds = 9 // GG20 signing has 9 rounds (matching Electron and Android)
}
for {
@ -430,8 +460,9 @@ func (s *TSSSession) handleOutgoingMessages() {
jsMsgJSON, _ := json.Marshal(jsMsg)
s.OnMessage.Invoke(string(jsMsgJSON))
// Send progress update
s.OnProgress.Invoke(totalRounds, totalRounds) // Simplified progress
// Extract current round from message type and send progress update
currentRound := extractRoundFromMessageType(msg.Type())
s.OnProgress.Invoke(currentRound, totalRounds)
}
}
}

View File

@ -43,8 +43,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create app directory with correct ownership
RUN mkdir -p /app && chown nestjs:nodejs /app
# Create app directory and uploads directory with correct ownership
RUN mkdir -p /app /app/uploads && chown -R nestjs:nodejs /app
WORKDIR /app
# Switch to non-root user before installing dependencies
@ -61,6 +61,13 @@ RUN DATABASE_URL="postgresql://user:pass@localhost:5432/db" npx prisma generate
# Copy built files
COPY --chown=nestjs:nodejs --from=builder /app/dist ./dist
# Create uploads directory with correct ownership (before volume mount)
# This ensures the directory exists and has correct ownership
# Note: When a named volume is mounted, if it's empty, Docker will copy the container's directory content to it
USER root
RUN mkdir -p /app/uploads && chown -R nestjs:nodejs /app/uploads
USER nestjs
# Create startup script that runs migrations before starting the app
RUN printf '%s\n' \
'#!/bin/sh' \

Some files were not shown because too many files have changed in this diff Show More