Compare commits

..

No commits in common. "main" and "pre-signature-fix" have entirely different histories.

1243 changed files with 8484 additions and 239110 deletions

View File

@ -426,356 +426,7 @@
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(planting-service\\): 修复跨服务调用使用错误标识符导致的500错误\n\n问题根源\n- getBalance 调用使用 userId.toString\\(\\) \\(纯数字如 \"14\"\\)\n- wallet-service 按 accountSequence 查找钱包失败后尝试创建新钱包\n- 但 userId 已存在触发唯一约束冲突导致500错误\n\n修复内容\n1. planting-application.service.ts:\n - createOrder: getBalance\\(userId.toString\\(\\)\\) → getBalance\\(accountSequence\\)\n - payOrder: getBalance\\(userId.toString\\(\\)\\) → getBalance\\(walletIdentifier\\)\n\n2. payment-compensation.service.ts:\n - 注入 IPlantingOrderRepository 获取订单的 accountSequence\n - handleUnfreeze/handleRetryConfirm 添加 accountSequence 参数\n\n3. wallet-service.client.ts:\n - ensureRegionAccounts 接口添加 provinceTeamAccount/cityTeamAccount 字段\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- 后端 wallet-service: getMyLedger API 返回 allocationType 字段\n- 前端流水明细: 显示权益类型名称(分享权益、省/市区域权益等)\n- 新增权益详情弹窗,点击权益记录可查看详细信息\n- 兑换页面: \"RMB/CNY提现\" 改为 \"提现\"\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(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 权益分配memo显示触发用户ID\n\n所有权益类型的memo现在统一显示\"来自用户xxx的认种\"格式:\n- 省团队权益来自用户xxx的认种\n- 省区域权益来自用户xxx的认种\n- 市团队权益来自用户xxx的认种\n- 市区域权益来自用户xxx的认种\n- 社区权益来自用户xxx的认种\n\n修改前只显示\"xx权益已激活\",现在与分享权益格式保持一致\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(echo \"请运行以下命令查看 D25122600005 的认种记录:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_planting -c \"\"\nSELECT order_no, account_sequence, tree_count, status, created_at\nFROM planting_orders\nWHERE account_sequence = ''D25122600005''\nORDER BY created_at DESC;\n\"\"\")",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(wallet-service\\): 修复社区权益根据 targetId 正确分配\n\n问题社区权益\\(COMMUNITY_RIGHT\\)无论 targetId 是什么,都强制分配到\n总部账户 S0000000001导致社区授权人无法在流水明细中看到社区权益。\n\n修复\n- 将 allocateToHeadquartersCommunity 方法重命名为 allocateCommunityRight\n- 根据 targetId 判断分配目标:\n - D 开头(用户账户): 分配到社区授权人账户\n - S 开头或 ''1''(系统账户): 分配到总部社区账户\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- 新增\"权益收入\"筛选项\\(SYSTEM_ALLOCATION\\),用于筛选:\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(chcp 65001)",
"Bash(cmd /c \"chcp 65001 && python -c \"\"import openpyxl; import sys; sys.stdout.reconfigure\\(encoding=''utf-8''\\); wb = openpyxl.load_workbook\\(r''c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\榴莲皇后数据.xlsx''\\); print\\(''Sheets:'', wb.sheetnames\\); sheet = wb.active; print\\(''Rows:'', sheet.max_row, ''Cols:'', sheet.max_column\\); [print\\(f''Row {i}:'', row\\) for i, row in enumerate\\(sheet.iter_rows\\(max_row=5, values_only=True\\), 1\\)]\"\"\")",
"Bash(node scripts/batch-register.js:*)",
"Bash(node batch-register.js:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app\\): 隐藏\"我的团队\"功能,需秘密点击解锁\n\n- 默认隐藏\"我的团队\"树形组件\n- 在\"团队种植数\"区域连续点击19次后显示\n- 点击间隔超过1秒自动重置计数器\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''\nrefactor\\(mobile-app\\): 修改\"我的\"页面文案\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''\nrefactor\\(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(npx jest --testPathPattern=\"referral\" --passWithNoTests)",
"Bash(npx jest --testPathPattern=\"wallet\" --passWithNoTests)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nrefactor\\(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''\nfeat\\(identity-service\\): 增强钱包生成可靠性确保100%生成成功\n\n核心改进\n- 基于数据库扫描代替Redis扫描防止状态丢失后无法重试\n- 指数退避策略\\(1分钟→60分钟\\),无时间限制持续重试\n- 分布式锁保护,防止多实例/并发重复触发\n- getWalletStatus API 检测失败状态并自动触发重试\n\n修改内容\n- RedisService: 添加 tryLock/unlock 分布式锁方法\n- UserAccountRepository: 添加 findUsersWithIncompleteWallets 查询\n- getWalletStatus: 增强状态检测,失败/超时时自动触发重试\n- WalletRetryTask: 完全重写,基于数据库驱动+指数退避\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 ls:*)",
"Bash(tree:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(co-managed-wallet\\): 添加分布式多方共管钱包创建功能\n\n## 功能概述\n实现分布式多方共管钱包创建功能包括 Admin-Web 扩展和 Service-Party 桌面应用。\n\n## 主要变更\n\n### 1. Admin-Web 扩展 \\(前端\\)\n- 新增 CoManagedWalletSection 组件 \\(frontend/admin-web/src/components/features/co-managed-wallet/\\)\n- 在授权管理页面添加共管钱包入口卡片\n- 实现创建钱包向导: 配置 → 邀请 → 生成 → 完成\n- 包含组件: ThresholdConfig, InviteQRCode, ParticipantList, SessionProgress, WalletResult\n\n### 2. Admin-Service 后端 API\n- 新增共管钱包领域实体和枚举 \\(domain/entities/co-managed-wallet.entity.ts\\)\n- 新增 REST 控制器 \\(api/controllers/co-managed-wallet.controller.ts\\)\n- 新增服务层 \\(application/services/co-managed-wallet.service.ts\\)\n- 新增 Prisma 模型: CoManagedWalletSession, CoManagedWallet\n- 更新 app.module.ts 注册新模块\n\n### 3. Session Coordinator 扩展 \\(Go\\)\n- 新增会话类型: SessionTypeCoManagedKeygen \\(\"co_managed_keygen\"\\)\n- 扩展 MPCSession 实体添加 WalletName 和 InviteCode 字段\n- 更新 PostgreSQL 和 Redis 适配器支持新字段\n- 新增数据库迁移: 008_add_co_managed_wallet_fields\n\n### 4. Service-Party 桌面应用 \\(新项目\\)\n- 位置: backend/mpc-system/services/service-party-app/\n- 技术栈: Electron + React + TypeScript + Vite\n- 包含模块:\n - gRPC 客户端 \\(连接 Message Router\\)\n - TSS 处理器 \\(子进程方式运行 Go TSS 协议\\)\n - 本地加密存储 \\(AES-256-GCM\\)\n- 页面: Home, Join, Create, Session, Settings\n\n## 修改的现有文件 \\(便于回滚\\)\n\n1. backend/mpc-system/services/session-coordinator/domain/entities/mpc_session.go\n - 添加 SessionTypeCoManagedKeygen 常量\n - 添加 IsKeygen\\(\\) 方法\n - 添加 WalletName, InviteCode 字段\n - 更新 ReconstructSession, ToDTO, SessionDTO\n\n2. backend/mpc-system/services/session-coordinator/adapters/output/postgres/session_postgres_repo.go\n - 更新 SQL 查询包含 wallet_name, invite_code\n - 更新 Save, FindByUUID, FindByStatus 等方法\n - 更新 scanSessions, sessionRow\n\n3. backend/mpc-system/services/session-coordinator/adapters/output/redis/session_cache_adapter.go\n - 更新 sessionCacheEntry 结构\n - 更新 sessionToCacheEntry, cacheEntryToSession\n\n4. backend/services/admin-service/prisma/schema.prisma\n - 新增 WalletSessionStatus 枚举\n - 新增 CoManagedWalletSession, CoManagedWallet 模型\n\n5. backend/services/admin-service/src/app.module.ts\n - 导入并注册共管钱包相关组件\n\n6. frontend/admin-web/src/app/\\(dashboard\\)/authorization/page.tsx\n - 导入并添加 CoManagedWalletSection\n\n7. frontend/admin-web/src/infrastructure/api/endpoints.ts\n - 添加 CO_MANAGED_WALLETS API 端点\n\n## 回滚说明\n\n如需回滚此功能:\n1. 回滚数据库迁移: 运行 008_add_co_managed_wallet_fields.down.sql\n2. 删除新增文件夹:\n - backend/mpc-system/services/service-party-app/\n - frontend/admin-web/src/components/features/co-managed-wallet/\n - backend/services/admin-service/src/**/co-managed-wallet*\n3. 恢复修改的文件到前一个版本\n4. 运行 prisma generate 重新生成 Prisma 客户端\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 mod tidy:*)",
"Bash(protoc:*)",
"Bash(backend/services/admin-service/prisma/schema.prisma )",
"Bash(backend/services/admin-service/src/app.module.ts )",
"Bash(backend/services/admin-service/src/api/controllers/system-maintenance.controller.ts )",
"Bash(backend/services/admin-service/src/api/dto/request/system-maintenance.dto.ts )",
"Bash(backend/services/admin-service/src/api/dto/response/system-maintenance.dto.ts )",
"Bash(backend/services/admin-service/src/api/interceptors/ )",
"Bash(backend/services/admin-service/src/domain/entities/system-maintenance.entity.ts )",
"Bash(backend/services/admin-service/src/domain/repositories/system-maintenance.repository.ts )",
"Bash(backend/services/admin-service/src/infrastructure/persistence/repositories/system-maintenance.repository.impl.ts )",
"Bash(frontend/admin-web/src/components/layout/Sidebar/Sidebar.tsx )",
"Bash(frontend/admin-web/src/infrastructure/api/endpoints.ts )",
"Bash(frontend/admin-web/src/services/maintenanceService.ts )",
"Bash(\"frontend/admin-web/src/app/\\(dashboard\\)/maintenance/\" )",
"Bash(frontend/mobile-app/lib/app.dart )",
"Bash(frontend/mobile-app/lib/core/providers/ )",
"Bash(frontend/mobile-app/lib/core/services/maintenance_service.dart )",
"Bash(frontend/mobile-app/lib/features/auth/presentation/pages/splash_page.dart)",
"Bash(frontend/mobile-app/lib/features/home/presentation/widgets/bottom_nav_bar.dart )",
"Bash(frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart )",
"Bash(frontend/mobile-app/lib/features/profile/presentation/pages/profile_page.dart )",
"Bash(frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart )",
"Bash(frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mobile-app,admin\\): 添加系统维护功能和通知徽章功能\n\n系统维护功能:\n- 后端: 添加系统维护配置实体、仓库和控制器\n- 后端: 添加维护模式拦截器返回503状态码\n- admin-web: 添加系统维护管理页面,支持创建/编辑/开关维护配置\n- mobile-app: 添加维护状态检查服务和阻断弹窗\n- mobile-app: 在启动页、向导页集成维护检查\n- mobile-app: 支持App从后台恢复时自动检查维护状态\n\n通知徽章功能:\n- 添加通知徽章Provider监听登录状态自动刷新\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\\(co-managed-wallet\\): 修复向后兼容性问题并完善protobuf定义\n\n## 变更概述\n根据用户反馈将 Session Coordinator 的函数签名改为可选参数模式,\n确保新功能 100% 不影响现有的 keygen/sign 功能。\n\n## 主要变更\n\n### 1. Session Coordinator 向后兼容修复\n- 保留原有 `ReconstructSession` 函数签名不变\n- 新增 `ReconstructSessionOptions` 结构体存放可选参数\n- 新增 `ReconstructSessionWithOptions` 函数支持新字段\n- 原函数内部调用新函数,传入 nil options\n\n### 2. Protobuf 定义更新\n- CreateSessionRequest 新增字段:\n - wallet_name \\(field 10\\): 钱包名称\n - invite_code \\(field 11\\): 邀请码\n- SessionInfo 新增字段:\n - wallet_name \\(field 8\\): 钱包名称\n - invite_code \\(field 9\\): 邀请码\n- session_type 支持 \"co_managed_keygen\"\n\n### 3. TSS Party 子进程修复\n- 修复 tss.NewPartyID 参数类型错误 \\(big.Int\\)\n- 修复 go.mod 依赖问题 \\(ed25519 replace\\)\n- 删除未使用的变量\n\n### 4. 清理错误生成的文件\n- 删除 api/proto/*.pb.go \\(错误位置\\)\n- 保留 api/grpc/coordinator/v1/*.pb.go \\(正确位置\\)\n\n## 修改的文件\n\n| 文件 | 变更类型 | 说明 |\n|------|---------|------|\n| mpc_session.go | 修改 | 添加 ReconstructSessionWithOptions |\n| session_postgres_repo.go | 修改 | 使用新函数传入 options |\n| session_cache_adapter.go | 修改 | 使用新函数传入 options |\n| session_coordinator.proto | 修改 | 添加 wallet_name, invite_code 字段 |\n| session_coordinator.pb.go | 重新生成 | 包含新 protobuf 字段 |\n| tss-party/main.go | 修复 | NewPartyID 参数和未使用变量 |\n| tss-party/go.mod | 修复 | ed25519 依赖替换 |\n\n## 向后兼容性保证\n\n- 所有现有代码调用 ReconstructSession 无需任何修改\n- 数据库使用 COALESCE 处理 NULL 值\n- Protobuf 新字段使用高序号,不影响现有消息解析\n- **影响现有功能的风险: 0%**\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''\nchore\\(admin-service\\): 添加系统维护和共管钱包的数据库迁移\n\n添加缺失的 migration 文件,包含:\n- system_maintenances 表 \\(系统维护公告\\)\n- WalletSessionStatus 枚举\n- co_managed_wallet_sessions 表 \\(共管钱包会话\\)\n- co_managed_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 commit -m \"$\\(cat <<''EOF''\nfix\\(admin-service\\): 修复共管钱包 status 类型不匹配问题\n\n使用 Prisma 生成的类型替代手动定义的接口:\n- PrismaCoManagedWalletSession -> @prisma/client\n- PrismaCoManagedWallet -> @prisma/client\n- status 字段使用 PrismaWalletSessionStatus 枚举类型\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''\ndocs: 添加 Service Party App 技术文档\n\n添加分布式共管钱包桌面应用的详细技术文档包括\n\n- 应用概述和使用场景\n- 目录结构说明\n- 技术架构和技术栈\n- TSS 子进程架构设计\n- IPC 消息格式定义\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\\(admin-web\\): 修复系统维护\"立即激活\"按钮不显示的问题\n\n- 修复 getStatusTag 函数逻辑,未激活状态使用 ''inactive'' 样式而不是 ''expired''\n- 添加更细化的状态判断:维护中、已过期、已计划、未激活、待激活\n- 添加 inactive 标签样式(橙色背景)\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\\(migration\\): 使数据库迁移脚本幂等化,支持重复执行\n\n将 008_add_co_managed_wallet_fields.up.sql 改为幂等脚本:\n- 使用 DO $$... IF NOT EXISTS 检查列是否存在再添加\n- 使用 CREATE INDEX IF NOT EXISTS 创建索引\n- 使用 DROP CONSTRAINT IF EXISTS 删除约束\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\\(service-party-app\\): 添加 Windows 一键编译脚本\n\n添加 build-windows.bat 脚本,支持:\n- 检查 Node.js 和 Go 环境\n- 编译 TSS 子进程 \\(tss-party.exe\\)\n- 安装 npm 依赖\n- 编译 Electron 应用\n\n使用方法: 双击运行 build-windows.bat\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_modules/.bin/tsc:*)",
"Bash(npm ls:*)",
"Bash(npm run build:win:*)",
"Bash(npm run clean:*)",
"Bash(git cherry-pick:*)",
"Bash(git stash:*)",
"Bash(docker compose build:*)",
"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:*)"
"Bash(git commit -m \"$\\(cat <<''EOF''\nfix\\(reward-service\\): 权益分配memo显示触发用户ID\n\n所有权益类型的memo现在统一显示\"来自用户xxx的认种\"格式:\n- 省团队权益来自用户xxx的认种\n- 省区域权益来自用户xxx的认种\n- 市团队权益来自用户xxx的认种\n- 市区域权益来自用户xxx的认种\n- 社区权益来自用户xxx的认种\n\n修改前只显示\"xx权益已激活\",现在与分享权益格式保持一致\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
],
"deny": [],
"ask": []

127
.gitignore vendored
View File

@ -2,130 +2,3 @@ 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,10 +48,6 @@ 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 - 钱包服务
@ -177,11 +173,6 @@ 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 - 授权服务
@ -224,10 +215,6 @@ services:
paths:
- /api/v1/mobile/notifications
strip_path: false
- name: admin-mobile-system
paths:
- /api/v1/mobile/system
strip_path: false
# ---------------------------------------------------------------------------
# Presence Service - 在线状态服务
@ -259,116 +246,6 @@ services:
- /api/v1/balance
strip_path: false
# ---------------------------------------------------------------------------
# MPC Account Service - MPC 账户服务 (Go - 共管钱包)
# ---------------------------------------------------------------------------
- name: mpc-account-service
url: http://192.168.1.111:4000
routes:
- name: mpc-co-managed
paths:
- /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 - 全局插件配置
# =============================================================================
@ -378,12 +255,10 @@ 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
@ -408,8 +283,8 @@ plugins:
# 请求限流
- name: rate-limiting
config:
minute: 10000
hour: 500000
minute: 100
hour: 5000
policy: local
# 请求日志

View File

@ -1,280 +0,0 @@
#!/bin/bash
# =============================================================================
# MPC gRPC 代理 - Nginx 配置安装脚本
# =============================================================================
# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router
# 域名: mpc-grpc.szaiai.com
#
# 前提条件:
# 1. Nginx 已安装并运行
# 2. Certbot 已安装
# 3. DNS 已配置 mpc-grpc.szaiai.com 指向此服务器
# 4. Message Router 在后端服务器 (192.168.1.111:50051) 运行
#
# 此脚本完全独立,不影响现有服务
# =============================================================================
set -e
DOMAIN="mpc-grpc.szaiai.com"
DOMAIN_CONF="${DOMAIN}.conf" # Nginx 配置文件需要 .conf 后缀
EMAIL="admin@szaiai.com"
BACKEND_HOST="192.168.1.111"
BACKEND_PORT="50051"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 检查 root 权限
check_root() {
if [ "$EUID" -ne 0 ]; then
log_error "请使用 root 权限运行: sudo ./install-mpc-grpc.sh"
exit 1
fi
}
# 检查前提条件
check_prerequisites() {
log_info "检查前提条件..."
# 检查 Nginx
if ! command -v nginx &> /dev/null; then
log_error "Nginx 未安装,请先安装 Nginx"
exit 1
fi
# 检查 Certbot
if ! command -v certbot &> /dev/null; then
log_error "Certbot 未安装,请先安装 Certbot"
exit 1
fi
# 检查 Nginx 是否支持 http2 和 grpc
if ! nginx -V 2>&1 | grep -q "http_v2_module"; then
log_warn "Nginx 可能不支持 HTTP/2gRPC 需要 HTTP/2 支持"
fi
log_success "前提条件检查通过"
}
# 步骤 1: 创建临时 HTTP 配置用于证书申请
configure_http() {
log_info "步骤 1/4: 创建临时 HTTP 配置..."
# 确保 certbot webroot 目录及子目录存在
mkdir -p /var/www/certbot/.well-known/acme-challenge
chmod -R 755 /var/www/certbot
# 创建临时 HTTP 配置 (使用 .conf 后缀以便 nginx 加载)
cat > /etc/nginx/sites-available/$DOMAIN_CONF << EOF
# 临时 HTTP 配置 - 用于 Let's Encrypt 验证
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
# Let's Encrypt 验证目录
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 200 'MPC gRPC proxy - waiting for SSL certificate';
add_header Content-Type text/plain;
}
}
EOF
# 启用站点
ln -sf /etc/nginx/sites-available/$DOMAIN_CONF /etc/nginx/sites-enabled/$DOMAIN_CONF
# 测试并重载
nginx -t && systemctl reload nginx
log_success "临时 HTTP 配置完成"
}
# 步骤 2: 申请 SSL 证书
obtain_certificate() {
log_info "步骤 2/4: 申请 Let's Encrypt SSL 证书..."
# 检查证书是否已存在
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
log_warn "证书已存在,跳过申请"
return 0
fi
# 申请证书
certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email $EMAIL \
--agree-tos \
--no-eff-email \
-d $DOMAIN
log_success "SSL 证书申请成功"
}
# 步骤 3: 配置 gRPC 代理
configure_grpc() {
log_info "步骤 3/4: 配置 Nginx gRPC 代理..."
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 复制 gRPC 配置
cp "$SCRIPT_DIR/mpc-grpc.szaiai.com.conf" /etc/nginx/sites-available/$DOMAIN_CONF
# 测试并重载
nginx -t && systemctl reload nginx
log_success "gRPC 代理配置完成"
}
# 步骤 4: 验证配置
verify_setup() {
log_info "步骤 4/4: 验证配置..."
# 检查 Nginx 状态
if systemctl is-active --quiet nginx; then
log_success "Nginx 运行正常"
else
log_error "Nginx 未运行"
exit 1
fi
# 检查证书
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then
log_success "SSL 证书已就绪"
else
log_error "SSL 证书未找到"
exit 1
fi
# 检查配置语法
if nginx -t 2>/dev/null; then
log_success "Nginx 配置语法正确"
else
log_error "Nginx 配置语法错误"
exit 1
fi
log_success "验证完成"
}
# 显示完成信息
show_completion() {
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} MPC gRPC 代理安装完成!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "gRPC 端点: ${BLUE}mpc-grpc.szaiai.com:443${NC}"
echo ""
echo "架构:"
echo " Service Party App → Nginx (SSL/gRPC) → Message Router"
echo " ↓"
echo " $DOMAIN:443"
echo " ↓"
echo " $BACKEND_HOST:$BACKEND_PORT"
echo ""
echo "Service Party App 连接配置:"
echo " gRPC 地址: mpc-grpc.szaiai.com:443"
echo " TLS: 启用"
echo ""
echo "常用命令:"
echo " 查看 Nginx 状态: systemctl status nginx"
echo " 重载 Nginx: systemctl reload nginx"
echo " 查看证书: certbot certificates"
echo " 查看日志: tail -f /var/log/nginx/$DOMAIN.access.log"
echo ""
echo -e "${YELLOW}注意: 确保后端 Message Router ($BACKEND_HOST:$BACKEND_PORT) 正在运行${NC}"
echo ""
}
# 显示使用帮助
show_help() {
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " --help, -h 显示帮助信息"
echo " --verify 仅验证现有配置"
echo " --uninstall 卸载配置"
echo ""
}
# 卸载配置
uninstall() {
log_info "卸载 MPC gRPC 代理配置..."
# 移除站点配置 (兼容新旧文件名)
rm -f /etc/nginx/sites-enabled/$DOMAIN_CONF
rm -f /etc/nginx/sites-available/$DOMAIN_CONF
rm -f /etc/nginx/sites-enabled/$DOMAIN
rm -f /etc/nginx/sites-available/$DOMAIN
# 重载 Nginx
nginx -t && systemctl reload nginx
log_success "配置已卸载"
log_info "注意: SSL 证书未删除,如需删除请运行: certbot delete --cert-name $DOMAIN"
}
# 主函数
main() {
case "${1:-}" in
--help|-h)
show_help
exit 0
;;
--verify)
check_prerequisites
verify_setup
exit 0
;;
--uninstall)
check_root
uninstall
exit 0
;;
esac
echo ""
echo "============================================"
echo " MPC gRPC 代理 - Nginx 安装脚本"
echo " 域名: $DOMAIN"
echo " 后端: $BACKEND_HOST:$BACKEND_PORT"
echo "============================================"
echo ""
check_root
check_prerequisites
echo ""
log_warn "请确保以下条件已满足:"
echo " 1. 域名 $DOMAIN 的 DNS A 记录已指向本服务器 IP"
echo " 2. Message Router 已在 $BACKEND_HOST:$BACKEND_PORT 运行"
echo ""
read -p "是否继续安装? (y/n): " confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
log_info "安装已取消"
exit 0
fi
configure_http
obtain_certificate
configure_grpc
verify_setup
show_completion
}
main "$@"

View File

@ -1,95 +0,0 @@
# =============================================================================
# MPC Message Router gRPC 代理配置
# =============================================================================
# 域名: mpc-grpc.szaiai.com
# 用途: 为 Service Party App 提供 gRPC 连接到 Message Router
# 后端: Message Router gRPC 服务 (端口 50051)
#
# 部署步骤:
# 1. 放置到: /etc/nginx/sites-available/mpc-grpc.szaiai.com
# 2. 启用: ln -s /etc/nginx/sites-available/mpc-grpc.szaiai.com /etc/nginx/sites-enabled/
# 3. 申请证书: certbot certonly --nginx -d mpc-grpc.szaiai.com
# 4. 重载: nginx -t && systemctl reload nginx
#
# 注意: 此配置完全独立,不影响现有服务
# =============================================================================
# HTTP 重定向到 HTTPS (gRPC 必须使用 HTTPS)
server {
listen 80;
listen [::]:80;
server_name mpc-grpc.szaiai.com;
# Let's Encrypt 验证目录
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS + gRPC 配置
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name mpc-grpc.szaiai.com;
# SSL 证书 (Let's Encrypt)
# 首次部署前需要先申请证书:
# certbot certonly --nginx -d mpc-grpc.szaiai.com
ssl_certificate /etc/letsencrypt/live/mpc-grpc.szaiai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mpc-grpc.szaiai.com/privkey.pem;
# SSL 配置优化
ssl_session_timeout 1d;
ssl_session_cache shared:MPC_SSL:10m;
ssl_session_tickets off;
# 现代加密套件
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# 日志
access_log /var/log/nginx/mpc-grpc.szaiai.com.access.log;
error_log /var/log/nginx/mpc-grpc.szaiai.com.error.log;
# gRPC 代理到 Message Router
# 后端服务器: 192.168.1.111 (与其他服务相同)
# Message Router gRPC 端口: 50051
location / {
# gRPC 代理
grpc_pass grpc://192.168.1.111:50051;
# gRPC 超时设置
# 会话等待时间较长 (24小时倒计时),需要较长超时
grpc_read_timeout 300s;
grpc_send_timeout 300s;
grpc_connect_timeout 60s;
# 错误处理
error_page 502 = /error502grpc;
}
# gRPC 错误处理
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "Message Router unavailable";
return 204;
}
# HTTP 健康检查端点 (用于监控)
location = /health {
access_log off;
return 200 '{"status":"ok","service":"mpc-grpc-proxy"}';
add_header Content-Type application/json;
}
}

View File

@ -159,7 +159,6 @@ 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

@ -2,7 +2,7 @@
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.1
// source: session_coordinator.proto
// source: api/proto/session_coordinator.proto
package coordinator
@ -24,7 +24,7 @@ const (
// CreateSessionRequest creates a new MPC session
type CreateSessionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SessionType string `protobuf:"bytes,1,opt,name=session_type,json=sessionType,proto3" json:"session_type,omitempty"` // "keygen", "sign", or "co_managed_keygen"
SessionType string `protobuf:"bytes,1,opt,name=session_type,json=sessionType,proto3" json:"session_type,omitempty"` // "keygen" or "sign"
ThresholdN int32 `protobuf:"varint,2,opt,name=threshold_n,json=thresholdN,proto3" json:"threshold_n,omitempty"` // Total number of parties
ThresholdT int32 `protobuf:"varint,3,opt,name=threshold_t,json=thresholdT,proto3" json:"threshold_t,omitempty"` // Minimum required parties
Participants []*ParticipantInfo `protobuf:"bytes,4,rep,name=participants,proto3" json:"participants,omitempty"` // Optional: if empty, coordinator selects automatically
@ -35,16 +35,13 @@ type CreateSessionRequest struct {
DelegateUserShare *DelegateUserShare `protobuf:"bytes,8,opt,name=delegate_user_share,json=delegateUserShare,proto3" json:"delegate_user_share,omitempty"`
// For sign sessions: which keygen session's shares to use
KeygenSessionId string `protobuf:"bytes,9,opt,name=keygen_session_id,json=keygenSessionId,proto3" json:"keygen_session_id,omitempty"`
// For co_managed_keygen sessions: wallet name and invite code
WalletName string `protobuf:"bytes,10,opt,name=wallet_name,json=walletName,proto3" json:"wallet_name,omitempty"` // Wallet name (for co_managed_keygen)
InviteCode string `protobuf:"bytes,11,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` // Invite code for participants to join (for co_managed_keygen)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSessionRequest) Reset() {
*x = CreateSessionRequest{}
mi := &file_session_coordinator_proto_msgTypes[0]
mi := &file_api_proto_session_coordinator_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -56,7 +53,7 @@ func (x *CreateSessionRequest) String() string {
func (*CreateSessionRequest) ProtoMessage() {}
func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[0]
mi := &file_api_proto_session_coordinator_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -69,7 +66,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead.
func (*CreateSessionRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{0}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{0}
}
func (x *CreateSessionRequest) GetSessionType() string {
@ -135,20 +132,6 @@ func (x *CreateSessionRequest) GetKeygenSessionId() string {
return ""
}
func (x *CreateSessionRequest) GetWalletName() string {
if x != nil {
return x.WalletName
}
return ""
}
func (x *CreateSessionRequest) GetInviteCode() string {
if x != nil {
return x.InviteCode
}
return ""
}
// DelegateUserShare contains user's share for delegate party to use in signing
type DelegateUserShare struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -161,7 +144,7 @@ type DelegateUserShare struct {
func (x *DelegateUserShare) Reset() {
*x = DelegateUserShare{}
mi := &file_session_coordinator_proto_msgTypes[1]
mi := &file_api_proto_session_coordinator_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -173,7 +156,7 @@ func (x *DelegateUserShare) String() string {
func (*DelegateUserShare) ProtoMessage() {}
func (x *DelegateUserShare) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[1]
mi := &file_api_proto_session_coordinator_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -186,7 +169,7 @@ func (x *DelegateUserShare) ProtoReflect() protoreflect.Message {
// Deprecated: Use DelegateUserShare.ProtoReflect.Descriptor instead.
func (*DelegateUserShare) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{1}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{1}
}
func (x *DelegateUserShare) GetDelegatePartyId() string {
@ -222,7 +205,7 @@ type PartyComposition struct {
func (x *PartyComposition) Reset() {
*x = PartyComposition{}
mi := &file_session_coordinator_proto_msgTypes[2]
mi := &file_api_proto_session_coordinator_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -234,7 +217,7 @@ func (x *PartyComposition) String() string {
func (*PartyComposition) ProtoMessage() {}
func (x *PartyComposition) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[2]
mi := &file_api_proto_session_coordinator_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -247,7 +230,7 @@ func (x *PartyComposition) ProtoReflect() protoreflect.Message {
// Deprecated: Use PartyComposition.ProtoReflect.Descriptor instead.
func (*PartyComposition) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{2}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{2}
}
func (x *PartyComposition) GetPersistentCount() int32 {
@ -283,7 +266,7 @@ type ParticipantInfo struct {
func (x *ParticipantInfo) Reset() {
*x = ParticipantInfo{}
mi := &file_session_coordinator_proto_msgTypes[3]
mi := &file_api_proto_session_coordinator_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -295,7 +278,7 @@ func (x *ParticipantInfo) String() string {
func (*ParticipantInfo) ProtoMessage() {}
func (x *ParticipantInfo) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[3]
mi := &file_api_proto_session_coordinator_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -308,7 +291,7 @@ func (x *ParticipantInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use ParticipantInfo.ProtoReflect.Descriptor instead.
func (*ParticipantInfo) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{3}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{3}
}
func (x *ParticipantInfo) GetPartyId() string {
@ -345,7 +328,7 @@ type DeviceInfo struct {
func (x *DeviceInfo) Reset() {
*x = DeviceInfo{}
mi := &file_session_coordinator_proto_msgTypes[4]
mi := &file_api_proto_session_coordinator_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -357,7 +340,7 @@ func (x *DeviceInfo) String() string {
func (*DeviceInfo) ProtoMessage() {}
func (x *DeviceInfo) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[4]
mi := &file_api_proto_session_coordinator_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -370,7 +353,7 @@ func (x *DeviceInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeviceInfo.ProtoReflect.Descriptor instead.
func (*DeviceInfo) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{4}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{4}
}
func (x *DeviceInfo) GetDeviceType() string {
@ -415,7 +398,7 @@ type CreateSessionResponse struct {
func (x *CreateSessionResponse) Reset() {
*x = CreateSessionResponse{}
mi := &file_session_coordinator_proto_msgTypes[5]
mi := &file_api_proto_session_coordinator_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -427,7 +410,7 @@ func (x *CreateSessionResponse) String() string {
func (*CreateSessionResponse) ProtoMessage() {}
func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[5]
mi := &file_api_proto_session_coordinator_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -440,7 +423,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead.
func (*CreateSessionResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{5}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{5}
}
func (x *CreateSessionResponse) GetSessionId() string {
@ -491,7 +474,7 @@ type JoinSessionRequest struct {
func (x *JoinSessionRequest) Reset() {
*x = JoinSessionRequest{}
mi := &file_session_coordinator_proto_msgTypes[6]
mi := &file_api_proto_session_coordinator_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -503,7 +486,7 @@ func (x *JoinSessionRequest) String() string {
func (*JoinSessionRequest) ProtoMessage() {}
func (x *JoinSessionRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[6]
mi := &file_api_proto_session_coordinator_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -516,7 +499,7 @@ func (x *JoinSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use JoinSessionRequest.ProtoReflect.Descriptor instead.
func (*JoinSessionRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{6}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{6}
}
func (x *JoinSessionRequest) GetSessionId() string {
@ -560,7 +543,7 @@ type JoinSessionResponse struct {
func (x *JoinSessionResponse) Reset() {
*x = JoinSessionResponse{}
mi := &file_session_coordinator_proto_msgTypes[7]
mi := &file_api_proto_session_coordinator_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -572,7 +555,7 @@ func (x *JoinSessionResponse) String() string {
func (*JoinSessionResponse) ProtoMessage() {}
func (x *JoinSessionResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[7]
mi := &file_api_proto_session_coordinator_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -585,7 +568,7 @@ func (x *JoinSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use JoinSessionResponse.ProtoReflect.Descriptor instead.
func (*JoinSessionResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{7}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{7}
}
func (x *JoinSessionResponse) GetSuccess() bool {
@ -627,16 +610,13 @@ type SessionInfo struct {
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"`
// For sign sessions: which keygen session's shares to use
KeygenSessionId string `protobuf:"bytes,7,opt,name=keygen_session_id,json=keygenSessionId,proto3" json:"keygen_session_id,omitempty"`
// For co_managed_keygen sessions
WalletName string `protobuf:"bytes,8,opt,name=wallet_name,json=walletName,proto3" json:"wallet_name,omitempty"` // Wallet name (for co_managed_keygen)
InviteCode string `protobuf:"bytes,9,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` // Invite code (for co_managed_keygen)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SessionInfo) Reset() {
*x = SessionInfo{}
mi := &file_session_coordinator_proto_msgTypes[8]
mi := &file_api_proto_session_coordinator_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -648,7 +628,7 @@ func (x *SessionInfo) String() string {
func (*SessionInfo) ProtoMessage() {}
func (x *SessionInfo) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[8]
mi := &file_api_proto_session_coordinator_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -661,7 +641,7 @@ func (x *SessionInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use SessionInfo.ProtoReflect.Descriptor instead.
func (*SessionInfo) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{8}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{8}
}
func (x *SessionInfo) GetSessionId() string {
@ -713,20 +693,6 @@ func (x *SessionInfo) GetKeygenSessionId() string {
return ""
}
func (x *SessionInfo) GetWalletName() string {
if x != nil {
return x.WalletName
}
return ""
}
func (x *SessionInfo) GetInviteCode() string {
if x != nil {
return x.InviteCode
}
return ""
}
// PartyInfo contains party information
type PartyInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -739,7 +705,7 @@ type PartyInfo struct {
func (x *PartyInfo) Reset() {
*x = PartyInfo{}
mi := &file_session_coordinator_proto_msgTypes[9]
mi := &file_api_proto_session_coordinator_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -751,7 +717,7 @@ func (x *PartyInfo) String() string {
func (*PartyInfo) ProtoMessage() {}
func (x *PartyInfo) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[9]
mi := &file_api_proto_session_coordinator_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -764,7 +730,7 @@ func (x *PartyInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use PartyInfo.ProtoReflect.Descriptor instead.
func (*PartyInfo) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{9}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{9}
}
func (x *PartyInfo) GetPartyId() string {
@ -798,7 +764,7 @@ type GetSessionStatusRequest struct {
func (x *GetSessionStatusRequest) Reset() {
*x = GetSessionStatusRequest{}
mi := &file_session_coordinator_proto_msgTypes[10]
mi := &file_api_proto_session_coordinator_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -810,7 +776,7 @@ func (x *GetSessionStatusRequest) String() string {
func (*GetSessionStatusRequest) ProtoMessage() {}
func (x *GetSessionStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[10]
mi := &file_api_proto_session_coordinator_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -823,7 +789,7 @@ func (x *GetSessionStatusRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetSessionStatusRequest.ProtoReflect.Descriptor instead.
func (*GetSessionStatusRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{10}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{10}
}
func (x *GetSessionStatusRequest) GetSessionId() string {
@ -848,20 +814,13 @@ type GetSessionStatusResponse struct {
// Delegate share info (returned when keygen session completed and delegate party submitted share)
// Only populated if session_type="keygen" AND has_delegate=true AND session is completed
DelegateShare *DelegateShareInfo `protobuf:"bytes,8,opt,name=delegate_share,json=delegateShare,proto3" json:"delegate_share,omitempty"`
// participants contains detailed participant information including party_index
// Used by service-party-app for co_managed_keygen sessions
Participants []*ParticipantStatus `protobuf:"bytes,9,rep,name=participants,proto3" json:"participants,omitempty"`
// threshold_n and threshold_t - actual threshold values from session config
// Used for co_managed_keygen sessions where total_parties may differ from threshold_n during joining
ThresholdN int32 `protobuf:"varint,10,opt,name=threshold_n,json=thresholdN,proto3" json:"threshold_n,omitempty"` // Total number of parties required (e.g., 3 in 2-of-3)
ThresholdT int32 `protobuf:"varint,11,opt,name=threshold_t,json=thresholdT,proto3" json:"threshold_t,omitempty"` // Minimum parties needed to sign (e.g., 2 in 2-of-3)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSessionStatusResponse) Reset() {
*x = GetSessionStatusResponse{}
mi := &file_session_coordinator_proto_msgTypes[11]
mi := &file_api_proto_session_coordinator_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -873,7 +832,7 @@ func (x *GetSessionStatusResponse) String() string {
func (*GetSessionStatusResponse) ProtoMessage() {}
func (x *GetSessionStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[11]
mi := &file_api_proto_session_coordinator_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -886,7 +845,7 @@ func (x *GetSessionStatusResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetSessionStatusResponse.ProtoReflect.Descriptor instead.
func (*GetSessionStatusResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{11}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{11}
}
func (x *GetSessionStatusResponse) GetStatus() string {
@ -945,88 +904,6 @@ func (x *GetSessionStatusResponse) GetDelegateShare() *DelegateShareInfo {
return nil
}
func (x *GetSessionStatusResponse) GetParticipants() []*ParticipantStatus {
if x != nil {
return x.Participants
}
return nil
}
func (x *GetSessionStatusResponse) GetThresholdN() int32 {
if x != nil {
return x.ThresholdN
}
return 0
}
func (x *GetSessionStatusResponse) GetThresholdT() int32 {
if x != nil {
return x.ThresholdT
}
return 0
}
// ParticipantStatus contains participant status information
type ParticipantStatus struct {
state protoimpl.MessageState `protogen:"open.v1"`
PartyId string `protobuf:"bytes,1,opt,name=party_id,json=partyId,proto3" json:"party_id,omitempty"`
PartyIndex int32 `protobuf:"varint,2,opt,name=party_index,json=partyIndex,proto3" json:"party_index,omitempty"`
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` // pending, joined, ready, completed
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ParticipantStatus) Reset() {
*x = ParticipantStatus{}
mi := &file_session_coordinator_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ParticipantStatus) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ParticipantStatus) ProtoMessage() {}
func (x *ParticipantStatus) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ParticipantStatus.ProtoReflect.Descriptor instead.
func (*ParticipantStatus) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{12}
}
func (x *ParticipantStatus) GetPartyId() string {
if x != nil {
return x.PartyId
}
return ""
}
func (x *ParticipantStatus) GetPartyIndex() int32 {
if x != nil {
return x.PartyIndex
}
return 0
}
func (x *ParticipantStatus) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
// DelegateShareInfo contains the delegate party's share for user
type DelegateShareInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1039,7 +916,7 @@ type DelegateShareInfo struct {
func (x *DelegateShareInfo) Reset() {
*x = DelegateShareInfo{}
mi := &file_session_coordinator_proto_msgTypes[13]
mi := &file_api_proto_session_coordinator_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1051,7 +928,7 @@ func (x *DelegateShareInfo) String() string {
func (*DelegateShareInfo) ProtoMessage() {}
func (x *DelegateShareInfo) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[13]
mi := &file_api_proto_session_coordinator_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1064,7 +941,7 @@ func (x *DelegateShareInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use DelegateShareInfo.ProtoReflect.Descriptor instead.
func (*DelegateShareInfo) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{13}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{12}
}
func (x *DelegateShareInfo) GetEncryptedShare() []byte {
@ -1101,7 +978,7 @@ type ReportCompletionRequest struct {
func (x *ReportCompletionRequest) Reset() {
*x = ReportCompletionRequest{}
mi := &file_session_coordinator_proto_msgTypes[14]
mi := &file_api_proto_session_coordinator_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1113,7 +990,7 @@ func (x *ReportCompletionRequest) String() string {
func (*ReportCompletionRequest) ProtoMessage() {}
func (x *ReportCompletionRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[14]
mi := &file_api_proto_session_coordinator_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1126,7 +1003,7 @@ func (x *ReportCompletionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ReportCompletionRequest.ProtoReflect.Descriptor instead.
func (*ReportCompletionRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{14}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{13}
}
func (x *ReportCompletionRequest) GetSessionId() string {
@ -1168,7 +1045,7 @@ type ReportCompletionResponse struct {
func (x *ReportCompletionResponse) Reset() {
*x = ReportCompletionResponse{}
mi := &file_session_coordinator_proto_msgTypes[15]
mi := &file_api_proto_session_coordinator_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1180,7 +1057,7 @@ func (x *ReportCompletionResponse) String() string {
func (*ReportCompletionResponse) ProtoMessage() {}
func (x *ReportCompletionResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[15]
mi := &file_api_proto_session_coordinator_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1193,7 +1070,7 @@ func (x *ReportCompletionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ReportCompletionResponse.ProtoReflect.Descriptor instead.
func (*ReportCompletionResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{15}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{14}
}
func (x *ReportCompletionResponse) GetSuccess() bool {
@ -1220,7 +1097,7 @@ type CloseSessionRequest struct {
func (x *CloseSessionRequest) Reset() {
*x = CloseSessionRequest{}
mi := &file_session_coordinator_proto_msgTypes[16]
mi := &file_api_proto_session_coordinator_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1232,7 +1109,7 @@ func (x *CloseSessionRequest) String() string {
func (*CloseSessionRequest) ProtoMessage() {}
func (x *CloseSessionRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[16]
mi := &file_api_proto_session_coordinator_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1245,7 +1122,7 @@ func (x *CloseSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CloseSessionRequest.ProtoReflect.Descriptor instead.
func (*CloseSessionRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{16}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{15}
}
func (x *CloseSessionRequest) GetSessionId() string {
@ -1265,7 +1142,7 @@ type CloseSessionResponse struct {
func (x *CloseSessionResponse) Reset() {
*x = CloseSessionResponse{}
mi := &file_session_coordinator_proto_msgTypes[17]
mi := &file_api_proto_session_coordinator_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1277,7 +1154,7 @@ func (x *CloseSessionResponse) String() string {
func (*CloseSessionResponse) ProtoMessage() {}
func (x *CloseSessionResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[17]
mi := &file_api_proto_session_coordinator_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1290,7 +1167,7 @@ func (x *CloseSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CloseSessionResponse.ProtoReflect.Descriptor instead.
func (*CloseSessionResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{17}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{16}
}
func (x *CloseSessionResponse) GetSuccess() bool {
@ -1311,7 +1188,7 @@ type MarkPartyReadyRequest struct {
func (x *MarkPartyReadyRequest) Reset() {
*x = MarkPartyReadyRequest{}
mi := &file_session_coordinator_proto_msgTypes[18]
mi := &file_api_proto_session_coordinator_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1323,7 +1200,7 @@ func (x *MarkPartyReadyRequest) String() string {
func (*MarkPartyReadyRequest) ProtoMessage() {}
func (x *MarkPartyReadyRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[18]
mi := &file_api_proto_session_coordinator_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1336,7 +1213,7 @@ func (x *MarkPartyReadyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use MarkPartyReadyRequest.ProtoReflect.Descriptor instead.
func (*MarkPartyReadyRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{18}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{17}
}
func (x *MarkPartyReadyRequest) GetSessionId() string {
@ -1366,7 +1243,7 @@ type MarkPartyReadyResponse struct {
func (x *MarkPartyReadyResponse) Reset() {
*x = MarkPartyReadyResponse{}
mi := &file_session_coordinator_proto_msgTypes[19]
mi := &file_api_proto_session_coordinator_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1378,7 +1255,7 @@ func (x *MarkPartyReadyResponse) String() string {
func (*MarkPartyReadyResponse) ProtoMessage() {}
func (x *MarkPartyReadyResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[19]
mi := &file_api_proto_session_coordinator_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1391,7 +1268,7 @@ func (x *MarkPartyReadyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use MarkPartyReadyResponse.ProtoReflect.Descriptor instead.
func (*MarkPartyReadyResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{19}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{18}
}
func (x *MarkPartyReadyResponse) GetSuccess() bool {
@ -1432,7 +1309,7 @@ type StartSessionRequest struct {
func (x *StartSessionRequest) Reset() {
*x = StartSessionRequest{}
mi := &file_session_coordinator_proto_msgTypes[20]
mi := &file_api_proto_session_coordinator_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1444,7 +1321,7 @@ func (x *StartSessionRequest) String() string {
func (*StartSessionRequest) ProtoMessage() {}
func (x *StartSessionRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[20]
mi := &file_api_proto_session_coordinator_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1457,7 +1334,7 @@ func (x *StartSessionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartSessionRequest.ProtoReflect.Descriptor instead.
func (*StartSessionRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{20}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{19}
}
func (x *StartSessionRequest) GetSessionId() string {
@ -1478,7 +1355,7 @@ type StartSessionResponse struct {
func (x *StartSessionResponse) Reset() {
*x = StartSessionResponse{}
mi := &file_session_coordinator_proto_msgTypes[21]
mi := &file_api_proto_session_coordinator_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1490,7 +1367,7 @@ func (x *StartSessionResponse) String() string {
func (*StartSessionResponse) ProtoMessage() {}
func (x *StartSessionResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[21]
mi := &file_api_proto_session_coordinator_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1503,7 +1380,7 @@ func (x *StartSessionResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartSessionResponse.ProtoReflect.Descriptor instead.
func (*StartSessionResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{21}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{20}
}
func (x *StartSessionResponse) GetSuccess() bool {
@ -1534,7 +1411,7 @@ type SubmitDelegateShareRequest struct {
func (x *SubmitDelegateShareRequest) Reset() {
*x = SubmitDelegateShareRequest{}
mi := &file_session_coordinator_proto_msgTypes[22]
mi := &file_api_proto_session_coordinator_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1546,7 +1423,7 @@ func (x *SubmitDelegateShareRequest) String() string {
func (*SubmitDelegateShareRequest) ProtoMessage() {}
func (x *SubmitDelegateShareRequest) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[22]
mi := &file_api_proto_session_coordinator_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1559,7 +1436,7 @@ func (x *SubmitDelegateShareRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use SubmitDelegateShareRequest.ProtoReflect.Descriptor instead.
func (*SubmitDelegateShareRequest) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{22}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{21}
}
func (x *SubmitDelegateShareRequest) GetSessionId() string {
@ -1607,7 +1484,7 @@ type SubmitDelegateShareResponse struct {
func (x *SubmitDelegateShareResponse) Reset() {
*x = SubmitDelegateShareResponse{}
mi := &file_session_coordinator_proto_msgTypes[23]
mi := &file_api_proto_session_coordinator_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1619,7 +1496,7 @@ func (x *SubmitDelegateShareResponse) String() string {
func (*SubmitDelegateShareResponse) ProtoMessage() {}
func (x *SubmitDelegateShareResponse) ProtoReflect() protoreflect.Message {
mi := &file_session_coordinator_proto_msgTypes[23]
mi := &file_api_proto_session_coordinator_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1632,7 +1509,7 @@ func (x *SubmitDelegateShareResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use SubmitDelegateShareResponse.ProtoReflect.Descriptor instead.
func (*SubmitDelegateShareResponse) Descriptor() ([]byte, []int) {
return file_session_coordinator_proto_rawDescGZIP(), []int{23}
return file_api_proto_session_coordinator_proto_rawDescGZIP(), []int{22}
}
func (x *SubmitDelegateShareResponse) GetSuccess() bool {
@ -1642,11 +1519,11 @@ func (x *SubmitDelegateShareResponse) GetSuccess() bool {
return false
}
var File_session_coordinator_proto protoreflect.FileDescriptor
var File_api_proto_session_coordinator_proto protoreflect.FileDescriptor
const file_session_coordinator_proto_rawDesc = "" +
const file_api_proto_session_coordinator_proto_rawDesc = "" +
"\n" +
"\x19session_coordinator.proto\x12\x12mpc.coordinator.v1\"\xad\x04\n" +
"#api/proto/session_coordinator.proto\x12\x12mpc.coordinator.v1\"\xeb\x03\n" +
"\x14CreateSessionRequest\x12!\n" +
"\fsession_type\x18\x01 \x01(\tR\vsessionType\x12\x1f\n" +
"\vthreshold_n\x18\x02 \x01(\x05R\n" +
@ -1658,12 +1535,7 @@ const file_session_coordinator_proto_rawDesc = "" +
"\x12expires_in_seconds\x18\x06 \x01(\x03R\x10expiresInSeconds\x12Q\n" +
"\x11party_composition\x18\a \x01(\v2$.mpc.coordinator.v1.PartyCompositionR\x10partyComposition\x12U\n" +
"\x13delegate_user_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateUserShareR\x11delegateUserShare\x12*\n" +
"\x11keygen_session_id\x18\t \x01(\tR\x0fkeygenSessionId\x12\x1f\n" +
"\vwallet_name\x18\n" +
" \x01(\tR\n" +
"walletName\x12\x1f\n" +
"\vinvite_code\x18\v \x01(\tR\n" +
"inviteCode\"\x89\x01\n" +
"\x11keygen_session_id\x18\t \x01(\tR\x0fkeygenSessionId\"\x89\x01\n" +
"\x11DelegateUserShare\x12*\n" +
"\x11delegate_party_id\x18\x01 \x01(\tR\x0fdelegatePartyId\x12'\n" +
"\x0fencrypted_share\x18\x02 \x01(\fR\x0eencryptedShare\x12\x1f\n" +
@ -1712,7 +1584,7 @@ const file_session_coordinator_proto_rawDesc = "" +
"\fsession_info\x18\x02 \x01(\v2\x1f.mpc.coordinator.v1.SessionInfoR\vsessionInfo\x12B\n" +
"\rother_parties\x18\x03 \x03(\v2\x1d.mpc.coordinator.v1.PartyInfoR\fotherParties\x12\x1f\n" +
"\vparty_index\x18\x04 \x01(\x05R\n" +
"partyIndex\"\xba\x02\n" +
"partyIndex\"\xf8\x01\n" +
"\vSessionInfo\x12\x1d\n" +
"\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +
@ -1723,11 +1595,7 @@ const file_session_coordinator_proto_rawDesc = "" +
"thresholdT\x12!\n" +
"\fmessage_hash\x18\x05 \x01(\fR\vmessageHash\x12\x16\n" +
"\x06status\x18\x06 \x01(\tR\x06status\x12*\n" +
"\x11keygen_session_id\x18\a \x01(\tR\x0fkeygenSessionId\x12\x1f\n" +
"\vwallet_name\x18\b \x01(\tR\n" +
"walletName\x12\x1f\n" +
"\vinvite_code\x18\t \x01(\tR\n" +
"inviteCode\"\x88\x01\n" +
"\x11keygen_session_id\x18\a \x01(\tR\x0fkeygenSessionId\"\x88\x01\n" +
"\tPartyInfo\x12\x19\n" +
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
"\vparty_index\x18\x02 \x01(\x05R\n" +
@ -1736,7 +1604,7 @@ const file_session_coordinator_proto_rawDesc = "" +
"deviceInfo\"8\n" +
"\x17GetSessionStatusRequest\x12\x1d\n" +
"\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\"\xe2\x03\n" +
"session_id\x18\x01 \x01(\tR\tsessionId\"\xd5\x02\n" +
"\x18GetSessionStatusResponse\x12\x16\n" +
"\x06status\x18\x01 \x01(\tR\x06status\x12+\n" +
"\x11completed_parties\x18\x02 \x01(\x05R\x10completedParties\x12#\n" +
@ -1746,18 +1614,7 @@ const file_session_coordinator_proto_rawDesc = "" +
"public_key\x18\x05 \x01(\fR\tpublicKey\x12\x1c\n" +
"\tsignature\x18\x06 \x01(\fR\tsignature\x12!\n" +
"\fhas_delegate\x18\a \x01(\bR\vhasDelegate\x12L\n" +
"\x0edelegate_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateShareInfoR\rdelegateShare\x12I\n" +
"\fparticipants\x18\t \x03(\v2%.mpc.coordinator.v1.ParticipantStatusR\fparticipants\x12\x1f\n" +
"\vthreshold_n\x18\n" +
" \x01(\x05R\n" +
"thresholdN\x12\x1f\n" +
"\vthreshold_t\x18\v \x01(\x05R\n" +
"thresholdT\"g\n" +
"\x11ParticipantStatus\x12\x19\n" +
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
"\vparty_index\x18\x02 \x01(\x05R\n" +
"partyIndex\x12\x16\n" +
"\x06status\x18\x03 \x01(\tR\x06status\"x\n" +
"\x0edelegate_share\x18\b \x01(\v2%.mpc.coordinator.v1.DelegateShareInfoR\rdelegateShare\"x\n" +
"\x11DelegateShareInfo\x12'\n" +
"\x0fencrypted_share\x18\x01 \x01(\fR\x0eencryptedShare\x12\x1f\n" +
"\vparty_index\x18\x02 \x01(\x05R\n" +
@ -1816,19 +1673,19 @@ const file_session_coordinator_proto_rawDesc = "" +
"\x13SubmitDelegateShare\x12..mpc.coordinator.v1.SubmitDelegateShareRequest\x1a/.mpc.coordinator.v1.SubmitDelegateShareResponseBEZCgithub.com/rwadurian/mpc-system/api/grpc/coordinator/v1;coordinatorb\x06proto3"
var (
file_session_coordinator_proto_rawDescOnce sync.Once
file_session_coordinator_proto_rawDescData []byte
file_api_proto_session_coordinator_proto_rawDescOnce sync.Once
file_api_proto_session_coordinator_proto_rawDescData []byte
)
func file_session_coordinator_proto_rawDescGZIP() []byte {
file_session_coordinator_proto_rawDescOnce.Do(func() {
file_session_coordinator_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_session_coordinator_proto_rawDesc), len(file_session_coordinator_proto_rawDesc)))
func file_api_proto_session_coordinator_proto_rawDescGZIP() []byte {
file_api_proto_session_coordinator_proto_rawDescOnce.Do(func() {
file_api_proto_session_coordinator_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_proto_session_coordinator_proto_rawDesc), len(file_api_proto_session_coordinator_proto_rawDesc)))
})
return file_session_coordinator_proto_rawDescData
return file_api_proto_session_coordinator_proto_rawDescData
}
var file_session_coordinator_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
var file_session_coordinator_proto_goTypes = []any{
var file_api_proto_session_coordinator_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
var file_api_proto_session_coordinator_proto_goTypes = []any{
(*CreateSessionRequest)(nil), // 0: mpc.coordinator.v1.CreateSessionRequest
(*DelegateUserShare)(nil), // 1: mpc.coordinator.v1.DelegateUserShare
(*PartyComposition)(nil), // 2: mpc.coordinator.v1.PartyComposition
@ -1841,75 +1698,73 @@ var file_session_coordinator_proto_goTypes = []any{
(*PartyInfo)(nil), // 9: mpc.coordinator.v1.PartyInfo
(*GetSessionStatusRequest)(nil), // 10: mpc.coordinator.v1.GetSessionStatusRequest
(*GetSessionStatusResponse)(nil), // 11: mpc.coordinator.v1.GetSessionStatusResponse
(*ParticipantStatus)(nil), // 12: mpc.coordinator.v1.ParticipantStatus
(*DelegateShareInfo)(nil), // 13: mpc.coordinator.v1.DelegateShareInfo
(*ReportCompletionRequest)(nil), // 14: mpc.coordinator.v1.ReportCompletionRequest
(*ReportCompletionResponse)(nil), // 15: mpc.coordinator.v1.ReportCompletionResponse
(*CloseSessionRequest)(nil), // 16: mpc.coordinator.v1.CloseSessionRequest
(*CloseSessionResponse)(nil), // 17: mpc.coordinator.v1.CloseSessionResponse
(*MarkPartyReadyRequest)(nil), // 18: mpc.coordinator.v1.MarkPartyReadyRequest
(*MarkPartyReadyResponse)(nil), // 19: mpc.coordinator.v1.MarkPartyReadyResponse
(*StartSessionRequest)(nil), // 20: mpc.coordinator.v1.StartSessionRequest
(*StartSessionResponse)(nil), // 21: mpc.coordinator.v1.StartSessionResponse
(*SubmitDelegateShareRequest)(nil), // 22: mpc.coordinator.v1.SubmitDelegateShareRequest
(*SubmitDelegateShareResponse)(nil), // 23: mpc.coordinator.v1.SubmitDelegateShareResponse
nil, // 24: mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
(*DelegateShareInfo)(nil), // 12: mpc.coordinator.v1.DelegateShareInfo
(*ReportCompletionRequest)(nil), // 13: mpc.coordinator.v1.ReportCompletionRequest
(*ReportCompletionResponse)(nil), // 14: mpc.coordinator.v1.ReportCompletionResponse
(*CloseSessionRequest)(nil), // 15: mpc.coordinator.v1.CloseSessionRequest
(*CloseSessionResponse)(nil), // 16: mpc.coordinator.v1.CloseSessionResponse
(*MarkPartyReadyRequest)(nil), // 17: mpc.coordinator.v1.MarkPartyReadyRequest
(*MarkPartyReadyResponse)(nil), // 18: mpc.coordinator.v1.MarkPartyReadyResponse
(*StartSessionRequest)(nil), // 19: mpc.coordinator.v1.StartSessionRequest
(*StartSessionResponse)(nil), // 20: mpc.coordinator.v1.StartSessionResponse
(*SubmitDelegateShareRequest)(nil), // 21: mpc.coordinator.v1.SubmitDelegateShareRequest
(*SubmitDelegateShareResponse)(nil), // 22: mpc.coordinator.v1.SubmitDelegateShareResponse
nil, // 23: mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
}
var file_session_coordinator_proto_depIdxs = []int32{
var file_api_proto_session_coordinator_proto_depIdxs = []int32{
3, // 0: mpc.coordinator.v1.CreateSessionRequest.participants:type_name -> mpc.coordinator.v1.ParticipantInfo
2, // 1: mpc.coordinator.v1.CreateSessionRequest.party_composition:type_name -> mpc.coordinator.v1.PartyComposition
1, // 2: mpc.coordinator.v1.CreateSessionRequest.delegate_user_share:type_name -> mpc.coordinator.v1.DelegateUserShare
4, // 3: mpc.coordinator.v1.ParticipantInfo.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
24, // 4: mpc.coordinator.v1.CreateSessionResponse.join_tokens:type_name -> mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
23, // 4: mpc.coordinator.v1.CreateSessionResponse.join_tokens:type_name -> mpc.coordinator.v1.CreateSessionResponse.JoinTokensEntry
4, // 5: mpc.coordinator.v1.JoinSessionRequest.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
8, // 6: mpc.coordinator.v1.JoinSessionResponse.session_info:type_name -> mpc.coordinator.v1.SessionInfo
9, // 7: mpc.coordinator.v1.JoinSessionResponse.other_parties:type_name -> mpc.coordinator.v1.PartyInfo
4, // 8: mpc.coordinator.v1.PartyInfo.device_info:type_name -> mpc.coordinator.v1.DeviceInfo
13, // 9: mpc.coordinator.v1.GetSessionStatusResponse.delegate_share:type_name -> mpc.coordinator.v1.DelegateShareInfo
12, // 10: mpc.coordinator.v1.GetSessionStatusResponse.participants:type_name -> mpc.coordinator.v1.ParticipantStatus
0, // 11: mpc.coordinator.v1.SessionCoordinator.CreateSession:input_type -> mpc.coordinator.v1.CreateSessionRequest
6, // 12: mpc.coordinator.v1.SessionCoordinator.JoinSession:input_type -> mpc.coordinator.v1.JoinSessionRequest
10, // 13: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:input_type -> mpc.coordinator.v1.GetSessionStatusRequest
18, // 14: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:input_type -> mpc.coordinator.v1.MarkPartyReadyRequest
20, // 15: mpc.coordinator.v1.SessionCoordinator.StartSession:input_type -> mpc.coordinator.v1.StartSessionRequest
14, // 16: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:input_type -> mpc.coordinator.v1.ReportCompletionRequest
16, // 17: mpc.coordinator.v1.SessionCoordinator.CloseSession:input_type -> mpc.coordinator.v1.CloseSessionRequest
22, // 18: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:input_type -> mpc.coordinator.v1.SubmitDelegateShareRequest
5, // 19: mpc.coordinator.v1.SessionCoordinator.CreateSession:output_type -> mpc.coordinator.v1.CreateSessionResponse
7, // 20: mpc.coordinator.v1.SessionCoordinator.JoinSession:output_type -> mpc.coordinator.v1.JoinSessionResponse
11, // 21: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:output_type -> mpc.coordinator.v1.GetSessionStatusResponse
19, // 22: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:output_type -> mpc.coordinator.v1.MarkPartyReadyResponse
21, // 23: mpc.coordinator.v1.SessionCoordinator.StartSession:output_type -> mpc.coordinator.v1.StartSessionResponse
15, // 24: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:output_type -> mpc.coordinator.v1.ReportCompletionResponse
17, // 25: mpc.coordinator.v1.SessionCoordinator.CloseSession:output_type -> mpc.coordinator.v1.CloseSessionResponse
23, // 26: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:output_type -> mpc.coordinator.v1.SubmitDelegateShareResponse
19, // [19:27] is the sub-list for method output_type
11, // [11:19] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
12, // 9: mpc.coordinator.v1.GetSessionStatusResponse.delegate_share:type_name -> mpc.coordinator.v1.DelegateShareInfo
0, // 10: mpc.coordinator.v1.SessionCoordinator.CreateSession:input_type -> mpc.coordinator.v1.CreateSessionRequest
6, // 11: mpc.coordinator.v1.SessionCoordinator.JoinSession:input_type -> mpc.coordinator.v1.JoinSessionRequest
10, // 12: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:input_type -> mpc.coordinator.v1.GetSessionStatusRequest
17, // 13: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:input_type -> mpc.coordinator.v1.MarkPartyReadyRequest
19, // 14: mpc.coordinator.v1.SessionCoordinator.StartSession:input_type -> mpc.coordinator.v1.StartSessionRequest
13, // 15: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:input_type -> mpc.coordinator.v1.ReportCompletionRequest
15, // 16: mpc.coordinator.v1.SessionCoordinator.CloseSession:input_type -> mpc.coordinator.v1.CloseSessionRequest
21, // 17: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:input_type -> mpc.coordinator.v1.SubmitDelegateShareRequest
5, // 18: mpc.coordinator.v1.SessionCoordinator.CreateSession:output_type -> mpc.coordinator.v1.CreateSessionResponse
7, // 19: mpc.coordinator.v1.SessionCoordinator.JoinSession:output_type -> mpc.coordinator.v1.JoinSessionResponse
11, // 20: mpc.coordinator.v1.SessionCoordinator.GetSessionStatus:output_type -> mpc.coordinator.v1.GetSessionStatusResponse
18, // 21: mpc.coordinator.v1.SessionCoordinator.MarkPartyReady:output_type -> mpc.coordinator.v1.MarkPartyReadyResponse
20, // 22: mpc.coordinator.v1.SessionCoordinator.StartSession:output_type -> mpc.coordinator.v1.StartSessionResponse
14, // 23: mpc.coordinator.v1.SessionCoordinator.ReportCompletion:output_type -> mpc.coordinator.v1.ReportCompletionResponse
16, // 24: mpc.coordinator.v1.SessionCoordinator.CloseSession:output_type -> mpc.coordinator.v1.CloseSessionResponse
22, // 25: mpc.coordinator.v1.SessionCoordinator.SubmitDelegateShare:output_type -> mpc.coordinator.v1.SubmitDelegateShareResponse
18, // [18:26] is the sub-list for method output_type
10, // [10:18] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
}
func init() { file_session_coordinator_proto_init() }
func file_session_coordinator_proto_init() {
if File_session_coordinator_proto != nil {
func init() { file_api_proto_session_coordinator_proto_init() }
func file_api_proto_session_coordinator_proto_init() {
if File_api_proto_session_coordinator_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_session_coordinator_proto_rawDesc), len(file_session_coordinator_proto_rawDesc)),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_session_coordinator_proto_rawDesc), len(file_api_proto_session_coordinator_proto_rawDesc)),
NumEnums: 0,
NumMessages: 25,
NumMessages: 24,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_session_coordinator_proto_goTypes,
DependencyIndexes: file_session_coordinator_proto_depIdxs,
MessageInfos: file_session_coordinator_proto_msgTypes,
GoTypes: file_api_proto_session_coordinator_proto_goTypes,
DependencyIndexes: file_api_proto_session_coordinator_proto_depIdxs,
MessageInfos: file_api_proto_session_coordinator_proto_msgTypes,
}.Build()
File_session_coordinator_proto = out.File
file_session_coordinator_proto_goTypes = nil
file_session_coordinator_proto_depIdxs = nil
File_api_proto_session_coordinator_proto = out.File
file_api_proto_session_coordinator_proto_goTypes = nil
file_api_proto_session_coordinator_proto_depIdxs = nil
}

View File

@ -2,7 +2,7 @@
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.1
// source: session_coordinator.proto
// source: api/proto/session_coordinator.proto
package coordinator
@ -391,5 +391,5 @@ var SessionCoordinator_ServiceDesc = grpc.ServiceDesc{
},
},
Streams: []grpc.StreamDesc{},
Metadata: "session_coordinator.proto",
Metadata: "api/proto/session_coordinator.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,700 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.1
// source: api/proto/message_router.proto
package router
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
MessageRouter_RouteMessage_FullMethodName = "/mpc.router.v1.MessageRouter/RouteMessage"
MessageRouter_SubscribeMessages_FullMethodName = "/mpc.router.v1.MessageRouter/SubscribeMessages"
MessageRouter_GetPendingMessages_FullMethodName = "/mpc.router.v1.MessageRouter/GetPendingMessages"
MessageRouter_AcknowledgeMessage_FullMethodName = "/mpc.router.v1.MessageRouter/AcknowledgeMessage"
MessageRouter_GetMessageStatus_FullMethodName = "/mpc.router.v1.MessageRouter/GetMessageStatus"
MessageRouter_RegisterParty_FullMethodName = "/mpc.router.v1.MessageRouter/RegisterParty"
MessageRouter_Heartbeat_FullMethodName = "/mpc.router.v1.MessageRouter/Heartbeat"
MessageRouter_SubscribeSessionEvents_FullMethodName = "/mpc.router.v1.MessageRouter/SubscribeSessionEvents"
MessageRouter_PublishSessionEvent_FullMethodName = "/mpc.router.v1.MessageRouter/PublishSessionEvent"
MessageRouter_GetRegisteredParties_FullMethodName = "/mpc.router.v1.MessageRouter/GetRegisteredParties"
MessageRouter_JoinSession_FullMethodName = "/mpc.router.v1.MessageRouter/JoinSession"
MessageRouter_MarkPartyReady_FullMethodName = "/mpc.router.v1.MessageRouter/MarkPartyReady"
MessageRouter_ReportCompletion_FullMethodName = "/mpc.router.v1.MessageRouter/ReportCompletion"
MessageRouter_GetSessionStatus_FullMethodName = "/mpc.router.v1.MessageRouter/GetSessionStatus"
MessageRouter_SubmitDelegateShare_FullMethodName = "/mpc.router.v1.MessageRouter/SubmitDelegateShare"
)
// MessageRouterClient is the client API for MessageRouter service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// MessageRouter service handles MPC message routing
// This is the ONLY service that server-parties need to connect to.
// All session operations are proxied through Message Router to Session Coordinator.
type MessageRouterClient interface {
// RouteMessage routes a message from one party to others
RouteMessage(ctx context.Context, in *RouteMessageRequest, opts ...grpc.CallOption) (*RouteMessageResponse, error)
// SubscribeMessages subscribes to messages for a party (streaming)
SubscribeMessages(ctx context.Context, in *SubscribeMessagesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MPCMessage], error)
// GetPendingMessages retrieves pending messages (polling alternative)
GetPendingMessages(ctx context.Context, in *GetPendingMessagesRequest, opts ...grpc.CallOption) (*GetPendingMessagesResponse, error)
// AcknowledgeMessage acknowledges receipt of a message
// Must be called after processing a message to confirm delivery
AcknowledgeMessage(ctx context.Context, in *AcknowledgeMessageRequest, opts ...grpc.CallOption) (*AcknowledgeMessageResponse, error)
// GetMessageStatus gets the delivery status of a message
GetMessageStatus(ctx context.Context, in *GetMessageStatusRequest, opts ...grpc.CallOption) (*GetMessageStatusResponse, error)
// RegisterParty registers a party with the message router (party actively connects)
RegisterParty(ctx context.Context, in *RegisterPartyRequest, opts ...grpc.CallOption) (*RegisterPartyResponse, error)
// Heartbeat sends a heartbeat to keep the party alive
Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error)
// SubscribeSessionEvents subscribes to session lifecycle events (session start, etc.)
SubscribeSessionEvents(ctx context.Context, in *SubscribeSessionEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SessionEvent], error)
// PublishSessionEvent publishes a session event (called by Session Coordinator)
PublishSessionEvent(ctx context.Context, in *PublishSessionEventRequest, opts ...grpc.CallOption) (*PublishSessionEventResponse, error)
// GetRegisteredParties returns all registered parties (for Session Coordinator party discovery)
GetRegisteredParties(ctx context.Context, in *GetRegisteredPartiesRequest, opts ...grpc.CallOption) (*GetRegisteredPartiesResponse, error)
// JoinSession joins a session (proxied to Session Coordinator)
JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error)
// MarkPartyReady marks a party as ready (proxied to Session Coordinator)
MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error)
// ReportCompletion reports completion (proxied to Session Coordinator)
ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error)
// GetSessionStatus gets session status (proxied to Session Coordinator)
GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error)
// SubmitDelegateShare submits user's share from delegate party (proxied to Session Coordinator)
SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error)
}
type messageRouterClient struct {
cc grpc.ClientConnInterface
}
func NewMessageRouterClient(cc grpc.ClientConnInterface) MessageRouterClient {
return &messageRouterClient{cc}
}
func (c *messageRouterClient) RouteMessage(ctx context.Context, in *RouteMessageRequest, opts ...grpc.CallOption) (*RouteMessageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RouteMessageResponse)
err := c.cc.Invoke(ctx, MessageRouter_RouteMessage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) SubscribeMessages(ctx context.Context, in *SubscribeMessagesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MPCMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MessageRouter_ServiceDesc.Streams[0], MessageRouter_SubscribeMessages_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[SubscribeMessagesRequest, MPCMessage]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MessageRouter_SubscribeMessagesClient = grpc.ServerStreamingClient[MPCMessage]
func (c *messageRouterClient) GetPendingMessages(ctx context.Context, in *GetPendingMessagesRequest, opts ...grpc.CallOption) (*GetPendingMessagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetPendingMessagesResponse)
err := c.cc.Invoke(ctx, MessageRouter_GetPendingMessages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) AcknowledgeMessage(ctx context.Context, in *AcknowledgeMessageRequest, opts ...grpc.CallOption) (*AcknowledgeMessageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AcknowledgeMessageResponse)
err := c.cc.Invoke(ctx, MessageRouter_AcknowledgeMessage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) GetMessageStatus(ctx context.Context, in *GetMessageStatusRequest, opts ...grpc.CallOption) (*GetMessageStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMessageStatusResponse)
err := c.cc.Invoke(ctx, MessageRouter_GetMessageStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) RegisterParty(ctx context.Context, in *RegisterPartyRequest, opts ...grpc.CallOption) (*RegisterPartyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RegisterPartyResponse)
err := c.cc.Invoke(ctx, MessageRouter_RegisterParty_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HeartbeatResponse)
err := c.cc.Invoke(ctx, MessageRouter_Heartbeat_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) SubscribeSessionEvents(ctx context.Context, in *SubscribeSessionEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[SessionEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &MessageRouter_ServiceDesc.Streams[1], MessageRouter_SubscribeSessionEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[SubscribeSessionEventsRequest, SessionEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MessageRouter_SubscribeSessionEventsClient = grpc.ServerStreamingClient[SessionEvent]
func (c *messageRouterClient) PublishSessionEvent(ctx context.Context, in *PublishSessionEventRequest, opts ...grpc.CallOption) (*PublishSessionEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PublishSessionEventResponse)
err := c.cc.Invoke(ctx, MessageRouter_PublishSessionEvent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) GetRegisteredParties(ctx context.Context, in *GetRegisteredPartiesRequest, opts ...grpc.CallOption) (*GetRegisteredPartiesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRegisteredPartiesResponse)
err := c.cc.Invoke(ctx, MessageRouter_GetRegisteredParties_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JoinSessionResponse)
err := c.cc.Invoke(ctx, MessageRouter_JoinSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MarkPartyReadyResponse)
err := c.cc.Invoke(ctx, MessageRouter_MarkPartyReady_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReportCompletionResponse)
err := c.cc.Invoke(ctx, MessageRouter_ReportCompletion_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetSessionStatusResponse)
err := c.cc.Invoke(ctx, MessageRouter_GetSessionStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageRouterClient) SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubmitDelegateShareResponse)
err := c.cc.Invoke(ctx, MessageRouter_SubmitDelegateShare_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// MessageRouterServer is the server API for MessageRouter service.
// All implementations must embed UnimplementedMessageRouterServer
// for forward compatibility.
//
// MessageRouter service handles MPC message routing
// This is the ONLY service that server-parties need to connect to.
// All session operations are proxied through Message Router to Session Coordinator.
type MessageRouterServer interface {
// RouteMessage routes a message from one party to others
RouteMessage(context.Context, *RouteMessageRequest) (*RouteMessageResponse, error)
// SubscribeMessages subscribes to messages for a party (streaming)
SubscribeMessages(*SubscribeMessagesRequest, grpc.ServerStreamingServer[MPCMessage]) error
// GetPendingMessages retrieves pending messages (polling alternative)
GetPendingMessages(context.Context, *GetPendingMessagesRequest) (*GetPendingMessagesResponse, error)
// AcknowledgeMessage acknowledges receipt of a message
// Must be called after processing a message to confirm delivery
AcknowledgeMessage(context.Context, *AcknowledgeMessageRequest) (*AcknowledgeMessageResponse, error)
// GetMessageStatus gets the delivery status of a message
GetMessageStatus(context.Context, *GetMessageStatusRequest) (*GetMessageStatusResponse, error)
// RegisterParty registers a party with the message router (party actively connects)
RegisterParty(context.Context, *RegisterPartyRequest) (*RegisterPartyResponse, error)
// Heartbeat sends a heartbeat to keep the party alive
Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error)
// SubscribeSessionEvents subscribes to session lifecycle events (session start, etc.)
SubscribeSessionEvents(*SubscribeSessionEventsRequest, grpc.ServerStreamingServer[SessionEvent]) error
// PublishSessionEvent publishes a session event (called by Session Coordinator)
PublishSessionEvent(context.Context, *PublishSessionEventRequest) (*PublishSessionEventResponse, error)
// GetRegisteredParties returns all registered parties (for Session Coordinator party discovery)
GetRegisteredParties(context.Context, *GetRegisteredPartiesRequest) (*GetRegisteredPartiesResponse, error)
// JoinSession joins a session (proxied to Session Coordinator)
JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error)
// MarkPartyReady marks a party as ready (proxied to Session Coordinator)
MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error)
// ReportCompletion reports completion (proxied to Session Coordinator)
ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error)
// GetSessionStatus gets session status (proxied to Session Coordinator)
GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error)
// SubmitDelegateShare submits user's share from delegate party (proxied to Session Coordinator)
SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error)
mustEmbedUnimplementedMessageRouterServer()
}
// UnimplementedMessageRouterServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMessageRouterServer struct{}
func (UnimplementedMessageRouterServer) RouteMessage(context.Context, *RouteMessageRequest) (*RouteMessageResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RouteMessage not implemented")
}
func (UnimplementedMessageRouterServer) SubscribeMessages(*SubscribeMessagesRequest, grpc.ServerStreamingServer[MPCMessage]) error {
return status.Error(codes.Unimplemented, "method SubscribeMessages not implemented")
}
func (UnimplementedMessageRouterServer) GetPendingMessages(context.Context, *GetPendingMessagesRequest) (*GetPendingMessagesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetPendingMessages not implemented")
}
func (UnimplementedMessageRouterServer) AcknowledgeMessage(context.Context, *AcknowledgeMessageRequest) (*AcknowledgeMessageResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AcknowledgeMessage not implemented")
}
func (UnimplementedMessageRouterServer) GetMessageStatus(context.Context, *GetMessageStatusRequest) (*GetMessageStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetMessageStatus not implemented")
}
func (UnimplementedMessageRouterServer) RegisterParty(context.Context, *RegisterPartyRequest) (*RegisterPartyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method RegisterParty not implemented")
}
func (UnimplementedMessageRouterServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Heartbeat not implemented")
}
func (UnimplementedMessageRouterServer) SubscribeSessionEvents(*SubscribeSessionEventsRequest, grpc.ServerStreamingServer[SessionEvent]) error {
return status.Error(codes.Unimplemented, "method SubscribeSessionEvents not implemented")
}
func (UnimplementedMessageRouterServer) PublishSessionEvent(context.Context, *PublishSessionEventRequest) (*PublishSessionEventResponse, error) {
return nil, status.Error(codes.Unimplemented, "method PublishSessionEvent not implemented")
}
func (UnimplementedMessageRouterServer) GetRegisteredParties(context.Context, *GetRegisteredPartiesRequest) (*GetRegisteredPartiesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetRegisteredParties not implemented")
}
func (UnimplementedMessageRouterServer) JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method JoinSession not implemented")
}
func (UnimplementedMessageRouterServer) MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method MarkPartyReady not implemented")
}
func (UnimplementedMessageRouterServer) ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ReportCompletion not implemented")
}
func (UnimplementedMessageRouterServer) GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSessionStatus not implemented")
}
func (UnimplementedMessageRouterServer) SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitDelegateShare not implemented")
}
func (UnimplementedMessageRouterServer) mustEmbedUnimplementedMessageRouterServer() {}
func (UnimplementedMessageRouterServer) testEmbeddedByValue() {}
// UnsafeMessageRouterServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to MessageRouterServer will
// result in compilation errors.
type UnsafeMessageRouterServer interface {
mustEmbedUnimplementedMessageRouterServer()
}
func RegisterMessageRouterServer(s grpc.ServiceRegistrar, srv MessageRouterServer) {
// If the following call panics, it indicates UnimplementedMessageRouterServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&MessageRouter_ServiceDesc, srv)
}
func _MessageRouter_RouteMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RouteMessageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).RouteMessage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_RouteMessage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).RouteMessage(ctx, req.(*RouteMessageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_SubscribeMessages_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SubscribeMessagesRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MessageRouterServer).SubscribeMessages(m, &grpc.GenericServerStream[SubscribeMessagesRequest, MPCMessage]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MessageRouter_SubscribeMessagesServer = grpc.ServerStreamingServer[MPCMessage]
func _MessageRouter_GetPendingMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetPendingMessagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).GetPendingMessages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_GetPendingMessages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).GetPendingMessages(ctx, req.(*GetPendingMessagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_AcknowledgeMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AcknowledgeMessageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).AcknowledgeMessage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_AcknowledgeMessage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).AcknowledgeMessage(ctx, req.(*AcknowledgeMessageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_GetMessageStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMessageStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).GetMessageStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_GetMessageStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).GetMessageStatus(ctx, req.(*GetMessageStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_RegisterParty_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterPartyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).RegisterParty(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_RegisterParty_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).RegisterParty(ctx, req.(*RegisterPartyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HeartbeatRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).Heartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_Heartbeat_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).Heartbeat(ctx, req.(*HeartbeatRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_SubscribeSessionEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SubscribeSessionEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(MessageRouterServer).SubscribeSessionEvents(m, &grpc.GenericServerStream[SubscribeSessionEventsRequest, SessionEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type MessageRouter_SubscribeSessionEventsServer = grpc.ServerStreamingServer[SessionEvent]
func _MessageRouter_PublishSessionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PublishSessionEventRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).PublishSessionEvent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_PublishSessionEvent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).PublishSessionEvent(ctx, req.(*PublishSessionEventRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_GetRegisteredParties_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRegisteredPartiesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).GetRegisteredParties(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_GetRegisteredParties_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).GetRegisteredParties(ctx, req.(*GetRegisteredPartiesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_JoinSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JoinSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).JoinSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_JoinSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).JoinSession(ctx, req.(*JoinSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_MarkPartyReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkPartyReadyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).MarkPartyReady(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_MarkPartyReady_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).MarkPartyReady(ctx, req.(*MarkPartyReadyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_ReportCompletion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportCompletionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).ReportCompletion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_ReportCompletion_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).ReportCompletion(ctx, req.(*ReportCompletionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_GetSessionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSessionStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).GetSessionStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_GetSessionStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).GetSessionStatus(ctx, req.(*GetSessionStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageRouter_SubmitDelegateShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitDelegateShareRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageRouterServer).SubmitDelegateShare(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageRouter_SubmitDelegateShare_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageRouterServer).SubmitDelegateShare(ctx, req.(*SubmitDelegateShareRequest))
}
return interceptor(ctx, in, info, handler)
}
// MessageRouter_ServiceDesc is the grpc.ServiceDesc for MessageRouter service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var MessageRouter_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mpc.router.v1.MessageRouter",
HandlerType: (*MessageRouterServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "RouteMessage",
Handler: _MessageRouter_RouteMessage_Handler,
},
{
MethodName: "GetPendingMessages",
Handler: _MessageRouter_GetPendingMessages_Handler,
},
{
MethodName: "AcknowledgeMessage",
Handler: _MessageRouter_AcknowledgeMessage_Handler,
},
{
MethodName: "GetMessageStatus",
Handler: _MessageRouter_GetMessageStatus_Handler,
},
{
MethodName: "RegisterParty",
Handler: _MessageRouter_RegisterParty_Handler,
},
{
MethodName: "Heartbeat",
Handler: _MessageRouter_Heartbeat_Handler,
},
{
MethodName: "PublishSessionEvent",
Handler: _MessageRouter_PublishSessionEvent_Handler,
},
{
MethodName: "GetRegisteredParties",
Handler: _MessageRouter_GetRegisteredParties_Handler,
},
{
MethodName: "JoinSession",
Handler: _MessageRouter_JoinSession_Handler,
},
{
MethodName: "MarkPartyReady",
Handler: _MessageRouter_MarkPartyReady_Handler,
},
{
MethodName: "ReportCompletion",
Handler: _MessageRouter_ReportCompletion_Handler,
},
{
MethodName: "GetSessionStatus",
Handler: _MessageRouter_GetSessionStatus_Handler,
},
{
MethodName: "SubmitDelegateShare",
Handler: _MessageRouter_SubmitDelegateShare_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "SubscribeMessages",
Handler: _MessageRouter_SubscribeMessages_Handler,
ServerStreams: true,
},
{
StreamName: "SubscribeSessionEvents",
Handler: _MessageRouter_SubscribeSessionEvents_Handler,
ServerStreams: true,
},
},
Metadata: "api/proto/message_router.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ service SessionCoordinator {
// CreateSessionRequest creates a new MPC session
message CreateSessionRequest {
string session_type = 1; // "keygen", "sign", or "co_managed_keygen"
string session_type = 1; // "keygen" or "sign"
int32 threshold_n = 2; // Total number of parties
int32 threshold_t = 3; // Minimum required parties
repeated ParticipantInfo participants = 4; // Optional: if empty, coordinator selects automatically
@ -32,9 +32,6 @@ message CreateSessionRequest {
DelegateUserShare delegate_user_share = 8;
// For sign sessions: which keygen session's shares to use
string keygen_session_id = 9;
// For co_managed_keygen sessions: wallet name and invite code
string wallet_name = 10; // Wallet name (for co_managed_keygen)
string invite_code = 11; // Invite code for participants to join (for co_managed_keygen)
}
// DelegateUserShare contains user's share for delegate party to use in signing
@ -101,9 +98,6 @@ message SessionInfo {
string status = 6;
// For sign sessions: which keygen session's shares to use
string keygen_session_id = 7;
// For co_managed_keygen sessions
string wallet_name = 8; // Wallet name (for co_managed_keygen)
string invite_code = 9; // Invite code (for co_managed_keygen)
}
// PartyInfo contains party information
@ -132,20 +126,6 @@ message GetSessionStatusResponse {
// Delegate share info (returned when keygen session completed and delegate party submitted share)
// Only populated if session_type="keygen" AND has_delegate=true AND session is completed
DelegateShareInfo delegate_share = 8;
// participants contains detailed participant information including party_index
// Used by service-party-app for co_managed_keygen sessions
repeated ParticipantStatus participants = 9;
// threshold_n and threshold_t - actual threshold values from session config
// Used for co_managed_keygen sessions where total_parties may differ from threshold_n during joining
int32 threshold_n = 10; // Total number of parties required (e.g., 3 in 2-of-3)
int32 threshold_t = 11; // Minimum parties needed to sign (e.g., 2 in 2-of-3)
}
// ParticipantStatus contains participant status information
message ParticipantStatus {
string party_id = 1;
int32 party_index = 2;
string status = 3; // pending, joined, ready, completed
}
// DelegateShareInfo contains the delegate party's share for user

View File

@ -0,0 +1,395 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.1
// source: api/proto/session_coordinator.proto
package coordinator
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SessionCoordinator_CreateSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/CreateSession"
SessionCoordinator_JoinSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/JoinSession"
SessionCoordinator_GetSessionStatus_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/GetSessionStatus"
SessionCoordinator_MarkPartyReady_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/MarkPartyReady"
SessionCoordinator_StartSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/StartSession"
SessionCoordinator_ReportCompletion_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/ReportCompletion"
SessionCoordinator_CloseSession_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/CloseSession"
SessionCoordinator_SubmitDelegateShare_FullMethodName = "/mpc.coordinator.v1.SessionCoordinator/SubmitDelegateShare"
)
// SessionCoordinatorClient is the client API for SessionCoordinator service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// SessionCoordinator service manages MPC sessions
type SessionCoordinatorClient interface {
// Session management
CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResponse, error)
JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error)
GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error)
MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error)
StartSession(ctx context.Context, in *StartSessionRequest, opts ...grpc.CallOption) (*StartSessionResponse, error)
ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error)
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error)
// Delegate party share submission (delegate party submits user's share after keygen)
SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error)
}
type sessionCoordinatorClient struct {
cc grpc.ClientConnInterface
}
func NewSessionCoordinatorClient(cc grpc.ClientConnInterface) SessionCoordinatorClient {
return &sessionCoordinatorClient{cc}
}
func (c *sessionCoordinatorClient) CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*CreateSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateSessionResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_CreateSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) JoinSession(ctx context.Context, in *JoinSessionRequest, opts ...grpc.CallOption) (*JoinSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(JoinSessionResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_JoinSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) GetSessionStatus(ctx context.Context, in *GetSessionStatusRequest, opts ...grpc.CallOption) (*GetSessionStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetSessionStatusResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_GetSessionStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) MarkPartyReady(ctx context.Context, in *MarkPartyReadyRequest, opts ...grpc.CallOption) (*MarkPartyReadyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MarkPartyReadyResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_MarkPartyReady_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) StartSession(ctx context.Context, in *StartSessionRequest, opts ...grpc.CallOption) (*StartSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartSessionResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_StartSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) ReportCompletion(ctx context.Context, in *ReportCompletionRequest, opts ...grpc.CallOption) (*ReportCompletionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReportCompletionResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_ReportCompletion_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CloseSessionResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_CloseSession_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sessionCoordinatorClient) SubmitDelegateShare(ctx context.Context, in *SubmitDelegateShareRequest, opts ...grpc.CallOption) (*SubmitDelegateShareResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubmitDelegateShareResponse)
err := c.cc.Invoke(ctx, SessionCoordinator_SubmitDelegateShare_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SessionCoordinatorServer is the server API for SessionCoordinator service.
// All implementations must embed UnimplementedSessionCoordinatorServer
// for forward compatibility.
//
// SessionCoordinator service manages MPC sessions
type SessionCoordinatorServer interface {
// Session management
CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResponse, error)
JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error)
GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error)
MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error)
StartSession(context.Context, *StartSessionRequest) (*StartSessionResponse, error)
ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error)
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error)
// Delegate party share submission (delegate party submits user's share after keygen)
SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error)
mustEmbedUnimplementedSessionCoordinatorServer()
}
// UnimplementedSessionCoordinatorServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSessionCoordinatorServer struct{}
func (UnimplementedSessionCoordinatorServer) CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateSession not implemented")
}
func (UnimplementedSessionCoordinatorServer) JoinSession(context.Context, *JoinSessionRequest) (*JoinSessionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method JoinSession not implemented")
}
func (UnimplementedSessionCoordinatorServer) GetSessionStatus(context.Context, *GetSessionStatusRequest) (*GetSessionStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSessionStatus not implemented")
}
func (UnimplementedSessionCoordinatorServer) MarkPartyReady(context.Context, *MarkPartyReadyRequest) (*MarkPartyReadyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method MarkPartyReady not implemented")
}
func (UnimplementedSessionCoordinatorServer) StartSession(context.Context, *StartSessionRequest) (*StartSessionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method StartSession not implemented")
}
func (UnimplementedSessionCoordinatorServer) ReportCompletion(context.Context, *ReportCompletionRequest) (*ReportCompletionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ReportCompletion not implemented")
}
func (UnimplementedSessionCoordinatorServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
}
func (UnimplementedSessionCoordinatorServer) SubmitDelegateShare(context.Context, *SubmitDelegateShareRequest) (*SubmitDelegateShareResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitDelegateShare not implemented")
}
func (UnimplementedSessionCoordinatorServer) mustEmbedUnimplementedSessionCoordinatorServer() {}
func (UnimplementedSessionCoordinatorServer) testEmbeddedByValue() {}
// UnsafeSessionCoordinatorServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SessionCoordinatorServer will
// result in compilation errors.
type UnsafeSessionCoordinatorServer interface {
mustEmbedUnimplementedSessionCoordinatorServer()
}
func RegisterSessionCoordinatorServer(s grpc.ServiceRegistrar, srv SessionCoordinatorServer) {
// If the following call panics, it indicates UnimplementedSessionCoordinatorServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SessionCoordinator_ServiceDesc, srv)
}
func _SessionCoordinator_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).CreateSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_CreateSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).CreateSession(ctx, req.(*CreateSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_JoinSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JoinSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).JoinSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_JoinSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).JoinSession(ctx, req.(*JoinSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_GetSessionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSessionStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).GetSessionStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_GetSessionStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).GetSessionStatus(ctx, req.(*GetSessionStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_MarkPartyReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkPartyReadyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).MarkPartyReady(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_MarkPartyReady_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).MarkPartyReady(ctx, req.(*MarkPartyReadyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_StartSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).StartSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_StartSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).StartSession(ctx, req.(*StartSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_ReportCompletion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportCompletionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).ReportCompletion(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_ReportCompletion_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).ReportCompletion(ctx, req.(*ReportCompletionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CloseSessionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).CloseSession(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_CloseSession_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).CloseSession(ctx, req.(*CloseSessionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SessionCoordinator_SubmitDelegateShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitDelegateShareRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SessionCoordinatorServer).SubmitDelegateShare(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SessionCoordinator_SubmitDelegateShare_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SessionCoordinatorServer).SubmitDelegateShare(ctx, req.(*SubmitDelegateShareRequest))
}
return interceptor(ctx, in, info, handler)
}
// SessionCoordinator_ServiceDesc is the grpc.ServiceDesc for SessionCoordinator service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SessionCoordinator_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mpc.coordinator.v1.SessionCoordinator",
HandlerType: (*SessionCoordinatorServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateSession",
Handler: _SessionCoordinator_CreateSession_Handler,
},
{
MethodName: "JoinSession",
Handler: _SessionCoordinator_JoinSession_Handler,
},
{
MethodName: "GetSessionStatus",
Handler: _SessionCoordinator_GetSessionStatus_Handler,
},
{
MethodName: "MarkPartyReady",
Handler: _SessionCoordinator_MarkPartyReady_Handler,
},
{
MethodName: "StartSession",
Handler: _SessionCoordinator_StartSession_Handler,
},
{
MethodName: "ReportCompletion",
Handler: _SessionCoordinator_ReportCompletion_Handler,
},
{
MethodName: "CloseSession",
Handler: _SessionCoordinator_CloseSession_Handler,
},
{
MethodName: "SubmitDelegateShare",
Handler: _SessionCoordinator_SubmitDelegateShare_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/proto/session_coordinator.proto",
}

View File

@ -86,8 +86,8 @@ load_environment() {
# Service lists
CORE_SERVICES="postgres"
DEV_MPC_SERVICES="session-coordinator message-router server-party-1 server-party-2 server-party-3 server-party-api server-party-co-managed-1 server-party-co-managed-2 server-party-co-managed-3 account-service"
PROD_CENTRAL_SERVICES="postgres message-router session-coordinator account-service server-party-api server-party-co-managed-1 server-party-co-managed-2 server-party-co-managed-3"
DEV_MPC_SERVICES="session-coordinator message-router server-party-1 server-party-2 server-party-3 server-party-api account-service"
PROD_CENTRAL_SERVICES="postgres message-router session-coordinator account-service server-party-api"
# ============================================
# Development Mode Commands (docker-compose.yml)
@ -212,55 +212,6 @@ dev_commands() {
echo ""
;;
start-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Starting $2..."
docker compose up -d "$2"
log_success "$2 started"
;;
stop-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Stopping $2..."
docker compose stop "$2"
log_success "$2 stopped"
;;
restart-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Restarting $2..."
docker compose stop "$2"
docker compose up -d "$2"
log_success "$2 restarted"
;;
rebuild-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
local svc="$2"
local no_cache="$3"
log_info "Rebuilding $svc..."
if [ "$no_cache" = "--no-cache" ]; then
log_info "Building without cache..."
docker compose build --no-cache "$svc"
else
docker compose build "$svc"
fi
docker compose up -d "$svc"
log_success "$svc rebuilt and restarted"
;;
*)
return 1
;;
@ -363,55 +314,6 @@ prod_commands() {
fi
;;
start-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Starting $2..."
docker compose -f docker-compose.prod.yml up -d "$2"
log_success "$2 started"
;;
stop-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Stopping $2..."
docker compose -f docker-compose.prod.yml stop "$2"
log_success "$2 stopped"
;;
restart-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
log_info "Restarting $2..."
docker compose -f docker-compose.prod.yml stop "$2"
docker compose -f docker-compose.prod.yml up -d "$2"
log_success "$2 restarted"
;;
rebuild-svc)
if [ -z "$2" ]; then
log_error "Please specify a service name"
return 1
fi
local svc="$2"
local no_cache="$3"
log_info "Rebuilding $svc..."
if [ "$no_cache" = "--no-cache" ]; then
log_info "Building without cache..."
docker compose -f docker-compose.prod.yml build --no-cache "$svc"
else
docker compose -f docker-compose.prod.yml build "$svc"
fi
docker compose -f docker-compose.prod.yml up -d "$svc"
log_success "$svc rebuilt and restarted"
;;
*)
return 1
;;
@ -533,67 +435,39 @@ show_help() {
echo ""
echo "Development Commands (default mode):"
echo " $0 build Build all Docker images"
echo " $0 build-no-cache Build all images without cache"
echo " $0 up|start Start all services"
echo " $0 down|stop Stop all services"
echo " $0 restart Restart all services"
echo " $0 logs [service] Follow logs"
echo " $0 logs-tail [svc] Show last 100 lines of logs"
echo " $0 status|ps Show services status"
echo " $0 health Check all services health"
echo " $0 clean Remove containers and volumes"
echo " $0 shell [service] Open shell in container"
echo " $0 test-api Test Account Service API"
echo ""
echo "Single Service Commands (Development):"
echo " $0 start-svc <name> Start a specific service"
echo " $0 stop-svc <name> Stop a specific service"
echo " $0 restart-svc <name> Restart a specific service"
echo " $0 rebuild-svc <name> [--no-cache] Rebuild and restart a service"
echo ""
echo "Production Central Commands:"
echo " $0 prod build Build central services"
echo " $0 prod up Start central services"
echo " $0 prod down Stop central services"
echo " $0 prod restart Restart central services"
echo " $0 prod logs [svc] Follow central logs"
echo " $0 prod status Show central status"
echo " $0 prod logs Follow central logs"
echo " $0 prod health Check central health"
echo " $0 prod clean Remove central containers and volumes"
echo " $0 prod start-svc <name> Start a specific service"
echo " $0 prod stop-svc <name> Stop a specific service"
echo " $0 prod restart-svc <name> Restart a specific service"
echo " $0 prod rebuild-svc <name> [--no-cache] Rebuild and restart"
echo ""
echo "Production Party Commands (run on each party machine):"
echo " $0 party build Build party service"
echo " $0 party up Start party (connects to central)"
echo " $0 party down Stop party"
echo " $0 party restart Restart party"
echo " $0 party logs Follow party logs"
echo " $0 party status Show party status"
echo " $0 party health Check party health and connectivity"
echo " $0 party clean Remove party containers and volumes"
echo ""
echo "Environment Files:"
echo " .env Development configuration"
echo " .env.prod Production Central configuration"
echo " .env.party Production Party configuration"
echo ""
echo "Services (Development):"
echo " postgres, session-coordinator, message-router, account-service,"
echo " server-party-api, server-party-1, server-party-2, server-party-3,"
echo " server-party-co-managed-1, server-party-co-managed-2, server-party-co-managed-3"
echo ""
echo "Examples:"
echo " # Development (all on one machine)"
echo " $0 up"
echo " $0 rebuild-svc account-service --no-cache"
echo " $0 restart-svc session-coordinator"
echo ""
echo " # Production Central (on central server)"
echo " $0 prod up"
echo " $0 prod rebuild-svc account-service"
echo ""
echo " # Production Party (on each party machine)"
echo " PARTY_ID=server-party-1 $0 party up"

View File

@ -91,8 +91,7 @@ services:
dockerfile: services/message-router/Dockerfile
container_name: mpc-message-router
ports:
- "50051:50051" # gRPC for party connections
- "8082:8080" # HTTP for health checks
- "8082:8080" # WebSocket for external connections
environment:
TZ: Asia/Shanghai
MPC_SERVER_GRPC_PORT: 50051
@ -279,123 +278,6 @@ services:
- mpc-network
restart: unless-stopped
# ============================================
# Co-Managed Server Party Services - TSS 参与方 (专用于 co_managed_keygen)
# 与普通 server-party 隔离,使用两阶段事件处理
# 行为与 service-party-app 100% 兼容
# ============================================
# Co-Managed Server Party 1
server-party-co-managed-1:
build:
context: .
dockerfile: services/server-party-co-managed/Dockerfile
container_name: mpc-server-party-co-managed-1
environment:
TZ: Asia/Shanghai
MPC_SERVER_HTTP_PORT: 8080
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
MPC_DATABASE_HOST: postgres
MPC_DATABASE_PORT: 5432
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
MPC_DATABASE_DBNAME: mpc_system
MPC_DATABASE_SSLMODE: disable
MESSAGE_ROUTER_ADDR: message-router:50051
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
PARTY_ID: co-managed-party-1
depends_on:
postgres:
condition: service_healthy
session-coordinator:
condition: service_healthy
message-router:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- mpc-network
restart: unless-stopped
# Co-Managed Server Party 2
server-party-co-managed-2:
build:
context: .
dockerfile: services/server-party-co-managed/Dockerfile
container_name: mpc-server-party-co-managed-2
environment:
TZ: Asia/Shanghai
MPC_SERVER_HTTP_PORT: 8080
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
MPC_DATABASE_HOST: postgres
MPC_DATABASE_PORT: 5432
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
MPC_DATABASE_DBNAME: mpc_system
MPC_DATABASE_SSLMODE: disable
MESSAGE_ROUTER_ADDR: message-router:50051
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
PARTY_ID: co-managed-party-2
depends_on:
postgres:
condition: service_healthy
session-coordinator:
condition: service_healthy
message-router:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- mpc-network
restart: unless-stopped
# Co-Managed Server Party 3
server-party-co-managed-3:
build:
context: .
dockerfile: services/server-party-co-managed/Dockerfile
container_name: mpc-server-party-co-managed-3
environment:
TZ: Asia/Shanghai
MPC_SERVER_HTTP_PORT: 8080
MPC_SERVER_ENVIRONMENT: ${ENVIRONMENT:-development}
MPC_LOGGER_LEVEL: ${LOG_LEVEL:-debug}
MPC_DATABASE_HOST: postgres
MPC_DATABASE_PORT: 5432
MPC_DATABASE_USER: ${POSTGRES_USER:-mpc_user}
MPC_DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
MPC_DATABASE_DBNAME: mpc_system
MPC_DATABASE_SSLMODE: disable
MESSAGE_ROUTER_ADDR: message-router:50051
MPC_CRYPTO_MASTER_KEY: ${CRYPTO_MASTER_KEY}
PARTY_ID: co-managed-party-3
depends_on:
postgres:
condition: service_healthy
session-coordinator:
condition: service_healthy
message-router:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- mpc-network
restart: unless-stopped
# ============================================
# Account Service - External API Entry Point
# Main HTTP API for backend mpc-service integration

View File

@ -1,17 +0,0 @@
-- Migration: 008_add_co_managed_wallet_fields (rollback)
-- Description: Remove wallet_name and invite_code fields and revert session_type constraint
-- Drop the index for invite_code
DROP INDEX IF EXISTS idx_mpc_sessions_invite_code;
-- Remove the columns
ALTER TABLE mpc_sessions
DROP COLUMN IF EXISTS wallet_name,
DROP COLUMN IF EXISTS invite_code;
-- Drop the updated session_type constraint
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
-- Restore the original session_type constraint
ALTER TABLE mpc_sessions
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign'));

View File

@ -1,32 +0,0 @@
-- Migration: 008_add_co_managed_wallet_fields
-- Description: Add wallet_name and invite_code fields for co-managed wallet sessions
-- and extend session_type to support 'co_managed_keygen'
-- This migration is idempotent - safe to run multiple times
-- Add new columns for co-managed wallet sessions (idempotent)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'mpc_sessions' AND column_name = 'wallet_name') THEN
ALTER TABLE mpc_sessions ADD COLUMN wallet_name VARCHAR(255);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'mpc_sessions' AND column_name = 'invite_code') THEN
ALTER TABLE mpc_sessions ADD COLUMN invite_code VARCHAR(50);
END IF;
END $$;
-- Create index for invite_code lookups (idempotent)
CREATE INDEX IF NOT EXISTS idx_mpc_sessions_invite_code ON mpc_sessions(invite_code) WHERE invite_code IS NOT NULL;
-- Drop the existing session_type constraint
ALTER TABLE mpc_sessions DROP CONSTRAINT IF EXISTS chk_session_type;
-- Add updated session_type constraint including 'co_managed_keygen'
ALTER TABLE mpc_sessions
ADD CONSTRAINT chk_session_type CHECK (session_type IN ('keygen', 'sign', 'co_managed_keygen'));
-- Add comment for the new columns (safe to run multiple times)
COMMENT ON COLUMN mpc_sessions.wallet_name IS 'Wallet name for co-managed wallet sessions';
COMMENT ON COLUMN mpc_sessions.invite_code IS 'Invite code for co-managed wallet sessions - used for participants to join';

View File

@ -117,11 +117,8 @@ 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)
tssThreshold := config.Threshold - 1
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), tssThreshold)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, len(sortedPartyIDs), config.Threshold)
return &KeygenSession{
config: config,

View File

@ -132,15 +132,10 @@ func NewSigningSession(
keygenIndexToSortedIndex, selfParty.PartyID)
// Create peer context and parameters
// 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
// 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
peerCtx := tss.NewPeerContext(sortedPartyIDs)
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)
params := tss.NewParameters(tss.S256(), peerCtx, selfTSSID, config.TotalParties, config.Threshold)
// Convert message hash to big.Int
msgHash := new(big.Int).SetBytes(messageHash)
@ -172,17 +167,8 @@ func (s *SigningSession) Start(ctx context.Context) (*SigningResult, error) {
s.started = true
s.mu.Unlock()
// 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)
// Create local party for signing
s.localParty = signing.NewLocalParty(s.messageHash, s.params, *s.saveData, s.outCh, s.endCh)
// Start the local party
go func() {

View File

@ -833,11 +833,9 @@ 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

@ -1,864 +0,0 @@
package http
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
grpcclient "github.com/rwadurian/mpc-system/services/account/adapters/output/grpc"
"github.com/rwadurian/mpc-system/pkg/jwt"
"github.com/rwadurian/mpc-system/pkg/logger"
"go.uber.org/zap"
)
// CoManagedHTTPHandler handles HTTP requests for co-managed wallets
// This is a completely independent handler that does not affect existing functionality
type CoManagedHTTPHandler struct {
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient
db *sql.DB // Database connection for invite_code lookups
jwtService *jwt.JWTService // JWT service for generating join tokens
}
// NewCoManagedHTTPHandler creates a new CoManagedHTTPHandler
func NewCoManagedHTTPHandler(
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient,
) *CoManagedHTTPHandler {
return &CoManagedHTTPHandler{
sessionCoordinatorClient: sessionCoordinatorClient,
db: nil,
}
}
// NewCoManagedHTTPHandlerWithDB creates a new CoManagedHTTPHandler with database support
func NewCoManagedHTTPHandlerWithDB(
sessionCoordinatorClient *grpcclient.SessionCoordinatorClient,
db *sql.DB,
jwtService *jwt.JWTService,
) *CoManagedHTTPHandler {
return &CoManagedHTTPHandler{
sessionCoordinatorClient: sessionCoordinatorClient,
db: db,
jwtService: jwtService,
}
}
// RegisterRoutes registers HTTP routes for co-managed wallets
func (h *CoManagedHTTPHandler) RegisterRoutes(router *gin.RouterGroup) {
coManaged := router.Group("/co-managed")
{
// Keygen session routes
coManaged.POST("/sessions", h.CreateSession)
coManaged.POST("/sessions/:sessionId/join", h.JoinSession)
coManaged.GET("/sessions/:sessionId", h.GetSessionStatus)
coManaged.GET("/sessions/by-invite-code/:inviteCode", h.GetSessionByInviteCode)
// 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)
}
}
// generateInviteCode generates a random invite code in format XXXX-XXXX-XXXX
func generateInviteCode() string {
bytes := make([]byte, 6)
rand.Read(bytes)
code := fmt.Sprintf("%X", bytes)
return fmt.Sprintf("%s-%s-%s", code[0:4], code[4:8], code[8:12])
}
// ============================================
// Create Co-Managed Wallet Session
// ============================================
// CreateCoManagedSessionRequest represents the request for creating a co-managed wallet session
type CreateCoManagedSessionRequest struct {
WalletName string `json:"wallet_name" binding:"required"` // Wallet name
ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold (actual signers needed = t+1)
ThresholdN int `json:"threshold_n" binding:"required,min=2"` // Total parties
InitiatorPartyID string `json:"initiator_party_id" binding:"required"` // Initiator's party ID
InitiatorName string `json:"initiator_name"` // Initiator's display name
PersistentCount int `json:"persistent_count"` // Number of server parties (default 2)
}
// CreateSession handles creating a new co-managed wallet session
func (h *CoManagedHTTPHandler) CreateSession(c *gin.Context) {
var req CreateCoManagedSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate threshold
if req.ThresholdT >= req.ThresholdN {
c.JSON(http.StatusBadRequest, gin.H{"error": "threshold_t must be less than threshold_n"})
return
}
// Default persistent count is 2 (platform backup parties)
persistentCount := req.PersistentCount
if persistentCount <= 0 {
persistentCount = 2
}
// Calculate external party count
externalCount := req.ThresholdN - persistentCount
if externalCount < 1 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "threshold_n must be greater than persistent_count to allow external participants",
})
return
}
// Generate invite code
inviteCode := generateInviteCode()
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger.Info("Creating co-managed keygen session",
zap.String("wallet_name", req.WalletName),
zap.Int("threshold_n", req.ThresholdN),
zap.Int("threshold_t", req.ThresholdT),
zap.Int("persistent_count", persistentCount),
zap.Int("external_count", externalCount),
zap.String("initiator_party_id", req.InitiatorPartyID))
resp, err := h.sessionCoordinatorClient.CreateCoManagedKeygenSession(
ctx,
req.WalletName,
inviteCode,
int32(req.ThresholdN),
int32(req.ThresholdT),
int32(persistentCount),
int32(externalCount),
3600, // 1 hour expiry for session creation phase
)
if err != nil {
logger.Error("Failed to create co-managed keygen session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Get wildcard join token for external participants
wildcardToken := ""
if token, ok := resp.JoinTokens["*"]; ok {
wildcardToken = token
}
logger.Info("Co-managed keygen session created successfully",
zap.String("session_id", resp.SessionID),
zap.String("invite_code", inviteCode),
zap.Int("num_server_parties", len(resp.SelectedServerParties)))
c.JSON(http.StatusCreated, gin.H{
"session_id": resp.SessionID,
"wallet_name": req.WalletName,
"invite_code": inviteCode,
"join_token": wildcardToken, // Token for external participants (backward compatible)
"join_tokens": resp.JoinTokens, // Full join tokens map for service-party-app
"threshold_n": req.ThresholdN,
"threshold_t": req.ThresholdT,
"selected_server_parties": resp.SelectedServerParties,
"status": "waiting_for_participants",
"current_participants": len(resp.SelectedServerParties), // Server parties auto-joined
"required_participants": req.ThresholdN,
"expires_at": resp.ExpiresAt,
})
}
// ============================================
// Join Co-Managed Wallet Session
// ============================================
// JoinSessionRequest represents the request for joining a session
type JoinSessionRequest struct {
PartyID string `json:"party_id" binding:"required"` // Participant's party ID
JoinToken string `json:"join_token" binding:"required"` // Join token (from invite)
ParticipantName string `json:"participant_name"` // Display name
DeviceType string `json:"device_type"` // Device type (pc, android, ios)
DeviceID string `json:"device_id"` // Device ID
}
// JoinSession handles joining an existing co-managed session
func (h *CoManagedHTTPHandler) JoinSession(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
}
var req JoinSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Default device type
deviceType := req.DeviceType
if deviceType == "" {
deviceType = "pc"
}
deviceID := req.DeviceID
if deviceID == "" {
deviceID = req.PartyID // Use party ID as device ID if not provided
}
// Call session coordinator via gRPC
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
logger.Info("Joining co-managed session",
zap.String("session_id", sessionID),
zap.String("party_id", req.PartyID),
zap.String("participant_name", req.ParticipantName))
resp, err := h.sessionCoordinatorClient.JoinSession(
ctx,
sessionID,
req.PartyID,
req.JoinToken,
deviceType,
deviceID,
)
if err != nil {
logger.Error("Failed to join session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !resp.Success {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to join session"})
return
}
// Build other parties list
otherParties := make([]gin.H, 0, len(resp.OtherParties))
for _, p := range resp.OtherParties {
otherParties = append(otherParties, gin.H{
"party_id": p.PartyID,
"party_index": p.PartyIndex,
})
}
result := gin.H{
"success": true,
"party_index": resp.PartyIndex,
"other_parties": otherParties,
}
if resp.SessionInfo != nil {
result["session_info"] = gin.H{
"session_id": resp.SessionInfo.SessionID,
"session_type": resp.SessionInfo.SessionType,
"threshold_n": resp.SessionInfo.ThresholdN,
"threshold_t": resp.SessionInfo.ThresholdT,
"status": resp.SessionInfo.Status,
"wallet_name": resp.SessionInfo.WalletName,
}
}
logger.Info("Joined co-managed session successfully",
zap.String("session_id", sessionID),
zap.String("party_id", req.PartyID),
zap.Int32("party_index", resp.PartyIndex))
c.JSON(http.StatusOK, result)
}
// ============================================
// Get Session Status
// ============================================
// GetSessionStatus handles getting the status of a co-managed session
func (h *CoManagedHTTPHandler) GetSessionStatus(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 session status", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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 public key if keygen completed
if resp.SessionType == "co_managed_keygen" && len(resp.PublicKey) > 0 {
result["public_key"] = hex.EncodeToString(resp.PublicKey)
}
// Include participants with party_index (for service-party-app to build correct participant list)
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)
}
// ============================================
// Get Session By Invite Code
// ============================================
// GetSessionByInviteCode handles looking up a session by its invite code
// This allows Service-Party-App to find the session_id from an invite_code
func (h *CoManagedHTTPHandler) GetSessionByInviteCode(c *gin.Context) {
inviteCode := c.Param("inviteCode")
if inviteCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
return
}
// Check if database connection is available
if h.db == nil {
logger.Error("Database connection not available for invite_code lookup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
return
}
// Query database for session by invite_code
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var sessionID string
var walletName string
var thresholdN, thresholdT int
var status string
var expiresAt time.Time
err := h.db.QueryRowContext(ctx, `
SELECT id, COALESCE(wallet_name, ''), threshold_n, threshold_t, status, expires_at
FROM mpc_sessions
WHERE invite_code = $1 AND session_type = 'co_managed_keygen'
`, inviteCode).Scan(&sessionID, &walletName, &thresholdN, &thresholdT, &status, &expiresAt)
if err != nil {
if err == sql.ErrNoRows {
logger.Info("Session not found for invite_code",
zap.String("invite_code", inviteCode))
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or expired"})
return
}
logger.Error("Failed to query session by invite_code",
zap.String("invite_code", inviteCode),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup session"})
return
}
// Check if session is expired
if time.Now().After(expiresAt) {
logger.Info("Session expired for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID))
c.JSON(http.StatusGone, gin.H{"error": "session has expired"})
return
}
// Get session status from session coordinator
statusResp, err := h.sessionCoordinatorClient.GetSessionStatus(ctx, sessionID)
if err != nil {
logger.Error("Failed to get session status from coordinator",
zap.String("session_id", sessionID),
zap.Error(err))
// Return basic info without join token
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": status,
"expires_at": expiresAt.UnixMilli(),
})
return
}
// Generate a wildcard join token for this session
// This allows any participant to join using this token
var joinToken string
if h.jwtService != nil {
sessionUUID, err := uuid.Parse(sessionID)
if err == nil {
// Token valid until session expires
tokenExpiry := time.Until(expiresAt)
if tokenExpiry > 0 {
joinToken, err = h.jwtService.GenerateJoinToken(sessionUUID, "*", tokenExpiry)
if err != nil {
logger.Warn("Failed to generate join token",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
}
}
logger.Info("Found session for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID),
zap.String("wallet_name", walletName),
zap.Bool("has_join_token", joinToken != ""))
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"wallet_name": walletName,
"threshold_n": thresholdN,
"threshold_t": thresholdT,
"status": statusResp.Status,
"completed_parties": statusResp.CompletedParties,
"total_parties": statusResp.TotalParties,
"expires_at": expiresAt.UnixMilli(),
"join_token": joinToken,
})
}
// ============================================
// Co-Managed Sign Session (NEW - Independent)
// ============================================
// SignPartyInfo contains party information for signing
type SignPartyInfo struct {
PartyID string `json:"party_id" binding:"required"`
PartyIndex int32 `json:"party_index" binding:"required"`
}
// CreateSignSessionRequest represents the request for creating a co-managed sign session
type CreateSignSessionRequest struct {
KeygenSessionID string `json:"keygen_session_id" binding:"required"` // The keygen session that created the wallet
WalletName string `json:"wallet_name"` // Wallet name (for display)
MessageHash string `json:"message_hash" binding:"required"` // Hex-encoded message hash to sign
Parties []SignPartyInfo `json:"parties" binding:"required,min=1"` // Parties to participate in signing (t+1)
ThresholdT int `json:"threshold_t" binding:"required,min=1"` // Signing threshold
InitiatorName string `json:"initiator_name"` // Initiator's display name
}
// CreateSignSession handles creating a new co-managed sign session
// This is a completely new endpoint that does not affect existing sign functionality
func (h *CoManagedHTTPHandler) CreateSignSession(c *gin.Context) {
var req CreateSignSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate keygen_session_id format
if _, err := uuid.Parse(req.KeygenSessionID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid keygen_session_id format"})
return
}
// Validate message_hash (should be hex encoded)
messageHash, err := hex.DecodeString(req.MessageHash)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "message_hash must be hex encoded"})
return
}
// 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 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()
// Convert parties to gRPC format
parties := make([]grpcclient.SigningPartyInfo, len(req.Parties))
for i, p := range req.Parties {
parties[i] = grpcclient.SigningPartyInfo{
PartyID: p.PartyID,
PartyIndex: p.PartyIndex,
}
}
logger.Info("Creating co-managed sign session",
zap.String("keygen_session_id", req.KeygenSessionID),
zap.String("wallet_name", req.WalletName),
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
nil, // No delegate share for co-managed wallets
req.KeygenSessionID,
)
if err != nil {
logger.Error("Failed to create co-managed sign session", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Store invite_code mapping in database (for lookup)
if h.db != nil {
_, dbErr := h.db.ExecContext(ctx, `
UPDATE mpc_sessions
SET invite_code = $1, wallet_name = $2
WHERE id = $3
`, inviteCode, req.WalletName, resp.SessionID)
if dbErr != nil {
logger.Warn("Failed to store invite_code for sign session",
zap.String("session_id", resp.SessionID),
zap.Error(dbErr))
// Don't fail the request, just log the warning
}
}
// Get wildcard token for backward compatibility (join_token field)
wildcardToken := ""
if token, ok := resp.JoinTokens["*"]; ok {
wildcardToken = token
}
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_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, // 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,
})
}
// 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) {
inviteCode := c.Param("inviteCode")
if inviteCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invite_code is required"})
return
}
// Check if database connection is available
if h.db == nil {
logger.Error("Database connection not available for sign invite_code lookup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "service configuration error"})
return
}
// Query database for sign session by invite_code
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var sessionID string
var walletName string
var keygenSessionID string
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, ''),
status, expires_at, COALESCE(message_hash, '')
FROM mpc_sessions
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 {
logger.Info("Sign session not found for invite_code",
zap.String("invite_code", inviteCode))
c.JSON(http.StatusNotFound, gin.H{"error": "sign session not found or expired"})
return
}
logger.Error("Failed to query sign session by invite_code",
zap.String("invite_code", inviteCode),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup sign session"})
return
}
// Check if session is expired
if time.Now().After(expiresAt) {
logger.Info("Sign session expired for invite_code",
zap.String("invite_code", inviteCode),
zap.String("session_id", sessionID))
c.JSON(http.StatusGone, gin.H{"error": "sign session has expired"})
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 {
logger.Error("Failed to get sign session status from coordinator",
zap.String("session_id", sessionID),
zap.Error(err))
// Return basic info without detailed status
c.JSON(http.StatusOK, gin.H{
"session_id": sessionID,
"keygen_session_id": keygenSessionID,
"wallet_name": walletName,
"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("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,
"message_hash": hex.EncodeToString(messageHash),
"threshold_n": keygenThresholdN,
"threshold_t": keygenThresholdT,
"status": statusResp.Status,
"joined_count": statusResp.CompletedParties,
"expires_at": expiresAt.UnixMilli(),
"join_token": joinToken,
"parties": parties,
})
}

View File

@ -137,19 +137,9 @@ 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,
@ -165,11 +155,9 @@ 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: keygenThresholdN,
ThresholdN: int32(len(parties)),
ThresholdT: thresholdT,
Participants: pbParticipants,
MessageHash: messageHash,
@ -186,14 +174,12 @@ func (c *SessionCoordinatorClient) CreateSigningSessionAuto(
}
logger.Info("Sending CreateSigningSession gRPC request with delegate user share",
zap.Int32("threshold_t", thresholdT),
zap.Int32("keygen_threshold_n", keygenThresholdN),
zap.Int("num_signing_parties", len(parties)),
zap.Int("num_parties", len(parties)),
zap.String("delegate_party_id", delegateUserShare.DelegatePartyID))
} else {
logger.Info("Sending CreateSigningSession gRPC request",
zap.Int32("threshold_t", thresholdT),
zap.Int32("keygen_threshold_n", keygenThresholdN),
zap.Int("num_signing_parties", len(parties)))
zap.Int("num_parties", len(parties)))
}
resp, err := c.client.CreateSession(ctx, req)
@ -239,8 +225,6 @@ func (c *SessionCoordinatorClient) GetSessionStatus(
Status: resp.Status,
CompletedParties: resp.CompletedParties,
TotalParties: resp.TotalParties,
ThresholdT: resp.ThresholdT,
ThresholdN: resp.ThresholdN,
SessionType: resp.SessionType,
PublicKey: resp.PublicKey,
Signature: resp.Signature,
@ -256,18 +240,6 @@ func (c *SessionCoordinatorClient) GetSessionStatus(
}
}
// Include participants if present (for co_managed_keygen sessions)
if len(resp.Participants) > 0 {
result.Participants = make([]ParticipantStatusInfo, len(resp.Participants))
for i, p := range resp.Participants {
result.Participants[i] = ParticipantStatusInfo{
PartyID: p.PartyId,
PartyIndex: p.PartyIndex,
Status: p.Status,
}
}
}
return result, nil
}
@ -309,8 +281,6 @@ type SessionStatusResponse struct {
Status string
CompletedParties int32
TotalParties int32
ThresholdT int32 // Minimum parties needed to sign (e.g., 2 in 2-of-3)
ThresholdN int32 // Total number of parties required (e.g., 3 in 2-of-3)
SessionType string // "keygen" or "sign"
PublicKey []byte
Signature []byte
@ -320,16 +290,6 @@ type SessionStatusResponse struct {
// DelegateShare is non-nil when session_type="keygen" AND has_delegate=true AND session is completed
// nil with has_delegate=true means share was already retrieved (one-time retrieval)
DelegateShare *DelegateShareInfo
// Participants contains detailed participant information including party_index
// Used by service-party-app for co_managed_keygen sessions
Participants []ParticipantStatusInfo
}
// ParticipantStatusInfo contains participant status information
type ParticipantStatusInfo struct {
PartyID string
PartyIndex int32
Status string
}
// DelegateShareInfo contains the delegate party's share for user
@ -338,167 +298,3 @@ type DelegateShareInfo struct {
PartyIndex int32
PartyID string
}
// CreateCoManagedSessionResponse contains the created co-managed session info
type CreateCoManagedSessionResponse struct {
SessionID string
InviteCode string
WalletName string
SelectedServerParties []string // Auto-selected server parties
JoinTokens map[string]string // Includes wildcard token for external parties
ExpiresAt int64
ThresholdN int32
ThresholdT int32
}
// CreateCoManagedKeygenSession creates a new co-managed keygen session
// This session waits for external participants to join via invite code
func (c *SessionCoordinatorClient) CreateCoManagedKeygenSession(
ctx context.Context,
walletName string,
inviteCode string,
thresholdN int32,
thresholdT int32,
persistentCount int32, // Number of server parties to auto-select
externalCount int32, // Number of external parties (Service Party Apps)
expiresInSeconds int64,
) (*CreateCoManagedSessionResponse, error) {
req := &coordinatorpb.CreateSessionRequest{
SessionType: "co_managed_keygen",
ThresholdN: thresholdN,
ThresholdT: thresholdT,
Participants: nil, // External participants will join later
ExpiresInSeconds: expiresInSeconds,
PartyComposition: &coordinatorpb.PartyComposition{
PersistentCount: persistentCount,
DelegateCount: 0,
TemporaryCount: externalCount, // External parties treated as temporary
},
WalletName: walletName,
InviteCode: inviteCode,
}
logger.Info("Sending CreateCoManagedKeygenSession gRPC request",
zap.String("session_type", "co_managed_keygen"),
zap.String("wallet_name", walletName),
zap.Int32("threshold_n", thresholdN),
zap.Int32("threshold_t", thresholdT),
zap.Int32("persistent_count", persistentCount),
zap.Int32("external_count", externalCount))
resp, err := c.client.CreateSession(ctx, req)
if err != nil {
logger.Error("CreateCoManagedKeygenSession gRPC call failed", zap.Error(err))
return nil, fmt.Errorf("failed to create co-managed keygen session: %w", err)
}
// Extract selected server parties
var serverParties []string
for partyID := range resp.JoinTokens {
if partyID != "*" { // Exclude wildcard token
serverParties = append(serverParties, partyID)
}
}
logger.Info("CreateCoManagedKeygenSession gRPC call succeeded",
zap.String("session_id", resp.SessionId),
zap.Int("num_server_parties", len(serverParties)))
return &CreateCoManagedSessionResponse{
SessionID: resp.SessionId,
InviteCode: inviteCode,
WalletName: walletName,
SelectedServerParties: serverParties,
JoinTokens: resp.JoinTokens,
ExpiresAt: resp.ExpiresAt,
ThresholdN: thresholdN,
ThresholdT: thresholdT,
}, nil
}
// JoinSessionResponse contains join session result
type JoinSessionResponse struct {
Success bool
PartyIndex int32
SessionInfo *SessionInfoResponse
OtherParties []PartyInfoResponse
}
// SessionInfoResponse contains session info from join response
type SessionInfoResponse struct {
SessionID string
SessionType string
ThresholdN int32
ThresholdT int32
Status string
WalletName string
InviteCode string
KeygenSessionID string
}
// PartyInfoResponse contains party info
type PartyInfoResponse struct {
PartyID string
PartyIndex int32
}
// JoinSession joins an existing session
func (c *SessionCoordinatorClient) JoinSession(
ctx context.Context,
sessionID string,
partyID string,
joinToken string,
deviceType string,
deviceID string,
) (*JoinSessionResponse, error) {
req := &coordinatorpb.JoinSessionRequest{
SessionId: sessionID,
PartyId: partyID,
JoinToken: joinToken,
DeviceInfo: &coordinatorpb.DeviceInfo{
DeviceType: deviceType,
DeviceId: deviceID,
},
}
logger.Info("Sending JoinSession gRPC request",
zap.String("session_id", sessionID),
zap.String("party_id", partyID))
resp, err := c.client.JoinSession(ctx, req)
if err != nil {
logger.Error("JoinSession gRPC call failed", zap.Error(err))
return nil, fmt.Errorf("failed to join session: %w", err)
}
result := &JoinSessionResponse{
Success: resp.Success,
PartyIndex: resp.PartyIndex,
}
if resp.SessionInfo != nil {
result.SessionInfo = &SessionInfoResponse{
SessionID: resp.SessionInfo.SessionId,
SessionType: resp.SessionInfo.SessionType,
ThresholdN: resp.SessionInfo.ThresholdN,
ThresholdT: resp.SessionInfo.ThresholdT,
Status: resp.SessionInfo.Status,
WalletName: resp.SessionInfo.WalletName,
InviteCode: resp.SessionInfo.InviteCode,
KeygenSessionID: resp.SessionInfo.KeygenSessionId,
}
}
for _, p := range resp.OtherParties {
result.OtherParties = append(result.OtherParties, PartyInfoResponse{
PartyID: p.PartyId,
PartyIndex: p.PartyIndex,
})
}
logger.Info("JoinSession gRPC call succeeded",
zap.Bool("success", resp.Success),
zap.Int32("party_index", resp.PartyIndex))
return result, nil
}

View File

@ -137,7 +137,6 @@ func main() {
getRecoveryStatusUC,
cancelRecoveryUC,
sessionCoordinatorClient,
db,
); err != nil {
errChan <- fmt.Errorf("HTTP server error: %w", err)
}
@ -240,7 +239,6 @@ func startHTTPServer(
getRecoveryStatusUC *use_cases.GetRecoveryStatusUseCase,
cancelRecoveryUC *use_cases.CancelRecoveryUseCase,
sessionCoordinatorClient *grpcadapter.SessionCoordinatorClient,
db *sql.DB,
) error {
// Set Gin mode
if cfg.Server.Environment == "production" {
@ -302,19 +300,14 @@ func startHTTPServer(
})
})
// Create co-managed wallet handler (independent from existing functionality)
// Uses database connection for invite_code lookups and JWT service for generating join tokens
coManagedHandler := httphandler.NewCoManagedHTTPHandlerWithDB(sessionCoordinatorClient, db, jwtService)
// Configure authentication middleware
// Skip paths that don't require authentication
authConfig := middleware.AuthConfig{
JWTService: jwtService,
SkipPaths: []string{
"/health",
"/api/v1/auth/*", // Auth endpoints (login, refresh, challenge)
"/api/v1/auth/*", // Auth endpoints (login, refresh, challenge)
"/api/v1/accounts/from-keygen", // Internal API from coordinator
"/api/v1/co-managed/*", // Co-managed wallet API (public for Service Party App)
},
AllowAnonymous: false,
}
@ -324,9 +317,6 @@ func startHTTPServer(
api.Use(middleware.BearerAuth(authConfig))
httpHandler.RegisterRoutes(api)
// Register co-managed wallet routes (public API)
coManagedHandler.RegisterRoutes(api)
logger.Info("Starting HTTP server",
zap.Int("port", cfg.Server.HTTPPort),
zap.String("environment", cfg.Server.Environment),

View File

@ -87,49 +87,12 @@ func (s *MessageRouterServer) RouteMessage(
}
// SubscribeMessages subscribes to messages for a party (streaming)
// On subscription, it first sends any pending messages from the database
// to ensure no messages are lost during reconnection
func (s *MessageRouterServer) SubscribeMessages(
req *pb.SubscribeMessagesRequest,
stream pb.MessageRouter_SubscribeMessagesServer,
) error {
ctx := stream.Context()
logger.Info("Party subscribing to messages",
zap.String("session_id", req.SessionId),
zap.String("party_id", req.PartyId))
// First, send any pending messages from the database (message recovery on reconnect)
if s.getPendingMessagesUC != nil && req.SessionId != "" {
input := use_cases.GetPendingMessagesInput{
SessionID: req.SessionId,
PartyID: req.PartyId,
AfterTimestamp: 0, // Get all pending messages
}
pendingMessages, err := s.getPendingMessagesUC.Execute(ctx, input)
if err != nil {
logger.Warn("Failed to get pending messages on subscribe",
zap.String("session_id", req.SessionId),
zap.String("party_id", req.PartyId),
zap.Error(err))
} else if len(pendingMessages) > 0 {
logger.Info("Sending pending messages on subscribe",
zap.String("session_id", req.SessionId),
zap.String("party_id", req.PartyId),
zap.Int("count", len(pendingMessages)))
for _, msg := range pendingMessages {
if err := sendMessage(stream, msg); err != nil {
logger.Error("Failed to send pending message",
zap.String("message_id", msg.ID),
zap.Error(err))
return err
}
}
}
}
// Subscribe to party messages
partyCh, err := s.messageBroker.SubscribeToPartyMessages(ctx, req.PartyId)
if err != nil {
@ -146,9 +109,6 @@ func (s *MessageRouterServer) SubscribeMessages(
for {
select {
case <-ctx.Done():
logger.Info("Party unsubscribed from messages",
zap.String("session_id", req.SessionId),
zap.String("party_id", req.PartyId))
return nil
case msg, ok := <-partyCh:
if !ok {
@ -350,10 +310,8 @@ func (s *MessageRouterServer) SubscribeSessionEvents(
zap.String("party_id", req.PartyId))
// Subscribe to events
// 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)
eventCh := s.eventBroadcaster.Subscribe(req.PartyId)
defer s.eventBroadcaster.Unsubscribe(req.PartyId)
// Stream events
for {
@ -558,7 +516,6 @@ func (s *MessageRouterServer) JoinSession(
ThresholdT: coordResp.SessionInfo.ThresholdT,
MessageHash: coordResp.SessionInfo.MessageHash,
KeygenSessionId: coordResp.SessionInfo.KeygenSessionId,
Status: coordResp.SessionInfo.Status, // 修复: 添加缺失的 Status 字段
}
}
@ -682,24 +639,11 @@ func (s *MessageRouterServer) GetSessionStatus(
return nil, err
}
// Convert participants from coordinator response
var participants []*pb.PartyInfo
if len(coordResp.Participants) > 0 {
participants = make([]*pb.PartyInfo, len(coordResp.Participants))
for i, p := range coordResp.Participants {
participants[i] = &pb.PartyInfo{
PartyId: p.PartyId,
PartyIndex: p.PartyIndex,
}
}
}
return &pb.GetSessionStatusResponse{
SessionId: req.SessionId,
Status: coordResp.Status,
ThresholdN: coordResp.ThresholdN, // Actual threshold N from session config
ThresholdT: coordResp.ThresholdT, // Actual threshold T from session config
Participants: participants, // Include participants for co_managed_keygen
SessionId: req.SessionId,
Status: coordResp.Status,
ThresholdN: coordResp.TotalParties, // Use TotalParties as N
ThresholdT: coordResp.CompletedParties, // Return completed count in ThresholdT for info
}, nil
}

View File

@ -116,7 +116,6 @@ func (a *MessageBrokerAdapter) PublishToSession(
}
// SubscribeToPartyMessages subscribes to messages for a specific party
// If the party already has an active subscription, the old channel is closed first
func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
ctx context.Context,
partyID string,
@ -124,15 +123,11 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
a.mu.Lock()
defer a.mu.Unlock()
// Close existing channel if party is re-subscribing (e.g., after reconnect)
if oldCh, exists := a.partyChannels[partyID]; exists {
close(oldCh)
logger.Info("closed existing party channel for re-subscription",
zap.String("party_id", partyID))
// Create channel if not exists
if _, exists := a.partyChannels[partyID]; !exists {
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
}
// Create new channel
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
ch := a.partyChannels[partyID]
// Return a read-only channel
@ -160,7 +155,6 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
}
// SubscribeToSessionMessages subscribes to all messages in a session
// If the party already has an active subscription for this session, the old channel is closed first
func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
ctx context.Context,
sessionID string,
@ -177,18 +171,14 @@ func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
zap.String("key", key),
zap.Int("current_channel_count", len(a.sessionChannels)))
// Close existing channel if party is re-subscribing (e.g., after reconnect)
if oldCh, exists := a.sessionChannels[key]; exists {
close(oldCh)
logger.Info("closed existing session channel for re-subscription",
// Create channel if not exists
if _, exists := a.sessionChannels[key]; !exists {
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
logger.Info("Created new session channel",
zap.String("key", key))
}
// Create new channel
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
ch := a.sessionChannels[key]
logger.Info("Created new session channel",
zap.String("key", key))
// Return a read-only channel
out := make(chan *entities.MessageDTO, 100)

View File

@ -2,11 +2,8 @@ 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
@ -23,51 +20,25 @@ func NewSessionEventBroadcaster() *SessionEventBroadcaster {
}
// Subscribe subscribes a party to session events
// 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) {
func (b *SessionEventBroadcaster) Subscribe(partyID string) <-chan *pb.SessionEvent {
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
// Generate a unique subscription ID (using current time in nanoseconds)
subscriptionID := time.Now().UnixNano()
return ch, subscriptionID
return ch
}
// 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) {
// Unsubscribe removes a party's subscription
func (b *SessionEventBroadcaster) Unsubscribe(partyID string) {
b.mu.Lock()
defer b.mu.Unlock()
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))
}
if ch, exists := b.subscribers[partyID]; exists {
close(ch)
delete(b.subscribers, partyID)
}
}
@ -91,34 +62,16 @@ 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

@ -1,38 +0,0 @@
# Build stage
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git ca-certificates
# Set Go proxy (can be overridden with --build-arg GOPROXY=...)
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o /bin/server-party-co-managed \
./services/server-party-co-managed/cmd/server
# Final stage
FROM alpine:3.18
RUN apk --no-cache add ca-certificates curl
RUN adduser -D -s /bin/sh mpc
COPY --from=builder /bin/server-party-co-managed /bin/server-party-co-managed
USER mpc
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:8080/health || exit 1
ENTRYPOINT ["/bin/server-party-co-managed"]

View File

@ -1,464 +0,0 @@
package main
import (
"context"
"database/sql"
"encoding/hex"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
_ "github.com/lib/pq"
router "github.com/rwadurian/mpc-system/api/grpc/router/v1"
"github.com/rwadurian/mpc-system/pkg/config"
"github.com/rwadurian/mpc-system/pkg/crypto"
"github.com/rwadurian/mpc-system/pkg/logger"
grpcclient "github.com/rwadurian/mpc-system/services/server-party/adapters/output/grpc"
"github.com/rwadurian/mpc-system/services/server-party/adapters/output/postgres"
"github.com/rwadurian/mpc-system/services/server-party/application/use_cases"
"go.uber.org/zap"
)
// PendingSession stores session info between session_created and session_started events
type PendingSession struct {
SessionID uuid.UUID
JoinToken string
MessageHash []byte
ThresholdN int
ThresholdT int
SelectedParties []string
CreatedAt time.Time
}
// PendingSessionCache stores pending sessions waiting for session_started
type PendingSessionCache struct {
mu sync.RWMutex
sessions map[string]*PendingSession // sessionID -> PendingSession
}
// Global pending session cache
var pendingSessionCache = &PendingSessionCache{
sessions: make(map[string]*PendingSession),
}
// Store stores a pending session
func (c *PendingSessionCache) Store(sessionID string, session *PendingSession) {
c.mu.Lock()
defer c.mu.Unlock()
c.sessions[sessionID] = session
logger.Info("Pending session stored",
zap.String("session_id", sessionID))
}
// Get retrieves and deletes a pending session
func (c *PendingSessionCache) Get(sessionID string) (*PendingSession, bool) {
c.mu.Lock()
defer c.mu.Unlock()
session, exists := c.sessions[sessionID]
if exists {
delete(c.sessions, sessionID)
logger.Info("Pending session retrieved and deleted",
zap.String("session_id", sessionID))
}
return session, exists
}
// Delete removes a pending session without returning it
func (c *PendingSessionCache) Delete(sessionID string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.sessions, sessionID)
}
func main() {
// Parse flags
configPath := flag.String("config", "", "Path to config file")
flag.Parse()
// Load configuration
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
os.Exit(1)
}
// Initialize logger
if err := logger.Init(&logger.Config{
Level: cfg.Logger.Level,
Encoding: cfg.Logger.Encoding,
}); err != nil {
fmt.Printf("Failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer logger.Sync()
logger.Info("Starting Server Party Co-Managed Service",
zap.String("environment", cfg.Server.Environment),
zap.Int("http_port", cfg.Server.HTTPPort))
// Initialize database connection
db, err := initDatabase(cfg.Database)
if err != nil {
logger.Fatal("Failed to connect to database", zap.Error(err))
}
defer db.Close()
// Initialize crypto service with master key from environment
masterKeyHex := os.Getenv("MPC_CRYPTO_MASTER_KEY")
if masterKeyHex == "" {
masterKeyHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" // 64 hex chars = 32 bytes
}
masterKey, err := hex.DecodeString(masterKeyHex)
if err != nil {
logger.Fatal("Invalid master key format", zap.Error(err))
}
cryptoService, err := crypto.NewCryptoService(masterKey)
if err != nil {
logger.Fatal("Failed to create crypto service", zap.Error(err))
}
// Get Message Router address from environment
routerAddr := os.Getenv("MESSAGE_ROUTER_ADDR")
if routerAddr == "" {
routerAddr = "localhost:9092"
}
// Initialize Message Router client
messageRouter, err := grpcclient.NewMessageRouterClient(routerAddr)
if err != nil {
logger.Fatal("Failed to connect to message router", zap.Error(err))
}
defer messageRouter.Close()
// Initialize repositories
keyShareRepo := postgres.NewKeySharePostgresRepo(db)
// Initialize use cases
participateKeygenUC := use_cases.NewParticipateKeygenUseCase(
keyShareRepo,
messageRouter,
messageRouter,
cryptoService,
)
// Create shutdown context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Get party ID from environment
partyID := os.Getenv("PARTY_ID")
if partyID == "" {
partyID, _ = os.Hostname()
if partyID == "" {
partyID = "co-managed-party-" + uuid.New().String()[:8]
}
}
// Party role is co_managed_persistent - different from normal persistent
// This ensures co_managed_keygen sessions only select these parties
partyRole := "co_managed_persistent"
// Register this party with Message Router
logger.Info("Registering co-managed party with Message Router",
zap.String("party_id", partyID),
zap.String("role", partyRole))
if err := messageRouter.RegisterPartyWithNotification(ctx, partyID, partyRole, "1.0.0", nil); err != nil {
logger.Fatal("Failed to register party", zap.Error(err))
}
// Start heartbeat
heartbeatCancel := messageRouter.StartHeartbeat(ctx, partyID, 30*time.Second, func(pendingCount int32) {
if pendingCount > 0 {
logger.Info("Pending messages detected via heartbeat",
zap.String("party_id", partyID),
zap.Int32("pending_count", pendingCount))
}
})
defer heartbeatCancel()
logger.Info("Heartbeat started", zap.String("party_id", partyID), zap.Duration("interval", 30*time.Second))
// Subscribe to session events with two-phase handling for co_managed_keygen
logger.Info("Subscribing to session events (co_managed_keygen only)", zap.String("party_id", partyID))
eventHandler := createCoManagedSessionEventHandler(
ctx,
partyID,
messageRouter,
participateKeygenUC,
)
if err := messageRouter.SubscribeSessionEvents(ctx, partyID, eventHandler); err != nil {
logger.Fatal("Failed to subscribe to session events", zap.Error(err))
}
logger.Info("Co-managed party initialized successfully",
zap.String("party_id", partyID),
zap.String("role", partyRole))
// Start HTTP server
errChan := make(chan error, 1)
go func() {
if err := startHTTPServer(cfg); err != nil {
errChan <- fmt.Errorf("HTTP server error: %w", err)
}
}()
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigChan:
logger.Info("Received shutdown signal", zap.String("signal", sig.String()))
case err := <-errChan:
logger.Error("Server error", zap.Error(err))
}
// Graceful shutdown
logger.Info("Shutting down...")
cancel()
time.Sleep(5 * time.Second)
logger.Info("Shutdown complete")
}
func initDatabase(cfg config.DatabaseConfig) (*sql.DB, error) {
const maxRetries = 10
const retryDelay = 2 * time.Second
var db *sql.DB
var err error
for i := 0; i < maxRetries; i++ {
db, err = sql.Open("postgres", cfg.DSN())
if err != nil {
logger.Warn("Failed to open database connection, retrying...",
zap.Int("attempt", i+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
time.Sleep(retryDelay * time.Duration(i+1))
continue
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLife)
if err = db.Ping(); err != nil {
logger.Warn("Failed to ping database, retrying...",
zap.Int("attempt", i+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
db.Close()
time.Sleep(retryDelay * time.Duration(i+1))
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
var result int
err = db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
cancel()
if err != nil {
logger.Warn("Database ping succeeded but query failed, retrying...",
zap.Int("attempt", i+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
db.Close()
time.Sleep(retryDelay * time.Duration(i+1))
continue
}
logger.Info("Connected to PostgreSQL and verified connectivity",
zap.Int("attempt", i+1))
return db, nil
}
return nil, fmt.Errorf("failed to connect to database after %d retries: %w", maxRetries, err)
}
func startHTTPServer(cfg *config.Config) error {
if cfg.Server.Environment == "production" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "server-party-co-managed",
})
})
logger.Info("Starting HTTP server", zap.Int("port", cfg.Server.HTTPPort))
return r.Run(fmt.Sprintf(":%d", cfg.Server.HTTPPort))
}
// createCoManagedSessionEventHandler creates a handler specifically for co_managed_keygen sessions
// Two-phase event handling:
// Phase 1 (session_created): JoinSession immediately + store session info
// Phase 2 (session_started): Execute TSS protocol (same timing as user clients receiving all_joined)
func createCoManagedSessionEventHandler(
ctx context.Context,
partyID string,
messageRouter *grpcclient.MessageRouterClient,
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
) func(*router.SessionEvent) {
return func(event *router.SessionEvent) {
// Check if this party is selected for the session
isSelected := false
for _, selectedParty := range event.SelectedParties {
if selectedParty == partyID {
isSelected = true
break
}
}
if !isSelected {
logger.Debug("Party not selected for this session",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
return
}
logger.Info("Received session event",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.String("event_type", event.EventType))
// Parse session ID
sessionID, err := uuid.Parse(event.SessionId)
if err != nil {
logger.Error("Invalid session ID", zap.Error(err))
return
}
// Handle different event types
switch event.EventType {
case "session_created":
// Only handle keygen sessions (no message_hash)
if len(event.MessageHash) > 0 {
logger.Debug("Ignoring sign session (co-managed only handles keygen)",
zap.String("session_id", event.SessionId))
return
}
// Phase 1: Get join token
joinToken, exists := event.JoinTokens[partyID]
if !exists {
logger.Error("No join token found for party in session_created",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
return
}
// Immediately call JoinSession (this is required to trigger session_started)
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
_, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
joinCancel()
if err != nil {
logger.Error("Failed to join session",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID),
zap.Error(err))
return
}
logger.Info("Successfully joined session, waiting for session_started",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
// Store pending session for later use when session_started arrives
pendingSessionCache.Store(event.SessionId, &PendingSession{
SessionID: sessionID,
JoinToken: joinToken,
MessageHash: event.MessageHash,
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
SelectedParties: event.SelectedParties,
CreatedAt: time.Now(),
})
case "session_started":
// Phase 2: All participants have joined, now execute TSS protocol
pendingSession, exists := pendingSessionCache.Get(event.SessionId)
if !exists {
logger.Warn("No pending session found for session_started event",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
return
}
logger.Info("Session started event received, beginning TSS keygen protocol",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
// Execute TSS keygen protocol in goroutine
// Timeout starts NOW (when session_started is received), not at session_created
go func() {
// 10 minute timeout for TSS protocol execution
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
logger.Info("Auto-participating in co_managed_keygen session",
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
// Build SessionInfo from session_started event (NOT from pendingSession cache)
// session_started event contains ALL participants who have joined,
// including external parties that joined dynamically after session_created
// Note: We already called JoinSession in session_created phase,
// so we use ExecuteWithSessionInfo to skip the duplicate JoinSession call
participants := make([]use_cases.ParticipantInfo, len(event.SelectedParties))
for i, p := range event.SelectedParties {
participants[i] = use_cases.ParticipantInfo{
PartyID: p,
PartyIndex: i,
}
}
sessionInfo := &use_cases.SessionInfo{
SessionID: pendingSession.SessionID,
SessionType: "co_managed_keygen",
ThresholdN: int(event.ThresholdN),
ThresholdT: int(event.ThresholdT),
MessageHash: pendingSession.MessageHash,
Participants: participants,
}
result, err := participateKeygenUC.ExecuteWithSessionInfo(
participateCtx,
pendingSession.SessionID,
partyID,
sessionInfo,
)
if err != nil {
logger.Error("Co-managed keygen participation failed",
zap.Error(err),
zap.String("session_id", event.SessionId))
} else {
logger.Info("Co-managed keygen participation completed",
zap.String("session_id", event.SessionId),
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
}
}()
default:
logger.Debug("Ignoring unhandled event type",
zap.String("session_id", event.SessionId),
zap.String("event_type", event.EventType))
}
}
}

View File

@ -385,7 +385,7 @@ func (c *MessageRouterClient) UpdateNotificationChannels(
return c.RegisterPartyWithNotification(ctx, partyID, partyRole, version, notification)
}
// SubscribeSessionEvents subscribes to session lifecycle events with auto-reconnect
// SubscribeSessionEvents subscribes to session lifecycle events
func (c *MessageRouterClient) SubscribeSessionEvents(
ctx context.Context,
partyID string,
@ -396,7 +396,7 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
EventTypes: []string{}, // Subscribe to all event types
}
// Create initial streaming connection
// Create a streaming connection
stream, err := c.createSessionEventStream(ctx, req)
if err != nil {
logger.Error("Failed to subscribe to session events",
@ -408,12 +408,8 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
logger.Info("Subscribed to session events",
zap.String("party_id", partyID))
// Start goroutine to receive events with auto-reconnect
// Start goroutine to receive events
go func() {
currentStream := stream
reconnectBackoff := time.Second // Start with 1 second backoff
maxBackoff := 30 * time.Second
for {
select {
case <-ctx.Done():
@ -422,60 +418,27 @@ func (c *MessageRouterClient) SubscribeSessionEvents(
return
default:
event := &router.SessionEvent{}
err := currentStream.RecvMsg(event)
err := stream.RecvMsg(event)
if err == io.EOF {
logger.Warn("Session event stream ended, reconnecting...",
logger.Info("Session event stream ended",
zap.String("party_id", partyID))
} else if err != nil {
logger.Warn("Error receiving session event, reconnecting...",
return
}
if err != nil {
logger.Error("Error receiving session event",
zap.Error(err),
zap.String("party_id", partyID))
} else {
// Successfully received event, reset backoff
reconnectBackoff = time.Second
logger.Info("Received session event",
zap.String("event_type", event.EventType),
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
// Call event handler
if eventHandler != nil {
eventHandler(event)
}
continue
return
}
// Reconnect with exponential backoff
for {
select {
case <-ctx.Done():
return
case <-time.After(reconnectBackoff):
logger.Info("Attempting to reconnect session event stream",
zap.String("party_id", partyID),
zap.Duration("backoff", reconnectBackoff))
logger.Info("Received session event",
zap.String("event_type", event.EventType),
zap.String("session_id", event.SessionId),
zap.String("party_id", partyID))
newStream, err := c.createSessionEventStream(ctx, req)
if err != nil {
logger.Error("Failed to reconnect session event stream",
zap.Error(err),
zap.String("party_id", partyID))
// Increase backoff for next attempt
reconnectBackoff = reconnectBackoff * 2
if reconnectBackoff > maxBackoff {
reconnectBackoff = maxBackoff
}
continue
}
logger.Info("Successfully reconnected to session events",
zap.String("party_id", partyID))
currentStream = newStream
reconnectBackoff = time.Second // Reset backoff on success
break
}
break
// Call event handler
if eventHandler != nil {
eventHandler(event)
}
}
}
@ -851,45 +814,3 @@ func (c *MessageRouterClient) SubmitDelegateShare(
return nil
})
}
// GetSessionStatusFull gets the full session status including participants via Message Router
// This is used for co_managed_keygen sessions to wait for all parties to join
// Includes automatic retry with exponential backoff for transient failures
func (c *MessageRouterClient) GetSessionStatusFull(
ctx context.Context,
sessionID uuid.UUID,
) (*use_cases.SessionStatusInfo, error) {
req := &router.GetSessionStatusRequest{
SessionId: sessionID.String(),
}
return retry.Do(ctx, c.retryCfg, "GetSessionStatusFull", func() (*use_cases.SessionStatusInfo, error) {
resp := &router.GetSessionStatusResponse{}
err := c.getConn().Invoke(ctx, "/mpc.router.v1.MessageRouter/GetSessionStatus", req, resp)
if err != nil {
return nil, err
}
// Convert participants from response
participants := make([]use_cases.ParticipantInfo, len(resp.Participants))
for i, p := range resp.Participants {
participants[i] = use_cases.ParticipantInfo{
PartyID: p.PartyId,
PartyIndex: int(p.PartyIndex),
}
}
logger.Debug("GetSessionStatusFull response",
zap.String("session_id", sessionID.String()),
zap.String("status", resp.Status),
zap.Int32("threshold_n", resp.ThresholdN),
zap.Int("participants_count", len(participants)))
return &use_cases.SessionStatusInfo{
Status: resp.Status,
ThresholdN: int(resp.ThresholdN),
ThresholdT: int(resp.ThresholdT),
Participants: participants,
}, nil
})
}

View File

@ -41,22 +41,12 @@ type ParticipateKeygenOutput struct {
type SessionCoordinatorClient interface {
JoinSession(ctx context.Context, sessionID uuid.UUID, partyID, joinToken string) (*SessionInfo, error)
ReportCompletion(ctx context.Context, sessionID uuid.UUID, partyID string, publicKey []byte) error
GetSessionStatusFull(ctx context.Context, sessionID uuid.UUID) (*SessionStatusInfo, error)
}
// SessionStatusInfo contains full session status information
type SessionStatusInfo struct {
Status string
ThresholdN int
ThresholdT int
Participants []ParticipantInfo
}
// MessageRouterClient defines the interface for message router communication
type MessageRouterClient interface {
RouteMessage(ctx context.Context, sessionID uuid.UUID, fromParty string, toParties []string, roundNumber int, payload []byte) error
SubscribeMessages(ctx context.Context, sessionID uuid.UUID, partyID string) (<-chan *MPCMessage, error)
Heartbeat(ctx context.Context, partyID string) (int32, error)
}
// SessionInfo contains session information from coordinator
@ -120,61 +110,16 @@ func (uc *ParticipateKeygenUseCase) Execute(
return nil, err
}
// Accept both "keygen" and "co_managed_keygen" session types
if sessionInfo.SessionType != "keygen" && sessionInfo.SessionType != "co_managed_keygen" {
if sessionInfo.SessionType != "keygen" {
return nil, ErrInvalidSession
}
// For co_managed_keygen: wait for all N participants to join before proceeding
// This is necessary because server parties join immediately but external party joins later
if sessionInfo.SessionType == "co_managed_keygen" {
sessionInfo, err = uc.waitForAllParticipants(ctx, input.SessionID, sessionInfo, input.PartyID)
if err != nil {
return nil, err
}
}
// Delegate to the common execution logic
return uc.executeWithSessionInfo(ctx, input.SessionID, input.PartyID, sessionInfo)
}
// ExecuteWithSessionInfo participates in a keygen session with pre-obtained SessionInfo
// This is used by server-party-co-managed which has already called JoinSession in session_created phase
// and receives session_started event when all participants have joined
func (uc *ParticipateKeygenUseCase) ExecuteWithSessionInfo(
ctx context.Context,
sessionID uuid.UUID,
partyID string,
sessionInfo *SessionInfo,
) (*ParticipateKeygenOutput, error) {
// Validate session type
if sessionInfo.SessionType != "keygen" && sessionInfo.SessionType != "co_managed_keygen" {
return nil, ErrInvalidSession
}
logger.Info("ExecuteWithSessionInfo: starting keygen with pre-obtained session info",
zap.String("session_id", sessionID.String()),
zap.String("party_id", partyID),
zap.String("session_type", sessionInfo.SessionType),
zap.Int("participants", len(sessionInfo.Participants)))
// Delegate to the common execution logic
return uc.executeWithSessionInfo(ctx, sessionID, partyID, sessionInfo)
}
// executeWithSessionInfo is the common execution logic shared by Execute and ExecuteWithSessionInfo
func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
ctx context.Context,
sessionID uuid.UUID,
partyID string,
sessionInfo *SessionInfo,
) (*ParticipateKeygenOutput, error) {
// 1. Find self in participants and build party index map
// 2. Find self in participants and build party index map
var selfIndex int
partyIndexMap := make(map[string]int)
for _, p := range sessionInfo.Participants {
partyIndexMap[p.PartyID] = p.PartyIndex
if p.PartyID == partyID {
if p.PartyID == input.PartyID {
selfIndex = p.PartyIndex
}
logger.Debug("Added participant to index map",
@ -182,13 +127,13 @@ func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
zap.Int("party_index", p.PartyIndex))
}
logger.Info("Built party index map",
zap.String("session_id", sessionID.String()),
zap.String("self_party_id", partyID),
zap.String("session_id", input.SessionID.String()),
zap.String("self_party_id", input.PartyID),
zap.Int("self_index", selfIndex),
zap.Int("total_participants", len(sessionInfo.Participants)))
// 3. Subscribe to messages
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, sessionID, partyID)
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, input.SessionID, input.PartyID)
if err != nil {
return nil, err
}
@ -196,8 +141,8 @@ func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
// 4. Run TSS Keygen protocol
saveData, publicKey, err := uc.runKeygenProtocol(
ctx,
sessionID,
partyID,
input.SessionID,
input.PartyID,
selfIndex,
sessionInfo.Participants,
sessionInfo.ThresholdN,
@ -210,15 +155,15 @@ func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
}
// 5. Encrypt the share
encryptedShare, err := uc.cryptoService.EncryptShare(saveData, partyID)
encryptedShare, err := uc.cryptoService.EncryptShare(saveData, input.PartyID)
if err != nil {
return nil, err
}
keyShare := entities.NewPartyKeyShare(
partyID,
input.PartyID,
selfIndex,
sessionID,
input.SessionID,
sessionInfo.ThresholdN,
sessionInfo.ThresholdT,
encryptedShare,
@ -236,21 +181,21 @@ func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
return nil, ErrShareSaveFailed
}
logger.Info("Share saved to database (persistent party)",
zap.String("party_id", partyID),
zap.String("session_id", sessionID.String()))
zap.String("party_id", input.PartyID),
zap.String("session_id", input.SessionID.String()))
case "delegate":
// Delegate Party: do NOT save to database, return to user
shareForUser = encryptedShare
logger.Info("Share NOT saved, will be returned to user (delegate party)",
zap.String("party_id", partyID),
zap.String("session_id", sessionID.String()),
zap.String("party_id", input.PartyID),
zap.String("session_id", input.SessionID.String()),
zap.Int("share_size", len(shareForUser)))
case "temporary":
// Temporary Party: optionally save to temp storage (not implemented yet)
logger.Info("Temporary party - share not saved",
zap.String("party_id", partyID))
zap.String("party_id", input.PartyID))
default:
// Default to persistent for safety
@ -258,12 +203,12 @@ func (uc *ParticipateKeygenUseCase) executeWithSessionInfo(
return nil, ErrShareSaveFailed
}
logger.Warn("Unknown party role, defaulting to persistent",
zap.String("party_id", partyID),
zap.String("party_id", input.PartyID),
zap.String("role", partyRole))
}
// 7. Report completion to coordinator
if err := uc.sessionClient.ReportCompletion(ctx, sessionID, partyID, publicKey); err != nil {
if err := uc.sessionClient.ReportCompletion(ctx, input.SessionID, input.PartyID, publicKey); err != nil {
logger.Error("failed to report completion", zap.Error(err))
// Don't fail - share is handled
}
@ -423,89 +368,3 @@ func (uc *ParticipateKeygenUseCase) getPartyRole() string {
}
return role
}
// waitForAllParticipants waits for all N participants to join the session
// This is only used for co_managed_keygen sessions where server parties join first
// and need to wait for the external party to join via invite code
func (uc *ParticipateKeygenUseCase) waitForAllParticipants(
ctx context.Context,
sessionID uuid.UUID,
initialSessionInfo *SessionInfo,
partyID string,
) (*SessionInfo, error) {
logger.Info("Waiting for all participants to join co_managed_keygen session",
zap.String("session_id", sessionID.String()),
zap.Int("expected_n", initialSessionInfo.ThresholdN),
zap.Int("current_participants", len(initialSessionInfo.Participants)))
// If already have all participants, return immediately
if len(initialSessionInfo.Participants) >= initialSessionInfo.ThresholdN {
logger.Info("All participants already joined",
zap.String("session_id", sessionID.String()))
return initialSessionInfo, nil
}
// Poll for session status until all participants join or timeout
pollInterval := 2 * time.Second
maxWaitTime := 5 * time.Minute
deadline := time.Now().Add(maxWaitTime)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(pollInterval):
// Send heartbeat to keep the party alive during wait
// This prevents the session-coordinator from timing out this party
_, heartbeatErr := uc.messageRouter.Heartbeat(ctx, partyID)
if heartbeatErr != nil {
logger.Warn("Failed to send heartbeat during wait",
zap.String("session_id", sessionID.String()),
zap.String("party_id", partyID),
zap.Error(heartbeatErr))
// Continue anyway - heartbeat failure is not fatal
}
// Get full session status including participants
statusInfo, err := uc.sessionClient.GetSessionStatusFull(ctx, sessionID)
if err != nil {
logger.Warn("Failed to get session status, will retry",
zap.String("session_id", sessionID.String()),
zap.Error(err))
continue
}
logger.Debug("Polled session status",
zap.String("session_id", sessionID.String()),
zap.String("status", statusInfo.Status),
zap.Int("participants", len(statusInfo.Participants)),
zap.Int("expected_n", initialSessionInfo.ThresholdN))
// Check if session is in_progress (all parties joined and ready)
if statusInfo.Status == "in_progress" && len(statusInfo.Participants) >= initialSessionInfo.ThresholdN {
logger.Info("All participants joined, session is in_progress",
zap.String("session_id", sessionID.String()),
zap.Int("participants", len(statusInfo.Participants)))
// Update session info with full participants list
initialSessionInfo.Participants = statusInfo.Participants
return initialSessionInfo, nil
}
// Also accept if we have all N participants even if status hasn't changed
if len(statusInfo.Participants) >= initialSessionInfo.ThresholdN {
logger.Info("All participants joined",
zap.String("session_id", sessionID.String()),
zap.Int("participants", len(statusInfo.Participants)))
initialSessionInfo.Participants = statusInfo.Participants
return initialSessionInfo, nil
}
}
}
logger.Error("Timeout waiting for all participants",
zap.String("session_id", sessionID.String()),
zap.Int("expected_n", initialSessionInfo.ThresholdN))
return nil, ErrKeygenTimeout
}

View File

@ -1,99 +0,0 @@
# 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

@ -1,101 +0,0 @@
# 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

@ -1,200 +0,0 @@
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

@ -1,21 +0,0 @@
# 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

@ -1,44 +0,0 @@
<?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

@ -1,526 +0,0 @@
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

@ -1,7 +0,0 @@
package com.durian.tssparty
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TssPartyApplication : Application()

View File

@ -1,104 +0,0 @@
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

@ -1,172 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,234 +0,0 @@
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

@ -1,85 +0,0 @@
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

@ -1,258 +0,0 @@
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

@ -1,847 +0,0 @@
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

@ -1,229 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,542 +0,0 @@
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

@ -1,199 +0,0 @@
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

@ -1,273 +0,0 @@
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

@ -1,716 +0,0 @@
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

@ -1,100 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,207 +0,0 @@
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

@ -1,629 +0,0 @@
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

@ -1,215 +0,0 @@
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

@ -1,17 +0,0 @@
<?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

@ -1,5 +0,0 @@
<?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

@ -1,5 +0,0 @@
<?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

@ -1,8 +0,0 @@
<?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

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TSS Party</string>
</resources>

View File

@ -1,6 +0,0 @@
<?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

@ -1,6 +0,0 @@
<?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

@ -1,13 +0,0 @@
<?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

@ -1,290 +0,0 @@
@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

@ -1,18 +0,0 @@
// 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

@ -1,115 +0,0 @@
# 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

@ -1,229 +0,0 @@
[
{
"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

@ -1 +0,0 @@
608060405234801561001057600080fd5b5033600081815260208181526040808320670de0b6b3a76400009081905590519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a36106fb8061006d6000396000f3fe608060405234801561001057600080fd5b50600436106100935760003560e01c8063313ce56711610066578063313ce5671461012b57806370a082311461014557806395d89b411461016e578063a9059cbb14610192578063dd62ed3e146101a557600080fd5b806306fdde0314610098578063095ea7b3146100d857806318160ddd146100fb57806323b872dd14610118575b600080fd5b6100c26040518060400160405280600b81526020016a111d5c9a585b881554d11560aa1b81525081565b6040516100cf91906105a0565b60405180910390f35b6100eb6100e636600461060a565b6101de565b60405190151581526020016100cf565b61010a670de0b6b3a764000081565b6040519081526020016100cf565b6100eb610126366004610634565b6102a0565b610133600681565b60405160ff90911681526020016100cf565b61010a610153366004610670565b6001600160a01b031660009081526020819052604090205490565b6100c260405180604001604052806005815260200164191554d11560da1b81525081565b6100eb6101a036600461060a565b61049a565b61010a6101b3366004610692565b6001600160a01b03918216600090815260016020908152604080832093909416825291909152205490565b60006001600160a01b03831661023b5760405162461bcd60e51b815260206004820152601760248201527f417070726f766520746f207a65726f206164647265737300000000000000000060448201526064015b60405180910390fd5b3360008181526001602090815260408083206001600160a01b03881680855290835292819020869055518581529192917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a350600192915050565b60006001600160a01b0384166102f85760405162461bcd60e51b815260206004820152601a60248201527f5472616e736665722066726f6d207a65726f20616464726573730000000000006044820152606401610232565b6001600160a01b0383166103495760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b6001600160a01b0384166000908152602081905260409020548211156103a85760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b6001600160a01b03841660009081526001602090815260408083203384529091529020548211156104145760405162461bcd60e51b8152602060048201526016602482015275496e73756666696369656e7420616c6c6f77616e636560501b6044820152606401610232565b6001600160a01b03848116600081815260208181526040808320805488900390559387168083528483208054880190558383526001825284832033845282529184902080548790039055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b60006001600160a01b0383166104ed5760405162461bcd60e51b81526020600482015260186024820152775472616e7366657220746f207a65726f206164647265737360401b6044820152606401610232565b336000908152602081905260409020548211156105435760405162461bcd60e51b8152602060048201526014602482015273496e73756666696369656e742062616c616e636560601b6044820152606401610232565b33600081815260208181526040808320805487900390556001600160a01b03871680845292819020805487019055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910161028f565b600060208083528351808285015260005b818110156105cd578581018301518582016040015282016105b1565b506000604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b038116811461060557600080fd5b919050565b6000806040838503121561061d57600080fd5b610626836105ee565b946020939093013593505050565b60008060006060848603121561064957600080fd5b610652846105ee565b9250610660602085016105ee565b9150604084013590509250925092565b60006020828403121561068257600080fd5b61068b826105ee565b9392505050565b600080604083850312156106a557600080fd5b6106ae836105ee565b91506106bc602084016105ee565b9050925092905056fea264697066735822122028c97073f6e7db0ad943d101cb6873b31c3eb19bcea3eda83148447ab676a5ee64736f6c63430008130033

View File

@ -1,78 +0,0 @@
// 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

@ -1,120 +0,0 @@
# 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

@ -1,83 +0,0 @@
# 钱包密钥信息
> **重要安全警告**: 本文件包含私钥,仅供内部使用。切勿将此文件提交到公开仓库或分享给他人。
## 管理员钱包
该钱包用于部署和管理 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

@ -1,11 +0,0 @@
# 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

@ -1,7 +0,0 @@
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

@ -1,184 +0,0 @@
#!/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

@ -1,92 +0,0 @@
@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

@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TSSPartyAndroid"
include(":app")

View File

@ -1,23 +0,0 @@
@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

@ -1,24 +0,0 @@
#!/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

@ -1,37 +0,0 @@
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

@ -1,272 +0,0 @@
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

@ -1,657 +0,0 @@
// 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

@ -1,39 +0,0 @@
# Dependencies
node_modules/
# Build outputs
dist/
dist-electron/
release/
# Logs
logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
.env.*.local
# Temporary files
*.tmp
*.temp
# SQLite database files
*.db
*.sqlite
*.sqlite3
# Electron build cache
.electron/

View File

@ -1,168 +0,0 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ============================================
echo Service Party App - Windows Build Script
echo ============================================
echo.
cd /d "%~dp0"
:: Parse command line arguments
set "DO_CLEAN=0"
set "DO_CLEAN_ALL=0"
if "%1"=="clean" set "DO_CLEAN=1"
if "%1"=="--clean" set "DO_CLEAN=1"
if "%1"=="-c" set "DO_CLEAN=1"
if "%1"=="cleanall" set "DO_CLEAN_ALL=1"
if "%1"=="--clean-all" set "DO_CLEAN_ALL=1"
:: Clean all (includes node_modules)
if "%DO_CLEAN_ALL%"=="1" (
echo [CLEAN] Performing full clean...
if exist "dist" rmdir /s /q "dist"
if exist "dist-electron" rmdir /s /q "dist-electron"
if exist "release" rmdir /s /q "release"
if exist "node_modules" rmdir /s /q "node_modules"
if exist "package-lock.json" del /q "package-lock.json"
if exist "node_modules\.cache" rmdir /s /q "node_modules\.cache"
echo [OK] Full clean completed
echo.
)
:: Clean build artifacts only
if "%DO_CLEAN%"=="1" (
echo [CLEAN] Cleaning build artifacts...
if exist "dist" rmdir /s /q "dist"
if exist "dist-electron" rmdir /s /q "dist-electron"
if exist "release" rmdir /s /q "release"
if exist "node_modules\.cache" rmdir /s /q "node_modules\.cache"
echo [OK] Clean completed
echo.
)
:: Check Node.js
where node >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Node.js not found. Please install Node.js 18+
echo Download: https://nodejs.org/
goto :error
)
:: Check Go
where go >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Go not found. Please install Go 1.21+
echo Download: https://go.dev/dl/
goto :error
)
:: Show versions
echo [INFO] Node.js version:
node --version
echo [INFO] Go version:
go version
echo.
:: Step 1: Build TSS subprocess
echo ============================================
echo [1/4] Building TSS subprocess (Go)...
echo ============================================
cd tss-party
if not exist "..\bin\win32-x64" mkdir "..\bin\win32-x64"
echo Building tss-party.exe...
go build -ldflags="-s -w" -o ..\bin\win32-x64\tss-party.exe .
if %errorlevel% neq 0 (
echo [ERROR] TSS subprocess build failed
goto :error
)
echo [OK] tss-party.exe created
cd ..
echo.
:: Step 2: Install dependencies
echo ============================================
echo [2/4] Installing npm dependencies...
echo ============================================
if exist "node_modules" (
if "%DO_CLEAN_ALL%"=="0" (
echo Dependencies exist, skipping install
) else (
call npm install
if %errorlevel% neq 0 (
echo [ERROR] npm install failed
goto :error
)
)
) else (
call npm install
if %errorlevel% neq 0 (
echo [ERROR] npm install failed
goto :error
)
)
echo [OK] Dependencies installed
echo.
:: Step 3: Check resources
echo ============================================
echo [3/4] Checking resources...
echo ============================================
if not exist "build" mkdir "build"
if not exist "build\icon.ico" echo [WARN] build\icon.ico not found, using default icon
echo.
:: Step 4: Build Electron app
echo ============================================
echo [4/4] Building Windows application...
echo ============================================
echo Building frontend code...
call npm run build:win
if %errorlevel% neq 0 (
echo [ERROR] Application build failed
goto :error
)
echo.
echo ============================================
echo Build Complete!
echo ============================================
echo.
echo Output directory: %cd%\release\
echo.
:: List generated files
if exist "release" (
echo Generated files:
dir /b release\*.exe 2>nul
)
echo.
echo ============================================
echo Usage Tips
echo ============================================
echo.
echo build-windows.bat Normal build
echo build-windows.bat clean Clean and rebuild
echo build-windows.bat cleanall Full clean (delete node_modules) and rebuild
echo.
echo Press any key to exit...
pause >nul
exit /b 0
:error
echo.
echo Build failed. Please check error messages.
echo.
echo If you encounter module/type errors, try:
echo build-windows.bat cleanall
echo.
pause
exit /b 1

View File

@ -1,85 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "com.rwadurian.service-party",
"productName": "Service Party",
"copyright": "Copyright © 2024 RWADurian",
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"afterPack": "./scripts/afterPack.js",
"extraMetadata": {
"main": "dist-electron/main.js"
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
},
{
"target": "portable",
"arch": ["x64"]
}
],
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Service Party"
},
"mac": {
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"icon": "build/icon.icns",
"category": "public.app-category.utilities",
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "deb",
"arch": ["x64"]
}
],
"icon": "build/icons",
"category": "Utility",
"artifactName": "${productName}-${version}-${platform}-${arch}.${ext}"
},
"publish": null
}

File diff suppressed because it is too large Load Diff

View File

@ -1,379 +0,0 @@
/**
* Account Service HTTP Client
*
* Service-Party-App Account HTTP API
* / keygen sign
*/
// =============================================================================
// 类型定义
// =============================================================================
// Keygen 会话相关
export interface CreateKeygenSessionRequest {
wallet_name: string;
threshold_t: number;
threshold_n: number;
initiator_party_id: string;
initiator_name?: string;
persistent_count: number;
external_count: number;
expires_in_seconds?: number;
}
export interface CreateKeygenSessionResponse {
session_id: string;
invite_code: string;
wallet_name: string;
threshold_n: number;
threshold_t: number;
selected_server_parties: string[];
join_tokens: Record<string, string>;
join_token?: string; // Wildcard token for backward compatibility
expires_at: number;
}
export interface JoinSessionRequest {
party_id: string;
join_token: string;
device_type?: string;
device_id?: string;
}
export interface PartyInfo {
party_id: string;
party_index: number;
}
export interface SessionInfo {
session_id: string;
session_type: string;
threshold_n: number;
threshold_t: number;
status: string;
wallet_name: string;
invite_code: string;
keygen_session_id?: string;
}
export interface JoinSessionResponse {
success: boolean;
party_index: number;
session_info: SessionInfo;
other_parties: PartyInfo[];
}
// Participant status information with party_index
export interface ParticipantStatusInfo {
party_id: string;
party_index: number;
status: string;
}
export interface GetSessionStatusResponse {
session_id: string;
status: string;
threshold_t: number; // Minimum parties needed to sign (e.g., 2 in 2-of-3)
threshold_n: number; // Total number of parties required (e.g., 3 in 2-of-3)
completed_parties: number;
total_parties: number;
session_type: string;
public_key?: string;
signature?: string;
has_delegate: boolean;
// participants contains detailed participant information including party_index
// Used for co_managed_keygen sessions to build correct participant list
participants?: ParticipantStatusInfo[];
}
export interface GetSessionByInviteCodeResponse {
session_id: string;
wallet_name: string;
threshold_n: number;
threshold_t: number;
status: string;
invite_code: string;
expires_at: number;
joined_parties: number;
completed_parties?: number;
total_parties?: number;
join_token?: string;
}
// Sign 会话相关
export interface SignPartyInfo {
party_id: string;
party_index: number;
}
export interface CreateSignSessionRequest {
keygen_session_id: string;
wallet_name: string;
message_hash: string;
parties: SignPartyInfo[];
threshold_t: number;
initiator_name?: string;
}
export interface CreateSignSessionResponse {
session_id: string;
invite_code: string;
keygen_session_id: string;
wallet_name: string;
threshold_t: number;
selected_parties: string[];
expires_at: number;
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 {
session_id: string;
keygen_session_id: string;
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;
}
// 错误响应
export interface ErrorResponse {
error: string;
message?: string;
}
// =============================================================================
// HTTP 客户端类
// =============================================================================
export class AccountClient {
private baseUrl: string;
private timeout: number;
/**
*
* @param baseUrl Account URL (例如: https://api.szaiai.com 或 http://localhost:8080)
* @param timeout ()
*/
constructor(baseUrl: string, timeout: number = 30000) {
// 移除末尾的斜杠
this.baseUrl = baseUrl.replace(/\/$/, '');
this.timeout = timeout;
}
/**
* URL
*/
setBaseUrl(baseUrl: string): void {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* HTTP
*/
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
body?: unknown
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
console.log(`[AccountClient] ${method} ${url}`, body ? JSON.stringify(body) : '');
const response = await fetch(url, options);
const text = await response.text();
let data: T | ErrorResponse;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Invalid JSON response: ${text}`);
}
if (!response.ok) {
const errorData = data as ErrorResponse;
throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`);
}
console.log(`[AccountClient] Response:`, data);
return data as T;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${this.timeout}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// ===========================================================================
// Keygen 会话 API
// ===========================================================================
/**
* Keygen
*/
async createKeygenSession(
params: CreateKeygenSessionRequest
): Promise<CreateKeygenSessionResponse> {
return this.request<CreateKeygenSessionResponse>(
'POST',
'/api/v1/co-managed/sessions',
params
);
}
/**
*
*/
async joinSession(
sessionId: string,
params: JoinSessionRequest
): Promise<JoinSessionResponse> {
return this.request<JoinSessionResponse>(
'POST',
`/api/v1/co-managed/sessions/${sessionId}/join`,
params
);
}
/**
*
*/
async getSessionStatus(sessionId: string): Promise<GetSessionStatusResponse> {
return this.request<GetSessionStatusResponse>(
'GET',
`/api/v1/co-managed/sessions/${sessionId}`
);
}
/**
* Keygen
*/
async getSessionByInviteCode(inviteCode: string): Promise<GetSessionByInviteCodeResponse> {
return this.request<GetSessionByInviteCodeResponse>(
'GET',
`/api/v1/co-managed/sessions/by-invite-code/${inviteCode}`
);
}
// ===========================================================================
// Sign 会话 API
// ===========================================================================
/**
* Sign
*/
async createSignSession(
params: CreateSignSessionRequest
): Promise<CreateSignSessionResponse> {
return this.request<CreateSignSessionResponse>(
'POST',
'/api/v1/co-managed/sign',
params
);
}
/**
* Sign
*/
async getSignSessionByInviteCode(inviteCode: string): Promise<GetSignSessionByInviteCodeResponse> {
return this.request<GetSignSessionByInviteCodeResponse>(
'GET',
`/api/v1/co-managed/sign/by-invite-code/${inviteCode}`
);
}
/**
* Sign
*/
async getSignSessionStatus(sessionId: string): Promise<GetSignSessionStatusResponse> {
return this.request<GetSignSessionStatusResponse>(
'GET',
`/api/v1/co-managed/sign/${sessionId}`
);
}
// ===========================================================================
// 健康检查
// ===========================================================================
/**
*
*/
async healthCheck(): Promise<{ status: string; service: string }> {
return this.request<{ status: string; service: string }>(
'GET',
'/health'
);
}
/**
*
*/
async testConnection(): Promise<boolean> {
try {
const result = await this.healthCheck();
return result.status === 'healthy';
} catch {
return false;
}
}
}
// =============================================================================
// 默认实例
// =============================================================================
// 默认使用生产环境地址
const DEFAULT_ACCOUNT_SERVICE_URL = 'https://rwaapi.szaiai.com';
// 创建默认客户端实例
export const accountClient = new AccountClient(DEFAULT_ACCOUNT_SERVICE_URL);

View File

@ -1,333 +0,0 @@
import * as crypto from 'crypto';
import { bech32 } from 'bech32';
// =============================================================================
// 链配置
// =============================================================================
export interface ChainConfig {
name: string;
prefix: string;
coinType: number;
curve: 'secp256k1' | 'ed25519';
derivationPath: string;
}
export const CHAIN_CONFIGS: Record<string, ChainConfig> = {
kava: {
name: 'Kava',
prefix: 'kava',
coinType: 459,
curve: 'secp256k1',
derivationPath: "m/44'/459'/0'/0/0",
},
cosmos: {
name: 'Cosmos Hub',
prefix: 'cosmos',
coinType: 118,
curve: 'secp256k1',
derivationPath: "m/44'/118'/0'/0/0",
},
osmosis: {
name: 'Osmosis',
prefix: 'osmo',
coinType: 118,
curve: 'secp256k1',
derivationPath: "m/44'/118'/0'/0/0",
},
ethereum: {
name: 'Ethereum',
prefix: '0x',
coinType: 60,
curve: 'secp256k1',
derivationPath: "m/44'/60'/0'/0/0",
},
};
// =============================================================================
// 地址派生工具
// =============================================================================
/**
* Bech32 (Cosmos )
*
* :
* 1. SHA256 RIPEMD160 20
* 2. 20 Bech32
*/
export function deriveCosmosAddress(publicKeyHex: string, prefix: string): string {
// 移除可能的 0x 前缀
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
// 对于 secp256k1需要压缩公钥 (33 bytes)
// 如果是未压缩公钥 (65 bytes),需要先压缩
let compressedKey: Buffer = publicKeyBytes;
if (publicKeyBytes.length === 65) {
compressedKey = compressSecp256k1PublicKey(publicKeyBytes);
} else if (publicKeyBytes.length === 64) {
// 没有前缀的未压缩公钥
const uncompressed = Buffer.concat([Buffer.from([0x04]), publicKeyBytes]);
compressedKey = compressSecp256k1PublicKey(uncompressed);
}
// SHA256 → RIPEMD160
const sha256Hash = crypto.createHash('sha256').update(compressedKey).digest();
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
// Bech32 编码
const words = bech32.toWords(ripemd160Hash);
const address = bech32.encode(prefix, words);
return address;
}
/**
*
*
* :
* 1. ( 04 ) Keccak256 20
*/
export function deriveEthereumAddress(publicKeyHex: string): string {
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
// 需要未压缩公钥的 x, y 坐标 (64 bytes)
let uncompressedKey: Buffer;
if (publicKeyBytes.length === 33) {
// 压缩公钥,需要解压
uncompressedKey = decompressSecp256k1PublicKey(publicKeyBytes);
} else if (publicKeyBytes.length === 65) {
// 未压缩公钥,去掉 04 前缀
uncompressedKey = publicKeyBytes.slice(1) as Buffer;
} else if (publicKeyBytes.length === 64) {
uncompressedKey = publicKeyBytes;
} else {
throw new Error(`Invalid public key length: ${publicKeyBytes.length}`);
}
// Keccak256 (使用 keccak256 而不是 sha3-256)
const { keccak_256 } = require('@noble/hashes/sha3');
const hash = keccak_256(uncompressedKey);
// 取后 20 字节
const addressBytes = hash.slice(-20);
const address = '0x' + Buffer.from(addressBytes).toString('hex');
return checksumAddress(address);
}
/**
* Ed25519 ()
*/
export function deriveEd25519Address(publicKeyHex: string, prefix: string): string {
const cleanHex = publicKeyHex.startsWith('0x') ? publicKeyHex.slice(2) : publicKeyHex;
const publicKeyBytes = Buffer.from(cleanHex, 'hex');
// SHA256 → RIPEMD160
const sha256Hash = crypto.createHash('sha256').update(publicKeyBytes).digest();
const ripemd160Hash = crypto.createHash('ripemd160').update(sha256Hash).digest();
// Bech32 编码
const words = bech32.toWords(ripemd160Hash);
const address = bech32.encode(prefix, words);
return address;
}
/**
* secp256k1
*/
function compressSecp256k1PublicKey(uncompressed: Buffer): Buffer {
if (uncompressed.length !== 65 || uncompressed[0] !== 0x04) {
throw new Error('Invalid uncompressed public key');
}
const x = uncompressed.slice(1, 33);
const y = uncompressed.slice(33, 65);
// 判断 y 是奇数还是偶数
const prefix = y[31] % 2 === 0 ? 0x02 : 0x03;
return Buffer.concat([Buffer.from([prefix]), x]);
}
/**
* secp256k1
* 使用椭圆曲线数学: y² = x³ + 7 (mod p)
*/
function decompressSecp256k1PublicKey(compressed: Buffer): Buffer {
if (compressed.length !== 33) {
throw new Error('Invalid compressed public key');
}
// secp256k1 曲线参数
const p = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F');
const prefix = compressed[0];
const x = BigInt('0x' + compressed.slice(1).toString('hex'));
// 计算 y² = x³ + 7 (mod p)
const xCubed = modPow(x, 3n, p);
const ySquared = (xCubed + 7n) % p;
// 计算平方根 (p ≡ 3 mod 4, 所以 y = ySquared^((p+1)/4) mod p)
let y = modPow(ySquared, (p + 1n) / 4n, p);
// 根据前缀选择正确的 y 值
const isYOdd = y % 2n === 1n;
const shouldBeOdd = prefix === 0x03;
if (isYOdd !== shouldBeOdd) {
y = p - y;
}
// 转换为 Buffer (64 bytes: x || y)
const xBuffer = Buffer.from(x.toString(16).padStart(64, '0'), 'hex');
const yBuffer = Buffer.from(y.toString(16).padStart(64, '0'), 'hex');
return Buffer.concat([xBuffer, yBuffer]);
}
/**
*
*/
function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
let result = 1n;
base = base % modulus;
while (exponent > 0n) {
if (exponent % 2n === 1n) {
result = (result * base) % modulus;
}
exponent = exponent / 2n;
base = (base * base) % modulus;
}
return result;
}
/**
* EIP-55
*/
function checksumAddress(address: string): string {
const { keccak_256 } = require('@noble/hashes/sha3');
const addr = address.toLowerCase().replace('0x', '');
const hash = Buffer.from(keccak_256(Buffer.from(addr, 'utf8'))).toString('hex');
let result = '0x';
for (let i = 0; i < addr.length; i++) {
if (parseInt(hash[i], 16) >= 8) {
result += addr[i].toUpperCase();
} else {
result += addr[i];
}
}
return result;
}
// =============================================================================
// 地址派生服务
// =============================================================================
export interface DerivedAddress {
chain: string;
chainName: string;
prefix: string;
address: string;
derivationPath: string;
publicKeyHex: string;
}
/**
*
*
* TSS keygen
* 使
* HD
*
*
* - 使
* -
* - TSS
*/
export class AddressDerivationService {
/**
* TSS
*/
deriveAddress(publicKeyHex: string, chain: string): DerivedAddress {
const config = CHAIN_CONFIGS[chain];
if (!config) {
throw new Error(`Unsupported chain: ${chain}`);
}
let address: string;
if (chain === 'ethereum') {
address = deriveEthereumAddress(publicKeyHex);
} else if (config.curve === 'ed25519') {
address = deriveEd25519Address(publicKeyHex, config.prefix);
} else {
// Cosmos 系列 (kava, cosmos, osmosis 等)
address = deriveCosmosAddress(publicKeyHex, config.prefix);
}
return {
chain,
chainName: config.name,
prefix: config.prefix,
address,
derivationPath: config.derivationPath,
publicKeyHex,
};
}
/**
*
*/
deriveAllAddresses(publicKeyHex: string): DerivedAddress[] {
const addresses: DerivedAddress[] = [];
for (const chain of Object.keys(CHAIN_CONFIGS)) {
try {
const derived = this.deriveAddress(publicKeyHex, chain);
addresses.push(derived);
} catch (err) {
console.error(`Failed to derive ${chain} address:`, err);
}
}
return addresses;
}
/**
*
*/
validateAddress(address: string, chain: string): boolean {
const config = CHAIN_CONFIGS[chain];
if (!config) {
return false;
}
if (chain === 'ethereum') {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
try {
const decoded = bech32.decode(address);
return decoded.prefix === config.prefix;
} catch {
return false;
}
}
/**
*
*/
getSupportedChains(): ChainConfig[] {
return Object.values(CHAIN_CONFIGS);
}
}
// 导出单例
export const addressDerivationService = new AddressDerivationService();

View File

@ -1,778 +0,0 @@
import * as crypto from 'crypto';
import * as path from 'path';
import * as fs from 'fs';
import { app } from 'electron';
import initSqlJs from 'sql.js';
import type { Database as SqlJsDatabase, SqlJsStatic } from 'sql.js';
import { v4 as uuidv4 } from 'uuid';
// =============================================================================
// sql.js WASM 文件路径
// =============================================================================
function getSqlJsWasmPath(): string {
// 在开发环境中WASM 文件在 node_modules 中
// 在生产环境中WASM 文件被复制到 extraResources 目录
const isDev = !app.isPackaged;
if (isDev) {
// 开发环境: 使用 node_modules 中的文件
return path.join(__dirname, '../../node_modules/sql.js/dist/sql-wasm.wasm');
} else {
// 生产环境: 使用 extraResources 中的文件
return path.join(process.resourcesPath, 'sql-wasm.wasm');
}
}
// =============================================================================
// 数据库路径
// =============================================================================
function getDatabasePath(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'service-party.db');
}
// =============================================================================
// 加密配置
// =============================================================================
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const SALT_LENGTH = 32;
const TAG_LENGTH = 16;
const ITERATIONS = 100000;
// =============================================================================
// 数据类型定义
// =============================================================================
export interface ShareRecord {
id: string;
session_id: string;
wallet_name: string;
party_id: string;
party_index: number;
threshold_t: number;
threshold_n: number;
public_key_hex: string;
encrypted_share: string;
created_at: string;
last_used_at: string | null;
participants_json: string; // JSON 存储参与者列表
}
export interface DerivedAddressRecord {
id: string;
share_id: string;
chain: string;
derivation_path: string;
address: string;
public_key_hex: string;
created_at: string;
}
export interface SigningHistoryRecord {
id: string;
share_id: string;
session_id: string;
message_hash: string;
signature: string | null;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
error_message: string | null;
created_at: string;
completed_at: string | null;
}
export interface SettingsRecord {
key: string;
value: string;
}
// =============================================================================
// 数据库管理类 (使用 sql.js - 纯 JavaScript SQLite)
// =============================================================================
export class DatabaseManager {
private db: SqlJsDatabase | null = null;
private SQL: SqlJsStatic | null = null;
private dbPath: string;
private initPromise: Promise<void>;
constructor() {
this.dbPath = getDatabasePath();
this.initPromise = this.initialize();
}
/**
*
*/
private async initialize(): Promise<void> {
// 获取 WASM 文件路径
const wasmPath = getSqlJsWasmPath();
console.log('[Database] App packaged:', app.isPackaged);
console.log('[Database] Resources path:', process.resourcesPath);
console.log('[Database] WASM path:', wasmPath);
console.log('[Database] WASM exists:', fs.existsSync(wasmPath));
// 初始化 sql.js (加载 WASM)
// 使用 wasmBinary 直接加载 WASM 文件,这在打包环境中更可靠
let config: { wasmBinary?: ArrayBuffer; locateFile?: (file: string) => string } = {};
if (fs.existsSync(wasmPath)) {
// 直接读取 WASM 文件作为 ArrayBuffer - 这种方式更可靠
const wasmBuffer = fs.readFileSync(wasmPath);
config.wasmBinary = wasmBuffer.buffer.slice(
wasmBuffer.byteOffset,
wasmBuffer.byteOffset + wasmBuffer.byteLength
);
console.log('[Database] WASM loaded as binary, size:', wasmBuffer.length);
} else {
console.warn('[Database] WASM file not found, sql.js will try to load from default location');
// 作为备用方案,使用 locateFile
config.locateFile = (file: string) => {
console.log('[Database] locateFile called for:', file);
return file;
};
}
this.SQL = await initSqlJs(config);
// 如果数据库文件存在,加载它
if (fs.existsSync(this.dbPath)) {
const buffer = fs.readFileSync(this.dbPath);
this.db = new this.SQL.Database(buffer);
} else {
this.db = new this.SQL.Database();
}
// 创建表结构
this.createTables();
this.saveToFile();
}
/**
*
*/
private async ensureReady(): Promise<void> {
await this.initPromise;
}
/**
*
*/
async waitForReady(): Promise<void> {
await this.initPromise;
}
/**
*
*/
private createTables(): void {
if (!this.db) return;
this.db.run(`
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
wallet_name TEXT NOT NULL,
party_id TEXT NOT NULL,
party_index INTEGER NOT NULL,
threshold_t INTEGER NOT NULL,
threshold_n INTEGER NOT NULL,
public_key_hex TEXT NOT NULL,
encrypted_share TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT,
participants_json TEXT NOT NULL DEFAULT '[]'
)
`);
this.db.run(`
CREATE TABLE IF NOT EXISTS derived_addresses (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
chain TEXT NOT NULL,
derivation_path TEXT NOT NULL,
address TEXT NOT NULL,
public_key_hex TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(share_id, chain, derivation_path)
)
`);
this.db.run(`
CREATE TABLE IF NOT EXISTS signing_history (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
session_id TEXT NOT NULL,
message_hash TEXT NOT NULL,
signature TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
)
`);
this.db.run(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
// 已处理消息表 - 用于消息去重,防止重连后重复处理消息
this.db.run(`
CREATE TABLE IF NOT EXISTS processed_messages (
message_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
processed_at TEXT NOT NULL
)
`);
// 创建索引
this.db.run(`CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_share ON derived_addresses(share_id)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_chain ON derived_addresses(chain)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_share ON signing_history(share_id)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_status ON signing_history(status)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processed_messages_session ON processed_messages(session_id)`);
// 插入默认设置
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['message_router_url', 'mpc-grpc.szaiai.com:443']);
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['auto_backup', 'false']);
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['backup_path', '']);
}
/**
*
*/
private saveToFile(): void {
if (!this.db) return;
const data = this.db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(this.dbPath, buffer);
}
/**
*
*/
private deriveKey(password: string, salt: Buffer): Buffer {
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH, 'sha256');
}
/**
*
*/
private encrypt(data: string, password: string): string {
const salt = crypto.randomBytes(SALT_LENGTH);
const key = this.deriveKey(password, salt);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();
// 格式: salt(hex) + iv(hex) + tag(hex) + encrypted(hex)
return salt.toString('hex') + iv.toString('hex') + tag.toString('hex') + encrypted;
}
/**
*
*/
private decrypt(encryptedData: string, password: string): string {
const salt = Buffer.from(encryptedData.slice(0, SALT_LENGTH * 2), 'hex');
const iv = Buffer.from(encryptedData.slice(SALT_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2), 'hex');
const tag = Buffer.from(encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2, SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2), 'hex');
const encrypted = encryptedData.slice(SALT_LENGTH * 2 + IV_LENGTH * 2 + TAG_LENGTH * 2);
const key = this.deriveKey(password, salt);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
*
*/
private queryToObjects<T>(sql: string, params: unknown[] = []): T[] {
if (!this.db) return [];
const results = this.db.exec(sql, params);
if (results.length === 0) return [];
const columns = results[0].columns;
return results[0].values.map((row: (number | string | Uint8Array | null)[]) => {
const obj: Record<string, unknown> = {};
columns.forEach((col: string, i: number) => {
obj[col] = row[i];
});
return obj as T;
});
}
/**
*
*/
private queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
const results = this.queryToObjects<T>(sql, params);
return results[0];
}
// ===========================================================================
// Share 操作
// ===========================================================================
/**
* share
*/
saveShare(params: {
sessionId: string;
walletName: string;
partyId: string;
partyIndex: number;
thresholdT: number;
thresholdN: number;
publicKeyHex: string;
rawShare: string;
participants: Array<{ partyId: string; name: string }>;
}, password: string): ShareRecord {
if (!this.db) throw new Error('Database not initialized');
const id = uuidv4();
const encryptedShare = this.encrypt(params.rawShare, password);
const now = new Date().toISOString();
this.db.run(`
INSERT INTO shares (
id, session_id, wallet_name, party_id, party_index,
threshold_t, threshold_n, public_key_hex, encrypted_share,
created_at, participants_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
params.sessionId,
params.walletName,
params.partyId,
params.partyIndex,
params.thresholdT,
params.thresholdN,
params.publicKeyHex,
encryptedShare,
now,
JSON.stringify(params.participants)
]);
this.saveToFile();
return {
id,
session_id: params.sessionId,
wallet_name: params.walletName,
party_id: params.partyId,
party_index: params.partyIndex,
threshold_t: params.thresholdT,
threshold_n: params.thresholdN,
public_key_hex: params.publicKeyHex,
encrypted_share: encryptedShare,
created_at: now,
last_used_at: null,
participants_json: JSON.stringify(params.participants),
};
}
/**
* share ()
*/
listShares(): Omit<ShareRecord, 'encrypted_share'>[] {
return this.queryToObjects<Omit<ShareRecord, 'encrypted_share'>>(`
SELECT id, session_id, wallet_name, party_id, party_index,
threshold_t, threshold_n, public_key_hex, created_at,
last_used_at, participants_json
FROM shares
ORDER BY created_at DESC
`);
}
/**
* share ()
*/
getShare(id: string, password: string): ShareRecord & { raw_share: string } {
const share = this.queryOne<ShareRecord>(`SELECT * FROM shares WHERE id = ?`, [id]);
if (!share) {
throw new Error('Share not found');
}
const rawShare = this.decrypt(share.encrypted_share, password);
return {
...share,
raw_share: rawShare,
};
}
/**
* share 使
*/
updateShareLastUsed(id: string): void {
if (!this.db) return;
this.db.run(`UPDATE shares SET last_used_at = ? WHERE id = ?`, [new Date().toISOString(), id]);
this.saveToFile();
}
/**
* share ()
*/
deleteShare(id: string): void {
if (!this.db) return;
// 手动级联删除
this.db.run(`DELETE FROM derived_addresses WHERE share_id = ?`, [id]);
this.db.run(`DELETE FROM signing_history WHERE share_id = ?`, [id]);
this.db.run(`DELETE FROM shares WHERE id = ?`, [id]);
this.saveToFile();
}
// ===========================================================================
// 派生地址操作
// ===========================================================================
/**
*
*/
saveDerivedAddress(params: {
shareId: string;
chain: string;
derivationPath: string;
address: string;
publicKeyHex: string;
}): DerivedAddressRecord {
if (!this.db) throw new Error('Database not initialized');
const id = uuidv4();
const now = new Date().toISOString();
this.db.run(`
INSERT OR REPLACE INTO derived_addresses (
id, share_id, chain, derivation_path, address, public_key_hex, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
id,
params.shareId,
params.chain,
params.derivationPath,
params.address,
params.publicKeyHex,
now
]);
this.saveToFile();
return {
id,
share_id: params.shareId,
chain: params.chain,
derivation_path: params.derivationPath,
address: params.address,
public_key_hex: params.publicKeyHex,
created_at: now,
};
}
/**
* share
*/
getAddressesByShare(shareId: string): DerivedAddressRecord[] {
return this.queryToObjects<DerivedAddressRecord>(`
SELECT * FROM derived_addresses
WHERE share_id = ?
ORDER BY chain, derivation_path
`, [shareId]);
}
/**
*
*/
getAddressByChain(shareId: string, chain: string): DerivedAddressRecord | undefined {
return this.queryOne<DerivedAddressRecord>(`
SELECT * FROM derived_addresses
WHERE share_id = ? AND chain = ?
LIMIT 1
`, [shareId, chain]);
}
/**
*
*/
getAllAddressesByChain(chain: string): DerivedAddressRecord[] {
return this.queryToObjects<DerivedAddressRecord>(`
SELECT * FROM derived_addresses
WHERE chain = ?
ORDER BY created_at DESC
`, [chain]);
}
// ===========================================================================
// 签名历史操作
// ===========================================================================
/**
*
*/
createSigningHistory(params: {
shareId: string;
sessionId: string;
messageHash: string;
}): SigningHistoryRecord {
if (!this.db) throw new Error('Database not initialized');
const id = uuidv4();
const now = new Date().toISOString();
this.db.run(`
INSERT INTO signing_history (
id, share_id, session_id, message_hash, status, created_at
) VALUES (?, ?, ?, ?, 'pending', ?)
`, [id, params.shareId, params.sessionId, params.messageHash, now]);
this.saveToFile();
return {
id,
share_id: params.shareId,
session_id: params.sessionId,
message_hash: params.messageHash,
signature: null,
status: 'pending',
error_message: null,
created_at: now,
completed_at: null,
};
}
/**
*
*/
updateSigningHistory(id: string, params: {
status: SigningHistoryRecord['status'];
signature?: string;
errorMessage?: string;
}): void {
if (!this.db) return;
const completedAt = params.status === 'completed' || params.status === 'failed'
? new Date().toISOString()
: null;
this.db.run(`
UPDATE signing_history
SET status = ?, signature = ?, error_message = ?, completed_at = ?
WHERE id = ?
`, [
params.status,
params.signature || null,
params.errorMessage || null,
completedAt,
id
]);
this.saveToFile();
}
/**
* share
*/
getSigningHistoryByShare(shareId: string): SigningHistoryRecord[] {
return this.queryToObjects<SigningHistoryRecord>(`
SELECT * FROM signing_history
WHERE share_id = ?
ORDER BY created_at DESC
`, [shareId]);
}
// ===========================================================================
// 设置操作
// ===========================================================================
/**
*
*/
getSetting(key: string): string | undefined {
const row = this.queryOne<{ value: string }>(`SELECT value FROM settings WHERE key = ?`, [key]);
return row?.value;
}
/**
*
*/
setSetting(key: string, value: string): void {
if (!this.db) return;
this.db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`, [key, value]);
this.saveToFile();
}
/**
*
*/
getAllSettings(): Record<string, string> {
const rows = this.queryToObjects<SettingsRecord>(`SELECT key, value FROM settings`);
const settings: Record<string, string> = {};
for (const row of rows) {
settings[row.key] = row.value;
}
return settings;
}
// ===========================================================================
// 消息去重操作
// ===========================================================================
/**
*
*/
isMessageProcessed(messageId: string): boolean {
const row = this.queryOne<{ message_id: string }>(
`SELECT message_id FROM processed_messages WHERE message_id = ?`,
[messageId]
);
return !!row;
}
/**
*
*/
markMessageProcessed(messageId: string, sessionId: string): void {
if (!this.db) return;
const now = new Date().toISOString();
this.db.run(
`INSERT OR IGNORE INTO processed_messages (message_id, session_id, processed_at) VALUES (?, ?, ?)`,
[messageId, sessionId, now]
);
this.saveToFile();
}
/**
*
*
*/
clearProcessedMessages(sessionId: string): void {
if (!this.db) return;
this.db.run(`DELETE FROM processed_messages WHERE session_id = ?`, [sessionId]);
this.saveToFile();
}
/**
* 24
*
*/
cleanupOldProcessedMessages(): void {
if (!this.db) return;
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
this.db.run(`DELETE FROM processed_messages WHERE processed_at < ?`, [cutoff]);
this.saveToFile();
}
// ===========================================================================
// 导入导出
// ===========================================================================
/**
* share ()
*/
exportShare(id: string, password: string): Buffer {
const share = this.getShare(id, password);
const addresses = this.getAddressesByShare(id);
const exportData = {
version: '1.0.0',
exportedAt: new Date().toISOString(),
share: {
session_id: share.session_id,
wallet_name: share.wallet_name,
party_id: share.party_id,
party_index: share.party_index,
threshold_t: share.threshold_t,
threshold_n: share.threshold_n,
public_key_hex: share.public_key_hex,
raw_share: share.raw_share,
participants: JSON.parse(share.participants_json),
},
addresses: addresses.map(addr => ({
chain: addr.chain,
derivation_path: addr.derivation_path,
address: addr.address,
public_key_hex: addr.public_key_hex,
})),
};
const encrypted = this.encrypt(JSON.stringify(exportData), password);
return Buffer.from(encrypted, 'utf8');
}
/**
* share
*/
importShare(data: Buffer, password: string): ShareRecord {
if (!this.db) throw new Error('Database not initialized');
const encrypted = data.toString('utf8');
const decrypted = this.decrypt(encrypted, password);
const exportData = JSON.parse(decrypted);
if (!exportData.version || !exportData.share) {
throw new Error('Invalid export file format');
}
// 检查是否已存在
const existing = this.queryOne<{ id: string }>(`
SELECT id FROM shares WHERE session_id = ? AND party_id = ?
`, [exportData.share.session_id, exportData.share.party_id]);
if (existing) {
throw new Error('Share already exists');
}
// 保存 share
const share = this.saveShare({
sessionId: exportData.share.session_id,
walletName: exportData.share.wallet_name,
partyId: exportData.share.party_id,
partyIndex: exportData.share.party_index,
thresholdT: exportData.share.threshold_t,
thresholdN: exportData.share.threshold_n,
publicKeyHex: exportData.share.public_key_hex,
rawShare: exportData.share.raw_share,
participants: exportData.share.participants,
}, password);
// 恢复派生地址
if (exportData.addresses) {
for (const addr of exportData.addresses) {
this.saveDerivedAddress({
shareId: share.id,
chain: addr.chain,
derivationPath: addr.derivation_path,
address: addr.address,
publicKeyHex: addr.public_key_hex,
});
}
}
return share;
}
/**
*
*/
close(): void {
if (this.db) {
this.saveToFile();
this.db.close();
this.db = null;
}
}
}

View File

@ -1,736 +0,0 @@
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import * as path from 'path';
import { EventEmitter } from 'events';
import { app } from 'electron';
// 定义 proto 包结构类型
interface ProtoPackage {
mpc?: {
router?: {
v1?: {
MessageRouter?: grpc.ServiceClientConstructor;
};
};
};
}
// 延迟加载的 Proto 定义
let packageDefinition: protoLoader.PackageDefinition | null = null;
// Proto 文件路径 - 在打包后需要从 app.asar.unpacked 或 resources 目录加载
function getProtoPath(): string {
// 开发环境
if (!app.isPackaged) {
return path.join(__dirname, '../../proto/message_router.proto');
}
// 生产环境 - proto 文件需要解包
return path.join(process.resourcesPath, 'proto/message_router.proto');
}
// 延迟加载 Proto 定义
function loadProtoDefinition(): protoLoader.PackageDefinition {
if (!packageDefinition) {
const protoPath = getProtoPath();
console.log('Loading proto from:', protoPath);
packageDefinition = protoLoader.loadSync(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
}
return packageDefinition;
}
// Note: field names must match proto definitions with keepCase: true
// Proto uses snake_case: session_id, session_type, threshold_n, threshold_t
interface SessionInfo {
session_id: string;
session_type: string;
threshold_n: number;
threshold_t: number;
message_hash?: Buffer;
keygen_session_id?: string;
status?: string;
}
interface PartyInfo {
party_id: string;
party_index: number;
}
interface JoinSessionResponse {
success: boolean;
session_info?: SessionInfo;
party_index: number;
other_parties: PartyInfo[];
}
interface MPCMessage {
message_id: string;
session_id: string;
from_party: string;
is_broadcast: boolean;
round_number: number;
payload: Buffer;
created_at: string;
}
interface SessionEvent {
event_id: string;
event_type: string;
session_id: string;
threshold_n: number;
threshold_t: number;
selected_parties: string[];
join_tokens: Record<string, string>;
message_hash?: Buffer;
}
// Raw proto response (snake_case)
interface RegisteredPartyProto {
party_id: string;
role: string;
online: boolean;
registered_at: string;
last_seen_at: string;
}
interface GetRegisteredPartiesResponse {
parties: RegisteredPartyProto[];
}
// Converted response (camelCase) - used by callers
interface RegisteredParty {
partyId: string;
role: string;
online: boolean;
registeredAt: string;
lastSeenAt: string;
}
// 重连配置
interface ReconnectConfig {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
maxRetries: 10,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
};
/**
* gRPC - Message Router
*
* :
* - 开发环境: localhost:50051 ()
* - 生产环境: mpc-grpc.szaiai.com:443 (TLS )
*
* :
* - 退
* -
* -
*/
export class GrpcClient extends EventEmitter {
private client: grpc.Client | null = null;
private connected = false;
private partyId: string | null = null;
private partyRole: string | null = null;
private heartbeatInterval: NodeJS.Timeout | null = null;
private messageStream: grpc.ClientReadableStream<MPCMessage> | null = null;
private eventStream: grpc.ClientReadableStream<SessionEvent> | null = null;
// 重连相关
private reconnectConfig: ReconnectConfig;
private currentAddress: string | null = null;
private currentUseTLS: boolean | undefined;
private isReconnecting = false;
private reconnectAttempts = 0;
private reconnectTimeout: NodeJS.Timeout | null = null;
private shouldReconnect = true;
// 消息流状态(用于重连后恢复)
private activeMessageSubscription: { sessionId: string; partyId: string } | null = null;
private eventStreamSubscribed = false;
// 心跳失败计数
private heartbeatFailCount = 0;
private readonly MAX_HEARTBEAT_FAILS = 3;
constructor(reconnectConfig?: Partial<ReconnectConfig>) {
super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...reconnectConfig };
}
/**
* Message Router
* @param address 格式: host:port ( mpc-grpc.szaiai.com:443 localhost:50051)
* @param useTLS 使 TLS (默认: 自动检测 443 使 TLS)
*/
async connect(address: string, useTLS?: boolean): Promise<void> {
// 保存连接参数用于重连
this.currentAddress = address;
this.currentUseTLS = useTLS;
this.shouldReconnect = true;
return this.doConnect(address, useTLS);
}
private async doConnect(address: string, useTLS?: boolean): Promise<void> {
return new Promise((resolve, reject) => {
const definition = loadProtoDefinition();
const proto = grpc.loadPackageDefinition(definition) as ProtoPackage;
const MessageRouter = proto.mpc?.router?.v1?.MessageRouter;
if (!MessageRouter) {
reject(new Error('Failed to load MessageRouter service definition'));
return;
}
// 解析地址,如果没有端口则默认使用 443
let targetAddress = address;
if (!address.includes(':')) {
targetAddress = `${address}:443`;
}
// 自动检测是否使用 TLS: 端口 443 或显式指定
const port = parseInt(targetAddress.split(':')[1], 10);
const shouldUseTLS = useTLS !== undefined ? useTLS : (port === 443);
// 创建凭证
const credentials = shouldUseTLS
? grpc.credentials.createSsl() // TLS 加密 (生产环境)
: grpc.credentials.createInsecure(); // 不加密 (开发环境)
console.log(`[gRPC] Connecting to Message Router: ${targetAddress} (TLS: ${shouldUseTLS})`);
this.client = new MessageRouter(
targetAddress,
credentials
) as grpc.Client;
// 等待连接就绪
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 10);
(this.client as grpc.Client & { waitForReady: (deadline: Date, callback: (err?: Error) => void) => void })
.waitForReady(deadline, (err?: Error) => {
if (err) {
reject(err);
} else {
this.connected = true;
this.reconnectAttempts = 0; // 重置重连计数
this.heartbeatFailCount = 0;
console.log('[gRPC] Connected successfully');
this.emit('connected');
resolve();
}
});
});
}
/**
*
*/
disconnect(): void {
this.shouldReconnect = false;
this.cleanupConnection();
}
/**
*
*/
private cleanupConnection(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.messageStream) {
try {
this.messageStream.cancel();
} catch (e) {
// 忽略取消错误
}
this.messageStream = null;
}
if (this.eventStream) {
try {
this.eventStream.cancel();
} catch (e) {
// 忽略取消错误
}
this.eventStream = null;
}
if (this.client) {
try {
(this.client as grpc.Client & { close: () => void }).close();
} catch (e) {
// 忽略关闭错误
}
this.client = null;
}
this.connected = false;
}
/**
*
*/
private async triggerReconnect(reason: string): Promise<void> {
if (!this.shouldReconnect || this.isReconnecting || !this.currentAddress) {
return;
}
console.log(`[gRPC] Triggering reconnect: ${reason}`);
this.isReconnecting = true;
this.connected = false;
this.emit('disconnected', reason);
// 清理现有连接
this.cleanupConnection();
// 计算延迟时间(指数退避)
const delay = Math.min(
this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, this.reconnectAttempts),
this.reconnectConfig.maxDelayMs
);
console.log(`[gRPC] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.reconnectConfig.maxRetries})`);
this.reconnectTimeout = setTimeout(async () => {
this.reconnectAttempts++;
if (this.reconnectAttempts > this.reconnectConfig.maxRetries) {
console.error('[gRPC] Max reconnect attempts reached');
this.isReconnecting = false;
this.emit('reconnectFailed', 'Max retries exceeded');
return;
}
try {
await this.doConnect(this.currentAddress!, this.currentUseTLS);
// 重新注册
if (this.partyId && this.partyRole) {
console.log(`[gRPC] Re-registering as party: ${this.partyId}`);
await this.registerParty(this.partyId, this.partyRole);
}
// 重新订阅事件流
if (this.eventStreamSubscribed && this.partyId) {
console.log('[gRPC] Re-subscribing to session events');
this.subscribeSessionEvents(this.partyId);
}
// 重新订阅消息流
if (this.activeMessageSubscription) {
console.log(`[gRPC] Re-subscribing to messages for session: ${this.activeMessageSubscription.sessionId}`);
this.subscribeMessages(this.activeMessageSubscription.sessionId, this.activeMessageSubscription.partyId);
}
this.isReconnecting = false;
this.emit('reconnected');
} catch (err) {
console.error(`[gRPC] Reconnect attempt ${this.reconnectAttempts} failed:`, (err as Error).message);
this.isReconnecting = false;
// 继续尝试重连
this.triggerReconnect('Previous reconnect attempt failed');
}
}, delay);
}
/**
*
*/
isConnected(): boolean {
return this.connected;
}
/**
* Party ID
*/
getPartyId(): string | null {
return this.partyId;
}
/**
*
*/
async registerParty(partyId: string, role: string): Promise<void> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
(this.client as grpc.Client & { registerParty: (req: unknown, callback: (err: Error | null, res: { success: boolean }) => void) => void })
.registerParty(
{
party_id: partyId,
party_role: role,
version: '1.0.0',
},
(err: Error | null, response: { success: boolean }) => {
if (err) {
reject(err);
} else if (!response.success) {
reject(new Error('Registration failed'));
} else {
this.partyId = partyId;
this.partyRole = role;
this.startHeartbeat();
resolve();
}
}
);
});
}
/**
*
*/
private startHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.heartbeatFailCount = 0;
this.heartbeatInterval = setInterval(() => {
if (this.client && this.partyId) {
(this.client as grpc.Client & { heartbeat: (req: unknown, callback: (err: Error | null) => void) => void })
.heartbeat(
{ party_id: this.partyId },
(err: Error | null) => {
if (err) {
this.heartbeatFailCount++;
console.error(`[gRPC] Heartbeat failed (${this.heartbeatFailCount}/${this.MAX_HEARTBEAT_FAILS}):`, err.message);
this.emit('connectionError', err);
// 连续失败多次后触发重连
if (this.heartbeatFailCount >= this.MAX_HEARTBEAT_FAILS) {
this.triggerReconnect('Heartbeat failed');
}
} else {
// 心跳成功,重置失败计数
this.heartbeatFailCount = 0;
}
}
);
}
}, 30000); // 每 30 秒一次
}
/**
*
*/
async joinSession(sessionId: string, partyId: string, joinToken: string): Promise<JoinSessionResponse> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
(this.client as grpc.Client & { joinSession: (req: unknown, callback: (err: Error | null, res: JoinSessionResponse) => void) => void })
.joinSession(
{
session_id: sessionId,
party_id: partyId,
join_token: joinToken,
},
(err: Error | null, response: JoinSessionResponse) => {
if (err) {
reject(err);
} else {
resolve(response);
}
}
);
});
}
/**
*
*/
subscribeSessionEvents(partyId: string): void {
if (!this.client) {
throw new Error('Not connected');
}
// 标记已订阅(用于重连后恢复)
this.eventStreamSubscribed = true;
// 取消现有流 - 先移除事件监听器再取消,防止旧流的 end 事件触发重连
if (this.eventStream) {
const oldStream = this.eventStream;
oldStream.removeAllListeners();
try {
oldStream.cancel();
} catch (e) {
// 忽略
}
}
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);
});
this.eventStream.on('error', (err: Error) => {
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');
}
});
this.eventStream.on('end', () => {
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');
}
});
}
/**
*
*/
unsubscribeSessionEvents(): void {
this.eventStreamSubscribed = false;
if (this.eventStream) {
try {
this.eventStream.cancel();
} catch (e) {
// 忽略
}
this.eventStream = null;
}
}
/**
* MPC
*/
subscribeMessages(sessionId: string, partyId: string): void {
if (!this.client) {
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 {
oldStream.cancel();
} catch (e) {
console.log('[gRPC] Ignored error while canceling old message stream:', (e as Error).message);
}
this.messageStream = null;
}
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);
});
this.messageStream.on('error', (err: Error) => {
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');
}
});
this.messageStream.on('end', () => {
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');
}
});
}
/**
* MPC
*/
unsubscribeMessages(): void {
this.activeMessageSubscription = null;
if (this.messageStream) {
try {
this.messageStream.cancel();
} catch (e) {
// 忽略
}
this.messageStream = null;
}
}
/**
* MPC
*/
async routeMessage(
sessionId: string,
fromParty: string,
toParties: string[],
roundNumber: number,
payload: Buffer
): Promise<string> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
(this.client as grpc.Client & { routeMessage: (req: unknown, callback: (err: Error | null, res: { message_id: string }) => void) => void })
.routeMessage(
{
session_id: sessionId,
from_party: fromParty,
to_parties: toParties,
round_number: roundNumber,
payload: payload,
},
(err: Error | null, response: { message_id: string }) => {
if (err) {
reject(err);
} else {
resolve(response.message_id);
}
}
);
});
}
/**
*
*/
async reportCompletion(sessionId: string, partyId: string, publicKey: Buffer): Promise<boolean> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
(this.client as grpc.Client & { reportCompletion: (req: unknown, callback: (err: Error | null, res: { all_completed: boolean }) => void) => void })
.reportCompletion(
{
session_id: sessionId,
party_id: partyId,
public_key: publicKey,
},
(err: Error | null, response: { all_completed: boolean }) => {
if (err) {
reject(err);
} else {
resolve(response.all_completed);
}
}
);
});
}
/**
*
* @param roleFilter (persistent/delegate/temporary)
* @param onlyOnline 线
*/
async getRegisteredParties(roleFilter?: string, onlyOnline?: boolean): Promise<RegisteredParty[]> {
if (!this.client) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
(this.client as grpc.Client & { getRegisteredParties: (req: unknown, callback: (err: Error | null, res: GetRegisteredPartiesResponse) => void) => void })
.getRegisteredParties(
{
role_filter: roleFilter || '',
only_online: onlyOnline || false,
},
(err: Error | null, response: GetRegisteredPartiesResponse) => {
if (err) {
reject(err);
} else {
// 转换字段名从 snake_case 到 camelCase
const parties = (response.parties || []).map((p: { party_id?: string; partyId?: string; role?: string; party_role?: string; online?: boolean; registered_at?: string; registeredAt?: string; last_seen_at?: string; lastSeenAt?: string }) => ({
partyId: p.party_id || p.partyId || '',
role: p.role || p.party_role || '',
online: p.online || false,
registeredAt: p.registered_at || p.registeredAt || '',
lastSeenAt: p.last_seen_at || p.lastSeenAt || '',
}));
resolve(parties);
}
}
);
});
}
}

View File

@ -1,561 +0,0 @@
/**
* Kava
*
* :
* 1.
* 2. ( sequence account_number)
* 3.
* 4. 广
*
* 使 Kava LCD REST API:
* - 主网: https://api.kava.io
* - 备用: https://api.kava-rpc.com
*/
import * as crypto from 'crypto';
// =============================================================================
// 配置
// =============================================================================
export interface KavaClientConfig {
lcdEndpoint: string; // LCD REST API 端点
chainId: string; // 链 ID (kava_2222-10 for mainnet)
gasPrice: string; // Gas 价格 (如 "0.025ukava")
defaultGasLimit: number; // 默认 Gas 限制
}
export const KAVA_MAINNET_CONFIG: KavaClientConfig = {
lcdEndpoint: 'https://api.kava.io',
chainId: 'kava_2222-10',
gasPrice: '0.025ukava',
defaultGasLimit: 200000,
};
export const KAVA_TESTNET_CONFIG: KavaClientConfig = {
lcdEndpoint: 'https://api.testnet.kava.io',
chainId: 'kava_2221-16000',
gasPrice: '0.025ukava',
defaultGasLimit: 200000,
};
// 备用端点列表
export const KAVA_LCD_ENDPOINTS = [
'https://api.kava.io',
'https://api.kava-rpc.com',
'https://api.kava.chainstacklabs.com',
];
// =============================================================================
// 类型定义
// =============================================================================
export interface Coin {
denom: string;
amount: string;
}
export interface AccountInfo {
address: string;
accountNumber: string;
sequence: string;
pubKey?: {
type: string;
value: string;
};
}
export interface BalanceResponse {
balances: Coin[];
pagination: {
next_key: string | null;
total: string;
};
}
export interface AccountResponse {
account: {
'@type': string;
address: string;
pub_key?: {
'@type': string;
key: string;
};
account_number: string;
sequence: string;
};
}
export interface TxResponse {
height: string;
txhash: string;
codespace: string;
code: number;
data: string;
raw_log: string;
logs: unknown[];
info: string;
gas_wanted: string;
gas_used: string;
tx: unknown;
timestamp: string;
events: unknown[];
}
export interface BroadcastTxResponse {
tx_response: TxResponse;
}
export interface SimulateTxResponse {
gas_info: {
gas_wanted: string;
gas_used: string;
};
result: {
data: string;
log: string;
events: unknown[];
};
}
// 交易消息类型
export interface MsgSend {
'@type': '/cosmos.bank.v1beta1.MsgSend';
from_address: string;
to_address: string;
amount: Coin[];
}
export interface Fee {
amount: Coin[];
gas_limit: string;
payer?: string;
granter?: string;
}
export interface SignerInfo {
public_key: {
'@type': string;
key: string;
};
mode_info: {
single: {
mode: string;
};
};
sequence: string;
}
export interface AuthInfo {
signer_infos: SignerInfo[];
fee: Fee;
}
export interface TxBody {
messages: MsgSend[];
memo: string;
timeout_height: string;
extension_options: unknown[];
non_critical_extension_options: unknown[];
}
export interface TxRaw {
body_bytes: string;
auth_info_bytes: string;
signatures: string[];
}
// =============================================================================
// Kava 客户端类
// =============================================================================
export class KavaClient {
private config: KavaClientConfig;
private currentEndpointIndex = 0;
constructor(config: KavaClientConfig = KAVA_MAINNET_CONFIG) {
this.config = config;
}
/**
* LCD
*/
private getLcdEndpoint(): string {
return this.config.lcdEndpoint;
}
/**
*
*/
private switchToBackupEndpoint(): void {
this.currentEndpointIndex = (this.currentEndpointIndex + 1) % KAVA_LCD_ENDPOINTS.length;
this.config.lcdEndpoint = KAVA_LCD_ENDPOINTS[this.currentEndpointIndex];
console.log(`Switched to backup endpoint: ${this.config.lcdEndpoint}`);
}
/**
* HTTP
*/
private async request<T>(
path: string,
method: 'GET' | 'POST' = 'GET',
body?: unknown
): Promise<T> {
const url = `${this.getLcdEndpoint()}${path}`;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
},
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return await response.json() as T;
} catch (error) {
// 如果请求失败,尝试切换端点
console.error(`Request failed for ${url}:`, error);
this.switchToBackupEndpoint();
throw error;
}
}
// ===========================================================================
// 查询功能
// ===========================================================================
/**
*
*
* @param address - Kava (bech32 "kava" )
* @returns
*/
async getBalances(address: string): Promise<Coin[]> {
const response = await this.request<BalanceResponse>(
`/cosmos/bank/v1beta1/balances/${address}`
);
return response.balances;
}
/**
*
*
* @param address - Kava
* @param denom - ( "ukava")
* @returns
*/
async getBalance(address: string, denom: string = 'ukava'): Promise<Coin> {
const response = await this.request<{ balance: Coin }>(
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${denom}`
);
return response.balance;
}
/**
* ()
*
* @param address - Kava
* @returns ( account_number sequence)
*/
async getAccountInfo(address: string): Promise<AccountInfo> {
const response = await this.request<AccountResponse>(
`/cosmos/auth/v1beta1/accounts/${address}`
);
const account = response.account;
return {
address: account.address,
accountNumber: account.account_number,
sequence: account.sequence,
pubKey: account.pub_key ? {
type: account.pub_key['@type'],
value: account.pub_key.key,
} : undefined,
};
}
/**
*
*
* @param txHash -
* @returns
*/
async getTx(txHash: string): Promise<TxResponse> {
const response = await this.request<{ tx_response: TxResponse }>(
`/cosmos/tx/v1beta1/txs/${txHash}`
);
return response.tx_response;
}
/**
*
*/
async getLatestBlockHeight(): Promise<number> {
const response = await this.request<{ block: { header: { height: string } } }>(
`/cosmos/base/tendermint/v1beta1/blocks/latest`
);
return parseInt(response.block.header.height, 10);
}
// ===========================================================================
// 交易构建
// ===========================================================================
/**
*
*
* @param fromAddress -
* @param toAddress -
* @param amount -
* @param denom -
* @returns MsgSend
*/
buildMsgSend(
fromAddress: string,
toAddress: string,
amount: string,
denom: string = 'ukava'
): MsgSend {
return {
'@type': '/cosmos.bank.v1beta1.MsgSend',
from_address: fromAddress,
to_address: toAddress,
amount: [{ denom, amount }],
};
}
/**
*
*
* @param messages -
* @param memo -
* @returns TxBody
*/
buildTxBody(messages: MsgSend[], memo: string = ''): TxBody {
return {
messages,
memo,
timeout_height: '0',
extension_options: [],
non_critical_extension_options: [],
};
}
/**
* AuthInfo
*
* @param publicKeyBase64 - (Base64)
* @param sequence -
* @param gasLimit - Gas
* @param feeAmount -
* @returns AuthInfo
*/
buildAuthInfo(
publicKeyBase64: string,
sequence: string,
gasLimit: number = this.config.defaultGasLimit,
feeAmount?: Coin[]
): AuthInfo {
// 计算手续费 (如果未提供)
if (!feeAmount) {
const gasPrice = parseFloat(this.config.gasPrice.replace('ukava', ''));
const fee = Math.ceil(gasLimit * gasPrice);
feeAmount = [{ denom: 'ukava', amount: fee.toString() }];
}
return {
signer_infos: [{
public_key: {
'@type': '/cosmos.crypto.secp256k1.PubKey',
key: publicKeyBase64,
},
mode_info: {
single: {
mode: 'SIGN_MODE_DIRECT',
},
},
sequence,
}],
fee: {
amount: feeAmount,
gas_limit: gasLimit.toString(),
},
};
}
/**
*
*
* @param txBody -
* @param authInfo -
* @param accountNumber -
* @returns ( TSS )
*/
buildSignDoc(
txBody: TxBody,
authInfo: AuthInfo,
accountNumber: string
): {
bodyBytes: Buffer;
authInfoBytes: Buffer;
chainId: string;
accountNumber: string;
signBytes: Buffer;
} {
// 注意:这里需要使用 protobuf 编码
// 简化版本:使用 JSON 编码后进行 SHA256 哈希
// 生产环境应使用 @cosmjs/proto-signing
const bodyBytes = Buffer.from(JSON.stringify(txBody));
const authInfoBytes = Buffer.from(JSON.stringify(authInfo));
// 构建 SignDoc
const signDoc = {
body_bytes: bodyBytes.toString('base64'),
auth_info_bytes: authInfoBytes.toString('base64'),
chain_id: this.config.chainId,
account_number: accountNumber,
};
// 计算签名哈希 (SHA256)
const signBytes = crypto.createHash('sha256')
.update(JSON.stringify(signDoc))
.digest();
return {
bodyBytes,
authInfoBytes,
chainId: this.config.chainId,
accountNumber,
signBytes,
};
}
// ===========================================================================
// 交易广播
// ===========================================================================
/**
* ( Gas)
*
* @param txBytes - (Base64)
* @returns
*/
async simulateTx(txBytes: string): Promise<SimulateTxResponse> {
return this.request<SimulateTxResponse>(
'/cosmos/tx/v1beta1/simulate',
'POST',
{ tx_bytes: txBytes }
);
}
/**
* 广
*
* @param txBytes - (Base64)
* @param mode - 广 (BROADCAST_MODE_SYNC | BROADCAST_MODE_ASYNC | BROADCAST_MODE_BLOCK)
* @returns 广
*/
async broadcastTx(
txBytes: string,
mode: 'BROADCAST_MODE_SYNC' | 'BROADCAST_MODE_ASYNC' | 'BROADCAST_MODE_BLOCK' = 'BROADCAST_MODE_SYNC'
): Promise<BroadcastTxResponse> {
return this.request<BroadcastTxResponse>(
'/cosmos/tx/v1beta1/txs',
'POST',
{
tx_bytes: txBytes,
mode,
}
);
}
/**
*
*
* @param bodyBytes -
* @param authInfoBytes -
* @param signature - (Buffer)
* @returns Base64
*/
encodeSignedTx(
bodyBytes: Buffer,
authInfoBytes: Buffer,
signature: Buffer
): string {
// 简化版本:使用 JSON 编码
// 生产环境应使用 protobuf 编码
const txRaw = {
body_bytes: bodyBytes.toString('base64'),
auth_info_bytes: authInfoBytes.toString('base64'),
signatures: [signature.toString('base64')],
};
return Buffer.from(JSON.stringify(txRaw)).toString('base64');
}
// ===========================================================================
// 便捷方法
// ===========================================================================
/**
* KAVA (ukava -> KAVA)
*
* @param amount - (ukava)
* @returns
*/
formatKava(amount: string): string {
const ukava = BigInt(amount);
const kava = Number(ukava) / 1_000_000;
return kava.toFixed(6);
}
/**
* KAVA ukava
*
* @param kava - KAVA
* @returns ukava
*/
toUkava(kava: number | string): string {
const ukava = Math.floor(Number(kava) * 1_000_000);
return ukava.toString();
}
/**
*
*
* @param address -
* @returns
*/
isValidAddress(address: string): boolean {
return address.startsWith('kava') && address.length === 43;
}
/**
*
*/
getConfig(): KavaClientConfig {
return { ...this.config };
}
/**
*
*/
updateConfig(config: Partial<KavaClientConfig>): void {
this.config = { ...this.config, ...config };
}
}
// 导出默认客户端实例
export const kavaClient = new KavaClient();

View File

@ -1,537 +0,0 @@
/**
* Kava
*
* 使 Kava LCD REST API 广
* TSS
*
* API :
* - https://docs.kava.io/docs/using-kava-endpoints/endpoints/
* - https://docs.cosmos.network/main/learn/advanced/grpc_rest
*/
import * as crypto from 'crypto';
// =============================================================================
// 配置
// =============================================================================
export interface KavaTxConfig {
lcdEndpoint: string;
rpcEndpoint: string;
chainId: string;
prefix: string;
denom: string;
gasPrice: number; // ukava per gas unit
}
export const KAVA_MAINNET_TX_CONFIG: KavaTxConfig = {
lcdEndpoint: 'https://api.kava.io',
rpcEndpoint: 'https://rpc.kava.io',
chainId: 'kava_2222-10',
prefix: 'kava',
denom: 'ukava',
gasPrice: 0.025,
};
export const KAVA_TESTNET_TX_CONFIG: KavaTxConfig = {
lcdEndpoint: 'https://api.testnet.kava.io',
rpcEndpoint: 'https://rpc.testnet.kava.io',
chainId: 'kava_2221-16000',
prefix: 'kava',
denom: 'ukava',
gasPrice: 0.025,
};
// 备用端点
const BACKUP_ENDPOINTS = [
'https://api.kava.io',
'https://api.kava-rpc.com',
'https://api.kava.chainstacklabs.com',
];
// =============================================================================
// 类型定义
// =============================================================================
export interface Coin {
denom: string;
amount: string;
}
export interface AccountBalance {
denom: string;
amount: string;
formatted: string; // 人类可读格式 (KAVA)
}
export interface AccountInfo {
address: string;
accountNumber: number;
sequence: number;
balances: AccountBalance[];
}
export interface UnsignedTxData {
// 用于 TSS 签名的数据
signBytes: Uint8Array; // 待签名的哈希
signBytesHex: string; // 十六进制格式
// 交易元数据
txBodyBytes: Uint8Array;
authInfoBytes: Uint8Array;
accountNumber: number;
sequence: number;
chainId: string;
// 可读信息
from: string;
to: string;
amount: string;
denom: string;
memo: string;
fee: string;
gasLimit: number;
}
export interface SignedTxData {
txBytes: Uint8Array; // 完整的已签名交易
txBytesBase64: string; // Base64 格式
txHash: string; // 交易哈希
}
export interface TxBroadcastResult {
success: boolean;
txHash?: string;
code?: number;
rawLog?: string;
gasUsed?: string;
gasWanted?: string;
height?: string;
}
export interface TxStatus {
found: boolean;
status: 'pending' | 'success' | 'failed';
code?: number;
rawLog?: string;
height?: string;
gasUsed?: string;
timestamp?: string;
}
// =============================================================================
// Kava 交易服务类
// =============================================================================
export class KavaTxService {
private config: KavaTxConfig;
private backupEndpointIndex = 0;
constructor(config: KavaTxConfig = KAVA_MAINNET_TX_CONFIG) {
this.config = { ...config };
}
// ===========================================================================
// 配置管理
// ===========================================================================
getConfig(): KavaTxConfig {
return { ...this.config };
}
updateConfig(config: Partial<KavaTxConfig>): void {
this.config = { ...this.config, ...config };
}
/**
*
*/
switchToTestnet(): void {
this.config = { ...KAVA_TESTNET_TX_CONFIG };
}
/**
*
*/
switchToMainnet(): void {
this.config = { ...KAVA_MAINNET_TX_CONFIG };
}
/**
*
*/
isTestnet(): boolean {
return this.config.chainId === KAVA_TESTNET_TX_CONFIG.chainId;
}
// ===========================================================================
// HTTP 请求
// ===========================================================================
private async request<T>(
path: string,
method: 'GET' | 'POST' = 'GET',
body?: unknown,
timeout: number = 10000
): Promise<T> {
const url = `${this.config.lcdEndpoint}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const options: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
return await response.json() as T;
} catch (error) {
// 如果是主网且请求失败,尝试备用端点
if (!this.isTestnet()) {
this.backupEndpointIndex = (this.backupEndpointIndex + 1) % BACKUP_ENDPOINTS.length;
console.log(`Switched to backup endpoint: ${BACKUP_ENDPOINTS[this.backupEndpointIndex]}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// ===========================================================================
// 查询功能
// ===========================================================================
/**
* -
*/
async healthCheck(): Promise<{ ok: boolean; latency?: number; blockHeight?: number; error?: string }> {
const start = Date.now();
try {
const response = await this.request<{ block: { header: { height: string } } }>(
'/cosmos/base/tendermint/v1beta1/blocks/latest',
'GET',
undefined,
5000
);
const latency = Date.now() - start;
return {
ok: true,
latency,
blockHeight: parseInt(response.block.header.height, 10),
};
} catch (error) {
return {
ok: false,
error: (error as Error).message,
};
}
}
/**
* KAVA
*/
async getKavaBalance(address: string): Promise<string> {
try {
const response = await this.request<{ balance: Coin }>(
`/cosmos/bank/v1beta1/balances/${address}/by_denom?denom=ukava`
);
return response.balance?.amount || '0';
} catch {
return '0';
}
}
/**
*
*/
async getAllBalances(address: string): Promise<AccountBalance[]> {
const response = await this.request<{ balances: Coin[] }>(
`/cosmos/bank/v1beta1/balances/${address}`
);
return (response.balances || []).map(coin => ({
denom: coin.denom,
amount: coin.amount,
formatted: this.formatAmount(coin.amount, coin.denom),
}));
}
/**
*
*/
async getAccountInfo(address: string): Promise<AccountInfo | null> {
try {
const [accountResp, balances] = await Promise.all([
this.request<{
account: {
'@type': string;
address: string;
account_number: string;
sequence: string;
};
}>(`/cosmos/auth/v1beta1/accounts/${address}`),
this.getAllBalances(address),
]);
return {
address: accountResp.account.address,
accountNumber: parseInt(accountResp.account.account_number, 10),
sequence: parseInt(accountResp.account.sequence, 10),
balances,
};
} catch {
return null;
}
}
/**
*
*/
async getTxStatus(txHash: string): Promise<TxStatus> {
try {
const response = await this.request<{
tx_response: {
code: number;
raw_log: string;
height: string;
gas_used: string;
timestamp: string;
};
}>(`/cosmos/tx/v1beta1/txs/${txHash}`);
return {
found: true,
status: response.tx_response.code === 0 ? 'success' : 'failed',
code: response.tx_response.code,
rawLog: response.tx_response.raw_log,
height: response.tx_response.height,
gasUsed: response.tx_response.gas_used,
timestamp: response.tx_response.timestamp,
};
} catch {
return { found: false, status: 'pending' };
}
}
// ===========================================================================
// 交易构建 (使用 Amino JSON)
// ===========================================================================
/**
* ()
*/
async buildSendTx(
fromAddress: string,
toAddress: string,
amount: string,
publicKeyHex: string,
memo: string = ''
): Promise<UnsignedTxData> {
// 获取账户信息
const accountInfo = await this.getAccountInfo(fromAddress);
if (!accountInfo) {
throw new Error('Account not found or has no transactions');
}
const gasLimit = 100000;
const feeAmount = Math.ceil(gasLimit * this.config.gasPrice);
// 构建 Amino JSON 签名文档
const signDoc = {
chain_id: this.config.chainId,
account_number: accountInfo.accountNumber.toString(),
sequence: accountInfo.sequence.toString(),
fee: {
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
gas: gasLimit.toString(),
},
msgs: [{
type: 'cosmos-sdk/MsgSend',
value: {
from_address: fromAddress,
to_address: toAddress,
amount: [{ denom: 'ukava', amount }],
},
}],
memo,
};
// 计算签名哈希 (SHA256)
const signDocJson = JSON.stringify(sortObject(signDoc));
const signBytes = crypto.createHash('sha256').update(signDocJson).digest();
// 构建交易体和认证信息 (用于广播)
const txBody = {
messages: [{
'@type': '/cosmos.bank.v1beta1.MsgSend',
from_address: fromAddress,
to_address: toAddress,
amount: [{ denom: 'ukava', amount }],
}],
memo,
timeout_height: '0',
extension_options: [],
non_critical_extension_options: [],
};
const authInfo = {
signer_infos: [{
public_key: {
'@type': '/cosmos.crypto.secp256k1.PubKey',
key: Buffer.from(publicKeyHex, 'hex').toString('base64'),
},
mode_info: { single: { mode: 'SIGN_MODE_LEGACY_AMINO_JSON' } },
sequence: accountInfo.sequence.toString(),
}],
fee: {
amount: [{ denom: 'ukava', amount: feeAmount.toString() }],
gas_limit: gasLimit.toString(),
},
};
return {
signBytes: new Uint8Array(signBytes),
signBytesHex: signBytes.toString('hex'),
txBodyBytes: new Uint8Array(Buffer.from(JSON.stringify(txBody))),
authInfoBytes: new Uint8Array(Buffer.from(JSON.stringify(authInfo))),
accountNumber: accountInfo.accountNumber,
sequence: accountInfo.sequence,
chainId: this.config.chainId,
from: fromAddress,
to: toAddress,
amount,
denom: 'ukava',
memo,
fee: feeAmount.toString(),
gasLimit,
};
}
/**
* ()
*/
async completeTx(unsignedTx: UnsignedTxData, signatureHex: string): Promise<SignedTxData> {
// 解析保存的交易数据
const txBody = JSON.parse(Buffer.from(unsignedTx.txBodyBytes).toString());
const authInfo = JSON.parse(Buffer.from(unsignedTx.authInfoBytes).toString());
// 构建完整的已签名交易
const signedTx = {
body: txBody,
auth_info: authInfo,
signatures: [Buffer.from(signatureHex, 'hex').toString('base64')],
};
const txBytes = Buffer.from(JSON.stringify(signedTx));
const txHash = crypto.createHash('sha256').update(txBytes).digest('hex').toUpperCase();
return {
txBytes: new Uint8Array(txBytes),
txBytesBase64: txBytes.toString('base64'),
txHash,
};
}
/**
* 广
*/
async broadcastTx(signedTx: SignedTxData): Promise<TxBroadcastResult> {
try {
const response = await this.request<{
tx_response: {
code: number;
txhash: string;
raw_log: string;
gas_used: string;
gas_wanted: string;
height: string;
};
}>('/cosmos/tx/v1beta1/txs', 'POST', {
tx_bytes: signedTx.txBytesBase64,
mode: 'BROADCAST_MODE_SYNC',
});
return {
success: response.tx_response.code === 0,
txHash: response.tx_response.txhash,
code: response.tx_response.code,
rawLog: response.tx_response.raw_log,
gasUsed: response.tx_response.gas_used,
gasWanted: response.tx_response.gas_wanted,
height: response.tx_response.height,
};
} catch (error) {
return {
success: false,
rawLog: (error as Error).message,
};
}
}
// ===========================================================================
// 工具方法
// ===========================================================================
formatAmount(amount: string, denom: string): string {
if (denom === 'ukava') {
const kava = Number(amount) / 1_000_000;
return `${kava.toFixed(6)} KAVA`;
}
return `${amount} ${denom}`;
}
toUkava(kava: number | string): string {
return Math.floor(Number(kava) * 1_000_000).toString();
}
fromUkava(ukava: string): number {
return Number(ukava) / 1_000_000;
}
/**
* ()
*/
disconnect(): void {
// 目前使用 REST API无需特殊清理
}
}
// ===========================================================================
// 辅助函数
// ===========================================================================
/**
* (Amino JSON )
*/
function sortObject(obj: unknown): unknown {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObject);
}
const sortedObj: Record<string, unknown> = {};
const keys = Object.keys(obj as Record<string, unknown>).sort();
for (const key of keys) {
sortedObj[key] = sortObject((obj as Record<string, unknown>)[key]);
}
return sortedObj;
}
// 导出默认实例
export const kavaTxService = new KavaTxService();

Some files were not shown because too many files have changed in this diff Show More