EVE Frontier 构建者完整课程
每节课约 2 小时,共 54 节:36 个基础章节 + 18 个实战案例 ≈ 108 小时完整学习内容。
📖 章节主线(按建议学习顺序排列,每节约 2 小时)
前置章节
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Prelude | chapter-00.md | 先读懂 EVE Frontier 这款游戏:玩家在争夺什么、设施为什么重要、位置/战损/物流/经济如何串成完整玩法 |
第一阶段:入门基础(Chapter 1-5)
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 1 | chapter-01.md | EVE Frontier 宏观架构:三层模型、智能组件类型、Sui/Move 选型 |
| Chapter 2 | chapter-02.md | 开发环境配置:Sui CLI、EVE Vault、测试资产获取与最小验收 |
| Chapter 3 | chapter-03.md | Move 合约基础:模块、Abilities、对象所有权、Capability/Witness/Hot Potato |
| Chapter 4 | chapter-04.md | 智能组件开发与链上部署:角色、网络节点、炮塔/星门/存储箱改造全流程 |
| Chapter 5 | chapter-05.md | dApp 前端开发:dapp-kit SDK、React Hooks、钱包集成、链上交易 |
配套实战:Example 1 炮塔白名单、Example 2 星门收费站
第二阶段:Builder 工程闭环(Chapter 6-10)
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 6 | chapter-06.md | Builder Scaffold 入口:项目结构、smart_gate 架构、编译与发布 |
| Chapter 7 | chapter-07.md | TS 脚本与前端:helper.ts、脚本链路、React dApp 模板 |
| Chapter 8 | chapter-08.md | 服务端协同:Sponsored Tx、AdminACL、链上链下配合 |
| Chapter 9 | chapter-09.md | 数据读取:GraphQL、事件订阅、索引器思路 |
| Chapter 10 | chapter-10.md | dApp 钱包接入:useConnection、赞助交易、Epoch 处理 |
配套实战:Example 4 任务解锁系统、Example 11 物品租赁系统
第三阶段:合约设计进阶(Chapter 11-17)
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 11 | chapter-11.md | 所有权模型深度解析:OwnerCap、Keychain、Borrow-Use-Return、委托 |
| Chapter 12 | chapter-12.md | Move 进阶:泛型、动态字段、事件系统、Table 与 VecMap |
| Chapter 13 | chapter-13.md | NFT 设计与元数据管理:Display 标准、动态 NFT、Collection 模式 |
| Chapter 14 | chapter-14.md | 链上经济系统设计:代币发行、去中心化市场、动态定价、金库 |
| Chapter 15 | chapter-15.md | 跨合约组合性:调用其他 Builder 的合约、接口设计、协议标准 |
| Chapter 16 | chapter-16.md | 位置与临近性系统:哈希位置、临近证明、地理策略设计 |
| Chapter 17 | chapter-17.md | 测试、调试与安全审计:Move 单元测试、漏洞类型、升级策略 |
配套实战:Example 3 链上拍卖行、Example 6 动态 NFT、Example 7 星门物流网络、Example 9 跨 Builder 协议、Example 13 订阅制通行证、Example 14 NFT 质押借贷、Example 16 NFT 合成拆解、Example 18 跨联盟外交条约
第四阶段:架构、集成与产品(Chapter 18-25)
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 18 | chapter-18.md | 多租户与游戏服务器集成:Tenant 模型、ObjectRegistry、服务端脚本 |
| Chapter 19 | chapter-19.md | 全栈 dApp 架构设计:状态管理、实时更新、多链支持、CI/CD |
| Chapter 20 | chapter-20.md | 游戏内接入:浮层 UI、postMessage、游戏事件桥接 |
| Chapter 21 | chapter-21.md | 性能优化与 Gas 最小化:事务批处理、读写分离、链下计算 |
| Chapter 22 | chapter-22.md | Move 高级模式:升级兼容设计、动态字段扩展、数据迁移 |
| Chapter 23 | chapter-23.md | 发布、维护与社区协作:主网部署、Package 升级、Builder 协作 |
| Chapter 24 | chapter-24.md | 故障排查手册:常见 Move/Sui/dApp 错误类型与系统化调试方法 |
| Chapter 25 | chapter-25.md | 从 Builder 到产品:商业模式、用户增长、社区运营、渐进去中心化 |
配套实战:Example 5 联盟 DAO、Example 12 联盟招募、Example 15 PvP 物品保险、Example 17 游戏内浮层实战
🔬 第五阶段:World 合约源码精读(Chapter 26-32)
基于 world-contracts 真实源代码,深度解析 EVE Frontier 核心系统机制。
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 26 | chapter-26.md | 访问控制完整解析:GovernorCap / AdminACL / OwnerCap / Receiving 模式 |
| Chapter 27 | chapter-27.md | 链下签名 × 链上验证:Ed25519、PersonalMessage intent、sig_verify 精读 |
| Chapter 28 | chapter-28.md | 位置证明协议:LocationProof、BCS 反序列化、临近性验证实战 |
| Chapter 29 | chapter-29.md | 能量与燃料系统:EnergySource、Fuel 消耗率计算、已知 Bug 分析 |
| Chapter 30 | chapter-30.md | Extension 模式实战:官方 tribe_permit + corpse_gate_bounty 精读 |
| Chapter 31 | chapter-31.md | 炮塔 AI 扩展:TargetCandidate、优先级队列、自定义 AI 开发 |
| Chapter 32 | chapter-32.md | KillMail 系统:PvP 击杀记录、TenantItemId、derived_object 防重放 |
配套实战:Example 8 Builder 竞赛系统、Example 10 综合实战
🔐 第六阶段:钱包内部与未来(Chapter 33-35)
在已经会接入钱包和 dApp 之后,再回头深入钱包内部实现与未来方向,学习曲线更顺。
| 章节 | 文件 | 主题摘要 |
|---|---|---|
| Chapter 33 | chapter-33.md | zkLogin 原理与设计:零知识证明、FusionAuth OAuth、Enoki 盐值、临时密钥对 |
| Chapter 34 | chapter-34.md | 技术架构与开发部署:Chrome MV3 五层结构、Keeper 安全容器、消息协议、本地构建 |
| Chapter 35 | chapter-35.md | 未来展望:零知识证明、完全去中心化游戏、EVM 互操作 |
配套建议:完成本阶段后,回看 Example 17 的钱包连接、签名与游戏内接入链路。
🛠 案例索引(按复杂度查看,每节 2 小时)
主线分配见上;下面保留按复杂度查看的索引,方便选题和回查。
初级案例(Example 1-3)——基础组件应用
| 案例 | 文件 | 技术亮点 |
|---|---|---|
| Example 1 | example-01.md | 炮塔白名单:MiningPass NFT + AdminCap + 管理 dApp |
| Example 2 | example-02.md | 星门收费站:金库合约 + JumpPermit + 玩家购票 dApp |
| Example 3 | example-03.md | 链上拍卖行:荷兰式定价 + 自动结算 + 实时倒计时 dApp |
中级案例(Example 4-7)——经济与治理
| 案例 | 文件 | 技术亮点 |
|---|---|---|
| Example 4 | example-04.md | 任务解锁系统:链上位标志任务 + 链下监控 + 条件星门 |
| Example 5 | example-05.md | 联盟 DAO:自定义 Coin + 快照分红 + 加权治理投票 |
| Example 6 | example-06.md | 动态 NFT:随游戏状态实时更新元数据的可进化装备 |
| Example 7 | example-07.md | 星门物流网络:多跳路由 + Dijkstra 路径规划 + dApp |
高级案例(Example 8-10)——系统集成
| 案例 | 文件 | 技术亮点 |
|---|---|---|
| Example 8 | example-08.md | Builder 竞赛系统:链上排行榜 + 积分 + 奖杯 NFT 自动分发 |
| Example 9 | example-09.md | 跨 Builder 协议:适配器模式 + 多合约聚合市场 |
| Example 10 | example-10.md | 综合实战:太空资源争夺战(整合角色/炮塔/星门/代币) |
扩展案例(Example 11-15)——金融与产品化
| 案例 | 文件 | 技术亮点 |
|---|---|---|
| Example 11 | example-11.md | 物品租赁系统:时间锁 NFT + 押金管理 + 提前归还退款 |
| Example 12 | example-12.md | 联盟招募:申请押金 + 成员投票 + 一票否决 + 自动发 NFT |
| Example 13 | example-13.md | 订阅制通行证:月/季套餐 + 可转让 Pass NFT + 续费 |
| Example 14 | example-14.md | NFT 质押借贷:LTV 60% + 月息 3% + 逾期清算拍卖 |
| Example 15 | example-15.md | PvP 物品保险:购买保单 + 服务器签名理赔 + 赔付池 |
高级扩展(Example 16-18)——创新玩法
| 案例 | 文件 | 技术亮点 |
|---|---|---|
| Example 16 | example-16.md | NFT 合成拆解:三级物品体系 + 链上随机数 + 安慰奖机制 |
| Example 17 | example-17.md | 游戏内浮层实战:收费站游戏内版 + postMessage + 无缝签名 |
| Example 18 | example-18.md | 跨联盟外交条约:双签生效 + 押金约束 + 违约举证与罚款 |
📖 阅读建议
| 阶段 | 内容 | 建议 | 时长 |
|---|---|---|---|
| 入门基础 | Prelude → Chapter 1-5 → Example 1, 2 | 先建立玩法直觉,再进入架构、组件和最小闭环 | ~16h |
| 工程闭环 | Chapter 6-10 → Example 4, 11 | 先把 Builder 的端到端链路跑通 | ~14h |
| 合约进阶 | Chapter 11-17 → Example 3, 6, 7, 9, 13, 14, 16, 18 | 回头补强合约设计能力 | ~30h |
| 架构与产品 | Chapter 18-25 → Example 5, 12, 15, 17 | 面向长期维护、游戏接入和产品化 | ~24h |
| 源码精读 | Chapter 26-32 → Example 8, 10 | 从 World 核心模块反推设计理念,再做复杂系统案例 | ~18h |
| 钱包内部与未来 | Chapter 33-35 | 深入理解 EVE Vault 内核和后续方向 | ~6h |
推荐学习路径
快速上手 Builder(最短路径,约 26h): Prelude → Chapter 1-4 → Example 1-2 → Chapter 6-10 → Example 4
完整 Builder 路径(约 96h): Prelude → Chapter 1-5 → Example 1-2 → Chapter 6-10 → Example 4, 11 → Chapter 11-17 → Example 3, 6, 7, 9, 13, 14, 16, 18 → Chapter 18-25 → Example 5, 12, 15, 17 → Chapter 26-32 → Example 8, 10 → Chapter 33-35
源码研究者路径(约 32h): Prelude → Chapter 3 → Chapter 11 → Chapter 15 → Chapter 26-32 → Example 8, 10 → Chapter 6-10
📚 参考资源
- 官方 builder-documentation
- builder-scaffold(脚手架)
- World Contracts 源码
- Sui 文档
- Move Book
- EVE Frontier dapp-kit API
- Sui GraphQL IDE(Testnet)
- EVE Frontier Discord
- 术语表
术语表
本页统一解释课程中高频出现、且容易在不同章节里重复出现的术语。阅读 Chapter 26-35 与 Example 11-18 时,建议把本页当作速查表。
AdminACL
World 合约中的服务端授权控制对象。游戏服务器或 Builder 后端会把被允许的 sponsor 地址写入 AdminACL,链上逻辑通过 verify_sponsor 等校验函数确认调用者是否具备“服务器代表”身份。
OwnerCap
对象或设施的所有权凭证。很多 World 侧权限检查并不只看 ctx.sender(),而是要求调用方显式持有与目标对象关联的 OwnerCap。
AdminCap
Builder 自己模块中的管理员能力对象。它通常在 init 时发给发布者,用来写配置、修改规则、暂停功能或提取资金。
Typed Witness
一种通过类型系统收紧授权边界的模式。EVE Frontier 的 Gate / Turret / Storage Unit 扩展经常用它约束“只有特定模块、特定入口”才能调用敏感 API。
Shared Object
Sui 上可被多方并发访问的共享对象。World 里的 Gate、Storage Unit、Registry 这一类设施经常采用该模型。
Derived Object
基于父对象和业务键确定性派生出的对象 ID。KillMail、注册表子对象等场景用它来保证 业务 ID -> 链上对象 ID 是稳定且不可重复的。
Sponsored Transaction
玩家发起、但由 Builder 或服务器代付 Gas 的交易。EVE Vault 支持赞助交易扩展,这也是“用户没有 SUI 也能用 dApp”的核心基础。
zkLogin
Sui 的无助记词登录方案。用户用 Web2 身份完成 OAuth 登录后,钱包再基于临时密钥、salt、proof 派生链上地址。
Epoch
Sui 的纪元单位。zkLogin 的临时证明和部分缓存都与 Epoch 绑定,过期后需要重新签发或刷新登录态。
0x6
Sui Clock 系统对象的固定对象 ID。文中许多时间相关示例会把 0x6 作为参数传入。
0x8
Sui Random 系统对象的固定对象 ID。需要链上随机数的示例中通常会传入该对象。
LUX 与 SUI
课程中的不少案例会“用 SUI 代替 LUX 演示”,方便在公开环境和标准 SDK 中说明资金流。实际接入 EVE Frontier 时,需以游戏内真实资产与 World/钱包接口为准。
GraphQL / Indexer
本书里提到的 GraphQL,多数指 Sui 索引层提供的查询入口;Indexer 指围绕事件和对象状态建立的链下检索服务。它们主要负责“读”,而不是“写”。
前置章节:先读懂 EVE Frontier 这款游戏
目标: 在学习合约、组件和 dApp 之前,先理解 EVE Frontier 里的玩家到底在争夺什么、建设什么、为什么这些机制会天然适合被做成链上规则。
状态:前置导读。重点是先建立“游戏玩法直觉”,让后面章节里的 Gate、Turret、StorageUnit、KillMail、LocationProof 不再像抽象名词。
0.1 这不是一款“套了链的钱包游戏”
如果你先把 EVE Frontier 想成“一个有 NFT 和代币的太空游戏”,后面大概率会越学越别扭。因为这款游戏真正的核心,不是发资产,而是一个持续运行、充满资源竞争、地理约束和玩家冲突的开放世界。
它更接近下面这种组合:
| 维度 | EVE Frontier 更像什么 | 为什么这点重要 |
|---|---|---|
| 世界结构 | 持续存在的太空沙盒 | 世界不会因为你下线就暂停,设施、路线、控制区和经济关系会继续变化 |
| 生存压力 | 从“活下来”开始,而不是从“点签到领奖”开始 | 资源、燃料、运输、安全和位置都是真问题 |
| 玩家关系 | 长期合作与长期对抗并存 | 你会需要联盟、补给、通道、防御、外交和报复 |
| 建筑意义 | 建筑不是摆设,而是改变玩法的基础设施 | 星门、炮塔、存储设施会直接影响谁能通过、谁能拿货、谁会被打 |
| 区块链角色 | 公开规则层与资产层 | 重点不是把所有玩法搬上链,而是把值得公开验证的那部分变成可编程规则 |
所以你可以先记住一句话:
EVE Frontier 的“乐趣单位”不是单个 NFT,而是一个玩家群体围绕基础设施、资源流和控制权展开的长期博弈。
0.2 一个普通玩家在这个世界里通常会做什么?
从玩法上看,玩家的活动通常会围绕下面这条主线循环:
进入世界
-> 建立角色与身份
-> 找到安全落脚点
-> 获取资源、物品和燃料
-> 搭起自己的基地或接入别人的设施
-> 运输、交易、收费、防守、掠夺
-> 在冲突中损失、回收、重建、升级
这不是线性任务链,而是一个不断重复的经营与对抗循环。玩家可能专注于不同风格:
- 生存型玩家:优先解决补给、安全、可持续驻留
- 工业型玩家:更关心仓储、物流、物品流转和市场
- 军事型玩家:更关心炮塔、防线、敌我识别、KillMail 和战损
- 运营型玩家:更关心收费站、许可系统、联盟协作和服务定价
- Builder / Operator 型玩家:更关心如何把基础设施变成可以收费、筛选、激励和自动执行的规则系统
本书面向的是最后一类,但你设计的东西最终都是给前面几类玩家用的,所以必须先理解他们在游戏里到底会遇到什么问题。
站在玩家视角,一天通常怎么度过?
如果把玩法压缩成一个更具体的日常循环,很多动作会像这样发生:
登录
-> 确认自己当前所在位置和基地状态
-> 检查燃料、库存、通行权限、附近风险
-> 决定今天是采集、运输、交易、防守还是出击
-> 使用 Gate、Storage、市场或联盟设施完成目标
-> 途中可能遭遇拦截、收费、炮塔检查或 PvP
-> 成功带回收益,或在战损后重整资源
这条日常循环里,几乎每一步都能被 Builder 影响:
- “确认自己能不能出门”会碰到
Gate - “检查库存和补给”会碰到
Storage Unit - “基地是不是还能运转”会碰到
Network Node / Energy / Fuel - “路上会不会被拦”会碰到
Turret和区域治理 - “打输了损失是否可追踪”会碰到
KillMail - “是不是人真的到了现场”会碰到
LocationProof
也就是说,Builder 不只是做一个额外网页,而是在接入玩家每天都会经过的决策节点。
这个世界里,玩家真正反复权衡的是什么?
从玩法上看,EVE Frontier 的很多选择,最后都会落到下面 4 个矛盾上:
| 玩家要权衡的东西 | 典型问题 |
|---|---|
| 收益 vs 风险 | 这条路线赚钱更快,但会不会更容易被打? |
| 便利 vs 控制 | 让所有人都能用我的设施更赚钱,但会不会失去筛选能力? |
| 流动性 vs 安全性 | 货物放在公共节点更方便卖,但会不会更容易出问题? |
| 短期获利 vs 长期秩序 | 今天多收点过路费很爽,但会不会把人全赶走,导致航线衰退? |
这也是为什么后面你看到的很多规则都不像“按钮功能”,而更像制度设计。收费、许可、白名单、保险、押金、赔付、奖励,本质上都是在调这些矛盾。
同一个世界,在不同玩家眼里完全不是同一张地图
如果你想做出真正有用的 Builder 产品,必须意识到:同一座门、同一个炮塔、同一个仓库,在不同人眼里价值完全不同。
| 视角 | 他们最先看到什么 | 他们真正关心什么 |
|---|---|---|
| 新手玩家 | 安不安全、会不会迷路、会不会一出门就死 | 活下来、少犯错、少被坑、知道下一步做什么 |
| 商人 / 物流玩家 | 路线稳不稳、仓库好不好用、收费是否可预测 | 成本、时效、损耗、库存安全 |
| 海盗 / 掠夺型玩家 | 哪条路上有人、有货、有漏洞 | 拦截收益、埋伏效率、目标筛选、逃逸成本 |
| 联盟运营者 | 哪些节点必须守、哪些航线必须通、哪些设施值得长期投入 | 区域秩序、税收、物流韧性、防区稳定性 |
| Builder / Operator | 哪些节点能变成规则入口、收费点、数据入口 | 规则可执行性、运营成本、用户转化、长期复用性 |
这张表很重要,因为它解释了为什么同一个设施会出现不同诉求:
- 新手希望门“少一点限制”
- 运营者希望门“有更强筛选和治理能力”
- 商人希望门“价格透明、稳定通行”
- 海盗希望门“制造拥堵和暴露”
Builder 的工作不是只满足某一方,而是有意识地决定你的产品站在哪一边。
用五种典型身份看同一个基地
假设宇宙里有一个建立在航线节点上的基地,里面有 Network Node + Gate + Turret + Storage Unit。不同人进入这里时,脑子里想的完全不同:
1. 新手
新手进入这个基地,第一反应通常不是“这套规则多精巧”,而是:
- 我会不会被打
- 我过门要不要付费
- 我东西放这里会不会丢
- 我看不看得懂这个系统在要我做什么
对新手来说,一个好的 Builder 系统往往有这些特征:
- 规则清楚
- 失败成本低
- 误操作少
- 尽量把“你为什么被拒绝”讲明白
所以很多你以为是“体验文案”的东西,实际是玩法留存的一部分。
2. 商人 / 物流玩家
商人不会先看这个基地“酷不酷”,他先算账:
- 通过这条 Gate 比绕路节省多少时间
- 过路费是否稳定
- Storage Unit 是否能安全暂存货
- 炮塔是否能保证高价值货运过程的基本安全
如果一个基地让商人形成“虽然贵,但稳定可靠”的预期,它就可能慢慢变成交易节点。对商人来说,可预测性本身就是产品价值。
3. 海盗 / 掠夺者
海盗看到的不是服务,而是漏洞和流量:
- 这条路是不是必经之路
- 门口会不会形成排队和减速
- 哪些玩家会因为付费、开箱、交易而停留
- 炮塔是不是能被规避、诱导或反利用
这类视角会逼着 Builder 重新理解安全问题。很多系统不是“功能能跑就行”,而是要问:
- 会不会形成固定埋伏点
- 会不会暴露高价值用户
- 会不会让某类玩法变成过于稳定的收割器
4. 联盟运营者
联盟运营者看的是持续秩序:
- 这座基地值不值得长期守
- 供能成本和维护成本是否可控
- 门禁规则能否区分盟友、访客和敌对者
- KillMail 和通行数据能不能帮助判断防区质量
对他们来说,设施不是单次互动工具,而是领地制度的一部分。Builder 如果只提供一次性功能,而没有持续运营视角,产品很难进入这类玩家的长期工作流。
5. Builder / Operator
Builder 和 Operator 通常看得更“制度化”:
- 这套规则能不能扩展
- 哪些部分该链上、哪些部分该链下
- 有没有办法降低客服解释成本
- 能不能积累数据、沉淀信誉、形成复用模板
这类视角会把一个基地看成一套可复制的商业模型,而不是一组零散功能。
0.3 这个世界里有哪些最关键的“游戏对象”?
角色(Character)
角色是玩家进入世界后的核心身份。你可以把它理解成“游戏中的你”,但在 EVE Frontier 里,它还承担一个非常特殊的职责:它是链上权限和资产控制的中枢。
角色至少有三层含义:
- 它是游戏里的角色身份
- 它是链上
Character对象 - 它还是保管许多
OwnerCap的“钥匙串”
后面你学到 OwnerCap、Receiving、borrow-use-return 时,如果不先知道角色在玩法上就是“玩家控制权的载体”,会很容易觉得整套设计太绕。
部族(Tribe)
部族可以先理解成角色的初始归属或身份标签。它不一定等于永久政治阵营,但它经常被拿来做:
- 新手保护
- 门禁条件
- 通行许可
- 阵营识别
- 玩法分流
所以你后面看到“只允许某个 tribe 通过星门”“按 tribe 决定炮塔态度”时,不要把它当成一个随便的 u32 字段,它在玩法上承载的是身份分类。
物品(Items)
物品是游戏里的资源、装备、战利品、钥匙、许可和经济载体。玩法上最重要的不是“它是不是 NFT”,而是它是否能参与下面这些动作:
- 被角色携带
- 被存入存储设施
- 被交易、出租、抵押、合成、销毁
- 在玩家死亡后成为战利品或损失
- 成为某个服务的门票、押金或消耗品
这也是为什么 StorageUnit 在 EVE Frontier 里会如此重要。你不是单纯在做一个链上仓库,而是在控制游戏里“物品如何流动”。
基地与设施(Assemblies)
玩家不会只靠钱包和角色活着,他们会围绕空间位置建立设施网络。设施是玩法的真正放大器,因为它们把“一个玩家的动作”变成了“一个区域的规则”。
高频名词到底各自扮演什么角色?
下面这张表尽量用“玩法语言”而不是“源码语言”来解释你后面会频繁看到的词:
| 名词 | 在世界里是什么 | 它扮演的角色 |
|---|---|---|
Character | 玩家在链上的角色身份 | 是很多权限、设施控制权和交互资格的起点 |
Wallet / EVE Vault | 玩家发起链上动作的钱包与身份容器 | 负责签名、持币、连接 dApp,但它不等于完整游戏身份 |
Tribe | 角色所属的身份分类 | 常被拿来做阵营、白名单、通行和新手保护逻辑 |
Item | 资源、装备、许可、战利品等物品 | 是物流、交易、保险、租赁和奖励系统的基础材料 |
Assembly | 玩家部署在宇宙里的设施对象 | 把个人行为放大成区域规则,是玩法真正落地的节点 |
Network Node | 基地供能核心 | 决定一个基地能否承载更多设施,是“能不能开工”的前提 |
Energy | 供能容量 / 配额 | 决定一座基地同时能挂多少在线设施 |
Fuel | 持续消耗的运行资源 | 决定设施能维持多久在线,属于经营成本 |
Gate | 空间通行与跳跃入口 | 影响路线、收费、门禁和区域流量 |
JumpPermit | 一次或限时通行许可 | 把“能不能过门”做成显式规则和资产 |
Turret | 自动防御设施 | 负责筛选和惩罚靠近某区域的目标 |
Storage Unit | 仓储和物品流转节点 | 是市场、寄售、租赁、奖池、战利品管理的底座 |
Location | 某个对象在世界里的空间位置表达 | 把“这个东西在哪”变成可被规则引用的状态 |
LocationProof | 服务器证明“你真的在场”的凭证 | 把线下空间事实带到链上,避免远程滥用 |
KillMail | 一次击杀或战损的公开记录 | 让冲突和损失变成可索引、可奖励、可统计的事实 |
OwnerCap | 某个对象的控制权凭证 | 决定谁有资格配置和管理某座设施或某个关键对象 |
AdminACL | 服务器授权白名单 | 让只有受信任后台能写入某些高权限世界状态 |
Extension | Builder 写给设施的扩展逻辑 | 决定设施如何收费、放行、筛选、消耗和响应 |
如果你想更进一步地记这些词,可以用一个更简单的分类法:
Character / Wallet / Tribe这组词解决“你是谁”Item / Storage Unit / Logistics这组词解决“东西怎么流动”Gate / Turret / Location这组词解决“谁能进、谁能留、谁会被打”Network Node / Energy / Fuel这组词解决“设施能不能持续运转”KillMail / LocationProof / AdminACL这组词解决“哪些事实值得被公开验证”OwnerCap / Extension这组词解决“谁有权改规则、规则怎么被挂上去”
再往深一层看,这些名词各自在替世界解决什么问题?
如果只背“定义”,这些词还是容易散。更有用的记法是:它们各自在替这个世界解决哪类长期问题。
| 名词 | 它真正替世界解决的问题 |
|---|---|
Character | 把“玩家是谁、他能控制什么”集中到一个稳定身份节点上 |
Wallet / EVE Vault | 把 Web2 登录、签名和链上交互摩擦压低,让更多玩家能进入规则系统 |
Tribe | 提供最基础的一层社会分组,让门禁、保护、筛选有天然抓手 |
Gate | 让路径不再是自然给定,而可以被治理、收费和制度化 |
JumpPermit | 让“允许通行”从一句口头规则变成可验证、可时效化的凭证 |
Turret | 让区域规则拥有自动执行后果,而不只停留在 UI 提示 |
Storage Unit | 让物品控制权和流转顺序能够被稳定编排 |
Network Node | 让基地扩张必须面对容量和供能现实,而不是无限堆设施 |
Energy | 让“能挂多少设施”成为一个明确的资源约束 |
Fuel | 让设施运行具备持续成本,逼迫经营和补给发生 |
LocationProof | 让“必须亲自到场”这件事可以被规则化验证 |
KillMail | 让战损、击杀、战争结果能够留下公共可验证痕迹 |
OwnerCap | 让“对象控制权”不再只是一个地址字段,而是可借用、可约束的能力对象 |
AdminACL | 让世界承认某些链下后台输入,但又不至于把高权限开放给所有人 |
Extension | 让 Builder 能改规则,而不必直接重写世界内核 |
当你以后设计一个新产品时,可以反过来问:
- 你想解决的是“路径问题”还是“权限问题”?
- 是“物品流转问题”还是“现场验证问题”?
- 是“区域后果执行问题”还是“战损记录问题”?
这样你会更容易找到该接哪类 World 能力,而不是一上来就乱堆模块。
很多新手为什么会把这些词混掉?
因为这些词看起来都像“链上概念”,但它们其实来自不同层面:
| 层面 | 典型词 | 它们在回答什么问题 |
|---|---|---|
| 身份层 | Character、Wallet、Tribe | 谁在行动?是谁的角色? |
| 资产层 | Item、Storage Unit、KillMail | 什么东西被持有、损失、转移、记录? |
| 空间层 | Gate、Turret、Location | 你能去哪里、能停在哪里、会不会被打? |
| 运行层 | Network Node、Energy、Fuel | 设施能不能持续在线? |
| 权限层 | OwnerCap、AdminACL、Extension | 谁能配置、谁能修改、谁能代表服务器写入? |
只要先把这些层分开,后面很多概念就不会再一股脑挤在一起。
还有一组特别容易混淆的词
下面这些词经常被一起提到,但它们其实不是一回事:
| 容易混淆的词 | 真正差别 |
|---|---|
Character vs Wallet | Wallet 负责签名和持有链上资产,Character 更像游戏里的身份与控制中枢 |
Gate vs JumpPermit | Gate 是基础设施本体,JumpPermit 是允许你通过它的一种凭证 |
Energy vs Fuel | Energy 是容量配额,Fuel 是持续消耗资源 |
Storage Unit vs Item | 前者是流转容器和规则入口,后者是被搬运、交易、锁定的对象 |
OwnerCap vs AdminACL | 前者偏对象级控制权,后者偏服务器级高权限白名单 |
Location vs LocationProof | 前者是位置状态,后者是“你此刻确实满足某个位置条件”的证明 |
KillMail vs Event 日志 | KillMail 是可被索引和复用的战损事实对象,不只是一次性广播消息 |
0.4 Smart Assemblies 为什么是玩法核心?
EVE Frontier 最特别的地方之一,是把基础设施做成了玩家可拥有、可配置、可扩展的 Smart Assemblies。
你可以先把它们理解成“太空里的服务节点”:
| 设施 | 游戏里的作用 | 对 Builder 的意义 |
|---|---|---|
Network Node | 基地供能核心、设施联网起点 | 决定哪些设施能上线,影响整个基地的容量上限 |
Gate | 控制通行、跳跃、路线 | 可以做收费、白名单、门票、任务门、联盟专线 |
Turret | 防御与自动攻击 | 可以做防区规则、威胁筛选、自动安保 |
Storage Unit | 仓储与物品流转 | 可以做商店、租赁、寄售、奖池、任务交付点 |
这些设施的价值,不在于“它们是链上对象”,而在于它们直接卡住了玩家最真实的几个需求:
- 我能不能通过这里?
- 我能不能在这里安全存货?
- 我会不会被这里的炮塔打?
- 我能不能在这里买到、租到、交付某种物品?
一旦某个设施掌握了这些入口,它就自然会变成经济节点、战略节点或政治节点。
0.5 基地为什么一定绕不开 Network Node、Energy 和 Fuel?
很多人第一次接触 EVE Frontier,会本能地把设施想成“放下去就能用的链上对象”。实际不是。设施在玩法上是有生存成本和运行条件的。
一个最小基地大致是这样:
先找到可锚定位置
-> 建立 Network Node
-> 给节点补燃料并上线
-> 接入 Gate / Turret / Storage Unit
-> 这些设施从网络节点预留 Energy
-> 自身再依赖 Fuel 和在线状态持续运行
这里至少有三种完全不同的约束:
- 地理约束:你必须先在空间里有一个落点
- 容量约束:节点能提供多少 Energy,决定你能挂多少设施
- 消耗约束:Fuel 会随着时间烧掉,设施不是永久无成本在线
这意味着“我的门为什么不能用”在玩法上可能完全是三种不同的问题:
- 这座门根本没接上可用节点
- 节点容量不够,Energy 被别的设施占满了
- Fuel 烧完了,建筑进入离线状态
这也是为什么后面的 Energy / Fuel 章节不是单纯的技术细节。它们直接决定了基地是不是一个真正可经营、可收费、可持续的系统。
一个基地为什么会自然长成“服务网络”?
很多人以为基地只是“把几个建筑摆在一起”。但在 EVE Frontier 里,一个成熟基地更像一个小型制度系统:
Network Node提供底层供能能力Gate决定谁能进入、出去、绕路还是付费Turret决定靠近者面临的安全后果Storage Unit决定货物、补给和押金如何被管理
这 4 类设施一旦组合起来,基地就不再只是落脚点,而会逐步演化成:
- 收费前哨
- 联盟防区
- 物流中转站
- 边境贸易口岸
- 战争补给节点
所以 Builder 真正写出来的,常常不是单个功能,而是“某个区域应该怎么运转”的规则网络。
一个基地成熟后,通常会出现哪些“二级玩法”?
一开始基地只是活下去的工具,但一旦稳定下来,就会长出很多二级玩法:
- 收费玩法 例如过门费、仓储费、代办费、加急费
- 筛选玩法 例如白名单、会籍、部族专属、任务达标后开放
- 安全玩法 例如门口炮塔联动、重点物资仓库防区、危险名单自动识别
- 金融玩法 例如押金、保险、租赁、赔付、赏金
- 社交 / 政治玩法 例如联盟专属通道、外交条约、区域协防、战区准入制度
这也是为什么一个“只是门和炮塔”的基地,最后会变成一个像港口、关卡、市场和边防站混合体的东西。
0.6 星门、位置和空间控制为什么这么重要?
EVE Frontier 是一个强空间感的世界。你不只是“点击某个按钮去某个页面”,而是在一个有距离、有路径、有风险暴露的宇宙里移动。
Gate 在玩法上意味着什么?
Gate 的本质不是传送特效,而是通行权控制器。谁能过、什么时候能过、付多少钱、是否满足某个条件,都会影响玩家的行为路径。
所以一个星门可以自然演变成很多玩法形态:
- 收费站
- 白名单通道
- 任务完成后的奖励入口
- 联盟专属航线
- 风险区域的海关
更细一点说,Gate 在玩法上至少同时控制 4 件事:
- 路径选择 玩家是不是愿意走这条路,取决于这里快不快、稳不稳、贵不贵。
- 准入资格 谁能通过,谁要先满足条件,谁会被拒绝。
- 通行成本 这里能不能变成收费节点、月卡节点、联盟特权节点。
- 区域节奏 一旦一条门被收费、封锁或武装化,周边物流和冲突分布都会变。
所以 Gate 从来不是“一个跳转按钮”,而是交通制度。
Gate 为什么往往是最先商业化的设施?
因为 Gate 天然卡在“必经路径”上。谁掌握路径,谁就掌握三种特别容易变现的能力:
- 收费能力 过路费、会员费、临时许可费都很自然。
- 筛选能力 谁能进、谁不能进,直接影响区域人口和货物流。
- 引流能力 玩家在门口停留,就意味着周边仓储、商店、任务点都能被带动。
所以很多 Builder 第一个真正像样的商业产品,都会从 Gate 开始,而不是从更抽象的 Token 模型开始。
为什么需要位置证明?
因为很多动作不是“只要你拥有某个钱包地址就能做”,而是“你真的已经到现场了才行”。比如:
- 你靠近某个门才能通行
- 你靠近某个宝箱才能开箱
- 你到了某个市场节点附近才能现场交易
链上合约无法自己知道你在游戏世界里的实时坐标,所以才需要游戏服务器对“你在某个对象附近”这件事出具证明,再由链上验证。这就是后面 LocationProof 和临近性系统存在的玩法背景。
你也可以把位置证明理解成“把地理到场这件事做成门票条件”。没有它,很多本该依赖现场性的玩法都会退化成远程脚本操作:
- 远程开门
- 远程开箱
- 远程交易
- 远程提交本该现场完成的任务
一旦这些都能远程完成,空间就失去意义,基地和航线的战略价值也会一起下降。
0.7 炮塔、防线和“区域秩序”是怎么来的?
如果 Gate 解决的是“谁能通过”,那 Turret 解决的就是“谁靠近会被打”。
炮塔在玩法上不是单纯的伤害装置,而是区域秩序的一部分。它会让一片空间从“谁都能来”变成“来之前得先考虑后果”。这会直接改变:
- 敌我识别
- 新手保护
- 基地防守
- 通道威慑
- 物流安全成本
默认炮塔只提供基础防御逻辑,但 Builder 介入之后,炮塔可以变成更复杂的策略设施:
- 优先打主动攻击者
- 放过白名单或特定 tribe
- 对高风险目标提权
- 配合门禁和收费做整片区域的治理
所以不要把炮塔只看成战斗模块。它本质上是空间治理模块。
在成熟的区域玩法里,炮塔经常和别的设施一起发挥作用:
Gate + Turret形成收费且有强制执行力的边境口岸Storage Unit + Turret形成高价值物资节点的防守体系LocationProof + Turret形成“只有现场并满足条件的人才能安全操作”的区域规则
也就是说,炮塔不只是伤害输出,而是“规则不遵守时的后果机器”。
炮塔为什么会直接改变经济?
因为安全从来不是免费的环境变量,而是交易成本的一部分。只要炮塔存在,下面这些东西都会变化:
- 货运玩家是否愿意走某条路
- 高价值货物是否敢在某个节点暂存
- 某个区域的收费能不能被执行
- 海盗的拦截成本会不会升高
所以炮塔并不是“军事玩家才关心的东西”。它会真实影响商人、运营者和普通路过玩家的决策。
0.8 死亡、战利品、KillMail 和“损失的可见性”
EVE 系玩法和很多轻量链游有一个根本区别:损失不是抽象的数值回落,而是真实的资产、位置和机会损失。
一场 PvP 或一次错误的航行,可能带来这些结果:
- 飞船或设施被摧毁
- 物品流失
- 战利品被别人捡走
- 某条运输线被迫中断
- 对手获得一条公开的 KillMail 记录
KillMail 的意义不只是“做个战绩榜很好看”。它在玩法上至少有 4 个作用:
- 让损失可被公开验证
- 让战斗历史成为经济和声望系统的一部分
- 让 Builder 可以围绕击杀记录设计奖励、保险、赏金、排行榜
- 让世界里的冲突留下持久痕迹
这也是为什么你后面会看到围绕 KillMail 做保险、悬赏、成就和统计的设计。因为它不是边缘日志,而是战争叙事的一部分。
如果再讲得更直接一点,KillMail 让这个世界里的很多东西第一次可以被制度化:
- “这次损失到底是不是真的”可以被验证
- “这个人最近是不是高风险目标”可以被统计
- “保险到底该不该赔”可以不靠嘴说
- “这个联盟是不是在守住航线”可以通过战斗记录侧面体现
所以它不是单纯的战报,而是战争、保险、信誉、赏金系统的公共底稿。
“死亡有记录”为什么会深刻改变玩家行为?
因为一旦损失会留下公共痕迹,玩家的行为就不再只是短期结果,还会影响长期名声和制度评价:
- 运营者会更关心航线是否安全
- 保险方会更关心赔付条件是否真实
- 赏金系统可以更精确地识别目标
- 玩家会更在意哪些地方容易出事
KillMail 让“这世界发生过什么”不再只存在于聊天记录和回忆里,而进入了可统计、可组合的规则层。
0.9 存储、物流和交易为什么会成为一整套经济?
在 EVE Frontier 里,物品不是“进了背包就完事”的。真正有价值的是物品如何被保管、搬运、交换和许可访问。
一个物品从产出到消费,可能会经过很多环节:
玩家获得物品
-> 临时携带
-> 存入 Storage Unit
-> 被挂牌出售 / 作为押金 / 作为租赁标的
-> 被其他玩家购买、租走、赎回或消耗
-> 在战争、运输或死亡中再次流转
于是存储设施就不再只是仓库,而会自然演化成:
- 商店
- 自动售货机
- 寄售点
- 共享仓库
- 奖励池
- 战利品回收点
- 跨联盟物资交换接口
从玩法角度说,Builder 最容易做出价值的地方,常常不是“发一个新 Token”,而是把现有物品流转路径做得更顺、更安全、更有规则感。
这也是为什么 Storage Unit 经常是最被低估的设施。很多产品创新,表面上像是在做市场、租赁、保险、盲盒或任务系统,底层其实都在回答一个问题:
某个物品什么时候可以被谁拿走、放进去、锁住、释放、转让、销毁或兑换?
谁控制了这个流程,谁就控制了相当大一部分游戏经济。
为什么 Storage Unit 常常是所有玩法的“幕后主角”?
因为很多系统最后都要落到“东西放哪、谁能拿、什么时候放行”:
- 市场要解决交割
- 租赁要解决暂时转移和到期回收
- 奖励池要解决按条件释放
- 保险要解决赔付物资或赔付资金的发放
- 任务系统要解决交付物和领取物的顺序
所以 Storage Unit 虽然表面上不像 Gate 那样显眼,但它经常是最靠近经济底盘的设施。
0.10 经济系统里,SUI 和 LUX 分别扮演什么角色?
在这本书的上下文里,你可以先做一个足够实用的理解:
- SUI 更像底层链上燃料和通用结算资产
- LUX 更像更贴近游戏服务和日常经济交互的资产
很多 Builder 产品在玩法上会围绕这些动作设计:
- 通行收费
- 服务费
- 押金
- 租赁结算
- 保险保费与赔付
- 奖励发放
真正重要的不是“用哪种 Coin 更高级”,而是你设计的收费模型是否符合玩法。比如:
- 高频小额服务更在意低摩擦
- 风险场景更需要押金和违约惩罚
- 长期关系更适合订阅、会籍、许可模式
- 战争相关产品更看重赔付、担保和信誉
所以经济设计从来不是独立章节。它始终嵌在通行、防御、仓储、物流和外交这些玩法里。
0.11 哪些事情在链上,哪些事情仍然在游戏服务器?
理解 EVE Frontier 最关键的一点,就是不要把“开放世界游戏”和“链上合约”混成一个系统。
| 更适合游戏服务器处理的事 | 更适合链上处理的事 |
|---|---|
| 实时位置、物理模拟、战斗过程 | 资产归属、权限对象、许可规则、收费结算 |
| 高频状态刷新、即时判定 | 可验证记录、长期状态、开放组合接口 |
| 游戏内坐标和实时观测 | KillMail、OwnerCap、JumpPermit、配置对象 |
两边是协作关系,不是替代关系。
你可以把它理解成:
- 游戏服务器 负责“世界正在发生什么”
- 区块链 负责“哪些规则和结果需要公开、持久、可组合地成立”
正因为这样,Builder 写的东西才更像“规则基础设施”,而不是接管整个游戏引擎。
这层分工如果理解错了,后面就会产生两种常见误区:
- 一种误区是“为什么不把所有实时玩法都搬上链” 实时物理和高频判定不适合直接做成公开链状态
- 另一种误区是“既然服务器都知道,那为什么还要上链” 因为权限、资产、许可、赔付、公开记录这些东西,恰恰需要公开可验证
EVE Frontier 的特别之处,不是极端地只选一边,而是把两边各自最擅长的部分拼成一个更强的系统。
作为 Builder,最值得优先观察的 10 类玩法入口
如果你想从“知道世界观”过渡到“知道该做什么”,最值得先观察的是这些入口:
- 谁在某条路上拥有天然流量。
- 哪些地点要求玩家必须亲自到场。
- 哪些物品会频繁存取、转手、押注或消耗。
- 哪些规则需要自动执行,而不能只靠口头约定。
- 哪些损失需要公开记录,才能支持后续赔付或统计。
- 哪些区域已经存在稳定但低效的收费或筛选需求。
- 哪些服务因为需要人工信任而做不大。
- 哪些联盟或团体需要长期维护自己的秩序和边界。
- 哪些玩家因为操作成本太高而放弃某种交易或协作。
- 哪些地方一旦规则标准化,就能被复制到很多基地和航线。
这 10 类入口,本质上就是 Builder 最容易发现真实需求的地方。
0.12 开发者(Builder)能在这个世界里做什么?
理解了玩家的游戏循环后,作为开发者(Builder),你可以利用 Sui 的智能合约和 EVE Frontier 的开放接口,在以下四个核心方向大展拳脚:
1. 编写智能组件扩展(Extensions)
游戏内的星门(Gate)、炮塔(Turret)和储物箱(Storage Unit)默认只有最基础的运行逻辑。Builder 可以编写 Move 合约,将这些设施变成复杂的商业规则引擎:
- 星门收费站:按次收费、提供月卡订阅、或针对敌对联盟收取高额“过路费”。
- 智能火控网:让炮塔识别“有赏金的通缉犯”或“未缴纳保护费的路人”并自动开火。
- 自动化物流箱:玩家存入矿石对象,合约自动按当前市场汇率结算 LUX 代币。
2. 构建去中心化应用与界面(dApps & UI)
有了链上规则,玩家需要方便的用户界面来交互。你可以使用 EVE Vault(玩家数字身份插件)结合前端和 dApp Kit 开发:
- 门票发售大厅:一个可供玩家在线购买“跳跃通行证(Jump Permit)”的网页。
- 联盟财务看板:展示共享金库的实时余额、每日税收和战利品分发记录。
- 游戏内嵌套浮层:在游戏内部直接弹出的 Web UI,让玩家不离开客户端就能完成交互与签名。
3. 设计高级深空金融与经济模型(DeFi)
由于所有的物品(飞船、武器、资源)都可以在链上被映射为独特的 Object,EVE Frontier 天然适合孕育硬核的金融协议:
- 战损保险合约:根据击杀日志(KillMail)验证确切战损,自动向爆船的玩家进行赔付。
- 去中心化租赁协议:用极大保障玩家安全的机制临时借用极品火力装备(如炮塔),到期不还自动没收押金或撤销控制权。
- 期货与期权市场:结合各大财团战区资源产出率建立大型深度交易池(如 DeepBook 集成)锁定矿物价格。
4. 数据情报与情报网络(Data & Intel Indexing)
链上每一笔事件都在广播游戏世界的实时异动。Builder 可以利用索引器(Indexer)或后端监听搭建生态情报工具:
- 星际战事热力图:通过聚合全网跳跃许可记录和
KillMail数据,实时提示哪些星系正在爆发星战,成为前线高危区。 - 全网赏金猎杀红名榜:记录每个玩家恶意违约、敌对击杀行为等链上足迹,让佣兵联盟形成可量化的信誉评级。
简而言之,普通的玩家是在体验这个数字宇宙的残酷与宏大,而 Builder 则是直接在书写和分发这个宇宙的基础运行法则。
0.13 用一个完整场景把这些概念串起来
假设某个 Builder 团队在一条重要航线上经营一个前哨基地:
- 他们先建立
Network Node,让整座基地获得供能能力。 - 接着部署一个
Gate,把这条航线变成可收费通道。 - 在门附近部署
Turret,防止敌对玩家白嫖或袭扰。 - 再放一个
Storage Unit,让路过玩家能买补给、寄存物资、交押金。 - 为了避免任何人都能穿门,他们给 Gate 写了一个扩展:
- 白名单联盟成员免费通过
- 普通玩家支付通行费
- 高风险区域玩家必须持有临时许可
- 为了处理“人到了现场才允许交易”的玩法,他们要求市场操作附带位置证明。
- 如果附近发生击杀,KillMail 会被索引出来,用来给安保联盟发战斗奖励。
你会发现,这里面几乎已经把后面整本书的大部分主题都串起来了:
Character和权限Gate / Turret / StorageUnitEnergy / FuelLocationProofKillMail- 收费、许可、保险、奖励
- dApp 展示和链下索引
所以这本书后面每一章,其实都不是在讲“一个抽象技术点”,而是在拆解这个世界里某一类真实玩法需求。
0.14 读完这一章后,你应该带着什么进入后续章节?
如果这一章你已经建立起下面这些直觉,后面的内容就会顺很多:
- EVE Frontier 的核心不是发资产,而是经营基础设施和规则入口。
- 设施是玩法节点,不是装饰物。
- 位置、通行、仓储、防御、战损和经济是互相连着的。
- 游戏服务器和链上合约分工明确,但会在关键规则上协作。
- Builder 写的不是“外挂功能”,而是可能长期嵌入世界秩序的规则系统。
接下来建议按这个顺序进入:
等你再往后读到下面这些章节时,可以随时回来看这一章:
- Chapter 16:位置与临近性系统
- Chapter 32:KillMail 系统
- Chapter 29:能量与燃料系统
- Chapter 30:Extension 模式实战
- Chapter 31:炮塔 AI 扩展
- Chapter 26:访问控制完整解析
Chapter 1:EVE Frontier 宏观架构与核心概念
目标: 理解 EVE Frontier 是什么,它为什么选择 Sui 区块链,以及“可编程宇宙“的核心哲学。
状态:基础章节。正文以宏观架构和术语建立为主,适合作为全书入口。
如果你对这款游戏本身还没有形成清晰直觉,建议先读 前置章节:先读懂 EVE Frontier 这款游戏。
1.1 为什么 EVE Frontier 不一样?
传统网络游戏的世界规则由开发商独断——经济系统、战斗公式、内容更新,玩家只是参与者。EVE Frontier 挑战了这一范式:游戏的核心机制是开放的,开发者(Builders)可以在游戏服务器限定的框架内,真正重写和扩展游戏规则。
这不是简单的“MOD 插件“——你写下的逻辑会作为智能合约运行在 Sui 公链上,永久可查、无需中心化服务器托管、7×24 自动执行。
它不是什么?
初学者最容易把 EVE Frontier 想成以下几种东西,但它都不完全等同:
| 容易混淆的对象 | 为什么像 | 为什么又不一样 |
|---|---|---|
| 传统 MOD / 插件系统 | 都允许第三方扩展游戏逻辑 | MOD 通常跑在中心化服务器或客户端;EVE Frontier 的关键状态和规则可以上链、可审计、可组合 |
| 私服脚本系统 | 都能改掉默认玩法 | 私服脚本通常由运营方单方面控制;Builder 合约则可以形成公开、可验证的规则市场 |
| 普通链游合约 | 都有 NFT、Token、市场 | EVE Frontier 的重点不是单个资产合约,而是把“星门、炮塔、存储箱”这种游戏基础设施变成可编程对象 |
| 纯 on-chain game | 都强调链上规则 | EVE Frontier 仍保留游戏服务器、物理模拟和实时世界,因此是“链上规则 + 游戏服务器协作”的混合体系 |
你可以把它理解成:
EVE Frontier 不是“把整个游戏搬上链”,而是把足够重要、足够可组合、足够值得公开验证的那部分游戏规则搬上链。
三种玩家角色
| 角色 | 主要动作 |
|---|---|
| Builder(构建者) | 编写 Move 合约,部署智能组件,构建 dApp 界面 |
| Operator(经营者) | 购买/拥有设施,配置 Builder 的模块,经营经济势力 |
| Player(玩家) | 与 Builder/Operator 搭建的设施交互,组成游戏世界 |
本课程的目标受众是 Builder,但理解另外两个角色有助于你设计更有价值的产品。
这三种角色如何互动?
很多人第一次读会以为这三个角色是完全分开的。实际不是,它们描述的是同一套生态里的三种责任:
- Builder 负责“定义规则” 例子:写一个收费星门、租赁市场、联盟分红系统
- Operator 负责“经营规则” 例子:真的去买下设施、设置费率、颁发通行证、维护库存
- Player 负责“消费规则” 例子:购票、租装备、通过炮塔检查、领取奖励
一条最小业务链通常是这样的:
Builder 写合约
-> Operator 部署并配置设施
-> Player 与设施交互
-> 链上状态变化被 dApp / 其他 Builder 继续消费
这也是为什么 Builder 不能只会写合约。你还得理解:
- Operator 在意什么:收益、权限、安全、维护成本
- Player 在意什么:价格、便利性、可预测性、是否被坑
一个最小 Builder 闭环长什么样?
如果把 EVE Builder 生态压缩成最小闭环,通常是下面这条链:
Builder 设计规则
-> 部署设施和扩展
-> Operator 配置参数并经营
-> Player 付费或满足条件后使用
-> 交易、权限和资产变化落到链上
-> 前端和索引层把结果再展示出来
这条链里每一环都不是可有可无:
- 没有 Builder,世界里就没有新的规则设施
- 没有 Operator,设施就缺少持续经营者
- 没有 Player,规则就不会形成真实经济活动
所以后面你在设计任何组件时,都最好先问自己三件事:
- 谁来定义规则?
- 谁来经营和维护它?
- 玩家为什么愿意使用它?
1.2 智能组件 (Smart Assemblies):可编程的星空基础设施
Smart Assemblies 是 EVE Frontier 中被玩家建造在太空中的物理设施。它们既是游戏对象,也是区块链上的可编程合约对象。
更准确地说,一个智能组件通常同时有三层身份:
- 游戏世界中的物理设施 例子:你在太空里真的能看到一个炮塔或星门
- 链上的共享对象 例子:它有对象 ID、状态字段、权限规则
- dApp 可访问的服务入口 例子:前端可以查询它的库存、费率、在线状态,并发起交易
所以当你说“我做了一个智能星门”,本质上不是只做了一个 UI,也不是只写了一个 Move 模块,而是做了一个:
游戏中可见、链上可验证、前端可操作的基础设施服务。
主要组件类型
🏗 网络节点 (Network Node)
- 锚定在拉格朗日点(Lagrange Point)
- 为整个基地提供能源(Energy)
- 所有设施必须连接网络节点才能运行
- 不可直接编程,但是其他组件的运行基础
📦 智能存储单元 (Smart Storage Unit, SSU)
- 链上存储物品,支持“主仓库“与“临时仓库“(Ephemeral Inventory)
- 默认只允许 Owner 取放物品
- 通过自定义合约可变身为:自动售货机、拍卖行、公会金库
⚡ 智能炮塔 (Smart Turret)
- 自动防御设施
- 默认行为是标准攻击逻辑
- 通过合约可自定义锁定目标的判断逻辑(例如:只攻击没有许可证的角色)
🌀 智能星门 (Smart Gate)
- 链接两个位置,允许角色跃迁
- 默认所有人可跳跃
- 通过合约引入“跳跃许可证 (JumpPermit)“机制,可实现白名单、收费、时效控制等
四种组件最常见的 Builder 改造方向
| 组件 | 默认能力 | Builder 最常做的改造 |
|---|---|---|
| Network Node | 提供供能与联网基础 | 一般不直接改逻辑,而是围绕能量/联网状态做上层业务 |
| Storage Unit | 存取物品 | 商店、拍卖、租赁、任务仓库、联盟金库 |
| Turret | 自动攻击 | 白名单、收费保护、战斗事件联动、优先级 AI |
| Gate | 允许跃迁 | 收费、许可证、任务门槛、部族/阵营过滤 |
如果你不确定一个点子该从哪种组件切入,先问自己:
- 它本质上是“存东西”吗?优先从
Storage Unit开始 - 它本质上是“决定谁能通过”吗?优先从
Gate开始 - 它本质上是“决定谁会被打”吗?优先从
Turret开始 - 它本质上依赖供能/联网约束吗?需要同时理解
Network Node
Smart Assembly 的生命周期
一个智能组件不是“发到链上就完了”,它通常会经历一整条生命周期:
- 创建 / 锚定 设施第一次在世界里被建立
- 归属 它被绑定到某个角色或经营主体
- 上线 它获得能量、网络和可交互状态
- 扩展 Builder 把自定义规则挂进去
- 运营 Operator 调整价格、库存、权限
- 消费 Player 与之发生真实交互
- 下线 / 迁移 / 停用 设施可能失去能量、升级、被替换或停止运营
你后面学到的合约、dApp、脚本、钱包、索引,其实都是围绕这条生命周期服务的。
1.3 三层架构:游戏世界是如何构建的?
EVE Frontier 的世界合约使用了严格的三层架构,这是理解后续所有内容的关键:
┌────────────────────────────────────────────────────┐
│ Layer 3: Player Extensions(玩家扩展层) │
│ 你写的 Move 合约就在这里 │
└────────────────┬───────────────────────────────────┘
│ 通过 Typed Witness Pattern 调用
┌────────────────▼───────────────────────────────────┐
│ Layer 2: Smart Assemblies(智能组件层) │
│ storage_unit.move gate.move turret.move │
└────────────────┬───────────────────────────────────┘
│ 内部调用
┌────────────────▼───────────────────────────────────┐
│ Layer 1: Primitives(基础原语层) │
│ status location inventory fuel energy │
└────────────────────────────────────────────────────┘
- Layer 1 - 基础原语:不可直接调用的底层模块,实现“数字物理学“(如位置、库存、燃料)
- Layer 2 - 智能组件:面向玩家开放的组件对象,每一个都是 Sui 共享对象(Shared Object)
- Layer 3 - 玩家扩展:你作为 Builder 工作的地方,通过类型见证(Typed Witness)安全插入自定义逻辑
关键理解:你无法直接修改 Layer 1/2,但你可以在 Layer 3 编写逻辑,通过官方授权的 API 与组件交互。这既保证了游戏世界的安全性,又为 Builders 提供了足够的自由度。
每一层到底负责什么?
| 层级 | 负责什么 | 典型问题 | 你通常怎么接触到它 |
|---|---|---|---|
| Layer 1: Primitives | 定义最底层世界规则 | 位置、库存、燃料、能量、状态切换怎么表示 | 通常通过源码精读理解,不直接改 |
| Layer 2: Assemblies | 把底层规则包装成玩家能用的设施 | 星门怎么跳、炮塔怎么打、存储箱怎么取放 | 通过官方 API、官方组件入口与之交互 |
| Layer 3: Extensions | 在不破坏内核的前提下插入自定义业务 | 谁能过门、收费多少、满足什么条件才放行 | 这是 Builder 的主战场 |
一个很实用的判断标准:
- 如果你在定义“世界基本规律”,那通常是 Layer 1 的问题
- 如果你在定义“官方设施默认怎么工作”,那通常是 Layer 2 的问题
- 如果你在定义“我的设施想怎么工作”,那通常是 Layer 3 的问题
一次真实交互是怎么穿过三层的?
以“玩家付费通过星门”为例:
玩家在 dApp 点击“购买并跳跃”
-> Layer 3: 你的收费扩展检查是否已支付 / 是否持票
-> Layer 2: Gate 组件执行跃迁入口
-> Layer 1: 底层位置、状态、权限、燃料等原语完成校验与状态更新
-> 结果写回链上对象,前端刷新
所以你写扩展时,脑子里最好始终分清:
- 哪部分是“我的业务规则”
- 哪部分是“官方组件保证的行为”
- 哪部分是“底层世界物理规则”
为什么这套分层对 Builder 很重要?
因为它直接决定了你应该把逻辑写在哪里。
比如你想做一个“收费星门”:
- 收费规则和折扣策略:写在你的扩展里
- 星门跳跃本身如何执行:由官方组件负责
- 跳跃时涉及的位置、权限、状态切换:由底层原语负责
如果你把这三件事混在一起,就会出现两种常见问题:
- 你在扩展里重复实现了底层已经保证的规则
- 你以为自己能改官方组件内核,实际根本没有那个权限
什么是 Typed Witness?
这里先给一个直觉,不深入语法细节:
- 你不能随便对官方星门说“以后按我的函数来”
- 你必须通过一种被官方接受的类型身份标记接入
- 这个类型身份就是后面会反复出现的
Typed Witness
你可以把它粗略理解成:
“我不是直接改官方代码,而是拿着一张类型化的授权工牌,把自己的扩展逻辑挂到官方组件上。”
后面在 Chapter 30 你会看到它如何具体工作。
1.4 为什么选择 Sui 区块链?
EVE Frontier 迁移到 Sui 不是偶然,而是深思熟虑的技术选型。
Sui 的核心优势
| 特性 | 传统区块链 | Sui |
|---|---|---|
| 资产模型 | 账户余额模型 | 以**对象(Object)**为中心,每个资产有唯一 ID 和所有权历史 |
| 并发处理 | 串行执行 | 独立对象可并行执行,极高吞吐量 |
| Transaction 延迟 | 秒级到分钟级 | 亚秒级最终确认 |
| 玩家体验 | 需要管理助记词 | zkLogin:使用 Google/Twitch 账号登录 |
| Gas 费 | 用户自付 | 支持赞助交易(Sponsored Tx),开发者可代付 |
对象模型意味着什么?
在 Sui 上,游戏内的每一件物品、每一个角色、每一个组件都是独立的链上对象,具有:
- 唯一的
ObjectID - 明确的所有权(
owned by address/shared/owned by object) - 完整可追溯的操作历史
这对游戏世界特别重要,因为很多游戏对象本来就天然适合“独立实体”表达:
- 许可证是一张独立票据
- 仓库是一座独立设施
- 条约是一份独立协议
- 击杀记录是一条独立战报
当这些东西都是对象时,你就能很自然地做:
- 转让
- 授权
- 查询
- 组合
- 历史追踪
这也是为什么 EVE Frontier 能把“设施、权限、交易、事件”做成可编程生态,而不是一堆只能内部消费的数据库记录。
这使得去中心化的所有权、交易和游戏历史存档变成了天然成立的能力。
三种最关键的对象状态
这一节如果不讲清,后面很多内容都会读着别扭。
| 对象状态 | 含义 | EVE Frontier 中常见例子 |
|---|---|---|
owned by address | 对象归某个地址直接拥有 | 玩家钱包里的 NFT、某些凭证对象 |
shared | 对象任何人都可在满足规则时访问 | 星门、炮塔、市场、共享金库 |
owned by object | 对象被另一个对象持有 | 角色持有的能力对象、设施内部资产 |
这三种状态决定了你后面几乎所有设计:
- 权限怎么写
- 交易怎么组装
- 前端怎么查对象
- 能否并行执行
为什么对象模型特别适合空间游戏?
因为空间游戏天然就是“很多离散对象在互动”:
- 飞船是对象
- 角色是对象
- 门、炮塔、存储箱是对象
- 通行证、保单、租赁凭证也是对象
Sui 的对象模型让这些东西不需要硬塞进一个中心化数据库表或者一个巨大的合约映射里。你可以把每个设施、每个凭证、每笔关系都建成独立对象,再通过:
- 所有权关系
- 共享访问
- 事件
- 动态字段
把它们组织起来。
Sponsored Tx 和 zkLogin 为什么对游戏体验重要?
传统链上应用最劝退玩家的两个点是:
- 要先学钱包、助记词、Gas
- 每做一步都要自己付手续费
Sui 在 EVE Frontier 里的价值,不只是“性能更高”,而是它提供了把 Web2 玩家逐步引入链上交互的基础条件:
- zkLogin:降低钱包门槛
- Sponsored Tx:降低交易门槛
- 低延迟对象交易:降低交互等待感
这三点叠在一起,才让“游戏内点一下就完成链上动作”变得现实。
1.5 EVE Vault:你的身份与钱包
EVE Vault 是官方提供的浏览器扩展 + Web 钱包,是你作为 Builder 和玩家的数字身份。
核心功能
- 存储 LUX、EVE Token 及游戏内 NFT
- 通过 zkLogin 用 EVE Frontier SSO 账号创建 Sui 钱包,无需管理助记词
- 作为 dApp 连接协议,在游戏内和外部浏览器中授权第三方 dApp 访问
- FusionAuth OAuth 将游戏角色身份与钱包绑定
它和普通钱包的区别是什么?
普通加密钱包的思路通常是:“先有钱包,再去找应用”。
EVE Vault 更像是:“我先是 EVE Frontier 里的用户,然后钱包能力自然嵌进这个身份体系里”。
这意味着它同时承担三件事:
- 资产容器 持有 LUX、Token、NFT、凭证
- 身份桥梁 把游戏账号、SSO 登录、Sui 地址连接起来
- 交互授权器 给 dApp 提供连接、签名、赞助交易能力
zkLogin 先记住什么就够了?
先不用一上来就钻密码学细节,理解这三点就够:
- 它让用户可以用熟悉的登录方式进入链上系统
- 背后仍然会落到一个可在 Sui 上使用的钱包身份
- 这不是“没有钱包”,而是“钱包创建和恢复的体验被重新包装了”
等你看到 Chapter 33 时,再深入它的证明结构和临时密钥机制。
两种货币
| 货币 | 用途 |
|---|---|
| LUX | 游戏内主要交易货币,用于购买、服务、收费等 |
| EVE Token | 生态参与代币,用于开发者激励、特殊资产购买 |
1.6 可编程经济:Builder 的商业可能性
回顾一下 Builder 可以实现哪些真实的商业逻辑:
💰 经济系统
├── 自定义交易市场(自动撮合、竞价拍卖)
├── 联盟代币(基于 Sui 的 Fungible Token)
└── 服务收费(星门通行费、存储租金)
🛡 安全与权限
├── 白名单访问控制(哪些玩家可以使用你的设施)
└── 条件锁定(只有完成任务的角色才能提取物品)
🤖 自动化
├── 炮塔自定义锁定逻辑
├── 物品自动分发(任务奖励、空投)
└── 跨设施联动(A 设施的行为触发 B 设施的响应)
🏗 基础设施服务
├── 第三方 dApp 读取链上状态
└── 外部 API 联动(链外数据触发链上动作)
一个最小 Builder 商业闭环长什么样?
如果你还是觉得“Builder 到底在做什么”有点抽象,可以先记住这个最小闭环:
我控制一个设施
-> 我定义别人使用它时必须遵守的规则
-> 规则写进链上
-> 玩家按规则付费/持证/满足条件后使用设施
-> 收入、权限、凭证、历史记录都留在链上
例如:
- 收费星门:按次收费
- 联盟仓库:按权限放物品
- 任务门:完成考核后才能进入
- 拍卖箱:按价格曲线卖资源
这和“做一个普通游戏插件”最大的差别是:
- 规则是公开的
- 状态是可验证的
- 资产流转是可追踪的
- 其他 Builder 还可以继续组合你的规则
第一章读完后,你至少应该能回答这 5 个问题
- EVE Frontier 为什么不是普通 MOD 系统?
- Builder、Operator、Player 各自负责什么?
- Smart Assembly 为什么既是游戏设施又是链上对象?
- 三层架构里,Builder 真正工作的层是哪一层?
- Sui 的对象模型为什么比传统账户余额模型更适合这类游戏?
🔖 本章小结
| 学习点 | 核心概念 |
|---|---|
| EVE Frontier 的定位 | 真正开放的可编程宇宙,Builder 可改写游戏规则 |
| 智能组件类型 | Network Node / SSU / Turret / Gate |
| 三层架构 | Primitives → Assemblies → Player Extensions |
| 为什么用 Sui | 对象模型、并发、低延迟、zkLogin 无摩擦体验 |
| EVE Vault | 官方钱包 + 身份系统,基于 zkLogin |
📚 延伸阅读
Chapter 2:Sui 与 EVE 环境配置
目标: 只完成本书最基础、最必要的两项安装:
Sui CLI与EVE Vault。本章不再展开 Git、Docker、Node.js、pnpm 这类通用开发工具。
状态:基础章节。正文只保留 Sui 与 EVE Frontier 直接相关的安装与配置。
2.1 本章只安装什么?
这一章只处理两类和本书直接相关的安装项:
| 工具 | 版本要求 | 用途 |
|---|---|---|
| Sui CLI | testnet 版 | 编译、发布 Move 合约 |
| EVE Vault | 最新版 | 浏览器钱包 + 身份 |
2.2 为什么先只装这两样?
因为你在继续往下读之前,真正必须具备的最小能力只有两项:
- 本地能跑
sui命令 你后面所有 Move 编译、测试、发布、对象查询都依赖它 - 浏览器里有一个可用的 EVE 钱包身份 你后面所有 dApp 连接、签名、领测试资产都依赖它
像 Git、Docker、Node.js、pnpm 当然后面还会用到,但它们属于:
- 通用开发工具
- 脚手架工程工具
- 前端和脚本运行工具
这些更适合在你进入 Chapter 6 和 Chapter 7 时,再结合工程目录一起装。
这一章真正要建立的,不只是两个软件
更准确地说,这一章是在建立两套工作入口:
- 命令行入口 给你做编译、发布、查询、测试
- 浏览器入口 给你做钱包连接、签名、dApp 交互
后面你几乎所有开发动作,都会在这两个入口之间来回切换:
- 写完合约,用 CLI 编译和发布
- 打开前端,用 EVE Vault 连接和签名
- 查对象时可能用 CLI,也可能用前端或 GraphQL
所以这章虽然看起来只是安装,实际上是在给后面全书铺“工作台”。
2.3 安装 Sui CLI
推荐直接使用官方的 suiup 安装方式。这样本章就不需要区分 Homebrew、apt、nvm 之类系统工具链。
# 安装 suiup
curl -sSfL https://raw.githubusercontent.com/MystenLabs/suiup/main/install.sh | sh
# 重新打开终端或 reload shell 后执行
suiup install sui@testnet
# 验证
sui --version
如果 sui --version 能正常输出版本号,本章第一步就算完成。
2.4 初始化 Sui 客户端
安装 Sui CLI 后,需要初始化客户端并连接网络:
# 初始化配置(首次运行会提示选择网络)
sui client
# 选择 testnet,或连接本地节点:
# localnet: http://0.0.0.0:9000
# 查看当前地址
sui client active-address
# 查看余额
sui client balance
你在这里到底完成了什么?
执行完 sui client 后,你本地会多出一套最基本的链上身份和网络配置:
- 当前活跃地址
- 当前默认网络
- 与该网络对应的 RPC 配置
- 本地 CLI 之后发交易和查对象时要用到的账户上下文
也就是说,sui client 不是单纯“看看余额”的命令,而是在给你后续所有 Move 开发动作打地基。
sui client 和 EVE Vault 是什么关系?
这两个东西最容易让初学者混淆:
sui client是命令行环境里的身份与网络配置EVE Vault是浏览器环境里的身份与签名入口
它们都能代表“你”,但服务的场景不同:
- 你在终端里发布合约、跑测试、查对象时,主要依赖
sui client - 你在网页里连接 dApp、点击按钮、签名交易时,主要依赖
EVE Vault
它们必须是同一个地址吗?
不一定。
很多开发者会出现这种情况:
- CLI 用一个测试地址
- EVE Vault 里是另一个 zkLogin 地址
这不是绝对错误,但你必须非常清楚:
- 你现在在哪个地址上发包
- 你的设施归哪个地址或角色控制
- 你的前端连的是哪个钱包地址
只要这三件事没对齐,你就会频繁遇到“我明明发了,前端为什么看不到 / 不能操作”的问题。
从 Faucet 获取测试 SUI
如果连接的是 testnet:
# 通过 CLI 请求测试币
sui client faucet
# 或访问网页 Faucet:
# https://faucet.testnet.sui.io
2.5 安装并初始化 EVE Vault
EVE Vault 是你的浏览器身份,用于连接 dApp 和授权交易。
安装步骤
- 下载最新版 Chrome 扩展:
https://github.com/evefrontier/evevault/releases/download/v0.0.2/eve-vault-chrome.zip - 解压 zip 文件
- 打开 Chrome → 扩展管理 → 开启“开发者模式“→ “加载已解压的扩展程序” → 选择解压文件夹
- 点击扩展图标,使用 EVE Frontier SSO 账号(Google/Twitch 等)通过 zkLogin 创建你的 Sui 钱包
优势:zkLogin 不需要助记词,你的 Sui 地址完全由你的 OAuth 身份唯一推导出,安全且便捷。
这里最值得理解的不是“安装方法”,而是它为什么会极大降低新用户门槛:
- 不需要先教育用户保存助记词
- 不需要先装一套传统钱包心智
- 用户可以直接用熟悉的账号体系进入链上交互
对 Builder 来说,这意味着你的 dApp 不必默认把用户当成“已经是资深加密用户的人”。这会直接影响你的产品设计方式:
- 登录和连接流程可以更短
- Gas 体验可以进一步配合赞助交易优化
- 你可以把重点放在设施体验,而不是钱包教育
2.6 EVE Vault 在本书里具体负责什么?
安装完 EVE Vault 后,它在后续章节里会承担三类职责:
- 钱包 持有 LUX、SUI、NFT、权限凭证
- 身份 用 EVE Frontier 账号进入链上交互体系
- 授权入口 给 dApp 提供连接、签名、赞助交易能力
你可以先把它理解成:
sui client是命令行里的链上身份,EVE Vault是浏览器和 dApp 里的链上身份。
两者不一定是同一个地址,但它们都必须工作正常。
什么时候该先检查 CLI,什么时候该先检查钱包?
这能帮你更快定位问题:
- 合约编译失败 先看 CLI
- 发布交易失败 先看 CLI 当前网络和地址
- 前端连不上钱包 先看 EVE Vault
- 前端能连钱包但按钮报权限错误 先核对钱包地址、角色归属和对象权限
不要把所有链上问题都归咎于“钱包坏了”或者“CLI 配错了”。大多数时候,是你没有先分清问题发生在哪一层。
2.7 EVE Vault Faucet:获取测试资产
在开发和测试阶段,你至少会碰到两种测试资产:
- 测试 SUI 用于链上交易 Gas
- 测试 LUX 用于模拟 EVE Frontier 游戏内经济交互
获取 LUX 的方式:
- 安装 EVE Vault 后,在扩展界面找到 GAS Faucet
- 输入你的 Sui 地址请求测试代币
- LUX 会出现在你的 EVE Vault 余额中
详细说明见:GAS Faucet 文档
为什么测试阶段同时需要 SUI 和 LUX?
因为它们扮演的角色不同:
- SUI 是链上交易的 Gas 资源,没有它很多交易连发都发不出去
- LUX 更像 EVE Frontier 业务环境里的经济资产,很多教程和案例会用它模拟游戏内收费、结算、许可购买
如果你只有 SUI,没有 LUX:
- 你能发交易
- 但很多业务流程没法按书里的方式演练
如果你只有 LUX,没有 SUI:
- 你甚至很难完成最基础的链上交互
2.8 最小验收清单
到这里为止,你不需要马上跑脚手架,也不需要先装前端依赖。先确认下面四件事:
sui --version能输出版本sui client active-address能返回当前地址EVE Vault已完成 zkLogin 初始化- 钱包里至少能看到测试资产或可请求 Faucet
如果这四件事都成立,说明你已经具备继续学习本书前半段的最小环境。
最常见的三种环境错位
1. CLI 在 testnet,钱包却切在别的网络
表现:
- 终端里能查到对象
- 前端里看不到对应资产或组件
2. CLI 地址和钱包地址不是同一个,但自己没意识到
表现:
- 合约是一个地址发的
- dApp 连的是另一个地址
- 前端操作时提示没有权限
3. 水龙头领到了币,但领到了“另一套身份”上
表现:
- 你明明领过测试币
- 但当前正在用的钱包或 CLI 地址余额仍然是 0
一旦遇到“我明明做了,但系统说没有”这种问题,先不要急着怀疑教程。先把这三件事重新核对一遍。
什么时候再装其他工具?
🔖 本章小结
| 步骤 | 操作 |
|---|---|
| 安装 Sui CLI | suiup install sui@testnet |
| 配置 Sui 客户端 | sui client 选择网络并创建地址 |
| 安装 EVE Vault | Chrome 扩展 + zkLogin 创建链上身份 |
| 获取测试资产 | SUI Faucet + EVE Vault GAS Faucet |
| 验证环境 | CLI 地址、钱包地址、网络、余额都可见 |
📚 延伸阅读
Chapter 3:Move 智能合约基础
目标: 掌握 Move 语言的核心概念,理解 Sui 对象模型,能看懂并修改 EVE Frontier 的合约代码。
状态:基础章节。正文以 Move 语言、对象模型和最小示例为主。
3.1 Move 语言概览
Move 是 Sui 使用的智能合约语言,专门为“链上资产不能乱复制、乱丢弃、乱转移”这个问题设计。它不是先写一套通用编程语言,再靠库去约束资产;而是从语言层面就把“资源”当成最重要的对象。
你可以先抓住三个直觉:
- 资产不是一串余额数字
在 Sui 上,很多资产真的是一个独立对象,有自己的
id、字段、所有权和生命周期 - 类型决定你能不能复制、存储、丢弃 Move 会用能力系统限制一个值能做什么,避免你误把“珍贵资产”当成普通变量
- 合约更像模块 + 对象系统 你写的不是“一份全局大状态”,而是一组模块函数去创建、读取、修改对象
所以学 Move,不是只学语法。你真正要建立的是一套新的思维方式:
- 先分清什么是普通数据,什么是资源
- 再分清对象是谁拥有、谁能改、谁能转
- 最后才是把这些规则写进函数入口和业务流程
这也正适合 EVE Frontier。因为在 EVE 里,很多东西天然就不是“数据库里的一行记录”,而更像独立存在的资产或设施:
- 一张通行证 NFT
- 一个智能星门
- 一个仓储单元
- 一个权限凭证
- 一条击杀记录
这些东西放到 Move 里,表达会非常自然。
3.2 模块 (Module) 结构
一个 Move 合约由一个或多个模块组成:
// 文件:sources/my_contract.move
// 模块声明:包名::模块名
module my_package::my_module {
// 导入依赖
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
use sui::transfer;
// 结构体定义(资产/数据)
public struct MyObject has key, store {
id: UID,
value: u64,
}
// 初始化函数(合约部署时自动执行一次)
fun init(ctx: &mut TxContext) {
let obj = MyObject {
id: object::new(ctx),
value: 0,
};
transfer::share_object(obj);
}
// 公开函数(可被外部调用)
public fun set_value(obj: &mut MyObject, new_value: u64) {
obj.value = new_value;
}
}
上面这段代码虽然很短,但已经包含了 Move 最常见的四类元素:
- 模块声明
module my_package::my_module表示“这个文件里定义了一个模块” - 依赖导入
use用来引入别的模块暴露出来的类型或函数 - 结构体定义
MyObject描述链上对象长什么样 - 函数入口
init、set_value这些函数定义对象如何被创建和修改
模块和包到底是什么关系?
很多新手会把“包”和“模块”混成一件事,实际上它们不是一个层级:
- 包(Package)
是一整个 Move 工程目录,通常包含
Move.toml、sources/、tests/ - 模块(Module) 是包内部的代码单元,一个包里可以有多个模块
举个更接近真实项目的结构:
my-extension/
├── Move.toml
├── sources/
│ ├── gate_logic.move
│ ├── gate_auth.move
│ └── pricing.move
└── tests/
└── gate_tests.move
这里:
my-extension是一个包gate_logic、gate_auth、pricing是三个模块
你可以把“包”理解为部署单位,把“模块”理解为代码组织单位。
init 为什么重要?
init 会在包首次发布时执行一次,常见用途包括:
- 创建共享对象
- 给部署者发
AdminCap - 初始化全局配置
- 建立注册表对象
它通常是“系统第一次上线时的开机动作”。如果你在 init 里把关键对象没建好,后面很多入口函数都没法正常使用。
字段为什么几乎总是从 id: UID 开始?
因为在 Sui 上,一个真正的链上对象必须带 UID,这代表它有全局唯一身份。没有 UID 的 struct 往往只是:
- 普通嵌套数据
- 配置项
- 事件载荷
- 一次性凭证
这也是你以后读 EVE 合约时判断“这是不是独立对象”的第一眼线索。
3.3 Move 的 Abilities(能力系统)
这是 Move 中最重要的概念之一。每个结构体类型可以拥有以下能力(abilities):
| 能力 | 关键字 | 含义 |
|---|---|---|
| Key | has key | 可以作为 Sui 对象,存储在全局状态中 |
| Store | has store | 可以嵌套存储在其他对象中 |
| Copy | has copy | 可以被隐式复制(谨慎使用!) |
| Drop | has drop | 函数结束时可以自动丢弃(不使用也没关系) |
不要把 abilities 当成“语法装饰”。它们本质上是在回答一个非常严肃的问题:
这个值,允许开发者怎样处理?
四个能力分别意味着什么?
1. key
has key 说明这个类型可以作为顶层链上对象存在。
常见特征:
- 通常包含
id: UID - 可以被地址拥有、共享、或被对象拥有
- 可以作为交易读写的核心对象
如果没有 key,这个类型就不能独立挂在链上全局状态里。
2. store
has store 说明这个类型可以被安全地放进别的对象字段里。
例如:
- 把某个配置 struct 放进
StorageUnit - 把某个白名单规则放进
SmartAssembly - 把某个元数据结构嵌入 NFT
很多时候,一个类型不是独立对象,但它必须能作为别的对象的组成部分存在,这时就需要 store。
3. copy
has copy 说明这个值可以被复制。
这通常只适合:
- 小型纯数据
- 不代表稀缺资源的值
- 类似 ID、布尔标记、枚举、简单配置
如果一个东西代表“权限”、“资产”、“唯一凭证”,通常就不应该给 copy。
4. drop
has drop 说明这个值如果不用了,可以直接被丢弃。
这个能力看似不起眼,其实很关键。因为 Move 默认是很严格的:一个值如果没有被正确消费,编译器会追着你问“你到底打算怎么处理它?”
所以:
- 有
drop,可以不用也没关系 - 没
drop,你必须显式消费或转移它
为什么 abilities 会直接影响安全?
因为很多安全边界其实不是靠 if 判断守住的,而是靠“类型根本不允许你这么做”。
例如:
- 一个 NFT 没有
copy,你就没法复制出第二份 - 一个热土豆对象没有
drop,你就不能偷偷忽略它 - 一个权限对象没有公开构造路径,外部就伪造不出来
这就是 Move 很强的一点:它把很多业务约束前移到类型系统里。
在 EVE Frontier 中的应用
// JumpPermit:有 key + store,是真实的链上资产,不可复制
public struct JumpPermit has key, store {
id: UID,
character_id: ID,
route_hash: vector<u8>,
expires_at_timestamp_ms: u64,
}
// VendingAuth:只有 drop,是一次性的"凭证"(Witness Pattern)
public struct VendingAuth has drop {}
这两个例子可以一起看:
JumpPermit是真正要存在链上的对象,所以有keyVendingAuth只是某个调用流程里的见证值,不需要链上持久化,所以只给drop
读 EVE 合约时,你经常能通过 abilities 直接猜出作者想表达什么:
has key, store:大概率是真对象或准对象- 只有
drop:大概率是 witness、receipt、一次性中间态 copy, drop, store:大概率是普通值类型或配置数据
3.4 Sui 对象模型详解
在 Sui 上,所有带 key ability 的结构体都是对象,分三种所有权类型:
所有权类型
1. 地址拥有(Address-owned)
└── 只有持有该地址的人能访问
└── 例如:玩家角色的 OwnerCap
2. 共享对象(Shared Object)
└── 任何人都可以在链上读写(受合约逻辑控制)
└── 例如:智能存储单元、星门本体
3. 对象拥有(Object-owned)
└── 被另一个对象持有,外部无法直接访问
└── 例如:存储在组件内部的配置
这三种所有权不是抽象分类,而是你设计业务模型时最核心的决策之一。
1. 地址拥有:最像“我的资产”
地址拥有对象通常适合:
- 玩家个人 NFT
OwnerCap- 角色私有凭证
- 可转移的门票、许可证、勋章
特点是:
- 归某个地址控制
- 交易里通常要由该地址签名
- 很适合表达“谁拥有、谁支配”
2. 共享对象:最像“公共设施”
共享对象适合:
- 市场
- 星门
- 仓储单元
- 联盟金库
- 全服登记表
它的重点不是“谁拥有这个对象”,而是“谁在什么规则下可以对它执行什么操作”。
这正是 EVE Frontier 很多设施合约的核心形态。因为一个设施虽然也有经营者,但它首先是一个会被很多玩家共同交互的公共对象。
3. 对象拥有:最像“设施内部组件”
对象拥有常用于隐藏复杂内部状态,例如:
- 某个设施内部的配置对象
- 某个组件内部的库存表
- 某个注册表内部的辅助索引
它的好处是把状态封装起来,不让外部随便直接拿出来乱用。
为什么对象模型比“全局 mapping”更容易表达游戏世界?
因为游戏里的很多实体本来就是独立存在、可被引用、可被转让、可被组合的:
- 一座炮塔
- 一张许可证
- 一个角色权限
- 一份联盟条约
如果都塞进一张大表里,逻辑会越来越像“数据库管理脚本”。而对象模型更接近真实世界中的“实体 + 关系 + 所有权”。
对象不是只有“有没有”两个状态
你在设计时还要考虑对象生命周期:
- 创建
谁来创建?在
init里还是在业务入口里? - 持有 创建后由谁拥有?地址、共享还是对象内部?
- 修改
谁能拿到
&mut?什么前提下允许修改? - 转移 能不能转让?转让后权限是否跟着走?
- 销毁 什么时候可以消失?销毁前要不要结算余额或回收资源?
对象 ID 的确定性推导
EVE Frontier 中每个游戏内实体在链上的 ObjectID 是通过 TenantItemId 确定性推导的:
public struct TenantItemId has copy, drop, store {
item_id: u64, // 游戏内的唯一 ID
tenant: String, // 区分不同游戏服务器实例
}
这意味着在游戏服务器知道 item_id 后,可以提前计算出该物品在链上的 ObjectID,无需等待链上响应。
这件事在 EVE 场景里非常重要,因为链下服务器和链上对象需要长期对齐:
- 游戏服务器知道某个设施、角色、物品的业务 ID
- 合约需要用稳定规则把它映射成链上对象键
- 前端和索引服务再按同样规则去查询
如果这个映射不稳定,整个系统都会乱:
- 链下认为是同一个设施
- 链上却找到了另一个对象
- 前端显示的数据和真实可交互对象对不上
所以你以后看到 TenantItemId、derived_object、注册表时,要先意识到:作者在解决的不是“代码怎么写”,而是“跨系统身份如何保持一致”。
3.5 关键安全模式
EVE Frontier 和其他 Sui 项目广泛使用几个 Move 特有的安全设计模式:
模式一:Capability Pattern(能力模式)
权限通过持有对象来表示,不是账户角色。
// 定义能力对象
public struct OwnerCap<phantom T> has key, store {
id: UID,
}
// 需要 OwnerCap 才能调用的函数
public fun withdraw_by_owner<T: key>(
storage_unit: &mut StorageUnit,
owner_cap: &OwnerCap<T>, // 必须持有此凭证
ctx: &mut TxContext,
): Item {
// ...
}
优势:OwnerCap 可以转让,可以委托,比账号级别的权限更灵活。
你可以把 Capability 当成“权限实体化”:
- 传统思路常常是“判断
sender == admin” - Move/Sui 更常见的思路是“你有没有拿着某个权限对象”
这会带来几个直接好处:
- 权限可以转让
- 权限可以拆分
- 权限可以做成 NFT / Badge / Cap
- 权限关系更容易被链上审计
模式二:Typed Witness Pattern(类型见证模式)
这是 EVE Frontier 扩展系统的核心! 用于验证调用者是特定包的模块。
// Builder 在自己的包中定义一个 Witness 类型
module my_extension::custom_gate {
// 只有这个模块能创建 Auth 实例(因为它没有公开构造函数)
public struct Auth has drop {}
// 调用星门 API 时,把 Auth {} 作为凭证传入
public fun request_jump(
gate: &mut Gate,
character: &Character,
ctx: &mut TxContext,
) {
// 自定义逻辑(例如检查费用)
// ...
// 用 Auth {} 证明调用来自这个已授权的模块
gate::issue_jump_permit(
gate, destination, character,
Auth {}, // Witness:证明我是 my_extension::custom_gate
expires_at,
ctx,
)
}
}
Star Gate 组件知道你的 Auth 类型已被注册在白名单中,因此允许调用。
这个模式第一次看会觉得怪,因为 Auth {} 里什么数据都没有。但它真正要表达的是:
“我不是靠字段内容证明身份,我是靠类型本身证明我来自哪个模块。”
为什么这很强?
- 外部模块不能随便伪造你的 witness 类型
- 组件可以只信任白名单里的 witness 类型
- 于是“谁能调用某个底层能力”就可以被限制在特定扩展包里
这正是 EVE Frontier 可扩展组件的核心。很多组件不是简单地暴露一个 public entry 给任何人调,而是要求你带着特定 witness 来进入。
模式三:Hot Potato(热土豆模式)
一种没有 copy、store、drop 能力的对象,必须在同一个交易中被消耗:
// 没有任何 ability = 热土豆,必须在本次 tx 中处理掉
public struct NetworkCheckReceipt {}
public fun check_network(node: &NetworkNode): NetworkCheckReceipt {
// 执行检查...
NetworkCheckReceipt {} // 返回热土豆
}
public fun complete_action(
assembly: &mut Assembly,
receipt: NetworkCheckReceipt, // 必须传入,保证检查被执行过
) {
let NetworkCheckReceipt {} = receipt; // 消耗热土豆
// 正式执行操作
}
用途:强制某些操作必须原子组合完成(如“先检查网络节点 → 再执行组件操作“)。
这种模式特别适合做“前置检查不可跳过”的流程:
- 先验证资格,再铸造凭证
- 先检查网络状态,再执行设施动作
- 先读取并锁定某个上下文,再做结算
它的重点不是存数据,而是用类型系统强迫调用者按顺序办事。
3.6 函数可见性与访问控制
module example::access_demo {
// 私有函数:只能在本模块内调用
fun internal_logic() { }
// 包内可见:同一个包的其他模块可调用(Layer 1 Primitives 使用这个)
public(package) fun package_only() { }
// Entry:可以直接作为交易(Transaction)的顶层调用
public fun user_action(ctx: &mut TxContext) { }
// 公开:任何模块都可以调用
public fun read_data(): u64 { 42 }
}
3.7 编写你的第一个 Move 扩展模块
让我们把上面的概念结合起来,写一个最简单的 Storage Unit 扩展:
module my_extension::simple_vault;
use world::storage_unit::{Self, StorageUnit};
use world::character::Character;
use world::inventory::Item;
use sui::tx_context::TxContext;
// 我们的 Witness 类型
public struct VaultAuth has drop {}
/// 任何人都可以存入物品(开放存款)
public fun deposit_item(
storage_unit: &mut StorageUnit,
character: &Character,
item: Item,
ctx: &mut TxContext,
) {
// 使用 VaultAuth{} 作为见证,证明这个调用是合法绑定的扩展
storage_unit::deposit_item(
storage_unit,
character,
item,
VaultAuth {},
ctx,
)
}
/// 只有拥有特定 Badge(NFT)的角色才能取出物品
public fun withdraw_item_with_badge(
storage_unit: &mut StorageUnit,
character: &Character,
_badge: &MemberBadge, // 必须持有成员勋章才能调用
type_id: u64,
ctx: &mut TxContext,
): Item {
storage_unit::withdraw_item(
storage_unit,
character,
VaultAuth {},
type_id,
ctx,
)
}
3.8 编译与测试
# 在你的 Move 包目录下
cd my-extension
# 编译(会检查类型和逻辑)
sui move build
# 运行单元测试
sui move test
# 发布到测试网
sui client publish
发布成功后,你会得到一个 Package ID(如 0xabcdef...),这是你的合约在链上的地址。
🔖 本章小结
| 概念 | 关键要点 |
|---|---|
| Move 模块 | module package::name { } 是代码组织单元 |
| Abilities | key(对象) store(嵌套) copy(可复制) drop(可丢弃) |
| 三种所有权 | 地址拥有 / 共享对象 / 对象拥有 |
| Capability 模式 | 权限 = 持有对象,可转让可委托 |
| Witness 模式 | 唯一实例化的类型作为调用凭证,EVE Frontier 扩展核心 |
| Hot Potato | 无能力结构体,强制原子操作 |
📚 延伸阅读
Chapter 4:智能组件开发与链上部署
目标: 理解每种智能组件的工作原理和 API,掌握从角色创建到合约部署的完整工作流。
状态:基础章节。正文以部署工作流和链上组件操作为主。
4.1 完整的部署工作流
在你的代码能在真实游戏中生效之前,需要完成以下完整链路:
1. 创建链上角色 (Smart Character)
↓
2. 部署网络节点 (Network Node),存入燃料并上线
↓
3. 锚定智能组件 (Anchor Assembly)
↓
4. 将组件上线 (Assembly Online)
↓
5. 编写并发布自定义 Move 扩展包
↓
6. 将扩展注册到组件 (authorize_extension)
↓
7. 玩家通过扩展 API 与组件交互
在本地开发中,步骤 1-5 可以用 builder-scaffold 的初始化脚本一键完成。
很多人第一次接触这一章时,会误以为“发布合约”才是主流程。实际上不是。对 EVE Builder 来说,真正的主流程是:
- 先有链上主体
- 再有可运行的设施
- 然后才有自定义扩展逻辑
- 最后把扩展挂到设施上供玩家消费
也就是说,你写出来的 Move 包并不是凭空就能独立工作。它必须挂接到一个真实存在、已经上线、已经归属到角色体系中的智能组件上。
这一章最容易混淆的三个“ID”
在部署过程中,至少会同时出现三类 ID:
- Package ID 代表你发布到链上的 Move 包
- Object ID 代表具体对象,例如角色、星门、炮塔、存储箱
- 业务 ID 代表游戏服务器里的角色、物品、设施编号
这三者不要混:
Package ID决定“你的代码在哪里”Object ID决定“你的设施和资产在哪里”- 业务 ID 决定“游戏世界里的东西是谁”
后面你会频繁地在“代码地址”和“设施对象地址”之间来回切换。如果这两个概念不分开,调试时会非常痛苦。
4.2 Smart Character(智能角色)
Smart Character 是你在链上的 主体身份,所有组件都归属于你的角色。
角色的链上结构
public struct Character has key {
id: UID, // 唯一对象 ID
// 每个拥有的资产对应一个 OwnerCap
// owner_caps 以 dynamic field 形式存储
}
OwnerCap:资产所有权凭证
每当你拥有一个组件(网络节点/炮塔/星门/存储箱),角色就会持有对应的 OwnerCap<T> 对象。对该组件的所有写操作都需要先从角色中“借用“这个 OwnerCap:
// TypeScript 脚本示例:借用 OwnerCap
const [ownerCap] = tx.moveCall({
target: `${packageId}::character::borrow_owner_cap`,
typeArguments: [`${packageId}::assembly::Assembly`],
arguments: [tx.object(characterId), tx.object(ownerCapId)],
});
// ... 使用 ownerCap 执行操作 ...
// 用完必须归还
tx.moveCall({
target: `${packageId}::character::return_owner_cap`,
typeArguments: [`${packageId}::assembly::Assembly`],
arguments: [tx.object(characterId), ownerCap],
});
💡 借用-归还 (Borrow & Return) 模式配合 Hot Potato 确保 OwnerCap 不会离开角色对象。
为什么不是把 OwnerCap 直接取出来永久持有?
因为 OwnerCap 不是普通钥匙,而是高权限凭证。把它设计成“借用后必须归还”,有几个直接好处:
- 权限不会轻易脱离角色体系
- 一笔交易结束后,不会留下悬空的高权限对象
- 组件所有权仍然稳定地归属于角色,而不是散落到脚本地址或临时对象里
从设计上看,这相当于在链上实现了“临时提权”:
- 你先证明自己是角色的合法操作者
- 系统暂时借给你权限对象
- 你完成高权限操作后,必须把权限交还
这比“管理员地址硬编码”更灵活,也更适合游戏场景中的委托、转移、继承、换壳运营等需求。
Character 在业务上到底扮演什么角色?
不要把 Character 只理解成钱包地址的别名。它更像一个链上“经营主体”:
- 组件挂在角色名下,而不是直接挂在钱包地址名下
- 角色内部可以统一管理多个
OwnerCap - 角色可以作为链上权限和游戏内身份的桥梁
所以在很多 Builder 场景里,真正稳定的主体不是“哪个钱包点了按钮”,而是“哪个角色在经营这些设施”。
4.3 Network Node(网络节点)
什么是网络节点?
- 锚定在拉格朗日点(Lagrange Point)的能源站
- 为附近所有智能组件提供 Energy(能量)
- 每个组件上线时需要从网络节点“预留“一定量的能量
生命周期
Anchored(已锚定)
↓ depositFuel(存入燃料)
Fueled(已充能)
↓ online(上线)
Online(运行中) ←→ offline(下线)
这里最重要的不是记住状态名字,而是理解:
设施能不能工作,不只是“合约有没有发布”,还取决于它在游戏世界里有没有被真正供能。
这正是 EVE Frontier 和普通 dApp 的一个关键差异。普通 dApp 里,合约发布成功后,理论上任何人都能调用;但在 EVE 里,很多设施的可用性还会受到“世界状态”的约束:
- 有没有网络节点
- 网络节点有没有燃料
- 设施有没有被正确锚定
- 设施是不是在线
从 Builder 视角看,Network Node 实际解决了什么?
它解决的是“设施不应该无条件永久在线”这个问题。
如果没有这层设计:
- 星门可以永远开放
- 炮塔可以永远工作
- 仓储设施可以一直响应
那游戏里的运营、维护、补给、占领都会失去很多意义。加入网络节点之后,设施就会变成一种真正需要维护的资产,而不是“一次部署永久印钞机”。
本地测试用的初始化脚本(来自 builder-scaffold)
# 在 builder-scaffold/ts-scripts 目录执行
pnpm setup:character # 创建角色
pnpm setup:network-node # 创建并启动网络节点
pnpm setup:assembly # 创建并连接智能组件
4.4 Smart Storage Unit(智能存储单元)深度解析
两种仓库
| 仓库类型 | 持有者 | 容量 | 访问方式 |
|---|---|---|---|
| 主仓库 (Primary) | 组件 Owner | 大 | OwnerCap<StorageUnit> |
| 临时仓库 (Ephemeral) | 交互角色 | 小 | 角色自己的 OwnerCap |
临时仓库用于非 Owner 的玩家与你的 SSU 交互(如:购买物品时先把物品转到临时仓库,玩家再取走)。
物品如何到达链上?
游戏内物品 → game_item_to_chain_inventory() → 链上 Item 对象
链上 Item 对象 → chain_item_to_game_inventory() → 游戏内物品(需要临近证明)
这里真正难的不是“调用哪个函数”,而是理解两边库存并不是简单镜像。
很多新手会默认认为:
- 游戏背包里有一把枪
- 上链后就只是“复制一条记录”
实际上正确理解更接近:
- 某件游戏内物品通过可信流程被映射成链上对象
- 这件对象之后进入链上库存体系
- 当它被取回游戏世界时,又需要经过另一条可信回流路径
所以 Storage Unit 的本质不是“链上柜子”,而是链上与游戏世界之间的资产交换节点。
为什么要区分主仓库和临时仓库?
因为很多交互都不是“Owner 自己打开仓库拿东西”,而是“第三方玩家和你的设施发生一次受控交互”。
比如自动售货机:
- 玩家支付代币
- 设施把对应物品先转入一个临时中间区
- 玩家再从该路径领取
这样做的好处是:
- 不必把主仓库完全暴露给外部
- 交易中间态更容易审计
- 失败时更容易做回滚和结算
扩展 API 一览
// 1. 注册扩展(Owner 调用)
public fun authorize_extension<Auth: drop>(
storage_unit: &mut StorageUnit,
owner_cap: &OwnerCap<StorageUnit>,
)
// 2. 扩展存入物品
public fun deposit_item<Auth: drop>(
storage_unit: &mut StorageUnit,
character: &Character,
item: Item,
_auth: Auth, // Witness
ctx: &mut TxContext,
)
// 3. 扩展取出物品
public fun withdraw_item<Auth: drop>(
storage_unit: &mut StorageUnit,
character: &Character,
_auth: Auth, // Witness
type_id: u64,
ctx: &mut TxContext,
): Item
4.5 Smart Gate(智能星门)深度解析
默认 vs 自定义行为
无扩展:任何人都可以跳跃
↓ authorize_extension<MyAuth>()
有扩展:玩家必须持有 JumpPermit 才能跳跃
JumpPermit 机制
// 跳跃许可证:有时效性的链上对象
public struct JumpPermit has key, store {
id: UID,
character_id: ID,
route_hash: vector<u8>, // A↔B 双向有效
expires_at_timestamp_ms: u64,
}
JumpPermit 的关键不在于“它是一张票”,而在于它把一次复杂判断拆成了两段:
- 先决定“你有没有资格拿到票”
- 再决定“你拿着票能不能执行跃迁”
这种拆法非常适合游戏规则扩展,因为“资格判断”可以很复杂:
- 你是不是白名单成员
- 你有没有付费
- 你是不是完成了前置任务
- 你是不是在有效时间窗内
但一旦票已经发出,真正执行跃迁时的逻辑就可以更标准、更统一。
这也是很多扩展设计的通用思路:
把复杂业务判断前移成“凭证发放”,把底层设施动作收敛成“凭证消费”。
完整跳跃流程:
- 玩家调用你的扩展函数(例如
pay_and_request_permit()) - 扩展验证条件(检查代币、检查白名单等)
- 扩展调用
gate::issue_jump_permit()发放 Permit - Permit 转给玩家
- 玩家调用
gate::jump_with_permit()跃迁,Permit 被消耗
扩展 API
// 注册扩展
public fun authorize_extension<Auth: drop>(
gate: &mut Gate,
owner_cap: &OwnerCap<Gate>,
)
// 发放跳跃许可(只有已注册的 Auth 类型才能调用)
public fun issue_jump_permit<Auth: drop>(
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
_auth: Auth,
expires_at_timestamp_ms: u64,
ctx: &mut TxContext,
)
// 使用许可跳跃(消耗 JumpPermit)
public fun jump_with_permit(
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
jump_permit: JumpPermit,
admin_acl: &AdminACL,
clock: &Clock,
ctx: &mut TxContext,
)
authorize_extension 到底在授权什么?
它授权的不是“某个地址”,也不是“某次交易”,而是某种类型身份。
也就是说,组件真正相信的是:
- 只有带着某个指定 witness 类型的调用
- 才能进入底层能力入口
这让组件扩展具备两个重要性质:
- 组件内核不用知道你的业务逻辑长什么样
- 但它可以非常明确地知道“哪些扩展类型有资格接入”
所以 Builder 的工作,很多时候不是“改官方逻辑”,而是“把自己的逻辑封装成官方允许接入的类型化扩展”。
4.6 Smart Turret(智能炮塔)深度解析
炮塔的扩展模式与星门类似,通过 Typed Witness 授权。
默认行为
炮塔使用游戏服务器提供的标准攻击逻辑。
自定义行为
Builder 可以注册扩展,改变炮塔的目标判断逻辑。例如:
- 允许持有特定 NFT 的角色安全通过
- 只攻击不在联盟名单里的角色
- 根据时间段开关攻击(白天开放,夜晚封闭)
4.7 将扩展发布并注册到组件
第一步:发布你的扩展包
# 在你的 Move 包目录下
sui client publish
# 输出示例:
# Package ID: 0x1234abcd...
# Transaction Digest: HMNaf...
记下 Package ID,这是你的合约地址。
发布完成后,你至少要立即记录三类信息:
- 你的
Package ID - 你要绑定的组件对象 ID
- 交易 digest
因为后面排查问题时,几乎所有链路都要从这三样东西往回追:
- 合约有没有成功发布
- 设施是不是你以为的那个对象
- 这次授权或注册到底有没有成功上链
第二步:授权扩展到组件
通过 TypeScript 脚本(或 dApp 调用)将你的扩展注册:
import { Transaction } from "@mysten/sui/transactions";
const tx = new Transaction();
// 从角色借用 OwnerCap
const [ownerCap] = tx.moveCall({
target: `${WORLD_PACKAGE}::character::borrow_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::gate::Gate`],
arguments: [tx.object(CHARACTER_ID), tx.object(OWNER_CAP_ID)],
});
// 授权扩展(告诉星门:允许 my_extension::custom_gate::Auth 类型调用)
tx.moveCall({
target: `${WORLD_PACKAGE}::gate::authorize_extension`,
typeArguments: [`${MY_PACKAGE}::custom_gate::Auth`], // 你的 Witness 类型
arguments: [tx.object(GATE_ID), ownerCap],
});
// 归还 OwnerCap
tx.moveCall({
target: `${WORLD_PACKAGE}::character::return_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::gate::Gate`],
arguments: [tx.object(CHARACTER_ID), ownerCap],
});
await client.signAndExecuteTransaction({ signer: keypair, transaction: tx });
这里要特别注意一件事:
“发布成功”不等于“扩展已经生效”。
你至少还要确认三层绑定关系都成立:
- 你的包已经在链上
- 你的组件对象是正确的那个组件
- 组件已经把你的 witness 类型加入允许列表
第三步:验证注册成功
# 查询星门对象,确认扩展类型已被添加到 allowed_extensions
sui client object <GATE_ID>
4.8 使用 TypeScript 读取链上状态
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
// 读取星门对象
const gateObject = await client.getObject({
id: GATE_ID,
options: { showContent: true },
});
console.log(gateObject.data?.content);
// GraphQL 查询所有指定类型的组件
const query = `
query {
objects(filter: { type: "${WORLD_PACKAGE}::gate::Gate" }) {
nodes {
address
asMoveObject { contents { json } }
}
}
}
`;
为什么部署章节里还要讲读取状态?
因为实际开发中,部署和读取从来不是两件分开的事。你每做完一步,都需要立刻验证:
- 对象有没有创建出来
- 状态有没有切到在线
- 扩展有没有注册成功
- 组件字段有没有按预期变化
所以真实节奏通常是:
执行一步
-> 立刻读链上状态
-> 确认对象和字段变化
-> 再继续下一步
如果你只会“发交易”,不会“立刻验证状态”,那你很难判断到底是:
- 交易没发出去
- 发出去了但对象不对
- 对象对了但状态没变
- 状态变了但前端查错了地方
从开发者视角看,本章的最小闭环是什么?
最小闭环不是“我发布了一个包”,而是:
- 有角色
- 有设施
- 有权限凭证
- 有自定义扩展包
- 有成功注册记录
- 有一次真实可验证的玩家交互
只有这 6 件事都打通,你才算真正完成了一个 Builder 设施扩展。
🔖 本章小结
| 部署步骤 | 关键操作 |
|---|---|
| 1. 角色 | 链上身份,持有所有 OwnerCap |
| 2. 网络节点 | 存燃料 → 上线 → 输出能量 |
| 3. 组件 | 锚定 → 连接节点 → 上线 |
| 4. 扩展包 | sui client publish |
| 5. 注册扩展 | authorize_extension<MyAuth>(gate, owner_cap) |
| 6. 玩家交互 | 调用你的 Entry functions,通过 Witness 调用世界合约 |
📚 延伸阅读
Chapter 5:dApp 前端开发与钱包集成
目标: 使用
@evefrontier/dapp-kit构建一个能连接 EVE Vault 钱包、读取链上数据并执行交易的前端 dApp。
状态:基础章节。正文以钱包接入、前端状态读取和交易发起为主。
5.1 dApp 在 EVE Frontier 中的角色
当你完成了 Move 合约开发后,玩家需要一个界面来与你的设施交互。dApp(去中心化应用)就是这个界面,它可以:
- 显示你的智能组件的实时状态(库存、在线状态等)
- 让玩家连接 EVE Vault 钱包
- 通过 UI 触发链上交易(购买物品、申请跳跃许可等)
- 运行在标准 Web 浏览器中,无需下载游戏客户端
两种使用场景
| 场景 | 描述 |
|---|---|
| 游戏内浮窗 | 玩家在游戏内靠近组件时,游戏客户端显示你的 dApp(iframe) |
| 外部浏览器 | 独立网页,通过 EVE Vault 扩展连接钱包 |
很多人会把 dApp 误解成“给合约包一层前端皮肤”。在 EVE Frontier 里,它更准确的角色是:
把链上设施变成玩家真的愿意使用的服务界面。
因为同一个设施,如果只有合约,没有 dApp,玩家通常会缺少这些关键信息:
- 当前状态是什么
- 自己是否有权限操作
- 操作要花多少钱
- 点完按钮之后到底发生了什么
所以 dApp 不只是“展示层”,它还承担三类非常实际的责任:
- 解释状态 把对象字段翻译成玩家看得懂的业务状态
- 组织交易 帮用户把复杂参数、对象 ID、金额拼成一次合法交易
- 处理反馈 告诉用户现在是等待签名、等待上链、成功、失败还是需要重试
一个 dApp 的最小工作回路
无论你做的是商店、星门还是炮塔控制台,前端基本都绕不开这个循环:
连接钱包
-> 读取组件和用户状态
-> 判断当前允许的动作
-> 构建交易
-> 请求签名 / 发起赞助交易
-> 等待结果
-> 刷新对象和界面
只要这个循环里有一环没做好,用户体验就会断掉。
5.2 安装 dapp-kit
# 创建 React 项目(以 Vite 为例)
npx create-vite my-dapp --template react-ts
cd my-dapp
# 安装 EVE Frontier dApp SDK 和依赖
npm install @evefrontier/dapp-kit @tanstack/react-query react
SDK 核心功能一览
| 功能 | 提供内容 |
|---|---|
| 🔌 钱包连接 | 与 EVE Vault 和标准 Sui 钱包集成 |
| 📦 智能对象数据 | 通过 GraphQL 获取并转换组件数据 |
| ⚡ 赞助交易 | 支持免 Gas 交易(由 EVE Frontier 后端代付) |
| 🔄 自动轮询 | 实时刷新链上数据 |
| 🎨 TypeScript 全类型 | 所有组件类型完整定义 |
5.3 项目基础配置
配置 Provider
所有 dApp 功能都必须包裹在 EveFrontierProvider 中:
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { EveFrontierProvider } from '@evefrontier/dapp-kit'
import App from './App'
// React Query 客户端(管理缓存)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000, // 5 秒后重新获取
}
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* EVE Frontier SDK Provider */}
<EveFrontierProvider queryClient={queryClient}>
<App />
</EveFrontierProvider>
</React.StrictMode>
)
Provider 的作用不只是“让 Hook 能用”。它实际上帮你统一管理了三类上下文:
- 钱包连接上下文
- 链上查询与缓存上下文
- dApp-kit 自己的环境信息
所以它本质上是整个 dApp 的“运行底座”。如果这层配错,后面很多看起来像业务问题的报错,其实都是上下文没初始化好。
通过 URL 参数绑定组件
dApp 通过 URL 参数知道要显示哪个组件:
# 游戏内访问:
https://your-dapp.com/?tenant=utopia&itemId=0x1234abcd...
# tenant:游戏服务器实例名称(prod/testnet/dev)
# itemId:组件在链上的 ObjectID
SDK 会自动从 URL 读取这些参数,你无需手动处理。
这里的核心思想是:
同一个前端页面,不是固定服务某一个组件,而是按 URL 动态绑定当前组件上下文。
这有两个直接好处:
- 你可以复用同一套前端去服务很多个设施
- 游戏内浮层只需要把
tenant和itemId传进来,就能让页面知道“我现在在给谁服务”
tenant 和 itemId 为什么缺一不可?
itemId解决“是哪一个对象”tenant解决“它属于哪一个世界实例”
如果只传 itemId,在多租户或多环境场景下,很容易把数据读错世界;如果只传 tenant,你又不知道当前到底是哪个设施对象。
5.4 核心 Hooks 详解
Hook 1:useConnection(钱包连接状态)
import { useConnection } from '@evefrontier/dapp-kit'
function WalletButton() {
const {
isConnected, // boolean:是否已连接钱包
currentAddress, // string | null:当前钱包地址
handleConnect, // () => void:触发连接流程
handleDisconnect, // () => void:断开连接
} = useConnection()
if (!isConnected) {
return (
<button onClick={handleConnect} className="connect-btn">
连接 EVE Vault 钱包
</button>
)
}
return (
<div>
<span>已连接:{currentAddress?.slice(0, 8)}...</span>
<button onClick={handleDisconnect}>断开</button>
</div>
)
}
useConnection 解决的不是“能不能弹出钱包”这么简单,而是整个页面的第一层状态分叉:
- 没连钱包时,页面只能展示公共信息
- 已连钱包但没角色时,页面可能要提示先初始化身份
- 已连钱包且有角色时,页面才能进入真正的交互态
Hook 2:useSmartObject(当前组件数据)
import { useSmartObject } from '@evefrontier/dapp-kit'
function AssemblyStatus() {
const {
assembly, // 当前组件的完整数据(库存、状态、名称等)
loading, // 是否正在加载
error, // 错误信息
refetch, // 手动刷新
} = useSmartObject()
if (loading) return <div className="spinner">读取链上数据中...</div>
if (error) return <div className="error">错误:{error.message}</div>
return (
<div className="assembly-card">
<h2>{assembly?.name}</h2>
<p>状态:{assembly?.status}</p>
<p>所有者:{assembly?.owner}</p>
</div>
)
}
这里最重要的不是 Hook 名字,而是你要养成一个习惯:
页面应该始终以“链上对象状态”为中心,而不是以本地按钮状态为中心。
也就是说,用户点完按钮之后,不要只是在前端本地把 status = success。更稳的做法是:
- 等交易返回
- 重新读取对象
- 以链上真实状态来刷新 UI
否则你很容易出现:
- 前端以为成功了
- 但链上对象没变
- 页面却还显示“操作已完成”
Hook 3:useNotification(用户通知)
import { useNotification } from '@evefrontier/dapp-kit'
function ActionButton() {
const { showNotification } = useNotification()
const handleAction = async () => {
try {
// ... 执行交易 ...
showNotification({ type: 'success', message: '交易成功!' })
} catch (e) {
showNotification({ type: 'error', message: '交易失败:' + e.message })
}
}
return <button onClick={handleAction}>执行操作</button>
}
通知系统真正的价值,不是“做个弹窗”,而是把链上异步流程拆成用户能理解的几个阶段:
- 正在连接钱包
- 等待签名
- 正在上链
- 已确认
- 失败,需要重试
如果你只给用户一个“成功 / 失败”,很多复杂交易都会显得像黑盒。
5.5 执行链上交易
标准交易(用户付 Gas)
使用 @mysten/dapp-kit-react 的 useDAppKit 来执行:
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
function BuyItemButton({ storageUnitId, typeId }: Props) {
const dAppKit = useDAppKit()
const handleBuy = async () => {
// 构建交易
const tx = new Transaction()
tx.moveCall({
// 调用你发布的扩展合约函数
target: `${MY_PACKAGE_ID}::vending_machine::buy_item`,
arguments: [
tx.object(storageUnitId),
tx.object(CHARACTER_ID),
tx.splitCoins(tx.gas, [tx.pure.u64(100)]), // 支付 100 SUI
tx.pure.u64(typeId),
],
})
// 签名并执行
try {
const result = await dAppKit.signAndExecuteTransaction({
transaction: tx,
})
console.log('交易成功!', result.digest)
} catch (e) {
console.error('交易失败', e)
}
}
return <button onClick={handleBuy}>购买物品</button>
}
一笔前端交易通常分成哪几步?
从前端视角看,一笔交易至少分成 5 个阶段:
- 准备参数 组件 ID、角色 ID、金额、类型参数是否齐全
- 构建交易
把对象、纯值、拆币动作和函数入口拼成
Transaction - 请求签名 让钱包或赞助服务确认这笔交易
- 提交执行 交易真正进入链上执行
- 回写界面 根据 digest 和最新对象状态刷新 UI
前端很多 bug 都不是出在“交易失败”,而是出在第 1 步和第 5 步:
- 参数拿错了对象
- 本地用的是旧缓存
- 交易成功了但页面没刷新
- digest 有了但对象查询还没更新
赞助交易(Sponsored Tx,免 Gas)
当操作需要服务器验证或由平台代付 Gas 时:
import { signAndExecuteSponsoredTransaction } from '@evefrontier/dapp-kit'
const result = await signAndExecuteSponsoredTransaction({
transaction: tx,
// SDK 自动处理赞助逻辑,与 EVE Frontier 后端通信
})
赞助交易的体验更好,但链路也更长。它通常意味着:
- 前端先构建交易
- 请求后端检查是否允许赞助
- 后端进行风控 / 补签 / 代付
- 用户完成必要签名
- 交易再被提交执行
所以一旦赞助交易失败,你排查时不能只盯前端,还要分辨问题出在哪一层:
- 是前端构建错了交易
- 是用户资格不满足
- 是后端拒绝赞助
- 是钱包签名阶段失败
- 是链上执行本身失败
5.6 读取链上数据(GraphQL)
import {
getAssemblyWithOwner,
getObjectWithJson,
executeGraphQLQuery,
} from '@evefrontier/dapp-kit'
// 获取组件及其拥有者信息
async function loadAssembly(assemblyId: string) {
const { moveObject, character } = await getAssemblyWithOwner(assemblyId)
console.log('组件数据:', moveObject)
console.log('拥有者角色:', character)
}
// 自定义 GraphQL 查询
async function queryGates() {
const query = `
query GetGates($type: String!) {
objects(filter: { type: $type }, first: 10) {
nodes {
address
asMoveObject { contents { json } }
}
}
}
`
const data = await executeGraphQLQuery(query, {
type: `${WORLD_PACKAGE}::gate::Gate`
})
return data
}
为什么前端不能只靠事件流?
因为前端页面通常需要的是“当前状态”,而不只是“历史发生过什么”。
事件更适合回答:
- 谁在什么时候做过什么
- 某个动作有没有发生
- 用于日志、通知、时间线
对象查询更适合回答:
- 这个设施现在是什么状态
- 当前库存还有多少
- 当前所有者是谁
- 当前是否在线
所以成熟的 dApp 往往是:
- 用对象查询拿当前态
- 用事件查询补历史和时间线
只靠事件去还原当前态,通常会越来越脆弱。
5.7 实用工具函数
import {
abbreviateAddress,
isOwner,
formatM3,
formatDuration,
getTxUrl,
getDatahubGameInfo,
} from '@evefrontier/dapp-kit'
// 缩短地址:0x1234...cdef
abbreviateAddress('0x1234567890abcdef')
// 检查当前连接的钱包是否是指定对象的拥有者
const isMine = isOwner(assembly, currentAddress)
// 格式化体积
formatM3(1500) // "1.5 m³"
// 格式化时间
formatDuration(3661000) // "1h 1m 1s"
// 获取交易浏览器链接
getTxUrl('HNFaf...') // 返回 Sui Explorer URL
// 获取游戏物品元数据(名称、图标等)
const info = await getDatahubGameInfo(83463)
console.log(info.name, info.iconUrl)
这类工具函数看起来像边角料,但它们直接决定你的前端会不会显得“像产品而不是脚本页面”。
比如:
- 地址不缩写,页面就会难读
- 金额和体积不格式化,玩家就很难快速判断
- 没有交易链接,出问题时用户和开发者都无法追踪
前端产品感,很多时候就是靠这些小函数堆出来的。
5.8 完整的 dApp 示例
// src/App.tsx
import { useConnection, useSmartObject, useNotification } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
export default function App() {
const { isConnected, handleConnect, currentAddress } = useConnection()
const { assembly, loading } = useSmartObject()
const { showNotification } = useNotification()
const dAppKit = useDAppKit()
const handleJump = async () => {
if (!isConnected) {
showNotification({ type: 'warning', message: '请先连接钱包' })
return
}
const tx = new Transaction()
tx.moveCall({
target: `${MY_PACKAGE}::toll_gate::pay_and_jump`,
arguments: [
tx.object(GATE_ID),
tx.object(DEST_GATE_ID),
tx.object(CHARACTER_ID),
tx.splitCoins(tx.gas, [tx.pure.u64(100)]),
],
})
try {
await dAppKit.signAndExecuteTransaction({ transaction: tx })
showNotification({ type: 'success', message: '跃迁成功!' })
} catch (e: any) {
showNotification({ type: 'error', message: e.message })
}
}
if (loading) return <div>Loading...</div>
return (
<div className="app">
<header>
<h1>🌀 星门控制台</h1>
{!isConnected
? <button onClick={handleConnect}>连接钱包</button>
: <span>✅ {currentAddress?.slice(0, 8)}...</span>
}
</header>
<main>
<div className="gate-info">
<h2>{assembly?.name ?? 'Unknown Gate'}</h2>
<p>状态:{assembly?.status}</p>
</div>
<button
className="jump-btn"
onClick={handleJump}
disabled={!isConnected}
>
💳 支付 100 SUI 并跃迁
</button>
</main>
</div>
)
}
这个示例虽然简单,但它已经完整展示了一个最小交互回路:
- 连接钱包
- 读取当前组件
- 构建交易
- 请求签名并执行
- 给出结果通知
真实项目里通常还要再补三层状态
示例能跑,但如果你要把它做成稳定产品,通常还要再补:
- 本地 UI 状态 例如按钮 loading、弹窗开关、表单输入
- 钱包状态 当前地址、是否已授权、是否切错网络
- 链上对象状态 设施状态、库存、价格、当前拥有者
这三层状态不要混成一层。它们更新速度不同、可信度不同、排查方法也不同。
5.9 将 dApp 嵌入游戏
在游戏中靠近你的组件时,客户端会在浮窗中加载你注册的 dApp URL。配置方法:
- 将 dApp 部署到公开 URL(如 Vercel、Netlify)
- 在组件配置中设置你的 dApp URL
- 游戏客户端会在玩家交互时自动打开并传入
?itemId=...&tenant=...参数
相关文档:Connecting In-Game | Customizing External dApps
游戏内浮层和外部浏览器最大的差异
两者虽然都叫 dApp,但运行约束并不完全一样:
- 游戏内浮层 更像受宿主环境控制的嵌入页,重点是快、稳、参数明确、交互路径短
- 外部浏览器 更像独立 Web 应用,可以容纳更完整的页面结构和更长的交互流程
所以你在做游戏内 dApp 时,通常要额外注意:
- 页面首屏必须快
- 不能依赖太复杂的多页跳转
- 参数丢失时要有兜底提示
- 钱包未连接、角色未初始化、设施未在线时要有明确状态页
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| Provider 配置 | <EveFrontierProvider> 包裹整个应用 |
| URL 参数 | ?tenant=&itemId= 绑定链上组件 |
useConnection | 钱包连接状态与操作 |
useSmartObject | 自动轮询的组件链上数据 |
| 执行交易 | dAppKit.signAndExecuteTransaction() |
| 赞助交易 | signAndExecuteSponsoredTransaction() 免 Gas |
| 读取数据 | GraphQL / getAssemblyWithOwner() |
📚 延伸阅读
实战案例 1:白名单矿区守卫(智能炮塔访问控制)
目标: 编写一个智能炮塔扩展,让炮塔只放行持有“矿区通行证 NFT“的玩家;同时构建一个管理界面,让 Owner 能在线颁发通行证。
状态:已映射到本地代码目录。正文覆盖通行证 NFT 与炮塔白名单逻辑,适合作为第一个完整 Builder 闭环。
对应代码目录
最小调用链
Owner 颁发通行证 -> 玩家持有 MiningPass -> 炮塔扩展读取凭证 -> 放行或开火
需求分析
场景: 你的联盟在深空开采到了一片稀有矿区,部署了一个智能炮塔保护基地。但你希望区别对待不同角色:
- ✅ 联盟成员:持有
MiningPassNFT,炮塔放行 - ❌ 非成员:没有
MiningPass,炮塔自动开火
额外要求:
- Owner(你)可以通过 dApp 给信任角色颁发
MiningPass MiningPass可以被 Owner 撤销- dApp 显示当前受保护状态和通行证持有者列表
第一部分:Move 合约开发
目录结构
mining-guard/
├── Move.toml
└── sources/
├── mining_pass.move # NFT 定义
└── guard_extension.move # 炮塔扩展
第一步:定义 MiningPass NFT
// sources/mining_pass.move
module mining_guard::mining_pass;
use sui::object::{Self, UID};
use sui::tx_context::TxContext;
use sui::transfer;
use sui::event;
/// 矿区通行证 NFT
public struct MiningPass has key, store {
id: UID,
holder_name: vector<u8>, // 持有者名称(方便辨识)
issued_at_ms: u64, // 颁发时间戳
zone_id: u64, // 对应哪个矿区(支持多矿区)
}
/// 管理员能力(只有合约部署者持有)
public struct AdminCap has key, store {
id: UID,
}
/// 事件:新通行证颁发
public struct PassIssued has copy, drop {
pass_id: ID,
recipient: address,
zone_id: u64,
}
/// 合约初始化:部署者获得 AdminCap
fun init(ctx: &mut TxContext) {
let admin_cap = AdminCap {
id: object::new(ctx),
};
// 将 AdminCap 转给部署者地址
transfer::transfer(admin_cap, ctx.sender());
}
/// 颁发矿区通行证(只有持有 AdminCap 才能调用)
public fun issue_pass(
_admin_cap: &AdminCap, // 验证调用者是管理员
recipient: address, // 接收者地址
holder_name: vector<u8>,
zone_id: u64,
ctx: &mut TxContext,
) {
let pass = MiningPass {
id: object::new(ctx),
holder_name,
issued_at_ms: ctx.epoch_timestamp_ms(),
zone_id,
};
// 发射事件
event::emit(PassIssued {
pass_id: object::id(&pass),
recipient,
zone_id,
});
// 将通行证转给接收者
transfer::transfer(pass, recipient);
}
/// 撤销通行证
/// Owner 可以通过 admin_cap 销毁指定角色的通行证
/// (实际上,你可以设计成"收回+销毁",这里简化为让持有者自行烧毁)
public fun revoke_pass(
_admin_cap: &AdminCap,
pass: MiningPass,
) {
let MiningPass { id, .. } = pass;
id.delete();
}
/// 检查通行证是否属于特定矿区
public fun is_valid_for_zone(pass: &MiningPass, zone_id: u64): bool {
pass.zone_id == zone_id
}
第二步:编写炮塔扩展
// sources/guard_extension.move
module mining_guard::guard_extension;
use mining_guard::mining_pass::{Self, MiningPass};
use world::turret::{Self, Turret};
use world::character::Character;
use sui::tx_context::TxContext;
/// 炮塔扩展的 Witness 类型
public struct GuardAuth has drop {}
/// 受保护的矿区 ID(这个版本保护 zone 1)
const PROTECTED_ZONE_ID: u64 = 1;
/// 请求安全通行(玩家持有通行证则被炮塔放过)
///
/// 注意:实际炮塔的"不开火"逻辑由游戏服务器执行,
/// 这里的合约用于验证和记录许可意图
public fun request_safe_passage(
turret: &mut Turret,
character: &Character,
pass: &MiningPass, // 必须持有通行证
ctx: &mut TxContext,
) {
// 验证通行证属于正确的矿区
assert!(
mining_pass::is_valid_for_zone(pass, PROTECTED_ZONE_ID),
0 // 错误码:无效的矿区通行证
);
// 调用炮塔的安全通行函数,传入 GuardAuth{} 作为扩展凭证
// (实际 API 以世界合约为准)
turret::grant_safe_passage(
turret,
character,
GuardAuth {},
ctx,
);
}
第三步:编译和发布
cd mining-guard
# 编译检查
sui move build
# 发布到测试网
sui client publish
# 记录输出:
# Package ID: 0x_YOUR_PACKAGE_ID_
# AdminCap Object ID: 0x_YOUR_ADMIN_CAP_
第四步:注册扩展到炮塔
// scripts/register-extension.ts
import { Transaction } from "@mysten/sui/transactions";
import { SuiClient } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
const WORLD_PACKAGE = "0x...";
const MY_PACKAGE = "0x_YOUR_PACKAGE_ID_";
const TURRET_ID = "0x...";
const CHARACTER_ID = "0x...";
const OWNER_CAP_ID = "0x...";
async function registerExtension() {
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
const keypair = Ed25519Keypair.fromSecretKey(/* your key */);
const tx = new Transaction();
// 1. 从角色借用炮塔的 OwnerCap
const [ownerCap] = tx.moveCall({
target: `${WORLD_PACKAGE}::character::borrow_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::turret::Turret`],
arguments: [tx.object(CHARACTER_ID), tx.object(OWNER_CAP_ID)],
});
// 2. 注册我们的扩展
tx.moveCall({
target: `${WORLD_PACKAGE}::turret::authorize_extension`,
typeArguments: [`${MY_PACKAGE}::guard_extension::GuardAuth`],
arguments: [tx.object(TURRET_ID), ownerCap],
});
// 3. 归还 OwnerCap
tx.moveCall({
target: `${WORLD_PACKAGE}::character::return_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::turret::Turret`],
arguments: [tx.object(CHARACTER_ID), ownerCap],
});
const result = await client.signAndExecuteTransaction({
signer: keypair,
transaction: tx,
});
console.log("扩展注册成功!Tx:", result.digest);
}
registerExtension();
第二部分:管理员 dApp
功能:颁发通行证界面
// src/AdminPanel.tsx
import { useState } from 'react'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { useConnection } from '@evefrontier/dapp-kit'
import { Transaction } from '@mysten/sui/transactions'
const MY_PACKAGE = "0x_YOUR_PACKAGE_ID_"
const ADMIN_CAP_ID = "0x_YOUR_ADMIN_CAP_"
export function AdminPanel() {
const { isConnected, handleConnect } = useConnection()
const dAppKit = useDAppKit()
const [recipient, setRecipient] = useState('')
const [holderName, setHolderName] = useState('')
const [status, setStatus] = useState('')
const issuePass = async () => {
if (!recipient || !holderName) {
setStatus('❌ 请填写接收者地址和名称')
return
}
const tx = new Transaction()
tx.moveCall({
target: `${MY_PACKAGE}::mining_pass::issue_pass`,
arguments: [
tx.object(ADMIN_CAP_ID),
tx.pure.address(recipient),
tx.pure.vector('u8', Array.from(new TextEncoder().encode(holderName))),
tx.pure.u64(1), // 矿区 Zone ID
],
})
try {
setStatus('⏳ 交易提交中...')
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 通行证已颁发!Tx: ${result.digest.slice(0, 12)}...`)
} catch (e: any) {
setStatus(`❌ 失败:${e.message}`)
}
}
if (!isConnected) {
return (
<div className="admin-panel">
<button onClick={handleConnect}>🔗 连接管理员钱包</button>
</div>
)
}
return (
<div className="admin-panel">
<h2>🛡 矿区通行证管理</h2>
<div className="form-group">
<label>接收者 Sui 地址</label>
<input
value={recipient}
onChange={e => setRecipient(e.target.value)}
placeholder="0x..."
/>
</div>
<div className="form-group">
<label>持有者名称</label>
<input
value={holderName}
onChange={e => setHolderName(e.target.value)}
placeholder="Mining Corp Alpha"
/>
</div>
<button className="issue-btn" onClick={issuePass}>
📜 颁发矿区通行证
</button>
{status && <p className="status">{status}</p>}
</div>
)
}
第三部分:玩家端 dApp
// src/PlayerPanel.tsx
import { useConnection, useSmartObject } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const MY_PACKAGE = "0x_YOUR_PACKAGE_ID_"
const TURRET_ID = "0x..."
const CHARACTER_ID = "0x..."
export function PlayerPanel() {
const { isConnected, handleConnect } = useConnection()
const { assembly, loading } = useSmartObject()
const dAppKit = useDAppKit()
const [passId, setPassId] = useState('')
const [status, setStatus] = useState('')
const requestPassage = async () => {
const tx = new Transaction()
tx.moveCall({
target: `${MY_PACKAGE}::guard_extension::request_safe_passage`,
arguments: [
tx.object(TURRET_ID),
tx.object(CHARACTER_ID),
tx.object(passId), // 玩家的 MiningPass Object ID
],
})
try {
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('✅ 安全通行已记录,炮塔将放行')
} catch (e: any) {
setStatus('❌ 通行证验证失败,无法进入矿区')
}
}
if (!isConnected) return <button onClick={handleConnect}>连接钱包</button>
if (loading) return <div>加载炮塔状态...</div>
return (
<div className="player-panel">
<h2>⚡ {assembly?.name ?? '矿区守卫炮塔'}</h2>
<p>状态:{assembly?.status}</p>
<div className="pass-input">
<label>输入你的矿区通行证 Object ID</label>
<input
value={passId}
onChange={e => setPassId(e.target.value)}
placeholder="0x..."
/>
<button onClick={requestPassage}>🛡 申请安全通行</button>
</div>
{status && <p>{status}</p>}
</div>
)
}
🎯 完整实现回顾
1. Move 合约
├── mining_pass.move → 定义 MiningPass NFT + AdminCap + issue_pass / revoke_pass
└── guard_extension.move → 炮塔扩展 + request_safe_passage(验证通行证后调用炮塔 API)
2. 注册流程
└── authorize_extension<GuardAuth>(turret, owner_cap)
3. 管理员 dApp
└── 输入地址和名称 → 调用 issue_pass → 将 NFT 转给目标角色
4. 玩家 dApp
└── 输入通行证 ID → 调用 request_safe_passage → 炮塔放行记录上链
🔧 扩展练习
- 给
MiningPass添加过期时间,过期后炮塔不再放行 - 在合约中记录所有活跃通行证的集合,供 dApp 查询展示
- 实现“团队许可证“:一张许可证可供多个预定成员使用
📚 关联文档
实战案例 2:太空高速收费站(智能星门收费系统)
目标: 编写一个智能星门扩展,实现按次收取 LUX 代币通行费;并建立一个面向玩家的购票 dApp 界面。
状态:已映射到本地代码目录。正文覆盖收费星门、票据与金库三件套,是最典型的 Builder 商业化案例之一。
对应代码目录
最小调用链
玩家支付通行费 -> 金库收款 -> 铸造 JumpTicket -> 星门校验票据 -> 完成跳跃
需求分析
场景: 你和联盟控制了两个星门组成的战略要道,连接了宇宙两个繁忙区域。你决定将这条航线商业化:
- 🎟 任何玩家想跳跃,必须支付 50 LUX 购买
JumpTicket - 🏦 所有收取的 LUX 进入金库(合约管理的共享对象)
- 💰 只有 Owner(你)可以提取金库中的 LUX
- 📊 dApp 实时显示当前票价、跳跃次数、金库余额
第一部分:Move 合约开发
目录结构
toll-gate/
├── Move.toml
└── sources/
├── treasury.move # 金库:收集和管理 LUX
└── toll_gate.move # 星门扩展:收费逻辑
第一步:定义金库合约
// sources/treasury.move
module toll_gate::treasury;
use sui::object::{Self, UID};
use sui::balance::{Self, Balance};
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::tx_context::TxContext;
use sui::transfer;
use sui::event;
// ── 类型定义 ─────────────────────────────────────────────
/// 这里用 SUI 代币代表 LUX(演示)
/// 真实部署中换成 LUX 的 Coin 类型
/// 金库:收集所有通行费
public struct TollTreasury has key {
id: UID,
balance: Balance<SUI>,
total_jumps: u64, // 累计跳跃次数(统计用)
toll_amount: u64, // 当前票价(以 MIST 计,1 SUI = 10^9 MIST)
}
/// OwnerCap:只有持有此对象才能提取金库资金
public struct TreasuryOwnerCap has key, store {
id: UID,
}
// ── 事件 ──────────────────────────────────────────────────
public struct TollCollected has copy, drop {
payer: address,
amount: u64,
total_jumps: u64,
}
public struct TollWithdrawn has copy, drop {
recipient: address,
amount: u64,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
// 创建金库(共享对象,任何人可以存入)
let treasury = TollTreasury {
id: object::new(ctx),
balance: balance::zero(),
total_jumps: 0,
toll_amount: 50_000_000_000, // 50 SUI(单位:MIST)
};
// 创建 Owner 凭证(转给部署者)
let owner_cap = TreasuryOwnerCap {
id: object::new(ctx),
};
transfer::share_object(treasury);
transfer::transfer(owner_cap, ctx.sender());
}
// ── 公开函数 ──────────────────────────────────────────────
/// 存入通行费(由星门扩展调用)
public fun deposit_toll(
treasury: &mut TollTreasury,
payment: Coin<SUI>,
payer: address,
) {
let amount = coin::value(&payment);
// 验证金额正确
assert!(amount >= treasury.toll_amount, 1); // E_INSUFFICIENT_FEE
treasury.total_jumps = treasury.total_jumps + 1;
balance::join(&mut treasury.balance, coin::into_balance(payment));
event::emit(TollCollected {
payer,
amount,
total_jumps: treasury.total_jumps,
});
}
/// 提取金库 LUX(只有持有 TreasuryOwnerCap 才能调用)
public fun withdraw(
treasury: &mut TollTreasury,
_cap: &TreasuryOwnerCap,
amount: u64,
ctx: &mut TxContext,
) {
let coin = coin::take(&mut treasury.balance, amount, ctx);
transfer::public_transfer(coin, ctx.sender());
event::emit(TollWithdrawn {
recipient: ctx.sender(),
amount,
});
}
/// 修改票价(Owner 调用)
public fun set_toll_amount(
treasury: &mut TollTreasury,
_cap: &TreasuryOwnerCap,
new_amount: u64,
) {
treasury.toll_amount = new_amount;
}
/// 读取当前票价
public fun toll_amount(treasury: &TollTreasury): u64 {
treasury.toll_amount
}
/// 读取金库余额
public fun balance_amount(treasury: &TollTreasury): u64 {
balance::value(&treasury.balance)
}
第二步:编写星门扩展
// sources/toll_gate.move
module toll_gate::toll_gate_ext;
use toll_gate::treasury::{Self, TollTreasury};
use world::gate::{Self, Gate};
use world::character::Character;
use sui::coin::Coin;
use sui::sui::SUI;
use sui::clock::Clock;
use sui::tx_context::TxContext;
/// 星门扩展的 Witness 类型
public struct TollAuth has drop {}
/// 默认跳跃许可有效期:15 分钟
const PERMIT_DURATION_MS: u64 = 15 * 60 * 1000;
/// 支付通行费并获得跳跃许可
public fun pay_toll_and_get_permit(
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
treasury: &mut TollTreasury,
payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
// 1. 收取通行费
treasury::deposit_toll(treasury, payment, ctx.sender());
// 2. 计算 Permit 过期时间
let expires_at = clock.timestamp_ms() + PERMIT_DURATION_MS;
// 3. 向星门申请跳跃许可(TollAuth{} 是扩展凭证)
gate::issue_jump_permit(
source_gate,
destination_gate,
character,
TollAuth {},
expires_at,
ctx,
);
// 注意:JumpPermit 对象会被自动转给 character 的 Owner
}
第三步:发布合约
cd toll-gate
sui move build
sui client publish
# 记录:
# Package ID: 0x_TOLL_PACKAGE_
# TollTreasury ID: 0x_TREASURY_ID_(共享对象)
# TreasuryOwnerCap ID: 0x_OWNER_CAP_ID_
第四步:注册扩展到星门
// scripts/authorize-toll-gate.ts
import { Transaction } from "@mysten/sui/transactions";
import { SuiClient } from "@mysten/sui/client";
const WORLD_PACKAGE = "0x...";
const TOLL_PACKAGE = "0x_TOLL_PACKAGE_";
const GATE_ID = "0x...";
const CHARACTER_ID = "0x...";
const GATE_OWNER_CAP_ID = "0x...";
async function authorizeTollGate() {
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
const tx = new Transaction();
// 借用星门 OwnerCap
const [ownerCap] = tx.moveCall({
target: `${WORLD_PACKAGE}::character::borrow_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::gate::Gate`],
arguments: [tx.object(CHARACTER_ID), tx.object(GATE_OWNER_CAP_ID)],
});
// 注册 TollAuth 作为授权扩展
tx.moveCall({
target: `${WORLD_PACKAGE}::gate::authorize_extension`,
typeArguments: [`${TOLL_PACKAGE}::toll_gate_ext::TollAuth`],
arguments: [tx.object(GATE_ID), ownerCap],
});
// 归还 OwnerCap
tx.moveCall({
target: `${WORLD_PACKAGE}::character::return_owner_cap`,
typeArguments: [`${WORLD_PACKAGE}::gate::Gate`],
arguments: [tx.object(CHARACTER_ID), ownerCap],
});
const result = await client.signAndExecuteTransaction({
signer: keypair,
transaction: tx,
});
console.log("收费站扩展注册成功!", result.digest);
}
第二部分:玩家购票 dApp
完整购票界面
// src/TollGateApp.tsx
import { useState, useEffect } from 'react'
import { useConnection, useSmartObject, getObjectWithJson } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const WORLD_PACKAGE = "0x..."
const TOLL_PACKAGE = "0x_TOLL_PACKAGE_"
const SOURCE_GATE_ID = "0x..."
const DEST_GATE_ID = "0x..."
const CHARACTER_ID = "0x..."
const TREASURY_ID = "0x_TREASURY_ID_"
interface TreasuryData {
toll_amount: string
total_jumps: string
balance: string
}
export function TollGateApp() {
const { isConnected, handleConnect, currentAddress } = useConnection()
const { assembly, loading } = useSmartObject()
const dAppKit = useDAppKit()
const [treasury, setTreasury] = useState<TreasuryData | null>(null)
const [txStatus, setTxStatus] = useState('')
const [isPaying, setIsPaying] = useState(false)
// 加载金库数据
const loadTreasury = async () => {
const data = await getObjectWithJson(TREASURY_ID)
if (data?.content?.dataType === 'moveObject') {
setTreasury(data.content.fields as TreasuryData)
}
}
useEffect(() => {
loadTreasury()
const interval = setInterval(loadTreasury, 10_000) // 每 10 秒刷新
return () => clearInterval(interval)
}, [])
const payAndJump = async () => {
if (!isConnected) {
setTxStatus('❌ 请先连接钱包')
return
}
setIsPaying(true)
setTxStatus('⏳ 提交交易中...')
const tollAmount = BigInt(treasury?.toll_amount ?? 50_000_000_000)
const tx = new Transaction()
// 分割出票价金额的 SUI
const [paymentCoin] = tx.splitCoins(tx.gas, [
tx.pure.u64(tollAmount)
])
// 调用收费并获取 Permit
tx.moveCall({
target: `${TOLL_PACKAGE}::toll_gate_ext::pay_toll_and_get_permit`,
arguments: [
tx.object(SOURCE_GATE_ID),
tx.object(DEST_GATE_ID),
tx.object(CHARACTER_ID),
tx.object(TREASURY_ID),
paymentCoin,
tx.object('0x6'), // Clock 系统对象
],
})
try {
const result = await dAppKit.signAndExecuteTransaction({
transaction: tx,
})
setTxStatus(`✅ 已获得跳跃许可! Tx: ${result.digest.slice(0, 12)}...`)
loadTreasury() // 刷新金库数据
} catch (e: any) {
setTxStatus(`❌ ${e.message}`)
} finally {
setIsPaying(false)
}
}
const tollInSui = treasury
? (Number(treasury.toll_amount) / 1e9).toFixed(2)
: '...'
const balanceInSui = treasury
? (Number(treasury.balance) / 1e9).toFixed(2)
: '...'
return (
<div className="toll-gate-app">
{/* 星门信息 */}
<header className="gate-header">
<div className="gate-icon">🌀</div>
<div>
<h1>{loading ? '...' : assembly?.name ?? '星门'}</h1>
<span className={`status-badge ${assembly?.status?.toLowerCase()}`}>
{assembly?.status ?? '检测中...'}
</span>
</div>
</header>
{/* 通行费信息 */}
<section className="toll-info">
<div className="info-card">
<span className="label">💰 当前票价</span>
<span className="value">{tollInSui} SUI</span>
</div>
<div className="info-card">
<span className="label">🚀 累计跳跃</span>
<span className="value">{treasury?.total_jumps ?? '...'} 次</span>
</div>
<div className="info-card">
<span className="label">🏦 金库余额</span>
<span className="value">{balanceInSui} SUI</span>
</div>
</section>
{/* 跳跃操作 */}
<section className="jump-section">
{!isConnected ? (
<button className="connect-btn" onClick={handleConnect}>
🔗 连接 EVE Vault 钱包
</button>
) : (
<>
<div className="wallet-info">
✅ {currentAddress?.slice(0, 6)}...{currentAddress?.slice(-4)}
</div>
<button
className="jump-btn"
onClick={payAndJump}
disabled={isPaying || assembly?.status !== 'Online'}
>
{isPaying ? '⏳ 处理中...' : `🛸 支付 ${tollInSui} SUI 并跃迁`}
</button>
</>
)}
{txStatus && (
<div className={`tx-status ${txStatus.startsWith('✅') ? 'success' : 'error'}`}>
{txStatus}
</div>
)}
</section>
{/* 目的地信息 */}
<section className="destination-info">
<p>📍 目的地:<strong>Alpha Centauri 矿区</strong></p>
<p>⏱ 许可证有效期:<strong>15 分钟</strong></p>
</section>
</div>
)
}
第三部分:Owner 管理面板
// src/OwnerPanel.tsx
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const TOLL_PACKAGE = "0x_TOLL_PACKAGE_"
const TREASURY_ID = "0x_TREASURY_ID_"
const OWNER_CAP_ID = "0x_OWNER_CAP_ID_"
export function OwnerPanel({ treasuryBalance }: { treasuryBalance: number }) {
const dAppKit = useDAppKit()
const [withdrawAmount, setWithdrawAmount] = useState('')
const [newToll, setNewToll] = useState('')
const [status, setStatus] = useState('')
const withdraw = async () => {
const amountMist = Math.floor(parseFloat(withdrawAmount) * 1e9)
const tx = new Transaction()
tx.moveCall({
target: `${TOLL_PACKAGE}::treasury::withdraw`,
arguments: [
tx.object(TREASURY_ID),
tx.object(OWNER_CAP_ID),
tx.pure.u64(amountMist),
],
})
try {
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 已提取 ${withdrawAmount} SUI`)
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
const updateToll = async () => {
const amountMist = Math.floor(parseFloat(newToll) * 1e9)
const tx = new Transaction()
tx.moveCall({
target: `${TOLL_PACKAGE}::treasury::set_toll_amount`,
arguments: [
tx.object(TREASURY_ID),
tx.object(OWNER_CAP_ID),
tx.pure.u64(amountMist),
],
})
try {
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 票价已更新为 ${newToll} SUI`)
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
return (
<div className="owner-panel">
<h2>⚙️ 收费站管理</h2>
<div className="panel-section">
<h3>💵 提取收入</h3>
<p>金库余额:{(treasuryBalance / 1e9).toFixed(2)} SUI</p>
<input
type="number"
value={withdrawAmount}
onChange={e => setWithdrawAmount(e.target.value)}
placeholder="提取金额(SUI)"
/>
<button onClick={withdraw}>提取到钱包</button>
</div>
<div className="panel-section">
<h3>🏷 调整票价</h3>
<input
type="number"
value={newToll}
onChange={e => setNewToll(e.target.value)}
placeholder="新票价(SUI)"
/>
<button onClick={updateToll}>更新票价</button>
</div>
{status && <p className="status">{status}</p>}
</div>
)
}
🎯 完整实现回顾
Move 合约层
├── treasury.move
│ ├── TollTreasury(共享金库对象)
│ ├── TreasuryOwnerCap(提款权凭证)
│ ├── deposit_toll() ← 扩展调用
│ ├── withdraw() ← Owner 调用
│ └── set_toll_amount() ← Owner 调用
│
└── toll_gate_ext.move
├── TollAuth(Witness 类型)
└── pay_toll_and_get_permit() ← 玩家调用
├── 1. 验证并收费 → treasury.deposit_toll()
└── 2. 颁发许可 → gate::issue_jump_permit()
dApp 层
├── TollGateApp.tsx → 玩家购票界面
│ ├── 实时显示票价、跳跃次数、金库余额
│ └── 一键支付并获取 JumpPermit
└── OwnerPanel.tsx → 管理员面板
├── 提取金库收入
└── 调整票价
🔧 扩展练习
- 等级会员制:联盟成员持有会员 NFT 可享受折扣(检查 NFT 后应用不同票价)
- 限时免费通道:在特定时间段(如维护期)自动接受 0 LUX Permit
- 收益分配:金库收入自动按比例分配给多个联盟股东地址
- 历史记录 dApp:监听
TollCollected事件,展示最近 50 次跳跃记录
📚 关联文档
- Smart Gate 文档
- Interfacing with the World
- Chapter 3:Move 资源与 Coin 模型
- Chapter 5:dApp 发起链上交易
- builder-scaffold Smart Gate 示例
第6章:Builder Scaffold 完整使用指南(一)——项目结构与合约开发
学习目标:掌握
builder-scaffold的完整目录结构,理解 Docker 和本机两种开发流程,并能独立完成 smart_gate 合约的本地开发与发布。
状态:已映射到本地脚手架目录。正文命令以本仓库现有
builder-scaffold目录为准。
最小调用链
启动本地链 -> 编译 smart_gate -> 发布 -> 记录 package/object id -> 配置规则 -> 发 permit
对应代码目录
1. 什么是 Builder Scaffold?
builder-scaffold 是 EVE Frontier 官方提供的一站式 Builder 开发脚手架,包含:
- Move 合约模板:两个完整的 Smart Gate Extension 示例
- TypeScript 交互脚本:发布后立即可用的链上交互脚本
- Docker 开发环境:零配置、开箱即用的本地链
- dApp 模板:React + EVE Frontier dapp-kit 的前端起点
builder-scaffold/
├── docker/ # Docker 开发环境(Sui CLI + Node.js 容器)
├── move-contracts/ # Move 合约示例
│ ├── smart_gate/ # 主要示例:Star Gate Extension
│ ├── storage_unit/ # 存储单元 Extension 示例
│ └── tokens/ # 代币合约示例
├── ts-scripts/ # TypeScript 交互脚本
│ ├── smart_gate/ # 针对 smart_gate 的 6 个操作脚本
│ ├── utils/ # 公共工具:env配置、derive-object-id、proof
│ └── helpers/ # 查询 OwnerCap 等辅助函数
├── dapps/ # React dApp 模板(EVE Frontier dapp-kit)
└── docs/ # 完整的部署流程文档
这一章最重要的不是背目录,而是理解:
builder-scaffold不是一个示例仓库而已,它其实是在替你把“本地链、合约、脚本、前端”这几条线预先接好。
所以真正的价值是:
- 降低第一次打通闭环的成本
- 给你一个能边改边跑的标准骨架
- 让后面的自定义开发尽量从“改模板”开始,而不是从“自己搭平台”开始
2. 选择开发流程
官方支持两种流程:
| 流程 | 适用场景 | 前置要求 |
|---|---|---|
| Docker 流程 | 不想在本机安装 Sui/Node 的用户 | 仅 Docker |
| 本机(Host)流程 | 已有 Sui CLI + Node.js | Sui CLI + Node.js |
这两种流程真正的取舍
- Docker 更稳,环境差异更少,适合先跑通
- Host 更快,更贴近日常开发,但更依赖你本机环境已经干净
如果你的目标是“先理解完整闭环”,优先 Docker。
如果你的目标是“高频迭代自己写代码”,后面通常会逐步转向 Host。
3. Docker 开发环境(推荐新手)
快速启动
# 克隆仓库
git clone https://github.com/evefrontier/builder-scaffold.git
cd builder-scaffold
# 启动开发容器(首次会下载镜像约 2-3 分钟)
cd docker
docker compose run --rm --service-ports sui-dev
首次启动时,容器会自动:
- 创建 3 个 ed25519 密钥对(
ADMIN、PLAYER_A、PLAYER_B) - 启动本地 Sui 节点
- 向账户发放测试 SUI
密钥持久保存在 Docker Volume,容器重启后不会丢失。
容器内工作目录结构
/workspace/
├── builder-scaffold/ # 完整仓库(与宿主机同步)
└── world-contracts/ # 在宿主机克隆后在容器内可见
在宿主机编辑文件,在容器内运行命令——两者实时同步。
为什么 build 时用 -e testnet?
sui move build -e testnet # ← 这里的 testnet 是"构建环境",不是发布目标
本地链的 chain ID 每次重启都变化,无法固定在 Move.toml 里。-e testnet 让依赖解析用 testnet 规则,但实际发布仍然到本地链。
这里最容易误解的是把“构建环境”和“发布目标”混成一件事。
这一步用 -e testnet,并不是说你现在真的在往 testnet 发,而是告诉构建器:
- 依赖地址按哪套规则解析
- 包构建按哪套环境约定处理
如果这个概念不分开,后面你在 localnet / testnet / mainnet 切换时会非常容易判断错误。
容器常用命令速查
| 任务 | 命令 |
|---|---|
| 查看所有密钥 | cat /workspace/builder-scaffold/docker/.env.sui |
| 切换到测试网 | sui client switch --env testnet |
| 导入已有密钥 | sui keytool import <key> ed25519 |
| 编译合约 | cd .../smart_gate && sui move build -e testnet |
| 运行 TS 脚本 | cd /workspace/builder-scaffold && pnpm configure-rules |
| 启动 GraphQL | curl http://localhost:9125/graphql |
| 清除重置 | docker compose down --volumes && docker compose run --rm --service-ports sui-dev |
PostgreSQL + GraphQL 索引器
Docker 环境内置了 Sui 索引器和 GraphQL 支持:
# 查询链 ID(验证 GraphQL 是否启动)
curl -X POST http://localhost:9125/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ chainIdentifier }"}'
GraphQL 端点:http://localhost:9125/graphql(可用 Altair 调试)
4. Smart Gate 合约的文件结构
move-contracts/smart_gate/
├── Move.toml # 包配置(依赖 world-contracts)
├── sources/
│ ├── config.move # 共享配置基础:ExtensionConfig + AdminCap + XAuth
│ ├── tribe_permit.move # 示例1:部族身份验证通行证
│ └── corpse_gate_bounty.move # 示例2:提交尸体物品换通行证
└── tests/
└── gate_tests.move # 测试
Move.toml 分析
[package]
name = "smart_gate"
edition = "2024"
[dependencies]
# Git 依赖(推荐锁定稳定 tag)
world = { git = "https://github.com/evefrontier/world-contracts.git", subdir = "contracts/world", rev = "v0.0.14" }
[addresses]
smart_gate = "0x0" # 发布时自动替换为实际地址
重要:建议直接使用 git 依赖并锁定
rev(如v0.0.14),不要追踪main,否则 world-contracts 主分支的 Breaking Change 会直接影响编译结果。
为什么脚手架示例最适合拿来学“扩展模式”
因为它不是抽象 demo,而是把几个最关键的 Builder 要素都放进去了:
- 动态字段配置
- AdminCap 管理
- Typed Witness 扩展
- Gate 组件接入
换句话说,smart_gate 不是在教你写某一个具体业务,而是在教你 EVE Builder 最核心的扩展骨架。
5. config.move:Extension 基础框架
module smart_gate::config;
use sui::dynamic_field as df;
/// 发布后自动创建,是所有规则的共享存储
public struct ExtensionConfig has key {
id: UID,
}
/// 管理员权限凭证(init 时转移给部署者)
public struct AdminCap has key, store {
id: UID,
}
/// 授权见证类型(Typed Witness),传入 gate::issue_jump_permit<XAuth>
public struct XAuth has drop {}
fun init(ctx: &mut TxContext) {
// AdminCap 转移给部署者
transfer::transfer(AdminCap { id: object::new(ctx) }, ctx.sender());
// ExtensionConfig 共享化(所有人可读,只有 AdminCap 持有者可写)
transfer::share_object(ExtensionConfig { id: object::new(ctx) });
}
动态字段规则系统
ExtensionConfig 使用动态字段来存储各种规则,这样一个配置对象可以同时支持多种不同的扩展规则:
// set_rule:插入或覆盖规则(value 需要 drop ability)
public fun set_rule<K: copy + drop + store, V: store + drop>(
config: &mut ExtensionConfig,
_: &AdminCap, // 只有 AdminCap 才能设置
key: K,
value: V,
) {
if (df::exists_(&config.id, copy key)) {
let _old: V = df::remove(&mut config.id, copy key);
};
df::add(&mut config.id, key, value);
}
6. tribe_permit.move:部族通行证(精读)
这是最简单的 Extension 实现,适合理解扩展模式的核心结构:
module smart_gate::tribe_permit;
// 规则配置(动态字段值)
public struct TribeConfig has drop, store {
tribe: u32, // 允许通过的部族 ID
expiry_duration_ms: u64, // 通行证有效期(毫秒)
}
// 规则标识(动态字段 Key)
public struct TribeConfigKey has copy, drop, store {}
颁发通行证
pub fun issue_jump_permit(
extension_config: &ExtensionConfig,
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
clock: &Clock,
ctx: &mut TxContext,
) {
// 1. 读取规则配置
let tribe_cfg = extension_config.borrow_rule<TribeConfigKey, TribeConfig>(TribeConfigKey {});
// 2. 验证角色部族
assert!(character.tribe() == tribe_cfg.tribe, ENotStarterTribe);
// 3. 计算过期时间(防溢出检查)
let ts = clock.timestamp_ms();
assert!(ts <= (0xFFFFFFFFFFFFFFFFu64 - tribe_cfg.expiry_duration_ms), EExpiryOverflow);
let expires_at = ts + tribe_cfg.expiry_duration_ms;
// 4. 调用 world 合约颁发 JumpPermit NFT
gate::issue_jump_permit<XAuth>(
source_gate, destination_gate, character,
config::x_auth(), // 包内唯一的 XAuth 实例
expires_at, ctx,
);
}
设计细节:与 world-contracts 的原版相比,这里增加了防溢出检查(
EExpiryOverflow),是更健壮的生产实现。
管理员设置规则
pub fun set_tribe_config(
extension_config: &mut ExtensionConfig,
admin_cap: &AdminCap,
tribe: u32,
expiry_duration_ms: u64,
) {
extension_config.set_rule<TribeConfigKey, TribeConfig>(
admin_cap,
TribeConfigKey {},
TribeConfig { tribe, expiry_duration_ms },
);
}
7. 编译与测试
# 进入 smart_gate 目录
cd move-contracts/smart_gate
# 编译(使用 testnet 作为构建环境)
sui move build -e testnet
# 运行测试
sui move test -e testnet
编译失败常见问题
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
Unpublished dependencies: World | world-contracts 未部署 | 先部署 world-contracts,或切换为 local 依赖 |
Move.lock wrong env | Move.lock 记录的环境不匹配 | rm Move.lock && sui move build -e testnet |
edition = "legacy" 警告 | 使用了旧版 Move | 在 Move.toml 中改为 edition = "2024" |
8. 发布合约到本地链
# 确保 world-contracts 已部署,获得其 publication file
sui client test-publish \
--build-env testnet \
--pubfile-path ../../deployments/Pub.localnet.toml
# 发布成功后记录输出的 Package ID
# 填入 .env 文件的 BUILDER_PACKAGE_ID
test-publishvspublish:test-publish是 Sui 的特殊发布模式,允许在本地链上发布依赖未发布的包(用于测试)。实际发布到测试网/主网时使用sui client publish。
9. 添加你自己的 Extension 规则
以添加“付费通道规则“为例:
第一步:在 config.move 旁创建新文件 toll_gate.move
module smart_gate::toll_gate;
use smart_gate::config::{Self, AdminCap, XAuth, ExtensionConfig};
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
// 规则数据
public struct TollConfig has drop, store {
toll_amount: u64,
expiry_duration_ms: u64,
}
public struct TollConfigKey has copy, drop, store {}
// 收费账本(共享对象)
public struct TollVault has key {
id: UID,
balance: Balance<SUI>,
}
// 初始化时创建金库
public fun create_vault(ctx: &mut TxContext) {
transfer::share_object(TollVault {
id: object::new(ctx),
balance: balance::zero(),
});
}
第二步:实现颁发函数
pub fun pay_and_jump(
extension_config: &ExtensionConfig,
vault: &mut TollVault,
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let toll_cfg = extension_config.borrow_rule<TollConfigKey, TollConfig>(TollConfigKey {});
assert!(coin::value(&payment) >= toll_cfg.toll_amount, ETollInsufficient);
let toll = coin::split(&mut payment, toll_cfg.toll_amount, ctx);
balance::join(&mut vault.balance, coin::into_balance(toll));
if (coin::value(&payment) > 0) {
transfer::public_transfer(payment, ctx.sender());
} else {
coin::destroy_zero(payment);
};
let expires = clock.timestamp_ms() + toll_cfg.expiry_duration_ms;
gate::issue_jump_permit<XAuth>(
source_gate, destination_gate, character, config::x_auth(), expires, ctx,
);
}
const ETollInsufficient: u64 = 0;
本章小结
| 组件 | 用途 |
|---|---|
docker/compose.yml | 本地 Sui 链 + GraphQL 索引器一键启动 |
move-contracts/smart_gate/ | Gate Extension 主模板 |
config.move | ExtensionConfig + AdminCap + XAuth 基础框架 |
tribe_permit.move | 示例①:部族身份验证 |
corpse_gate_bounty.move | 示例②:物品消耗换通行证 |
-e testnet 构建标志 | 解决本地链 chain ID 不稳定的问题 |
下一章:TypeScript 脚本与 dApp 开发 —— 合约发布后,如何用 6 个现成脚本与链上合约交互,以及如何基于 dApp 模板构建 EVE Frontier 前端。
第7章:Builder Scaffold 完整使用指南(二)——TS 脚本与 dApp 开发
学习目标:掌握
ts-scripts/中 6 个交互脚本的用法与原理,理解helper.ts工具链,并学会在dapps/React 模板的基础上构建属于自己的 EVE Frontier dApp。
状态:已映射脚本与 dApp 目录。正文以本仓库内
builder-scaffold的脚本布局为准。
最小调用链
读取 .env -> helper.ts 初始化客户端/对象 ID -> TS 脚本发起 PTB -> 链上对象变化 -> dApp 查询并展示新状态
目录职责边界
把 builder-scaffold 用顺,关键不是记住每个脚本名,而是先分清三层职责:
| 目录/文件 | 责任 | 不应该承担的事 |
|---|---|---|
ts-scripts/smart_gate/* | 组织单个业务动作,拼装 PTB | 塞大量共享工具函数 |
ts-scripts/utils/helper.ts | 初始化客户端、读取环境、封装公共查询 | 写具体业务规则 |
dapps/src/* | 展示状态、发起交互、承接钱包连接 | 直接硬编码环境和对象 ID |
一条完整脚本链路应该长什么样
.env
-> helper.ts 读取网络 / package id / key
-> 业务脚本拼装 PTB
-> 提交链上交易
-> dApp 或查询脚本刷新对象状态
如果一个脚本同时负责“读配置 + 查对象 + 拼复杂业务规则 + 打印 UI 文案”,基本就该拆了。
脚本体系真正要解决的,不是“把命令行自动化”这么简单,而是把工程动作拆成清晰职责:
- 配置来自哪里
- 公共查询由谁负责
- 单个业务动作由谁组织
- 前端和脚本怎样共享同一套对象理解
两个常见反模式
helper.ts越写越大,最后变成难以维护的“上帝文件”- 前端直接复制脚本里的对象 ID 和网络配置,导致脚本与页面长期漂移
对应代码目录
1. TypeScript 脚本的使用前提
在运行任何脚本之前,需要完成以下准备:
前置条件:
1. ✅ world-contracts 已发布(本地或测试网)
2. ✅ smart_gate 合约已发布(执行 sui client publish)
3. ✅ .env 文件已填写所有必要的环境变量
4. ✅ test-resources.json + extracted-object-ids.json 存在于项目根目录
配置 .env 文件
cp .env.example .env
关键环境变量:
# 网络选择
NETWORK=localnet # localnet | testnet | mainnet
# 管理员私钥(导出的 Sui 密钥,0x 开头的 Bech32 格式)
ADMIN_EXPORTED_KEY=suiprivkey1...
# 合约地址
WORLD_PACKAGE_ID=0xabc... # world-contracts 发布后的 Package ID
BUILDER_PACKAGE_ID=0xdef... # smart_gate 发布后的 Package ID
# 租户名称(游戏世界的命名空间)
TENANT=evefrontier
.env 的本质不是配置表,而是工程边界
只要某个值会因为环境不同而变化,它就不该被散落在脚本正文里。
最常见会漂移的值包括:
- 网络
- 包 ID
- 管理员密钥
- 租户名
- 关键对象 ID
一旦这些东西被脚本、前端、测试各写一份,后面排查会非常痛苦。
2. 6 个脚本的执行顺序与功能
完整执行流程
① pnpm configure-rules → 设置 Gate 的扩展规则(tribe ID、悬赏物品 type_id)
② pnpm authorise-gate → 将扩展注册到 Gate 对象
③ pnpm authorise-storage-unit → 将扩展注册到 StorageUnit
④ pnpm issue-tribe-jump-permit → 为符合部族条件的角色颁发通行证
⑤ pnpm jump-with-permit → 持有通行证跳跃
⑥ pnpm collect-corpse-bounty → 提交尸体物品 → 获得通行证(悬赏流程)
3. 精读:configure-rules.ts
这是最常修改的脚本,负责初始化两种规则:
// ts-scripts/smart_gate/configure-rules.ts
import { Transaction } from "@mysten/sui/transactions";
import { getEnvConfig, initializeContext, hydrateWorldConfig } from "../utils/helper";
import { resolveSmartGateExtensionIds } from "./extension-ids";
async function main() {
// 1. 读取 .env 配置
const env = getEnvConfig();
// 2. 初始化 Sui 客户端 + 密钥对
const ctx = initializeContext(env.network, env.adminExportedKey);
const { client, keypair, address } = ctx;
// 3. 从链上读取 world-contracts 的配置
await hydrateWorldConfig(ctx);
// 4. 从链上查询 AdminCap、ExtensionConfig 的对象 ID
const { builderPackageId, adminCapId, extensionConfigId } =
await resolveSmartGateExtensionIds(client, address);
const tx = new Transaction();
// 5. 设置部族规则(tribe=100, 有效期=1小时)
tx.moveCall({
target: `${builderPackageId}::tribe_permit::set_tribe_config`,
arguments: [
tx.object(extensionConfigId),
tx.object(adminCapId),
tx.pure.u32(100), // 允许的部族 ID
tx.pure.u64(3600000), // 有效期:1 小时(毫秒)
],
});
// 6. 设置悬赏规则(物品 type_id=ITEM_A_TYPE_ID, 有效期=1小时)
tx.moveCall({
target: `${builderPackageId}::corpse_gate_bounty::set_bounty_config`,
arguments: [
tx.object(extensionConfigId),
tx.object(adminCapId),
tx.pure.u64(ITEM_A_TYPE_ID), // 尸体物品的 type_id
tx.pure.u64(3600000),
],
});
// 7. 提交交易
const result = await client.signAndExecuteTransaction({
transaction: tx,
signer: keypair,
options: { showEffects: true, showObjectChanges: true },
});
console.log("Transaction digest:", result.digest);
}
这类脚本最值得保留的结构是什么?
就是这条清晰链路:
- 读环境
- 初始化上下文
- 解析链上关键对象
- 组装 PTB
- 提交并记录 digest
只要你以后新增脚本也保持这个骨架,工程会稳定很多。
修改规则参数
常见修改点:
// 改为允许部族 ID = 3(对应你游戏世界的部族配置)
tx.pure.u32(3),
// 改为 24 小时有效期
tx.pure.u64(24 * 60 * 60 * 1000),
// ITEM_A_TYPE_ID 在 utils/constants.ts 中定义,根据实际物品调整
4. 工具函数解析:utils/helper.ts
这是所有脚本的共享基础组件:
import { getEnvConfig, initializeContext, hydrateWorldConfig } from "../utils/helper";
// getEnvConfig():读取 .env 并验证必要字段
const env = getEnvConfig();
// → { network, rpcUrl, packageId, adminExportedKey, tenant }
// initializeContext():创建 Sui RPC 客户端和 Ed25519 密钥对
const ctx = initializeContext(env.network, env.adminExportedKey);
// → { client, keypair, address, config, network }
// hydrateWorldConfig():从链上读取 world 配置(ObjectRegistry、AdminACL 等对象 ID)
await hydrateWorldConfig(ctx);
// 之后可通过 ctx.config 访问所有 world 对象 ID
关键工具
utils/
├── helper.ts # 环境配置、上下文初始化、world 配置读取
├── config.ts # Network 类型、WorldConfig 接口、RPC URL 映射
├── constants.ts # TENANT、ITEM_A_TYPE_ID 等常量
├── derive-object-id.ts # 从 game item_id 推导 Sui 对象 ID(deterministic)
└── proof.ts # 生成 LocationProof(用于位置验证测试)
helper.ts 为什么既重要又危险?
因为它天然会变成所有脚本都依赖的中心文件。
重要在于:
- 它统一了网络、客户端、配置读取
- 它降低了重复代码
危险在于:
- 它很容易无限膨胀
- 最后把一堆业务判断也吸进去
所以更稳的原则是:helper.ts 只做“公共基础设施”,不要做“具体业务策略”。
5. resolve-extension-ids.ts:自动查询对象 ID
// 不需要手动查询对象 ID!脚本会自动从链上查找 AdminCap 和 ExtensionConfig
export async function resolveSmartGateExtensionIds(client, ownerAddress) {
// 查找属于 ownerAddress 的 AdminCap 对象
const adminCapId = await findObjectByType(
client,
ownerAddress,
`${builderPackageId}::config::AdminCap`,
);
// 查找共享的 ExtensionConfig 对象
const extensionConfigId = await findSharedObjectByType(
client,
`${builderPackageId}::config::ExtensionConfig`,
);
return { builderPackageId, adminCapId, extensionConfigId };
}
6. 为自定义合约添加脚本
以第6章的 toll_gate 为例,添加一个 configure-toll.ts:
// ts-scripts/smart_gate/configure-toll.ts
import "dotenv/config";
import { Transaction } from "@mysten/sui/transactions";
import { getEnvConfig, initializeContext, hydrateWorldConfig } from "../utils/helper";
import { resolveSmartGateExtensionIds } from "./extension-ids";
async function main() {
const env = getEnvConfig();
const ctx = initializeContext(env.network, env.adminExportedKey);
await hydrateWorldConfig(ctx);
const { client, keypair } = ctx;
const { builderPackageId, adminCapId, extensionConfigId } =
await resolveSmartGateExtensionIds(client, ctx.address);
const tx = new Transaction();
tx.moveCall({
target: `${builderPackageId}::toll_gate::set_toll_config`,
arguments: [
tx.object(extensionConfigId),
tx.object(adminCapId),
tx.pure.u64(1_000_000_000), // 通行费:1 SUI = 10^9 MIST
tx.pure.u64(3600000), // 有效期 1 小时
],
});
const result = await client.signAndExecuteTransaction({
transaction: tx,
signer: keypair,
options: { showEffects: true },
});
console.log("Toll config set! Digest:", result.digest);
}
main();
然后在 package.json 中添加:
"scripts": {
"configure-toll": "tsx ts-scripts/smart_gate/configure-toll.ts"
}
7. dApp 模板:快速上手
cd dapps
pnpm install
cp .envsample .env # 填写 VITE_ITEM_ID 等变量
pnpm dev # 启动开发服务器:http://localhost:5173
技术栈
| 库 | 版本 | 用途 |
|---|---|---|
| React + TypeScript | 18 | UI 框架 |
| Vite | 5 | 构建工具 |
| Radix UI | 1 | UI 组件库 |
@evefrontier/dapp-kit | latest | EVE Frontier 专用 SDK |
@mysten/dapp-kit-react | latest | Sui 钱包连接 |
Provider 架构(main.tsx)
// src/main.tsx
ReactDOM.createRoot(document.getElementById("root")!).render(
<EveFrontierProvider queryClient={queryClient}>
{/* 一个 Provider 组合了所有必要的 Context */}
{/* QueryClientProvider → DAppKitProvider → VaultProvider → SmartObjectProvider → NotificationProvider */}
<App />
</EveFrontierProvider>,
);
8. 核心 Hooks 速查
钱包连接(App.tsx)
import { abbreviateAddress, useConnection } from "@evefrontier/dapp-kit";
import { useCurrentAccount } from "@mysten/dapp-kit-react";
// 连接/断开钱包
const { handleConnect, handleDisconnect, isConnected, walletAddress } = useConnection();
// 读取当前账户
const account = useCurrentAccount();
// 显示缩短的地址(如 0x1234...5678)
<span>{abbreviateAddress(account?.address ?? "")}</span>
读取 Smart Object(Assembly Data)
import { useSmartObject } from "@evefrontier/dapp-kit";
// 传入游戏内的 item_id(从 URL 参数或 env 读取)
const { assembly, character, loading, error, refetch } = useSmartObject({
itemId: VITE_ITEM_ID,
});
// assembly 包含:name, typeId, state, id, owner character
// character 包含:持有者角色信息
执行交易(WalletStatus.tsx)
import { useDAppKit } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
const { signAndExecuteTransaction } = useDAppKit();
async function callMyContract() {
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::tribe_permit::issue_jump_permit`,
arguments: [/* ... */],
});
const result = await signAndExecuteTransaction({ transaction: tx });
await refetch(); // 刷新 assembly 状态
}
9. 实战:在 dApp 中颁发部族通行证
// src/components/IssuePermit.tsx
import { useSmartObject, useConnection } from "@evefrontier/dapp-kit";
import { useDAppKit } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
export function IssuePermit({ gateItemId }: { gateItemId: string }) {
const { assembly } = useSmartObject({ itemId: gateItemId });
const { isConnected } = useConnection();
const { signAndExecuteTransaction } = useDAppKit();
const handleIssuePermit = async () => {
const tx = new Transaction();
tx.moveCall({
target: `${import.meta.env.VITE_BUILDER_PACKAGE_ID}::tribe_permit::issue_jump_permit`,
arguments: [
tx.object(import.meta.env.VITE_EXTENSION_CONFIG_ID),
tx.object(SOURCE_GATE_ID),
tx.object(DEST_GATE_ID),
tx.object(CHARACTER_ID),
tx.object("0x6"), // Clock 对象(Sui 系统对象固定 ID)
],
});
const result = await signAndExecuteTransaction({ transaction: tx });
console.log("JumpPermit 已颁发!", result.digest);
};
return (
<button
onClick={handleIssuePermit}
disabled={!isConnected || !assembly}
>
{assembly ? `申请通过 ${assembly.name}` : "加载中..."}
</button>
);
}
10. 赞助交易(Sponsored TX)
对于想要隐藏 gas 费用的 Builder,dapp-kit 支持赞助交易:
import { useSponsoredTransaction } from "@evefrontier/dapp-kit";
const { sponsoredSignAndExecute } = useSponsoredTransaction();
// 玩家不需要支付 gas——Builder 的服务器替他们支付
await sponsoredSignAndExecute({ transaction: tx });
// 注意:只有 EVE Vault 钱包支持此功能
// 如果用户使用其他钱包,需要 catch WalletSponsoredTransactionNotSupportedError
11. GraphQL 数据查询(高级)
当 useSmartObject 不够用时,可以直接用 GraphQL:
import { executeGraphQLQuery, getAssemblyWithOwner } from "@evefrontier/dapp-kit";
// 查询 Gate 的完整数据(含所有者角色)
const gateData = await getAssemblyWithOwner({ itemId: gateItemId });
// 执行自定义 GraphQL 查询
const result = await executeGraphQLQuery(`
query GetMyGates($owner: SuiAddress!) {
objects(filter: { type: "${PACKAGE_ID}::smart_gate::Gate", owner: $owner }) {
nodes {
address
contents { json }
}
}
}
`, { owner: address });
12. 项目完整搭建流程总结
1. 克隆 builder-scaffold
2. 克隆 world-contracts(docker 用户:在宿主机,容器内自动可见)
3. 选择流程:Docker 或 Host
4. 启动本地链(docker compose run 或 sui start)
5. 发布 world-contracts(参考 docs/builder-flow-docker.md)
6. 编译 smart_gate:sui move build -e testnet
7. 发布 smart_gate:sui client test-publish --pubfile-path ...
8. 填写 .env 文件(BUILDER_PACKAGE_ID + WORLD_PACKAGE_ID + ADMIN_KEY)
9. 运行 pnpm configure-rules → pnpm authorise-gate → pnpm issue-tribe-jump-permit
10. 启动 dApp:cd dapps && pnpm dev
本章小结
| 组件 | 用途 |
|---|---|
configure-rules | 设置 tribe + bounty 配置规则 |
authorise-gate | 将 XAuth 注册到目标 Gate |
issue-tribe-jump-permit | 为符合条件的玩家颁发 JumpPermit |
utils/helper.ts | 环境变量、Sui 客户端、world 配置初始化 |
EveFrontierProvider | 统一包装所有 React Context |
useSmartObject | 读取链上 Assembly 数据的核心 Hook |
useSponsoredTransaction | 为玩家代付 Gas 的赞助交易 |
这两章涵盖了 Builder Scaffold 从本地搭建到合约部署、脚本交互、前端开发的完整链路。结合之前的 World 合约章节,你现在具备了独立构建端到端 EVE Frontier Builder 应用的全部知识。
Chapter 8:赞助交易与服务端集成
目标: 深入理解 EVE Frontier 的赞助交易机制,掌握如何构建后端服务来验证业务逻辑并代玩家支付 Gas,实现无摩擦的游戏体验。
状态:工程章节。正文以赞助交易、服务端校验和链上链下协同为主。
8.1 什么是赞助交易?
在普通的 Sui 交易中,发起者(Sender)和 Gas 付款人(Gas Owner)是同一个人。赞助交易允许这两个角色分离:
普通交易: 玩家签名 + 玩家付 Gas
赞助交易: 玩家签名意图 + 服务器验证 + 服务器付 Gas
对 EVE Frontier 至关重要,因为:
- 某些操作需要游戏服务器验证(如临近性证明、距离检查)
- 降低玩家的入门门槛(不需要提前充值 SUI 做 Gas)
- 实现业务级别的风控:服务器可以拒绝非法请求
这里真正的关键不是“谁替谁付 Gas”这么简单,而是:
赞助交易把一次玩家操作拆成了“用户意图 + 服务端审核 + 链上执行”三段。
这让很多原本很难做的产品体验成为可能:
- 玩家不需要先准备 SUI
- 服务端可以在上链前做业务判断
- 风险控制可以发生在签名前,而不是等资产出事后再补救
但代价也很明确:你的系统不再只是前端 + 合约,而是正式进入“链上链下协同系统”。
8.2 AdminACL:游戏服务器的权限对象
EVE Frontier 通过 AdminACL 共享对象来管理哪些服务器地址被授权作为赞助者:
GovernorCap
└──(管理)AdminACL(共享对象)
└── sponsors: vector<address>
├── 游戏服务器1地址
├── 游戏服务器2地址
└── ...
需要服务器参与的操作(如跳跃)在合约中有类似这样的检查:
public fun verify_sponsor(admin_acl: &AdminACL, ctx: &TxContext) {
// tx_context::sponsor() 返回 Gas 付款人的地址
let sponsor = ctx.sponsor().unwrap(); // 如果没有 sponsor 则 abort
assert!(
vector::contains(&admin_acl.sponsors, &sponsor),
EUnauthorizedSponsor,
);
}
这意味着:即使玩家自己构造了一个合法的交易,如果没有授权服务器签名,调用 jump_with_permit 等函数也会 abort。
AdminACL 真正表达的是什么?
它表达的不是“这个服务器技术上能签名”,而是:
这个服务器被世界规则正式信任,可以为某类敏感动作背书。
这和普通后端服务有本质区别。很多 Web 应用里,后端只是帮你做业务判断;在这里,后端本身还是链上权限模型的一部分。
所以一旦 AdminACL 管理混乱,影响的不是单个接口,而是整条可信链:
- 谁能代付
- 谁能为临近性证明背书
- 谁能发起某些受限动作
8.3 赞助交易的完整流程
玩家 你的后端服务 Sui 网络
│ │ │
│── 1. 构建 Transaction ──►│ │
│ (setSender = 玩家地址) │ │
│ │ │
│◄── 2. 后端验证业务逻辑 ───│ │
│ (检查临近性、余额等) │ │
│ │ │
│── 3. 玩家签名 (Sender) ──►│ │
│ │ │
│ │── 4. 服务器签名 (Gas) ─────►│
│ │ (setGasOwner = 服务器) │
│ │ │
│◄─────────────────────────┼── 5. 交易执行结果 ─────────│
这条链路里每一段分别在防什么?
- 玩家构建交易 防止服务端替用户随意捏造意图
- 后端验证业务逻辑 防止不满足条件的请求直接上链
- 玩家签名 证明这确实是用户授权的动作
- 服务器签名 证明平台愿意为这笔动作代付并背书
四段缺一不可。只要少一段,就会出现典型问题:
- 没有玩家签名:变成平台可代用户乱发
- 没有后端校验:变成谁都能白嫖赞助
- 没有服务器签名:链上受限入口直接失败
8.4 构建简单的后端赞助服务
项目结构
backend/
├── src/
│ ├── server.ts # Express 服务器
│ ├── sponsor.ts # 赞助交易逻辑
│ ├── validators.ts # 业务验证
│ └── config.ts # 配置
└── package.json
sponsor.ts:核心赞助逻辑
// src/sponsor.ts
import { SuiClient } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { fromBase64 } from "@mysten/sui/utils";
const client = new SuiClient({
url: process.env.SUI_RPC_URL ?? "https://fullnode.testnet.sui.io:443",
});
// 服务器签名密钥(安全存储在环境变量中)
const serverKeypair = Ed25519Keypair.fromSecretKey(
fromBase64(process.env.SERVER_PRIVATE_KEY!)
);
export interface SponsoredTxRequest {
txBytes: string; // 玩家构建的交易(base64)
playerSignature: string; // 玩家对 txBytes 的签名(base64)
playerAddress: string;
}
export async function sponsorAndExecute(req: SponsoredTxRequest) {
// 1. 反序列化玩家的交易
const txBytes = fromBase64(req.txBytes);
// 2. 服务器设置 Gas 付款人
// 这会修改交易,使服务器地址成为 Gas 付款人
const tx = Transaction.from(txBytes);
tx.setGasOwner(serverKeypair.getPublicKey().toSuiAddress());
// 3. 服务器签名(作为 Gas 付款人)
const sponsoredBytes = await tx.build({ client });
const serverSig = await serverKeypair.signTransaction(sponsoredBytes);
// 4. 执行:同时提交玩家签名和服务器签名
const result = await client.executeTransactionBlock({
transactionBlock: sponsoredBytes,
signature: [
req.playerSignature, // 玩家作为 Sender 的签名
serverSig.signature, // 服务器作为 Gas Owner 的签名
],
options: { showEvents: true, showEffects: true },
});
return result;
}
服务端在这里最需要防的,不是“请求失败”,而是“请求被滥用”
一个真正可用的赞助服务,至少要考虑这些风控点:
- 同一玩家短时间内重复请求
- 同一交易被重复提交
- 某类高成本操作被批量刷
- 玩家把本不该赞助的交易偷偷塞给服务端
所以在真实项目里,赞助服务通常还会增加:
- 请求频率限制
- 交易白名单或入口白名单
- 每个动作的预算限制
- 请求日志和审计记录
validators.ts:业务验证逻辑
// src/validators.ts
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: process.env.SUI_RPC_URL! });
// 验证临近性(简化版:检查两个组件的游戏坐标是否足够近)
export async function validateProximity(
playerAddress: string,
assemblyId: string,
): Promise<boolean> {
// 在真实场景中,这里会查询游戏服务器或链上的位置哈希
// 此处仅做示例性实现
try {
const assembly = await client.getObject({
id: assemblyId,
options: { showContent: true },
});
// 检查玩家是否在组件附近(游戏物理规则验证)
// 真实实现需要与游戏服务器通信
return true; // 简化
} catch {
return false;
}
}
// 验证玩家是否满足条件(如持有特定 NFT)
export async function validatePlayerCondition(
playerAddress: string,
requiredNftType: string,
): Promise<boolean> {
const objects = await client.getOwnedObjects({
owner: playerAddress,
filter: { StructType: requiredNftType },
});
return objects.data.length > 0;
}
校验逻辑为什么不要和执行逻辑混在一起?
因为这两件事变化速度不同:
- 校验规则会频繁迭代
- 执行链路需要尽量稳定
把它们拆开后,你会得到几个直接好处:
- 风控规则更容易单独更新
- 更容易给不同 action 组合不同验证器
- 更容易做灰度和回放分析
server.ts:REST API 服务器
// src/server.ts
import express from "express";
import { sponsorAndExecute, SponsoredTxRequest } from "./sponsor";
import { validateProximity, validatePlayerCondition } from "./validators";
const app = express();
app.use(express.json());
// 赞助跳跃请求
app.post("/api/sponsor/jump", async (req, res) => {
const { txBytes, playerSignature, playerAddress, gateId } = req.body;
try {
// 1. 验证临近性(玩家必须在星门附近)
const isNear = await validateProximity(playerAddress, gateId);
if (!isNear) {
return res.status(400).json({ error: "玩家不在星门附近" });
}
// 2. 执行赞助交易
const result = await sponsorAndExecute({
txBytes,
playerSignature,
playerAddress,
});
res.json({ success: true, digest: result.digest });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// 赞助通用操作(带自定义验证)
app.post("/api/sponsor/action", async (req, res) => {
const { txBytes, playerSignature, playerAddress, actionType, metadata } = req.body;
try {
// 根据 actionType 做不同验证
switch (actionType) {
case "deposit_ore": {
// 验证是否在存储箱附近
const ok = await validateProximity(playerAddress, metadata.ssuId);
if (!ok) return res.status(400).json({ error: "不在附近" });
break;
}
case "special_gate": {
// 验证是否持有 VIP NFT
const hasNft = await validatePlayerCondition(
playerAddress,
`${process.env.MY_PACKAGE}::vip_pass::VipPass`
);
if (!hasNft) return res.status(403).json({ error: "需要 VIP 通行证" });
break;
}
}
const result = await sponsorAndExecute({ txBytes, playerSignature, playerAddress });
res.json({ success: true, digest: result.digest });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.listen(3001, () => console.log("赞助服务运行在 :3001"));
幂等性是赞助服务最容易被忽略的问题
玩家网络抖动、前端重试、用户狂点按钮,都会导致同一个请求被发多次。
如果你的后端没有幂等设计,就会出现:
- 同一业务请求被重复赞助
- 用户以为点了一次,链上却发了两次
- 预算和统计全部失真
实际项目里,至少应该给每次业务动作一个稳定请求 ID,并在服务端记录“这个请求是否已经处理过”。
8.5 前端配合赞助交易
// src/hooks/useSponsoredAction.ts
import { useWallet } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
import { toBase64 } from "@mysten/sui/utils";
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:3001";
export function useSponsoredAction() {
const wallet = useWallet();
const executeSponsoredJump = async (
tx: Transaction,
gateId: string,
) => {
if (!wallet.currentAccount) throw new Error("请先连接钱包");
const playerAddress = wallet.currentAccount.address;
// 1. 玩家只签名,不提交
const txBytes = await tx.build({ client: suiClient });
const { signature: playerSig } = await wallet.signTransaction({
transaction: tx,
});
// 2. 发送到后端,让服务器验证并代付 Gas
const response = await fetch(`${BACKEND_URL}/api/sponsor/jump`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
txBytes: toBase64(txBytes),
playerSignature: playerSig,
playerAddress,
gateId,
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(error);
}
return response.json();
};
return { executeSponsoredJump };
}
8.6 赞助交易的安全考量
| 风险 | 防御措施 |
|---|---|
| 服务器私钥泄露 | 使用 HSM 或 KMS 存储私钥;定期轮换 |
| 恶意玩家重放交易 | Sui 的 TransactionDigest 是唯一的,无法重放 |
| DDoS 攻击后端 | Rate limiting + IP 封锁 + 要求玩家 auth |
| 绕过验证直接提交 | 链上合约的 verify_sponsor 强制要求授权地址 |
| Gas 费耗尽 | 监控服务器账户余额,设置告警阈值 |
8.7 @evefrontier/dapp-kit 内置赞助支持
官方 SDK 已内置对赞助交易的支持:
import { signAndExecuteSponsoredTransaction } from "@evefrontier/dapp-kit";
// SDK 会自动与 EVE Frontier 后端通信完成赞助
const result = await signAndExecuteSponsoredTransaction({
transaction: tx,
// 无需手动处理签名和后端通信
});
适用场景:官方游戏操作(如组件上/下线、仓库转移)通常可以用官方赞助服务。
需要自建后端:当你的扩展合约需要自定义业务验证时(如检查 NFT 持有、游戏内条件),需要部署自己的赞助服务。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 赞助交易本质 | Sender(玩家)与 Gas Owner(服务器)分离 |
| AdminACL | 游戏合约验证 ctx.sponsor() 必须在授权列表 |
| 后端服务职责 | 业务验证 + 服务器签名 + 合并签名提交 |
| 安全要点 | 私钥保护 + Rate Limiting + 合约层兜底 |
| SDK 支持 | signAndExecuteSponsoredTransaction() 处理官方场景 |
📚 延伸阅读
Chapter 9:链下索引与 GraphQL 进阶
目标: 掌握链下数据查询的完整工具链,包括 GraphQL、gRPC、事件订阅和自定义索引器,构建高性能的数据驱动 dApp。
状态:工程章节。正文以 GraphQL、事件和索引器设计为主。
9.1 读写分离原则
EVE Frontier 开发的黄金法则:
写操作(改变链上状态)→ 通过 Transaction 提交 → 消耗 Gas
读操作(查询链上状态)→ 通过 GraphQL/gRPC/SuiClient → 完全免费
设计指导:将所有可能的逻辑移到链下读取,只在真正需要改变状态时才提交交易。
这条原则看起来简单,但它其实决定了你的整个系统成本结构:
- 链上写得越多,Gas 越高、失败面越大
- 链下读得越好,前端越快、交互越轻
所以一个成熟的 Builder 系统,通常不是“什么都往链上塞”,而是明确拆成三层:
- 链上对象 存必须可信的状态
- 链上事件 存发生过的动作
- 链下索引 存前端真正需要消费的视图
如果这三层不拆开,你的前端迟早会变成一堆昂贵又难维护的实时 RPC 调用。
9.2 SuiClient 基础读取
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
// ❶ 读取单个对象
const gate = await client.getObject({
id: "0x...",
options: { showContent: true, showOwner: true, showType: true },
});
console.log(gate.data?.content);
// ❷ 批量读取多个对象(一次请求)
const objects = await client.multiGetObjects({
ids: ["0x...gate1", "0x...gate2", "0x...ssu"],
options: { showContent: true },
});
// ❸ 查询某地址拥有的所有对象
const ownedObjects = await client.getOwnedObjects({
owner: "0xALICE",
filter: { StructType: `${WORLD_PKG}::gate::Gate` },
options: { showContent: true },
});
// ❹ 分页查询(处理大量数据)
let cursor: string | null = null;
const allGates: any[] = [];
do {
const page = await client.getOwnedObjects({
owner: "0xALICE",
cursor,
limit: 50,
});
allGates.push(...page.data);
cursor = page.nextCursor ?? null;
} while (cursor);
SuiClient 最适合做什么?
它最适合:
- 单对象读取
- 小规模批量读取
- 调试和脚本验证
- 前端的轻量查询
它不一定适合直接承担:
- 大规模排行榜
- 跨多类型对象的聚合视图
- 高频复杂筛选
一旦你的查询需求开始出现“排序、聚合、跨对象拼表”,就该考虑上 GraphQL 或自定义索引层了。
9.3 GraphQL 深度使用
Sui 的 GraphQL 接口比 JSON-RPC 更强大,支持复杂过滤、嵌套查询和游标分页。
连接 GraphQL
import { SuiGraphQLClient, graphql } from "@mysten/sui/graphql";
const graphqlClient = new SuiGraphQLClient({
url: "https://graphql.testnet.sui.io/graphql",
});
查询某类型的所有对象
const GET_ALL_GATES = graphql(`
query GetAllGates($type: String!, $after: String) {
objects(filter: { type: $type }, first: 50, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
address
asMoveObject {
contents {
json # 以 JSON 格式返回字段
}
}
}
}
}
`);
async function getAllGates(): Promise<any[]> {
const results: any[] = [];
let after: string | null = null;
do {
const data = await graphqlClient.query({
query: GET_ALL_GATES,
variables: {
type: `${WORLD_PKG}::gate::Gate`,
after,
},
});
const objects = data.data?.objects;
if (!objects) break;
results.push(...objects.nodes.map(n => n.asMoveObject?.contents?.json));
after = objects.pageInfo.hasNextPage ? objects.pageInfo.endCursor : null;
} while (after);
return results;
}
GraphQL 真正的价值,不只是“语法更优雅”
它更重要的价值是让你能按前端视图去组织查询,而不是被 RPC 单对象接口牵着走。
这在实际产品里非常重要,因为页面需要的常常不是“某一个对象原始长什么样”,而是:
- 当前对象 + 关联对象摘要
- 一页列表 + 分页信息
- 多种对象拼成一个 dashboard
GraphQL 也不是万能的
如果你把它当成数据库去无限制拉取,同样会踩坑:
- 查询过大,前端首屏变慢
- 一页塞太多嵌套对象,调试困难
- 复杂查询一变更,前后端一起炸
所以 GraphQL 最好的用法通常是:
- 面向页面拆查询
- 每个查询只服务一类明确视图
- 需要聚合统计时让自定义索引器承担更多责任
查询多个关联对象(嵌套)
// 查询星门及其关联的网络节点信息
const GET_GATE_WITH_NODE = graphql(`
query GetGateWithNode($gateId: SuiAddress!) {
object(address: $gateId) {
address
asMoveObject {
contents { json }
}
}
}
`);
// 批量: 一次查询多个不同类型
const GET_ASSEMBLY_OVERVIEW = graphql(`
query AssemblyOverview($gateId: SuiAddress!, $ssuId: SuiAddress!) {
gate: object(address: $gateId) {
asMoveObject { contents { json } }
}
ssu: object(address: $ssuId) {
asMoveObject { contents { json } }
}
}
`);
按动态字段查询(Table 内容)
// 查询 Market 的 listings Table 中特定条目
const GET_LISTING = graphql(`
query GetListing($marketId: SuiAddress!, $typeId: String!) {
object(address: $marketId) {
dynamicField(name: { type: "u64", bcs: $typeId }) {
value {
... on MoveValue {
json
}
}
}
}
}
`);
为什么动态字段查询会比普通对象字段更麻烦?
因为动态字段天然更接近“运行时长出来的索引结构”,而不是固定 schema。
这意味着:
- 你必须非常清楚 key 的编码方式
- 前端和索引层必须用同一套 key 规则
- 一旦 key 设计变了,读路径会整体失效
所以动态字段的设计,不只是合约内部问题,它会直接外溢到查询和前端层。
9.4 事件实时订阅
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
// 订阅特定包的所有事件
const unsubscribe = await client.subscribeEvent({
filter: { Package: MY_PACKAGE },
onMessage: (event) => {
switch (event.type) {
case `${MY_PACKAGE}::toll_gate_ext::GateJumped`:
handleGateJump(event.parsedJson);
break;
case `${MY_PACKAGE}::market::ItemSold`:
handleItemSold(event.parsedJson);
break;
}
},
});
// 90 秒后取消订阅
setTimeout(unsubscribe, 90_000);
// 查询历史事件(带过滤)
const history = await client.queryEvents({
query: {
And: [
{ MoveEventType: `${MY_PACKAGE}::toll_gate_ext::GateJumped` },
{ Sender: "0xPlayerAddress..." },
],
},
order: "descending",
limit: 100,
});
事件订阅最适合解决什么问题?
最适合:
- 实时通知
- 活动流
- 轻量增量更新
- 索引器消费新交易
不适合直接当成:
- 当前态唯一来源
- 完整业务列表接口
- 高可靠历史数据库
因为事件流天然有两个现实问题:
- 你可能会掉线、漏消息
- 你总得有一套历史回补机制
所以成熟索引器通常都是:
- 先回放历史
- 再订阅增量
- 定期做一致性校验
9.5 gRPC:高吞吐量数据流
对于需要处理大量实时数据的场景(如排行榜、全网状态快照),gRPC 比 GraphQL 更高效:
// 使用 gRPC 流式读取最新 Checkpoints
import { SuiHTTPTransport } from "@mysten/sui/client";
// gRPC 适合监控整个链的状态变化
// 例如:每个 Checkpoint 包含该期间内所有交易的摘要
// 高级用法:构建自定义索引器时使用
什么时候值得上 gRPC,而不是继续堆 RPC / GraphQL?
当你开始遇到这些场景时:
- 需要长期消费 checkpoint
- 需要自己维护一套近实时索引
- 需要高吞吐、低延迟的链上数据流
如果你只是做一个普通 dApp 页面,通常没必要一开始就上 gRPC。它更像“基础设施建设工具”,不是页面查询工具。
9.6 构建自定义链下索引器
对于复杂的查询需求(如排行榜、聚合统计),可以构建自己的索引服务:
// server/indexer.ts
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: process.env.SUI_RPC! });
// 内存索引(小规模;生产环境用 Redis 或 PostgreSQL)
const jumpLeaderboard = new Map<string, number>(); // address → jump count
// 启动索引:监听事件并更新本地状态
async function startIndexer() {
console.log("索引器启动...");
// 先载入历史数据
await loadHistoricalEvents();
// 然后订阅新事件
await client.subscribeEvent({
filter: { Package: MY_PACKAGE },
onMessage: (event) => {
if (event.type.includes("GateJumped")) {
const { character_id } = event.parsedJson as any;
const count = jumpLeaderboard.get(character_id) ?? 0;
jumpLeaderboard.set(character_id, count + 1);
}
},
});
}
async function loadHistoricalEvents() {
let cursor = null;
do {
const page = await client.queryEvents({
query: { MoveEventType: `${MY_PACKAGE}::toll_gate_ext::GateJumped` },
cursor,
limit: 200,
});
for (const event of page.data) {
const { character_id } = event.parsedJson as any;
const count = jumpLeaderboard.get(character_id) ?? 0;
jumpLeaderboard.set(character_id, count + 1);
}
cursor = page.nextCursor;
} while (cursor && !cursor.startsWith("0x00")); // 简化终止条件
}
// API:提供排行榜数据
import express from "express";
const app = express();
app.get("/api/leaderboard", (req, res) => {
const sorted = [...jumpLeaderboard.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([address, count], rank) => ({ rank: rank + 1, address, count }));
res.json(sorted);
});
startIndexer().then(() => app.listen(3002));
9.7 在 dApp 中高效展示链上数据
使用 React Query 缓存与自动刷新
// src/hooks/useLeaderboard.ts
import { useQuery } from "@tanstack/react-query";
export function useLeaderboard() {
return useQuery({
queryKey: ["leaderboard"],
queryFn: async () => {
const res = await fetch("/api/leaderboard");
return res.json();
},
refetchInterval: 30_000, // 每 30 秒刷新
staleTime: 25_000, // 25 秒内不重新请求
});
}
// 使用
function Leaderboard() {
const { data, isLoading } = useLeaderboard();
return (
<table>
<thead><tr><th>#</th><th>玩家</th><th>跳跃次数</th></tr></thead>
<tbody>
{data?.map(({ rank, address, count }) => (
<tr key={address}>
<td>{rank}</td>
<td>{address.slice(0, 8)}...</td>
<td>{count}</td>
</tr>
))}
</tbody>
</table>
);
}
🔖 本章小结
| 工具 | 场景 | 特点 |
|---|---|---|
SuiClient.getObject() | 读取单个/多个对象 | 简单直接 |
GraphQL | 复杂过滤、嵌套查询 | 灵活,TypeScript 类型生成 |
subscribeEvent | 实时事件推送 | WebSocket,适合 dApp |
queryEvents | 历史事件分页查询 | 适合数据分析 |
| 自定义索引器 | 复杂聚合、排行榜 | 全控制,需要自己维护 |
📚 延伸阅读
第10章:EVE Vault 与 dApp 集成实战
学习目标:掌握在 Builder dApp 中接入 EVE Vault 的完整流程——账户发现、连接、签名交易、赞助交易,以及处理 zkLogin 特有的 Epoch 刷新和断连情况。
状态:教学示例。正文 API 说明以当前依赖版本与本仓库示例 dApp 为准,实际接入时需以本地包版本核对。
最小调用链
dApp Provider 初始化 -> useConnection 发现钱包 -> 构建 PTB -> EVE Vault 审批/签名 -> 链上执行 -> dApp 刷新对象状态
钱包能力矩阵
| 能力 | 普通 Wallet Standard 钱包 | EVE Vault |
|---|---|---|
| 发现与连接 | 支持 | 支持 |
| 普通交易签名 | 支持 | 支持 |
| Sponsored Tx | 通常不支持 | 支持 |
| zkLogin / Epoch 处理 | 依赖钱包实现 | 内建处理 |
| 游戏内浮层联动 | 通常没有 | 可与 EVE Frontier 场景配合 |
这张表的作用不是做宣传,而是提醒你:接入层必须先探测钱包能力,再决定是否展示赞助交易入口。
这章真正该建立的意识是:
钱包接入不是“连上就行”,而是要按钱包能力差异设计完整交互降级路径。
也就是说,你的 dApp 不能假设所有钱包都等价。
异常处理顺序
当用户反馈“钱包能连上,但交易发不出去”时,建议按这个顺序排查:
- 先确认当前钱包是否支持 Sponsored Tx
- 再确认网络、package id、对象 ID 是否一致
- 然后确认 zkLogin 证明是否过期、
maxEpoch是否需要刷新 - 最后再看前端是否正确处理了断连和重连后的状态恢复
对应代码目录
1. dApp 集成概述
因为 EVE Vault 实现了完整的 Sui Wallet Standard,任何使用 @mysten/dapp-kit 或 @evefrontier/dapp-kit 的 dApp 可以零配置地发现并连接 EVE Vault。
同时,EVE Vault 还实现了 EVE Frontier 专有的 赞助交易扩展,让 Builder 可以替玩家支付 Gas。
所以接入层通常至少要回答三件事:
- 当前有没有钱包
- 当前是不是 EVE Vault
- 当前操作需不需要 Sponsored Tx 能力
2. 安装依赖
# EVE Frontier 专用 SDK(推荐,包含 EVE Vault 赞助交易支持)
npm install @evefrontier/dapp-kit
# 或 Mysten 官方 SDK(基础 Wallet Standard,不含赞助交易)
npm install @mysten/dapp-kit
3. Provider 配置
// src/main.tsx
import { EveFrontierProvider } from "@evefrontier/dapp-kit";
import { QueryClient } from "@tanstack/react-query";
import ReactDOM from "react-dom/client";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<EveFrontierProvider queryClient={queryClient}>
<App />
</EveFrontierProvider>,
);
EveFrontierProvider 自动初始化:
- QueryClientProvider(React Query)
- DAppKitProvider(Sui 客户端 + Wallet)
- VaultProvider(EVE Vault 连接状态)
- SmartObjectProvider(游戏对象 GraphQL 查询)
- NotificationProvider(链上操作通知)
Provider 这里最关键的不是“包了多少层”,而是能力顺序。
你后面的连接、签名、对象查询和通知体验,都依赖这层初始化顺序正确。
4. 连接钱包
import { useConnection, abbreviateAddress } from "@evefrontier/dapp-kit";
import { useCurrentAccount } from "@mysten/dapp-kit-react";
function ConnectButton() {
const { handleConnect, handleDisconnect, isConnected, walletAddress, hasEveVault } = useConnection();
const account = useCurrentAccount();
if (!isConnected) {
return (
<div>
<button onClick={handleConnect}>连接 EVE Vault</button>
{!hasEveVault && (
<p style={{ color: "orange" }}>
请先安装 <a href="https://github.com/evefrontier/evevault/releases/latest/download/eve-vault-chrome.zip">EVE Vault 扩展</a>
</p>
)}
</div>
);
}
return (
<div>
<span>已连接:{abbreviateAddress(account?.address ?? "")}</span>
<button onClick={handleDisconnect}>断开</button>
</div>
);
}
hasEveVault 的意义
hasEveVault 为 true 时表示 EVE Vault 扩展已安装且在钱包列表中被发现。这让你可以给未安装的用户提供下载链接引导。
连接流程里最容易忽视的问题,不是“按钮能不能点亮”,而是连上之后页面有没有立刻切到正确状态:
- 当前地址是否刷新
- 需要的对象查询是否重新拉取
- 依赖钱包能力的按钮是否切换显示
5. 发送交易(普通签名)
import { useDAppKit } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
import { useConnection } from "@evefrontier/dapp-kit";
function SendTxButton() {
const { signAndExecuteTransaction } = useDAppKit();
const { isConnected } = useConnection();
const handleSend = async () => {
const tx = new Transaction();
// 调用 Builder 合约
tx.moveCall({
target: `${PACKAGE_ID}::tribe_permit::issue_jump_permit`,
arguments: [
tx.object(EXTENSION_CONFIG_ID),
tx.object(SOURCE_GATE_ID),
tx.object(DEST_GATE_ID),
tx.object(CHARACTER_ID),
tx.object("0x6"), // Sui Clock(固定对象 ID)
],
});
try {
const result = await signAndExecuteTransaction({ transaction: tx });
console.log("交易成功,Digest:", result.digest);
} catch (err) {
// EVE Vault 审批弹窗被用户关闭
if (err.message?.includes("User rejected")) {
alert("交易被用户取消");
}
}
};
return <button onClick={handleSend} disabled={!isConnected}>颁发通行证</button>;
}
普通签名流程的关键不是代码能调用,而是用户能理解自己正在签什么。
所以交易按钮前最好就把:
- 目标对象
- 关键成本
- 预期结果
尽量讲清楚,而不是把一切都留给钱包审批页。
6. 赞助交易(Sponsored TX)——最重要的特性
EVE Vault 是唯一实现了 sign_sponsored_transaction 的 Sui 钱包。这意味着 Builder 的服务器可以替玩家支付 Gas,玩家不需要持有 SUI 才能使用 dApp。
import { useSponsoredTransaction, WalletSponsoredTransactionNotSupportedError } from "@evefrontier/dapp-kit";
import { Transaction } from "@mysten/sui/transactions";
function SponsoredTxButton() {
const { sponsoredSignAndExecute } = useSponsoredTransaction();
const handleSponsoredTx = async () => {
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::my_extension::some_action`,
arguments: [/* ... */],
});
try {
// 玩家签名,Gas 由 Builder 服务器赞助
const result = await sponsoredSignAndExecute({ transaction: tx });
console.log("赞助交易成功!", result.digest);
} catch (err) {
if (err instanceof WalletSponsoredTransactionNotSupportedError) {
// 用户使用的不是 EVE Vault,降级到普通交易
console.warn("当前钱包不支持赞助交易,请使用 EVE Vault");
// 可以 fallback 到 signAndExecuteTransaction
}
}
};
return <button onClick={handleSponsoredTx}>免 Gas 操作(EVE Vault 赞助)</button>;
}
Builder 服务器端的赞助配置
赞助交易需要 Builder 在服务器端配置一个 Gas 赞助者账户:
// Builder 后端(Node.js)
import { SuiClient } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
const sponsorKeypair = Ed25519Keypair.fromSecretKey(SPONSOR_PRIVATE_KEY);
// 接收玩家的 PTB,添加 Gas 并签名返回
app.post("/sponsor-tx", async (req, res) => {
const { serializedTx } = req.body;
const tx = Transaction.from(serializedTx);
// 设置 Gas 赞助者
tx.setSender(playerAddress);
tx.setGasOwner(sponsorKeypair.getPublicKey().toSuiAddress());
const sponsorSignature = await tx.sign({ signer: sponsorKeypair, client });
res.json({ sponsorSignature, serializedTx: tx.serialize() });
});
Sponsored Tx 的接入重点不是“省 Gas”,而是“前后端协同”。
它要求至少三层都配合正确:
- 前端能识别钱包能力
- 后端能正确补 Gas 和签名
- 钱包能完成对应审批流程
只要其中一层口径不一致,用户看到的就会是“能连上,但怎么都发不出去”。
7. 读取游戏对象(Smart Object)
import { useSmartObject } from "@evefrontier/dapp-kit";
function GateStatus({ gateItemId }: { gateItemId: string }) {
const { assembly, character, loading, error, refetch } = useSmartObject({
itemId: gateItemId,
});
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message}</div>;
if (!assembly) return <div>未找到 Gate</div>;
return (
<div>
<h2>{assembly.name}</h2>
<p>类型 ID: {assembly.typeId}</p>
<p>状态: {assembly.state}</p>
<p>所有者: {character?.name ?? "未知"}</p>
<button onClick={refetch}>刷新</button>
</div>
);
}
8. zkLogin Epoch 刷新处理
zkLogin 的临时密钥对绑定到 Sui Epoch(约 24 小时)。当 Epoch 过期时,需要重新生成密钥和 ZK Proof:
import { useConnection } from "@evefrontier/dapp-kit";
import { useDAppKit } from "@mysten/dapp-kit-react";
function TransactionButton() {
const { isConnected, walletAddress } = useConnection();
const { signAndExecuteTransaction } = useDAppKit();
const handleTransaction = async () => {
const tx = new Transaction();
// ...构建交易...
try {
await signAndExecuteTransaction({ transaction: tx });
} catch (err) {
const errMsg = err?.message ?? "";
if (errMsg.includes("ZK proof") || errMsg.includes("maxEpoch")) {
// Epoch 已过期,ZK Proof 无效
// EVE Vault 会自动弹出重新验证的引导
alert("您的登录已过期,请在 EVE Vault 中刷新登录状态");
} else if (errMsg.includes("User rejected")) {
// 用户在审批页面取消了交易
console.log("用户取消了操作");
} else {
console.error("交易失败:", errMsg);
}
}
};
return <button onClick={handleTransaction} disabled={!isConnected}>执行操作</button>;
}
9. 监听网络切换
EVE Vault 支持用户在 Devnet/Testnet 之间切换。dApp 需要响应这个变化:
import { useCurrentAccount } from "@mysten/dapp-kit-react";
import { useEffect } from "react";
function NetworkAwareComponent() {
const account = useCurrentAccount();
useEffect(() => {
if (!account) return;
// account.chains 包含当前钱包支持的链
const currentChain = account.chains[0]; // "sui:testnet" 或 "sui:devnet"
console.log("当前网络:", currentChain);
// 根据网络切换 API 端点或合约地址
}, [account]);
// ...
}
10. 消息签名(Personal Message)
import { useDAppKit } from "@mysten/dapp-kit-react";
import { toBase64 } from "@mysten/sui/utils";
function SignMessageButton() {
const { signPersonalMessage } = useDAppKit();
const handleSign = async () => {
const message = new TextEncoder().encode("EVE Frontier Builder Auth: " + Date.now());
const { bytes, signature } = await signPersonalMessage({
message,
});
console.log("消息签名:", signature);
// 可将 signature 发给服务器验证用户身份(link game account to builder system)
};
return <button onClick={handleSign}>用 EVE Vault 签名验证身份</button>;
}
11. 完整示例:Gate Extension dApp
以下是一个将所有功能整合的最小完整示例:
// src/App.tsx
import { useConnection, useSmartObject, abbreviateAddress } from "@evefrontier/dapp-kit";
import { useDAppKit } from "@mysten/dapp-kit-react";
import { useSponsoredTransaction } from "@evefrontier/dapp-kit";
import { Transaction } from "@mysten/sui/transactions";
const GATE_ITEM_ID = import.meta.env.VITE_GATE_ITEM_ID;
const PACKAGE_ID = import.meta.env.VITE_BUILDER_PACKAGE_ID;
const EXTENSION_CONFIG_ID = import.meta.env.VITE_EXTENSION_CONFIG_ID;
export function App() {
const { handleConnect, handleDisconnect, isConnected, hasEveVault } = useConnection();
const { assembly, loading } = useSmartObject({ itemId: GATE_ITEM_ID });
const { signAndExecuteTransaction } = useDAppKit();
const { sponsoredSignAndExecute } = useSponsoredTransaction();
const requestJumpPermit = async () => {
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::tribe_permit::issue_jump_permit`,
arguments: [tx.object(EXTENSION_CONFIG_ID), /* ... */],
});
await signAndExecuteTransaction({ transaction: tx });
};
const requestFreeJump = async () => {
// 赞助交易版本(Builder 付 Gas)
const tx = new Transaction();
tx.moveCall({ /* 同上 */ });
await sponsoredSignAndExecute({ transaction: tx });
};
return (
<div>
{/* 顶栏 */}
<header>
<h1>Star Gate Manager</h1>
<button onClick={isConnected ? handleDisconnect : handleConnect}>
{isConnected ? "断开钱包" : "连接 EVE Vault"}
</button>
</header>
{/* Gate 状态卡片 */}
{!loading && assembly && (
<div>
<h2>{assembly.name}</h2>
<p>当前状态: {assembly.state}</p>
</div>
)}
{/* 操作按钮 */}
{isConnected && (
<div>
<button onClick={requestJumpPermit}>申请通行证(自付 Gas)</button>
<button onClick={requestFreeJump}>免费申请(赞助交易)</button>
</div>
)}
{/* EVE Vault 未安装提示 */}
{!hasEveVault && (
<div style={{ background: "#fff3cd", padding: 12, borderRadius: 8 }}>
⚠️ 请安装{" "}
<a href="https://github.com/evefrontier/evevault/releases/latest/download/eve-vault-chrome.zip">
EVE Vault 扩展
</a>{" "}
以连接您的 EVE Frontier 账户
</div>
)}
</div>
);
}
12. 常见集成问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
WalletSponsoredTransactionNotSupportedError | 用户使用非 EVE Vault 钱包 | Catch 错误,降级为普通交易 |
| 审批弹窗不出现 | Chrome 拦截了弹窗 | 告知用户检查右上角的拦截提示 |
maxEpoch exceeded | ZK Proof 过期 | 提示用户在 EVE Vault 弹窗中刷新 |
hasEveVault = false | 扩展未安装或未激活 | 展示下载链接和安装指引 |
| 网络不匹配 | dApp 期望 testnet,钱包连 devnet | 监听 account.chains,提示用户切换网络 |
本章小结
| 功能 | API |
|---|---|
| 检测钱包安装 | useConnection().hasEveVault |
| 连接/断开 | handleConnect / handleDisconnect |
| 普通交易 | useDAppKit().signAndExecuteTransaction |
| 赞助交易 | useSponsoredTransaction().sponsoredSignAndExecute |
| 消息签名 | useDAppKit().signPersonalMessage |
| 读取游戏对象 | useSmartObject({ itemId }) |
| 监听网络切换 | useCurrentAccount().chains |
延伸阅读
你现在掌握了 EVE Frontier Builder 课程的完整知识体系:从 Move 2024 基础到 World 合约深度解析,从 Builder Scaffold 工程实践到 EVE Vault 钱包集成。是时候在星际中留下你的印记了。
实战案例 4:任务解锁系统(链上任务 + 条件星门)
目标: 构建一套链上任务系统:玩家完成指定任务后,链上记录完成状态;星门扩展读取任务状态,只允许完成任务的玩家跃迁。同时提供任务发布和验证的 dApp。
状态:已映射到本地代码目录。正文以任务状态和条件星门解耦为核心,适合做权限型玩法入口。
对应代码目录
最小调用链
注册任务 -> 玩家完成任务 -> 链上记录状态 -> 星门读取任务状态 -> 放行或拒绝
需求分析
场景: 你运营着一个星门,通向一个高价值矿区。想要进入的玩家必须先完成一系列“入会考核“:
- 📋 任务一:向你的存储箱捐献 100 单位矿石(链上可验证)
- 🔑 任务二:获得联盟 Leader 的链上签发认证
- 🚪 完成所有任务 → 可以通过星门进入矿区
设计特点:
- 任务状态全部在链上,无法伪造
- 任务系统和星门系统解耦,便于独立升级
- dApp 提供任务进度追踪和一键申请跃迁
第一部分:任务系统合约
quest_registry.move
module quest_system::registry;
use sui::object::{Self, UID, ID};
use sui::table::{Self, Table};
use sui::event;
use sui::tx_context::TxContext;
use sui::transfer;
/// 任务的类型(用 u8 枚举)
const QUEST_DONATE_ORE: u8 = 0;
const QUEST_LEADER_CERT: u8 = 1;
/// 任务完成状态(位标志)
/// bit 0: QUEST_DONATE_ORE 完成
/// bit 1: QUEST_LEADER_CERT 完成
const QUEST_ALL_COMPLETE: u64 = 0b11;
/// 任务注册表(共享对象)
public struct QuestRegistry has key {
id: UID,
gate_id: ID, // 对应哪个星门
completions: Table<address, u64>, // address → 完成标志位
}
/// 任务管理员凭证
public struct QuestAdminCap has key, store {
id: UID,
registry_id: ID,
}
/// 事件
public struct QuestCompleted has copy, drop {
registry_id: ID,
player: address,
quest_type: u8,
all_done: bool,
}
/// 部署:创建任务注册表
public fun create_registry(
gate_id: ID,
ctx: &mut TxContext,
) {
let registry = QuestRegistry {
id: object::new(ctx),
gate_id,
completions: table::new(ctx),
};
let admin_cap = QuestAdminCap {
id: object::new(ctx),
registry_id: object::id(®istry),
};
transfer::share_object(registry);
transfer::transfer(admin_cap, ctx.sender());
}
/// 管理员标记任务完成(由联盟 Leader 或管理脚本调用)
public fun mark_quest_complete(
registry: &mut QuestRegistry,
cap: &QuestAdminCap,
player: address,
quest_type: u8,
ctx: &TxContext,
) {
assert!(cap.registry_id == object::id(registry), ECapMismatch);
// 初始化玩家条目
if !table::contains(®istry.completions, player) {
table::add(&mut registry.completions, player, 0u64);
};
let flags = table::borrow_mut(&mut registry.completions, player);
*flags = *flags | (1u64 << (quest_type as u64));
let all_done = *flags == QUEST_ALL_COMPLETE;
event::emit(QuestCompleted {
registry_id: object::id(registry),
player,
quest_type,
all_done,
});
}
/// 查询玩家是否完成了所有任务
public fun is_all_complete(registry: &QuestRegistry, player: address): bool {
if !table::contains(®istry.completions, player) {
return false
}
*table::borrow(®istry.completions, player) == QUEST_ALL_COMPLETE
}
/// 查询玩家完成了哪些任务
public fun get_completion_flags(registry: &QuestRegistry, player: address): u64 {
if !table::contains(®istry.completions, player) {
return 0
}
*table::borrow(®istry.completions, player)
}
const ECapMismatch: u64 = 0;
quest_gate.move(星门扩展)
module quest_system::quest_gate;
use quest_system::registry::{Self, QuestRegistry};
use world::gate::{Self, Gate};
use world::character::Character;
use sui::clock::Clock;
use sui::tx_context::TxContext;
/// 星门扩展 Witness
public struct QuestGateAuth has drop {}
/// 任务完成后申请跳跃许可
public fun quest_jump(
source_gate: &Gate,
dest_gate: &Gate,
character: &Character,
quest_registry: &QuestRegistry,
clock: &Clock,
ctx: &mut TxContext,
) {
// 验证调用者已完成所有任务
assert!(
registry::is_all_complete(quest_registry, ctx.sender()),
EQuestsNotComplete,
);
// 签发跳跃许可(有效期 30 分钟)
let expires_at = clock.timestamp_ms() + 30 * 60 * 1000;
gate::issue_jump_permit(
source_gate,
dest_gate,
character,
QuestGateAuth {},
expires_at,
ctx,
);
}
const EQuestsNotComplete: u64 = 0;
第二部分:任务验证逻辑(任务一:捐献矿石)
任务一(捐献矿石)需要链下监控 SSU 的存储事件,然后管理员手动(或脚本自动)标记完成。
// scripts/auto-quest-monitor.ts
import { SuiClient } from "@mysten/sui/client"
import { Transaction } from "@mysten/sui/transactions"
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"
const QUEST_PACKAGE = "0x_QUEST_PACKAGE_"
const REGISTRY_ID = "0x_REGISTRY_ID_"
const QUEST_ADMIN_CAP_ID = "0x_QUEST_ADMIN_CAP_"
const STORAGE_UNIT_ID = "0x_SSU_ID_"
const DONATE_ORE_TYPE_ID = 12345 // 矿石物品类型 ID
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" })
const adminKeypair = Ed25519Keypair.fromSecretKey(/* ... */)
// 监听 SSU 的捐献事件
async function monitorDonations() {
await client.subscribeEvent({
filter: {
MoveEventType: `${"0x_WORLD_PACKAGE_"}::storage_unit::ItemDeposited`,
},
onMessage: async (event) => {
const { depositor, storage_unit_id, item_type_id } = event.parsedJson as any
// 检查是否是我们的 SSU 和指定物品
if (
storage_unit_id === STORAGE_UNIT_ID &&
Number(item_type_id) === DONATE_ORE_TYPE_ID
) {
console.log(`玩家 ${depositor} 捐献了矿石,标记任务完成...`)
await markQuestComplete(depositor, 0) // quest_type = 0 (QUEST_DONATE_ORE)
}
},
})
}
async function markQuestComplete(player: string, questType: number) {
const tx = new Transaction()
tx.moveCall({
target: `${QUEST_PACKAGE}::registry::mark_quest_complete`,
arguments: [
tx.object(REGISTRY_ID),
tx.object(QUEST_ADMIN_CAP_ID),
tx.pure.address(player),
tx.pure.u8(questType),
],
})
const result = await client.signAndExecuteTransaction({
signer: adminKeypair,
transaction: tx,
})
console.log(`任务标记成功: ${result.digest}`)
}
monitorDonations()
第三部分:任务追踪 dApp
// src/QuestTrackerApp.tsx
import { useState, useEffect } from 'react'
import { useConnection, getObjectWithJson } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
import { SuiClient } from '@mysten/sui/client'
const QUEST_PACKAGE = "0x_QUEST_PACKAGE_"
const REGISTRY_ID = "0x_REGISTRY_ID_"
const SOURCE_GATE_ID = "0x..."
const DEST_GATE_ID = "0x..."
const CHARACTER_ID = "0x..."
const QUEST_NAMES = [
{ id: 0, name: '捐献矿石', description: '向联盟存储箱存入 100 单位矿石' },
{ id: 1, name: '获得认证', description: '联系联盟 Leader 在链上为你签发认证' },
]
export function QuestTrackerApp() {
const { isConnected, handleConnect, currentAddress } = useConnection()
const dAppKit = useDAppKit()
const [flags, setFlags] = useState<number>(0)
const [isJumping, setIsJumping] = useState(false)
const [status, setStatus] = useState('')
const allComplete = flags === 0b11
// 加载任务完成状态
useEffect(() => {
if (!currentAddress) return
const loadFlags = async () => {
// 通过 GraphQL 读取 table 中的玩家条目
const client = new SuiClient({ url: 'https://fullnode.testnet.sui.io:443' })
const obj = await client.getDynamicFieldObject({
parentId: REGISTRY_ID,
name: {
type: 'address',
value: currentAddress,
},
})
if (obj.data?.content?.dataType === 'moveObject') {
setFlags(Number((obj.data.content.fields as any).value))
} else {
setFlags(0) // 玩家尚未有记录
}
}
loadFlags()
}, [currentAddress])
const handleJump = async () => {
if (!allComplete) {
setStatus('❌ 请先完成所有任务')
return
}
setIsJumping(true)
setStatus('⏳ 申请跳跃许可...')
try {
const tx = new Transaction()
tx.moveCall({
target: `${QUEST_PACKAGE}::quest_gate::quest_jump`,
arguments: [
tx.object(SOURCE_GATE_ID),
tx.object(DEST_GATE_ID),
tx.object(CHARACTER_ID),
tx.object(REGISTRY_ID),
tx.object('0x6'), // Clock
],
})
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('🚀 已获得跳跃许可,享受矿区之旅!')
} catch (e: any) {
setStatus(`❌ ${e.message}`)
} finally {
setIsJumping(false)
}
}
return (
<div className="quest-tracker">
<h1>🌟 联盟入会考核</h1>
{!isConnected ? (
<button onClick={handleConnect}>连接钱包</button>
) : (
<>
<div className="quest-list">
{QUEST_NAMES.map(quest => {
const done = (flags & (1 << quest.id)) !== 0
return (
<div key={quest.id} className={`quest-item ${done ? 'done' : 'pending'}`}>
<span className="quest-icon">{done ? '✅' : '⬜'}</span>
<div>
<strong>{quest.name}</strong>
<p>{quest.description}</p>
</div>
</div>
)
})}
</div>
<div className="progress">
完成进度:{Object.keys(QUEST_NAMES)
.filter(i => (flags & (1 << Number(i))) !== 0).length} / {QUEST_NAMES.length}
</div>
<button
className={`jump-btn ${allComplete ? 'active' : 'locked'}`}
onClick={handleJump}
disabled={!allComplete || isJumping}
>
{allComplete
? (isJumping ? '⏳ 申请中...' : '🚀 进入矿区')
: '🔒 完成所有任务才可进入'
}
</button>
{status && <p className="status">{status}</p>}
</>
)}
</div>
)
}
🎯 完整回顾
合约层
├── quest_registry.move
│ ├── QuestRegistry(共享对象,存储玩家完成标志位)
│ ├── QuestAdminCap(管理员凭证)
│ ├── mark_quest_complete() ← 管理员调用
│ └── is_all_complete() ← 星门合约调用
│
└── quest_gate.move
├── QuestGateAuth(星门扩展 Witness)
└── quest_jump() ← 玩家调用
├── registry::is_all_complete() → 验证任务完成
└── gate::issue_jump_permit() → 发放许可
链下监控
└── auto-quest-monitor.ts
├── 订阅 SSU ItemDeposited 事件
└── 自动调用 mark_quest_complete()
dApp 层
└── QuestTrackerApp.tsx
├── 显示任务进度(位标志解码)
└── 一键申请跳跃许可
🔧 扩展练习
- 任务时效:任务完成后 7 天内有效,过期需重新完成(在标志位旁存储时间戳)
- 链上任务一(不需要链下):玩家主动调用
donate_ore()函数,直接转移物品,合约自动标记任务完成 - 任务积分:每个任务赋予不同积分权重,累计达到阈值才解锁星门
📚 关联文档
实战案例 11:物品租赁系统(出租而非出售)
目标: 构建一个链上物品租赁市场——物品所有者出租而非出售装备,租用者在有效期内拥有使用权,到期后物品自动归还(或可赎回)。
状态:教学示例。正文解释核心业务流,完整目录以本地
book/src/code/example-11/为准。
对应代码目录
最小调用链
创建挂单 -> 用户租用 -> 合约铸造 RentalPass -> 到期或提前归还 -> 资金结算
测试闭环
- 挂单创建:确认
is_available == true,且可被前端正确查询到 - 成功租用:确认租用者收到
RentalPass,出租者收到 70% 租金 - 提前归还:确认退款按剩余天数计算,剩余押金正确流向出租者
- 到期回收:确认未到期时回收失败,到期后回收成功
需求分析
场景: 高级飞船模块价格昂贵,多数玩家买不起,但可以租用:
- 出租者将模块锁入租赁合约,设置日租金和最长租期
- 租用者支付租金,获得临时使用权凭证 NFT(
RentalPass) - 使用权凭证携带到期时间戳,合约在使用时验证是否在有效期内
- 到期后,出租者可以收回模块(或续租)
- 若租用者提前归还,退还剩余天数的租金
第一部分:租赁合约
module rental::equipment_rental;
use sui::object::{Self, UID, ID};
use sui::table::{Self, Table};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::transfer;
use sui::event;
use std::string::String;
// ── 常量 ──────────────────────────────────────────────────
const DAY_MS: u64 = 86_400_000;
// ── 数据结构 ───────────────────────────────────────────────
/// 租赁挂单(锁定物品)
public struct RentalListing has key {
id: UID,
item_id: ID, // 被租赁的物品对象 ID
item_name: String,
owner: address,
daily_rate_sui: u64, // 每天租金(MIST)
max_days: u64, // 最长租期
deposited_balance: Balance<SUI>, // 出租者预存的保证金(可选)
is_available: bool,
current_renter: option::Option<address>,
lease_expires_ms: u64,
}
/// 租用凭证 NFT(租用者持有)
public struct RentalPass has key, store {
id: UID,
listing_id: ID,
item_name: String,
renter: address,
expires_ms: u64,
prepaid_days: u64,
refundable_balance: Balance<SUI>, // 可退还余额(提前归还用)
}
// ── 事件 ──────────────────────────────────────────────────
public struct ItemRented has copy, drop {
listing_id: ID,
renter: address,
days: u64,
total_paid: u64,
expires_ms: u64,
}
public struct ItemReturned has copy, drop {
listing_id: ID,
renter: address,
early: bool,
refund_amount: u64,
}
// ── 出租者操作 ────────────────────────────────────────────
/// 创建租赁挂单
public fun create_listing(
item_name: vector<u8>,
tracked_item_id: ID, // 物品的 Object ID(合约追踪,实际物品在 SSU 中)
daily_rate_sui: u64,
max_days: u64,
ctx: &mut TxContext,
) {
let listing = RentalListing {
id: object::new(ctx),
item_id: tracked_item_id,
item_name: std::string::utf8(item_name),
owner: ctx.sender(),
daily_rate_sui,
max_days,
deposited_balance: balance::zero(),
is_available: true,
current_renter: option::none(),
lease_expires_ms: 0,
};
transfer::share_object(listing);
}
/// 下架(只有在物品未出租时才能撤回)
public fun delist(
listing: &mut RentalListing,
ctx: &TxContext,
) {
assert!(listing.owner == ctx.sender(), ENotOwner);
assert!(listing.is_available, EItemCurrentlyRented);
listing.is_available = false;
}
// ── 租用者操作 ────────────────────────────────────────────
/// 租用物品
public fun rent_item(
listing: &mut RentalListing,
days: u64,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(listing.is_available, ENotAvailable);
assert!(days >= 1 && days <= listing.max_days, EInvalidDays);
let total_cost = listing.daily_rate_sui * days;
assert!(coin::value(&payment) >= total_cost, EInsufficientPayment);
let expires_ms = clock.timestamp_ms() + days * DAY_MS;
// 扣除租金
let rent_payment = payment.split(total_cost, ctx);
// 给出租者发送 70%,剩余 30% 作为押金锁在 RentalPass 中(提前归还时退还)
let owner_share = rent_payment.split(total_cost * 70 / 100, ctx);
transfer::public_transfer(owner_share, listing.owner);
// 更新挂单状态
listing.is_available = false;
listing.current_renter = option::some(ctx.sender());
listing.lease_expires_ms = expires_ms;
// 发放 RentalPass NFT
let pass = RentalPass {
id: object::new(ctx),
listing_id: object::id(listing),
item_name: listing.item_name,
renter: ctx.sender(),
expires_ms,
prepaid_days: days,
refundable_balance: coin::into_balance(rent_payment), // 剩余 30%
};
// 退找零
if coin::value(&payment) > 0 {
transfer::public_transfer(payment, ctx.sender());
} else { coin::destroy_zero(payment); }
transfer::public_transfer(pass, ctx.sender());
event::emit(ItemRented {
listing_id: object::id(listing),
renter: ctx.sender(),
days,
total_paid: total_cost,
expires_ms,
});
}
/// 使用物品时验证租赁是否有效
public fun verify_rental(
pass: &RentalPass,
listing_id: ID,
clock: &Clock,
): bool {
pass.listing_id == listing_id
&& clock.timestamp_ms() <= pass.expires_ms
}
/// 提前归还(退押金)
public fun return_early(
listing: &mut RentalListing,
mut pass: RentalPass,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(pass.listing_id == object::id(listing), EWrongListing);
assert!(pass.renter == ctx.sender(), ENotRenter);
assert!(clock.timestamp_ms() < pass.expires_ms, EAlreadyExpired);
// 计算剩余天数应退款
let remaining_ms = pass.expires_ms - clock.timestamp_ms();
let remaining_days = remaining_ms / DAY_MS;
let refund = if remaining_days > 0 {
balance::value(&pass.refundable_balance) * remaining_days / pass.prepaid_days
} else { 0 };
// 退款
if refund > 0 {
let refund_coin = coin::take(&mut pass.refundable_balance, refund, ctx);
transfer::public_transfer(refund_coin, ctx.sender());
};
// 销毁剩余押金给出租者
let remaining_bal = balance::withdraw_all(&mut pass.refundable_balance);
if balance::value(&remaining_bal) > 0 {
transfer::public_transfer(coin::from_balance(remaining_bal, ctx), listing.owner);
} else { balance::destroy_zero(remaining_bal); }
// 归还 listing 可用性
listing.is_available = true;
listing.current_renter = option::none();
let RentalPass { id, refundable_balance, .. } = pass;
balance::destroy_zero(refundable_balance);
id.delete();
event::emit(ItemReturned {
listing_id: object::id(listing),
renter: ctx.sender(),
early: true,
refund_amount: refund,
});
}
/// 租期到期后,出租者收回控制权
public fun reclaim_after_expiry(
listing: &mut RentalListing,
clock: &Clock,
ctx: &TxContext,
) {
assert!(listing.owner == ctx.sender(), ENotOwner);
assert!(!listing.is_available, EAlreadyAvailable);
assert!(clock.timestamp_ms() > listing.lease_expires_ms, ELeaseNotExpired);
listing.is_available = true;
listing.current_renter = option::none();
}
// ── 错误码 ────────────────────────────────────────────────
const ENotOwner: u64 = 0;
const EItemCurrentlyRented: u64 = 1;
const ENotAvailable: u64 = 2;
const EInvalidDays: u64 = 3;
const EInsufficientPayment: u64 = 4;
const EWrongListing: u64 = 5;
const ENotRenter: u64 = 6;
const EAlreadyExpired: u64 = 7;
const EAlreadyAvailable: u64 = 8;
const ELeaseNotExpired: u64 = 9;
第二部分:租赁市场 dApp
// src/RentalMarket.tsx
import { useState } from 'react'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useQuery } from '@tanstack/react-query'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'
const RENTAL_PKG = "0x_RENTAL_PACKAGE_"
interface Listing {
id: string
item_name: string
owner: string
daily_rate_sui: string
max_days: string
is_available: boolean
lease_expires_ms: string
}
function DaysLeftBadge({ expireMs }: { expireMs: number }) {
const remaining = Math.max(0, expireMs - Date.now())
const days = Math.ceil(remaining / 86400000)
if (days === 0) return <span className="badge badge--expired">已到期</span>
return <span className="badge badge--active">剩余 {days} 天</span>
}
export function RentalMarket() {
const client = useCurrentClient()
const dAppKit = useDAppKit()
const [rentDays, setRentDays] = useState(1)
const [status, setStatus] = useState('')
const { data: listings } = useQuery({
queryKey: ['rental-listings'],
queryFn: async () => {
// 教学示例:直接读取当前挂单对象。
// 真实项目里建议通过 indexer 维护“可租挂单”视图,而不是从租用事件反推列表。
const objects = await client.getOwnedObjects({
owner: '0x_RENTAL_REGISTRY_OWNER_',
filter: { StructType: `${RENTAL_PKG}::equipment_rental::RentalListing` },
options: { showContent: true },
})
return objects.data.map(obj => (obj.data?.content as any)?.fields).filter(Boolean) as Listing[]
},
})
const handleRent = async (listingId: string, dailyRate: number) => {
const tx = new Transaction()
const totalCost = BigInt(dailyRate * rentDays)
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(totalCost)])
tx.moveCall({
target: `${RENTAL_PKG}::equipment_rental::rent_item`,
arguments: [
tx.object(listingId),
tx.pure.u64(rentDays),
payment,
tx.object('0x6'),
],
})
try {
setStatus('⏳ 提交租赁交易...')
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('✅ 租赁成功!RentalPass 已发送到你的钱包')
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
return (
<div className="rental-market">
<h1>🔧 装备租赁市场</h1>
<p className="subtitle">租而不买,灵活使用高端装备</p>
<div className="rent-days-selector">
<label>租期:</label>
{[1, 3, 7, 14, 30].map(d => (
<button
key={d}
className={rentDays === d ? 'selected' : ''}
onClick={() => setRentDays(d)}
>
{d} 天
</button>
))}
</div>
<div className="listings-grid">
{listings?.map(listing => (
<div key={listing.id} className="listing-card">
<h3>{listing.item_name}</h3>
<div className="listing-meta">
<span>💰 {Number(listing.daily_rate_sui) / 1e9} SUI/天</span>
<span>📅 最长 {listing.max_days} 天</span>
</div>
<div className="listing-cost">
租 {rentDays} 天共:<strong>{Number(listing.daily_rate_sui) * rentDays / 1e9} SUI</strong>
</div>
{listing.is_available ? (
<button
className="rent-btn"
onClick={() => handleRent(listing.id, Number(listing.daily_rate_sui))}
>
🤝 立即租用
</button>
) : (
<DaysLeftBadge expireMs={Number(listing.lease_expires_ms)} />
)}
</div>
))}
</div>
{status && <p className="status">{status}</p>}
</div>
)
}
🎯 关键设计亮点
| 机制 | 实现方式 |
|---|---|
| 时效控制 | RentalPass.expires_ms + clock.timestamp_ms() 实时验证 |
| 押金管理 | 30% 租金锁在 RentalPass.refundable_balance |
| 提前归还 | 按剩余天数比例退款,其余归出租者 |
| 到期回收 | reclaim_after_expiry() 由出租者在到期后调用 |
| 防双租 | is_available 标志保证同时只有一个租用者 |
📚 关联文档
Chapter 11:所有权模型深度解析
目标: 深入理解 EVE Frontier 的能力对象体系,掌握 OwnerCap 的完整生命周期,学会设计安全的委托授权和所有权转移方案。
状态:设计进阶章节。正文以 OwnerCap、委托与所有权生命周期为主。
11.1 为什么要有专门的所有权模型?
很多新手在第一次设计权限系统时,直觉都是:
- 记录一个 owner 地址
- 每次操作时检查调用者是不是这个地址
这种方式短期很省事,但一旦进入 EVE Frontier 这类“设施可经营、可转移、可委托、可组合”的世界,很快就会暴露问题:
- 不可委托 你很难安全地把部分权力临时交给别人
- 不可组合 权限规则散在各个函数里,系统越做越乱
- 不可细粒度表达 很难表达“可以操作这个炮塔,但不能操作那个星门”
- 不可自然转移 一旦设施、角色、经营权发生迁移,地址硬编码会变得很脆
EVE Frontier 使用的是 Sui 原生的 Capability 对象体系。它的核心思想不是“看你是谁”,而是:
看你手里拿着哪一个权限对象。
这会让所有权从“账户属性”变成“可组合、可转移、可验证的链上实体”。
11.2 权限层级结构
GovernorCap(部署者持有 — 最高权限)
│
└── AdminACL(共享对象 — 授权的服务器地址列表)
│
└── OwnerCap<T>(玩家持有 — 对特定对象的操作权)
GovernorCap:游戏运营层
GovernorCap 在合约部署时创建,由 CCP Games(游戏运营方)持有。它可以:
- 向
AdminACL添加/删除服务器授权地址 - 执行全局配置更改
作为 Builder,你无需关心 GovernorCap。
AdminACL:服务器授权层
AdminACL 是一个共享对象,包含被授权的游戏服务器地址列表。
某些操作(如临近证明、跃迁验证)需要游戏服务器作为**赞助者(Sponsor)**签署交易:
// 验证调用者是否为授权赞助者
public fun verify_sponsor(admin_acl: &AdminACL, ctx: &TxContext) {
assert!(
admin_acl.sponsors.contains(ctx.sponsor().unwrap()),
EUnauthorizedSponsor
);
}
这意味着:某些敏感操作玩家不能单独完成,必须经过游戏服务器验证。
OwnerCap:玩家操作层
public struct OwnerCap<phantom T> has key {
id: UID,
authorized_object_id: ID, // 只对这一个具体对象有效
}
phantom T 使得 OwnerCap<Gate> 和 OwnerCap<StorageUnit> 是完全不同的类型,无法混用——这是类型系统级别的安全保证。
这三层权限为什么要分开?
你可以把它理解成三种完全不同的职责:
- GovernorCap 解决“世界级规则和全局治理”
- AdminACL 解决“哪些服务器或后端流程被信任”
- OwnerCap 解决“具体哪个经营主体可以操作哪个设施”
把它们拆开最大的好处是:系统不会把“全局治理权”和“单设施操作权”混成一锅。
否则你很容易出现这种糟糕结构:
- 一个地址既是服务器授权者
- 又是所有设施管理员
- 又是某些临时业务的执行者
一旦这个地址出问题,整个系统的权限边界都会塌掉。
11.3 Character 作为钥匙串(Keychain)
玩家的所有 OwnerCap 都存储在 Character 对象中,而不是直接发给钱包地址。
玩家钱包地址
└── Character(共享对象,映射到钱包地址)
├── OwnerCap<NetworkNode> → 网络节点 0x...a1
├── OwnerCap<Gate> → 星门 0x...b2
├── OwnerCap<StorageUnit> → 存储箱 0x...c3
└── OwnerCap<Gate> → 星门 0x...d4(第二个星门)
为什么这样设计?
- 所有资产的所有权集中于 Character,转让 Character 等于转让所有资产
- 即使玩家更换钱包地址,Character 还在,资产不丢失
- 与联盟机制配合,可以实现集体所有权管理
这里要特别注意一件事:
Character 不是简单的钱包映射层,而是一个真正的权限容器。
它把“人、角色、设施、权限”这几个维度组织在了一起:
- 钱包是签名入口
- Character 是经营主体
- OwnerCap 是具体设施权限
- 设施对象是被控制的资产
这样的好处是,当你以后做:
- 账号迁移
- 多签控制
- 联盟托管
- 角色转让
你不需要重写一整套权限系统,而是围绕 Character 这层做变更。
11.4 Borrow-Use-Return 完整模式
执行任何需要 OwnerCap 的操作,都必须遵循「借用 → 使用 → 归还」三步原子事务:
// Character 模块提供的接口
public fun borrow_owner_cap<T: key>(
character: &mut Character,
owner_cap_ticket: Receiving<OwnerCap<T>>, // 使用 Receiving 模式
ctx: &TxContext,
): (OwnerCap<T>, ReturnOwnerCapReceipt) // 返回 Cap + 热土豆收据
public fun return_owner_cap<T: key>(
character: &Character,
owner_cap: OwnerCap<T>,
receipt: ReturnOwnerCapReceipt, // 必须消耗收据
)
ReturnOwnerCapReceipt 是一个热土豆(无 Abilities),确保 OwnerCap 必须被归还,不能在交易外流失。
这个模式真正防的是什么?
它不是单纯为了“写法优雅”,而是在防几类非常真实的风险:
- 高权限对象在交易中途被截留
- 脚本忘记归还权限,留下悬空状态
- 扩展逻辑把权限对象带进了不该到达的路径
- 多步骤操作中,权限边界变得不再可审计
把 borrow -> use -> return 强制收束在同一笔事务里,相当于给高权限操作加了一条硬约束:
你可以临时拿来做事,但不能把它带走。
为什么要配合 Hot Potato Receipt?
因为只靠“开发者自觉调用 return”是不够的。
只要类型系统允许你漏掉归还步骤,迟早会有人:
- 在脚本里忘掉
- 在重构时删掉
- 在错误分支里直接
return
加入 receipt 之后,编译器和类型系统会一起逼你把流程走完。
完整 TypeScript 调用示例
import { Transaction } from "@mysten/sui/transactions";
const WORLD_PKG = "0x...";
async function bringGateOnline(
tx: Transaction,
characterId: string,
ownerCapId: string,
gateId: string,
networkNodeId: string,
) {
// ① 借用 OwnerCap
const [ownerCap, receipt] = tx.moveCall({
target: `${WORLD_PKG}::character::borrow_owner_cap`,
typeArguments: [`${WORLD_PKG}::gate::Gate`],
arguments: [
tx.object(characterId),
tx.receivingRef({ objectId: ownerCapId, version: "...", digest: "..." }),
],
});
// ② 使用 OwnerCap:将星门上线
tx.moveCall({
target: `${WORLD_PKG}::gate::online`,
arguments: [
tx.object(gateId),
tx.object(networkNodeId),
tx.object(ENERGY_CONFIG_ID),
ownerCap,
],
});
// ③ 归还 OwnerCap(receipt 被消耗,热土豆使此步不可跳过)
tx.moveCall({
target: `${WORLD_PKG}::character::return_owner_cap`,
arguments: [tx.object(characterId), ownerCap, receipt],
});
}
11.5 所有权转让场景
场景一:转让单个组件的控制权
如果你想把一个星门的控制权交给盟友(但保留你的 Character 和其他设施),可以只转让对应的 OwnerCap:
// 从你的 Character 取出 OwnerCap,发给盟友
const tx = new Transaction();
// 取出 OwnerCap(注意这里不是借用,而是转移)
// 具体 API 以世界合约为准,此处仅展示思路
tx.moveCall({
target: `${WORLD_PKG}::character::transfer_owner_cap`,
typeArguments: [`${WORLD_PKG}::gate::Gate`],
arguments: [
tx.object(myCharacterId),
tx.object(ownerCapId),
tx.pure.address(allyAddress), // 盟友的 Character 地址
],
});
场景二:转让完整角色(所有资产打包转让)
转移整个 Character 对象,则对应钱包地址即可控制所有绑定资产。适合联盟整体资产交割、账号交易等场景。
这里要区分三件听起来很像、但完全不同的动作:
- 转让单个 OwnerCap 只交出某一个设施的控制权
- 转让 Character 把一整串权限和资产一起交出去
- 委托操作 不转移所有权,只给对方有限操作能力
如果这三件事不分开,你的产品设计会很快乱掉。
比如联盟金库场景:
- 财产权可能属于联盟主体
- 日常操作权可能属于值班成员
- 紧急停机权可能只属于核心管理员
这就要求你不能只用“一个 owner”去表达全部关系。
场景三:委托操作(不转让所有权)
通过编写扩展合约,可以允许特定地址在有限范围内操作你的设施,而无需转让 OwnerCap:
// 在你的扩展合约中,维护一个操作员白名单
public struct OperatorRegistry has key {
id: UID,
operators: Table<address, bool>,
}
public fun delegated_action(
registry: &OperatorRegistry,
ctx: &TxContext,
) {
// 验证调用者在操作员名单中
assert!(registry.operators.contains(ctx.sender()), ENotOperator);
// ... 执行操作
}
委托最容易踩的坑
很多人第一次做委托,会把白名单当成“弱化版所有权”。这是不够的。
一个安全的委托设计,至少要回答:
- 委托人能做哪些动作,不能做哪些动作?
- 委托有没有时间限制?
- 委托能不能撤销?
- 委托是不是只对某一个设施有效?
- 被委托人能不能再次转授?
如果这些边界没有写清,委托就会从“灵活授权”变成“隐形送权”。
11.6 OwnerCap 的安全边界
每个 OwnerCap 只对一个对象有效
public fun verify_owner_cap<T: key>(
obj: &T,
owner_cap: &OwnerCap<T>,
) {
// authorized_object_id 确保这个 OwnerCap 只能用于对应的那个对象
assert!(
owner_cap.authorized_object_id == object::id(obj),
EOwnerCapMismatch
);
}
这意味着如果你有两个星门,就有两个 OwnerCap<Gate>,它们不能互换使用。
为什么 authorized_object_id 这么关键?
因为 phantom T 只解决了“对象类别不能混用”,但还没解决“同类不同实例不能混用”。
例如:
OwnerCap<Gate>只能用于 Gate,没有问题- 但如果没有
authorized_object_id你的一张 Gate 权限就可能错误地操作另一座 Gate
所以完整安全边界其实是两层:
- 类型边界
Gate和StorageUnit不能混 - 实例边界 这座 Gate 和那座 Gate 也不能混
丢失 OwnerCap 意味着失去控制权
如果 OwnerCap 所在的 Character 被转让,你就失去了对所有设施的控制。请妥善保管你的 Character 对象的所有权私钥。
从运营角度看,更准确地说,你要保护的不是“某个按钮权限”,而是整条经营控制链:
- 钱包签名权
- Character 控制权
- Character 内部的 OwnerCap 集合
- 关键委托配置和多签设置
一旦这条链断掉,恢复成本会非常高。
11.7 高级:多签与联盟共有
通过 Sui 的多签(Multisig)功能,可以让一个联盟共同控制关键设施:
# 创建 2/3 多签地址(需要 3 个成员中的 2 个同意才能操作)
sui keytool multi-sig-address \
--pks <pk1> <pk2> <pk3> \
--weights 1 1 1 \
--threshold 2
将 Character 的控制地址设置为多签地址,联盟关键资产就需要多人签名才能操作。
多签适合什么,不适合什么?
多签非常适合:
- 联盟金库
- 超高价值基础设施
- 关键参数调整
- 升级与紧急停机
多签不一定适合:
- 高频日常操作
- 玩家需要秒级响应的交互
- 大量小额重复管理动作
所以现实做法通常不是“全部都上多签”,而是分层:
- 核心控制权放多签
- 日常运营权限通过受限委托释放给执行层
这才更接近真实组织结构。
🔖 本章小结
| 概念 | 核心要点 |
|---|---|
| 权限层级 | GovernorCap > AdminACL > OwnerCap |
| Character 钥匙串 | 所有 OwnerCap 集中存储,转让 Character = 转让所有资产 |
| Borrow-Use-Return | 三步原子操作,ReturnReceipt(热土豆)确保必须归还 |
| 类型安全 | OwnerCap<Gate> ≠ OwnerCap<StorageUnit>,无法混用 |
| 委托操作 | 通过扩展合约 + 白名单实现,无需转让 OwnerCap |
| 多签 | Sui 原生多签地址适合联盟共有资产场景 |
📚 延伸阅读
Chapter 12:Move 进阶 — 泛型、动态字段与事件系统
目标: 掌握 Move 中泛型编程、动态字段存储、Table/VecMap 数据结构和事件系统,能独立设计复杂的链上数据模型。
状态:设计进阶章节。正文以泛型、动态字段、事件和 Table/VecMap 为主。
12.1 泛型(Generics)
泛型让你的代码可以适用于多种类型,同时保持类型安全。这在 EVE Frontier 的 OwnerCap 中被广泛使用。
基础泛型语法
// T 是类型参数,类似其他语言的 <T>
public struct Box<T: store> has key, store {
id: UID,
value: T,
}
// 泛型函数
public fun wrap<T: store>(value: T, ctx: &mut TxContext): Box<T> {
Box { id: object::new(ctx), value }
}
public fun unwrap<T: store>(box: Box<T>): T {
let Box { id, value } = box;
id.delete();
value
}
Phantom 类型参数
phantom T 不真正持有 T 类型的值,只用于类型区分:
// T 没有实际被使用,但创造了类型区分
public struct OwnerCap<phantom T> has key {
id: UID,
authorized_object_id: ID,
}
// 这两个是完全不同的类型,系统不会混淆
let gate_cap: OwnerCap<Gate> = ...;
let ssu_cap: OwnerCap<StorageUnit> = ...;
带约束的泛型
// T 必须同时具有 key 和 store abilities
public fun transfer_to_object<T: key + store, Container: key>(
container: &mut Container,
value: T,
) { ... }
// T 必须具有 copy 和 drop(临时值,不是资产)
public fun log_value<T: copy + drop>(value: T) { ... }
泛型在 Move 里为什么特别重要?
因为 Move 里很多安全设计都不是靠“传一个字符串标识类型”,而是直接把类型本身放进接口里。
这样做的好处是:
- 编译期就能发现类型不匹配
- 权限和对象类别可以被强绑定
- 你不用在运行时手写一大堆脆弱的类型判断
phantom 到底解决了什么?
第一次看 phantom T 很容易觉得它只是语法技巧。其实它解决的是:
“我不需要真的存一个 T,但我需要这个类型身份参与安全边界。”
这在权限对象里特别常见,因为权限真正关心的常常不是数据本体,而是“这张权限卡到底是给谁的”。
什么时候该上泛型,什么时候不要上?
适合用泛型的场景:
- 权限对象
- 通用容器
- 同一套逻辑要服务多个对象类型
- 类型本身承载安全含义
不适合过度泛型化的场景:
- 业务语义已经非常明确
- 只有一两种固定对象类型
- 泛型会让接口阅读成本明显升高
也就是说,泛型不是为了“显得高级”,而是为了把“这套逻辑天然是通用的”表达清楚。
12.2 动态字段(Dynamic Fields)
Sui 有一个强大特性:动态字段(Dynamic Fields),允许在运行时向对象添加任意键值对,不需要在编译期定义所有字段。
为什么需要动态字段?
假设你的存储箱需要支持任意类型的物品,而物品类型在编译时未知:
// ❌ 不灵活的方式:固定字段
public struct Inventory has key {
id: UID,
fuel: Option<u64>,
ore: Option<u64>,
// 新增物品类型就要修改合约...
}
// ✅ 灵活的方式:动态字段
public struct Inventory has key {
id: UID,
// 没有预定义字段,用动态字段存储
}
动态字段 API
use sui::dynamic_field as df;
use sui::dynamic_object_field as dof;
// 添加动态字段(值不是对象类型)
df::add(&mut inventory.id, b"fuel_amount", 1000u64);
// 读取动态字段
let fuel: &u64 = df::borrow(&inventory.id, b"fuel_amount");
let fuel_mut: &mut u64 = df::borrow_mut(&mut inventory.id, b"fuel_amount");
// 检查是否存在
let exists = df::exists_(&inventory.id, b"fuel_amount");
// 移除动态字段
let old_value: u64 = df::remove(&mut inventory.id, b"fuel_amount");
// 动态对象字段(值本身是一个对象,有独立 ObjectID)
dof::add(&mut storage.id, item_type_id, item_object);
let item = dof::borrow<u64, Item>(&storage.id, item_type_id);
let item = dof::remove<u64, Item>(&mut storage.id, item_type_id);
EVE Frontier 中的实际应用
存储单元的 临时仓库(Ephemeral Inventory) 就是用动态字段实现的:
// 为特定角色创建临时仓库(以角色 OwnerCap ID 为 key)
df::add(
&mut storage_unit.id,
owner_cap_id, // 用角色的 OwnerCap ID 作为 key
EphemeralInventory::new(ctx),
);
// 角色访问自己的临时仓库
let my_inventory = df::borrow_mut<ID, EphemeralInventory>(
&mut storage_unit.id,
my_owner_cap_id,
);
动态字段的真正价值
它最大的价值不是“省得改 struct 定义”,而是:
让对象在运行时长出新的子状态,而不必提前把所有槽位写死。
这对游戏型系统尤其关键,因为很多状态是天然开放集合:
- 一个仓库可能容纳很多种物品
- 一个设施可能服务很多个角色
- 一个市场可能有不断新增的挂单
如果都写成固定字段,你的结构会很快失控。
什么时候用 dynamic_field,什么时候用 dynamic_object_field?
一个很实用的判断标准:
- 值只是一个简单值或普通 struct
用
dynamic_field - 值本身也应该是独立对象
用
dynamic_object_field
后者更适合:
- 需要独立对象 ID
- 需要单独转移、引用、删除
- 后续可能被别的逻辑单独操作
动态字段最常见的误区
1. 把它当成“万能数据库”
动态字段很灵活,但不是无限免费。它会带来:
- 更高的读写成本
- 更复杂的索引路径
- 更高的调试难度
2. 键设计过于随意
如果 key 设计不稳定,后面会出现:
- 同一业务实体找不到原来的数据
- 链下和链上的映射规则不一致
- 数据看似写成功,实际读不回来
3. 把频繁遍历的大集合直接塞进去
动态字段适合按 key 定位,不天然适合做高频全量遍历。只要你的业务经常需要“把所有条目扫一遍”,就要开始考虑索引和分页策略。
12.3 Table 与 VecMap:链上集合类型
Table:键值映射
use sui::table::{Self, Table};
public struct Registry has key {
id: UID,
members: Table<address, MemberInfo>,
}
// 添加
table::add(&mut registry.members, member_addr, MemberInfo { ... });
// 查询
let info = table::borrow(®istry.members, member_addr);
let info_mut = table::borrow_mut(&mut registry.members, member_addr);
// 存在检查
let is_member = table::contains(®istry.members, member_addr);
// 移除
let old_info = table::remove(&mut registry.members, member_addr);
// 长度
let count = table::length(®istry.members);
⚠️ 注意:Table 中的每个条目在链上都是一个独立的动态字段,每次访问都有单独的 cost。一个交易内最多访问 1024 个动态字段。
VecMap:小规模有序映射
use sui::vec_map::{Self, VecMap};
// VecMap 存储在对象字段中(不是动态字段),适合小数据集
public struct Config has key {
id: UID,
toll_settings: VecMap<u64, u64>, // zone_id -> toll_amount
}
// 操作
vec_map::insert(&mut config.toll_settings, zone_id, amount);
let amount = vec_map::get(&config.toll_settings, &zone_id);
vec_map::remove(&mut config.toll_settings, &zone_id);
选择建议
| 场景 | 推荐类型 |
|---|---|
| 大规模、动态增长的集合 | Table |
| 小于 100 条、需要遍历 | VecMap 或 vector |
| 以对象为值(有独立 ObjectID) | dynamic_object_field |
| 以简单值为值(u64, bool 等) | dynamic_field |
Table 本质上是什么?
它本质上不是“内存里的哈希表”,而是构建在动态字段之上的链上集合抽象。
所以你在用 Table 时,要始终记得三件事:
- 每次读写都有真实链上成本
- 条目越多,操作和排查越需要策略
- 它更像“可扩展索引结构”,不是随手就能乱用的本地容器
VecMap 为什么适合小规模配置?
因为它把数据直接存在对象字段里,通常更适合:
- 配置项数量少
- 需要整体读取
- 需要按插入顺序或较小规模遍历
典型例子包括:
- 收费档位表
- 小规模白名单
- 模式开关配置
选型时真正该问的问题
不要只问“这个容器能不能存”,而要问:
- 这个集合会增长到多大?
- 我是按 key 精确查,还是经常全量遍历?
- 值是不是独立对象?
- 我未来要不要对它做分页和索引?
这四个问题答清楚,容器选择通常就不会太偏。
12.4 事件系统(Events)
事件是链上合约与链下应用通信的桥梁。事件不存储在链上状态中,但会附在交易记录里,可以被索引器(indexer)捕获。
定义和发射事件
use sui::event;
// 事件结构体:只需要 copy + drop
public struct GateJumped has copy, drop {
gate_id: ID,
character_id: ID,
destination_gate_id: ID,
timestamp_ms: u64,
toll_paid: u64,
}
public struct ItemSold has copy, drop {
storage_unit_id: ID,
seller: address,
buyer: address,
item_type_id: u64,
price: u64,
}
// 在函数中发射事件
public fun process_purchase(
storage_unit: &mut StorageUnit,
buyer: &Character,
payment: Coin<SUI>,
item_type_id: u64,
ctx: &mut TxContext,
): Item {
let price = coin::value(&payment);
// ... 处理购买逻辑 ...
// 发射事件(无 gas 消耗差异,发射是免费的索引记录)
event::emit(ItemSold {
storage_unit_id: object::id(storage_unit),
seller: storage_unit.owner_address,
buyer: ctx.sender(),
item_type_id,
price,
});
// ... 返回物品 ...
}
事件最容易被误解的地方是:
它是“交易发生过什么”的记录,不是“系统当前是什么状态”的真相来源。
这句话非常重要。因为很多前端或索引设计问题,都是从把事件当状态开始的。
事件适合表达什么?
最适合表达:
- 某件事刚刚发生了
- 谁触发了这件事
- 当时的关键参数是什么
- 链下系统应该据此做什么订阅或通知
比如:
- 成交记录
- 跳跃记录
- 理赔触发
- 授权变更
事件不适合独立承担什么?
不适合独立承担:
- 当前库存真相
- 当前对象是否在线
- 当前某个设施的完整业务状态
因为事件天然是时间线,不是当前态快照。
在 TypeScript 中监听事件
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" });
// 查询历史事件
const events = await client.queryEvents({
query: {
MoveEventType: `${MY_PACKAGE}::toll_gate_ext::GateJumped`,
},
limit: 50,
});
events.data.forEach(event => {
const fields = event.parsedJson as {
gate_id: string;
character_id: string;
toll_paid: string;
};
console.log(`跳跃: ${fields.character_id} 支付 ${fields.toll_paid}`);
});
// 实时订阅(WebSocket)
const unsubscribe = await client.subscribeEvent({
filter: { Package: MY_PACKAGE },
onMessage: (event) => {
console.log("新事件:", event.type, event.parsedJson);
},
});
// 停止订阅
setTimeout(() => unsubscribe(), 60_000);
设计事件时,字段要怎么想?
一个好事件通常至少能回答:
- 谁做的
- 对哪个对象做的
- 做了什么
- 关键业务参数是什么
- 链下系统怎样据此定位相关对象
如果字段太少,链下难以消费;字段太多,又会让事件膨胀、语义模糊。
一个很实用的组合原则
成熟的链上系统通常会采用这套组合:
- 对象 存当前态
- 事件 存历史动作
- 索引层 把对象和事件重新组织成前端好用的数据视图
这也是为什么你后面读 GraphQL、索引器和 dApp 章节时,会一直看到“对象查询 + 事件查询”一起出现。
用事件驱动 dApp 实时更新
// src/hooks/useGateEvents.ts
import { useEffect, useState } from 'react'
import { SuiClient } from '@mysten/sui/client'
interface JumpEvent {
gate_id: string
character_id: string
toll_paid: string
timestamp_ms: string
}
export function useGateEvents(packageId: string) {
const [events, setEvents] = useState<JumpEvent[]>([])
useEffect(() => {
const client = new SuiClient({ url: 'https://fullnode.testnet.sui.io:443' })
const subscribe = async () => {
await client.subscribeEvent({
filter: { MoveEventType: `${packageId}::toll_gate_ext::GateJumped` },
onMessage: (event) => {
setEvents(prev => [event.parsedJson as JumpEvent, ...prev.slice(0, 49)])
},
})
}
subscribe()
}, [packageId])
return events
}
12.5 动态字段 vs 事件的使用场景
| 需求 | 方案 |
|---|---|
| 持久化存储的集合数据 | 动态字段 / Table |
| 历史记录查询(不需要在合约中保留) | 事件 |
| 实时通知链下系统 | 事件 |
| 合约内部的状态检查 | 动态字段 |
| 分析统计数据(交易量、活跃用户) | 事件 + 链下索引 |
12.6 实战:设计一个可追踪的拍卖状态机
将本章知识整合,设计一个复杂的拍卖状态对象:
module my_auction::auction;
use sui::object::{Self, UID, ID};
use sui::table::{Self, Table};
use sui::event;
use sui::clock::Clock;
/// 拍卖状态枚举(用 u8 表示)
const STATUS_OPEN: u8 = 0;
const STATUS_ENDED: u8 = 1;
const STATUS_CANCELLED: u8 = 2;
/// 拍卖对象
public struct Auction<phantom ItemType: key + store> has key {
id: UID,
status: u8,
min_bid: u64,
current_bid: u64,
current_winner: Option<address>,
end_time_ms: u64,
bid_history_count: u64,
// 竞价历史用动态字段存储(避免大对象)
}
/// 竞价事件
public struct BidPlaced has copy, drop {
auction_id: ID,
bidder: address,
amount: u64,
timestamp_ms: u64,
}
/// 竞价函数
public fun place_bid<T: key + store>(
auction: &mut Auction<T>,
payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let bid_amount = coin::value(&payment);
let now = clock.timestamp_ms();
// 验证
assert!(auction.status == STATUS_OPEN, EAuctionNotOpen);
assert!(now < auction.end_time_ms, EAuctionEnded);
assert!(bid_amount > auction.current_bid, EBidTooLow);
// 退还前一位竞拍者的出价(简化版)
// ...
// 更新拍卖状态
auction.current_bid = bid_amount;
auction.current_winner = option::some(ctx.sender());
// 记录竞价历史(用动态字段)
let bid_key = auction.bid_history_count;
auction.bid_history_count = bid_key + 1;
df::add(&mut auction.id, bid_key, BidRecord {
bidder: ctx.sender(),
amount: bid_amount,
timestamp_ms: now,
});
// 发射事件(供 dApp 实时显示)
event::emit(BidPlaced {
auction_id: object::id(auction),
bidder: ctx.sender(),
amount: bid_amount,
timestamp_ms: now,
});
}
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 泛型 | <T> 类型参数 + phantom T 类型区分 |
| 动态字段 | 运行时添加字段,df::add/borrow/remove,max 1024/tx |
| Table | 链上大规模 KV 存储,table::add/borrow/contains |
| VecMap | 小型有序 KV,存在字段里,适合配置表 |
| 事件 | has copy + drop,event::emit(),可被链下订阅 |
| 事件 vs 动态字段 | 临时通知用事件;持久状态用动态字段 |
📚 延伸阅读
Chapter 13:NFT 设计与元数据管理
目标: 掌握 Sui 的 NFT 标准(Display),设计可进化的动态 NFT,以及在 EVE Frontier 生态中应用 NFT 作为权限凭证、成就徽章和游戏资产。
状态:设计进阶章节。正文以 NFT 标准、动态元数据和 Collection 模式为主。
13.1 Sui 的 NFT 模型
在 Sui 上,NFT 就是一个带有 key ability 的唯一对象。没有特殊的 “NFT 合约”——任何带有唯一 ObjectID 的对象都天然是 NFT:
// 最简单的 NFT
public struct Badge has key, store {
id: UID,
name: vector<u8>,
description: vector<u8>,
image_url: vector<u8>,
}
最重要的理解不是“NFT 能显示图片”,而是:
NFT 在 Sui 里首先是一个对象,其次才是一个收藏品或展示品。
这意味着你可以很自然地把 NFT 用在三类不同场景:
- 纯展示型 勋章、纪念品、成就证明
- 权限型 通行证、会员卡、白名单凭证
- 功能型 可升级飞船、装备、订阅权、租赁凭证
这三类 NFT 的设计重点完全不同。
设计 NFT 前先问四个问题
- 它主要是展示品、权限卡,还是可操作资产?
- 它是否允许转让?
- 它的元数据是否会变化?
- 前端和市场应不应该把它当“可交易商品”看待?
只要这四个问题没答清,后面的 Display、Collection、TransferPolicy 都很容易做偏。
13.2 Sui Display 标准:让 NFT 在各处正确显示
Display 对象告诉钱包、市场如何显示你的 NFT:
module my_nft::space_badge;
use sui::display;
use sui::package;
use std::string::utf8;
// 一次性见证(创建 Publisher)
public struct SPACE_BADGE has drop {}
public struct SpaceBadge has key, store {
id: UID,
name: String,
tier: u8, // 1=铜牌, 2=银牌, 3=金牌
earned_at_ms: u64,
image_url: String,
}
fun init(witness: SPACE_BADGE, ctx: &mut TxContext) {
// 1. 用 OTW 创建 Publisher(证明这个包的作者身份)
let publisher = package::claim(witness, ctx);
// 2. 创建 Display(定义如何展示 SpaceBadge)
let mut display = display::new_with_fields<SpaceBadge>(
&publisher,
// 字段名 // 模板值({field_name} 会被实际字段值替换)
vector[
utf8(b"name"),
utf8(b"description"),
utf8(b"image_url"),
utf8(b"project_url"),
],
vector[
utf8(b"{name}"), // NFT 名称
utf8(b"EVE Frontier Builder Badge - Tier {tier}"), // 描述
utf8(b"{image_url}"), // 图片 URL
utf8(b"https://evefrontier.com"), // 项目链接
],
ctx,
);
// 3. 提交 Display(冻结版本,使其对外可见)
display::update_version(&mut display);
// 4. 转移(Publisher 给部署者,Display 共享或冻结)
transfer::public_transfer(publisher, ctx.sender());
transfer::public_freeze_object(display);
}
Display 真正解决的是什么?
它解决的是“链上对象字段”到“钱包和市场展示内容”之间的解释层问题。
如果没有这层:
- 钱包只能看到生硬字段
- 市场很难统一显示名称、描述、图片
- 同一类 NFT 在不同前端里会表现不一致
所以 Display 不是装饰,而是 NFT 产品体验的一部分。
设计 Display 时最容易犯的错
1. 把所有展示语义都塞进链上字段
不是所有展示文案都要做成可变链上字段。有些稳定说明更适合放在模板里,有些动态状态才适合放字段里。
2. 过度依赖外部图片 URL
如果图片资源路径不稳定,NFT 本体还在,但用户看到的体验会崩。
3. 字段命名和前端理解脱节
如果链上字段叫得过于内部化,前端和钱包层就很难稳定解释。
13.3 动态 NFT:会进化的元数据
EVE Frontier 的游戏状态实时变化,你的 NFT 元数据也可以随之变化:
module my_nft::evolving_ship;
/// 可进化的飞船 NFT
public struct EvolvingShip has key, store {
id: UID,
name: String,
hull_class: u8, // 0=护卫舰, 1=巡洋舰, 2=战列舰
combat_score: u64, // 战斗积分(随战斗增加)
kills: u64, // 击杀数
image_url: String, // 根据 hull_class 变化
}
/// 记录战斗结果(由炮塔合约调用)
public fun record_kill(
ship: &mut EvolvingShip,
ctx: &TxContext,
) {
ship.kills = ship.kills + 1;
ship.combat_score = ship.combat_score + 100;
// 升级飞船等级(进化)
if ship.combat_score >= 10_000 && ship.hull_class < 2 {
ship.hull_class = ship.hull_class + 1;
// 更新图片 URL(指向更高级别的资产)
ship.image_url = get_image_url(ship.hull_class);
}
}
fun get_image_url(class: u8): String {
let base = b"https://assets.evefrontier.com/ships/";
let suffix = if class == 0 { b"frigate.png" }
else if class == 1 { b"cruiser.png" }
else { b"battleship.png" };
// 拼接 URL(Move 中字符串操作用 sui::string)
let mut url = std::string::utf8(base);
url.append(std::string::utf8(suffix));
url
}
Display 模板自动更新:由于 Display 用 {hull_class} 和 {image_url} 等字段的当前值渲染,当字段变化时,NFT 在钱包中的显示也会立即更新。
动态 NFT 适合什么,不适合什么?
适合:
- 成长型资产
- 会被状态影响价值的物品
- 游戏内战绩、成就、熟练度映射
不一定适合:
- 强调静态稀缺叙事的收藏品
- 二级市场非常依赖固定元数据的资产
因为一旦元数据可变,你就默认引入了新的产品问题:
- 谁能改?
- 改动是否可审计?
- 玩家买入时到底买的是当前状态,还是未来可能变化的状态?
动态元数据设计的关键边界
- 状态变化是否链上可追溯 最好有事件记录
- 改动权限是否明确 不是任何模块都能乱改
- 前端是否能正确反映变化 否则链上变了,用户界面还停在旧图
13.4 集合(Collection)模式
module my_nft::badge_collection;
/// 勋章系列集合(元对象,描述这个 NFT 系列)
public struct BadgeCollection has key {
id: UID,
name: String,
total_supply: u64,
minted_count: u64,
admin: address,
}
/// 单个勋章
public struct AllianceBadge has key, store {
id: UID,
collection_id: ID, // 归属于哪个集合
serial_number: u64, // 系列编号(第几个铸造的)
tier: u8,
attributes: vector<NFTAttribute>,
}
public struct NFTAttribute has store, copy, drop {
trait_type: String,
value: String,
}
/// 铸造勋章(追踪编号和总量)
public fun mint_badge(
collection: &mut BadgeCollection,
recipient: address,
tier: u8,
attributes: vector<NFTAttribute>,
ctx: &mut TxContext,
) {
assert!(ctx.sender() == collection.admin, ENotAdmin);
assert!(collection.minted_count < collection.total_supply, ESoldOut);
collection.minted_count = collection.minted_count + 1;
let badge = AllianceBadge {
id: object::new(ctx),
collection_id: object::id(collection),
serial_number: collection.minted_count,
tier,
attributes,
};
transfer::public_transfer(badge, recipient);
}
Collection 的价值,不只是“把一批 NFT 归个类”,而是让系列化管理变得清晰:
- 总量控制
- 编号追踪
- 官方系列身份
- 前端聚合展示
Collection 最适合解决哪些问题?
- 某一系列是否已经售罄
- 第几号资产属于哪一系列
- 一个 badge 是否来自官方那套发行体系
如果没有 collection 这一层,你后面做:
- 系列页
- 稀有度统计
- 官方认证
都会变得更难。
13.5 NFT 作为访问控制凭证
在 EVE Frontier 中,NFT 是最天然的权限载体:
// 使用 NFT 检查权限的方式
public fun enter_restricted_zone(
gate: &Gate,
character: &Character,
badge: &AllianceBadge, // 持有勋章才能调用
clock: &Clock,
ctx: &mut TxContext,
) {
// 验证勋章等级(需要金牌才能进入)
assert!(badge.tier >= 3, EInsufficientBadgeTier);
// 验证勋章属于正确集合(防止伪造)
assert!(badge.collection_id == OFFICIAL_COLLECTION_ID, EWrongCollection);
// ...
}
这是 EVE Builder 里 NFT 最实用的一类用法,因为它把“权限”做成了玩家真的能持有和理解的对象。
为什么权限 NFT 往往比地址白名单更好?
因为它更灵活,也更产品化:
- 可以转让
- 可以回收
- 可以有等级
- 可以有到期时间
- 前端可以直观展示
但也要小心一件事:
只要它能转让,权限也会跟着流动。
所以你必须先决定,这张权限 NFT 到底应该是:
- 可转让的市场资产
- 还是不可转让的身份凭证
13.6 NFT 转让策略
Sui 支持灵活的 NFT 转让政策:
// 默认:任何人都可以转让(public_transfer)
transfer::public_transfer(badge, recipient);
// 锁仓:NFT 只能由特定合约转移(通过 TransferPolicy)
use sui::transfer_policy;
// 在包初始化时建立 TransferPolicy(限制转让条件)
fun init(witness: SPACE_BADGE, ctx: &mut TxContext) {
let publisher = package::claim(witness, ctx);
let (policy, policy_cap) = transfer_policy::new<SpaceBadge>(&publisher, ctx);
// 添加自定义规则(如需支付版税)
// royalty_rule::add(&mut policy, &policy_cap, 200, 0); // 2% 版税
transfer::public_share_object(policy);
transfer::public_transfer(policy_cap, ctx.sender());
transfer::public_transfer(publisher, ctx.sender());
}
转让策略本质上是在定义“这个 NFT 的社会属性”
- 自由转让 更像商品
- 受限转让 更像带规则的许可
- 不可转让 更像身份或成就
这不是技术细节,而是产品定位。
如果你的 NFT 是:
- 会员资格
- 实名凭证
- 联盟内部身份卡
那默认自由转让往往不是好主意。
13.7 将 NFT 嵌入 EVE Frontier 资产(对象拥有对象)
// 飞船装备 NFT(被飞船对象持有)
public struct Equipment has key, store {
id: UID,
name: String,
stat_bonus: u64,
}
public struct Ship has key {
id: UID,
// Equipment 被嵌入 Ship 对象中(对象拥有对象)
equipped_items: vector<Equipment>,
}
// 为飞船装备物品
public fun equip(
ship: &mut Ship,
equipment: Equipment, // Equipment 从玩家钱包移入 Ship
ctx: &TxContext,
) {
vector::push_back(&mut ship.equipped_items, equipment);
}
对象拥有对象这套设计,对游戏资产尤其自然,因为它允许你表达:
- 一艘船拥有多件装备
- 一个角色拥有一套证件
- 一个容器里放着多个特殊资产
什么时候该把 NFT 独立存在,什么时候该嵌进去?
适合独立存在:
- 需要单独交易
- 需要单独展示
- 需要单独授权或转让
适合嵌进别的对象:
- 主要作为某个大对象的组成部分
- 不需要频繁单独流转
- 更强调组合后的整体状态
这背后其实是在平衡“可流通性”和“组合表达力”。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| Sui NFT 本质 | 带 key 的唯一对象,ObjectID 即 NFT ID |
| Display 标准 | display::new_with_fields() 定义钱包显示模板 |
| 动态 NFT | 字段可变 + Display 模板引用字段 → 自动同步显示 |
| Collection 模式 | MetaObject 追踪总量和编号 |
| NFT 作为权限 | 传入 NFT 引用做权限检查,比地址白名单更灵活 |
| TransferPolicy | 控制 NFT 二级市场转让规则(如版税) |
📚 延伸阅读
Chapter 14:链上经济系统设计
目标: 学会在 EVE Frontier 中设计和实现完整的链上经济系统,包括自定义代币发行、去中心化市场、动态定价与金库管理。
状态:设计进阶章节。正文以代币、市场、金库和定价机制为主。
14.1 EVE Frontier 的经济体系
EVE Frontier 本身已有两种官方货币:
| 货币 | 用途 | 特点 |
|---|---|---|
| LUX | 游戏内主流交易货币 | 稳定,用于日常服务和商品交易 |
| EVE Token | 生态代币 | 用于开发者激励,可购买特殊资产 |
作为 Builder,你可以:
- 接受 LUX/SUI 作为支付手段(直接使用官方 Coin 类型)
- 发行你自己的联盟代币(自定义 Coin 模块)
- 构建市场和交易机制(基于 SSU 扩展)
这里最重要的不是“能发币、能收费”这些能力本身,而是要先分清:
你的经济系统,到底在卖什么、为什么有人会持续付费、什么情况下会被套利或抽干。
很多链上经济设计失败,不是因为代码写错,而是因为一开始就没有把下面这几件事想清楚:
- 你卖的是一次性物品、持续服务,还是准入资格?
- 收入是立即结算,还是长期分润?
- 价格由谁决定?固定、算法、拍卖,还是人工运营?
- 玩家为什么要在你的系统里留资产,而不是用完就走?
先区分四种最常见的 Builder 收费模型
| 模型 | 用户买的是什么 | 典型场景 | 风险点 |
|---|---|---|---|
| 一次性购买 | 一个物品或一次动作 | 售货机、跳门付费 | 容易变成纯比价市场 |
| 使用权购买 | 一段时间的访问或能力 | 租赁、订阅、通行证 | 到期、退款、滥用边界复杂 |
| 撮合抽成 | 平台流量和交易撮合 | 市场、拍卖、保险撮合 | 假交易、自买自卖、女巫刷量 |
| 长期金库分润 | 系统现金流的份额 | 联盟金库、协议收入分配 | 治理复杂、分配争议大 |
你在设计经济系统前,最好先明确自己属于哪一类。因为它们对应的对象模型、事件设计和风险控制完全不同。
14.2 发行自定义代币(Custom Coin)
Sui 的代币(Coin)模型非常标准化。通过 sui::coin 模块可以创建任意 Fungible Token:
module my_alliance::alliance_token;
use sui::coin::{Self, Coin, TreasuryCap};
use sui::object::UID;
use sui::transfer;
use sui::tx_context::TxContext;
/// 代币的"一次性见证"(One-Time Witness)
/// 必须与模块同名(全大写),只在 init 时能创建
public struct ALLIANCE_TOKEN has drop {}
/// 代币的元数据(名称、符号、小数位)
fun init(witness: ALLIANCE_TOKEN, ctx: &mut TxContext) {
let (treasury_cap, coin_metadata) = coin::create_currency(
witness,
6, // 小数位(decimals)
b"ALLY", // 代币符号
b"Alliance Token", // 代币全名
b"The official token of Alliance X", // 描述
option::none(), // 图标 URL(可选)
ctx,
);
// 将 TreasuryCap 发送给部署者(铸币权)
transfer::public_transfer(treasury_cap, ctx.sender());
// 将 CoinMetadata 共享(供 DEX、钱包展示)
transfer::public_share_object(coin_metadata);
}
/// 铸造代币(只有持有 TreasuryCap 才能调用)
public fun mint(
treasury: &mut TreasuryCap<ALLIANCE_TOKEN>,
amount: u64,
recipient: address,
ctx: &mut TxContext,
) {
let coin = coin::mint(treasury, amount, ctx);
transfer::public_transfer(coin, recipient);
}
/// 销毁代币(降低总供应量)
public fun burn(
treasury: &mut TreasuryCap<ALLIANCE_TOKEN>,
coin: Coin<ALLIANCE_TOKEN>,
) {
coin::burn(treasury, coin);
}
发币这件事在技术上很简单,在经济上却最容易被误用。
发币前先问的三个问题
1. 这个币为什么存在?
常见合理用途包括:
- 联盟内部记账和激励
- 协议内折扣、分润或投票凭证
- 某种服务配额或准入层
如果答案只是“大家都有币,所以我也发一个”,那大概率不值得做。
2. 这个币是否真的需要链上流通?
有些积分型系统其实不需要独立币,更适合:
- 链上记分对象
- 非转让 badge
- 金库份额记录
因为一旦做成真正可转让 Coin,你就默认引入了:
- 二级市场
- 囤积和投机
- 流动性预期
- 更高的合规和运营负担
3. 谁掌握供应量,供应怎么增长?
TreasuryCap 在技术上代表铸币权,在经济上代表货币主权。只要供应策略模糊,后面很容易演变成:
- Builder 随意增发
- 早期用户被稀释
- 价格和预期迅速崩掉
One-Time Witness 解决了什么,不解决什么?
它解决的是:
- 币种创建身份唯一
- 初始化路径规范
- 元数据和 TreasuryCap 的创建流程安全
它不解决的是:
- 你的供应曲线是否合理
- 币是否有需求
- 币价是否稳定
也就是说,语言保证“币不会被随便伪造”,但不会保证“你发的是个好币”。
14.3 建立去中心化市场
基于 Smart Storage Unit,可以构建去中心化的物品市场:
module my_market::item_market;
use world::storage_unit::{Self, StorageUnit};
use world::character::Character;
use world::inventory::Item;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::table::{Self, Table};
use sui::object::{Self, ID};
use sui::event;
/// 市场扩展 Witness
public struct MarketAuth has drop {}
/// 商品上架信息
public struct Listing has store {
seller: address,
item_type_id: u64,
price: u64, // 以 MIST(SUI 最小单位)计
expiry_ms: u64, // 0 = 永不过期
}
/// 市场注册表
public struct Market has key {
id: UID,
storage_unit_id: ID,
listings: Table<u64, Listing>, // item_type_id -> Listing
fee_rate_bps: u64, // 手续费(基点,100 bps = 1%)
fee_balance: Balance<SUI>,
}
/// 事件
public struct ItemListed has copy, drop {
market_id: ID,
seller: address,
item_type_id: u64,
price: u64,
}
public struct ItemSold has copy, drop {
market_id: ID,
buyer: address,
seller: address,
item_type_id: u64,
price: u64,
fee: u64,
}
/// 上架物品
public fun list_item(
market: &mut Market,
storage_unit: &mut StorageUnit,
character: &Character,
item_type_id: u64,
price: u64,
expiry_ms: u64,
ctx: &mut TxContext,
) {
// 将物品从存储箱取出,存入市场的专属临时仓库
// (实现细节:使用 MarketAuth{} 调用 SSU 的 withdraw_item)
// ...
// 记录上架信息
table::add(&mut market.listings, item_type_id, Listing {
seller: ctx.sender(),
item_type_id,
price,
expiry_ms,
});
event::emit(ItemListed {
market_id: object::id(market),
seller: ctx.sender(),
item_type_id,
price,
});
}
/// 购买物品
public fun buy_item(
market: &mut Market,
storage_unit: &mut StorageUnit,
character: &Character,
item_type_id: u64,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
): Item {
let listing = table::borrow(&market.listings, item_type_id);
// 检查有效期
if listing.expiry_ms > 0 {
assert!(clock.timestamp_ms() < listing.expiry_ms, EListingExpired);
}
// 验证支付金额
assert!(coin::value(&payment) >= listing.price, EInsufficientPayment);
// 扣除手续费
let fee = listing.price * market.fee_rate_bps / 10_000;
let seller_amount = listing.price - fee;
// 分割代币:手续费 + 卖家收益 + 找零
let fee_coin = payment.split(fee, ctx);
let seller_coin = payment.split(seller_amount, ctx);
let change = payment; // 剩余找零
balance::join(&mut market.fee_balance, coin::into_balance(fee_coin));
transfer::public_transfer(seller_coin, listing.seller);
transfer::public_transfer(change, ctx.sender());
let seller_addr = listing.seller;
let price = listing.price;
// 移除上架记录
table::remove(&mut market.listings, item_type_id);
event::emit(ItemSold {
market_id: object::id(market),
buyer: ctx.sender(),
seller: seller_addr,
item_type_id,
price,
fee,
});
// 从 SSU 取出物品给买家
storage_unit::withdraw_item(
storage_unit, character, MarketAuth {}, item_type_id, ctx,
)
}
这个市场示例已经能说明基本结构,但真实设计时你还要额外想清几件事。
一个市场最少包含哪四层?
- 挂单层 卖家提供什么、价格多少、何时过期
- 托管层 物品和资金由谁托管,什么时候真正转移
- 结算层 手续费、卖家收益、找零如何分配
- 索引层 前端如何查到当前可买列表,而不是只看到历史事件
这四层少一层都能“写出代码”,但写不出一个稳定市场。
市场设计最容易漏掉的边界
1. 上架时,物品到底有没有真的被锁住?
如果只是记录了 Listing,但物品本体没有被安全托管:
- 卖家可能已经把物品挪走
- 前端还会继续展示“可购买”
- 买家付款后才发现无法交付
2. 支付和交付是不是同一笔原子事务?
如果付款成功、交付失败,或交付成功、付款失败,都会造成严重体验和资产问题。链上市场最核心的价值之一,就是把这两个动作收进同一笔原子交易。
3. 下架、过期、重复上架路径是否闭合?
很多市场不是挂单和购买出问题,而是:
- 过期条目还在列表里
- 下架后库存没回去
- 重复上架导致状态错乱
14.4 动态定价策略
策略一:固定价格
最简单的定价,Owner 设定价格,玩家按价购买(如上面的市场例子)。
策略二:荷兰式拍卖(价格递减)
public fun get_current_price(
start_price: u64,
end_price: u64,
start_time_ms: u64,
duration_ms: u64,
clock: &Clock,
): u64 {
let elapsed = clock.timestamp_ms() - start_time_ms;
if elapsed >= duration_ms {
return end_price // 已到最低价
}
// 线性递减
let price_drop = (start_price - end_price) * elapsed / duration_ms;
start_price - price_drop
}
策略三:供需动态定价(AMM 风格)
基于 恒定乘积公式 x * y = k:
public struct LiquidityPool has key {
id: UID,
reserve_sui: Balance<SUI>,
reserve_item_count: u64,
k_constant: u64, // x * y = k
}
/// 计算购买 n 个物品需要支付多少 SUI
public fun get_buy_price(pool: &LiquidityPool, buy_count: u64): u64 {
let new_item_count = pool.reserve_item_count - buy_count;
let new_sui_reserve = pool.k_constant / new_item_count;
new_sui_reserve - balance::value(&pool.reserve_sui)
}
策略四:会员折扣
public fun calculate_price(
base_price: u64,
buyer: address,
member_registry: &Table<address, MemberTier>,
): u64 {
if table::contains(member_registry, buyer) {
let tier = table::borrow(member_registry, buyer);
match (tier) {
MemberTier::Gold => base_price * 80 / 100, // 8折
MemberTier::Silver => base_price * 90 / 100, // 9折
_ => base_price,
}
} else {
base_price
}
}
定价策略的选择,本质上是在做三件事之间的权衡:
- 收入最大化
- 用户可预测性
- 抗操纵能力
固定价为什么永远不会过时?
因为它最容易被理解,也最容易被运营。
适合:
- 低频商品
- 价格预期稳定的服务
- 刚上线、还没掌握真实需求曲线的产品
很多 Builder 一开始就想上复杂定价,但实际上更稳的路径通常是:
- 先用固定价跑出真实需求
- 再根据数据决定是否引入动态机制
荷兰拍卖适合什么?
它适合:
- 稀缺资源首次发售
- 你不确定市场心理价位
- 希望让价格随时间自动回落
但你要接受一个现实:
- 它更适合“单次发售”
- 不一定适合长期稳定营业的商店
AMM 风格为什么危险也强大?
强大在于:
- 连续可交易
- 不依赖人工逐条挂单
- 价格能自动响应库存变化
危险在于:
- 玩家会被滑点和曲线放大影响
- 参数设计不稳时容易被套利
- 池子深度不足时,价格会非常难看
所以如果你不是在做真正需要“持续流动性曲线”的系统,不一定非要上 AMM。
14.5 金库管理模式
每个商业设施都应该有金库来管理收入:
module my_finance::vault;
use sui::balance::{Self, Balance};
use sui::coin::{Self, Coin};
use sui::sui::SUI;
/// 多资产金库
public struct MultiVault has key {
id: UID,
sui_balance: Balance<SUI>,
total_deposited: u64, // 历史累计存入
total_withdrawn: u64, // 历史累计取出
}
/// 存入资金
public fun deposit(vault: &mut MultiVault, coin: Coin<SUI>) {
let amount = coin::value(&coin);
vault.total_deposited = vault.total_deposited + amount;
balance::join(&mut vault.sui_balance, coin::into_balance(coin));
}
/// 按比例分配给多个地址
public fun distribute(
vault: &mut MultiVault,
recipients: vector<address>,
shares: vector<u64>, // 份额(百分比,总和需等于 100)
ctx: &mut TxContext,
) {
assert!(vector::length(&recipients) == vector::length(&shares), EMismatch);
let total = balance::value(&vault.sui_balance);
let len = vector::length(&recipients);
let mut i = 0;
while (i < len) {
let share = *vector::borrow(&shares, i);
let payout = total * share / 100;
let coin = coin::take(&mut vault.sui_balance, payout, ctx);
transfer::public_transfer(coin, *vector::borrow(&recipients, i));
vault.total_withdrawn = vault.total_withdrawn + payout;
i = i + 1;
};
}
金库设计的重点从来不是“把钱装进去”,而是:
收入如何沉淀、谁能动、什么时候分、分完还能不能追账。
一个稳的金库至少要回答这些问题
- 收入按什么资产计价?
- 资金是实时分发还是先沉淀再结算?
- 谁能提取?谁能暂停?谁能改分润比例?
- 分配时的余数和舍入误差怎么处理?
- 出现争议时,链上记录能不能追溯?
“立即分发” 和 “先入库后结算” 的取舍
立即分发
优点:
- 逻辑直观
- 收入立刻到各方手里
缺点:
- 每次交易都更重
- 分润路径一多,失败面会扩大
先入库后结算
优点:
- 主交易更轻
- 分润、提现、审计更容易拆开
缺点:
- 要额外处理提现权限和结算时点
大多数真实产品里,后者会更稳。
14.6 经济系统设计原则
| 原则 | 实践建议 |
|---|---|
| 可持续性 | 设计回购机制(如用收入回购并销毁代币)避免通胀 |
| 透明度 | 所有经济参数链上可查,通过事件记录每笔交易 |
| 防操控 | 避免单点价格控制,引入 AMM 或荷兰式拍卖 |
| 激励对齐 | 让服务提供方(Builder)和用户的利益方向一致 |
| 升级保留 | 关键参数(费率、价格)设计成可更新的,避免合约锁死 |
再补三条最容易被低估的原则
| 原则 | 为什么重要 |
|---|---|
| 反刷量 | 只要你的系统有手续费返佣、活跃激励或排行榜,就会有人刷 |
| 退出路径 | 玩家能不能退订、取回保证金、下架资产,决定了系统是否可信 |
| 参数可解释 | 玩家看不懂价格和费用来源时,会天然不信任你的协议 |
设计时一定要主动问的攻击面
- 玩家能不能自己和自己交易来刷奖励?
- 大户能不能瞬间抽干流动性或操纵价格?
- 折扣和返佣能不能被循环套利?
- 金库收益分配时能不能被抢跑或重复领取?
如果你在设计阶段就把这些问题写出来,后面很多漏洞根本不会进代码。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 自定义代币 | ALLIANCE_TOKEN 一次性见证 + coin::create_currency() |
| 去中心化市场 | SSU 扩展 + Listing Table + 手续费机制 |
| 定价策略 | 固定价 / 荷兰拍卖 / AMM 恒定乘积 / 会员折扣 |
| 金库管理 | Balance<T> 作为内部账本,按比例分配 |
| 经济设计原则 | 可持续 + 透明 + 防操控 + 可升级 |
📚 延伸阅读
Chapter 15:跨合约组合性(Composability)
目标: 掌握如何设计对外友好的合约接口,以及如何安全地调用其他 Builder 发布的合约,构建可组合的 EVE Frontier 生态系统。
状态:设计进阶章节。正文以跨合约接口与可组合性为主。
15.1 可组合性的价值
EVE Frontier 最激动人心的特性之一:你的合约可以直接调用他人的合约,无需任何中间人。
Builder A:发行了 ALLY Token + 价格预言机
Builder B:调用 A 的价格预言机,以 ALLY Token 定价出售物品
Builder C:在 B 的市场上架,同时接受 A 的 ALLY 和 SUI 支付
这创造了真正意义上的开放经济协议栈。
可组合性真正厉害的地方,不是“大家都能互相调用”这句口号,而是:
你写的协议一旦足够清晰,别人就能把它当积木,而不是把它当黑盒。
这会直接改变 Builder 的思路:
- 你不再只是做一个单点功能
- 你是在决定自己要成为“终端产品”还是“底层能力”
很多最有价值的协议,并不是自己包办所有事,而是把某一个能力做成别人愿意反复接入的模块。
15.2 设计对外友好的 Move 接口
好的 Move 接口设计应遵循:
module my_protocol::oracle;
// ── 公开的视图函数(只读,免费调用)──────────────────────
/// 获取 ALLY/SUI 汇率(以 MIST 计)
public fun get_ally_price(oracle: &PriceOracle): u64 {
oracle.ally_per_sui
}
/// 检查价格是否在有效期内
public fun is_price_fresh(oracle: &PriceOracle, clock: &Clock): bool {
clock.timestamp_ms() - oracle.last_updated_ms < PRICE_TTL_MS
}
// ── 公开的可组合函数(其他合约可调用)───────────────────
/// 将 SUI 金额换算为 ALLY 数量
public fun sui_to_ally_amount(
oracle: &PriceOracle,
sui_amount: u64,
clock: &Clock,
): u64 {
assert!(is_price_fresh(oracle, clock), EPriceStale);
sui_amount * oracle.ally_per_sui / 1_000_000_000
}
设计原则
| 原则 | 实现方式 |
|---|---|
| 只读视图 | public fun 不含 &mut,零 Gas 调用 |
| 可组合操作 | 接受 Witness 参数,允许授权调用方执行 |
| 版本化 | 保留旧接口,新接口以新函数名/类型参数区分 |
| 事件发射 | 关键操作发射事件,方便监听 |
| 文档化 | 完整注释说明前置条件和返回值 |
好接口的标准,不只是“别人能调通”
一个真正对外友好的接口,至少应该让外部集成者能快速回答这些问题:
- 这个函数会不会改状态?
- 调用前必须准备哪些对象和权限?
- 调用失败最常见的原因是什么?
- 返回值和事件各自代表什么?
如果这些都不清楚,别人虽然“理论上能调”,但集成成本会高得离谱。
接口设计里最容易犯的三个错
1. 把内部实现细节直接暴露成外部依赖
一旦你的接口强依赖内部对象布局,后面每次重构都会把外部集成者一起拖下水。
2. 读接口和写接口混得太近
只读查询最好尽量简单稳定。可写入口则应该明确标注权限和副作用。两者混在一起,集成方很容易误用。
3. 错误边界不清楚
如果函数可能因为:
- 权限不足
- 数据过期
- 价格无效
- 对象状态不匹配
而失败,那这些前提最好能通过文档、命名或辅助只读接口提前暴露出来。
15.3 调用其他 Builder 的合约
在 Move.toml 中添加外部依赖
[dependencies]
# 依赖其他 Builder 已发布的包(通过 Git)
AllyOracle = {
git = "https://github.com/builder-alice/ally-oracle",
subdir = "contracts",
rev = "v1.0.0"
}
# 或直接指定链上地址(对已发布的包)
AllyOracleOnChain = { local = "../ally-oracle" } # 本地测试用
在 Move 代码中调用
module my_market::ally_market;
// 引入其他 Builder 的模块(需要在 Move.toml 中声明依赖)
use ally_oracle::oracle::{Self, PriceOracle};
use ally_dao::ally_token::ALLY_TOKEN;
public fun buy_with_ally(
storage_unit: &mut world::storage_unit::StorageUnit,
character: &Character,
price_oracle: &PriceOracle, // 外部 Builder A 的价格预言机
ally_payment: Coin<ALLY_TOKEN>, // 外部 Builder A 的代币
item_type_id: u64,
clock: &Clock,
ctx: &mut TxContext,
): Item {
// 调用外部合约的视图函数
let price_in_sui = oracle::sui_to_ally_amount(
price_oracle,
ITEM_BASE_PRICE_SUI,
clock,
);
assert!(coin::value(&ally_payment) >= price_in_sui, EInsufficientPayment);
// 处理 ALLY Token 支付(转到联盟金库等)
// ...
// 从自己的 SSU 取出物品
storage_unit::withdraw_item(
storage_unit, character, MyMarketAuth {}, item_type_id, ctx,
)
}
依赖别人合约时,真正绑定的是什么?
不是“一个 Git 仓库地址”这么简单,而是同时绑定了:
- 对方的接口稳定性
- 对方的升级策略
- 对方的经济和治理选择
- 你自己的故障半径
也就是说,你每引入一个外部协议,就等于把自己的一部分稳定性外包给了别人。
所以接外部协议前先问四个问题
- 这个协议的核心接口是否稳定?
- 它升级时会不会破坏我当前用法?
- 如果它暂停或失效,我有没有降级路径?
- 我能不能把关键依赖收敛到只读接口,而不是深度写入耦合?
15.4 接口版本控制与协议标准
当你的合约被广泛使用后,升级接口必须保证向后兼容:
module my_protocol::market_v2;
// 使用类型标记版本
public struct V1 has drop {}
public struct V2 has drop {}
// V1 接口(永远保留)
public fun get_price_v1(market: &Market, _: V1): u64 {
market.price
}
// V2 接口(新增,支持动态价格)
public fun get_price_v2(
market: &Market,
clock: &Clock,
_: V2,
): u64 {
calculate_dynamic_price(market, clock)
}
定义跨合约接口标准(类似 ERC 标准)
在 EVE Frontier 生态中,可以通过文档约定接口标准,让多个 Builder 的合约相互兼容:
// ── 非官方"市场接口"标准提案 ────────────────────────────
// 任何想接入聚合市场的 Builder 的合约应实现以下接口:
/// 列出物品:返回当前出售的物品类型和价格
public fun list_items(market: &T): vector<(u64, u64)> // (type_id, price_sui)
/// 查询特定物品是否可购买
public fun is_available(market: &T, item_type_id: u64): bool
/// 购买(返回物品)
public fun purchase<Auth: drop>(
market: &mut T,
buyer: &Character,
item_type_id: u64,
payment: &mut Coin<SUI>,
auth: Auth,
ctx: &mut TxContext,
): Item
为什么版本控制要从第一版就开始想?
因为只要别人开始依赖你,“改接口”就不再只是你的内部事务。
你要同时考虑:
- 老调用方还能不能继续活
- 新功能能不能逐步引入
- 前端、脚本、聚合器是否要同步迁移
很多协议不是死于功能不足,而是死于“第二版把第一版都打碎了”。
标准化接口最值钱的地方
不是显得专业,而是能催生二级生态:
- 聚合器更容易接
- 比价工具更容易做
- 第三方前端更容易复用
- 其他 Builder 更愿意基于你继续搭
15.5 实战:聚合价格比较器
// 在 dApp 中聚合多个 Builder 的市场价格
async function getAggregatedPrices(
itemTypeId: number,
marketIds: string[],
client: SuiClient,
): Promise<Array<{ marketId: string; price: number; builder: string }>> {
// 批量读取所有市场状态
const markets = await client.multiGetObjects({
ids: marketIds,
options: { showContent: true },
});
const prices = markets
.map((market, i) => {
const fields = (market.data?.content as any)?.fields;
if (!fields) return null;
// 读取 listings Table 中的价格(简化)
const listing = fields.listings?.fields?.contents?.find(
(entry: any) => Number(entry.fields?.key) === itemTypeId
);
if (!listing) return null;
return {
marketId: marketIds[i],
price: Number(listing.fields.value.fields.price),
builder: fields.owner ?? "未知",
};
})
.filter(Boolean)
.sort((a, b) => a!.price - b!.price); // 按价格升序
return prices as any[];
}
这个例子很适合说明一个现实:
可组合性的价值,很多时候是在链下被放大的。
也就是说,链上协议只要把接口和事件设计清楚,链下就能做出:
- 比价器
- 聚合器
- 推荐路由
- 策略编排
所以你设计合约时,不要只想着“链上另一个合约会不会调我”,也要想“链下工具会不会愿意消费我”。
15.6 组合性的风险与防御
| 风险 | 描述 | 防御 |
|---|---|---|
| 依赖合约升级 | 外部合约升级可能破坏你的调用 | 锁定特定版本(rev = “v1.0.0”) |
| 外部合约暂停 | 依赖的合约被撤销或修改 | 设计降级路径(fallback 逻辑) |
| 重入型攻击 | 外部合约回调你的合约 | Move 通过所有权系统天然防御 |
| 价格操控 | 依赖的预言机被操控 | 使用多个预言机取中位数 |
再补三个实际项目里很常见的风险
| 风险 | 描述 | 防御 |
|---|---|---|
| 接口语义漂移 | 函数名没变,但行为口径变了 | 用版本号、文档和事件语义一起约束 |
| 外部协议活着,但数据质量下降 | 预言机没坏,只是更新变慢或价格异常 | 增加 freshness / sanity check |
| 降级路径缺失 | 外部依赖不可用时,自己的主流程直接瘫痪 | 预设 fallback、暂停开关、手动接管路径 |
组合不是越深越好
组合层次越深,你获得的能力越强,但也越难维护。
一个实用原则是:
- 优先依赖稳定、只读、可验证的外部能力
- 谨慎依赖深度耦合、强状态写入的外部流程
因为前者坏了通常只是“数据变差”,后者坏了可能直接把你的核心业务链打断。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 可组合性价值 | 你的合约可以被他人调用,形成协议栈 |
| 接口设计 | 只读视图 + Witness 授权 + 文档注释 |
| 引用外部包 | Move.toml 依赖 + use 语句 |
| 版本控制 | 保留旧接口 + 类型标记版本 |
| 聚合 dApp | 批量读取多合约数据,前端聚合展示 |
📚 延伸阅读
Chapter 16:位置与临近性系统
目标: 理解 EVE Frontier 的链上位置隐私设计,掌握如何利用临近性系统构建地理化游戏逻辑,以及未来 ZK 证明方向。
状态:教学示例章节。正文以位置隐私、服务器证明和未来 ZK 方向为主。
16.1 空间游戏的链上挑战
一个传统MMORPG游戏中,位置信息由游戏服务器统一管理。在链上,这带来两个矛盾:
- 透明性:链上数据任何人可查;若坐标明文存储,所有玩家隐藏基地的位置立即暴露
- 信任性:如果位置由客户端上报,玩家可以造假(“我就在你旁边!”)
EVE Frontier 的解决方案:哈希位置 + 信任游戏服务器签名。
这里最重要的不是记住“哈希位置”这四个字,而是先看清它到底在平衡什么:
- 隐私 不能把基地、设施、玩家位置直接公开
- 可验证 又必须让某些距离相关动作能被证明
- 可用性 还不能把整套系统设计得慢到没法玩
所以位置系统本质上是一个“隐私、可信、实时性”三者之间的工程折中。
16.2 哈希位置:保护坐标隐私
链上存储的不是明文坐标,而是 哈希值:
存储:hash(x, y, salt) → chain.location_hash
查询:任何人只能看到哈希,无法反推坐标
验证:玩家向服务器证明"我知道这个哈希对应的坐标"
// location.move(简化版)
public struct Location has store {
location_hash: vector<u8>, // 坐标的哈希,而不是明文坐标
}
/// 更新位置(需要游戏服务器签名授权)
public fun update_location(
assembly: &mut Assembly,
new_location_hash: vector<u8>,
admin_acl: &AdminACL, // 必须由授权服务器作为赞助者
ctx: &TxContext,
) {
verify_sponsor(admin_acl, ctx);
assembly.location.location_hash = new_location_hash;
}
哈希位置能保护什么,不能保护什么?
它能保护的是:
- 链上不直接暴露明文坐标
- 普通观察者无法直接从对象字段看到真实地点
它不能自动保护的是:
- 弱哈希或可枚举空间带来的反推风险
- 链下接口泄露真实位置
- 前端或日志把映射关系意外暴露出去
也就是说,哈希只是隐私体系的一层,不是全部。
16.3 临近性验证:服务器签名模式
当需要验证“A 在 B 附近“时(如取物品、跳跃),当前采用服务器签名:
① 玩家向游戏服务器请求:"证明我在星门 0x...附近"
② 服务器查询玩家的实际游戏坐标
③ 服务器验证玩家确实在星门附近(<20km)
④ 服务器用私钥签名"玩家A在星门B附近"的声明
⑤ 玩家将这个签名附在交易中提交
⑥ 链上合约验证签名来自授权服务器(AdminACL)
这套设计里最关键的信任边界是:
链上并不知道真实坐标,它只相信“被授权的服务器已经替它判断过这件事”。
这意味着系统安全不只取决于链上校验是否严格,也取决于:
- 游戏服务器是否诚实
- 签名 payload 是否完整
- 时间窗和 nonce 是否设计正确
// 星门链接时的距离验证
public fun link_gates(
gate_a: &mut Gate,
gate_b: &mut Gate,
owner_cap_a: &OwnerCap<Gate>,
distance_proof: vector<u8>, // 服务器签名的"两门距离 > 20km"证明
admin_acl: &AdminACL,
ctx: &TxContext,
) {
// 验证服务器签名(简化;实际实现验证 ed25519 签名)
verify_sponsor(admin_acl, ctx);
// ...
}
16.3.1 建议的最小证明消息体
不要把“附近证明”做成一个只有服务器自己看得懂的黑盒字节串。最小可落地的 payload 至少要绑定以下字段:
{
"proof_type": "assembly_proximity",
"player": "0xPLAYER",
"assembly_id": "0xASSEMBLY",
"location_hash": "0xHASH",
"max_distance_m": 20000,
"issued_at_ms": 1735689600000,
"expires_at_ms": 1735689660000,
"nonce": "4d2f1c..."
}
每个字段的职责:
player:防止别的玩家复用证明assembly_id:防止把 A 星门的证明拿去调用 B 星门location_hash:把链上当前位置状态绑定进证明issued_at_ms/expires_at_ms:限制重放窗口nonce:防止同一窗口内多次重放
16.3.2 服务端签名与链上校验的最小闭环
链下服务至少要做两件事:先验证真实坐标关系,再对明确的 payload 签名。
type ProximityProofPayload = {
proofType: "assembly_proximity";
player: string;
assemblyId: string;
locationHash: string;
maxDistanceM: number;
issuedAtMs: number;
expiresAtMs: number;
nonce: string;
};
async function issueProximityProof(input: {
player: string;
assemblyId: string;
expectedHash: string;
}) {
const location = await getPlayerLocationFromGameServer(input.player);
const assembly = await getAssemblyLocation(input.assemblyId);
assert(hash(location) === input.expectedHash);
assert(distance(location, assembly) <= 20_000);
const payload: ProximityProofPayload = {
proofType: "assembly_proximity",
player: input.player,
assemblyId: input.assemblyId,
locationHash: input.expectedHash,
maxDistanceM: 20_000,
issuedAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
nonce: crypto.randomUUID(),
};
return signPayload(payload);
}
链上侧至少要校验四层:
// 简化伪代码:真实实现应把 payload 反序列化后逐字段比对
public fun verify_proximity_proof(
assembly_id: ID,
expected_player: address,
expected_hash: vector<u8>,
proof_bytes: vector<u8>,
admin_acl: &AdminACL,
clock: &Clock,
ctx: &TxContext,
) {
verify_sponsor(admin_acl, ctx);
let payload = decode_proximity_payload(proof_bytes);
assert!(payload.assembly_id == assembly_id, EWrongAssembly);
assert!(payload.player == expected_player, EWrongPlayer);
assert!(payload.location_hash == expected_hash, EWrongLocationHash);
assert!(clock.timestamp_ms() <= payload.expires_at_ms, EProofExpired);
assert!(check_and_consume_nonce(payload.nonce), EReplay);
}
这里真正重要的是:verify_sponsor(admin_acl, ctx) 只证明“这笔交易来自授权服务端”,还不够证明“这条位置声明本身是针对当前对象、当前玩家、当前时间窗的”。
所以位置证明最容易犯的错是什么?
不是“签名算法写错”,而是 payload 绑定不完整。
一旦 payload 少绑定一项,就会出现经典复用问题:
- 绑定了玩家,没绑定对象 玩家能拿着 A 的证明去调 B
- 绑定了对象,没绑定时间窗 旧证明能被反复重放
- 绑定了时间,没绑定当前位置哈希 旧位置能冒充新位置
16.4 围绕位置系统的策略设计
即使位置是哈希的,Builder 仍然可以设计许多地理化逻辑:
策略一:位置锁定(资产绑定地点)
// 资产只在特定位置哈希处有效
public fun claim_resource(
claim: &mut ResourceClaim,
claimant_location_hash: vector<u8>, // 服务器证明的位置
admin_acl: &AdminACL,
ctx: &mut TxContext,
) {
verify_sponsor(admin_acl, ctx);
// 验证玩家位置哈希与资源点匹配
assert!(
claimant_location_hash == claim.required_location_hash,
EWrongLocation,
);
// 发放资源
}
位置系统真正有意思的地方在于:你不需要知道明文坐标,也能设计出非常强的空间规则。
这意味着 Builder 在上层业务里关心的通常不是“你具体在宇宙哪一点”,而是:
- 你是否在某个设施附近
- 你是否在某个区域内
- 你是否满足进入、提取、激活条件
这会让很多玩法更像“条件访问控制”,而不是“地图渲染系统”。
策略二:基地范围控制
public struct BaseZone has key {
id: UID,
center_hash: vector<u8>, // 基地中心位置哈希
owner: address,
zone_nft_ids: vector<ID>, // 在这个区域内的友方 NFT 列表
}
// 授权组件只对在基地范围内的玩家开放
public fun base_service(
zone: &BaseZone,
service: &mut StorageUnit,
player_in_zone_proof: vector<u8>, // 服务器证明"玩家在基地范围内"
admin_acl: &AdminACL,
ctx: &mut TxContext,
) {
verify_sponsor(admin_acl, ctx);
// ...提供服务
}
策略三:移动路径追踪(链外 + 链上结合)
// 链下:监听玩家位置更新事件
client.subscribeEvent({
filter: { MoveEventType: `${WORLD_PKG}::location::LocationUpdated` },
onMessage: (event) => {
const { assembly_id, new_hash } = event.parsedJson as any;
// 更新本地路径记录
locationHistory.push({ assembly_id, hash: new_hash, time: Date.now() });
},
});
// 链上:只存储哈希,链下解析路径
16.5 未来方向:零知识证明取代服务器信任
官方文档提到,未来计划用 ZK 证明替代当前的服务器签名:
现在:
玩家 → 服务器(你在哪里?)→ 服务器签名 → 链上验证签名
未来(ZK):
玩家 → 本地计算 ZK 证明("我知道满足这个哈希的坐标,且 < 20km")
→ 链上 ZK 验证器(无需服务器参与)
ZK 证明的优势:
- 完全去中心化,不依赖服务器诚实性
- 玩家可以证明“我在这里“而不暴露具体坐标
- 理论上可以证明任意复杂的空间关系
实际开发建议:
- 当前阶段,与服务器集成时就把 payload 结构、时间窗、nonce 和对象绑定设计清楚(见 Chapter 8)
AdminACL.verify_sponsor()只能当“来源验证”的一层,不能替代 payload 校验- 未来 ZK 上线后,尽量只替换“证明机制”,不要重写上层业务状态机
为什么现在就要按“将来可替换证明机制”的思路设计?
因为真正应该稳定的是上层业务语义,而不是今天采用的证明实现细节。
换句话说,你最好把系统拆成两层:
- 上层业务规则 例如“只有在附近时才能取出物品”
- 底层证明机制 例如今天是服务器签名,未来可能换成 ZK
这样未来升级时,你替换的是“如何证明”,而不是把整条业务状态机重写一遍。
16.5.1 失败场景与防御清单
| 失败场景 | 典型原因 | 最小防御 |
|---|---|---|
| 重放证明 | payload 没有 nonce 或过期时间 | 加 nonce + 短有效期 + 链上消费 |
| 错对象复用 | 证明没有绑定 assembly_id | payload 强绑定目标对象 |
| 错人复用 | 证明没有绑定 player | payload 强绑定调用者地址 |
| 旧位置复用 | 没有绑定 location_hash | 把当前链上哈希写入 payload |
| 服务端时钟偏差 | 过期判断不一致 | 用链上 Clock 做最终裁决 |
再补一个常被忽略的失败场景:链下缓存过旧
如果服务端拿到的是旧位置缓存,也可能签出“形式上合法、业务上错误”的证明。
所以真实系统里,还要考虑:
- 服务端位置数据来源是否足够新
- 位置采样和上链状态是否存在明显延迟
- 某些动作是否需要更短的证明有效期
16.6 在 dApp 中展示位置信息
// 位置信息对 Builder 不直接可读(哈希),但可以展示游戏内坐标
// (通过与游戏服务器 API 对接解密)
interface AssemblyDisplayInfo {
id: string
name: string
systemName: string // 星系名称(从服务器API获取)
constellation: string // 星座
region: string // 区域
onlineStatus: string
}
async function getAssemblyDisplayInfo(assemblyId: string): Promise<AssemblyDisplayInfo> {
// 1. 从链上读取哈希化位置
const obj = await suiClient.getObject({
id: assemblyId,
options: { showContent: true },
});
const locationHash = (obj.data?.content as any)?.fields?.location?.fields?.location_hash;
// 2. 通过游戏服务器 API,用哈希查询星系名称
const geoRes = await fetch(`${GAME_API}/location?hash=${locationHash}`);
const geoInfo = await geoRes.json();
return {
id: assemblyId,
name: (obj.data?.content as any)?.fields?.name,
systemName: geoInfo.system_name,
constellation: geoInfo.constellation,
region: geoInfo.region,
onlineStatus: (obj.data?.content as any)?.fields?.status,
};
}
前端展示位置时,最重要的不是“展示得多详细”,而是“不泄露不该泄露的信息”
所以前端通常更适合展示:
- 星系名
- 星座
- 区域
- 是否在线
而不应该随意展示:
- 过细的内部坐标
- 可被用来反推精确位置的调试字段
这也是为什么位置系统一定要和链下展示层一起设计,而不是只在合约里考虑哈希就完事。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 哈希位置 | 坐标哈希化存储,防止隐私泄露 |
| 临近性验证 | 当前:服务器签名 → 未来:ZK 证明 |
| AdminACL 作用 | verify_sponsor() 验证服务器的赞助地址 |
| Builder 机会 | 位置锁定、基地范围、轨迹分析 |
| ZK 展望 | 无需服务器信任的完全去中心化空间证明 |
📚 延伸阅读
Chapter 17:测试、调试与安全审计
目标: 能为 Move 合约编写完整的单元测试,识别常见安全漏洞,制定合约升级策略。
状态:工程保障章节。正文以测试、安全与升级风险控制为主。
17.1 为什么安全测试至关重要?
链上合约一旦部署,资产是真实的。以下是常见损失场景:
- 价格计算溢出,导致物品以 0 价格出售
- 权限检查遗漏,任何人都能调用 “仅 Owner” 函数
- 可重入漏洞(在 Move 中较少见但仍需关注)
- 升级失误导致旧数据无法被新合约读取
防御策略: 先测试,再发布。
这里最值得建立的观念不是“测试很重要”这种空话,而是:
链上合约测试的目标,不是证明它能跑,而是证明它在错误输入、错误顺序、错误权限下也不会失控。
很多初学者写测试,只会验证“正常路径成功”。但真实资产损失通常来自另外三类路径:
- 本来就不该成功的调用却成功了
- 边界值输入让系统进入异常状态
- 升级或维护后,旧对象与新逻辑不再兼容
所以对 Builder 来说,测试不是收尾工作,而是设计工作的一部分。
17.2 Move 单元测试基础
Move 内置了测试框架,测试代码写在同一个 .move 文件中,用 #[test] 注解标记:
module my_package::my_module;
// ... 正常合约代码 ...
// 测试模块:只在 test 环境编译
#[test_only]
module my_package::my_module_tests;
use my_package::my_module;
use sui::test_scenario::{Self, Scenario};
use sui::coin;
use sui::sui::SUI;
use sui::clock;
// ── 基础测试 ─────────────────────────────────────────────
#[test]
fun test_deposit_and_withdraw() {
// 初始化测试场景(模拟区块链状态)
let mut scenario = test_scenario::begin(@0xALICE);
// 测试步骤 1:Alice 部署合约
{
let ctx = scenario.ctx();
my_module::init_for_testing(ctx); // 测试专用 init
};
// 测试步骤 2:Alice 存入物品
scenario.next_tx(@0xALICE);
{
let mut vault = scenario.take_shared<my_module::Vault>();
let ctx = scenario.ctx();
my_module::deposit(&mut vault, 100, ctx);
assert!(my_module::balance(&vault) == 100, 0);
test_scenario::return_shared(vault);
};
// 测试步骤 3:Bob 尝试取款(应该失败)
scenario.next_tx(@0xBOB);
{
let mut vault = scenario.take_shared<my_module::Vault>();
// 期望这个调用会失败(abort)
// 用 #[test, expected_failure] 测试失败路径
test_scenario::return_shared(vault);
};
scenario.end();
}
// ── 测试失败路径 ────────────────────────────────────────
#[test]
#[expected_failure(abort_code = my_module::ENotOwner)]
fun test_unauthorized_withdraw_fails() {
let mut scenario = test_scenario::begin(@0xALICE);
// 部署
{ my_module::init_for_testing(scenario.ctx()); };
// Bob 尝试以 Alice 身份操作(应 abort)
scenario.next_tx(@0xBOB);
{
let mut vault = scenario.take_shared<my_module::Vault>();
my_module::owner_withdraw(&mut vault, scenario.ctx()); // 应 abort
test_scenario::return_shared(vault);
};
scenario.end();
}
// ── 使用 Clock 测试时间相关逻辑 ─────────────────────────
#[test]
fun test_time_based_pricing() {
let mut scenario = test_scenario::begin(@0xALICE);
let mut clock = clock::create_for_testing(scenario.ctx());
// 设置当前时间
clock.set_for_testing(1_000_000);
{
let price = my_module::get_dutch_price(
1000, // 起始价
100, // 最低价
0, // 开始时间
2_000_000, // 持续时间(2秒)
&clock,
);
// 经过一半时间,价格应为中间值
assert!(price == 550, 0);
};
clock.destroy_for_testing();
scenario.end();
}
运行测试:
# 运行所有测试
sui move test
# 只运行特定测试
sui move test test_deposit_and_withdraw
# 显示详细输出
sui move test --verbose
写测试时,先分四类场景
一个实用的测试分层是:
- 正常路径 合法输入下,系统是否按预期完成
- 权限失败路径 没有权限时,是否稳定 abort
- 边界值路径 0、最大值、过期、空集合、最后一个条目等情况是否正确
- 状态演进路径 做完一步后,再做下一步,系统是否仍然一致
如果你的测试只有第一类,那其实还不够叫“有测试”。
test_scenario 真正适合拿来干什么?
它最适合模拟:
- 多个地址轮流发起交易
- 共享对象在多笔交易里的状态变化
- 时间推进后的行为变化
- 对象创建、取回、归还的完整生命周期
这恰好就是 EVE Builder 项目最常见的风险集中区。
测试不是越细碎越好
有些测试太碎,最后只证明“小函数按字面工作”,却没有覆盖真正的业务闭环。
更有价值的做法通常是:
- 保留少量关键单元测试
- 再写几条端到端业务场景测试
例如租赁系统里,比起只测 calc_refund(),更重要的是测:
- 创建挂单
- 成功租用
- 提前归还
- 到期回收
这条完整链路是否闭合。
17.3 常见安全漏洞与防御
漏洞一:整数溢出/下溢
// ❌ 危险:u64 减法下溢会 abort,但如果逻辑错误可能算出极大值
fun unsafe_calc(a: u64, b: u64): u64 {
a - b // 如果 b > a,直接 abort(Move 会检查)
}
// ✅ 安全:在操作前检查
fun safe_calc(a: u64, b: u64): u64 {
assert!(a >= b, EInsufficientBalance);
a - b
}
// ✅ 对于有意允许的下溢,使用检查后的计算
fun safe_pct(total: u64, bps: u64): u64 {
// bps 最大 10000,防止 total * bps 溢出
assert!(bps <= 10_000, EInvalidBPS);
total * bps / 10_000 // Move u64 最大 1.8e19,需要注意大数
}
✅ Move 的优势:Move 默认会检查 u64 运算溢出,溢出时 abort 而不是静默返回错误值(不同于 Solidity 早期版本)。
但要注意,Move 帮你解决的是“机器级溢出安全”,不是“业务数学正确”。
比如下面这些问题,类型系统并不会替你思考:
- 手续费是否应该先算再扣,还是先扣再算分润
- 百分比是否该向下取整还是四舍五入
- 多地址分账后余数应该留在金库还是返给用户
很多经济 bug 最后不是“黑客级漏洞”,而是结算口径本身设计错了。
漏洞二:权限检查遗漏
// ❌ 危险:没有验证调用者
public fun withdraw_all(treasury: &mut Treasury, ctx: &mut TxContext) {
let all = coin::take(&mut treasury.balance, balance::value(&treasury.balance), ctx);
transfer::public_transfer(all, ctx.sender()); // 任何人都能取走资金!
}
// ✅ 安全:要求 OwnerCap
public fun withdraw_all(
treasury: &mut Treasury,
_cap: &TreasuryOwnerCap, // 检查调用者持有 OwnerCap
ctx: &mut TxContext,
) {
let all = coin::take(&mut treasury.balance, balance::value(&treasury.balance), ctx);
transfer::public_transfer(all, ctx.sender());
}
权限检查里最容易犯的错,是只验证“某种权限存在”,却没验证:
- 这张权限是不是这个对象的
- 这笔调用是不是当前场景允许的
- 这张权限是不是应该只在某一时段或某一路径里使用
漏洞三:Capability 未正确绑定
// ❌ 危险:OwnerCap 没有验证对应的对象 ID
public fun admin_action(vault: &mut Vault, _cap: &OwnerCap) {
// 任何 OwnerCap 都能控制任何 Vault!
}
// ✅ 安全:验证 OwnerCap 和对象的绑定关系
public fun admin_action(vault: &mut Vault, cap: &OwnerCap) {
assert!(cap.authorized_object_id == object::id(vault), ECapMismatch);
// ...
}
漏洞四:时间戳操控
// ❌ 不推荐:直接依赖 ctx.epoch() 作为精确时间
// epoch 的粒度是约 24 小时,不适合细粒度时效
// ✅ 推荐:使用 Clock 对象
public fun check_expiry(expiry_ms: u64, clock: &Clock): bool {
clock.timestamp_ms() < expiry_ms
}
漏洞五:共享对象的竞态条件
共享对象可以被多个交易并发访问。当多个交易同时抢购同一物品时:
// ❌ 有竞态问题:两个交易可能同时通过检查
public fun buy_item(market: &mut Market, ...) {
let listing = table::borrow(&market.listings, item_type_id);
assert!(listing.amount > 0, EOutOfStock);
// ← 另一个 TX 可能在这里同时通过同样的检查
// ... 然后两个都执行购买,导致超卖
}
// ✅ Sui 的解决方案:通过对共享对象的写锁确保序列化
// Sui 的 Move 执行器保证:写同一个共享对象的交易是顺序执行的
// 所以上面的代码在 Sui 上实际是安全的!但要确保你的逻辑正确处理负库存
public fun buy_item(market: &mut Market, ...) {
// 这次检查是原子的,其他 TX 会等待
assert!(table::contains(&market.listings, item_type_id), ENotListed);
let listing = table::remove(&mut market.listings, item_type_id); // 原子移除
// ...
}
虽然 Sui 会对共享对象写入做顺序化,但这不代表你就可以忽略业务竞态。
你仍然要测试:
- 同一商品被连续快速购买
- 一个对象被先下架再购买
- 价格更新与购买在相邻交易发生时的表现
也就是说,底层执行器帮你解决了一部分并发安全,但没有替你设计完整业务一致性。
17.4 使用 Move Prover 进行形式验证
Move Prover 是一个形式化验证工具,可以数学证明某些属性永远成立:
// spec 块:形式规范
spec fun total_supply_conserved(treasury: TreasuryCap<TOKEN>): bool {
// 声明:铸造后总供应量增加的精确量
ensures result == old(total_supply(treasury)) + amount;
}
#[verify_only]
spec module {
// 不变量:金库余额永远不超过某个上限
invariant forall vault: Vault:
balance::value(vault.balance) <= MAX_VAULT_SIZE;
}
运行验证:
sui move prove
Move Prover 什么时候值得上?
并不是所有项目都需要一开始就做形式验证。更实际的策略通常是:
- 普通案例和中小项目:先把单测和失败路径覆盖做好
- 高价值金库、清算、权限系统:再引入 Prover 证明关键不变量
最适合用 Prover 的地方通常包括:
- 总量守恒
- 余额不会为负
- 某类权限不能越权
- 某个状态机不会跳非法状态
17.5 合约升级策略
Move 包一旦发布是不可变的,但可以通过升级机制发布新版本:
# 首次发布
sui client publish
# 得到 UpgradeCap 对象(升级权凭证)
# 升级(需要 UpgradeCap)
sui client upgrade \
--upgrade-capability <UPGRADE_CAP_ID> \
升级兼容性规则
| 变更类型 | 是否允许 |
|---|---|
| 添加新函数 | ✅ 允许 |
| 添加新模块 | ✅ 允许 |
| 修改函数逻辑(不变签名) | ✅ 允许 |
| 修改函数签名 | ❌ 不允许 |
| 删除函数 | ❌ 不允许 |
| 修改结构体字段 | ❌ 不允许 |
| 添加结构体字段 | ❌ 不允许 |
升级真正难的不是命令,而是数据继续活着
很多人第一次做升级,会把重点放在“怎么发新包”。但用户真正关心的是:
- 旧对象还能不能继续用
- 旧前端还能不能读
- 旧事件和新对象如何一起解释
也就是说,升级本质上是在维护一个还在运行的系统,而不是重新开服。
升级前必须问的四个问题
- 旧对象是否还能被新版本安全读取?
- 新版本是否要求额外迁移脚本?
- 前端是不是要同步更新字段解析?
- 一旦升级后发现问题,有没有回滚或止损路径?
数据迁移模式
当需要改变数据结构时,使用“新旧并存“策略:
// v1:旧版存储结构
public struct MarketV1 has key {
id: UID,
price: u64,
}
// v2:新版增加字段(不能直接修改 V1)
// 改为用动态字段扩展
public fun get_expiry_v2(market: &MarketV1): Option<u64> {
if df::exists_(&market.id, b"expiry") {
option::some(*df::borrow<vector<u8>, u64>(&market.id, b"expiry"))
} else {
option::none()
}
}
// 给旧对象添加新字段(迁移脚本)
public fun migrate_add_expiry(
market: &mut MarketV1,
expiry_ms: u64,
ctx: &mut TxContext,
) {
df::add(&mut market.id, b"expiry", expiry_ms);
}
17.6 EVE Frontier 特有的安全限制
引用官方文档中的关键约束:
| 约束 | 详情 |
|---|---|
| 对象大小 | Move 对象最大 250KB |
| 动态字段 | 单次交易最多访问 1024 个 |
| 结构体字段 | 单个结构体最多 32 个字段 |
| 交易计算上限 | 超出计算限制会直接 abort |
| 某些 Admin 操作 | 仅限游戏服务器签名 |
这些限制不要只当成“文档知识点”。它们会直接影响你的建模方式。
例如:
- 对象有大小上限,你就不能把所有状态塞进一个巨物对象
- 动态字段有访问上限,你就不能假设一笔交易能扫完整个市场
- 某些操作依赖服务器签名,你就不能把系统设计成纯用户自驱
17.7 安全清单
在发布合约前,逐项检查:
权限控制
✅ 所有写函数是否都有权限验证?
✅ OwnerCap 是否验证了 authorized_object_id?
✅ AdminACL 保护的函数是否有赞助者验证?
数学运算
✅ 所有乘法是否可能溢出?(u64 最大约 1.8 × 10^19)
✅ 百分比计算是否用 bps(基点)避免精度丢失?
✅ 减法操作前是否检查了 a >= b?
状态一致性
✅ 存入和取出逻辑是否完全对称?
✅ 热土豆对象是否总是被消耗?
✅ 共享对象的原子操作是否正确?
升级兼容
✅ 有没有规划 UpgradeCap 的安全存储?
✅ 是否设计了未来的数据迁移路径?
测试覆盖
✅ 是否测试了正常路径?
✅ 是否测试了所有 assert 失败路径?
✅ 是否测试了边界值(0、最大值)?
更实用的排查顺序
每次准备发布前,建议按这个顺序过一遍:
- 权限 谁能调、调谁、调完会改什么
- 钱 钱从哪来,到哪去,中途有没有可能丢
- 状态 成功和失败后,对象是否仍保持一致
- 升级 现在这版如果以后要改,会不会把自己锁死
这比纯粹照 checklist 打勾更有用,因为它逼你按真正的风险面重新审视设计。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| Move 测试框架 | test_scenario、#[test]、#[expected_failure] |
| 溢出安全 | Move 默认检查,但要正确处理逻辑错误 |
| 权限检查 | 所有写操作必须验证 Capability + object_id 绑定 |
| 竞态条件 | Sui 写共享对象是顺序执行的,原子操作是安全的 |
| 合约升级 | UpgradeCap + 兼容性规则 + 动态字段迁移 |
| EVE Frontier 约束 | 250KB 对象,1024 动态字段/tx,32 个结构体字段 |
📚 延伸阅读
实战案例 3:链上拍卖行(智能存储单元 + 荷兰式拍卖)
目标: 将 Smart Storage Unit 改造为荷兰式拍卖(价格随时间递减),物品自动流转给出价者,完整实现拍卖合约 + 竞拍者 dApp + Owner 管理面板。
状态:已附合约、dApp 与 Move 测试文件。正文已经接近完整案例,适合作为“定价策略 + 前端倒计时”范例。
对应代码目录
最小调用链
Owner 创建拍卖 -> 时间递减价格 -> 买家支付当前价 -> 拍卖结算 -> 物品流转
需求分析
场景: 你控制着一个存储着珍稀矿石的智能存储箱。相比固定价格,你希望通过荷兰式拍卖(价格从高到低递减)来最大化销售收益,并让价格发现更加透明:
- 🕐 拍卖开始时以 5000 LUX 起拍
- 📉 每 10 分钟降低 500 LUX
- 🏆 最低价为 500 LUX,价格不再下降
- ⚡ 任何时候有人支付当前价格,物品立即成交
- 📊 dApp 实时显示倒计时和当前价格
第一部分:Move 合约
目录结构
dutch-auction/
├── Move.toml
└── sources/
├── dutch_auction.move # 荷兰拍卖逻辑
└── auction_manager.move # 拍卖管理(创建/结束)
核心合约:dutch_auction.move
module dutch_auction::auction;
use world::storage_unit::{Self, StorageUnit};
use world::character::Character;
use world::inventory::Item;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::clock::Clock;
use sui::object::{Self, UID, ID};
use sui::event;
use sui::transfer;
/// SSU 扩展 Witness
public struct AuctionAuth has drop {}
/// 拍卖状态
public struct DutchAuction has key {
id: UID,
storage_unit_id: ID, // 绑定的存储箱
item_type_id: u64, // 拍卖的物品类型
start_price: u64, // 起始价(MIST)
end_price: u64, // 最低价
start_time_ms: u64, // 拍卖开始时间
price_drop_interval_ms: u64, // 每次降价间隔(毫秒)
price_drop_amount: u64, // 每次降价幅度
is_active: bool, // 是否仍在进行
proceeds: Balance<SUI>, // 拍卖收益
owner: address, // 拍卖创建者
}
/// 事件
public struct AuctionCreated has copy, drop {
auction_id: ID,
item_type_id: u64,
start_price: u64,
end_price: u64,
}
public struct AuctionSettled has copy, drop {
auction_id: ID,
winner: address,
final_price: u64,
item_type_id: u64,
}
// ── 计算当前价格 ─────────────────────────────────────────
public fun current_price(auction: &DutchAuction, clock: &Clock): u64 {
if !auction.is_active {
return auction.end_price
}
let elapsed_ms = clock.timestamp_ms() - auction.start_time_ms;
let drops = elapsed_ms / auction.price_drop_interval_ms;
let total_drop = drops * auction.price_drop_amount;
if total_drop >= auction.start_price - auction.end_price {
auction.end_price // 已降到最低价
} else {
auction.start_price - total_drop
}
}
/// 计算下次降价的剩余时间(毫秒)
public fun ms_until_next_drop(auction: &DutchAuction, clock: &Clock): u64 {
let elapsed = clock.timestamp_ms() - auction.start_time_ms;
let interval = auction.price_drop_interval_ms;
let next_drop_at = (elapsed / interval + 1) * interval;
next_drop_at - elapsed
}
// ── 创建拍卖 ─────────────────────────────────────────────
public fun create_auction(
storage_unit: &StorageUnit,
item_type_id: u64,
start_price: u64,
end_price: u64,
price_drop_interval_ms: u64,
price_drop_amount: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(start_price > end_price, EInvalidPricing);
assert!(price_drop_amount > 0, EInvalidDropAmount);
assert!(price_drop_interval_ms >= 60_000, EIntervalTooShort); // 最小1分钟
let auction = DutchAuction {
id: object::new(ctx),
storage_unit_id: object::id(storage_unit),
item_type_id,
start_price,
end_price,
start_time_ms: clock.timestamp_ms(),
price_drop_interval_ms,
price_drop_amount,
is_active: true,
proceeds: balance::zero(),
owner: ctx.sender(),
};
event::emit(AuctionCreated {
auction_id: object::id(&auction),
item_type_id,
start_price,
end_price,
});
transfer::share_object(auction);
}
// ── 竞拍:支付当前价格获得物品 ──────────────────────────
public fun buy_now(
auction: &mut DutchAuction,
storage_unit: &mut StorageUnit,
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
): Item {
assert!(auction.is_active, EAuctionEnded);
let price = current_price(auction, clock);
assert!(coin::value(&payment) >= price, EInsufficientPayment);
// 退还多付的部分
let change_amount = coin::value(&payment) - price;
if change_amount > 0 {
let change = payment.split(change_amount, ctx);
transfer::public_transfer(change, ctx.sender());
}
// 收入进入拍卖金库
balance::join(&mut auction.proceeds, coin::into_balance(payment));
auction.is_active = false;
event::emit(AuctionSettled {
auction_id: object::id(auction),
winner: ctx.sender(),
final_price: price,
item_type_id: auction.item_type_id,
});
// 从 SSU 取出物品
storage_unit::withdraw_item(
storage_unit,
character,
AuctionAuth {},
auction.item_type_id,
ctx,
)
}
// ── Owner:提取拍卖收益 ──────────────────────────────────
public fun withdraw_proceeds(
auction: &mut DutchAuction,
ctx: &mut TxContext,
) {
assert!(ctx.sender() == auction.owner, ENotOwner);
assert!(!auction.is_active, EAuctionStillActive);
let amount = balance::value(&auction.proceeds);
let coin = coin::take(&mut auction.proceeds, amount, ctx);
transfer::public_transfer(coin, ctx.sender());
}
// ── Owner:取消拍卖 ──────────────────────────────────────
public fun cancel_auction(
auction: &mut DutchAuction,
storage_unit: &mut StorageUnit,
character: &Character,
ctx: &mut TxContext,
): Item {
assert!(ctx.sender() == auction.owner, ENotOwner);
assert!(auction.is_active, EAuctionAlreadyEnded);
auction.is_active = false;
// 将物品取回给 Owner
storage_unit::withdraw_item(
storage_unit, character, AuctionAuth {}, auction.item_type_id, ctx,
)
}
// 错误码
const EInvalidPricing: u64 = 0;
const EInvalidDropAmount: u64 = 1;
const EIntervalTooShort: u64 = 2;
const EAuctionEnded: u64 = 3;
const EInsufficientPayment: u64 = 4;
const EAuctionStillActive: u64 = 5;
const EAuctionAlreadyEnded: u64 = 6;
const ENotOwner: u64 = 7;
第二部分:单元测试
#[test_only]
module dutch_auction::auction_tests;
use dutch_auction::auction;
use sui::test_scenario;
use sui::clock;
use sui::coin;
use sui::sui::SUI;
#[test]
fun test_price_decreases_over_time() {
let mut scenario = test_scenario::begin(@0xOwner);
let mut clock = clock::create_for_testing(scenario.ctx());
// 设置0时刻
clock.set_for_testing(0);
// 创建伪造拍卖对象测试价格计算
let auction = auction::create_test_auction(
5000, // start_price
500, // end_price
600_000, // 10分钟 (ms)
500, // 每次降 500
&clock,
scenario.ctx(),
);
// 时刻 0:价格应为 5000
assert!(auction::current_price(&auction, &clock) == 5000, 0);
// 经过 10 分钟:价格应为 4500
clock.set_for_testing(600_000);
assert!(auction::current_price(&auction, &clock) == 4500, 0);
// 经过 90 分钟(降价9次 × 500 = 4500,但最低 500):价格应为 500
clock.set_for_testing(5_400_000);
assert!(auction::current_price(&auction, &clock) == 500, 0);
clock.destroy_for_testing();
auction.destroy_test_auction();
scenario.end();
}
#[test]
#[expected_failure(abort_code = auction::EInsufficientPayment)]
fun test_underpayment_fails() {
// ...测试支付不足时的失败路径
}
第三部分:竞拍者 dApp
// src/AuctionApp.tsx
import { useState, useEffect, useCallback } from 'react'
import { useConnection, getObjectWithJson } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const DUTCH_PACKAGE = "0x_DUTCH_PACKAGE_"
const AUCTION_ID = "0x_AUCTION_ID_"
const STORAGE_UNIT_ID = "0x..."
const CHARACTER_ID = "0x..."
const CLOCK_OBJECT_ID = "0x6"
interface AuctionState {
start_price: string
end_price: string
start_time_ms: string
price_drop_interval_ms: string
price_drop_amount: string
is_active: boolean
item_type_id: string
}
function calculateCurrentPrice(state: AuctionState): number {
if (!state.is_active) return Number(state.end_price)
const now = Date.now()
const elapsed = now - Number(state.start_time_ms)
const drops = Math.floor(elapsed / Number(state.price_drop_interval_ms))
const totalDrop = drops * Number(state.price_drop_amount)
const maxDrop = Number(state.start_price) - Number(state.end_price)
if (totalDrop >= maxDrop) return Number(state.end_price)
return Number(state.start_price) - totalDrop
}
function msUntilNextDrop(state: AuctionState): number {
const now = Date.now()
const elapsed = now - Number(state.start_time_ms)
const interval = Number(state.price_drop_interval_ms)
return interval - (elapsed % interval)
}
export function AuctionApp() {
const { isConnected, handleConnect } = useConnection()
const dAppKit = useDAppKit()
const [auctionState, setAuctionState] = useState<AuctionState | null>(null)
const [currentPrice, setCurrentPrice] = useState(0)
const [countdown, setCountdown] = useState(0)
const [status, setStatus] = useState('')
const [isBuying, setIsBuying] = useState(false)
// 加载拍卖状态
const loadAuction = useCallback(async () => {
const obj = await getObjectWithJson(AUCTION_ID)
if (obj?.content?.dataType === 'moveObject') {
const fields = obj.content.fields as AuctionState
setAuctionState(fields)
}
}, [])
useEffect(() => {
loadAuction()
}, [loadAuction])
// 每秒更新价格倒计时
useEffect(() => {
if (!auctionState) return
const timer = setInterval(() => {
setCurrentPrice(calculateCurrentPrice(auctionState))
setCountdown(msUntilNextDrop(auctionState))
}, 1000)
return () => clearInterval(timer)
}, [auctionState])
const handleBuyNow = async () => {
if (!isConnected) { setStatus('请先连接钱包'); return }
setIsBuying(true)
setStatus('⏳ 提交交易...')
try {
const tx = new Transaction()
const [paymentCoin] = tx.splitCoins(tx.gas, [
tx.pure.u64(currentPrice + 1_000) // 略多于当前价,防止最后一秒涨价
])
tx.moveCall({
target: `${DUTCH_PACKAGE}::auction::buy_now`,
arguments: [
tx.object(AUCTION_ID),
tx.object(STORAGE_UNIT_ID),
tx.object(CHARACTER_ID),
paymentCoin,
tx.object(CLOCK_OBJECT_ID),
],
})
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`🏆 竞拍成功!Tx: ${result.digest.slice(0, 12)}...`)
await loadAuction()
} catch (e: any) {
setStatus(`❌ ${e.message}`)
} finally {
setIsBuying(false)
}
}
const countdownSec = Math.ceil(countdown / 1000)
const priceInSui = (currentPrice / 1e9).toFixed(2)
const nextPriceSui = (
Math.max(Number(auctionState?.end_price ?? 0), currentPrice - Number(auctionState?.price_drop_amount ?? 0)) / 1e9
).toFixed(2)
return (
<div className="auction-app">
<header>
<h1>🔨 荷兰式拍卖行</h1>
{!isConnected
? <button onClick={handleConnect}>连接钱包</button>
: <span className="connected">✅ 已连接</span>
}
</header>
{auctionState ? (
<div className="auction-board">
<div className="current-price">
<span className="label">当前价格</span>
<span className="price">{priceInSui} SUI</span>
</div>
<div className="countdown">
<span className="label">⏳ {countdownSec} 秒后降为</span>
<span className="next-price">{nextPriceSui} SUI</span>
</div>
<div className="info-row">
<span>起拍价:{(Number(auctionState.start_price) / 1e9).toFixed(2)} SUI</span>
<span>最低价:{(Number(auctionState.end_price) / 1e9).toFixed(2)} SUI</span>
</div>
{auctionState.is_active ? (
<button
className="buy-btn"
onClick={handleBuyNow}
disabled={isBuying || !isConnected}
>
{isBuying ? '⏳ 购买中...' : `💰 立即购买 ${priceInSui} SUI`}
</button>
) : (
<div className="sold-banner">🎉 已售出</div>
)}
{status && <p className="tx-status">{status}</p>}
</div>
) : (
<div>加载拍卖信息...</div>
)}
</div>
)
}
🎯 完整回顾
合约层
├── create_auction() → 创建共享 DutchAuction 对象
├── current_price() → 根据时间计算当前价格(纯计算,不修改状态)
├── buy_now() → 支付 → 收益入金库 → SSU 取出物品 → 发事件
├── cancel_auction() → Owner 取消,物品归还
└── withdraw_proceeds() → Owner 提取拍卖收益
dApp 层
├── 每秒重新计算价格(纯前端,不消耗 Gas)
├── 倒计时显示下次降价时间
└── 一键购买,自动附上当前价格
🔧 扩展练习
- 支持批量拍卖:同时拍卖多种物品,每种独立倒计时
- 预约购买:玩家设定目标价格,自动在达到时触发购买(链下监听 + 定时提交)
- 历史成交记录:监听
AuctionSettled事件展示近期成交数据
📚 关联文档
实战案例 6:动态 NFT 装备系统(可进化的飞船武器)
目标: 创建一套飞船武器 NFT,其属性随游戏战斗结果自动升级;利用 Sui Display 标准确保 NFT 在所有钱包和市场中实时显示最新状态。
状态:教学示例。正文聚焦动态 NFT 和 Display 更新,完整目录以
book/src/code/example-06/为准。
对应代码目录
最小调用链
玩家持有武器 NFT -> 击杀事件累加 -> 达到阈值升级 -> Display 元数据更新 -> 钱包/市场显示新外观
需求分析
场景: 你设计了一款“成长型武器“系统——玩家获得一把 PlasmaRifle,初始是一把普通武器,随着每次击杀积累,自动升级外观和属性:
- ⚪ 初级(0-9 击杀):Plasma Rifle Mk.1,基础伤害
- 🔵 精英(10-49 击杀):Plasma Rifle Mk.2,图片变为精英版本,伤害+30%
- 🟡 传奇(50+ 击杀):Plasma Rifle Mk.3 “Inferno”,图片变为传奇版本,特殊效果
第一部分:NFT 合约
module dynamic_nft::plasma_rifle;
use sui::object::{Self, UID};
use sui::display;
use sui::package;
use sui::transfer;
use sui::event;
use std::string::{Self, String, utf8};
// ── 一次性见证 ─────────────────────────────────────────────
public struct PLASMA_RIFLE has drop {}
// ── 武器等级常量 ───────────────────────────────────────────
const TIER_BASIC: u8 = 1;
const TIER_ELITE: u8 = 2;
const TIER_LEGENDARY: u8 = 3;
const KILLS_FOR_ELITE: u64 = 10;
const KILLS_FOR_LEGENDARY: u64 = 50;
// ── 数据结构 ───────────────────────────────────────────────
public struct PlasmaRifle has key, store {
id: UID,
name: String,
tier: u8,
kills: u64,
damage_bonus_pct: u64, // 伤害加成(百分比)
image_url: String,
description: String,
owner_history: u64, // 历史流通次数
}
public struct ForgeAdminCap has key, store {
id: UID,
}
// ── 事件 ──────────────────────────────────────────────────
public struct RifleEvolved has copy, drop {
rifle_id: ID,
from_tier: u8,
to_tier: u8,
total_kills: u64,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(witness: PLASMA_RIFLE, ctx: &mut TxContext) {
let publisher = package::claim(witness, ctx);
let keys = vector[
utf8(b"name"),
utf8(b"description"),
utf8(b"image_url"),
utf8(b"attributes"),
utf8(b"project_url"),
];
let values = vector[
utf8(b"{name}"),
utf8(b"{description}"),
utf8(b"{image_url}"),
// attributes 拼接多个字段
utf8(b"[{\"trait_type\":\"Tier\",\"value\":\"{tier}\"},{\"trait_type\":\"Kills\",\"value\":\"{kills}\"},{\"trait_type\":\"Damage Bonus\",\"value\":\"{damage_bonus_pct}%\"}]"),
utf8(b"https://evefrontier.com/weapons"),
];
let mut display = display::new_with_fields<PlasmaRifle>(
&publisher, keys, values, ctx,
);
display::update_version(&mut display);
let admin_cap = ForgeAdminCap { id: object::new(ctx) };
transfer::public_transfer(publisher, ctx.sender());
transfer::public_freeze_object(display);
transfer::public_transfer(admin_cap, ctx.sender());
}
// ── 铸造初始武器 ──────────────────────────────────────────
public fun forge_rifle(
_admin: &ForgeAdminCap,
recipient: address,
ctx: &mut TxContext,
) {
let rifle = PlasmaRifle {
id: object::new(ctx),
name: utf8(b"Plasma Rifle Mk.1"),
tier: TIER_BASIC,
kills: 0,
damage_bonus_pct: 0,
image_url: utf8(b"https://assets.example.com/weapons/plasma_mk1.png"),
description: utf8(b"A standard-issue plasma rifle. Prove yourself in combat."),
owner_history: 0,
};
transfer::public_transfer(rifle, recipient);
}
// ── 记录击杀(炮塔扩展调用此函数)────────────────────────
public fun record_kill(
rifle: &mut PlasmaRifle,
ctx: &TxContext,
) {
rifle.kills = rifle.kills + 1;
check_and_evolve(rifle);
}
fun check_and_evolve(rifle: &mut PlasmaRifle) {
let old_tier = rifle.tier;
if rifle.kills >= KILLS_FOR_LEGENDARY && rifle.tier < TIER_LEGENDARY {
rifle.tier = TIER_LEGENDARY;
rifle.name = utf8(b"Plasma Rifle Mk.3 \"Inferno\"");
rifle.damage_bonus_pct = 60;
rifle.image_url = utf8(b"https://assets.example.com/weapons/plasma_legendary.png");
rifle.description = utf8(b"This weapon has bathed in the fires of a thousand battles. Its plasma burns with legendary fury.");
} else if rifle.kills >= KILLS_FOR_ELITE && rifle.tier < TIER_ELITE {
rifle.tier = TIER_ELITE;
rifle.name = utf8(b"Plasma Rifle Mk.2");
rifle.damage_bonus_pct = 30;
rifle.image_url = utf8(b"https://assets.example.com/weapons/plasma_mk2.png");
rifle.description = utf8(b"Battle-hardened and upgraded. The plasma cells burn hotter than standard.");
};
if old_tier != rifle.tier {
event::emit(RifleEvolved {
rifle_id: object::id(rifle),
from_tier: old_tier,
to_tier: rifle.tier,
total_kills: rifle.kills,
});
}
}
// ── 读取函数 ──────────────────────────────────────────────
public fun get_tier(rifle: &PlasmaRifle): u8 { rifle.tier }
public fun get_kills(rifle: &PlasmaRifle): u64 { rifle.kills }
public fun get_damage_bonus(rifle: &PlasmaRifle): u64 { rifle.damage_bonus_pct }
// ── 转让追踪(可选) ─────────────────────────────────────
// 如果使用 TransferPolicy,可以追踪转让次数
// 此处简化为通过事件监听实现
第二部分:炮塔扩展 — 战斗结果上报武器
module dynamic_nft::turret_combat;
use dynamic_nft::plasma_rifle::{Self, PlasmaRifle};
use world::turret::{Self, Turret};
use world::character::Character;
public struct CombatAuth has drop {}
/// 炮塔击杀事件(炮塔扩展调用)
public fun on_kill(
turret: &Turret,
killer: &Character,
weapon: &mut PlasmaRifle, // 玩家使用的武器
ctx: &TxContext,
) {
// 验证是合法的炮塔扩展调用(需要 CombatAuth)
turret::verify_extension(turret, CombatAuth {});
// 记录击杀到武器
plasma_rifle::record_kill(weapon, ctx);
}
第三部分:前端武器展示 dApp
// src/WeaponDisplay.tsx
import { useState, useEffect } from 'react'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useRealtimeEvents } from './hooks/useRealtimeEvents'
const DYNAMIC_NFT_PKG = "0x_DYNAMIC_NFT_PACKAGE_"
interface RifleData {
name: string
tier: string
kills: string
damage_bonus_pct: string
image_url: string
description: string
}
const TIER_COLORS = {
'1': '#9CA3AF', // 灰色(普通)
'2': '#3B82F6', // 蓝色(精英)
'3': '#F59E0B', // 金色(传奇)
}
const TIER_LABELS = { '1': 'Basic', '2': 'Elite', '3': 'Legendary' }
export function WeaponDisplay({ rifleId }: { rifleId: string }) {
const client = useCurrentClient()
const [rifle, setRifle] = useState<RifleData | null>(null)
const [justEvolved, setJustEvolved] = useState(false)
const loadRifle = async () => {
const obj = await client.getObject({
id: rifleId,
options: { showContent: true },
})
if (obj.data?.content?.dataType === 'moveObject') {
setRifle(obj.data.content.fields as RifleData)
}
}
useEffect(() => { loadRifle() }, [rifleId])
// 监听进化事件
const evolutions = useRealtimeEvents<{
rifle_id: string; from_tier: string; to_tier: string; total_kills: string
}>(`${DYNAMIC_NFT_PKG}::plasma_rifle::RifleEvolved`)
useEffect(() => {
const myEvolution = evolutions.find(e => e.rifle_id === rifleId)
if (myEvolution) {
setJustEvolved(true)
loadRifle() // 重新加载最新数据
setTimeout(() => setJustEvolved(false), 5000)
}
}, [evolutions])
if (!rifle) return <div className="loading">加载武器数据...</div>
const tierColor = TIER_COLORS[rifle.tier as keyof typeof TIER_COLORS]
const tierLabel = TIER_LABELS[rifle.tier as keyof typeof TIER_LABELS]
const killsForNextTier = rifle.tier === '1'
? 10 : rifle.tier === '2' ? 50 : null
const progress = killsForNextTier
? Math.min(100, (Number(rifle.kills) / killsForNextTier) * 100) : 100
return (
<div className="weapon-card" style={{ borderColor: tierColor }}>
{justEvolved && (
<div className="evolution-banner">
✨ 武器已进化!
</div>
)}
<div className="weapon-image-container">
<img
src={rifle.image_url}
alt={rifle.name}
className={`weapon-image tier-${rifle.tier}`}
/>
<span className="tier-badge" style={{ background: tierColor }}>
{tierLabel}
</span>
</div>
<div className="weapon-info">
<h2>{rifle.name}</h2>
<p className="description">{rifle.description}</p>
<div className="stats">
<div className="stat">
<span>⚔️ 击杀数</span>
<strong>{rifle.kills}</strong>
</div>
<div className="stat">
<span>💥 伤害加成</span>
<strong>+{rifle.damage_bonus_pct}%</strong>
</div>
</div>
{killsForNextTier && (
<div className="evolution-progress">
<span>进化进度:{rifle.kills} / {killsForNextTier} 击杀</span>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%`, background: tierColor }}
/>
</div>
</div>
)}
{!killsForNextTier && (
<div className="max-tier-badge">👑 已达最高等级</div>
)}
</div>
</div>
)
}
🎯 完整回顾
合约层
├── plasma_rifle.move
│ ├── PlasmaRifle(NFT 对象,字段随战斗更新)
│ ├── Display(模板引用字段 → 钱包自动同步显示)
│ ├── forge_rifle() ← Owner 铸造发放
│ ├── record_kill() ← 炮塔合约调用
│ └── check_and_evolve() ← 内部:检查阈值,升级字段 + 发事件
│
└── turret_combat.move
└── on_kill() ← 炮塔击杀时调用武器升级
dApp 层
└── WeaponDisplay.tsx
├── 订阅 RifleEvolved 事件(一旦进化立即刷新)
├── 动态颜色主题(按等级)
└── 进化进度条
🔧 扩展练习
- 武器磨损:每次使用降低
durability字段,质量下降后伤害减少(需要修理) - 特殊属性:传奇等级随机获得特殊词缀(用随机数 + 动态字段)
- 武器融合:两把 Elite 武器销毁 → 铸造一把 Legendary(材料消耗型升级)
📚 关联文档
实战案例 7:星门物流网络(多跳路由系统)
目标: 构建一个联盟拥有多个星门的物流网络,支持“A → B → C“多跳路由,链下计算最优路径,链上原子执行多次跳跃;并提供路由规划 dApp。
状态:教学示例。正文聚焦多跳路由和链下规划,完整目录以
book/src/code/example-07/为准。
对应代码目录
最小调用链
链下计算最优路由 -> 构建多跳 PTB -> 链上原子执行所有跳跃 -> 全部成功或全部回滚
需求分析
场景: 你的联盟控制着 5 个互联星门,形成如下拓扑:
Mining Area ──[Gate1]──► Hub Alpha ──[Gate2]──► Trade Hub
│
[Gate3]
│
Refinery ──[Gate4]──► Manufacturing
│
[Gate5]
│
Safe Harbor
要求:
- 玩家可以一次性购买“多跳通行证“,完成 A→Hub Alpha→Trade Hub 这样的复合路由
- 路由计算在链下进行(节省 Gas)
- 链上原子执行:要么全部跳跃成功,要么全部回滚
- dApp 提供可视化路线规划器
第一部分:多跳路由合约
module logistics::multi_hop;
use world::gate::{Self, Gate};
use world::character::Character;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::clock::Clock;
use sui::object::{Self, ID};
use sui::event;
public struct LogisticsAuth has drop {}
/// 一次购买多跳路线
public fun purchase_route(
source_gate: &Gate,
hop1_dest: &Gate, // 第一跳目的
hop2_source: &Gate, // 第二跳起点(= hop1_dest 的链接门)
hop2_dest: &Gate, // 第二跳目的
character: &Character,
mut payment: Coin<SUI>, // 支付两跳的总费用
clock: &Clock,
ctx: &mut TxContext,
) {
// 验证路线连续性:hop1_dest 和 hop2_source 必须是链接的星门
assert!(
gate::are_linked(hop1_dest, hop2_source),
ERouteDiscontinuous,
);
// 计算并扣除每跳费用
let hop1_toll = get_toll(source_gate);
let hop2_toll = get_toll(hop2_source);
let total_toll = hop1_toll + hop2_toll;
assert!(coin::value(&payment) >= total_toll, EInsufficientPayment);
// 退还找零
let change = payment.split(coin::value(&payment) - total_toll, ctx);
if coin::value(&change) > 0 {
transfer::public_transfer(change, ctx.sender());
} else { coin::destroy_zero(change); }
// 发放两个 JumpPermit(1小时有效期)
let expires = clock.timestamp_ms() + 60 * 60 * 1000;
gate::issue_jump_permit(
source_gate, hop1_dest, character, LogisticsAuth {}, expires, ctx,
);
gate::issue_jump_permit(
hop2_source, hop2_dest, character, LogisticsAuth {}, expires, ctx,
);
// 扣除收费
let hop1_coin = payment.split(hop1_toll, ctx);
let hop2_coin = payment;
collect_toll(source_gate, hop1_coin, ctx);
collect_toll(hop2_source, hop2_coin, ctx);
event::emit(RouteTicketIssued {
character_id: object::id(character),
gates: vector[object::id(source_gate), object::id(hop1_dest), object::id(hop2_dest)],
total_toll,
});
}
/// 通用 N 跳路由(接受可变长度路线)
public fun purchase_route_n_hops(
gates: vector<&Gate>, // 星门列表 [A, B, C, D, ...]
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let n = vector::length(&gates);
assert!(n >= 2, ETooFewGates);
assert!(n <= 6, ETooManyHops); // 防止超大交易
// 验证路线连续性(每对相邻目的/起点必须链接)
let mut i = 1;
while (i < n - 1) {
assert!(
gate::are_linked(vector::borrow(&gates, i), vector::borrow(&gates, i)),
ERouteDiscontinuous,
);
i = i + 1;
};
// 计算总费用
let mut total: u64 = 0;
let mut j = 0;
while (j < n - 1) {
total = total + get_toll(vector::borrow(&gates, j));
j = j + 1;
};
assert!(coin::value(&payment) >= total, EInsufficientPayment);
// 发放所有 Permit
let expires = clock.timestamp_ms() + 60 * 60 * 1000;
let mut k = 0;
while (k < n - 1) {
gate::issue_jump_permit(
vector::borrow(&gates, k),
vector::borrow(&gates, k + 1),
character,
LogisticsAuth {},
expires,
ctx,
);
k = k + 1;
};
// 退款找零
let change = payment.split(coin::value(&payment) - total, ctx);
if coin::value(&change) > 0 {
transfer::public_transfer(change, ctx.sender());
} else { coin::destroy_zero(change); }
// 处理 payment 到各个星门金库...
}
fun get_toll(gate: &Gate): u64 {
// 从星门的扩展数据读取通行费(动态字段)
// 简化版:固定费率
10_000_000_000 // 10 SUI
}
fun collect_toll(gate: &Gate, coin: Coin<SUI>, ctx: &TxContext) {
// 将 coin 转到星门对应的 Treasury
// ...
}
public struct RouteTicketIssued has copy, drop {
character_id: ID,
gates: vector<ID>,
total_toll: u64,
}
const ERouteDiscontinuous: u64 = 0;
const EInsufficientPayment: u64 = 1;
const ETooFewGates: u64 = 2;
const ETooManyHops: u64 = 3;
第二部分:链下路径规划(Dijkstra)
// lib/routePlanner.ts
interface Gate {
id: string
name: string
linkedGates: string[] // 链接的星门 ID 列表
tollAmount: number // 通行费(SUI)
}
interface Route {
gateIds: string[]
totalToll: number
hops: number
}
// Dijkstra 最短路径(以通行费为权重)
export function findCheapestRoute(
gateMap: Map<string, Gate>,
fromId: string,
toId: string,
): Route | null {
const dist = new Map<string, number>()
const prev = new Map<string, string | null>()
const unvisited = new Set(gateMap.keys())
for (const id of gateMap.keys()) {
dist.set(id, Infinity)
prev.set(id, null)
}
dist.set(fromId, 0)
while (unvisited.size > 0) {
// 找距离最小的未访问节点
let current: string | null = null
let minDist = Infinity
for (const id of unvisited) {
if ((dist.get(id) ?? Infinity) < minDist) {
minDist = dist.get(id)!
current = id
}
}
if (!current || current === toId) break
unvisited.delete(current)
const gate = gateMap.get(current)!
for (const neighborId of gate.linkedGates) {
const neighbor = gateMap.get(neighborId)
if (!neighbor || !unvisited.has(neighborId)) continue
const newDist = (dist.get(current) ?? 0) + neighbor.tollAmount
if (newDist < (dist.get(neighborId) ?? Infinity)) {
dist.set(neighborId, newDist)
prev.set(neighborId, current)
}
}
}
if (dist.get(toId) === Infinity) return null // 不可达
// 重建路径
const path: string[] = []
let cur: string | null = toId
while (cur) {
path.unshift(cur)
cur = prev.get(cur) ?? null
}
return {
gateIds: path,
totalToll: dist.get(toId) ?? 0,
hops: path.length - 1,
}
}
第三部分:路由规划 dApp
// src/RoutePlannerApp.tsx
import { useState, useEffect } from 'react'
import { useConnection } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { findCheapestRoute } from '../lib/routePlanner'
import { Transaction } from '@mysten/sui/transactions'
const LOGISTICS_PKG = "0x_LOGISTICS_PACKAGE_"
// 星门网络拓扑(通常从链上读取)
const GATE_NETWORK = new Map([
['gate_mining', { id: 'gate_mining', name: '矿区入口', linkedGates: ['gate_hub_alpha'], tollAmount: 5 }],
['gate_hub_alpha', { id: 'gate_hub_alpha', name: 'Hub Alpha', linkedGates: ['gate_mining', 'gate_trade', 'gate_refinery'], tollAmount: 3 }],
['gate_trade', { id: 'gate_trade', name: '贸易中心', linkedGates: ['gate_hub_alpha'], tollAmount: 8 }],
['gate_refinery', { id: 'gate_refinery', name: '精炼厂', linkedGates: ['gate_hub_alpha', 'gate_manufacturing', 'gate_harbor'], tollAmount: 4 }],
['gate_manufacturing', { id: 'gate_manufacturing', name: '制造厂', linkedGates: ['gate_refinery'], tollAmount: 6 }],
['gate_harbor', { id: 'gate_harbor', name: '安全港湾', linkedGates: ['gate_refinery'], tollAmount: 2 }],
])
export function RoutePlannerApp() {
const { isConnected, handleConnect } = useConnection()
const dAppKit = useDAppKit()
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [route, setRoute] = useState<{gateIds: string[]; totalToll: number; hops: number} | null>(null)
const [status, setStatus] = useState('')
const planRoute = () => {
if (!from || !to) return
const result = findCheapestRoute(GATE_NETWORK, from, to)
setRoute(result)
}
const purchaseRoute = async () => {
if (!route || route.gateIds.length < 2) return
const tx = new Transaction()
// 准备支付(总费用 + 5% 缓冲防止价格变动)
const totalSui = Math.ceil(route.totalToll * 1.05) * 1e9
const [paymentCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(totalSui)])
// 构建星门参数列表
const gateArgs = route.gateIds.map(id => tx.object(id))
// 调用多跳路由合约
if (route.hops === 2) {
tx.moveCall({
target: `${LOGISTICS_PKG}::multi_hop::purchase_route`,
arguments: [
gateArgs[0], gateArgs[1], gateArgs[1], gateArgs[2],
tx.object('CHARACTER_ID'),
paymentCoin,
tx.object('0x6'),
],
})
}
try {
setStatus('⏳ 购买路线通行证...')
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 路线购买成功!Tx: ${result.digest.slice(0, 12)}...`)
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
return (
<div className="route-planner">
<h1>🗺 星门物流路线规划</h1>
<div className="planner-inputs">
<div>
<label>出发星门</label>
<select value={from} onChange={e => setFrom(e.target.value)}>
<option value="">选择出发地...</option>
{[...GATE_NETWORK.values()].map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div className="arrow">→</div>
<div>
<label>目的星门</label>
<select value={to} onChange={e => setTo(e.target.value)}>
<option value="">选择目的地...</option>
{[...GATE_NETWORK.values()].map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<button onClick={planRoute} disabled={!from || !to || from === to}>
📍 规划路线
</button>
</div>
{route && (
<div className="route-result">
<h3>最优路线(费用最低)</h3>
<div className="route-path">
{route.gateIds.map((id, i) => (
<>
<span key={id} className="gate-node">
{GATE_NETWORK.get(id)?.name}
</span>
{i < route.gateIds.length - 1 && (
<span className="arrow-icon">→</span>
)}
</>
))}
</div>
<div className="route-stats">
<span>🔀 跳跃次数:{route.hops}</span>
<span>💰 总费用:{route.totalToll} SUI</span>
</div>
<button
className="purchase-btn"
onClick={purchaseRoute}
disabled={!isConnected}
>
{isConnected ? '🚀 一键购买全程通行证' : '请先连接钱包'}
</button>
</div>
)}
{route === null && from && to && from !== to && (
<p className="no-route">⚠️ 找不到从 {from} 到 {to} 的路线</p>
)}
{status && <p className="status">{status}</p>}
</div>
)
}
🎯 完整回顾
合约层
├── multi_hop.move
│ ├── purchase_route() → 两跳快速版(指定4个星门参数)
│ ├── purchase_route_n_hops() → N跳通用版(vector参数,最多6跳)
│ └── LogisticsAuth {} → 星门扩展 Witness
链下路径规划
└── routePlanner.ts
└── findCheapestRoute() → Dijkstra,以通行费为权重
dApp 层
└── RoutePlannerApp.tsx
├── 下拉选择出发/目的地
├── 调用 Dijkstra 展示最优路线
└── 一键购买全程通行证
🔧 扩展练习
- 最短跳数路由:实现第二种模式(优先减少跳数而不是费用)
- 实时拥堵感知:监听 GateJumped 事件,计算最近 5 分钟各星门流量,路由时避开拥堵
- 物品护送保险:购买路线时可额外购买“物品损失险“NFT,失败时赔付
📚 关联文档
实战案例 9:跨 Builder 协议聚合市场
目标: 设计一个“协议适配器“层,让用户在一个 dApp 中同时访问多个不同 Builder 发布的市场合约(尽管它们接口各异),实现类似 DEX 聚合器的体验。
状态:教学示例。当前案例以聚合器架构和适配器分层为主,重点在统一接口而非单个 Move 合约。
对应代码目录
最小调用链
前端查询多个市场 -> 适配器归一化报价 -> 选出最优市场 -> 按对应协议提交购买
需求分析
场景: EVE Frontier 生态中已有 3 个不同 Builder 的市场合约:
| Builder | 合约地址 | 接口风格 |
|---|---|---|
| Builder Alice | 0xAAA... | buy_item(market, character, item_id, coin) |
| Builder Bob | 0xBBB... | purchase(storage, char, type_id, payment, ctx) |
| 你(Builder You) | 0xYYY... | buy_item_v2(market, character, item_id, coin, clock, ctx) |
玩家想买一件物品,需要查找哪个市场最便宜,并一键购买。
第一部分:链下适配器层(TypeScript)
由于不同合约接口不同,适配器在链下运行,将差异封装为统一接口:
// lib/marketAdapters.ts
import { Transaction } from "@mysten/sui/transactions"
import { SuiClient } from "@mysten/sui/client"
export interface MarketListing {
marketId: string
builder: string
itemTypeId: number
price: number // SUI
adapterName: string
}
// ── 适配器接口 ─────────────────────────────────────────────
export interface MarketAdapter {
name: string
packageId: string
// 查询物品在该市场的价格
getPrice(client: SuiClient, itemTypeId: number): Promise<number | null>
// 构建购买交易
buildBuyTx(
tx: Transaction,
itemTypeId: number,
characterId: string,
paymentCoin: any
): void
}
// ── 适配器 A:Builder Alice 的市场 ────────────────────────
export const AliceMarketAdapter: MarketAdapter = {
name: "Alice's Market",
packageId: "0xAAA...",
async getPrice(client, itemTypeId) {
// Alice 的市场用 Table 存储 listings,key 是 item_id
const obj = await client.getDynamicFieldObject({
parentId: "0xAAA_MARKET_ID",
name: { type: "u64", value: itemTypeId.toString() },
})
const fields = (obj.data?.content as any)?.fields
return fields ? Number(fields.price) / 1e9 : null
},
buildBuyTx(tx, itemTypeId, characterId, paymentCoin) {
tx.moveCall({
target: `0xAAA...::market::buy_item`,
arguments: [
tx.object("0xAAA_MARKET_ID"),
tx.object(characterId),
tx.pure.u64(itemTypeId),
paymentCoin,
],
})
},
}
// ── 适配器 B:Builder Bob 的市场 ──────────────────────────
export const BobMarketAdapter: MarketAdapter = {
name: "Bob's Depot",
packageId: "0xBBB...",
async getPrice(client, itemTypeId) {
// Bob 的市场用不同的结构体,价格字段名为 'cost'
const obj = await client.getObject({
id: "0xBBB_STORAGE_ID",
options: { showContent: true },
})
const listings = (obj.data?.content as any)?.fields?.listings?.fields?.contents
const found = listings?.find((e: any) => Number(e.fields?.key) === itemTypeId)
return found ? Number(found.fields.value.fields.cost) / 1e9 : null
},
buildBuyTx(tx, itemTypeId, characterId, paymentCoin) {
tx.moveCall({
target: `0xBBB...::depot::purchase`,
arguments: [
tx.object("0xBBB_STORAGE_ID"),
tx.object(characterId),
tx.pure.u64(itemTypeId),
paymentCoin,
],
})
},
}
// ── 适配器 C:你自己的市场 ────────────────────────────────
export const MyMarketAdapter: MarketAdapter = {
name: "Your Market",
packageId: "0xYYY...",
async getPrice(client, itemTypeId) {
// 你的市场有完整文档,读取方式最直接
const obj = await client.getDynamicFieldObject({
parentId: "0xYYY_MARKET_ID",
name: { type: "u64", value: itemTypeId.toString() },
})
const fields = (obj.data?.content as any)?.fields
return fields ? Number(fields.value.fields.price) / 1e9 : null
},
buildBuyTx(tx, itemTypeId, characterId, paymentCoin) {
tx.moveCall({
target: `0xYYY...::market::buy_item_v2`,
arguments: [
tx.object("0xYYY_MARKET_ID"),
tx.object(characterId),
tx.pure.u64(itemTypeId),
paymentCoin,
tx.object("0x6"), // Clock(V2 多了这个参数)
],
})
},
}
// ── 聚合价格查询 ──────────────────────────────────────────
const ALL_ADAPTERS = [AliceMarketAdapter, BobMarketAdapter, MyMarketAdapter]
export async function aggregatePrices(
client: SuiClient,
itemTypeId: number,
): Promise<MarketListing[]> {
const results = await Promise.all(
ALL_ADAPTERS.map(async (adapter) => {
const price = await adapter.getPrice(client, itemTypeId).catch(() => null)
if (price === null) return null
return {
marketId: adapter.packageId,
builder: adapter.name,
itemTypeId,
price,
adapterName: adapter.name,
} as MarketListing
})
)
return results
.filter((r): r is MarketListing => r !== null)
.sort((a, b) => a.price - b.price) // 按价格升序
}
第二部分:聚合购买 dApp
// src/AggregatedMarket.tsx
import { useState, useEffect } from 'react'
import { useConnection } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
import { aggregatePrices, MyMarketAdapter, BobMarketAdapter, AliceMarketAdapter, MarketListing } from '../lib/marketAdapters'
const ADAPTERS_MAP = {
[AliceMarketAdapter.packageId]: AliceMarketAdapter,
[BobMarketAdapter.packageId]: BobMarketAdapter,
[MyMarketAdapter.packageId]: MyMarketAdapter,
}
const ITEM_TYPES = [
{ id: 101, name: '稀有矿石' },
{ id: 102, name: '护盾模块' },
{ id: 103, name: '推进器' },
]
export function AggregatedMarket() {
const { isConnected, handleConnect } = useConnection()
const client = useCurrentClient()
const dAppKit = useDAppKit()
const [selectedItem, setSelectedItem] = useState<number | null>(null)
const [listings, setListings] = useState<MarketListing[]>([])
const [loading, setLoading] = useState(false)
const [status, setStatus] = useState('')
const searchListings = async (itemTypeId: number) => {
setSelectedItem(itemTypeId)
setLoading(true)
try {
const results = await aggregatePrices(client, itemTypeId)
setListings(results)
} finally {
setLoading(false)
}
}
const buyFromMarket = async (listing: MarketListing) => {
if (!isConnected) { setStatus('请先连接钱包'); return }
setStatus('⏳ 构建交易...')
const tx = new Transaction()
const priceMist = BigInt(Math.ceil(listing.price * 1e9))
const [paymentCoin] = tx.splitCoins(tx.gas, [tx.pure.u64(priceMist)])
const adapter = ADAPTERS_MAP[listing.marketId]
adapter.buildBuyTx(tx, listing.itemTypeId, 'CHARACTER_ID', paymentCoin)
try {
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 购买成功!Tx: ${result.digest.slice(0, 12)}...`)
searchListings(listing.itemTypeId) // 刷新
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
return (
<div className="aggregated-market">
<header>
<h1>🛒 跨市场聚合器</h1>
<p>实时比较多个 Builder 市场的价格,一键购买最低价</p>
{!isConnected && <button onClick={handleConnect}>连接钱包</button>}
</header>
{/* 物品选择 */}
<div className="item-selector">
{ITEM_TYPES.map(item => (
<button
key={item.id}
className={`item-btn ${selectedItem === item.id ? 'selected' : ''}`}
onClick={() => searchListings(item.id)}
>
{item.name}
</button>
))}
</div>
{/* 价格列表 */}
{loading && <div className="loading">🔍 查询各市场价格...</div>}
{!loading && listings.length > 0 && (
<div className="listings">
<h3>
{ITEM_TYPES.find(i => i.id === selectedItem)?.name} — 价格比较
<span className="badge">最低价优先</span>
</h3>
{listings.map((listing, i) => (
<div
key={listing.marketId}
className={`listing-row ${i === 0 ? 'best-price' : ''}`}
>
<span className="rank">#{i + 1}</span>
<span className="builder">{listing.builder}</span>
<span className="price">
{listing.price.toFixed(2)} SUI
{i === 0 && <span className="best-badge">最低</span>}
</span>
<button
className="buy-btn"
onClick={() => buyFromMarket(listing)}
disabled={!isConnected}
>
立即购买
</button>
</div>
))}
</div>
)}
{!loading && listings.length === 0 && selectedItem && (
<div className="empty">所有市场均无此物品上架</div>
)}
{status && <div className="status">{status}</div>}
</div>
)
}
🎯 完整回顾
架构层次
├── 合约层:各 Builder 各自发布,接口不同
│ ├── Alice: buy_item(market, char, item_id, coin)
│ ├── Bob: purchase(storage, char, type_id, payment, ctx)
│ └── You: buy_item_v2(market, char, id, coin, clock, ctx)
│
├── 适配器层(TypeScript,链下)
│ ├── MarketAdapter 接口统一
│ ├── AliceMarketAdapter:封装 Alice 的读/写差异
│ ├── BobMarketAdapter:封装 Bob 的读/写差异
│ └── MyMarketAdapter:封装你自己的读/写
│
└── 聚合 dApp 层
├── aggregatePrices():并行读取所有市场
├── 排序展示
└── buyFromMarket():调用对应适配器构建交易
🔧 扩展练习
- 链上适配器注册:在链上维护已认证适配器列表(防止恶意 Builder 以假价格骗取信任)
- 滑点保护:下单前再次验证链上最新价格,如变化超过 5% 则中止
- 批量购买:在一笔交易中同时从多个市场购买不同物品
📚 关联文档
实战案例 13:订阅制通行证(月付无限跳跃)
目标: 建立订阅制通行证系统——玩家每月支付固定 SUI,获得在你联盟星门网络中无限跳跃的权利,无需每次单独购票。
状态:教学示例。正文聚焦订阅模型,完整目录以
book/src/code/example-13/为准。
对应代码目录
最小调用链
选择套餐 -> 支付订阅费 -> 铸造/更新 GatePassNFT -> 星门校验 pass 是否有效
需求分析
场景: 你的联盟控制 5 个星门,希望建立月度会员制:
- 月票:30 SUI/月,所有星门无限跳跃
- 季票:80 SUI/季度,有折扣
- 过期后需续费,否则降级为按次付费
- 订阅 NFT 可转让(玩家可以二手交易)
合约
module subscription::gate_pass;
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::transfer;
use sui::event;
use std::string::String;
// ── 常量 ──────────────────────────────────────────────────
const MONTH_MS: u64 = 30 * 24 * 60 * 60 * 1000;
/// 套餐类型
const PLAN_MONTHLY: u8 = 0;
const PLAN_QUARTERLY: u8 = 1;
// ── 数据结构 ───────────────────────────────────────────────
/// 订阅管理器(共享对象)
public struct SubscriptionManager has key {
id: UID,
monthly_price: u64, // 月套餐价格(MIST)
quarterly_price: u64, // 季度套餐价格
revenue: Balance<SUI>,
admin: address,
total_subscribers: u64,
}
/// 订阅 NFT(可转让,持有即有权限)
public struct GatePassNFT has key, store {
id: UID,
plan: u8,
valid_until_ms: u64,
subscriber: address, // 原始订阅者
serial_number: u64,
}
// ── 事件 ──────────────────────────────────────────────────
public struct PassPurchased has copy, drop {
pass_id: ID,
buyer: address,
plan: u8,
valid_until_ms: u64,
}
public struct PassRenewed has copy, drop {
pass_id: ID,
new_expiry_ms: u64,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
transfer::share_object(SubscriptionManager {
id: object::new(ctx),
monthly_price: 30_000_000_000, // 30 SUI
quarterly_price: 80_000_000_000, // 80 SUI(比3个月便宜10 SUI)
revenue: balance::zero(),
admin: ctx.sender(),
total_subscribers: 0,
});
}
// ── 购买订阅 ──────────────────────────────────────────────
public fun purchase_pass(
mgr: &mut SubscriptionManager,
plan: u8,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let (price, duration_ms) = if plan == PLAN_MONTHLY {
(mgr.monthly_price, MONTH_MS)
} else if plan == PLAN_QUARTERLY {
(mgr.quarterly_price, 3 * MONTH_MS)
} else abort EInvalidPlan;
assert!(coin::value(&payment) >= price, EInsufficientPayment);
let pay = payment.split(price, ctx);
balance::join(&mut mgr.revenue, coin::into_balance(pay));
if coin::value(&payment) > 0 {
transfer::public_transfer(payment, ctx.sender());
} else { coin::destroy_zero(payment); }
mgr.total_subscribers = mgr.total_subscribers + 1;
let valid_until_ms = clock.timestamp_ms() + duration_ms;
let pass = GatePassNFT {
id: object::new(ctx),
plan,
valid_until_ms,
subscriber: ctx.sender(),
serial_number: mgr.total_subscribers,
};
let pass_id = object::id(&pass);
transfer::public_transfer(pass, ctx.sender());
event::emit(PassPurchased {
pass_id,
buyer: ctx.sender(),
plan,
valid_until_ms,
});
}
/// 续费(延长已有 Pass 的有效期)
public fun renew_pass(
mgr: &mut SubscriptionManager,
pass: &mut GatePassNFT,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let (price, duration_ms) = if pass.plan == PLAN_MONTHLY {
(mgr.monthly_price, MONTH_MS)
} else {
(mgr.quarterly_price, 3 * MONTH_MS)
};
assert!(coin::value(&payment) >= price, EInsufficientPayment);
let pay = payment.split(price, ctx);
balance::join(&mut mgr.revenue, coin::into_balance(pay));
if coin::value(&payment) > 0 {
transfer::public_transfer(payment, ctx.sender());
} else { coin::destroy_zero(payment); }
// 如果已过期从现在起算,否则在原到期时间上叠加
let base = if pass.valid_until_ms < clock.timestamp_ms() {
clock.timestamp_ms()
} else { pass.valid_until_ms };
pass.valid_until_ms = base + duration_ms;
event::emit(PassRenewed {
pass_id: object::id(pass),
new_expiry_ms: pass.valid_until_ms,
});
}
/// 星门扩展:验证 Pass 有效性
public fun is_pass_valid(pass: &GatePassNFT, clock: &Clock): bool {
clock.timestamp_ms() <= pass.valid_until_ms
}
/// 星门跳跃(持有有效 Pass 无限跳)
public fun subscriber_jump(
gate: &Gate,
dest_gate: &Gate,
character: &Character,
pass: &GatePassNFT,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(is_pass_valid(pass, clock), EPassExpired);
gate::issue_jump_permit(
gate, dest_gate, character, SubscriberAuth {},
clock.timestamp_ms() + 30 * 60 * 1000, ctx,
);
}
public struct SubscriberAuth has drop {}
/// 管理员提款
public fun withdraw_revenue(
mgr: &mut SubscriptionManager,
amount: u64,
ctx: &TxContext,
) {
assert!(ctx.sender() == mgr.admin, ENotAdmin);
let coin = coin::take(&mut mgr.revenue, amount, ctx);
transfer::public_transfer(coin, mgr.admin);
}
const EInvalidPlan: u64 = 0;
const EInsufficientPayment: u64 = 1;
const EPassExpired: u64 = 2;
const ENotAdmin: u64 = 3;
dApp
// PassShop.tsx
import { useState } from 'react'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'
const SUB_PKG = "0x_SUBSCRIPTION_PACKAGE_"
const MGR_ID = "0x_MANAGER_ID_"
const PLANS = [
{ id: 0, name: '月套餐', price: 30, duration: '30 天', badge: '标准' },
{ id: 1, name: '季套餐', price: 80, duration: '90 天', badge: '省 10 SUI', popular: true },
]
export function PassShop() {
const dAppKit = useDAppKit()
const [status, setStatus] = useState('')
const purchase = async (plan: number, priceInSUI: number) => {
const tx = new Transaction()
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(priceInSUI * 1e9)])
tx.moveCall({
target: `${SUB_PKG}::gate_pass::purchase_pass`,
arguments: [tx.object(MGR_ID), tx.pure.u8(plan), payment, tx.object('0x6')],
})
try {
setStatus('⏳ 购买中...')
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('✅ 订阅成功!GatePassNFT 已发送到你的钱包')
} catch (e: any) { setStatus(`❌ ${e.message}`) }
}
return (
<div className="pass-shop">
<h1>🎫 星门订阅通行证</h1>
<p>持有有效期内的通行证,在本联盟所有星门无限跳跃</p>
<div className="plan-grid">
{PLANS.map(plan => (
<div key={plan.id} className={`plan-card ${plan.popular ? 'popular' : ''}`}>
{plan.popular && <div className="popular-badge">推荐</div>}
<h3>{plan.name}</h3>
<div className="plan-price">
<span className="price">{plan.price}</span>
<span className="unit">SUI</span>
</div>
<div className="plan-duration">有效期 {plan.duration}</div>
<div className="plan-badge">{plan.badge}</div>
<button className="buy-btn" onClick={() => purchase(plan.id, plan.price)}>
购买 {plan.name}
</button>
</div>
))}
</div>
{status && <p className="status">{status}</p>}
</div>
)
}
📚 关联文档
实战案例 14:物品质押借贷协议
目标: 构建链上借贷协议——玩家以 NFT 或高价物品作为抵押,借取 SUI 流动性;逾期未还则抵押物被清算拍卖给出价最高者。
状态:教学示例。正文覆盖核心借贷流程,完整目录以
book/src/code/example-14/为准。
对应代码目录
最小调用链
出借人注入流动性 -> 借款人抵押 NFT -> 合约发放 SUI -> 到期还款或触发清算
需求分析
场景: 玩家持有一件价值 1000 SUI 的“稀有护盾“,但急需 SUI 购买矿机。他将护盾质押,借出 600 SUI(60% LTV),30 天内归还 618 SUI(含 3% 月息),否则护盾被清算。
合约
module lending::collateral_loan;
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::transfer;
use sui::dynamic_field as df;
use sui::event;
// ── 常量 ──────────────────────────────────────────────────
const MONTH_MS: u64 = 30 * 24 * 60 * 60 * 1000;
const LTV_BPS: u64 = 6_000; // 60% 贷款价值比
const MONTHLY_INTEREST_BPS: u64 = 300; // 3% 月息
const LIQUIDATION_BONUS_BPS: u64 = 500; // 清算人奖励 5%
// ── 数据结构 ───────────────────────────────────────────────
/// 借贷池(共享对象,存放出借方的 SUI)
public struct LendingPool has key {
id: UID,
liquidity: Balance<SUI>,
total_loaned: u64,
admin: address,
}
/// 单笔贷款
public struct Loan has key {
id: UID,
borrower: address,
collateral_id: ID, // 质押物 ObjectID
collateral_value: u64, // 出借时评估的价值(SUI)
loan_amount: u64, // 实际借出金额
interest_amount: u64, // 应还利息
repay_by_ms: u64, // 还款截止时间
is_active: bool,
}
// ── 事件 ──────────────────────────────────────────────────
public struct LoanCreated has copy, drop {
loan_id: ID,
borrower: address,
loan_amount: u64,
repay_by_ms: u64,
}
public struct LoanRepaid has copy, drop {
loan_id: ID,
repaid: u64,
}
public struct LoanLiquidated has copy, drop {
loan_id: ID,
liquidator: address,
collateral_id: ID,
}
// ── 初始化借贷池 ──────────────────────────────────────────
public fun create_pool(ctx: &mut TxContext) {
transfer::share_object(LendingPool {
id: object::new(ctx),
liquidity: balance::zero(),
total_loaned: 0,
admin: ctx.sender(),
});
}
/// 出借方向池中注入流动性
public fun deposit_liquidity(
pool: &mut LendingPool,
coin: Coin<SUI>,
_ctx: &TxContext,
) {
balance::join(&mut pool.liquidity, coin::into_balance(coin));
}
// ── 借款(以 NFT 为抵押)────────────────────────────────
/// 由 Oracle/Admin 评估并发起贷款
/// (实际场景中,collateral_value 需要通过链下价格预言机确定)
public fun create_loan<T: key + store>(
pool: &mut LendingPool,
collateral: T,
collateral_value_sui: u64, // 价格预言机或 Admin 确认的估值
clock: &Clock,
ctx: &mut TxContext,
) {
let loan_amount = collateral_value_sui * LTV_BPS / 10_000; // 60% LTV
let interest = loan_amount * MONTHLY_INTEREST_BPS / 10_000;
assert!(balance::value(&pool.liquidity) >= loan_amount, EInsufficientLiquidity);
let collateral_id = object::id(&collateral);
let mut loan = Loan {
id: object::new(ctx),
borrower: ctx.sender(),
collateral_id,
collateral_value: collateral_value_sui,
loan_amount,
interest_amount: interest,
repay_by_ms: clock.timestamp_ms() + MONTH_MS,
is_active: true,
};
// 将抵押物锁定在 Loan 对象中(动态字段)
df::add(&mut loan.id, b"collateral", collateral);
// 发放借款
let loan_coin = coin::take(&mut pool.liquidity, loan_amount, ctx);
pool.total_loaned = pool.total_loaned + loan_amount;
transfer::public_transfer(loan_coin, ctx.sender());
event::emit(LoanCreated {
loan_id: object::id(&loan),
borrower: ctx.sender(),
loan_amount,
repay_by_ms: loan.repay_by_ms,
});
transfer::share_object(loan);
}
// ── 还款(归还借款 + 利息,取回抵押物)──────────────────
public fun repay_loan<T: key + store>(
pool: &mut LendingPool,
loan: &mut Loan,
mut repayment: Coin<SUI>,
ctx: &mut TxContext,
) {
assert!(loan.borrower == ctx.sender(), ENotBorrower);
assert!(loan.is_active, ELoanInactive);
let total_due = loan.loan_amount + loan.interest_amount;
assert!(coin::value(&repayment) >= total_due, EInsufficientRepayment);
// 还款入池
let repay_coin = repayment.split(total_due, ctx);
balance::join(&mut pool.liquidity, coin::into_balance(repay_coin));
pool.total_loaned = pool.total_loaned - loan.loan_amount;
if coin::value(&repayment) > 0 {
transfer::public_transfer(repayment, ctx.sender());
} else { coin::destroy_zero(repayment); }
// 取回抵押物
let collateral: T = df::remove(&mut loan.id, b"collateral");
transfer::public_transfer(collateral, ctx.sender());
loan.is_active = false;
event::emit(LoanRepaid {
loan_id: object::id(loan),
repaid: total_due,
});
}
// ── 清算(到期未还,清算人取走抵押物)──────────────────
public fun liquidate<T: key + store>(
pool: &mut LendingPool,
loan: &mut Loan,
mut liquidation_payment: Coin<SUI>, // 清算人支付 collateral_value * 95%
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(loan.is_active, ELoanInactive);
assert!(clock.timestamp_ms() > loan.repay_by_ms, ENotYetExpired);
// 清算人需支付抵押物估值的 95%(留 5% 作为奖励)
let liquidation_price = loan.collateral_value * (10_000 - LIQUIDATION_BONUS_BPS) / 10_000;
assert!(coin::value(&liquidation_payment) >= liquidation_price, EInsufficientPayment);
let pay = liquidation_payment.split(liquidation_price, ctx);
// 还款本金 + 利息入池,剩余归清算人作奖励
balance::join(&mut pool.liquidity, coin::into_balance(pay));
if coin::value(&liquidation_payment) > 0 {
transfer::public_transfer(liquidation_payment, ctx.sender());
} else { coin::destroy_zero(liquidation_payment); }
// 清算人获得抵押物
let collateral: T = df::remove(&mut loan.id, b"collateral");
transfer::public_transfer(collateral, ctx.sender());
loan.is_active = false;
event::emit(LoanLiquidated {
loan_id: object::id(loan),
liquidator: ctx.sender(),
collateral_id: loan.collateral_id,
});
}
const EInsufficientLiquidity: u64 = 0;
const ENotBorrower: u64 = 1;
const ELoanInactive: u64 = 2;
const EInsufficientRepayment: u64 = 3;
const ENotYetExpired: u64 = 4;
const EInsufficientPayment: u64 = 5;
dApp 界面(借贷仪表盘)
// LendingDashboard.tsx
import { useQuery } from '@tanstack/react-query'
import { useCurrentClient } from '@mysten/dapp-kit-react'
const LENDING_PKG = "0x_LENDING_PACKAGE_"
const POOL_ID = "0x_POOL_ID_"
export function LendingDashboard() {
const client = useCurrentClient()
const { data: pool } = useQuery({
queryKey: ['lending-pool'],
queryFn: async () => {
const obj = await client.getObject({ id: POOL_ID, options: { showContent: true } })
return (obj.data?.content as any)?.fields
},
refetchInterval: 15_000,
})
const availableLiquidity = Number(pool?.liquidity?.fields?.value ?? 0) / 1e9
const totalLoaned = Number(pool?.total_loaned ?? 0) / 1e9
const utilization = totalLoaned / (availableLiquidity + totalLoaned) * 100
return (
<div className="lending-dashboard">
<h1>🏦 物品质押借贷</h1>
<div className="pool-stats">
<div className="stat">
<span>💧 可借流动性</span>
<strong>{availableLiquidity.toFixed(2)} SUI</strong>
</div>
<div className="stat">
<span>📤 已借出</span>
<strong>{totalLoaned.toFixed(2)} SUI</strong>
</div>
<div className="stat">
<span>📊 资金使用率</span>
<strong>{utilization.toFixed(1)}%</strong>
</div>
<div className="stat">
<span>💰 月息</span>
<strong>3%</strong>
</div>
</div>
<div className="loan-info">
<h3>借款条件</h3>
<ul>
<li>贷款价值比(LTV):60%</li>
<li>月息:3%(固定)</li>
<li>最长借期:30 天</li>
<li>逾期清算:抵押物以估值 95% 被清算人收购</li>
</ul>
</div>
</div>
)
}
📚 关联文档
实战案例 16:NFT 合成与拆解系统
目标: 构建材料合成系统——销毁多个低级 NFT 合成一个高级 NFT(概率性),也支持高级 NFT 拆解为材料;利用链上随机数确保结果公平。
状态:教学示例。正文讲解合成/拆解与随机数接入,完整目录以
book/src/code/example-16/为准。
对应代码目录
最小调用链
用户选择材料 -> 合约读取随机数 -> 执行合成/失败返还 -> 发事件 -> 前端刷新结果
需求分析
场景: 你设计了三层装备体系:
- 材料碎片(Fragment):普通,随机掉落
- 精炼组件(Component):3 个碎片 → 60% 概率合成
- 传世神器(Artifact):3 个精炼组件 → 30% 概率合成,失败返回 1 个组件
合约
module crafting::forge;
use sui::object::{Self, UID, ID};
use sui::random::{Self, Random};
use sui::transfer;
use sui::event;
use std::string::{Self, String, utf8};
// ── 常量 ──────────────────────────────────────────────────
const TIER_FRAGMENT: u8 = 0;
const TIER_COMPONENT: u8 = 1;
const TIER_ARTIFACT: u8 = 2;
// 合成成功率(BPS)
const FRAGMENT_TO_COMPONENT_BPS: u64 = 6_000; // 60%
const COMPONENT_TO_ARTIFACT_BPS: u64 = 3_000; // 30%
// ── 数据结构 ───────────────────────────────────────────────
public struct ForgeItem has key, store {
id: UID,
tier: u8,
name: String,
image_url: String,
power: u64, // 属性值(越高级越强)
}
public struct ForgeAdminCap has key, store { id: UID }
// ── 事件 ──────────────────────────────────────────────────
public struct CraftAttempted has copy, drop {
crafter: address,
input_tier: u8,
success: bool,
result_tier: u8,
}
public struct ItemDisassembled has copy, drop {
crafter: address,
from_tier: u8,
fragments_returned: u64,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
transfer::public_transfer(ForgeAdminCap { id: object::new(ctx) }, ctx.sender());
}
/// 铸造基础碎片(Admin only,比如任务奖励)
public fun mint_fragment(
_cap: &ForgeAdminCap,
recipient: address,
ctx: &mut TxContext,
) {
let item = ForgeItem {
id: object::new(ctx),
tier: TIER_FRAGMENT,
name: utf8(b"Plasma Fragment"),
image_url: utf8(b"https://assets.example.com/fragment.png"),
power: 10,
};
transfer::public_transfer(item, recipient);
}
// ── 合成:3 个低级 → 1 个高级(带随机成功率)────────────
public fun craft(
input1: ForgeItem,
input2: ForgeItem,
input3: ForgeItem,
random: &Random,
ctx: &mut TxContext,
) {
// 三个输入必须同一阶段
assert!(input1.tier == input2.tier && input2.tier == input3.tier, EMismatchedTier);
let input_tier = input1.tier;
assert!(input_tier < TIER_ARTIFACT, EMaxTierReached);
let target_tier = input_tier + 1;
// 获取链上随机数(0-9999)
let mut rng = random::new_generator(random, ctx);
let roll = rng.generate_u64() % 10_000;
let success_threshold = if target_tier == TIER_COMPONENT {
FRAGMENT_TO_COMPONENT_BPS
} else {
COMPONENT_TO_ARTIFACT_BPS
};
// 无论成功与否,都销毁三个输入
let ForgeItem { id: id1, .. } = input1;
let ForgeItem { id: id2, .. } = input2;
let ForgeItem { id: id3, .. } = input3;
id1.delete(); id2.delete(); id3.delete();
let success = roll < success_threshold;
if success {
let (name, image_url, power) = get_tier_info(target_tier);
let result = ForgeItem {
id: object::new(ctx),
tier: target_tier,
name,
image_url,
power,
};
transfer::public_transfer(result, ctx.sender());
} else if target_tier == TIER_ARTIFACT {
// 合成神器失败时,安慰奖:返还 1 个精炼组件
let (name, image_url, power) = get_tier_info(TIER_COMPONENT);
let consolation = ForgeItem {
id: object::new(ctx),
tier: TIER_COMPONENT,
name,
image_url,
power,
};
transfer::public_transfer(consolation, ctx.sender());
};
// 合成组件失败时不返还任何东西(60% 成功率,风险在于玩家)
event::emit(CraftAttempted {
crafter: ctx.sender(),
input_tier,
success,
result_tier: if success { target_tier } else { input_tier },
});
}
// ── 拆解:1 个高级 → 多个低级 ────────────────────────────
public fun disassemble(
item: ForgeItem,
ctx: &mut TxContext,
) {
assert!(item.tier > TIER_FRAGMENT, ECannotDisassembleFragment);
let target_tier = item.tier - 1;
let fragments_to_return = 2u64; // 拆解只返还 2 个(有损耗)
let item_tier = item.tier;
let ForgeItem { id, .. } = item;
id.delete();
let (name, image_url, power) = get_tier_info(target_tier);
let mut i = 0;
while (i < fragments_to_return) {
let fragment = ForgeItem {
id: object::new(ctx),
tier: target_tier,
name,
image_url,
power,
};
transfer::public_transfer(fragment, ctx.sender());
i = i + 1;
};
event::emit(ItemDisassembled {
crafter: ctx.sender(),
from_tier: item_tier,
fragments_returned: fragments_to_return,
});
}
fun get_tier_info(tier: u8): (String, String, u64) {
if tier == TIER_FRAGMENT {
(utf8(b"Plasma Fragment"), utf8(b"https://assets.example.com/fragment.png"), 10)
} else if tier == TIER_COMPONENT {
(utf8(b"Refined Component"), utf8(b"https://assets.example.com/component.png"), 100)
} else {
(utf8(b"Ancient Artifact"), utf8(b"https://assets.example.com/artifact.png"), 1000)
}
}
const EMismatchedTier: u64 = 0;
const EMaxTierReached: u64 = 1;
const ECannotDisassembleFragment: u64 = 2;
dApp(铸造台界面)
// ForgingStation.tsx
import { useState } from 'react'
import { useCurrentClient, useCurrentAccount } from '@mysten/dapp-kit-react'
import { useQuery } from '@tanstack/react-query'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'
const CRAFTING_PKG = "0x_CRAFTING_PACKAGE_"
const TIER_NAMES = ['💎 碎片', '⚙️ 精炼组件', '🌟 传世神器']
const CRAFT_RATES = ['60%', '30%', '—']
export function ForgingStation() {
const client = useCurrentClient()
const dAppKit = useDAppKit()
const account = useCurrentAccount()
const [selected, setSelected] = useState<string[]>([])
const [status, setStatus] = useState('')
const [lastCraft, setLastCraft] = useState<{success: boolean; tier: string} | null>(null)
const { data: userItems, refetch } = useQuery({
queryKey: ['forge-items', account?.address],
queryFn: async () => {
if (!account) return []
const objs = await client.getOwnedObjects({
owner: account.address,
filter: { StructType: `${CRAFTING_PKG}::forge::ForgeItem` },
options: { showContent: true },
})
return objs.data.map(obj => ({
id: obj.data!.objectId,
tier: Number((obj.data!.content as any).fields.tier),
name: (obj.data!.content as any).fields.name,
power: (obj.data!.content as any).fields.power,
}))
},
enabled: !!account,
})
const toggleSelect = (id: string) => {
setSelected(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : prev.length < 3 ? [...prev, id] : prev
)
}
const handleCraft = async () => {
if (selected.length !== 3) return
const tx = new Transaction()
tx.moveCall({
target: `${CRAFTING_PKG}::forge::craft`,
arguments: [
tx.object(selected[0]),
tx.object(selected[1]),
tx.object(selected[2]),
tx.object('0x8'), // Random 系统对象
],
})
try {
setStatus('⏳ 合成中(链上随机数判定)...')
const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
// 从事件读取合成结果
const craftEvent = result.events?.find(e => e.type.includes('CraftAttempted'))
if (craftEvent) {
const { success, result_tier } = craftEvent.parsedJson as any
setLastCraft({ success, tier: TIER_NAMES[Number(result_tier)] })
setStatus(success ? `✅ 合成成功!获得 ${TIER_NAMES[Number(result_tier)]}` : '❌ 合成失败')
}
setSelected([])
refetch()
} catch (e: any) { setStatus(`❌ ${e.message}`) }
}
const selectedTier = selected.length > 0 && userItems
? userItems.find(i => i.id === selected[0])?.tier
: null
return (
<div className="forging-station">
<h1>⚒ 神秘铸造台</h1>
{lastCraft && (
<div className={`craft-result ${lastCraft.success ? 'success' : 'fail'}`}>
{lastCraft.success ? '✨ 合成成功!' : '💔 合成失败'} → {lastCraft.tier}
</div>
)}
<div className="craft-info">
<div>碎片 × 3 → 精炼组件(成功率 {CRAFT_RATES[0]})</div>
<div>精炼组件 × 3 → 传世神器(成功率 {CRAFT_RATES[1]})</div>
</div>
<h3>选择 3 件同阶物品进行合成</h3>
<div className="items-grid">
{userItems?.map(item => (
<div
key={item.id}
className={`item-slot ${selected.includes(item.id) ? 'selected' : ''}`}
onClick={() => toggleSelect(item.id)}
>
<div className="tier-badge">{TIER_NAMES[item.tier]}</div>
<div className="power">⚡ {item.power}</div>
</div>
))}
</div>
<button
className="craft-btn"
disabled={selected.length !== 3}
onClick={handleCraft}
>
🔥 开始合成({selected.length}/3 已选)
</button>
{status && <p className="status">{status}</p>}
</div>
)
}
📚 关联文档
实战案例 18:跨联盟外交合约(停火与资源条约)
目标: 构建链上外交合约——两个联盟可以签署条约(停火、资源共享、贸易协定),条约由双方 Leader 多签生效,违约可在链上举证,有效期内强制执行。
状态:教学示例。正文覆盖条约状态机,完整目录以
book/src/code/example-18/为准。
对应代码目录
最小调用链
一方发起提案 -> 双方存押金并签署 -> 条约生效 -> 发生违约/撕约 -> 扣罚或退还押金
测试闭环
- 发起提案:确认
TreatyProposal创建成功并发出事件 - 双签生效:确认
effective_at_ms写入,双方押金对等 - 提前通知与终止:确认通知期未成熟前无法终止,成熟后押金退回
- 举报违约:确认罚款从违约方押金扣出并转给对方
需求分析
场景: 联盟 Alpha 和联盟 Beta 爆发冲突,双方决定谈判:
- 停火协议:72 小时内双方炮塔不对对方成员开火
- 过路协议:Alpha 成员可免费使用 Beta 的星门(反之亦然)
- 资源分享:双方每日互相转账 100 WAR Token
- 任一方可以单方面撕毁条约(需提前 24 小时通知链上)
- 违约行为(如炮塔非法开火)可以通过服务器签名举报,罚款押金
合约
module diplomacy::treaty;
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::transfer;
use sui::event;
use std::string::{Self, String, utf8};
// ── 常量 ──────────────────────────────────────────────────
const NOTICE_PERIOD_MS: u64 = 24 * 60 * 60 * 1000; // 撕约提前通知 24 小时
const BREACH_FINE: u64 = 100_000_000_000; // 违约罚款 100 SUI(从押金扣)
// 条约类型
const TREATY_CEASEFIRE: u8 = 0; // 停火协议
const TREATY_PASSAGE: u8 = 1; // 过路权协议
const TREATY_RESOURCE_SHARE: u8 = 2; // 资源共享
// ── 数据结构 ───────────────────────────────────────────────
/// 外交条约(共享对象)
public struct Treaty has key {
id: UID,
treaty_type: u8,
party_a: address, // 联盟 A 的 Leader 地址
party_b: address, // 联盟 B 的 Leader 地址
party_a_signed: bool,
party_b_signed: bool,
effective_at_ms: u64, // 生效时间(双签后)
expires_at_ms: u64, // 到期时间(0 = 无限期)
termination_notice_ms: u64, // 撕约通知时间(0 = 未通知)
party_a_deposit: Balance<SUI>, // A 方押金(用于违约赔偿)
party_b_deposit: Balance<SUI>, // B 方押金
breach_count_a: u64,
breach_count_b: u64,
description: String,
}
/// 条约提案(由一方发起,等待对方签署)
public struct TreatyProposal has key {
id: UID,
proposed_by: address,
counterparty: address,
treaty_type: u8,
duration_days: u64, // 有效期(天),0 = 无限期
deposit_required: u64, // 要求各方押金
description: String,
}
// ── 事件 ──────────────────────────────────────────────────
public struct TreatyProposed has copy, drop { proposal_id: ID, proposer: address, counterparty: address }
public struct TreatySigned has copy, drop { treaty_id: ID, party: address }
public struct TreatyEffective has copy, drop { treaty_id: ID, treaty_type: u8 }
public struct TreatyTerminated has copy, drop { treaty_id: ID, terminated_by: address }
public struct BreachReported has copy, drop { treaty_id: ID, breaching_party: address, fine: u64 }
// ── 发起条约提案 ──────────────────────────────────────────
public fun propose_treaty(
counterparty: address,
treaty_type: u8,
duration_days: u64,
deposit_required: u64,
description: vector<u8>,
ctx: &mut TxContext,
) {
let proposal = TreatyProposal {
id: object::new(ctx),
proposed_by: ctx.sender(),
counterparty,
treaty_type,
duration_days,
deposit_required,
description: utf8(description),
};
let proposal_id = object::id(&proposal);
transfer::share_object(proposal);
event::emit(TreatyProposed {
proposal_id,
proposer: ctx.sender(),
counterparty,
});
}
// ── 接受提案(发起方签署 + 押金)────────────────────────
public fun accept_and_sign_a(
proposal: &TreatyProposal,
mut deposit: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(ctx.sender() == proposal.proposed_by, ENotParty);
let deposit_amt = coin::value(&deposit);
assert!(deposit_amt >= proposal.deposit_required, EInsufficientDeposit);
let deposit_coin = deposit.split(proposal.deposit_required, ctx);
if coin::value(&deposit) > 0 {
transfer::public_transfer(deposit, ctx.sender());
} else { coin::destroy_zero(deposit); }
let expires = if proposal.duration_days > 0 {
clock.timestamp_ms() + proposal.duration_days * 86_400_000
} else { 0 };
let treaty = Treaty {
id: object::new(ctx),
treaty_type: proposal.treaty_type,
party_a: proposal.proposed_by,
party_b: proposal.counterparty,
party_a_signed: true,
party_b_signed: false,
effective_at_ms: 0,
expires_at_ms: expires,
termination_notice_ms: 0,
party_a_deposit: coin::into_balance(deposit_coin),
party_b_deposit: balance::zero(),
breach_count_a: 0,
breach_count_b: 0,
description: proposal.description,
};
let treaty_id = object::id(&treaty);
transfer::share_object(treaty);
event::emit(TreatySigned { treaty_id, party: ctx.sender() });
}
/// 对方联盟签署(条约正式生效)
public fun countersign(
treaty: &mut Treaty,
mut deposit: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(ctx.sender() == treaty.party_b, ENotParty);
assert!(treaty.party_a_signed, ENotYetSigned);
assert!(!treaty.party_b_signed, EAlreadySigned);
let required = balance::value(&treaty.party_a_deposit); // 对等押金
assert!(coin::value(&deposit) >= required, EInsufficientDeposit);
let dep = deposit.split(required, ctx);
balance::join(&mut treaty.party_b_deposit, coin::into_balance(dep));
if coin::value(&deposit) > 0 {
transfer::public_transfer(deposit, ctx.sender());
} else { coin::destroy_zero(deposit); }
treaty.party_b_signed = true;
treaty.effective_at_ms = clock.timestamp_ms();
event::emit(TreatyEffective { treaty_id: object::id(treaty), treaty_type: treaty.treaty_type });
event::emit(TreatySigned { treaty_id: object::id(treaty), party: ctx.sender() });
}
// ── 验证条约是否生效(炮塔/星门扩展调用)───────────────
public fun is_treaty_active(treaty: &Treaty, clock: &Clock): bool {
if !treaty.party_a_signed || !treaty.party_b_signed { return false };
if treaty.expires_at_ms > 0 && clock.timestamp_ms() > treaty.expires_at_ms { return false };
// 撕约通知期内,条约仍然有效
true
}
/// 检查某地址是否在条约保护下
public fun is_protected_by_treaty(
treaty: &Treaty,
protected_member: address, // 受保护的联盟成员(通过 FactionNFT.owner 或 member 列表核查)
aggressor_faction: address,
clock: &Clock,
): bool {
is_treaty_active(treaty, clock)
// 真实场景中需要额外核查成员与联盟的关联
}
// ── 提交撕约通知(24 小时后生效)───────────────────────
public fun give_termination_notice(
treaty: &mut Treaty,
clock: &Clock,
ctx: &TxContext,
) {
assert!(ctx.sender() == treaty.party_a || ctx.sender() == treaty.party_b, ENotParty);
assert!(is_treaty_active(treaty, clock), ETreatyNotActive);
treaty.termination_notice_ms = clock.timestamp_ms();
event::emit(TreatyTerminated { treaty_id: object::id(treaty), terminated_by: ctx.sender() });
}
/// 通知期满后正式终止条约,双方取回押金
public fun finalize_termination(
treaty: &mut Treaty,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(treaty.termination_notice_ms > 0, ENoNoticeGiven);
assert!(
clock.timestamp_ms() >= treaty.termination_notice_ms + NOTICE_PERIOD_MS,
ENoticeNotMature,
);
// 退还押金
let a_dep = balance::withdraw_all(&mut treaty.party_a_deposit);
let b_dep = balance::withdraw_all(&mut treaty.party_b_deposit);
if balance::value(&a_dep) > 0 {
transfer::public_transfer(coin::from_balance(a_dep, ctx), treaty.party_a);
} else { balance::destroy_zero(a_dep); }
if balance::value(&b_dep) > 0 {
transfer::public_transfer(coin::from_balance(b_dep, ctx), treaty.party_b);
} else { balance::destroy_zero(b_dep); }
}
// ── 举报违约(由游戏服务器验证并签名)──────────────────
public fun report_breach(
treaty: &mut Treaty,
breaching_party: address, // 违约联盟的 Leader 地址
admin_acl: &AdminACL,
ctx: &mut TxContext,
) {
verify_sponsor(admin_acl, ctx); // 服务器证明违约事件真实发生
let fine = BREACH_FINE;
if breaching_party == treaty.party_a {
treaty.breach_count_a = treaty.breach_count_a + 1;
// 从 A 的押金中扣除罚款转给 B
if balance::value(&treaty.party_a_deposit) >= fine {
let fine_coin = coin::take(&mut treaty.party_a_deposit, fine, ctx);
transfer::public_transfer(fine_coin, treaty.party_b);
}
} else if breaching_party == treaty.party_b {
treaty.breach_count_b = treaty.breach_count_b + 1;
if balance::value(&treaty.party_b_deposit) >= fine {
let fine_coin = coin::take(&mut treaty.party_b_deposit, fine, ctx);
transfer::public_transfer(fine_coin, treaty.party_a);
}
} else abort ENotParty;
event::emit(BreachReported {
treaty_id: object::id(treaty),
breaching_party,
fine,
});
}
const ENotParty: u64 = 0;
const EInsufficientDeposit: u64 = 1;
const ENotYetSigned: u64 = 2;
const EAlreadySigned: u64 = 3;
const ETreatyNotActive: u64 = 4;
const ENoNoticeGiven: u64 = 5;
const ENoticeNotMature: u64 = 6;
dApp(外交中心)
// DiplomacyCenter.tsx
import { useState } from 'react'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useQuery } from '@tanstack/react-query'
const DIP_PKG = "0x_DIPLOMACY_PACKAGE_"
const TREATY_TYPES = [
{ id: 0, name: '⚔️ 停火协议', desc: '双方在有效期内不得发起攻击' },
{ id: 1, name: '🚪 过路权协议', desc: '双方成员可免费使用对方星门' },
{ id: 2, name: '💰 资源共享协议', desc: '定期相互转移资源' },
]
export function DiplomacyCenter() {
const client = useCurrentClient()
const [proposing, setProposing] = useState(false)
const { data: treaties } = useQuery({
queryKey: ['active-treaties'],
queryFn: async () => {
const events = await client.queryEvents({
query: { MoveEventType: `${DIP_PKG}::treaty::TreatyEffective` },
limit: 20,
})
return events.data
},
refetchInterval: 30_000,
})
return (
<div className="diplomacy-center">
<header>
<h1>🌐 跨联盟外交中心</h1>
<p>在链上签署具有法律效力的联盟条约</p>
</header>
<section className="treaty-types">
<h3>可签署的条约类型</h3>
<div className="types-grid">
{TREATY_TYPES.map(t => (
<div key={t.id} className="type-card">
<h4>{t.name}</h4>
<p>{t.desc}</p>
</div>
))}
</div>
</section>
<section className="active-treaties">
<h3>当前生效条约</h3>
{treaties?.length === 0 && <p>暂无条约</p>}
{treaties?.map(e => {
const { treaty_id, treaty_type } = e.parsedJson as any
const type = TREATY_TYPES[Number(treaty_type)]
return (
<div key={treaty_id} className="treaty-card">
<span className="treaty-type">{type?.name}</span>
<span className="treaty-id">{treaty_id.slice(0, 12)}...</span>
<span className="treaty-status active">✅ 生效中</span>
</div>
)
})}
</section>
<button className="propose-btn" onClick={() => setProposing(true)}>
📝 提议新条约
</button>
</div>
)
}
🎯 关键设计亮点
| 机制 | 实现方式 |
|---|---|
| 双签生效 | party_a_signed + party_b_signed 都为 true 才生效 |
| 押金约束 | 争端双方各存押金,违约自动扣罚 |
| 撕约通知 | termination_notice_ms + 24 小时冷静期 |
| 违约举证 | 游戏服务器 AdminACL 签名证明,自动执行罚款 |
| 条约核查 | is_treaty_active() 供炮塔/星门扩展调用 |
📚 关联文档
Chapter 18:多租户架构与游戏服务器集成
目标: 理解 EVE Frontier 的多租户(Multi-tenant)世界合约设计,掌握如何构建可服务多个联盟的平台型合约,以及如何与游戏服务器双向集成。
状态:架构章节。正文以多租户设计和 Registry 模式为主。
18.1 什么是多租户合约?
单租户:一个合约只服务于一个 Owner(你的联盟)。
多租户:一个合约部署后,可以同时服务多个不相关的 Owner(多个联盟),彼此数据隔离。
单租户例子(Example 1-5 的模式):
合约 → 专属 TollGate(只有你的星门)
多租户例子:
合约 → 注册 Alliance A 的星门收费配置
→ 注册 Alliance B 的星门收费配置
→ 注册 Alliance C 的存储箱市场配置
→(每个联盟彼此隔离,数据独立)
适用场景:打造一个可供多个联盟使用的“SaaS“级工具。例如:通用拍卖平台、版税市场基础设施、任务系统框架。
多租户这件事最容易被误解成“把很多用户塞进一个合约”。真正要解决的问题其实是:
如何让很多彼此不信任的经营者,共享同一套协议能力,但又互不串线、互不越权、互不污染数据。
所以多租户设计的核心不是“省部署次数”,而是三件事:
- 隔离 A 租户不能碰 B 租户状态
- 复用 同一套逻辑不必为每个联盟重新发一遍包
- 可运营 平台方自己还能持续维护、升级和计费
18.2 多租户合约设计模式
module platform::multi_toll;
use sui::table::{Self, Table};
use sui::object::{Self, ID};
/// 平台注册表(共享对象,所有租户共用)
public struct TollPlatform has key {
id: UID,
registrations: Table<ID, TollConfig>, // gate_id → 收费配置
}
/// 每个租户(星门)的独立配置
public struct TollConfig has store {
owner: address, // 这个配置的 Owner(星门拥有者)
toll_amount: u64,
fee_recipient: address,
total_collected: u64,
}
/// 租户注册(任意 Builder 都可以把自己的星门注册进来)
public fun register_gate(
platform: &mut TollPlatform,
gate: &Gate,
owner_cap: &OwnerCap<Gate>, // 证明你是这个星门的 Owner
toll_amount: u64,
fee_recipient: address,
ctx: &TxContext,
) {
// 验证 OwnerCap 和 Gate 对应
assert!(owner_cap.authorized_object_id == object::id(gate), ECapMismatch);
let gate_id = object::id(gate);
assert!(!table::contains(&platform.registrations, gate_id), EAlreadyRegistered);
table::add(&mut platform.registrations, gate_id, TollConfig {
owner: ctx.sender(),
toll_amount,
fee_recipient,
total_collected: 0,
});
}
/// 调整租户配置(只有自己的配置才能修改)
public fun update_toll(
platform: &mut TollPlatform,
gate: &Gate,
owner_cap: &OwnerCap<Gate>,
new_toll_amount: u64,
ctx: &TxContext,
) {
assert!(owner_cap.authorized_object_id == object::id(gate), ECapMismatch);
let config = table::borrow_mut(&mut platform.registrations, object::id(gate));
assert!(config.owner == ctx.sender(), ENotConfigOwner);
config.toll_amount = new_toll_amount;
}
/// 多租户跳跃(收费逻辑复用,但配置各自独立)
public fun multi_tenant_jump(
platform: &mut TollPlatform,
source_gate: &Gate,
dest_gate: &Gate,
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
// 读取该星门的专属收费配置
let gate_id = object::id(source_gate);
assert!(table::contains(&platform.registrations, gate_id), EGateNotRegistered);
let config = table::borrow_mut(&mut platform.registrations, gate_id);
assert!(coin::value(&payment) >= config.toll_amount, EInsufficientPayment);
// 转给各自的 fee_recipient
let toll = payment.split(config.toll_amount, ctx);
transfer::public_transfer(toll, config.fee_recipient);
config.total_collected = config.total_collected + config.toll_amount;
// 还回找零
if coin::value(&payment) > 0 {
transfer::public_transfer(payment, ctx.sender());
} else {
coin::destroy_zero(payment);
};
// 发放跳跃许可
gate::issue_jump_permit(
source_gate, dest_gate, character, MultiTollAuth {}, clock.timestamp_ms() + 15 * 60 * 1000, ctx,
);
}
public struct MultiTollAuth has drop {}
const ECapMismatch: u64 = 0;
const EAlreadyRegistered: u64 = 1;
const ENotConfigOwner: u64 = 2;
const EGateNotRegistered: u64 = 3;
const EInsufficientPayment: u64 = 4;
多租户设计真正要先决定的,是“租户键”是什么
在这个例子里,gate_id 充当租户边界。现实里常见的租户键还有:
- 某个
assembly_id - 某个
character_id - 某个联盟对象 ID
- 某个经过规范化的业务主键
这个选择非常关键,因为它决定了:
- 数据如何隔离
- 权限如何校验
- 前端和索引层如何检索
如果租户键选得不稳定,后面你会频繁遇到“这到底算一个租户还是两个”的脏边界问题。
多租户合约最常见的三类事故
1. 隔离做得不彻底
看起来是多租户,实际某些路径仍然用全局共享参数,导致不同联盟之间互相影响。
2. 平台参数和租户参数混在一起
结果是:
- 有些配置本来应该全局统一
- 却被某个租户私自改了
或者反过来:
- 本来应该每租户独立的费率
- 却被做成全局唯一值
3. 查询模型没跟上
链上写了多租户结构,但前端和索引层仍然只会按“单对象”思路读取,最后平台根本不好用。
18.3 游戏服务器集成模式
模式一:服务器作为事件监听器
// game-server/event-listener.ts
// 游戏服务器监听链上事件,更新游戏状态
import { SuiClient } from "@mysten/sui/client";
const client = new SuiClient({ url: process.env.SUI_RPC! });
// 监听玩家成就,触发游戏内奖励
await client.subscribeEvent({
filter: { Package: MY_PACKAGE },
onMessage: async (event) => {
if (event.type.includes("AchievementUnlocked")) {
const { player, achievement_type } = event.parsedJson as any;
// 游戏服务器处理:给玩家发放游戏内物品
await gameServerAPI.grantItemToPlayer(player, achievement_type);
}
if (event.type.includes("GateJumped")) {
const { character_id, destination_gate_id } = event.parsedJson as any;
// 游戏服务器处理:传送玩家到目的地星系
await gameServerAPI.teleportCharacter(character_id, destination_gate_id);
}
},
});
模式二:服务器作为数据提供者
// game-server/api.ts
// 游戏服务器提供链下数据,dApp 调用
import express from "express";
const app = express();
// 提供星系名称(解密位置哈希)
app.get("/api/location/:hash", async (req, res) => {
const { hash } = req.params;
const geoInfo = await locationDB.getByHash(hash);
res.json(geoInfo);
});
// 验证临近性(供 Sponsor 服务调用)
app.post("/api/proximity/verify", async (req, res) => {
const { player_id, assembly_id, max_distance_km } = req.body;
const playerPos = await getPlayerPosition(player_id);
const assemblyPos = await getAssemblyPosition(assembly_id);
const distance = calculateDistance(playerPos, assemblyPos);
res.json({
is_near: distance <= max_distance_km,
distance_km: distance,
});
});
// 获取玩家实时游戏状态
app.get("/api/character/:id/status", async (req, res) => {
const status = await gameServerAPI.getCharacterStatus(req.params.id);
res.json({
online: status.online,
system: status.current_system,
ship: status.current_ship,
fleet: status.fleet_id,
});
});
模式三:双向状态同步
链上事件 ──────────────► 游戏服务器
(NFT 铸造、任务完成) (更新游戏世界状态)
游戏服务器 ──────────────► 链上交易
(物理验证、赞助签名) (记录结果、发放奖励)
这三种模式不要混成一锅
它们虽然都叫“服务端集成”,但职责完全不同:
- 事件监听器 偏消费型,把链上结果同步回游戏世界
- 数据提供者 偏查询型,为前端和后端提供链下解释层
- 双向同步 偏协同型,让链上和游戏服互相推动状态变化
如果你不分层,最后很容易出现:
- 一个服务既管监听,又管赞助,又管所有查询
- 出问题时完全不知道是哪条链路坏了
游戏服务器和链上之间最关键的不是“联通”,而是“口径一致”
例如:
- 链上认的
assembly_id和游戏服认的设施编号是否同一件事 - 位置哈希和链下地图坐标是否一一对应
- 事件里的角色 ID 与游戏数据库里的角色主键是否稳定映射
这些映射一旦漂移,系统表面上还是通的,业务却会慢慢失真。
18.4 ObjectRegistry:全局查询表
当你的合约有多个共享对象时,需要一个注册表让其他合约和 dApp 找到它们:
module platform::registry;
/// 全局注册表(类似域名系统)
public struct ObjectRegistry has key {
id: UID,
entries: Table<String, ID>, // 名称 → ObjectID
}
/// 注册一个命名对象
public fun register(
registry: &mut ObjectRegistry,
name: vector<u8>,
object_id: ID,
_admin_cap: &AdminCap,
ctx: &TxContext,
) {
table::add(
&mut registry.entries,
std::string::utf8(name),
object_id,
);
}
/// 查询
public fun resolve(registry: &ObjectRegistry, name: String): ID {
*table::borrow(®istry.entries, name)
}
// 通过注册表查找 Treasury ID
const registry = await getObjectWithJson(REGISTRY_ID);
const treasuryId = registry?.entries?.["alliance_treasury"];
Registry 的价值,不只是“方便查一个 ID”,而是把“分散的对象发现逻辑”统一下来。
这会直接改善三件事:
- 前端不必硬编码一堆对象地址
- 其他合约知道该去哪里找关键对象
- 升级或迁移后,可以通过注册表做平滑切换
但 Registry 也有边界
不要把它当成万能数据库。它最适合做:
- 命名解析
- 核心对象入口发现
- 少量稳定映射
不适合做:
- 高频变化的大列表
- 重型业务统计
- 大规模时间序列数据
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 多租户合约 | Table 按 gate_id 隔离配置,任意 Builder 可注册 |
| 服务端角色 | 事件监听 + 数据提供 + 临近性验证 |
| 双向同步 | 链上事件 → 游戏状态;游戏验证 → 链上记录 |
| ObjectRegistry | 全局命名表,方便其他合约和 dApp 查找对象 |
📚 延伸阅读
Chapter 19:全栈 dApp 架构设计
目标: 设计和实现生产级的 EVE Frontier dApp,涵盖状态管理、实时数据更新、错误处理、响应式设计和 CI/CD 自动化部署。
状态:架构章节。正文以全栈 dApp 组织、状态管理和部署为主。
19.1 全栈架构概览
┌─────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ ┌──────────────────────────────────────────────┐ │
│ │ React / Next.js dApp │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ EVE Vault│ │React │ │ Tanstack │ │ │
│ │ │ Wallet │ │ dapp-kit │ │ Query │ │ │
│ │ └──────────┘ └──────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────┘
│
┌───────────┼────────────┐
▼ ▼ ▼
Sui 全节点 你的后端 游戏服务器
GraphQL 赞助服务 位置 / 验证 API
事件流 索引服务
这张图最该传达的不是“技术栈很多”,而是:
一个真实可用的 EVE dApp,从来不是单页前端,而是一整套分层协同系统。
这套系统里每层都在解决不同问题:
- 浏览器负责交互和状态反馈
- 钱包负责签名与身份
- 全节点和 GraphQL 提供链上真相
- 后端负责赞助、风控、聚合
- 游戏服务器提供链下世界解释和验证
如果这些职责不分层,系统表面能跑,后面一定会越来越难维护。
19.2 项目结构(Next.js 示例)
dapp/
├── app/ # Next.js App Router
│ ├── layout.tsx # 全局布局(Provider)
│ ├── page.tsx # 首页
│ ├── gate/[id]/page.tsx # 星门详情页
│ └── dashboard/page.tsx # 管理面板
├── components/
│ ├── common/
│ │ ├── WalletButton.tsx
│ │ ├── TxStatus.tsx
│ │ └── LoadingSpinner.tsx
│ ├── gate/
│ │ ├── GateCard.tsx
│ │ ├── JumpPanel.tsx
│ │ └── TollInfo.tsx
│ └── market/
│ ├── ItemGrid.tsx
│ └── BuyButton.tsx
├── hooks/
│ ├── useGate.ts # 星门数据
│ ├── useMarket.ts # 市场数据
│ ├── useSponsoredAction.ts # 赞助交易
│ └── useEvents.ts # 实时事件
├── lib/
│ ├── sui.ts # SuiClient 实例
│ ├── contracts.ts # 合约常量
│ ├── queries.ts # GraphQL 查询
│ └── config.ts # 环境配置
├── store/
│ └── useAppStore.ts # Zustand 全局状态
└── .env.local
目录结构的真正目的不是“好看”,而是防止职责蔓延
最常见的失控方式是:
- 组件里直接塞链上请求
- Hook 里直接写业务规则
- 页面里直接拼交易细节
- 全局 store 里塞一切状态
短期能跑,长期会很难改。
一个更稳的边界通常是:
components/负责展示和交互hooks/负责页面级数据流lib/负责底层客户端和查询封装store/只放真正跨页面共享的本地 UI 状态
19.3 全局 Provider 配置
// app/layout.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SuiClientProvider, WalletProvider } from "@mysten/dapp-kit-react";
import { EveFrontierProvider } from "@evefrontier/dapp-kit";
import { getFullnodeUrl } from "@mysten/sui/client";
import { EVE_VAULT_WALLET } from "@evefrontier/dapp-kit";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 秒内不重新请求
refetchInterval: false,
retry: 2,
},
},
});
const networks = {
testnet: { url: getFullnodeUrl("testnet") },
mainnet: { url: getFullnodeUrl("mainnet") },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<QueryClientProvider client={queryClient}>
<SuiClientProvider networks={networks} defaultNetwork="testnet">
<WalletProvider wallets={[EVE_VAULT_WALLET]} autoConnect>
<EveFrontierProvider>
{children}
</EveFrontierProvider>
</WalletProvider>
</SuiClientProvider>
</QueryClientProvider>
</body>
</html>
);
}
Provider 链其实是在声明整套应用的运行时依赖顺序
这不是形式问题。顺序一旦错,常见后果包括:
- 钱包上下文拿不到 client
- Query cache 失效不按预期工作
- dapp-kit 读取不到需要的环境
所以全局 Provider 最好尽量稳定,不要在业务迭代中频繁改动。
19.4 状态管理(Zustand + React Query)
// store/useAppStore.ts
import { create } from "zustand";
interface AppStore {
selectedGateId: string | null;
txPending: boolean;
txDigest: string | null;
setSelectedGate: (id: string | null) => void;
setTxPending: (pending: boolean) => void;
setTxDigest: (digest: string | null) => void;
}
export const useAppStore = create<AppStore>((set) => ({
selectedGateId: null,
txPending: false,
txDigest: null,
setSelectedGate: (id) => set({ selectedGateId: id }),
setTxPending: (pending) => set({ txPending: pending }),
setTxDigest: (digest) => set({ txDigest: digest }),
}));
// hooks/useGate.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useCurrentClient } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
export function useGate(gateId: string) {
const client = useCurrentClient();
return useQuery({
queryKey: ["gate", gateId],
queryFn: async () => {
const obj = await client.getObject({
id: gateId,
options: { showContent: true },
});
return obj.data?.content?.dataType === "moveObject"
? obj.data.content.fields
: null;
},
refetchInterval: 15_000,
});
}
export function useJumpGate(gateId: string) {
const queryClient = useQueryClient();
const { signAndExecuteSponsoredTransaction } = useSponsoredAction();
return useMutation({
mutationFn: async (characterId: string) => {
const tx = new Transaction();
tx.moveCall({
target: `${TOLL_PACKAGE}::toll_gate_ext::pay_toll_and_get_permit`,
arguments: [/* ... */],
});
return signAndExecuteSponsoredTransaction(tx);
},
onSuccess: () => {
// 交易成功后使相关查询失效(触发重新加载)
queryClient.invalidateQueries({ queryKey: ["gate", gateId] });
queryClient.invalidateQueries({ queryKey: ["treasury"] });
},
});
}
React Query 和 Zustand 不要混用职责
一个非常实用的分工是:
- React Query 管链上数据、远程数据、缓存、失效与重取
- Zustand 管本地 UI 状态,例如当前选中项、弹窗、临时输入
一旦把链上对象也塞进 Zustand,或者把纯 UI 状态硬塞进 Query cache,后面几乎一定会变乱。
一个成熟 dApp 至少有三层状态
- 远程真相状态 链上对象、索引结果、游戏服 API 返回
- 本地交互状态 表单、hover、loading、弹窗
- 事务状态 正在签名、已提交、已确认、失败
这三层状态更新节奏不同,不应该揉成一层。
19.5 实时数据推送
// hooks/useEvents.ts
import { useEffect, useRef, useState } from "react";
import { useCurrentClient } from "@mysten/dapp-kit-react";
export function useRealtimeEvents<T>(
eventType: string,
options?: { maxEvents?: number }
) {
const client = useCurrentClient();
const [events, setEvents] = useState<T[]>([]);
const unsubRef = useRef<(() => void) | null>(null);
const maxEvents = options?.maxEvents ?? 50;
useEffect(() => {
const subscribe = async () => {
unsubRef.current = await client.subscribeEvent({
filter: { MoveEventType: eventType },
onMessage: (event) => {
setEvents((prev) => [event.parsedJson as T, ...prev].slice(0, maxEvents));
},
});
};
subscribe();
return () => { unsubRef.current?.(); };
}, [client, eventType, maxEvents]);
return events;
}
// 使用
function JumpFeed() {
const jumps = useRealtimeEvents<{character_id: string; toll_paid: string}>(
`${TOLL_PACKAGE}::toll_gate_ext::GateJumped`
);
return (
<ul>
{jumps.map((j, i) => (
<li key={i}>
{j.character_id.slice(0, 8)}... 支付 {Number(j.toll_paid) / 1e9} SUI
</li>
))}
</ul>
);
}
实时流不要拿来替代完整数据加载
它更适合做:
- 增量 feed
- 提示和通知
- 局部活跃信息
而不是直接充当页面首屏数据源。更稳的策略通常是:
- 页面先加载当前快照
- 再接事件流补增量
- 定时或按需做一致性刷新
19.6 错误处理与用户体验
// components/common/TxButton.tsx
import { useState } from "react";
interface TxButtonProps {
onClick: () => Promise<void>;
children: React.ReactNode;
disabled?: boolean;
}
export function TxButton({ onClick, children, disabled }: TxButtonProps) {
const [status, setStatus] = useState<"idle" | "pending" | "success" | "error">("idle");
const [message, setMessage] = useState("");
const handleClick = async () => {
setStatus("pending");
setMessage("⏳ 提交中...");
try {
await onClick();
setStatus("success");
setMessage("✅ 交易成功!");
setTimeout(() => setStatus("idle"), 3000);
} catch (e: any) {
setStatus("error");
// 解析 Move abort 错误码为人类可读信息
const abortCode = extractAbortCode(e.message);
setMessage(`❌ ${translateError(abortCode) ?? e.message}`);
}
};
return (
<div>
<button
onClick={handleClick}
disabled={disabled || status === "pending"}
className={`tx-btn tx-btn--${status}`}
>
{status === "pending" ? "⏳ 处理中..." : children}
</button>
{message && <p className={`message message--${status}`}>{message}</p>}
</div>
);
}
// 将 Move abort 错误码翻译为友好提示
function translateError(code: number | null): string | null {
const errors: Record<number, string> = {
0: "权限不足,请确认钱包已连接",
1: "余额不足",
2: "物品已售出",
3: "星门未上线",
};
return code !== null ? errors[code] ?? null : null;
}
function extractAbortCode(message: string): number | null {
const match = message.match(/abort_code: (\d+)/);
return match ? parseInt(match[1]) : null;
}
19.7 CI/CD 自动部署
# .github/workflows/deploy.yml
name: Deploy dApp
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run test
- run: npm run build
deploy-preview:
if: github.event_name == 'pull_request'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
env:
VITE_SUI_RPC_URL: ${{ vars.TESTNET_RPC_URL }}
VITE_WORLD_PACKAGE: ${{ vars.TESTNET_WORLD_PACKAGE }}
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
deploy-prod:
if: github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
env:
VITE_SUI_RPC_URL: ${{ vars.MAINNET_RPC_URL }}
VITE_WORLD_PACKAGE: ${{ vars.MAINNET_WORLD_PACKAGE }}
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-args: "--prod"
🔖 本章小结
| 架构组件 | 技术选择 | 职责 |
|---|---|---|
| UI 框架 | React + Next.js | 页面渲染、路由 |
| 链上通信 | @mysten/dapp-kit + SuiClient | 读链/签名/发交易 |
| 状态管理 | Zustand(全局) + React Query(服务端) | 缓存与同步 |
| 实时更新 | subscribeEvent(WebSocket) | 事件推送 |
| 错误处理 | abort code 翻译 + 状态机 | 用户友好提示 |
| CI/CD | GitHub Actions + Vercel | 自动测试与部署 |
📚 延伸阅读
Chapter 20:游戏内 dApp 集成(浮层 UI 与事件通信)
目标: 掌握如何将你的 dApp 嵌入 EVE Frontier 游戏客户端作为悬浮面板,实现游戏内与链上数据的无缝交互,以及如何从游戏内发起签名请求而无需切换到外部浏览器。
状态:集成章节。正文以游戏内 WebView、浮层 UI 和事件通信为主。
20.1 两种 dApp 访问模式
EVE Frontier 支持两种访问你的 dApp 的方式:
| 模式 | 入口 | 适合场景 |
|---|---|---|
| 外部浏览器 | 玩家手动打开网页 | 管理面板、数据分析、设置页 |
| 游戏内浮层 | 游戏客户端内嵌 WebView | 交易弹窗、实时状态、战斗辅助 |
游戏内集成提供更流畅的用户体验:玩家无需切出游戏就能完成购买、查看库存、签署交易。
这章最重要的不是“WebView 里也能打开网页”,而是:
同一套 dApp,在游戏内和外部浏览器里扮演的角色其实不一样。
外部浏览器更像完整后台:
- 信息量大
- 操作链更长
- 适合管理、分析、配置
游戏内浮层更像即时工具:
- 必须快
- 必须短
- 必须对当前场景强相关
如果你把两种入口做成完全一样,通常两边体验都会打折。
20.2 游戏内 WebView 的工作原理
EVE Frontier 客户端内置一个 Chromium WebView,可以加载外部 URL:
游戏客户端 (Unity/Electron)
└── WebView 组件
└── 加载你的 dApp URL(https://your-dapp.com)
└── 与 EVE Vault(已注入游戏内)通信
关键点:EVE Vault 被注入到游戏内 WebView 的 window 对象中,与外部浏览器扩展共享相同的 Wallet Standard API,因此同一套 @mysten/dapp-kit 代码无需修改即可在两种模式下运行。
但“API 兼容”不等于“体验等价”
技术上可以复用同一套钱包接入代码,不代表你可以无脑照搬整个产品流。
游戏内环境通常会额外受到这些约束:
- 页面空间更小
- 玩家注意力更短
- 操作时可能仍在战斗或移动
- 宿主环境会决定打开/关闭时机
所以真正该复用的是底层能力,而不是整套交互节奏。
20.3 检测当前运行环境
你的 dApp 需要知道自己是运行在游戏内还是外部浏览器,以便做出相应的 UI 调整:
// lib/environment.ts
export type RunEnvironment = "in-game" | "external-browser" | "unknown";
export function detectEnvironment(): RunEnvironment {
// EVE Frontier 客户端会在 WebView 的 navigator.userAgent 中注入标识
const ua = navigator.userAgent;
if (ua.includes("EVEFrontier/GameClient")) {
return "in-game";
}
// 也可以通过自定义查询参数传入
const params = new URLSearchParams(window.location.search);
if (params.get("env") === "ingame") {
return "in-game";
}
return "external-browser";
}
export const isInGame = detectEnvironment() === "in-game";
// App.tsx
import { isInGame } from "./lib/environment";
export function App() {
return (
<div className={`app ${isInGame ? "app--ingame" : "app--external"}`}>
{isInGame ? <InGameOverlay /> : <FullDashboard />}
</div>
);
}
环境检测真正要服务什么?
不是为了打一个 isInGame 标记,而是为了让页面决定:
- 当前该渲染哪套布局
- 某些按钮是否应该隐藏
- 是否要监听游戏事件桥
- 某些复杂操作是否该引导到外部浏览器完成
也就是说,环境检测不是展示层小技巧,而是交互路由的一部分。
20.4 游戏内浮层 UI 设计原则
游戏内 UI 与外部 Web 页面的设计要求不同:
| 外部浏览器 | 游戏内浮层 |
|---|---|
| 全屏布局 | 小窗口(通常 400×600px) |
| 标准字体大小 | 更大字体,高对比度 |
| 悬停 tooltip | 避免悬停(不确定焦点在游戏还是 UI) |
| 多步骤表单 | 单步操作为主,减少输入 |
| 非流式动效 | 轻量动效(防止遮挡游戏画面) |
/* ingame.css - 游戏内浮层专用样式 */
:root {
--ingame-bg: rgba(10, 15, 25, 0.92);
--ingame-border: rgba(80, 160, 255, 0.4);
--ingame-text: #e0e8ff;
--ingame-accent: #4fa3ff;
}
.app--ingame {
width: 420px;
min-height: 100vh;
background: var(--ingame-bg);
color: var(--ingame-text);
border: 1px solid var(--ingame-border);
backdrop-filter: blur(8px);
font-size: 15px; /* 比标准稍大 */
font-family: 'Share Tech Mono', monospace; /* EVE 风格字体 */
}
/* 确认按钮足够大,适合鼠标点击(游戏内操作精度要求) */
.ingame-btn {
min-height: 44px;
min-width: 140px;
font-size: 14px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* 隐藏非必要的横向导航 */
.app--ingame .sidebar-nav { display: none; }
.app--ingame .header-nav { display: none; }
游戏内浮层最容易犯的错
1. 把后台页面硬塞进浮层
结果就是:
- 信息密度过高
- 按钮太小
- 用户根本不知道当前最重要的动作是什么
2. 把确认流程做得过长
游戏内适合:
- 单步确认
- 当前对象的即时操作
- 强场景相关动作
不太适合:
- 长表单
- 多页设置向导
- 复杂筛选后台
3. 视觉上太“网页”,不够“嵌入式工具”
浮层更应该像一个面向当前设施的控制面板,而不是独立网站首页。
20.5 游戏事件监听(postMessage 桥接)
游戏客户端通过 window.postMessage 向 WebView 发送游戏内事件:
// lib/gameEvents.ts
export type GameEvent =
| { type: "PLAYER_ENTERED_RANGE"; assemblyId: string; distance: number }
| { type: "PLAYER_LEFT_RANGE"; assemblyId: string }
| { type: "INVENTORY_CHANGED"; characterId: string }
| { type: "SYSTEM_CHANGED"; fromSystem: string; toSystem: string };
type GameEventHandler = (event: GameEvent) => void;
const handlers = new Set<GameEventHandler>();
// 启动监听(在应用启动时调用一次)
export function startGameEventListener() {
window.addEventListener("message", (e) => {
// 仅处理来自游戏客户端的消息(通过 origin 或约定的 source 字段验证)
if (e.data?.source !== "EVEFrontierClient") return;
const event = e.data as { source: string } & GameEvent;
if (!event.type) return;
for (const handler of handlers) {
handler(event);
}
});
}
export function onGameEvent(handler: GameEventHandler) {
handlers.add(handler);
return () => handlers.delete(handler); // 返回取消订阅函数
}
事件桥最重要的不是“能收到消息”,而是消息语义稳定
一个成熟的消息桥接协议,至少应该保证:
- 事件类型稳定
- 字段名和字段含义稳定
- 缺失字段时前端能安全降级
- 前后端都知道哪些事件是一次性触发、哪些是状态同步
否则游戏客户端一改字段,前端会在最难排查的环境里静默出错。
在 React 中使用游戏事件
// hooks/useGameEvents.ts
import { useEffect } from "react";
import { onGameEvent, GameEvent } from "../lib/gameEvents";
export function useGameEvent<T extends GameEvent["type"]>(
type: T,
handler: (event: Extract<GameEvent, { type: T }>) => void,
) {
useEffect(() => {
return onGameEvent((event) => {
if (event.type === type) {
handler(event as Extract<GameEvent, { type: T }>);
}
});
}, [type, handler]);
}
// 使用场景:玩家进入星门范围时,自动弹出购票面板
function GatePanel() {
const [nearGate, setNearGate] = useState<string | null>(null);
useGameEvent("PLAYER_ENTERED_RANGE", (event) => {
setNearGate(event.assemblyId);
});
useGameEvent("PLAYER_LEFT_RANGE", () => {
setNearGate(null);
});
if (!nearGate) return null;
return <JumpTicketPanel gateId={nearGate} />;
}
游戏事件不要直接当作链上真相
事件桥最适合做:
- 当前场景提示
- UI 弹出/关闭
- 当前对象上下文切换
但真正涉及资产和权限的动作,仍然应该回到链上对象和正式验证流程上来。
换句话说:
- 游戏事件告诉你“玩家现在可能想操作谁”
- 链上数据告诉你“这个对象现在到底处于什么状态”
20.6 从游戏内发起签名请求
由于 EVE Vault 在游戏内已注入,签名请求直接弹出游戏内的 Vault UI:
// components/InGameMarket.tsx
import { useDAppKit } from "@mysten/dapp-kit-react";
import { Transaction } from "@mysten/sui/transactions";
export function InGameMarket({ gateId }: { gateId: string }) {
const dAppKit = useDAppKit();
const [status, setStatus] = useState("");
const handleBuy = async () => {
setStatus("请在右上角钱包确认交易...");
const tx = new Transaction();
tx.moveCall({
target: `${TOLL_PKG}::toll_gate_ext::pay_toll_and_get_permit`,
arguments: [/* ... */],
});
try {
// 签名请求会触发游戏内置的 EVE Vault 弹窗
const result = await dAppKit.signAndExecuteTransaction({
transaction: tx,
});
setStatus("✅ 通行证已发放!");
} catch (e: any) {
if (e.message?.includes("User rejected")) {
setStatus("❌ 已取消");
} else {
setStatus(`❌ ${e.message}`);
}
}
};
return (
<div className="ingame-market">
<div className="gate-info">
<span>⛽ 通行费:10 SUI</span>
<span>⏱ 有效期:30 分钟</span>
</div>
<button className="ingame-btn" onClick={handleBuy}>
🚀 购买通行证
</button>
{status && <p className="status">{status}</p>}
</div>
);
}
游戏内签名体验的关键不是“能签”,而是“别打断用户节奏”
最好的游戏内签名流程通常具备这些特征:
- 签名前就把关键成本讲清楚
- 失败后能快速回到原场景
- 成功后立刻给出当前对象状态变化
如果用户每次签名都像突然切出去做一件外部钱包任务,那游戏内集成价值会大幅下降。
20.7 响应式切换:同一套代码适配两种场景
// App.tsx 完整示例
import { isInGame } from "./lib/environment";
import { startGameEventListener } from "./lib/gameEvents";
import { useEffect } from "react";
export function App() {
useEffect(() => {
if (isInGame) startGameEventListener();
}, []);
return (
<EveFrontierProvider>
{isInGame ? (
// 游戏内:精简的单功能浮层
<InGameOverlay />
) : (
// 外部浏览器:完整功能仪表盘
<FullDashboard />
)}
</EveFrontierProvider>
);
}
20.8 游戏内 dApp 的 URL 配置
向玩家提供正确的 URL,他们可以在游戏设置中添加自定义 dApp:
你的 dApp 地址(在游戏内 WebView 打开):
https://your-dapp.com?env=ingame
# 或通过游戏客户端的"自定义面板"功能添加
# 游戏会在 User-Agent 中自动附加 EVEFrontier/GameClient 标识
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 两种访问模式 | 外部浏览器(完整)vs 游戏内 WebView(精简) |
| 环境检测 | navigator.userAgent 或查询参数判断 |
| UI 适配 | 小窗口、大字体、单步操作、高对比度 |
| 游戏事件监听 | window.postMessage + 事件分发器 |
| 签名无缝集成 | EVE Vault 已注入游戏内,API 完全相同 |
| 响应式切换 | 同一套代码,isInGame 条件渲染 |
📚 延伸阅读
Chapter 21:性能优化与 Gas 最小化
目标: 掌握链上操作的性能优化技巧,最大化利用链下计算,通过批处理、对象设计优化和 Gas 预算控制,构建高效低成本的 EVE Frontier 应用。
状态:工程章节。正文以 Gas、批处理和对象设计优化为主。
21.1 Gas 成本模型
Sui 的 Gas 由两部分组成:
Gas 费 = (计算单元 + 存储差额) × Gas 价格
- 计算单元:Move 代码执行消耗
- 存储差额:链上存储的净增量(新增字节收费,删除字节退款)
关键洞察:
- 读取数据是免费的(GraphQL/RPC 读取不上链)
- 动态字段的增删有显著 Gas 成本
- 发射事件几乎免费(不占用链上存储)
Gas 优化最容易走偏的一点是:很多人一上来就盯着“怎么省几个单位”,却没先看清:
真正昂贵的,往往不是某一行代码,而是你整个状态模型迫使系统反复做的那些事。
所以性能优化最好分三层看:
- 交易层 这笔交易是否能合并、是否重复做了很多小动作
- 对象层 你的对象是否过大、过热、过于集中
- 架构层 哪些计算和聚合其实根本不该上链
21.1.1 一组可以复用的 Gas 对比记录模板
这一章最容易流于口号。建议至少拿一组固定操作记录“优化前/后”数据:
| 操作 | 低效写法 | 优化写法 | 你要记录的字段 |
|---|---|---|---|
| 两个星门上线 + 链接 | 3 笔独立交易 | 1 笔 PTB 批处理 | gasUsed、对象写入数、总耗时 |
| 市场创建挂单 | 大对象追加 vector | 独立对象或动态字段 | 对象大小、写入次数、存储退款 |
| 历史记录 | 持久化到共享对象 | 改发事件 + 链下索引 | 事件数、对象增长字节 |
这些数字不需要追求“绝对标准值”,但必须留下同环境下的对比记录,否则优化结论没有说服力。
21.2 批处理:一笔交易做多件事
Sui 的可编程交易块(PTB)允许在一笔交易中执行多个 Move 调用:
// ❌ 低效:3 笔单独交易
await client.signAndExecuteTransaction({ transaction: tx_online }); // 上线星门1
await client.signAndExecuteTransaction({ transaction: tx_online }); // 上线星门2
await client.signAndExecuteTransaction({ transaction: tx_link }); // 链接星门
// ✅ 高效:1 笔交易完成所有操作
const tx = new Transaction();
// 借用 OwnerCap(一次)
const [ownerCap1, receipt1] = tx.moveCall({ target: `${PKG}::character::borrow_owner_cap`, ... });
const [ownerCap2, receipt2] = tx.moveCall({ target: `${PKG}::character::borrow_owner_cap`, ... });
// 执行所有操作
tx.moveCall({ target: `${PKG}::gate::online`, arguments: [gate1, ownerCap1, ...] });
tx.moveCall({ target: `${PKG}::gate::online`, arguments: [gate2, ownerCap2, ...] });
tx.moveCall({ target: `${PKG}::gate::link`, arguments: [gate1, gate2, ...] });
// 归还 OwnerCap
tx.moveCall({ target: `${PKG}::character::return_owner_cap`, arguments: [..., receipt1] });
tx.moveCall({ target: `${PKG}::character::return_owner_cap`, arguments: [..., receipt2] });
await client.signAndExecuteTransaction({ transaction: tx });
// 节省 2/3 的 Gas 基础费!
21.2.1 如何记录一次真实 Gas 对比
- 先固定输入:同一网络、同一对象数量、同一批操作
- 记录低效版本的执行结果:
digest、gasUsed、effects中的写对象数 - 再执行 PTB 版本,记录同样字段
- 把结果整理成一张对比表,写进你的发布或优化笔记
推荐至少记录这些字段:
- digest
- computationCost
- storageCost
- storageRebate
- nonRefundableStorageFee
- changedObjects count
PTB 不是“能合就全合”
批处理很强,但也不是无脑把所有动作塞进一笔就最好。
适合合并的情况:
- 原本就强关联的步骤
- 必须原子成功或一起失败的流程
- 多次借用同类权限对象的操作
不一定适合过度合并的情况:
- 一笔交易里塞太多无关逻辑
- 一旦失败就很难定位问题
- Gas 预算和计算量开始变得不可预测
所以 PTB 的目标不是“最大化长度”,而是“收敛一条真正应该原子化的流程”。
21.3 对象设计优化
原则一:避免大对象
// ❌ 把所有数据放在一个对象(最大 250KB)
public struct BadMarket has key {
id: UID,
listings: vector<Listing>, // 随商品增多,对象越来越大
bid_history: vector<BidRecord>, // 历史数据无限增长
}
// ✅ 用动态字段或独立对象分散存储
public struct GoodMarket has key {
id: UID,
listing_count: u64, // 只存计数器
// 具体 Listing 用动态字段存储:df::add(id, item_id, listing)
}
原则二:删除不再需要的对象(获取存储退款)
// 拍卖结束后,删除 Listing 获得 Gas 退款
public fun end_auction(auction: DutchAuction) {
let DutchAuction { id, .. } = auction;
id.delete(); // 删除对象 → 存储退款
}
// 领取完毕后,删除 DividendClaim 对象
public fun close_claim_record(record: DividendClaim) {
let DividendClaim { id, .. } = record;
id.delete();
}
原则三:用 u8/u16 代替 u64 存储小整数
// ❌ 浪费空间
public struct Config has key {
id: UID,
tier: u64, // 只存 1-5,但占 8 字节
status: u64, // 只存 0-3,但占 8 字节
}
// ✅ 紧凑存储
public struct Config has key {
id: UID,
tier: u8, // 只占 1 字节
status: u8, // 只占 1 字节
}
对象设计为什么几乎总是性能问题的根源?
因为在 Sui 上,性能和对象模型是绑在一起的:
- 对象越大,读写越重
- 共享对象越热,争用越高
- 状态越集中,扩展越难
所以很多性能优化,最后都不是在“重写算法”,而是在“重构对象边界”。
一个很实用的判断标准
只要一个对象同时具备下面两个特征,就要开始警惕:
- 经常被写
- 还在不断长大
这类对象几乎一定会成为性能热点。
21.4 链下计算,链上验证
黄金法则:所有不需要强制执行的计算,都放到链下做。
// ❌ 在链上排序(极度消耗 Gas)
public fun get_top_bidders(auction: &Auction, n: u64): vector<address> {
let mut sorted = vector::empty<BidRecord>();
// ... O(n²) 排序,每次都在链上执行
}
// ✅ 链上只存原始数据,链下排序
public fun get_bid_at(auction: &Auction, index: u64): BidRecord {
*df::borrow<u64, BidRecord>(&auction.id, index)
}
// dApp 或后端读取所有竞价,在内存中排序,展示排行榜
复杂路由计算在链下完成
// Example: 星门物流路由(链下计算最优路径)
function findOptimalRoute(
start: string,
end: string,
gateGraph: Map<string, string[]>, // gate_id → [connected_gate_ids]
): string[] {
// Dijkstra 等路径算法,在 dApp/后端执行
// 计算出最优路径后,只把最终跳跃操作提交上链
return dijkstra(gateGraph, start, end);
}
链下计算不是偷懒,而是正确分工
很多适合链下做的事,本质上不是“不重要”,而是:
- 结果需要展示,但不需要链上强制执行
- 算法复杂,但最终只需提交一个结论
- 可重算、可缓存、可替换
这类工作如果硬放链上,只会把成本和失败面一起拉高。
什么时候必须链上验证?
当结果会影响:
- 资产归属
- 权限放行
- 金额结算
- 稀缺资源分配
就必须把关键结论放回链上验证,而不是只信链下算出来就算数。
21.5 Gas 预算设置
const tx = new Transaction();
// 设置 Gas 预算上限(防止意外超额消耗)
tx.setGasBudget(10_000_000); // 10 SUI上限
// 或使用 dryRun 预估 Gas
const estimate = await client.dryRunTransactionBlock({
transactionBlock: await tx.build({ client }),
});
console.log("预估 Gas:", estimate.effects.gasUsed);
dryRun 最值钱的地方,不是“估个数”,而是提前发现模型问题
如果一笔交易的 dry run 结果已经显示:
- 写对象很多
- 存储成本异常高
- 返还很少
那通常说明问题不在预算,而在结构本身。
21.6 并行执行:无争用的共享对象设计
Sui 可以并行执行操作不同对象的交易。争用同一共享对象会导致顺序执行:
// ❌ 所有用户都争用同一个 Market 对象
Market (shared) ← 所有购买交易都需要写锁 → 顺序执行
(高流量时,队列堆积,延迟上升)
// ✅ 分片设计(多个 SubMarket)
Market_Shard_0 (shared) ← 物品 type_id % 4 == 0 的交易
Market_Shard_1 (shared) ← 物品 type_id % 4 == 1 的交易
Market_Shard_2 (shared) ← 物品 type_id % 4 == 2 的交易
Market_Shard_3 (shared) ← 物品 type_id % 4 == 3 的交易
(4 个分片并行执行,吞吐量 ×4)
// 分片路由
public fun buy_item_sharded(
shards: &mut vector<MarketShard>,
item_type_id: u64,
payment: Coin<SUI>,
ctx: &mut TxContext,
) {
let shard_index = item_type_id % vector::length(shards);
let shard = vector::borrow_mut(shards, shard_index);
buy_from_shard(shard, item_type_id, payment, ctx);
}
并发设计里最该问的问题
不是“能不能并行”,而是:
我的业务流里,到底哪些状态必须争用同一个共享对象,哪些其实可以天然拆开?
比如市场系统里,常见可以拆开的维度包括:
- 物品类型
- 区域
- 租户
- 时间桶
只要拆分维度选对,吞吐量通常会明显提升。
分片也有代价
别把分片当成免费午餐。它会带来:
- 查询聚合更复杂
- 路由逻辑更复杂
- 前端和索引层要额外知道分片规则
所以分片是“为了吞吐而增加系统复杂度”的明确交换,不是默认选项。
🔖 本章小结
| 优化技巧 | 节省比例 |
|---|---|
| PTB 批处理(合并多笔交易) | 30-70% 基础费 |
| 链下计算,链上验证 | 消除复杂计算 Gas |
| 删除废弃对象 | 获得存储退款 |
| 紧凑数据类型(u8 vs u64) | 减小对象尺寸 |
| 分片共享对象 | 提升并发吞吐量 |
📚 延伸阅读
Chapter 22:Move 高级模式 — 升级兼容性设计
目标: 掌握生产级 Move 合约的升级兼容架构,包括版本化 API、数据迁移、Policy 控制,以及在不中断服务的情况下平滑升级。
状态:设计进阶章节。正文以升级兼容、迁移和时间锁控制为主。
22.1 升级兼容性问题的本质
Move 合约升级面临两个核心约束:
约束1:结构体定义不可修改(不能加/删字段,不能改字段类型)
约束2:函数签名不可修改(参数和返回值不能变)
BUT:
✅ 可以添加新函数
✅ 可以添加新模块
✅ 可以修改函数内部逻辑(不变签名)
✅ 可以添加新结构体
挑战:如果你的合约 v1 有个 Market 结构体,v2 想增加一个 expiry_ms 字段,你不能直接修改。
升级兼容这一章真正要解决的,不是“怎么发新版本”,而是:
怎么让一个已经被对象、前端、脚本、用户共同依赖的系统继续活下去。
所以升级问题本质上是四层兼容问题:
- 链上对象兼容
- 链上接口兼容
- 前端解析兼容
- 运维流程兼容
22.2 扩展模式:用动态字段追加“未来字段“
最佳实践:预先为未来字段留下扩展空间:
module my_market::market_v1;
/// 当前字段
public struct Market has key {
id: UID,
toll: u64,
owner: address,
// 注意:不要试图预测未来需要的字段——因为你改不了
// 而是依赖动态字段做扩展
}
// V1 → V2:用动态字段追加 expiry_ms
// (升级包发布后,在迁移脚本中调用)
public fun add_expiry_field(
market: &mut Market,
expiry_ms: u64,
) {
// 如果还没有这个字段,才添加
if !df::exists_(&market.id, b"expiry_ms") {
df::add(&mut market.id, b"expiry_ms", expiry_ms);
}
}
/// V2 版本读取 expiry(向后兼容:旧对象没有这个字段时返回默认值)
public fun get_expiry(market: &Market): u64 {
if df::exists_(&market.id, b"expiry_ms") {
*df::borrow<vector<u8>, u64>(&market.id, b"expiry_ms")
} else {
0 // 默认永不过期
}
}
动态字段为什么会成为升级逃生口?
因为它让你在不改原始 struct 布局的前提下,给旧对象补充新语义。
但它也有边界:
- 适合追加字段
- 不适合把所有未来复杂结构都硬塞进去
如果一个版本升级需要往对象上拼很多临时字段,那通常说明你该重新思考模型,而不是无限依赖补丁式扩展。
22.3 版本化 API 设计
当你需要改变函数行为时,保留旧版本,添加新版本:
module my_market::market;
/// V1 API(永远保持向后兼容)
public fun buy_item_v1(
market: &mut Market,
payment: Coin<SUI>,
item_type_id: u64,
ctx: &mut TxContext,
): Item {
// 原始逻辑
}
/// V2 API(新功能:支持折扣码)
public fun buy_item_v2(
market: &mut Market,
payment: Coin<SUI>,
item_type_id: u64,
discount_code: Option<vector<u8>>, // 新参数
clock: &Clock, // 新参数(时效验证)
ctx: &mut TxContext,
): Item {
// 新逻辑(包含折扣处理)
let effective_price = apply_discount(market, item_type_id, discount_code, clock);
// ...
}
dApp 端适配:在 TypeScript 端检查合约版本,选择调用哪个函数:
async function buyItem(useV2: boolean, ...) {
const tx = new Transaction();
if (useV2) {
tx.moveCall({ target: `${PKG}::market::buy_item_v2`, ... });
} else {
tx.moveCall({ target: `${PKG}::market::buy_item_v1`, ... });
}
}
为什么“保留旧入口”往往比“强迫全部迁移”更稳?
因为线上系统的调用方从来不只有你自己:
- 旧前端还在跑
- 用户脚本可能还在用
- 第三方聚合器可能还没升级
所以最稳的升级路径往往不是“一刀切替换”,而是:
- 新旧并存
- 给迁移窗口
- 逐步下线旧接口
22.4 升级锁定策略
对于高价值合约,可以在 UpgradeCap 上增加时间锁:
module my_gov::upgrade_timelock;
use sui::package::UpgradeCap;
use sui::clock::Clock;
public struct TimelockWrapper has key {
id: UID,
upgrade_cap: UpgradeCap,
delay_ms: u64, // 升级需要提前公告的等待时间
announced_at_ms: u64, // 公告时间(0 = 未公告)
}
/// 第一步:公告升级意图(开始计时)
public fun announce_upgrade(
wrapper: &mut TimelockWrapper,
_admin: &AdminCap,
clock: &Clock,
) {
assert!(wrapper.announced_at_ms == 0, EAlreadyAnnounced);
wrapper.announced_at_ms = clock.timestamp_ms();
}
/// 第二步:等待延迟期后才能执行升级
public fun authorize_upgrade(
wrapper: &mut TimelockWrapper,
clock: &Clock,
): &mut UpgradeCap {
assert!(wrapper.announced_at_ms > 0, ENotAnnounced);
assert!(
clock.timestamp_ms() >= wrapper.announced_at_ms + wrapper.delay_ms,
ETimelockNotExpired,
);
// 重置,下次升级需要重新公告
wrapper.announced_at_ms = 0;
&mut wrapper.upgrade_cap
}
TimeLock 真正保护的不是代码,而是信任关系
它给社区、协作者和用户留出了观察窗口,让升级不至于变成“管理员今晚想改什么就改什么”。
这在高价值协议里非常关键,因为升级风险很多时候不是技术 bug,而是治理风险。
22.5 大规模数据迁移策略
当需要重建存储结构时,采用“增量迁移“而不是“一次性迁移“:
// 场景:将 ListingsV1(vector)迁移为 ListingsV2(Table)
module migration::market_migration;
public struct MigrationState has key {
id: UID,
migrated_count: u64,
total_count: u64,
is_complete: bool,
}
/// 每次迁移一批(避免一笔交易超出计算限制)
public fun migrate_batch(
old_market: &mut MarketV1,
new_market: &mut MarketV2,
state: &mut MigrationState,
batch_size: u64, // 每次处理 batch_size 条记录
ctx: &TxContext,
) {
let start = state.migrated_count;
let end = min(start + batch_size, state.total_count);
let mut i = start;
while (i < end) {
let listing = get_listing_v1(old_market, i);
insert_listing_v2(new_market, listing);
i = i + 1;
};
state.migrated_count = end;
if end == state.total_count {
state.is_complete = true;
};
}
迁移脚本:自动循环执行直到完成
async function runMigration(stateId: string) {
let isComplete = false;
let batchNum = 0;
while (!isComplete) {
const tx = new Transaction();
tx.moveCall({
target: `${MIGRATION_PKG}::market_migration::migrate_batch`,
arguments: [/* ... */, tx.pure.u64(100)], // 每批 100 条
});
const result = await client.signAndExecuteTransaction({ signer: adminKeypair, transaction: tx });
console.log(`Batch ${++batchNum} done:`, result.digest);
// 检查迁移状态
const state = await client.getObject({ id: stateId, options: { showContent: true } });
isComplete = (state.data?.content as any)?.fields?.is_complete;
await new Promise(r => setTimeout(r, 1000)); // 间隔 1 秒
}
console.log("迁移完成!");
}
为什么迁移最好增量做,而不是一把梭?
因为真实线上系统里,你通常要同时平衡:
- 计算上限
- 风险可控
- 失败可恢复
- 迁移期间服务还能继续运行
一次性迁移最大的问题不是写不出来,而是:
- 中途失败很难恢复
- 失败后状态容易半新半旧
- 交易太大时根本发不出去
22.6 升级完整工作流
① 开发新版本合约(本地 + testnet 验证)
② 声明升级意图(TimeLock 开始计时,通知社区)
③ 社区审查期(72 小时)
④ TimeLock 到期后,执行 sui client upgrade --upgrade-capability <CAP_ID>
⑤ 运行数据迁移脚本(如有必要)
⑥ 更新 dApp 配置(新 Package ID、新接口版本)
⑦ 公告升级完成
一个成熟团队会把升级视为一次“受控发布事件”
也就是说,除了链上动作本身,还应该同步准备:
- 升级公告
- 前端切换计划
- 回滚或停机预案
- 升级后观察指标
否则“链上已经升级完成”并不等于“系统已经稳定完成升级”。
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 升级约束 | 结构体/函数签名不可改,但可加新函数/模块 |
| 动态字段扩展 | df::add() 在运行时追加“未来字段“ |
| 版本化 API | buy_v1() / buy_v2() 并存,dApp 按版本选择 |
| TimeLock 升级 | 公告 + 等待期 → 社区审查 → 才能执行 |
| 增量迁移 | migrate_batch() 分批处理,避免超出计算限制 |
📚 延伸阅读
Chapter 23:发布、维护与社区协作
目标: 掌握从开发到上线的完整发布流程,理解 Builder 生态的边界与定位,成为可持续活跃的 EVE Frontier 构建者。
状态:发布与运营章节。正文以上线流程、维护和 Builder 协作为主。
23.1 发布 checklist 全览
从本地开发到正式上线,需要经历以下阶段:
Phase 1 —— 本地开发(Localnet)
✅ Docker 本地链运行
✅ Move build 编译通过
✅ 单元测试全部通过
✅ 功能测试(脚本模拟完整流程)
Phase 2 —— 测试网(Testnet)
✅ sui client publish 到 testnet
✅ 扩展注册到测试组件
✅ dApp 部署到测试 URL
✅ 邀请小范围用户测试
Phase 3 —— 主网发布(Mainnet)
✅ 代码审计(自审 + 社区审查)
✅ 备份 UpgradeCap 到安全地址
✅ sui client switch --env mainnet
✅ 发布合约,记录 Package ID
✅ dApp 发布到正式域名
✅ 通知社区 / 更新公告
这张 checklist 本身没有问题,但真正要建立的是一个观念:
发布不是“代码从本地搬到链上”,而是“把一个真实会被人使用和依赖的服务切到生产状态”。
所以发布要同时覆盖四条线:
- 合约线 包有没有发对、权限有没有配对
- 前端线 dApp 是否连到正确网络和对象
- 运营线 用户知不知道新版本怎么用、旧入口是否失效
- 应急线 出故障时谁处理、先停哪一层
23.2 网络环境配置
Sui 和 EVE Frontier 支持三个网络:
| 网络 | 用途 | RPC 地址 |
|---|---|---|
| localnet | 本地开发,Docker 启动 | http://127.0.0.1:9000 |
| testnet | 公开测试,无真实价值 | https://fullnode.testnet.sui.io:443 |
| mainnet | 正式生产环境 | https://fullnode.mainnet.sui.io:443 |
# 切换到不同网络
sui client switch --env testnet
sui client switch --env mainnet
# 查看当前网络
sui client envs
sui client active-env
# 查看账户余额
sui client balance
dApp 中的环境切换
// 通过环境变量控制 dApp 连接的网络
const RPC_URL = import.meta.env.VITE_SUI_RPC_URL
?? 'https://fullnode.testnet.sui.io:443'
const WORLD_PACKAGE = import.meta.env.VITE_WORLD_PACKAGE
?? '0x...' // testnet 的 package id
const client = new SuiClient({ url: RPC_URL })
环境切换最容易出错的地方,不是命令本身,而是“半切换”:
- CLI 已切到 mainnet
- 前端还在读 testnet
- 钱包连的是另一套环境
- 文档和公告写的还是旧 Package ID
一旦出现这种半切换状态,表面症状通常很迷惑:
- 合约看起来发成功了,但前端完全不认
- 用户钱包能连上,但对象查不到
- 自己本地能操作,别人环境里却不行
所以真正要验证的不是“某个地方改了”,而是“所有入口都指向同一个环境”。
23.3 从 Testnet 到 Mainnet 的注意事项
- Package ID 会变:Mainnet 发布后得到新的 Package ID,dApp 配置需要更新
- 数据不通用:Testnet 上创建的对象(角色、组件)在 Mainnet 上不存在,需要重新初始化
- Gas 费真实:Mainnet 的 SUI 有真实价值,发布和操作会消耗真实 Gas
- 不可撤销:已共享(
share_object)的对象无法撤回
从测试网迁到主网,最该警惕的其实是“心理复制”
很多团队会下意识以为:
- 测试网上流程跑通了
- 那主网就是“重复做一遍”
实际不是。主网和测试网最大的区别,不只是资产值钱,而是:
- 用户预期更高
- 错误成本更高
- 回滚空间更小
- 社区信任更脆弱
所以主网发布前,你应该把自己当成在上线一个产品,而不是交一次作业。
23.4 Package 升级的最佳实践
安全存储 UpgradeCap
UpgradeCap 是最敏感的权限对象,一旦丢失则无法升级合约:
# 查看你的 UpgradeCap
sui client objects --json | grep -A5 "UpgradeCap"
存储策略:
- 多签地址:将 UpgradeCap 转到 2/3 多签地址,防止单点失控
- 锁定时间:可以加入时间锁机制,升级需要提前公告
- 烧毁(极端情况):如果确认合约永远不需要升级,可以烧毁 UpgradeCap,彻底保证不可变性
// 将 UpgradeCap 转移到多签地址
const tx = new Transaction()
tx.transferObjects(
[tx.object(UPGRADE_CAP_ID)],
tx.pure.address(MULTISIG_ADDRESS)
)
UpgradeCap 为什么是发布后最危险的对象之一?
因为它控制的不是某一次交易,而是整个协议未来的形态。
如果它管理不当,会出现两种极端风险:
- 被盗 攻击者可以发布恶意升级
- 丢失 你永远失去升级能力
这两种都不是普通业务 bug,而是协议级事故。
版本管理
建议在合约中维护版本号:
const CURRENT_VERSION: u64 = 2;
public struct VersionedConfig has key {
id: UID,
version: u64,
// ... 配置字段
}
// 升级时调用迁移函数
public fun migrate_v1_to_v2(
config: &mut VersionedConfig,
_cap: &UpgradeCap,
) {
assert!(config.version == 1, EMigrationNotNeeded);
// ... 执行数据迁移
config.version = 2;
}
版本号不是摆设
它真正的作用是帮你明确回答:
- 这个对象现在处在哪个语义版本
- 前端和脚本该按哪套字段解释它
- 是否需要触发迁移逻辑
否则一旦线上同时存在旧对象和新逻辑,你很快就会陷入“到底是数据坏了还是代码坏了”的排查地狱。
23.5 dApp 部署与托管
静态部署(推荐方案)
# 构建生产版本
npm run build
# 部署到 Vercel(自动 CI/CD)
vercel --prod
# 或部署到 GitHub Pages
gh-pages -d dist
推荐平台:
| 平台 | 特点 |
|---|---|
| Vercel | 自动 CI/CD,简单配置,免费额度充足 |
| Cloudflare Pages | 全球 CDN,支持 KV 存储扩展 |
| IPFS/Arweave | 真正的去中心化部署,永久存储 |
环境变量配置
# .env.production
VITE_SUI_RPC_URL=https://fullnode.mainnet.sui.io:443
VITE_WORLD_PACKAGE=0x_MAINNET_WORLD_PACKAGE_
VITE_MY_PACKAGE=0x_MAINNET_MY_PACKAGE_
VITE_TREASURY_ID=0x_MAINNET_TREASURY_ID_
前端部署最容易被低估的问题:缓存和旧链接
链上代码发布后,前端不一定会立刻以你预期的方式切过去。你还要考虑:
- CDN 缓存
- 浏览器缓存
- 用户收藏的旧链接
- 第三方页面引用的旧域名或旧参数
也就是说,前端发布不是“把新包传上去”就结束了,你还得确保用户真正进入的是新版本入口。
23.6 Builder 在 EVE Frontier 的定位与约束
理解 Builder 的边界,对于长期成功至关重要:
你可以做的(Layer 3)
- ✅ 编写自定义扩展逻辑(Witness 模式)
- ✅ 构建新的经济机制(市场、拍卖、代币)
- ✅ 创建前端 dApp 界面
- ✅ 在已有设施类型上添加自定义规则
- ✅ 与其他 Builder 的合约组合使用
你不能改变的(Layer 1 & 2)
- ❌ 修改核心游戏物理规则(位置、能量系统)
- ❌ 创建全新类型的设施(只有 CCP 能做)
- ❌ 访问未公开的 Admin 操作
- ❌ 绕过 AdminACL 的服务器验证要求
设计技巧:在约束中找空间
官方限制:星门只能通过 JumpPermit 控制通行
你的扩展空间:
├── 许可证有效期(时效控制)
├── 许可证获取条件(付费/持有 NFT/任务完成)
├── 许可证的二级市场(转卖通行证)
└── 许可证的批量购买折扣
理解约束的正确姿势不是抱怨“为什么不给我更多权限”,而是:
在固定世界规则里,找到足够大的产品设计空间。
真正成熟的 Builder,往往不是想去改世界底层,而是善于在既有接口上做出:
- 更强的运营机制
- 更清晰的权限设计
- 更好的用户体验
- 更高的组合价值
23.7 社区协作与贡献
可组合性:你的合约可以被别人使用
当你发布了一个市场合约,其他 Builder 可以:
- 将你的价格预言机集成进他们的定价系统
- 在你的市场基础上增加推荐返佣
- 用你的代币作为他们服务的支付手段
设计建议:公开必要的读取接口,让你的合约对生态友好:
// 公开查询接口,供其他合约调用
public fun get_current_price(market: &Market, item_type_id: u64): u64 {
// 返回当前价格,其他合约可以用于定价参考
}
public fun is_item_available(market: &Market, item_type_id: u64): bool {
table::contains(&market.listings, item_type_id)
}
参与官方文档贡献
EVE Frontier 文档是开源的:
# 克隆文档仓库
git clone https://github.com/evefrontier/builder-documentation.git
# 创建分支,添加你的教程或修正
git checkout -b feat/add-auction-tutorial
# 提交 PR
贡献内容包括:
- 发现并修正文档错误
- 补充缺失的示例代码
- 翻译文档到其他语言
- 分享你的最佳实践案例
为什么社区协作不是“锦上添花”
因为 Builder 生态的真正复利来自复用:
- 你公开读取接口,别人能接进来
- 别人公开最佳实践,你少踩很多坑
- 文档一旦更准确,整个生态的开发效率都会上升
对你自己也有直接回报:
- 更容易被集成
- 更容易建立信誉
- 更容易获得早期使用者和反馈
行为准则
所有 Builder 必须遵守:
- ❌ 禁止通过编程基础设施骚扰或恶意攻击其他玩家
- ❌ 禁止欺骗性的经济行为(如蜜罐合约)
- ✅ 鼓励公平竞争和透明机制
- ✅ 鼓励集体分享知识和工具
23.8 可持续的 Builder 策略
经济可持续性
收入来源设计:
├── 手续费(市场交易的 1-3%)
├── 订阅服务(月度 LUX 订阅)
├── 高级功能(付费解锁)
└── 联盟服务合同(B2B)
成本控制:
├── 使用读取 API(GraphQL/gRPC)替代高频链上写入
├── 聚合多个操作到单笔交易
└── 利用赞助交易降低用户摩擦
技术可持续性
- 模块化设计:将功能拆分成独立模块,方便独立升级
- 向后兼容:新版本优先兼容旧版本数据
- 文档驱动:记录你自己的合约 API,方便他人集成
- 监控告警:订阅关键事件,当异常发生时获得通知
技术可持续和经济可持续必须一起看
很多项目死掉,不是技术做不出来,也不是没有收入,而是两边脱节:
- 功能很多,但维护成本过高
- 收入看似不错,但全靠人工运营撑着
- 用户能进来,但没有留存理由
所以一个可持续 Builder 项目,通常同时满足:
- 收费模型简单可解释
- 权限和运维不会把自己拖死
- 新版本可以平稳演进
- 关键问题出现时有人能快速响应
真正该长期记录的不是“发了几个版本”
而是这些运营事实:
- 有多少真实用户完成了关键动作
- 哪个环节流失最高
- 哪类交易失败最多
- 哪些功能几乎没人用
这些数据最后会反过来决定你该继续做什么、不该继续做什么。
23.9 EVE Frontier 生态的未来
根据官方文档,以下功能在未来可能开放给 Builder:
- 更多组件类型:冶炼厂、制造厂等工业设施的编程接口
- 零知识证明:用 ZK proof 替代服务器签名做临近验证,实现完全去中心化
- 更丰富的经济接口:更多官方 LUX/EVE Token 的交互接口
设计原则:为可扩展而设计。今天的合约应该能在明天的新功能上线后,通过升级无缝接入。
这里最现实的建议不是“押注所有未来方向”,而是:
- 先把今天真正可落地的组件能力吃透
- 再为未来接口变化留出演进空间
也就是说,未来感不应该来自“写很多还用不上的概念接口”,而应该来自:
- 对象结构别写死
- 配置和策略留升级位
- 前端别把链上字段解释写得过于僵硬
🔖 本章小结
| 知识点 | 核心要点 |
|---|---|
| 发布流程 | localnet → testnet → mainnet 三阶段 |
| 网络切换 | sui client switch --env mainnet |
| UpgradeCap 安全 | 多签存储,考虑时间锁 |
| dApp 部署 | Vercel/Cloudflare Pages + 环境变量 |
| Builder 约束 | Layer 3 自由扩展,Layer 1/2 不可改变 |
| 社区协作 | 开放 API、贡献文档、遵守行为准则 |
| 可持续策略 | 多元收入 + 模块化 + 监控 |
📚 延伸阅读
- Builder 约束文档
- Contributing Guide
- Sui Package 升级
- [EVE Frontier 开发路线图(community channels)]
Chapter 24:故障排查手册(常见错误与调试方法)
目标: 系统整理 EVE Frontier Builder 开发过程中最常遇到的错误类型,掌握高效的调试工作流,把“踩坑“时间降到最低。
状态:工程保障章节。正文以排错路径和调试习惯为主。
24.1 错误分类总览
EVE Frontier 开发错误
├── 合约错误(Move)
│ ├── 编译错误(构建失败)
│ ├── 链上 Abort(运行时失败)
│ └── 逻辑错误(成功执行但结果错误)
├── 交易错误(Sui)
│ ├── Gas 问题
│ ├── 对象版本冲突
│ └── 权限错误
├── dApp 错误(TypeScript/React)
│ ├── 钱包连接失败
│ ├── 读取链上数据失败
│ └── 参数构建错误
└── 环境错误
├── Docker/本地节点问题
├── Sui CLI 配置问题
└── ENV 变量缺失
真正高效的排错,不是背错误大全,而是先把问题定位到正确层。
一个很实用的思路是先问:
- 这是编译前就坏了,还是链上执行时坏了?
- 是对象和权限错了,还是前端构参错了?
- 是环境不一致,还是逻辑本身真的有 bug?
只要第一层归类做对,后面的排查效率会高很多。
24.2 Move 编译错误
错误:unbound module
error[E02001]: unbound module
┌─ sources/my_ext.move:3:5
│
3 │ use world::gate;
│ ^^^^^^^^^^^ Unbound module 'world::gate'
原因:Move.toml 中缺少对 world 包的依赖声明。
解决:
# Move.toml
[dependencies]
World = { git = "https://github.com/evefrontier/world-contracts.git", subdir = "contracts/world", rev = "v0.0.14" }
错误:ability constraint not satisfied
error[E05001]: ability constraint not satisfied
┌─ sources/market.move:42:30
|
42 │ transfer::public_transfer(listing, recipient);
| ^^^^^^^ Missing 'store' ability
原因:Listing 结构体缺少 store ability,无法被 public_transfer。
解决:
// 添加所需 ability
public struct Listing has key, store { ... }
// ^^^^^
错误:unused variable / unused let binding
warning[W09001]: unused let binding
= 'receipt' is bound but not used
解决:用下划线忽略,或确认是否遗漏了归还步骤(Borrow-Use-Return 模式):
let (_receipt) = character::borrow_owner_cap(...); // 暂时忽略
// 更好的做法:确认归还
character::return_owner_cap(own_cap, receipt);
对编译错误最有用的习惯
不是复制粘贴报错去搜,而是立刻判断它属于哪一类:
- 依赖解析问题
unbound module - 类型 / ability 问题
ability constraint not satisfied - 资源生命周期问题
unused let binding、值未消费、借用冲突
Move 编译器给的错误往往已经很接近真实原因,只要别把它当成纯噪音。
24.3 链上 Abort 错误解读
链上 Abort 返回如下格式:
MoveAbort(MoveLocation { module: ModuleId { address: 0x..., name: Identifier("toll_gate_ext") }, function: 2, instruction: 6, function_name: Some("pay_toll") }, 1)
关键信息:function_name + abort code(末尾的数字)。
常见 Abort Code 对照表
| 错误代码 | 典型含义 | 排查方向 |
|---|---|---|
0 | 权限不足(assert!(ctx.sender() == owner)) | 检查调用者地址 vs 合约中存储的 owner |
1 | 余额/数量不足 | 检查 coin::value() vs 所需金额 |
2 | 对象已存在(table::add 重复键) | 检查是否已注册/已购买过 |
3 | 对象不存在(table::borrow 找不到) | 检查 key 是否正确 |
4 | 时间校验失败(过期 / 未到时间) | clock.timestamp_ms() 与合约逻辑对比 |
5 | 状态不正确(如已结束、未开始) | 检查 is_settled、is_online 等状态字段 |
快速定位 Abort 来源
# 在源代码中搜索错误码
grep -n "assert!.*4\b\|abort.*4\b\|= 4;" sources/*.move
遇到 Abort 时,第一反应不该是“合约坏了”
更稳的顺序通常是:
- 先看
function_name - 再看 abort code
- 再对照当时传入的对象、地址、金额、时间参数
很多 Abort 其实不是代码 bug,而是:
- 用了错对象
- 当前状态不满足前置条件
- 前端组装了过期或不完整的参数
24.4 Gas 相关问题
InsufficientGas(Gas 耗尽)
TransactionExecutionError: InsufficientGas
解决方案:阶梯排查
// 1. 先 dryRun 估算 Gas
const estimate = await client.dryRunTransactionBlock({
transactionBlock: await tx.build({ client }),
});
console.log("Gas 估算:", estimate.effects.gasUsed);
// 2. 在实际交易中设置足够的 Gas Budget(+20% 缓冲)
const gasUsed = Number(estimate.effects.gasUsed.computationCost)
+ Number(estimate.effects.gasUsed.storageCost);
tx.setGasBudget(Math.ceil(gasUsed * 1.2));
GasBudgetTooHigh
你的 Gas Budget 超过了账户余额:
// 查询账户 SUI 余额
const balance = await client.getBalance({ owner: address, coinType: "0x2::sui::SUI" });
const maxBudget = Number(balance.totalBalance) * 0.5; // 最多用 50% 余额做 Gas
tx.setGasBudget(Math.min(desired_budget, maxBudget));
Gas 问题最容易被误判成“钱包没钱”
实际上常见成因有三种:
- 真没钱
- Gas budget 设太保守
- 交易模型本身就太重
如果你只会不断调大 budget,而不去看 dry run 结果里的成本结构,最后通常只是把结构问题掩盖掉。
24.5 对象版本冲突
TransactionExecutionError: ObjectVersionUnavailableForConsumption
原因:你的代码持有一个旧版本的对象引用,但链上已经被其他交易修改。
常见场景:同时发起多个使用同一共享对象的交易(如 Market)。
解决:
// ❌ 错误:并行发起多个使用同一共享对象的交易
await Promise.all([buyTx1, buyTx2])
// ✅ 正确:顺序执行
for (const tx of [buyTx1, buyTx2]) {
await client.signAndExecuteTransaction({ transaction: tx })
// 等待确认后再发下一笔
}
版本冲突本质上在提醒你:对象是活的
只要多个交易都要写同一个对象,就要假设它随时可能在你再次提交前已经变了。
所以这类问题往往不是“偶发玄学”,而是系统设计告诉你:
- 这里存在共享热点
- 这里需要串行化或刷新对象版本
- 这里可能需要重新考虑分片或拆对象
24.6 dApp 钱包连接问题
EVE Vault 未检测到
WalletNotFoundError: No wallet found
排查清单:
- ✅ EVE Vault 浏览器扩展是否已安装并启用?
- ✅
VITE_SUI_NETWORK是否与 Vault 当前网络一致(testnet/mainnet)? - ✅
@evefrontier/dapp-kit版本是否与 Vault 版本兼容?
// 列出所有检测到的钱包(调试用)
import { getWallets } from "@mysten/wallet-standard";
const wallets = getWallets();
console.log("检测到的钱包:", wallets.get().map(w => w.name));
签名请求被静默拒绝(无弹窗)
原因:Vault 可能处于锁定状态。
解决:在发起签名前检查钱包状态:
const { currentAccount } = useCurrentAccount();
if (!currentAccount) {
// 引导用户连接钱包,而不是直接发起签名
showConnectModal();
return;
}
钱包问题排查顺序
最稳的顺序通常是:
- 钱包有没有被检测到
- 当前账户有没有连接
- 网络是不是对的
- 对象和权限是不是当前账户可用的
不要一看到签名失败就直接怀疑 Vault 本身。有大量问题其实是前端状态、网络和对象上下文没对齐。
24.7 链上数据读取问题
getObject 返回 null
const obj = await client.getObject({ id: "0x...", options: { showContent: true } });
if (!obj.data) {
// 对象不存在,或 ID 错误
console.error("对象不存在,检查 ID 是否正确(可能是 testnet/mainnet 混淆)");
}
常见原因:
- 用了 testnet 的 Object ID 去查 mainnet(或反之)
- 对象已被删除(合约调用了
id.delete()) - 拼写错误
showContent: true 但 content.fields 为空
const content = obj.data?.content;
if (content?.dataType !== "moveObject") {
// 这是一个 package 对象,不是 Move 对象
console.error("对象不是 MoveObject,可能 ID 指向的是一个 Package");
}
读不到数据时,优先检查哪四件事
- ID 是否来自正确网络
- 这个 ID 是对象还是包
- 对象是否已经删除或迁移
- 前端解析路径是不是和真实字段结构一致
很多“读不到”的问题,根本不是节点坏了,而是你自己查错对象了。
24.8 本地开发环境问题
Docker 本地链启动失败
# 查看容器日志
docker compose logs -f
# 常见原因:端口被占用
lsof -i :9000
kill -9 <PID>
# 重置本地链状态(清空所有数据重新开始)
docker compose down -v
docker compose up -d
sui client publish 失败
# 错误:Package verification failed
# 原因:依赖的 world-contracts 地址与本地节点不一致
# 在 Move.toml 中确认本地测试使用 localnet 的包地址
[addresses]
world = "0x_LOCAL_WORLD_ADDRESS_" # 从本地链部署结果获取
合约部署后无法调用(找不到函数)
# 检查发布的包 ID 是否与 ENV 配置一致
echo $VITE_WORLD_PACKAGE
# 验证链上包是否包含预期函数
sui client object 0x_PACKAGE_ID_ --json | jq '.content.disassembled'
环境问题最怕“半正确”
也就是:
- 本地链是好的
- CLI 也能连
- 但某个地址、依赖或 ENV 还停在另一套环境
这种问题最烦,因为表面上每一层都“看起来没坏”。所以只要碰到环境类问题,最好把:
- 当前网络
- 当前地址
- 当前包 ID
- 当前 ENV 配置
一次性全打印出来比逐个猜快得多。
24.9 调试工作流:系统化排查
遇到问题时,按以下顺序排查:
1. 读错误信息(不要忽略任何细节)
├── 是 Move abort?→ 找 abort code → 查合约源码
├── 是 Gas 问题?→ dryRun 估算 → 调整 budget
└── 是 TypeScript 错误?→ console.log 每一步的参数
2. 隔离问题
├── 用 Sui Explorer 直接调用合约(绕开 dApp)
├── 写 Move 单元测试重现问题
└── 用 curl/Postman 测试 GraphQL 查询
3. 与社区对齐
├── 搜索 Discord #builders 频道
├── 粘贴完整错误信息(包括 Transaction Digest)
└── 提供最小可复现代码
一个更实战的排查心法
每次都先尽量把问题缩成最小:
- 最少对象
- 最少一步操作
- 最短调用链
因为链上系统一旦把前端、后端、钱包、索引、游戏服都卷进来,问题会迅速放大。先缩小,再定位,效率最高。
24.10 常用调试工具
| 工具 | 用途 | 链接 |
|---|---|---|
| Sui Explorer | 查看交易详情、对象状态 | https://suiexplorer.com |
| Sui GraphQL IDE | 手动测试 GraphQL 查询 | https://graphql.testnet.sui.io |
| Move Prover | 形式化验证合约属性 | sui move prove |
| dryRun | 估算 Gas 与模拟执行 | client.dryRunTransactionBlock() |
| sui client call | 命令行直接调用合约 | sui client call --help |
🔖 本章小结
| 错误类型 | 最快排查路径 |
|---|---|
| Move 编译错误 | 查 Move.toml 依赖 + ability 声明 |
| Abort (code N) | 合约源码 grep abort code,对照表速查 |
| Gas 耗尽 | dryRun() 预估 + 设置 20% 缓冲 |
| 对象版本冲突 | 顺序执行而非并发,等待每笔 confirm |
| 钱包未检测到 | 检查扩展安装、网络一致性、版本兼容 |
| 对象读取为空 | 确认网络环境(testnet vs mainnet) |
| 本地链问题 | docker compose logs + 重置数据卷 |
Chapter 25:从 Builder 到产品——商业化路径与生态运营
目标: 超越技术层面,理解如何将你的 EVE Frontier 合约和 dApp 打造成有用户、有收入、有社区的真实产品,以及如何在这个新兴生态中找到自己的定位。
状态:产品章节。正文以商业模式、增长和运营机制为主。
25.1 Builder 的四种商业模式
在 EVE Frontier 生态中,Builder 有四种主要的价值捕获方式:
┌─────────────────────────────────────────────────────────┐
│ Builder 商业模式图谱 │
├─────────────────┬───────────────────────────────────────┤
│ 模式 │ 代表案例 │ 收入来源 │
├─────────────────┼───────────────────────┼──────────────┤
│ 基础设施 │ 星门收费、存储市场 │ 使用费(自动)│
│ Infrastructure │ 通用拍卖平台 │ │
├─────────────────┼───────────────────────┼──────────────┤
│ 代币经济 │ 联盟 Token + DAO │ 代币升值、税 │
│ Token Economy │ 点数系统 │ │
├─────────────────┼───────────────────────┼──────────────┤
│ 平台/SaaS │ 多租户市场框架 │ 平台抽成 │
│ Platform │ 竞赛系统框架 │ 月费/注册费 │
├─────────────────┼───────────────────────┼──────────────┤
│ 数据服务 │ 排行榜、分析面板 │ 广告/订阅 │
│ Data & Tools │ 价格聚合器 │ 增值服务 │
└─────────────────┴───────────────────────┴──────────────┘
这张图最重要的不是帮你“选一个赛道名词”,而是看清:
你到底是在卖资产、卖流量、卖协议能力,还是卖信息优势。
很多 Builder 项目做不起来,不是技术不行,而是一开始就没想清楚自己卖的是什么。
25.2 定价策略:链上自动收入
最简单的 Builder 收入:交易自动抽佣,零运营成本。
双层费率结构
// 结算时:平台费 + Builder 费双层结构
public fun settle_sale(
market: &mut Market,
sale_price: u64,
mut payment: Coin<SUI>,
ctx: &mut TxContext,
): Coin<SUI> {
// 1. 平台协议费(EVE Frontier 官方,如果有的话)
let protocol_fee = sale_price * market.protocol_fee_bps / 10_000;
// 2. 你的 Builder 费
let builder_fee = sale_price * market.builder_fee_bps / 10_000; // 例:200 = 2%
// 3. 剩余给卖家
let seller_amount = sale_price - protocol_fee - builder_fee;
// 分配
transfer::public_transfer(payment.split(builder_fee, ctx), market.fee_recipient);
// ... 协议费到官方地址,剩余给卖家
payment // 返回 seller_amount
}
费率范围建议
| 类型 | 建议区间 | 说明 |
|---|---|---|
| 星门通行费 | 5-50 SUI/次 | 固定费,体现稀缺性 |
| 市场佣金 | 1-3% | 对标传统市场 |
| 拍卖平台费 | 2-5% | 提供的撮合服务 |
| 多租户平台月费 | 10-100 SUI | 其他 Builder 使用你的框架 |
自动抽佣为什么看起来最美,但也最容易高估
它的优势很明显:
- 收入自动化
- 不用人工追账
- 和实际使用量直接挂钩
但它也有前提:
- 用户真的愿意持续使用你的设施
- 你的收费不会被更便宜的替代品瞬间打掉
- 你的服务有明确差异,而不是纯同质化通道
所以链上自动收入不是“写好合约就会来钱”,它只是把商业模式执行得更干净。
25.3 用户获取:游戏内触达
玩家发现你的 dApp 的主要路径:
触达路径优先级:
1. 游戏内显示(最高转化率)
└── 玩家靠近你的星门/炮塔 → 游戏内浮层自动弹出 → 直接交互
2. EVE Frontier 官方 Builder 目录(预期功能)
└── 官方列出认证 Builder 的服务 → 玩家主动查找
3. 玩家社区(Discord / Reddit)
└── 口碑传播 → 联盟推荐 → 用户增长
4. 联盟内部推广
└── 与大联盟合作 → 嵌入他们的工具链 → 批量用户
增长飞轮设计
玩家使用服务
↓
获得奖励(代币/NFT/特权)
↓
价值可见、可交易
↓
向其他玩家炫耀/出售
↓
更多玩家了解并加入
↓
(回到顶部)
用户获取里最容易被忽视的一点
不是“怎么让更多人第一次点开”,而是“用户点开后为什么会留下来”。
尤其在 EVE 场景里,很多功能天然带强场景性:
- 当下需要就会用
- 不需要时就会立刻离开
所以真正要设计的是:
- 第一次使用是否足够顺滑
- 第二次是否还会回来
- 是否会形成联盟级或群体级依赖
25.4 社区建设:Builder 的护城河
在 EVE Frontier,社区是你最不可复制的资产。技术可以被抄,但关系不能。
建立社区的层次
1. Discord 服务器
├── #announcements(版本更新、新功能)
├── #support(用户问题解答)
├── #feedback(收集意见)
└── #governance(重要决策投票)
2. 定期沟通
├── 每月 AMA(Ask Me Anything)
├── 收支透明报告(展示 Treasury 余额和分红计划)
└── Roadmap 公开更新
3. 社区激励
├── 早期用户 NFT 徽章(见 Example 8)
├── 反馈奖励(提 Bug 得 Token)
└── 推荐奖励(带新用户注册联盟)
社区真正的价值不在于人数,而在于关系强度和反馈质量。
一个小但活跃的 Builder 社区,往往比一个大而沉默的频道更有用,因为它能提供:
- 真实需求反馈
- 问题复现样本
- 第一批传播者
- 早期共建者
25.5 透明度:链上可信的运营
链上数据天然透明,把它变成竞争优势:
// 生成每月公开财务报告
async function generateMonthlyReport(treasuryId: string) {
const treasury = await client.getObject({
id: treasuryId,
options: { showContent: true },
});
const fields = (treasury.data?.content as any)?.fields;
const events = await client.queryEvents({
query: { MoveEventType: `${PKG}::treasury::FeeCollected` },
// 筛选本月时间范围...
});
const totalCollected = events.data.reduce(
(sum, e) => sum + Number((e.parsedJson as any).amount), 0
);
return {
date: new Date().toISOString().slice(0, 7), // "2026-03"
totalRevenueSUI: totalCollected / 1e9,
currentBalanceSUI: Number(fields.balance) / 1e9,
totalUserTransactions: events.data.length,
topServices: calculateTopServices(events.data),
};
}
透明度这件事,不只是“把数据公开”这么简单,而是让用户能看懂:
- 钱从哪里来
- 钱到哪里去
- 哪些规则是固定的
- 哪些调整是后来变的
只要这些能被用户理解,你的协议信任成本就会明显下降。
25.6 合规与风险管理
虽然 EVE Frontier 是去中心化的,Builder 仍需注意:
技术风险
| 风险 | 缓解措施 |
|---|---|
| 合约漏洞导致资产损失 | 上线前审计;TimeLock 升级;设置单笔上限 |
| Package 升级破坏用户 | 版本化 API;公告期;迁移补贴 |
| Sui 网络故障 | 做好用户预期管理;设时效保护 |
| 依赖的 World Contracts 升级 | 关注官方 changelog;测试网验证 |
社区风险
| 风险 | 缓解措施 |
|---|---|
| 用户流失 | 持续交付价值;倾听反馈 |
| 竞争者复制 | 加速迭代;建立用户关系护城河 |
| 负面舆论 | 快速公开响应;透明沟通 |
风险管理里最重要的其实是“预案”
不是等出了事再想怎么说,而是提前知道:
- 漏洞时先停哪一层
- 前端要不要先隐藏某个入口
- 是否需要暂停赞助服务
- 哪些状态和资产要先保护
25.7 长期可持续性:渐进式去中心化
最健康的 Builder 项目应走向渐进去中心化:
阶段 1(启动期):Builder 中心化控制
• 快速迭代,灵活调整
• 建立初始用户群和现金流
阶段 2(成长期):引入社区治理
• 重要参数(费率、新功能)DAO 投票
• 代币持有者获得提案权
阶段 3(成熟期):完全社区自治
• 所有关键决策链上治理
• Builder 退为贡献者角色
• 协议收入完全分配给代币持有者
这里最需要克制的一点是:不是每个项目都必须走到“完全社区自治”。
更现实的问题应该是:
- 这个项目是否真的需要治理代币
- 社区是否已经成熟到能承担治理责任
- 哪些权力适合下放,哪些仍应保留在执行层
25.8 EVE Frontier 生态合作机会
不要单打独斗,寻找协同效应:
横向合作(同类Builder):
├── 共用技术标准(接口协议)
├── 联合市场推广
└── 互相引流(你的用户 → 我的服务)
纵向合作(不同层级Builder):
├── 基础设施 Builder 提供 API
├── 应用 Builder 在其上构建
└── 用户体验 Builder 做门户聚合
与 CCP 合作:
├── 申请官方 Featured Builder 认证
├── 参与官方测试和反馈项目
└── 在官方活动中展示你的工具
合作的真正价值通常有三类:
- 分发 让更多人更快知道你
- 互补 让你不必自己从零做完整栈
- 合法性 让用户更敢用你的服务
25.9 成功 Builder 的核心特质
从技术到产品,你需要的不仅仅是 Move 代码:
技术能力(你已有) 战略能力(同样重要)
───────────────────────── ─────────────────────────────
✅ Move 合约开发 ✅ 用户需求洞察
✅ dApp 全栈开发 ✅ 产品快速迭代
✅ 安全与测试 ✅ 社区建设与沟通
✅ 性能优化 ✅ 商业模型设计
✅ 升级与维护 ✅ 竞争分析与差异化
真正长期能留下来的 Builder,通常不是“最会写代码的人”,而是能把技术、产品、社区和节奏一起拿住的人。
25.10 你的 Builder 旅程路线图
月份 0-1(学习期):
├── 完成本课程所有章节和案例
├── 在 testnet 部署 Example 1-2
└── 加入 Builder Discord,认识社区
月份 1-3(实验期):
├── 发布 testnet 版本的第一个产品
├── 邀请测试用户,收集反馈
└── 迭代 2-3 轮
月份 3-6(验证期):
├── 主网发布(小规模,谨慎测试)
├── 实现第一笔链上收入
└── 建立初始社区(Discord 100+ 成员)
月份 6-12(成长期):
├── 月活用户 1000+
├── 引入代币经济(如适合)
└── 建立第一个跨 Builder 合作
年份 2+(生态期):
├── 成为生态中的"基础设施"
├── 渐进社区治理
└── 可持续自运营
这条路线图最应该被当成“阶段判断框架”,而不是 KPI 清单。
因为不同产品的节奏会差很多,但有一条判断始终成立:
先证明有人真在用,再放大;先证明模式成立,再复杂化。
🔖 本章小结
| 维度 | 核心要点 |
|---|---|
| 商业模式 | 四类模型:基础设施/代币/平台/数据 |
| 定价策略 | 链上自动抽佣,运营成本为零 |
| 用户获取 | 游戏内触达优先,社区口碑次之 |
| 社区建设 | Discord + 透明报告 + 激励机制 |
| 风险管理 | 技术审计 + 升级时间锁 + 快速响应 |
| 长期可持续 | 渐进去中心化,最终社区自治 |
🎓 课程完成!你现在是 EVE Frontier Builder
恭喜完成这套课程的全部 23 章 + 10 个实战案例。
你已经掌握了:
- ✅ Move 智能合约从入门到高级
- ✅ 四类智能组件的完整开发与部署
- ✅ 全栈 dApp 开发与生产级架构
- ✅ 链上经济、NFT、DAO 治理设计
- ✅ 安全审计、性能优化、升级策略
- ✅ 商业化路径与生态运营
在这个宇宙里,代码就是物理定律。去构建你的宇宙吧。 🚀
📚 书签这些资源
| 资源 | 用途 |
|---|---|
| EVE Frontier 官网 | 最新官方公告 |
| builder-documentation | 官方技术文档 |
| world-contracts | World 合约源码 |
| builder-scaffold | 项目脚手架 |
| Sui 文档 | Sui 区块链文档 |
| Move Book | Move 语言参考 |
| EVE Frontier Discord | Builder 社区 |
| Sui GraphQL IDE | 链上数据查询 |
实战案例 5:联盟代币与自动分红系统
目标: 发行联盟专属 Coin(
ALLY Token),构建一套自动分红合约——联盟运营的设施收入自动按持仓比例分配给代币持有者——并附带治理面板 dApp。
状态:教学示例。仓库内已有联盟代币、金库和治理源码,重点在理解资金流与治理流如何并存。
对应代码目录
最小调用链
发行 ALLY Token -> 收入汇入金库 -> 按持仓分红 -> 发起提案 -> 成员投票
需求分析
场景: 你的联盟同时运营多个星门收费站和存储箱市场,收入来自多个渠道。你希望:
- 💎 发行
ALLY Token(总量 1,000,000),按贡献分配给联盟成员 - 🏦 所有设施收入统一汇入联盟金库(Treasury)
- 💸 持有
ALLY Token的成员,按持仓比例定期领取分红 - 🗳 Token 持有者可对联盟重大决策(如费率调整)投票
- 📊 治理面板显示金库余额、分红历史、提案列表
第一部分:联盟代币合约
module ally_dao::ally_token;
use sui::coin::{Self, Coin, TreasuryCap, CoinMetadata};
use sui::transfer;
use sui::tx_context::TxContext;
/// 一次性见证(One-Time Witness)
public struct ALLY_TOKEN has drop {}
fun init(witness: ALLY_TOKEN, ctx: &mut TxContext) {
let (treasury_cap, coin_metadata) = coin::create_currency(
witness,
6, // 精度:6位小数
b"ALLY", // 符号
b"Alliance Token", // 名称
b"Governance and dividend token for Alliance X",
option::none(),
ctx,
);
// TreasuryCap 赋予联盟 DAO 合约(通过地址或多签)
transfer::public_transfer(treasury_cap, ctx.sender());
transfer::public_freeze_object(coin_metadata); // 元数据不可变
}
/// 铸造(由 DAO 合约控制,不直接暴露给外部)
public fun internal_mint(
treasury: &mut TreasuryCap<ALLY_TOKEN>,
amount: u64,
recipient: address,
ctx: &mut TxContext,
) {
let coin = coin::mint(treasury, amount, ctx);
transfer::public_transfer(coin, recipient);
}
第二部分:DAO 金库与分红合约
module ally_dao::treasury;
use ally_dao::ally_token::ALLY_TOKEN;
use sui::coin::{Self, Coin, TreasuryCap};
use sui::balance::{Self, Balance};
use sui::object::{Self, UID, ID};
use sui::table::{Self, Table};
use sui::event;
use sui::transfer;
use sui::tx_context::TxContext;
use sui::sui::SUI;
// ── 数据结构 ──────────────────────────────────────────────
/// 联盟金库
public struct AllianceTreasury has key {
id: UID,
sui_balance: Balance<SUI>, // 等待分配的 SUI
total_distributed: u64, // 历史累计分红总额
distribution_index: u64, // 当前分红轮次
total_ally_supply: u64, // 当前 ALLY Token 流通总量
}
/// 分红领取凭证(记录每个持有者已领到哪一轮)
public struct DividendClaim has key, store {
id: UID,
holder: address,
last_claimed_index: u64,
}
/// 提案(治理)
public struct Proposal has key {
id: UID,
proposer: address,
description: vector<u8>,
vote_yes: u64, // 赞成票(ALLY Token 数量加权)
vote_no: u64, // 反对票
deadline_ms: u64,
executed: bool,
}
/// 分红快照(每次分红创建一个)
public struct DividendSnapshot has store {
amount_per_token: u64, // 每个 ALLY Token 对应的 SUI 数量(以最小精度计)
total_supply_at_snapshot: u64,
}
// ── 事件 ──────────────────────────────────────────────────
public struct DividendDistributed has copy, drop {
treasury_id: ID,
total_amount: u64,
per_token_amount: u64,
distribution_index: u64,
}
public struct DividendClaimed has copy, drop {
holder: address,
amount: u64,
rounds: u64,
}
// ── 初始化 ────────────────────────────────────────────────
public fun create_treasury(
total_ally_supply: u64,
ctx: &mut TxContext,
) {
let treasury = AllianceTreasury {
id: object::new(ctx),
sui_balance: balance::zero(),
total_distributed: 0,
distribution_index: 0,
total_ally_supply,
};
transfer::share_object(treasury);
}
// ── 收入存入 ──────────────────────────────────────────────
/// 任何合约(星门、市场等)都可以向金库存入收入
public fun deposit_revenue(treasury: &mut AllianceTreasury, coin: Coin<SUI>) {
balance::join(&mut treasury.sui_balance, coin::into_balance(coin));
}
// ── 触发分红 ──────────────────────────────────────────────
/// 管理员触发:将当前金库余额按比例准备分红
/// 需要存储每轮的快照
public fun trigger_distribution(
treasury: &mut AllianceTreasury,
ctx: &TxContext,
) {
let total = balance::value(&treasury.sui_balance);
assert!(total > 0, ENoBalance);
assert!(treasury.total_ally_supply > 0, ENoSupply);
// 每个 Token 分到多少(以最小精度,即乘以 1e6 避免精度损失)
let per_token_scaled = total * 1_000_000 / treasury.total_ally_supply;
treasury.distribution_index = treasury.distribution_index + 1;
treasury.total_distributed = treasury.total_distributed + total;
// 存储快照到动态字段
sui::dynamic_field::add(
&mut treasury.id,
treasury.distribution_index,
DividendSnapshot {
amount_per_token: per_token_scaled,
total_supply_at_snapshot: treasury.total_ally_supply,
}
);
event::emit(DividendDistributed {
treasury_id: object::id(treasury),
total_amount: total,
per_token_amount: per_token_scaled,
distribution_index: treasury.distribution_index,
});
}
// ── 持有者领取分红 ────────────────────────────────────────
/// 持有者提供自己的 ALLY Token(不消耗,只读取数量)来领取分红
public fun claim_dividends(
treasury: &mut AllianceTreasury,
ally_coin: &Coin<ALLY_TOKEN>, // 持有者的 ALLY Token(只读)
claim_record: &mut DividendClaim,
ctx: &mut TxContext,
) {
assert!(claim_record.holder == ctx.sender(), ENotHolder);
let holder_balance = coin::value(ally_coin);
assert!(holder_balance > 0, ENoAllyTokens);
let from_index = claim_record.last_claimed_index + 1;
let to_index = treasury.distribution_index;
assert!(from_index <= to_index, ENothingToClaim);
let mut total_claim: u64 = 0;
let mut i = from_index;
while (i <= to_index) {
let snapshot: &DividendSnapshot = sui::dynamic_field::borrow(
&treasury.id, i
);
// 按持仓比例计算(反缩放)
total_claim = total_claim + (holder_balance * snapshot.amount_per_token / 1_000_000);
i = i + 1;
};
assert!(total_claim > 0, ENothingToClaim);
claim_record.last_claimed_index = to_index;
let payout = sui::coin::take(&mut treasury.sui_balance, total_claim, ctx);
transfer::public_transfer(payout, ctx.sender());
event::emit(DividendClaimed {
holder: ctx.sender(),
amount: total_claim,
rounds: to_index - from_index + 1,
});
}
/// 创建领取凭证(每个持有者创建一次)
public fun create_claim_record(ctx: &mut TxContext) {
let record = DividendClaim {
id: object::new(ctx),
holder: ctx.sender(),
last_claimed_index: 0,
};
transfer::transfer(record, ctx.sender());
}
const ENoBalance: u64 = 0;
const ENoSupply: u64 = 1;
const ENotHolder: u64 = 2;
const ENoAllyTokens: u64 = 3;
const ENothingToClaim: u64 = 4;
第三部分:治理投票合约
module ally_dao::governance;
use ally_dao::ally_token::ALLY_TOKEN;
use sui::coin::Coin;
use sui::object::{Self, UID};
use sui::clock::Clock;
use sui::transfer;
use sui::event;
public struct Proposal has key {
id: UID,
proposer: address,
description: vector<u8>,
vote_yes: u64,
vote_no: u64,
deadline_ms: u64,
executed: bool,
}
/// 创建提案(需要持有最少 1000 ALLY Token)
public fun create_proposal(
ally_coin: &Coin<ALLY_TOKEN>,
description: vector<u8>,
voting_duration_ms: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
// 需要持有足够代币才能发起提案
assert!(sui::coin::value(ally_coin) >= 1_000_000_000, EInsufficientToken); // 1000 ALLY
let proposal = Proposal {
id: object::new(ctx),
proposer: ctx.sender(),
description,
vote_yes: 0,
vote_no: 0,
deadline_ms: clock.timestamp_ms() + voting_duration_ms,
executed: false,
};
transfer::share_object(proposal);
}
/// 投票(用 ALLY Token 数量加权)
public fun vote(
proposal: &mut Proposal,
ally_coin: &Coin<ALLY_TOKEN>,
support: bool,
clock: &Clock,
_ctx: &TxContext,
) {
assert!(clock.timestamp_ms() < proposal.deadline_ms, EVotingEnded);
let weight = sui::coin::value(ally_coin);
if support {
proposal.vote_yes = proposal.vote_yes + weight;
} else {
proposal.vote_no = proposal.vote_no + weight;
};
}
const EInsufficientToken: u64 = 0;
const EVotingEnded: u64 = 1;
第四部分:治理面板 dApp
// src/GovernanceDashboard.tsx
import { useState, useEffect } from 'react'
import { useConnection, getObjectWithJson, executeGraphQLQuery } from '@evefrontier/dapp-kit'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
const DAO_PACKAGE = "0x_DAO_PACKAGE_"
const TREASURY_ID = "0x_TREASURY_ID_"
interface TreasuryInfo {
sui_balance: string
total_distributed: string
distribution_index: string
total_ally_supply: string
}
interface Proposal {
id: string
description: string
vote_yes: string
vote_no: string
deadline_ms: string
executed: boolean
}
export function GovernanceDashboard() {
const { isConnected, handleConnect, currentAddress } = useConnection()
const dAppKit = useDAppKit()
const [treasury, setTreasury] = useState<TreasuryInfo | null>(null)
const [proposals, setProposals] = useState<Proposal[]>([])
const [allyBalance, setAllyBalance] = useState<number>(0)
const [claimRecordId, setClaimRecordId] = useState<string | null>(null)
const [status, setStatus] = useState('')
// 加载金库数据
useEffect(() => {
getObjectWithJson(TREASURY_ID).then(obj => {
if (obj?.content?.dataType === 'moveObject') {
setTreasury(obj.content.fields as TreasuryInfo)
}
})
}, [])
// 领取分红
const claimDividends = async () => {
if (!claimRecordId) {
setStatus('⚠️ 请先创建领取凭证')
return
}
const tx = new Transaction()
tx.moveCall({
target: `${DAO_PACKAGE}::treasury::claim_dividends`,
arguments: [
tx.object(TREASURY_ID),
tx.object('ALLY_COIN_ID'), // 用户的 ALLY Coin 对象 ID
tx.object(claimRecordId),
],
})
try {
const r = await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 分红已领取! ${r.digest.slice(0, 12)}...`)
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
// 投票
const vote = async (proposalId: string, support: boolean) => {
const tx = new Transaction()
tx.moveCall({
target: `${DAO_PACKAGE}::governance::vote`,
arguments: [
tx.object(proposalId),
tx.object('ALLY_COIN_ID'), // 用户的 ALLY Coin 对象 ID
tx.pure.bool(support),
tx.object('0x6'), // Clock
],
})
try {
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 投票成功`)
} catch (e: any) {
setStatus(`❌ ${e.message}`)
}
}
return (
<div className="governance-dashboard">
<header>
<h1>🏛 联盟 DAO 治理中心</h1>
{!isConnected
? <button onClick={handleConnect}>连接钱包</button>
: <span>✅ {currentAddress?.slice(0, 8)}...</span>
}
</header>
{/* 金库状态 */}
<section className="treasury-panel">
<h2>💰 联盟金库</h2>
<div className="stats-grid">
<div className="stat">
<span className="label">当前余额</span>
<span className="value">
{((Number(treasury?.sui_balance ?? 0)) / 1e9).toFixed(2)} SUI
</span>
</div>
<div className="stat">
<span className="label">历史分红总额</span>
<span className="value">
{((Number(treasury?.total_distributed ?? 0)) / 1e9).toFixed(2)} SUI
</span>
</div>
<div className="stat">
<span className="label">分红轮次</span>
<span className="value">{treasury?.distribution_index ?? '-'}</span>
</div>
<div className="stat">
<span className="label">你的 ALLY 持仓</span>
<span className="value">{(allyBalance / 1e6).toFixed(2)} ALLY</span>
</div>
</div>
<button className="claim-btn" onClick={claimDividends} disabled={!isConnected}>
💸 领取待领分红
</button>
</section>
{/* 治理提案 */}
<section className="proposals-panel">
<h2>🗳 当前提案</h2>
{proposals.length === 0
? <p>暂无进行中的提案</p>
: proposals.map(p => {
const total = Number(p.vote_yes) + Number(p.vote_no)
const yesPct = total > 0 ? Math.round(Number(p.vote_yes) * 100 / total) : 0
const expired = Date.now() > Number(p.deadline_ms)
return (
<div key={p.id} className="proposal-card">
<p className="proposal-desc">{p.description}</p>
<div className="vote-bar">
<div className="yes-bar" style={{ width: `${yesPct}%` }} />
</div>
<div className="vote-stats">
<span>✅ {(Number(p.vote_yes) / 1e6).toFixed(0)} ALLY</span>
<span>❌ {(Number(p.vote_no) / 1e6).toFixed(0)} ALLY</span>
</div>
{!expired && !p.executed && (
<div className="vote-actions">
<button onClick={() => vote(p.id, true)}>👍 支持</button>
<button onClick={() => vote(p.id, false)}>👎 反对</button>
</div>
)}
{expired && <span className="badge">投票结束</span>}
</div>
)
})
}
</section>
{status && <div className="status-bar">{status}</div>}
</div>
)
}
🎯 完整回顾
Move 合约层
├── ally_token.move → 发行 ALLY_TOKEN(总量受 TreasuryCap 控制)
├── treasury.move
│ ├── AllianceTreasury → 共享金库对象,接收多渠道收入
│ ├── DividendClaim → 持有者的领取凭证(记录已领轮次)
│ ├── deposit_revenue() ← 星门/市场合约调用
│ ├── trigger_distribution() ← 管理员触发,按快照准备分红
│ └── claim_dividends() ← 持有者自助领取
└── governance.move
├── Proposal → 治理提案共享对象
├── create_proposal() ← 持有 1000+ ALLY 才能发起
└── vote() ← ALLY 持量加权投票
与其他设施集成
└── 在 example-02 的 toll_gate.move 中调用
treasury::deposit_revenue(alliance_treasury, fee_coin)
→ 星门收费直接进入联盟金库
dApp 层
└── GovernanceDashboard.tsx
├── 金库余额与分红历史统计
├── 一键领取分红
└── 提案列表 + 投票
🔧 扩展练习
- 防双投:一次分红周期内每个地址只能投一票(在 proposal 上维护
voted_addresses: Table<address, bool>) - 锁仓增益:持仓超过 30 天的地址,分红加权 1.2x(需要存储持仓时间戳)
- 多资产支持:金库同时接受 SUI 和 LUX,分红也按比例两种代币发放
- 自动执行提案:提案通过后,合约自动执行修改费率等操作(需 Governor 多签)
📚 关联文档
实战案例 12:联盟招募系统(申请→投票→批准)
目标: 构建完整的联盟加入流程:候选人提交申请 → 现有成员投票 → 达到阈值自动批准并发放成员 NFT;也可设置创始人一票否决权。
状态:教学示例。正文展示联盟招募的最小业务闭环,完整代码以
book/src/code/example-12/为准。
对应代码目录
最小调用链
用户申请 -> 成员投票 -> 票数达到阈值 -> 发 MemberNFT 或没收押金
需求分析
场景: 联盟“死亡先锋“有 20 名成员,每次接纳新人需要:
- 申请人押金 10 SUI(防止刷申请,批准后退还)
- 现有成员 72 小时内投票(匿名,链上记录)
- 支持票 ≥ 60% 则自动批准,发放 MemberNFT
- 创始人有一票否决权(
veto) - 拒绝时押金被没收,进联盟金库
第一部分:联盟招募合约
module alliance::recruitment;
use sui::table::{Self, Table};
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::transfer;
use sui::event;
use std::string::String;
// ── 常量 ──────────────────────────────────────────────────
const VOTE_WINDOW_MS: u64 = 72 * 60 * 60 * 1000; // 72 小时
const APPROVAL_THRESHOLD_BPS: u64 = 6_000; // 60%
const APPLICATION_DEPOSIT: u64 = 10_000_000_000; // 10 SUI
// ── 数据结构 ───────────────────────────────────────────────
public struct AllianceDAO has key {
id: UID,
name: String,
founder: address,
members: vector<address>,
treasury: Balance<SUI>,
pending_applications: Table<address, Application>,
total_accepted: u64,
}
public struct Application has store {
applicant: address,
applied_at_ms: u64,
votes_for: u64,
votes_against: u64,
voters: vector<address>, // 防止重复投票
deposit: Balance<SUI>,
status: u8, // 0=pending, 1=approved, 2=rejected, 3=vetoed
}
/// 成员 NFT
public struct MemberNFT has key, store {
id: UID,
alliance_name: String,
member: address,
joined_at_ms: u64,
serial_number: u64,
}
public struct FounderCap has key, store { id: UID }
// ── 事件 ──────────────────────────────────────────────────
public struct ApplicationSubmitted has copy, drop { applicant: address, alliance_id: ID }
public struct VoteCast has copy, drop { applicant: address, voter: address, approve: bool }
public struct ApplicationResolved has copy, drop {
applicant: address,
approved: bool,
votes_for: u64,
votes_total: u64,
}
// ── 初始化 ────────────────────────────────────────────────
public fun create_alliance(
name: vector<u8>,
ctx: &mut TxContext,
) {
let mut dao = AllianceDAO {
id: object::new(ctx),
name: std::string::utf8(name),
founder: ctx.sender(),
members: vector[ctx.sender()],
treasury: balance::zero(),
pending_applications: table::new(ctx),
total_accepted: 0,
};
// 创始人获得 MemberNFT(编号 #1)
let founder_nft = MemberNFT {
id: object::new(ctx),
alliance_name: dao.name,
member: ctx.sender(),
joined_at_ms: 0,
serial_number: 1,
};
dao.total_accepted = 1;
let founder_cap = FounderCap { id: object::new(ctx) };
transfer::share_object(dao);
transfer::public_transfer(founder_nft, ctx.sender());
transfer::public_transfer(founder_cap, ctx.sender());
}
// ── 申请加入 ──────────────────────────────────────────────
public fun apply(
dao: &mut AllianceDAO,
mut deposit: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let applicant = ctx.sender();
assert!(!vector::contains(&dao.members, &applicant), EAlreadyMember);
assert!(!table::contains(&dao.pending_applications, applicant), EAlreadyApplied);
assert!(coin::value(&deposit) >= APPLICATION_DEPOSIT, EInsufficientDeposit);
let deposit_balance = deposit.split(APPLICATION_DEPOSIT, ctx);
if coin::value(&deposit) > 0 {
transfer::public_transfer(deposit, applicant);
} else { coin::destroy_zero(deposit); }
table::add(&mut dao.pending_applications, applicant, Application {
applicant,
applied_at_ms: clock.timestamp_ms(),
votes_for: 0,
votes_against: 0,
voters: vector::empty(),
deposit: coin::into_balance(deposit_balance),
status: 0,
});
event::emit(ApplicationSubmitted { applicant, alliance_id: object::id(dao) });
}
// ── 成员投票 ──────────────────────────────────────────────
public fun vote(
dao: &mut AllianceDAO,
applicant: address,
approve: bool,
_member_nft: &MemberNFT, // 持有 NFT 才能投票
clock: &Clock,
ctx: &TxContext,
) {
assert!(vector::contains(&dao.members, &ctx.sender()), ENotMember);
assert!(table::contains(&dao.pending_applications, applicant), ENoApplication);
let app = table::borrow_mut(&mut dao.pending_applications, applicant);
assert!(app.status == 0, EApplicationClosed);
assert!(clock.timestamp_ms() <= app.applied_at_ms + VOTE_WINDOW_MS, EVoteWindowClosed);
assert!(!vector::contains(&app.voters, &ctx.sender()), EAlreadyVoted);
vector::push_back(&mut app.voters, ctx.sender());
if approve {
app.votes_for = app.votes_for + 1;
} else {
app.votes_against = app.votes_against + 1;
};
event::emit(VoteCast { applicant, voter: ctx.sender(), approve });
// 若票数已足够,尝试自动结算
try_resolve(dao, applicant, clock, ctx);
}
fun try_resolve(
dao: &mut AllianceDAO,
applicant: address,
clock: &Clock,
ctx: &mut TxContext,
) {
let app = table::borrow(&dao.pending_applications, applicant);
let total_votes = app.votes_for + app.votes_against;
let member_count = vector::length(&dao.members);
// 提前结算条件:赞成 >= 60% 且至少 3 票,或反对 > 40% 且覆盖全员
let approve_pct = total_votes * 10_000 / member_count;
let enough_approval = app.votes_for * 10_000 / member_count >= APPROVAL_THRESHOLD_BPS
&& total_votes >= 3;
let definite_rejection = app.votes_against * 10_000 / member_count > 4_000
&& total_votes == member_count;
let time_expired = clock.timestamp_ms() > app.applied_at_ms + VOTE_WINDOW_MS;
if enough_approval || time_expired || definite_rejection {
resolve_application(dao, applicant, ctx);
}
}
fun resolve_application(
dao: &mut AllianceDAO,
applicant: address,
ctx: &mut TxContext,
) {
let app = table::borrow_mut(&mut dao.pending_applications, applicant);
let total_votes = app.votes_for + app.votes_against;
let approved = total_votes > 0
&& app.votes_for * 10_000 / (total_votes) >= APPROVAL_THRESHOLD_BPS;
if approved {
app.status = 1;
// 退还押金
let deposit = balance::withdraw_all(&mut app.deposit);
transfer::public_transfer(coin::from_balance(deposit, ctx), applicant);
// 加入成员列表并发放 NFT
vector::push_back(&mut dao.members, applicant);
dao.total_accepted = dao.total_accepted + 1;
let nft = MemberNFT {
id: object::new(ctx),
alliance_name: dao.name,
member: applicant,
joined_at_ms: 0, // clock 无法传进内部函数,简化处理
serial_number: dao.total_accepted,
};
transfer::public_transfer(nft, applicant);
} else {
app.status = 2;
// 没收押金入金库
let deposit = balance::withdraw_all(&mut app.deposit);
balance::join(&mut dao.treasury, deposit);
};
event::emit(ApplicationResolved {
applicant,
approved,
votes_for: app.votes_for,
votes_total: total_votes,
});
}
/// 创始人一票否决
public fun veto(
dao: &mut AllianceDAO,
applicant: address,
_cap: &FounderCap,
ctx: &mut TxContext,
) {
assert!(table::contains(&dao.pending_applications, applicant), ENoApplication);
let app = table::borrow_mut(&mut dao.pending_applications, applicant);
assert!(app.status == 0, EApplicationClosed);
app.status = 3;
// 没收押金
let deposit = balance::withdraw_all(&mut app.deposit);
balance::join(&mut dao.treasury, deposit);
}
// ── 错误码 ────────────────────────────────────────────────
const EAlreadyMember: u64 = 0;
const EAlreadyApplied: u64 = 1;
const EInsufficientDeposit: u64 = 2;
const ENotMember: u64 = 3;
const ENoApplication: u64 = 4;
const EApplicationClosed: u64 = 5;
const EVoteWindowClosed: u64 = 6;
const EAlreadyVoted: u64 = 7;
第二部分:招募管理 dApp
// src/RecruitmentPanel.tsx
import { useState } from 'react'
import { useCurrentClient, useCurrentAccount } from '@mysten/dapp-kit-react'
import { useQuery } from '@tanstack/react-query'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'
const RECRUIT_PKG = "0x_RECRUIT_PACKAGE_"
const DAO_ID = "0x_DAO_ID_"
interface PendingApp {
applicant: string
applied_at_ms: string
votes_for: string
votes_against: string
status: string
}
export function RecruitmentPanel({ isMember, isFounder }: {
isMember: boolean, isFounder: boolean
}) {
const client = useCurrentClient()
const dAppKit = useDAppKit()
const account = useCurrentAccount()
const [status, setStatus] = useState('')
const { data: dao, refetch } = useQuery({
queryKey: ['dao', DAO_ID],
queryFn: async () => {
const obj = await client.getObject({ id: DAO_ID, options: { showContent: true } })
return (obj.data?.content as any)?.fields
},
refetchInterval: 15_000,
})
const handleApply = async () => {
const tx = new Transaction()
const [deposit] = tx.splitCoins(tx.gas, [tx.pure.u64(10_000_000_000)])
tx.moveCall({
target: `${RECRUIT_PKG}::recruitment::apply`,
arguments: [tx.object(DAO_ID), deposit, tx.object('0x6')],
})
try {
setStatus('⏳ 提交申请...')
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('✅ 申请已提交!等待成员投票(72小时内)')
refetch()
} catch (e: any) { setStatus(`❌ ${e.message}`) }
}
const handleVote = async (applicant: string, approve: boolean) => {
const tx = new Transaction()
tx.moveCall({
target: `${RECRUIT_PKG}::recruitment::vote`,
arguments: [
tx.object(DAO_ID),
tx.pure.address(applicant),
tx.pure.bool(approve),
tx.object('MEMBER_NFT_ID'),
tx.object('0x6'),
],
})
try {
setStatus('⏳ 提交投票...')
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus(`✅ 已投票:${approve ? '赞成' : '反对'}`)
refetch()
} catch (e: any) { setStatus(`❌ ${e.message}`) }
}
const pendingApps = dao?.pending_applications?.fields?.contents ?? []
const memberCount = dao?.members?.length ?? 0
return (
<div className="recruitment-panel">
<header>
<h1>⚔️ {dao?.name ?? '...'} — 招募中心</h1>
<div className="stats">
<span>👥 成员数:{memberCount}</span>
<span>📋 待审申请:{pendingApps.filter((a: any) => a.fields?.value?.fields?.status === '0').length}</span>
</div>
</header>
{/* 申请入盟 */}
{!isMember && (
<section className="apply-section">
<h3>申请加入联盟</h3>
<p>需要押金 10 SUI(批准后退还)。现有成员将在 72 小时内投票。</p>
<button className="apply-btn" onClick={handleApply}>
📝 提交申请(押金 10 SUI)
</button>
</section>
)}
{/* 待审列表(仅成员可见) */}
{isMember && (
<section className="pending-section">
<h3>待审申请</h3>
{pendingApps.map((entry: any) => {
const app = entry.fields?.value?.fields
if (!app || app.status !== '0') return null
const hoursLeft = Math.max(0,
Math.ceil((Number(app.applied_at_ms) + 72*3600_000 - Date.now()) / 3_600_000)
)
const totalVotes = Number(app.votes_for) + Number(app.votes_against)
const pct = memberCount > 0 ? Math.round(Number(app.votes_for) * 100 / memberCount) : 0
return (
<div key={entry.fields?.key} className="application-card">
<div className="applicant-info">
<strong>{entry.fields?.key?.slice(0, 8)}...</strong>
<span className="time-left">⏳ 剩余 {hoursLeft}h</span>
</div>
<div className="vote-bar">
<div className="vote-fill" style={{ width: `${pct}%` }} />
<span>{app.votes_for} 赞成 / {app.votes_against} 反对({totalVotes}/{memberCount} 人投票)</span>
</div>
<div className="vote-buttons">
<button className="btn-approve" onClick={() => handleVote(entry.fields?.key, true)}>
👍 赞成
</button>
<button className="btn-reject" onClick={() => handleVote(entry.fields?.key, false)}>
👎 反对
</button>
{isFounder && (
<button className="btn-veto" onClick={() => {}}>
🚫 否决
</button>
)}
</div>
</div>
)
})}
</section>
)}
{status && <p className="status">{status}</p>}
</div>
)
}
🎯 关键设计亮点
| 机制 | 实现方式 |
|---|---|
| 防刷申请 | 押金 10 SUI,被拒没收 |
| 防重复投票 | voters vector 追踪已投成员 |
| 自动结算 | 每次投票后检查是否达到阈值 |
| 一票否决 | FounderCap 授权的 veto() |
| 成员凭证 | MemberNFT 作为投票和权限载体 |
📚 关联文档
实战案例 15:去中心化物品保险
目标: 构建链上物品保险协议——玩家购买 PvP 战损险,若物品在游戏中被摧毁则通过服务器证明(AdminACL)自动赔付,理赔资金来自保险池。
状态:教学示例。正文强调理赔流程与资金池设计,完整目录以
book/src/code/example-15/为准。
对应代码目录
最小调用链
用户购买保单 -> 服务器出具战损证明 -> 合约验证保单与签名 -> 保险池赔付
测试闭环
- 投保成功:确认
claims_pool/reserve的 70/30 分账正确 - 有效期内理赔:确认赔付金额等于
coverage_amount - 过期拒赔:确认过期保单无法再次发起理赔
- 理赔池不足:确认不会发生负余额或重复扣款
需求分析
场景: 玩家带着价值 500 SUI 的稀有护盾出征 PvP。他花 15 SUI 购买 30 天物品险,若战斗中护盾被摧毁:
- 游戏服务器记录死亡事件
- 玩家提交理赔申请 + 服务器签名(AdminACL 验证)
- 合约验证保单有效期内,自动赔付(赔付率 80%)
合约
module insurance::pvp_shield;
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::table::{Self, Table};
use sui::transfer;
use sui::event;
// ── 常量 ──────────────────────────────────────────────────
const COVERAGE_BPS: u64 = 8_000; // 赔付率 80%
const DAY_MS: u64 = 86_400_000;
const MIN_PREMIUM_BPS: u64 = 300; // 最低保费:保额的 3%/月
// ── 数据结构 ───────────────────────────────────────────────
/// 保险池(共享)
public struct InsurancePool has key {
id: UID,
reserve: Balance<SUI>, // 准备金
total_collected: u64, // 累计保费
total_paid_out: u64, // 累计赔付
claims_pool: Balance<SUI>, // 专用理赔池(保费的 70%)
admin: address,
}
/// 保单 NFT
public struct PolicyNFT has key, store {
id: UID,
insured_item_id: ID, // 被保物品 ObjectID
insured_value: u64, // 保额(SUI)
coverage_amount: u64, // 最高赔付(= 保额 × 80%)
valid_until_ms: u64, // 有效期
is_claimed: bool,
policy_holder: address,
}
// ── 事件 ──────────────────────────────────────────────────
public struct PolicyIssued has copy, drop {
policy_id: ID,
holder: address,
insured_item_id: ID,
coverage: u64,
expires_ms: u64,
}
public struct ClaimPaid has copy, drop {
policy_id: ID,
holder: address,
amount_paid: u64,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
transfer::share_object(InsurancePool {
id: object::new(ctx),
reserve: balance::zero(),
total_collected: 0,
total_paid_out: 0,
claims_pool: balance::zero(),
admin: ctx.sender(),
});
}
// ── 购买保险 ──────────────────────────────────────────────
public fun purchase_policy(
pool: &mut InsurancePool,
insured_item_id: ID, // 被保物品的 ObjectID
insured_value: u64, // 声明保额
days: u64, // 保险天数(1-90)
mut premium: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(days >= 1 && days <= 90, EInvalidDuration);
// 计算保费:保额 × 月费率 × 天数
let monthly_premium = insured_value * MIN_PREMIUM_BPS / 10_000;
let required_premium = monthly_premium * days / 30;
assert!(coin::value(&premium) >= required_premium, EInsufficientPremium);
let pay = premium.split(required_premium, ctx);
let premium_amount = coin::value(&pay);
// 70% 进理赔池,30% 进准备金
let claims_share = premium_amount * 70 / 100;
let reserve_share = premium_amount - claims_share;
let mut pay_balance = coin::into_balance(pay);
let claims_portion = balance::split(&mut pay_balance, claims_share);
balance::join(&mut pool.claims_pool, claims_portion);
balance::join(&mut pool.reserve, pay_balance);
pool.total_collected = pool.total_collected + premium_amount;
if coin::value(&premium) > 0 {
transfer::public_transfer(premium, ctx.sender());
} else { coin::destroy_zero(premium); }
let coverage = insured_value * COVERAGE_BPS / 10_000;
let valid_until_ms = clock.timestamp_ms() + days * DAY_MS;
let policy = PolicyNFT {
id: object::new(ctx),
insured_item_id,
insured_value,
coverage_amount: coverage,
valid_until_ms,
is_claimed: false,
policy_holder: ctx.sender(),
};
let policy_id = object::id(&policy);
transfer::public_transfer(policy, ctx.sender());
event::emit(PolicyIssued {
policy_id,
holder: ctx.sender(),
insured_item_id,
coverage,
expires_ms: valid_until_ms,
});
}
// ── 理赔(需要游戏服务器签名证明物品已损毁)────────────
public fun file_claim(
pool: &mut InsurancePool,
policy: &mut PolicyNFT,
admin_acl: &AdminACL, // 游戏服务器验证物品确实损毁
clock: &Clock,
ctx: &mut TxContext,
) {
// 验证服务器签名(即服务器确认物品已经损毁)
verify_sponsor(admin_acl, ctx);
assert!(!policy.is_claimed, EAlreadyClaimed);
assert!(clock.timestamp_ms() <= policy.valid_until_ms, EPolicyExpired);
assert!(policy.policy_holder == ctx.sender(), ENotPolicyHolder);
// 检查赔付池余额是否足够
let payout = policy.coverage_amount;
assert!(balance::value(&pool.claims_pool) >= payout, EInsufficientClaimsPool);
// 标记已理赔(防止重复理赔)
policy.is_claimed = true;
// 赔付
let payout_coin = coin::take(&mut pool.claims_pool, payout, ctx);
pool.total_paid_out = pool.total_paid_out + payout;
transfer::public_transfer(payout_coin, ctx.sender());
event::emit(ClaimPaid {
policy_id: object::id(policy),
holder: ctx.sender(),
amount_paid: payout,
});
}
/// 管理员从准备金补充理赔池(当理赔池不足时)
public fun replenish_claims_pool(
pool: &mut InsurancePool,
amount: u64,
ctx: &TxContext,
) {
assert!(ctx.sender() == pool.admin, ENotAdmin);
assert!(balance::value(&pool.reserve) >= amount, EInsufficientReserve);
let replenish = balance::split(&mut pool.reserve, amount);
balance::join(&mut pool.claims_pool, replenish);
}
const EInvalidDuration: u64 = 0;
const EInsufficientPremium: u64 = 1;
const EAlreadyClaimed: u64 = 2;
const EPolicyExpired: u64 = 3;
const ENotPolicyHolder: u64 = 4;
const EInsufficientClaimsPool: u64 = 5;
const ENotAdmin: u64 = 6;
const EInsufficientReserve: u64 = 7;
dApp(购买与理赔)
// InsuranceApp.tsx
import { useState } from 'react'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'
const INS_PKG = "0x_INSURANCE_PACKAGE_"
const POOL_ID = "0x_POOL_ID_"
export function InsuranceApp() {
const dAppKit = useDAppKit()
const [value, setValue] = useState(500) // 保额(SUI)
const [days, setDays] = useState(30)
const [status, setStatus] = useState('')
// 保费计算
const premium = (value * 0.03 * days / 30).toFixed(2)
const coverage = (value * 0.8).toFixed(2)
const purchase = async () => {
const tx = new Transaction()
const premiumMist = BigInt(Math.ceil(Number(premium) * 1e9))
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(premiumMist)])
tx.moveCall({
target: `${INS_PKG}::pvp_shield::purchase_policy`,
arguments: [
tx.object(POOL_ID),
tx.pure.id('0x_ITEM_OBJECT_ID_'),
tx.pure.u64(value * 1e9),
tx.pure.u64(days),
payment,
tx.object('0x6'),
],
})
try {
setStatus('⏳ 购买保险...')
await dAppKit.signAndExecuteTransaction({ transaction: tx })
setStatus('✅ 保单已生效!PolicyNFT 已发送到钱包')
} catch (e: any) { setStatus(`❌ ${e.message}`) }
}
return (
<div className="insurance-app">
<h1>🛡 PvP 物品战损险</h1>
<div className="config-section">
<label>保额(SUI)</label>
<input type="range" min={100} max={5000} step={50}
value={value} onChange={e => setValue(Number(e.target.value))} />
<span>{value} SUI</span>
<label>保险天数</label>
{[7, 14, 30, 60, 90].map(d => (
<button key={d} className={days === d ? 'selected' : ''} onClick={() => setDays(d)}>
{d} 天
</button>
))}
</div>
<div className="summary-card">
<div className="summary-row">
<span>📋 保额</span><strong>{value} SUI</strong>
</div>
<div className="summary-row">
<span>💰 最高赔付</span><strong>{coverage} SUI</strong>
</div>
<div className="summary-row">
<span>🏷 保费</span><strong>{premium} SUI</strong>
</div>
<div className="summary-row">
<span>📅 有效期</span><strong>{days} 天</strong>
</div>
</div>
<button className="purchase-btn" onClick={purchase}>
购买保险({premium} SUI)
</button>
{status && <p className="status">{status}</p>}
</div>
)
}
📚 关联文档
实战案例 17:游戏内浮层 dApp 实战(收费站游戏内版)
目标: 将 Example 2 的星门收费站 dApp 改造为游戏内浮层版本——玩家靠近星门时自动弹出购票面板,可在不离开游戏的情况下完成签名和跳跃。
状态:教学示例。当前案例以 dApp 浮层改造为主,合约部分沿用 Example 2。
对应代码目录
最小调用链
游戏内事件 -> postMessage -> 浮层 dApp 更新状态 -> 用户签名 -> 购票/跳跃成功 -> 浮层关闭
需求分析
场景: 收费站逻辑已经存在(重用 Example 2 的合约),现在要:
- 游戏客户端检测到玩家进入星门 100km 范围
- 通过
postMessage发送事件至 WebView 浮层 - 浮层弹出购票面板,显示费用和目的地
- 玩家一键点击,EVE Vault 弹出签名确认
- 签名完成后,显示成功动画并自动关闭
这个案例聚焦于 Chapter 20 的工程实践,代码细节更完整。
项目结构
ingame-toll-overlay/
├── index.html
├── src/
│ ├── main.tsx # 入口,Provider 设置
│ ├── App.tsx # 环境检测和路由
│ ├── overlay/
│ │ ├── TollOverlay.tsx # 游戏内浮层主组件
│ │ ├── JumpPanel.tsx # 购票面板
│ │ └── SuccessAnimation.tsx # 成功动画
│ └── lib/
│ ├── gameEvents.ts # postMessage 监听
│ ├── environment.ts # 环境检测
│ └── contracts.ts # 合约常量
├── ingame.css # 浮层样式
└── vite.config.ts
第一部分:游戏事件监听
// src/lib/gameEvents.ts
export interface GateAproachEvent {
type: "GATE_IN_RANGE"
gateId: string
gateName: string
destinationSystemName: string
distanceKm: number
}
export interface PlayerLeftEvent {
type: "GATE_OUT_OF_RANGE"
gateId: string
}
export type OverlayEvent = GateAproachEvent | PlayerLeftEvent
type Listener = (event: OverlayEvent) => void
const listeners = new Set<Listener>()
let initialized = false
export function initGameEventListener() {
if (initialized) return
initialized = true
window.addEventListener("message", (e: MessageEvent) => {
if (e.data?.source !== "EVEFrontierClient") return
const event = e.data as { source: string } & OverlayEvent
if (!event.type) return
listeners.forEach(fn => fn(event))
})
}
export function addGameEventListener(fn: Listener): () => void {
listeners.add(fn)
return () => listeners.delete(fn)
}
// ── 开发/测试用:模拟游戏事件 ─────────────────────────────
export function simulateGateApproach(gateId: string) {
const mockEvent: GateAproachEvent = {
type: "GATE_IN_RANGE",
gateId,
gateName: "Alpha Gate Alpha-7",
destinationSystemName: "贸易枢纽 IV",
distanceKm: 78,
}
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "EVEFrontierClient", ...mockEvent },
})
)
}
第二部分:主浮层组件
// src/overlay/TollOverlay.tsx
import { useEffect, useState, useCallback } from 'react'
import {
initGameEventListener,
addGameEventListener,
GateAproachEvent,
} from '../lib/gameEvents'
import { JumpPanel } from './JumpPanel'
import { SuccessAnimation } from './SuccessAnimation'
type OverlayState = 'hidden' | 'visible' | 'success'
export function TollOverlay() {
const [state, setState] = useState<OverlayState>('hidden')
const [activeGate, setActiveGate] = useState<GateAproachEvent | null>(null)
useEffect(() => {
initGameEventListener()
return addGameEventListener((event) => {
if (event.type === 'GATE_IN_RANGE') {
setActiveGate(event)
setState('visible')
} else if (event.type === 'GATE_OUT_OF_RANGE') {
if (state !== 'success') setState('hidden')
}
})
}, [state])
const handleSuccess = useCallback(() => {
setState('success')
// 3 秒后自动关闭
setTimeout(() => {
setState('hidden')
setActiveGate(null)
}, 3000)
}, [])
const handleDismiss = useCallback(() => {
setState('hidden')
}, [])
if (state === 'hidden') return null
return (
<div className="overlay-container">
<div className={`overlay-panel ${state === 'success' ? 'overlay-panel--success' : ''}`}>
{state === 'success' ? (
<SuccessAnimation />
) : (
activeGate && (
<JumpPanel
gateEvent={activeGate}
onSuccess={handleSuccess}
onDismiss={handleDismiss}
/>
)
)}
</div>
</div>
)
}
第三部分:购票面板
// src/overlay/JumpPanel.tsx
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useDAppKit } from '@mysten/dapp-kit-react'
import { Transaction } from '@mysten/sui/transactions'
import { GateAproachEvent } from '../lib/gameEvents'
import { TOLL_PKG, ADMIN_ACL_ID, CHARACTER_ID } from '../lib/contracts'
interface JumpPanelProps {
gateEvent: GateAproachEvent
onSuccess: () => void
onDismiss: () => void
}
export function JumpPanel({ gateEvent, onSuccess, onDismiss }: JumpPanelProps) {
const client = useCurrentClient()
const dAppKit = useDAppKit()
const [buying, setBuying] = useState(false)
// 读取该星门的通行费
const { data: tollInfo } = useQuery({
queryKey: ['gate-toll', gateEvent.gateId],
queryFn: async () => {
const obj = await client.getObject({
id: gateEvent.gateId,
options: { showContent: true },
})
const fields = (obj.data?.content as any)?.fields
return {
tollAmount: Number(fields?.toll_amount ?? 0),
destinationGateId: fields?.linked_gate_id,
}
},
})
const tollSUI = ((tollInfo?.tollAmount ?? 0) / 1e9).toFixed(2)
const handleBuy = async () => {
if (!tollInfo) return
setBuying(true)
const tx = new Transaction()
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(tollInfo.tollAmount)])
tx.moveCall({
target: `${TOLL_PKG}::toll_gate_ext::pay_toll_and_get_permit`,
arguments: [
tx.object(gateEvent.gateId), // 源星门
tx.object(tollInfo.destinationGateId), // 目的星门
tx.object(CHARACTER_ID), // 角色对象
payment,
tx.object(ADMIN_ACL_ID),
tx.object('0x6'), // Clock
],
})
try {
// 调用赞助交易(服务器验证临近性后代付 Gas)
await dAppKit.signAndExecuteSponsoredTransaction({ transaction: tx })
onSuccess()
} catch (e: any) {
console.error(e)
setBuying(false)
}
}
return (
<div className="jump-panel">
{/* 关闭按钮 */}
<button className="dismiss-btn" onClick={onDismiss} aria-label="关闭">✕</button>
{/* 星门信息 */}
<div className="gate-icon">🌀</div>
<h2 className="gate-name">{gateEvent.gateName}</h2>
<p className="destination">
目的地:<strong>{gateEvent.destinationSystemName}</strong>
</p>
<p className="distance">📡 距离:{gateEvent.distanceKm} km</p>
{/* 费用 */}
<div className="toll-display">
<span className="toll-label">通行费</span>
<span className="toll-amount">{tollSUI} SUI</span>
</div>
{/* 购票按钮 */}
<button
className="jump-btn"
onClick={handleBuy}
disabled={buying || !tollInfo}
>
{buying ? '⏳ 签名中...' : '🚀 购票并跳跃'}
</button>
<p className="jump-hint">通行证有效期 30 分钟</p>
</div>
)
}
第四部分:成功动画
// src/overlay/SuccessAnimation.tsx
import { useEffect, useState } from 'react'
export function SuccessAnimation() {
const [frame, setFrame] = useState(0)
const frames = ['🌌', '⚡', '🌀', '✨', '🚀']
useEffect(() => {
const timer = setInterval(() => {
setFrame(f => (f + 1) % frames.length)
}, 200)
return () => clearInterval(timer)
}, [])
return (
<div className="success-animation">
<div className="animation-icon">{frames[frame]}</div>
<h2>跳跃成功!</h2>
<p>正在传送至目的地...</p>
</div>
)
}
游戏内专用 CSS
/* ingame.css */
.overlay-container {
position: fixed;
right: 16px;
top: 50%;
transform: translateY(-50%);
z-index: 9999;
width: 320px;
}
.overlay-panel {
background: rgba(8, 12, 24, 0.95);
border: 1px solid rgba(96, 180, 255, 0.5);
border-radius: 12px;
padding: 20px;
color: #d0e8ff;
font-family: 'Share Tech Mono', monospace;
backdrop-filter: blur(12px);
animation: slideIn 0.25s ease;
box-shadow: 0 0 30px rgba(96, 180, 255, 0.15);
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
.jump-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #1a5cff, #0a3acc);
border: none;
border-radius: 8px;
color: white;
font-size: 15px;
font-family: inherit;
letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
}
.jump-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #2a6cff, #1a4aee);
box-shadow: 0 0 20px rgba(26, 92, 255, 0.4);
}
.toll-display {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255,255,255,0.05);
border-radius: 8px;
padding: 12px 16px;
margin: 16px 0;
}
.toll-amount {
font-size: 24px;
font-weight: bold;
color: #4fa3ff;
}
.success-animation {
text-align: center;
padding: 24px 0;
animation-icon { font-size: 48px; }
}
📚 关联文档
第26章:访问控制系统完整解析
学习目标:深入理解
world::access模块的完整权限架构——从GovernorCap、AdminACL、OwnerCap到Receiving模式,掌握 EVE Frontier 访问控制系统的精密设计。
状态:教学示例。访问控制细节较多,建议直接对照源码与测试逐段阅读,而不是只看概念图。
最小调用链
调用入口 -> 权限对象/授权列表校验 -> 借出或消费 capability -> 执行业务动作 -> 归还或销毁能力对象
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
AdminACL | 服务器授权白名单 | 看 sponsor 白名单如何维护 |
GovernorCap | 系统级最高权限能力 | 看哪些动作必须走 governor 而不是 owner |
OwnerCap<T> | 泛型所有权凭证 | 看借出、归还、转移三种生命周期 |
Receiving 相关模式 | 安全借用 object-owned 资产 | 看 object-owned 和 address-owned 的差异 |
ServerAddressRegistry | 服务端地址注册表 | 看签名身份和业务权限如何串起来 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
verify_sponsor | 校验提交者是否在服务器白名单 | 它解决的是身份来源,不是全部业务约束 |
borrow_owner_cap / return_owner_cap | 借出与归还所有权凭证 | 是否严格遵守 Borrow-Use-Return |
| governor / registry 管理入口 | 维护系统级权限配置 | 是否把系统管理权限错误下放给普通 owner |
最容易误读的点
ctx.sender()在 EVE Frontier 里通常不够用,很多场景必须看 capability 或 sponsorOwnerCap<T>不是一次性消耗品,很多时候是临时借用后再归还- object-owned 资产不能照搬 address-owned 的权限判断方式
理解这一章最有效的办法,是把权限拆成 3 个来源:地址身份、能力对象、服务器背书。地址身份回答“这笔交易是谁发的”;能力对象回答“他对哪个具体对象拥有什么控制权”;服务器背书回答“这是不是被游戏世界认可的一次系统动作”。EVE Frontier 同时使用这三套来源,是因为单靠 ctx.sender() 无法表达复杂的物品托管、建筑控制和链下状态注入。
1. 为什么访问控制系统复杂?
传统智能合约的权限通常只有两层:owner(所有者)和 public(公开)。EVE Frontier 需要更精密的控制:
游戏公司(CCP Level) → GovernorCap:系统级别配置
├── 游戏服务器 → AdminACL/verify_sponsor:链上操作授权
├── 建筑所有者(Builder) → OwnerCap<T>:建筑控制权
└── 玩家(Character) → 通过 OwnerCap 访问自己的物品
一个角色的物品在另一个玩家的建筑里——谁能操作这个物品?这就是 EVE Frontier 访问控制需要解决的核心问题。
所以你会看到 EVE 的权限不是围绕“某地址是不是 owner”展开,而是围绕“某个对象现在被谁持有、谁能临时借出、谁能代表服务器写入世界状态”展开。对象世界一旦复杂起来,传统合约里常见的单一 owner 字段就不够细了。
2. AdminACL:服务器授权白名单
// world/sources/access/access_control.move
pub struct AdminACL has key {
id: UID,
authorized_sponsors: Table<address, bool>, // 服务器地址白名单
}
/// 仅允许已注册服务器执行特权操作
pub fun verify_sponsor(admin_acl: &AdminACL, ctx: &TxContext) {
assert!(
admin_acl.authorized_sponsors.contains(ctx.sender()),
EUnauthorizedSponsor,
);
}
用法:World 合约中所有需要游戏服务器权限的操作都以 admin_acl.verify_sponsor(ctx) 开头:
// 创建角色(必须由服务器触发)
pub fun create_character(..., admin_acl: &AdminACL, ...) {
admin_acl.verify_sponsor(ctx);
// ...
}
// 创建 KillMail(必须由服务器触发)
pub fun create_killmail(..., admin_acl: &AdminACL, ...) {
admin_acl.verify_sponsor(ctx);
// ...
}
服务器地址注册(只有 GovernorCap 可操作)
pub fun add_sponsor_to_acl(
admin_acl: &mut AdminACL,
_: &GovernorCap, // 需要最高权限
sponsor: address,
) {
admin_acl.authorized_sponsors.add(sponsor, true);
}
3. GovernorCap:系统最高权限
// GovernorCap 是整个系统的"根密钥"
// 它的存在意味着游戏公司保留了系统级别的配置能力
pub struct GovernorCap has key, store { id: UID }
GovernorCap 用于:
- 向
AdminACL添加/删除服务器地址 - 向
ServerAddressRegistry注册服务器(用于签名验证) - 设置全系统级别的配置参数
pub fun register_server_address(
server_address_registry: &mut ServerAddressRegistry,
_: &GovernorCap,
server_address: address,
) {
server_address_registry.authorized_address.add(server_address, true);
}
4. OwnerCap<T>:泛型所有权凭证
这是 EVE Frontier 访问控制最精巧的设计:
/// OwnerCap<T> 证明持有者对某个 T 类型对象的控制权
pub struct OwnerCap<phantom T: key> has key, store {
id: UID,
authorized_object_id: ID, // 绑定了具体对象的 ID
}
为什么用泛型?
OwnerCap<Gate> // 对某个 Gate 的控制权
OwnerCap<Turret> // 对某个 Turret 的控制权
OwnerCap<StorageUnit> // 对某个 StorageUnit 的控制权
OwnerCap<Character> // 对某个 Character 的控制权
类型系统天然保证了权限不会错误地跨类型使用。
OwnerCap 的创建(只有 AdminACL 可创建)
pub fun create_owner_cap<T: key>(
admin_acl: &AdminACL,
obj: &T,
ctx: &mut TxContext,
): OwnerCap<T> {
admin_acl.verify_sponsor(ctx);
let object_id = object::id(obj);
let owner_cap = OwnerCap<T> {
id: object::new(ctx),
authorized_object_id: object_id,
};
event::emit(OwnerCapCreatedEvent { ... });
owner_cap
}
重要约束:玩家无法自己创建 OwnerCap,只能由游戏服务器(verify_sponsor)颁发。
这层约束的意义是把“权限对象的铸造权”牢牢关在系统边界内。否则一旦任何人都能自己 mint OwnerCap<T>,整个能力体系就失去可信度了。能力对象之所以可靠,不只是因为它是个链上对象,而是因为它的来源链条本身也受控。
5. Receiving 模式:OwnerCap 的安全借用
这是 EVE Frontier 最独特的模式之一——OwnerCap 平时存放在角色对象(Character)的控制下,借用时用 Sui 的 Receiving<T> 临时取出:
Character(共享对象)
└── 持有 → OwnerCap<Gate>(通过 Sui transfer::transfer 存放)
玩家操作时:
1. 玩家提交 Receiving<OwnerCap<Gate>> ticket(证明有权取出)
2. character::receive_owner_cap() → 临时取出 OwnerCap<Gate>
3. 执行操作(如修改 Gate 配置)
4. 用 return_owner_cap_to_object() 将 OwnerCap 归还给 Character
源码实现
/// 从 Character 借出 OwnerCap
pub(package) fun receive_owner_cap<T: key>(
receiving_id: &mut UID,
ticket: Receiving<OwnerCap<T>>, // Sui 原生 Receiving ticket
): OwnerCap<T> {
transfer::receive(receiving_id, ticket)
}
/// 归还 OwnerCap 给 Character
pub fun return_owner_cap_to_object<T: key>(
owner_cap: OwnerCap<T>,
character: &mut Character,
receipt: ReturnOwnerCapReceipt, // 操作结束的收据
) {
validate_return_receipt(receipt, object::id(&owner_cap), ...);
transfer::transfer(owner_cap, character.character_address);
}
ReturnOwnerCapReceipt 防止遗失
pub struct ReturnOwnerCapReceipt {
owner_id: address,
owner_cap_id: ID,
}
在借用 OwnerCap 的函数签名中,必须返回 ReturnOwnerCapReceipt,否则编译报错。这样确保了:
- OwnerCap 一定会被归还(不能被遗失)
- 必须配对使用(无法伪造收据)
Receiving 模式表面上看有点繁琐,本质上是在把 object-owned 生命周期显式化。普通地址持有的东西,你拿引用就能用;但 Character、StorageUnit 这类对象持有的能力,如果没有一套“借出-使用-归还”的显式流程,就很容易在复杂调用链里丢失或被截留。EVE 选择把这个过程做得啰嗦一点,换来的是权限流转可审计、可回滚、可强约束。
6. 完整的权限层级图
GovernorCap(根密钥,CCP 持有)
│
▼ 配置
AdminACL(服务器白名单)
│
▼ verify_sponsor
所有特权操作(创建角色、创建建筑、颁发 OwnerCap...)
│
▼ create_owner_cap<T>
OwnerCap<Gate> OwnerCap<Turret> OwnerCap<StorageUnit>...
│ │
▼ 转给 Character ▼ 转给 Builder 玩家
Character 保管(Receiving 模式) 直接持有
│
▼ receive_owner_cap (Receiving<OwnerCap<Gate>>)
临时借出 → 使用 → 归还
7. ServerAddressRegistry:签名验证白名单
与 AdminACL 不同,ServerAddressRegistry 专门用于签名验证(不是函数调用权限):
pub struct ServerAddressRegistry has key {
id: UID,
authorized_address: Table<address, bool>,
}
pub fun is_authorized_server_address(
registry: &ServerAddressRegistry,
server_address: address,
): bool {
registry.authorized_address.contains(server_address)
}
用途:在 location::verify_proximity 中验证签名来源:
assert!(
access::is_authorized_server_address(server_registry, message.server_address),
EUnauthorizedServer,
);
这里也能看出 AdminACL 和 ServerAddressRegistry 的分工:前者偏“谁能直接代表服务器发交易”,后者偏“谁的链下签名可以被链上承认”。两者经常来自同一批后台系统,但语义并不一样。把它们混成一个表,短期省事,长期会让权限面变得很难收缩。
8. Builder 视角:如何正确使用 OwnerCap
创建建筑时
// 游戏服务器为 Builder 创建 Gate 时,自动创建并转移 OwnerCap<Gate>
pub fun create_gate_with_owner(...) {
admin_acl.verify_sponsor(ctx);
let gate = Gate { ... };
let owner_cap = create_owner_cap(&admin_acl, &gate, ctx);
// owner_cap 转移给 builder,builder 掌控这个 Gate
transfer::share_object(gate);
transfer::public_transfer(owner_cap, builder_address);
}
Builder 修改建筑配置时
// Builder 用 OwnerCap 证明自己有权操作该 Gate
pub fun set_gate_config(
gate: &mut Gate,
owner_cap: &OwnerCap<Gate>, // 持有就有权限
new_config: GateConfig,
ctx: &TxContext,
) {
// 验证 OwnerCap 对应的对象 ID 与 gate 一致
assert!(owner_cap.authorized_object_id == object::id(gate), EOwnerCapMismatch);
gate.config = new_config;
}
9. 比较:EVE vs 传统合约权限
| 场景 | 传统合约 | EVE Frontier |
|---|---|---|
| 建筑所有权 | 记录 owner 地址 | OwnerCap<T> 对象 |
| 转移所有权 | 更新地址字段 | 转移 OwnerCap<T> 对象 |
| 借出权限 | 无标准机制 | Receiving 模式 + ReturnReceipt |
| 服务器权限 | 硬编码地址 | AdminACL(可更新白名单) |
| 签名验证 | 无 | ServerAddressRegistry |
10. 安全陷阱:不要持有过多 OwnerCap
OwnerCap 是 has key, store 的,这意味着它可以被存入任何对象或表中。Builder 需要小心:
❌ 不好的设计:将 OwnerCap 存入公共共享对象
→ 任何人都可能借助某个漏洞调用
✅ 正确设计:
- OwnerCap 存在部署者的个人钱包地址
- 或通过 Character 的 Receiving 模式管理
- 重要操作使用多签钱包配合 OwnerCap
更直白地说,OwnerCap<T> 应该被当成控制面密钥,而不是普通业务资产。它不该随便放进公共共享对象里,也不该为了“方便前端调用”而暴露给过多中间合约。你可以把它和运维里的 root key 类比:真正安全的系统不是没有 root key,而是 root key 极少出现、极少流转、出现时总伴随额外流程约束。
11. 实战练习
- 权限分析:列出 World 合约中所有需要
admin_acl.verify_sponsor(ctx)的函数,分析哪些是玩家永远无法直接调用的 - OwnerCap 委托系统:设计一个合约,让 Gate Owner 可以将部分权限(如修改通行费)委托给另一个地址,而不需要转移 OwnerCap 本身
- 多签 OwnerCap 托管:实现一个 2-of-3 多签账户,三个维护者需要其中两个同意才能修改建筑配置
本章小结
| 组件 | 层级 | 作用 |
|---|---|---|
GovernorCap | 最高(CCP) | 系统级配置,注册服务器 |
AdminACL | 服务器层 | 游戏操作的函数调用授权 |
ServerAddressRegistry | 服务器层 | Ed25519 签名来源验证 |
OwnerCap<T> | 建筑层 | 泛型建筑控制权凭证 |
| Receiving 模式 | 玩家层 | OwnerCap 安全借用机制 |
ReturnOwnerCapReceipt | 安全机制 | 强制 OwnerCap 归还,防丢失 |
课程完结
恭喜你完成了 EVE Frontier Builder 完整课程!
从基础的 Move 2024 语法,到链上 PvP 记录(KillMail),再到签名验证、位置证明、能量燃料系统、Extension 模式、炮塔 AI 和访问控制——你已经掌握了在 EVE Frontier 上构建复杂应用所需的全部核心知识。
接下来的路:
- 加入 EVE Frontier Builders Discord
- 在测试网部署属于你的第一个 Extension
- 在游戏中找到属于你的星系,点亮一个 Smart Gate
在星际中建设,是一种文明的延伸。
第27章:链下签名 × 链上验证
学习目标:深入理解
world::sig_verify模块的 Ed25519 签名验证机制,掌握“游戏服务器签名 → Move 合约验证“这一 EVE Frontier 的核心安全模式。
状态:教学示例。正文中的验证流程是对官方实现的拆解版,落地时请优先对照实际源码和测试。
最小调用链
游戏服务器构造消息 -> Ed25519 签名 -> 玩家提交 bytes/signature -> sig_verify 模块校验 -> 合约继续执行
对应代码目录
关键 Struct / 输入
| 类型或输入 | 作用 | 阅读重点 |
|---|---|---|
| 消息 bytes | 链下事实的原始编码 | 看链下签名和链上验证是否使用完全相同的字节序列 |
| 签名 blob | flag + raw_sig + public_key | 看长度、切片顺序和签名算法标识 |
AdminACL / 授权地址 | 业务允许的服务器身份 | 看“签名正确”和“签名者有权”是两层校验 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
sig_verify 相关校验入口 | 验证签名与消息绑定 | 是否正确加入 intent 前缀、是否严格比对 bytes |
| 业务合约中的验证包装函数 | 把签名验证接入业务流程 | 是否同时校验 nonce、过期时间、对象绑定 |
| sponsor / server 白名单入口 | 限制可接受的服务端身份 | 是否与签名校验分层处理 |
最容易误读的点
- 签名通过不等于业务通过,业务字段仍要单独校验
- 链下签名前的 bytes 只要有一个字段编码不同,链上验证就必然失败
AdminACL解决的是“谁可以提交/赞助”,不是“消息内容一定正确”
读签名系统时,建议把验证拆成 4 层,不要混成一个“验签通过就安全”:
- 字节层:链下和链上看到的
message_bytes是否完全一致。 - 密码学层:签名是否真由那把私钥产生。
- 身份层:这把私钥对应的地址是否属于被允许的服务器。
- 业务层:消息里的玩家、对象、deadline、nonce、数量等字段是否真的和这次调用匹配。
sig_verify 只负责前两层和一部分第三层,真正决定业务是否安全的,往往是你在外面那层包装函数写得够不够严。
1. 为什么需要链下签名?
EVE Frontier 的一个根本性挑战:链上合约无法访问游戏世界的实时状态。
| 信息 | 来源 | 合约可直接读取? |
|---|---|---|
| 玩家的舰船位置坐标 | 游戏服务器实时计算 | ❌ |
| 某玩家是否在某建筑附近 | 游戏物理引擎 | ❌ |
| 今天的 PvP 击杀结果 | 游戏战斗服务器 | ❌ |
| 链上对象的状态 | Sui 状态树 | ✅ |
解决方案:游戏服务器在链下将这些“事实“签名成一个消息,玩家把这个签名提交给合约,合约验证签名的真实性。
2. Ed25519 签名格式
Sui 使用标准的 Ed25519 + 个人消息签名格式。
签名的组成
signature (97 bytes total):
┌─────────┬───────────────────┬──────────────────┐
│ flag │ raw_sig │ public_key │
│ 1 byte │ 64 bytes │ 32 bytes │
│ (0x00) │ (Ed25519 sig) │ (Ed25519 PK) │
└─────────┴───────────────────┴──────────────────┘
常量定义(来自源码)
const ED25519_FLAG: u8 = 0x00; // Ed25519 scheme 标识符
const ED25519_SIG_LEN: u64 = 64; // 签名长度
const ED25519_PK_LEN: u64 = 32; // 公钥长度
3. 源码精读:sig_verify.move
3.1 从公钥派生 Sui 地址
pub fun derive_address_from_public_key(public_key: vector<u8>): address {
assert!(public_key.length() == ED25519_PK_LEN, EInvalidPublicKeyLen);
// Sui 地址 = Blake2b256(flag_byte || public_key)
let mut concatenated: vector<u8> = vector::singleton(ED25519_FLAG);
concatenated.append(public_key);
sui::address::from_bytes(hash::blake2b256(&concatenated))
}
公式:sui_address = Blake2b256(0x00 || ed25519_public_key)
这意味着如果你知道游戏服务器的 Ed25519 公钥,你就能预知它的 Sui 地址。
3.2 PersonalMessage Intent 前缀
// x"030000" 是三个字节:
// 0x03 = IntentScope::PersonalMessage
// 0x00 = IntentVersion::V0
// 0x00 = AppId::Sui
let mut message_with_intent = x"030000";
message_with_intent.append(message);
let digest = hash::blake2b256(&message_with_intent);
⚠️ 重要细节:消息是直接附加的(不经过 BCS 序列化),这与 Sui 钱包签名的默认行为不同。原因是游戏服务器的 Go/TypeScript 端使用
SignPersonalMessage的方式直接操作字节。
3.3 完整验证流程
pub fun verify_signature(
message: vector<u8>,
signature: vector<u8>,
expected_address: address,
): bool {
let len = signature.length();
assert!(len >= 1, EInvalidLen);
// 1. 从第一个字节提取 scheme flag
let flag = signature[0];
// 2. Move 2024 match 语法(类似 Rust)
let (sig_len, pk_len) = match (flag) {
ED25519_FLAG => (ED25519_SIG_LEN, ED25519_PK_LEN),
_ => abort EUnsupportedScheme,
};
assert!(len == 1 + sig_len + pk_len, EInvalidLen);
// 3. 切分签名字节
let raw_sig = extract_bytes(&signature, 1, 1 + sig_len);
let raw_public_key = extract_bytes(&signature, 1 + sig_len, len);
// 4. 构造带 intent 前缀的消息摘要
let mut message_with_intent = x"030000";
message_with_intent.append(message);
let digest = hash::blake2b256(&message_with_intent);
// 5. 验证公钥对应的 Sui 地址
let sig_address = derive_address_from_public_key(raw_public_key);
if (sig_address != expected_address) {
return false
};
// 6. 验证 Ed25519 签名
match (flag) {
ED25519_FLAG => {
ed25519::ed25519_verify(&raw_sig, &raw_public_key, &digest)
},
_ => abort EUnsupportedScheme,
}
}
3.4 字节提取辅助函数
// Move 2024 的 vector::tabulate! 宏:简洁地创建切片
fun extract_bytes(source: &vector<u8>, start: u64, end: u64): vector<u8> {
vector::tabulate!(end - start, |i| source[start + i])
}
4. 端到端流程
游戏服务器(Go/Node.js)
│
├─ 构造消息:message = bcs_encode(LocationProofMessage)
├─ 添加 intent 前缀:msg_with_intent = 0x030000 + message
├─ 计算摘要:digest = blake2b256(msg_with_intent)
└─ 签名:signature = ed25519_sign(server_private_key, digest)
↓
玩家调用合约(Sui PTB)
│
└─ verify_signature(message, flag+sig+pk, server_address)
↓
Move 合约
├─ 重建摘要(相同算法)
├─ 从 signature 中提取 public_key
├─ 验证 address(public_key) == server_address(防伪造)
└─ ed25519_verify(sig, pk, digest) → true/false
这个端到端流程里最容易被忽略的是“签名绑定的到底是什么”。如果服务器签的是“玩家 A 今日可领取奖励”这种宽泛语义,而不是“玩家 A 在 deadline 前可为 item_id=123 执行 action=2 一次”,那么验签虽然正确,权限边界仍然过宽。很多重放漏洞、串用漏洞都不是出在加密算法上,而是出在消息语义过松。
5. 如何在 Builder 合约中使用?
5.1 基础用法:验证服务器颁发的许可
module my_extension::server_permit;
use world::sig_verify;
use world::access::ServerAddressRegistry;
use std::bcs;
public struct PermitMessage has copy, drop {
player: address,
action_type: u8, // 1=通行证, 2=物品奖励
item_id: u64,
deadline_ms: u64,
}
public fun redeem_server_permit(
server_registry: &ServerAddressRegistry,
message_bytes: vector<u8>,
signature: vector<u8>,
ctx: &mut TxContext,
) {
// 1. 反序列化消息(假设服务器用 BCS 序列化)
let msg = bcs::from_bytes<PermitMessage>(message_bytes);
// 2. 验证 deadline
// (实际需传入 Clock,此处简化)
// 3. 验证签名来自授权服务器
// 从 registry 中取出服务器地址
let server_addr = get_server_address(server_registry);
assert!(
sig_verify::verify_signature(message_bytes, signature, server_addr),
EInvalidSignature,
);
// 4. 执行业务逻辑
assert!(msg.player == ctx.sender(), EPlayerMismatch);
// ...发放物品、积分等
}
实际写 Builder 合约时,最少要补齐 5 个绑定项:player、action_type、target object id、deadline、nonce/request_id。少任何一个,都可能出现“签名本身没问题,但被拿去做了原本不想允许的事”。一个简单原则是:凡是你不希望用户替换、复用、拖延执行的字段,都应该进被签名字节。
5.2 实战:Location Proof 验证(预览 Ch.26 内容)
location.move 中的 verify_proximity 就是 sig_verify 的典型应用:
// world/sources/primitives/location.move
pub fun verify_proximity(
location: &Location,
proof: LocationProof,
server_registry: &ServerAddressRegistry,
clock: &Clock,
ctx: &mut TxContext,
) {
let LocationProof { message, signature } = proof;
// Step 1: 验证消息字段(位置哈希、发送者地址等)
validate_proof_message(&message, location, server_registry, ctx.sender());
// Step 2: 对消息做 BCS 编码
let message_bytes = bcs::to_bytes(&message);
// Step 3: 验证 deadline 未过期
assert!(is_deadline_valid(message.deadline_ms, clock), EDeadlineExpired);
// Step 4: 调用 sig_verify 验证签名!
assert!(
sig_verify::verify_signature(
message_bytes,
signature,
message.server_address,
),
ESignatureVerificationFailed,
)
}
6. 从 TypeScript 到链上:完整示例
服务器端签名(TypeScript/Node.js)
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { blake2b } from '@noble/hashes/blake2b';
const serverKeypair = Ed25519Keypair.fromSecretKey(SERVER_PRIVATE_KEY);
// 构造消息(与 Move 中 BCS 格式一致)
const message = {
server_address: serverKeypair.getPublicKey().toSuiAddress(),
player_address: playerAddress,
// ...其他字段
};
// 序列化(BCS)
const messageBytes = bcs.serialize(PermitMessage, message);
// 添加 PersonalMessage intent 前缀
const intentPrefix = new Uint8Array([0x03, 0x00, 0x00]);
const msgWithIntent = new Uint8Array([...intentPrefix, ...messageBytes]);
// 计算 Blake2b-256 摘要
const digest = blake2b(msgWithIntent, { dkLen: 32 });
// 用服务器私钥签名
const rawSig = serverKeypair.signData(digest); // 64 bytes
// 构建完整签名:flag (1) + sig (64) + pubkey (32) = 97 bytes
const pubKey = serverKeypair.getPublicKey().toRawBytes(); // 32 bytes
const fullSignature = new Uint8Array([0x00, ...rawSig, ...pubKey]);
玩家提交到链上(TypeScript/PTB)
const tx = new Transaction();
tx.moveCall({
target: `${PACKAGE_ID}::my_extension::redeem_server_permit`,
arguments: [
tx.object(SERVER_REGISTRY_ID),
tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(messageBytes))),
tx.pure(bcs.vector(bcs.u8()).serialize(Array.from(fullSignature))),
],
});
await client.signAndExecuteTransaction({ signer: playerKeypair, transaction: tx });
7. Match 语法:Move 2024 的新特性
sig_verify.move 大量使用了 Move 2024 的 match 表达式:
// Move 2024 match(类似 Rust)
let (sig_len, pk_len) = match (flag) {
ED25519_FLAG => (ED25519_SIG_LEN, ED25519_PK_LEN),
_ => abort EUnsupportedScheme,
};
对比旧写法:
// Move 旧写法
let sig_len: u64;
let pk_len: u64;
if (flag == ED25519_FLAG) {
sig_len = ED25519_SIG_LEN;
pk_len = ED25519_PK_LEN;
} else {
abort EUnsupportedScheme
};
8. 安全性注意事项
| 风险 | 防护机制 |
|---|---|
| 伪造签名 | Ed25519 密码学保障 |
| 重放攻击(同一个证明被反复提交) | deadline_ms 过期时间 + 一次性验证标记 |
| 错误服务器签名 | derive_address_from_public_key 验证地址匹配 |
| 未注册服务器 | ServerAddressRegistry 白名单过滤 |
9. 实战练习
- 签名验证工具:用 TypeScript 实现一个“签名生成器“,用测试密钥为玩家生成通行许可证签名
- 单次使用凭证:设计一个合约,接收服务器签发的“单次使用 item“,验证后在链上标记为“已使用“防止重放
- 多服务器支持:阅读
ServerAddressRegistry的设计,思考如何支持多个游戏服务器节点签名同一个凭证
本章小结
| 概念 | 要点 |
|---|---|
| Ed25519 签名格式 | flag(1) + sig(64) + pubkey(32) = 97 字节 |
| PersonalMessage intent | 0x030000 前缀 + 消息,Blake2b256 摘要 |
| 地址验证 | `Blake2b256(0x00 |
| Match 语法 | Move 2024 新特性,替代 if/else 分支 |
tabulate! 宏 | 简洁的字节切片操作 |
下一章:位置证明协议 —— LocationProof 的 BCS 序列化、临近性验证,以及如何在建筑合约中要求玩家“必须在场“。
第28章:位置证明协议深度剖析
学习目标:掌握
world::location模块的核心设计——位置哈希、BCS 反序列化、LocationProof 验证,以及在 Builder 扩展中要求玩家“必须在场“的完整实现。
状态:教学示例。位置证明的消息组织和签名流程会因业务而变,本章重点是解释协议结构和验证边界。
最小调用链
游戏服务器观测位置 -> 生成 LocationProof -> 玩家提交 proof -> 合约反序列化并验证 -> 放行/拒绝业务动作
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
Location | 链上位置哈希容器 | 看链上只保存 hash,不保存明文坐标 |
LocationProofMessage | 服务器签名的位置证明消息体 | 看玩家、源对象、目标对象、距离、deadline 是否全部绑定 |
LocationProof | 链上提交的证明载体 | 看 bytes、签名和消息体如何组合 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
verify_proximity | 校验“玩家是否在目标附近” | 是否同时校验签名、目标对象、距离阈值、时间窗 |
| BCS 反序列化路径 | 从 bytes 还原 proof | 字段顺序和链下编码是否完全一致 |
| 业务模块包装入口 | 把 proximity proof 接进 Gate / Turret / Storage | proof 是否绑定具体业务对象而不是通用复用 |
最容易误读的点
- 位置证明不是只证明“我在场”,而是证明“我在某个对象附近、在某个时间窗内”
- 只校验距离不校验目标对象,proof 就可能被串用到别的业务入口
- BCS 一旦字段顺序不一致,问题通常不在密码学,而在编码
位置证明最好按协议层来理解,而不是按“一个签名对象”来理解。它至少有 4 层含义:谁在场、相对谁在场、在多长时间窗内有效、这份证明还绑定了哪些业务上下文。真正安全的 Builder 设计,不会只拿 distance 一个字段做判断,而是会把 player_address、target_structure_id、target_location_hash、deadline_ms 甚至 data 里的业务标识一起绑定成一份不可拆分的陈述。
1. 位置系统的核心问题
EVE Frontier 的链上合约面临一个根本性挑战:如何验证一个玩家(飞船)目前位于某个空间位置附近?
链上合约无法访问游戏世界的实时位置数据。EVE Frontier 的解决方案是位置证明(LocationProof):
游戏服务器观测到"玩家 A 在建筑 B 旁边(距离 < 1000m)"
↓
服务器将这一"观测事实"签名成一个 LocationProof
↓
玩家 A 把这个 proof 提交到链上合约
↓
合约验证签名、位置哈希、过期时间后执行业务逻辑
2. LocationProof 数据结构
// world/sources/primitives/location.move
/// 位置哈希(32字节,包含 x/y/z 坐标的混合哈希)
public struct Location has store {
location_hash: vector<u8>, // 32 bytes
}
/// 服务器签名的位置证明消息体
public struct LocationProofMessage has copy, drop {
server_address: address, // 签名者(服务器地址)
player_address: address, // 被证明的玩家钱包地址
source_structure_id: ID, // 玩家当前所在结构的 ID
source_location_hash: vector<u8>, // 玩家所在位置的哈希
target_structure_id: ID, // 目标建筑的 ID
target_location_hash: vector<u8>, // 目标所在位置的哈希
distance: u64, // 两者之间的距离(游戏单位)
data: vector<u8>, // 存储额外的业务数据
deadline_ms: u64, // 证明的过期时间(毫秒)
}
/// 完整的位置证明(消息体 + 签名)
public struct LocationProof has drop {
message: LocationProofMessage,
signature: vector<u8>,
}
这里最值得注意的字段其实是 data。它的存在不是为了“多塞点备注”,而是为了给不同业务留出扩展绑定位。比如宝箱系统可以把 chest 类型或开启轮次写进去,市场系统可以把 market_id 或订单上下文写进去。这样一份 proof 就不会只是“我在某地”,而是“我在某地,并且这份证明是给某个具体业务入口使用的”。如果放弃这层绑定,proof 很容易在多个入口间被串用。
3. verify_proximity 函数完整解析
pub fun verify_proximity(
location: &Location, // 目标建筑的链上位置对象
proof: LocationProof, // 玩家提交的证明
server_registry: &ServerAddressRegistry, // 授权服务器白名单
clock: &Clock,
ctx: &mut TxContext,
) {
let LocationProof { message, signature } = proof;
// ① 验证消息字段的合法性
validate_proof_message(&message, location, server_registry, ctx.sender());
// ② 将消息结构体序列化为字节(BCS 格式)
let message_bytes = bcs::to_bytes(&message);
// ③ 验证 deadline 未过期
assert!(is_deadline_valid(message.deadline_ms, clock), EDeadlineExpired);
// ④ 调用 sig_verify 验证 Ed25519 签名
assert!(
sig_verify::verify_signature(
message_bytes,
signature,
message.server_address,
),
ESignatureVerificationFailed,
)
}
validate_proof_message 内部验证
fun validate_proof_message(
message: &LocationProofMessage,
expected_location: &Location,
server_registry: &ServerAddressRegistry,
sender: address,
) {
// 1. 服务器地址在白名单中
assert!(
access::is_authorized_server_address(server_registry, message.server_address),
EUnauthorizedServer,
);
// 2. 消息中的玩家地址与调用者一致(防止别人用你的证明)
assert!(message.player_address == sender, EUnverifiedSender);
// 3. 目标位置哈希与链上 Location 对象匹配
assert!(
message.target_location_hash == expected_location.location_hash,
EInvalidLocationHash,
);
}
三重验证保障安全:
- ✅ 签名来自授权服务器
- ✅ 证明是为当前调用者颁发的(防抢跑)
- ✅ 目标位置与链上对象的位置一致(防篡改)
这三重验证解决的是最基本的身份与目标绑定,但 Builder 自己往往还要补第四重验证:业务绑定。例如“开这个门”和“开那个宝箱”即使都在同一坐标附近,也不应该共享同一份 proof。最稳妥的做法是让 data 或目标对象字段能唯一指向本次业务入口,而不是只依赖空间接近这一件事。
4. BCS 反序列化:从字节还原 LocationProof
当玩家通过 SDK 提交 proof_bytes(原始字节)而非结构体时,合约需要手动反序列化:
pub fun verify_proximity_proof_from_bytes(
server_registry: &ServerAddressRegistry,
location: &Location,
proof_bytes: vector<u8>,
clock: &Clock,
ctx: &mut TxContext,
) {
// 手动 BCS 反序列化
let (message, signature) = unpack_proof(proof_bytes);
// ...(之后与 verify_proximity 相同)
}
unpack_proof 的 BCS 手工反序列化
fun unpack_proof(proof_bytes: vector<u8>): (LocationProofMessage, vector<u8>) {
let mut bcs_data = bcs::new(proof_bytes);
// 按 BCS 字段顺序逐字段 "peel"(剥取)
let server_address = bcs_data.peel_address();
let player_address = bcs_data.peel_address();
// ID 类型通过 address 还原
let source_structure_id = object::id_from_address(bcs_data.peel_address());
// vector<u8> 类型用 peel_vec! 宏
let source_location_hash = bcs_data.peel_vec!(|bcs| bcs.peel_u8());
let target_structure_id = object::id_from_address(bcs_data.peel_address());
let target_location_hash = bcs_data.peel_vec!(|bcs| bcs.peel_u8());
let distance = bcs_data.peel_u64();
let data = bcs_data.peel_vec!(|bcs| bcs.peel_u8());
let deadline_ms = bcs_data.peel_u64();
let signature = bcs_data.peel_vec!(|bcs| bcs.peel_u8());
let message = LocationProofMessage {
server_address, player_address, source_structure_id,
source_location_hash, target_structure_id, target_location_hash,
distance, data, deadline_ms,
};
(message, signature)
}
peel_vec!宏:Move 2024 中处理 BCS 编码的vector<u8>的标准写法,等价于先读长度,再逐字节读取。
5. 距离验证
除了“是否在附近“,还支持“两个结构之间的距离是否满足要求“:
pub fun verify_distance(
location: &Location,
server_registry: &ServerAddressRegistry,
proof_bytes: vector<u8>,
max_distance: u64, // Builder 设定的最大距离阈值
ctx: &mut TxContext,
) {
let (message, signature) = unpack_proof(proof_bytes);
validate_proof_message(&message, location, server_registry, ctx.sender());
let message_bytes = bcs::to_bytes(&message);
// 验证距离不超过 Builder 设定的阈值
assert!(message.distance <= max_distance, EOutOfRange);
assert!(
sig_verify::verify_signature(message_bytes, signature, message.server_address),
ESignatureVerificationFailed,
)
}
同位置验证(无需签名)
/// 验证两个临时库存在同一位置(用于 EVE 太空 P2P 交易)
pub fun verify_same_location(location_a_hash: vector<u8>, location_b_hash: vector<u8>) {
assert!(location_a_hash == location_b_hash, ENotInProximity);
}
6. Builder 实战:空间限定交易市场
module my_market::space_market;
use world::location::{Self, Location, LocationProof};
use world::access::ServerAddressRegistry;
use sui::clock::Clock;
/// 只有在市场附近的玩家才能购买
pub fun buy_item(
market: &mut Market,
market_location: &Location, // 市场的链上位置对象
proximity_proof: LocationProof, // 玩家提交的位置证明
server_registry: &ServerAddressRegistry,
payment: Coin<SUI>,
item_id: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
// 验证玩家在市场附近(核心守卫)
location::verify_proximity(
market_location,
proximity_proof,
server_registry,
clock,
ctx,
);
// 后续业务逻辑
// ...
}
7. Builder 实战:位置锁定宝箱
module my_treasure::chest;
use world::location::{Self, Location};
use world::access::ServerAddressRegistry;
/// 只有到达宝箱位置才能打开
pub fun open_chest(
chest: &mut TreasureChest,
chest_location: &Location,
proximity_proof_bytes: vector<u8>,
server_registry: &ServerAddressRegistry,
clock: &Clock,
ctx: &mut TxContext,
) {
// 使用 bytes 接口(服务器直接传字节,无需在 PTB 中构造结构体)
location::verify_proximity_proof_from_bytes(
server_registry,
chest_location,
proximity_proof_bytes,
clock,
ctx,
);
// 开箱!
let loot = chest.claim_loot(ctx);
transfer::public_transfer(loot, ctx.sender());
}
8. 位置证明的过期机制
fun is_deadline_valid(deadline_ms: u64, clock: &Clock): bool {
let current_time_ms = clock.timestamp_ms();
deadline_ms > current_time_ms
}
游戏服务器通常为位置证明设置 30 秒到 5 分钟的有效期。过期后,玩家需要重新从服务器申请新的证明。
设计建议:
- 一次性行为(如开宝箱):设置 30 秒有效期
- 持续性行为(如采矿会话):设置 5 分钟有效期,定期刷新
过期时间本质上是在平衡两件事:安全窗口和交互成本。窗口太长,proof 被截获或被玩家延后使用的风险上升;窗口太短,又会让网络抖动、钱包确认延迟、赞助交易排队变成大量误伤。Builder 设计时不要只问“理论上多短最安全”,还要看真实交易路径里从服务器签名到链上落地通常要多久。
9. 测试时的特殊处理
由于测试环境无法运行真实的游戏服务器签名,world-contracts 提供了无 deadline 验证的测试版本:
#[test_only]
pub fun verify_proximity_without_deadline(
server_registry: &ServerAddressRegistry,
location: &Location,
proof: LocationProof,
ctx: &mut TxContext,
): bool {
let LocationProof { message, signature } = proof;
validate_proof_message(&message, location, server_registry, ctx.sender());
let message_bytes = bcs::to_bytes(&message);
sig_verify::verify_signature(message_bytes, signature, message.server_address)
}
在测试中可以预先生成一个固定的“永不过期“签名,绕过时间检查。
本章小结
| 概念 | 要点 |
|---|---|
Location | 32 字节哈希,由游戏服务器维护 |
LocationProof | 消息体 + Ed25519 签名,有效期有限 |
| 三重验证 | 服务器白名单 + 玩家地址匹配 + 位置哈希匹配 |
verify_distance | 支持两建筑间距离的上限验证 |
| BCS peel 手工反序列化 | 字段顺序必须与结构体定义一致 |
下一章:能量与燃料系统 —— 深入理解 EVE Frontier 建筑运行所需的双层能源机制,以及燃料消耗率的精确计算逻辑。
第29章:能量与燃料系统机制
学习目标:深入理解 EVE Frontier 建筑运行的双层能源机制——Energy(电力容量)与 Fuel(燃料消耗),掌握
world::energy和world::fuel模块的源码设计,并学会编写与这两个系统交互的 Builder 扩展。
状态:教学示例。正文中的能量/燃料模型用于帮助你读懂官方实现,字段和入口请以实际模块为准。
最小调用链
Network Node 分配能量 -> 建筑检查 energy/fuel 条件 -> 业务模块消耗燃料 -> 建筑状态更新
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
EnergyConfig | 不同装配类型的能量配置 | 看类型到能量需求的映射如何维护 |
EnergySource | 网络节点的供能状态 | 看最大产能、当前产能、已预留能量三者关系 |
Fuel 相关结构 | 建筑燃料存量与消耗状态 | 看燃料存量和时间费率如何绑定 |
FuelEfficiency | 燃料类型与效率差异 | 看不同燃料如何影响续航和成本 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
available_energy | 计算剩余可用能量 | 当前产能和已预留量是否同步更新 |
| 燃料消耗入口 | 业务执行时扣减 fuel | 扣 fuel 是否与业务动作绑在同一事务 |
| 建筑上线/离线路径 | 结合 energy + fuel 判断状态 | 是否同时满足两套条件 |
最容易误读的点
Energy更像容量/配额,不是“可以慢慢花掉的钱包余额”- 只补 fuel 不补 energy,建筑仍然可能离线
- 状态判断必须和资源扣减放在同一事务,否则前端很容易读到过期状态
这章最重要的理解,不是记住几个字段名,而是区分容量约束和消耗约束。Energy 回答的是“这台建筑有没有资格挂在这张电网上运行”;Fuel 回答的是“它此刻还能维持多久”。前者更像并发配额,后者更像时间账本。把这两件事混成一个余额模型,Builder 在设计在线状态、预警逻辑和补给系统时很容易出错。
1. 为什么需要双层能源系统?
EVE Frontier 的建筑(SmartAssembly)需要同时管理两种不同性质的“资源“:
| 概念 | 对应模块 | 性质 | 类比 |
|---|---|---|---|
| Energy(能量) | world::energy | 功率/容量,持续可用 | 电网容量(KW) |
| Fuel(燃料) | world::fuel | 消耗品,有存量 | 发电机的燃油(升) |
- 建筑联网(NetworkNode)会分配一定的 能量容量 给各个接入的建筑
- 建筑本身需要持续燃烧 燃料 来维持运行
从 Builder 视角看,这意味着很多“离线”其实有两种完全不同的根因:一种是没电网容量了,另一种是没燃料了。它们在玩家体验上都表现为“建筑不能用了”,但在产品动作上不一样。容量不足常常需要做网络拓扑、建筑接入顺序或升级决策;燃料不足更像补给、收费、代运营的问题。把这两个诊断面拆开,后面的告警和收费系统才会清晰。
2. Energy 模块
2.1 核心数据结构
// world/sources/primitives/energy.move
pub struct EnergyConfig has key {
id: UID,
// type_id → 该装配类型所需能量数值
assembly_energy: Table<u64, u64>,
}
pub struct EnergySource has store {
max_energy_production: u64, // 最大发电量(NetworkNode 的能量上限)
current_energy_production: u64, // 当前激活的发电量
total_reserved_energy: u64, // 已被各建筑预留的总能量
}
2.2 能量计算公式
/// 可用能量 = 当前产能 - 已预留能量
pub fun available_energy(energy_source: &EnergySource): u64 {
if (energy_source.current_energy_production > energy_source.total_reserved_energy) {
energy_source.current_energy_production - energy_source.total_reserved_energy
} else {
0 // 不能为负
}
}
2.3 能量预留与释放
当一个建筑(如 Gate 或 Turret)加入 NetworkNode 时:
// 内部包函数(Builder 不直接调用)
pub(package) fun reserve(
energy_source: &mut EnergySource,
energy_source_id: ID,
assembly_type_id: u64, // 要接入的建筑类型
energy_config: &EnergyConfig, // 读取该类型所需能量数
ctx: &TxContext,
) {
let energy_required = energy_config.assembly_energy(assembly_type_id);
assert!(energy_source.available_energy() >= energy_required, EInsufficientAvailableEnergy);
energy_source.total_reserved_energy = energy_source.total_reserved_energy + energy_required;
event::emit(EnergyReservedEvent { ... });
}
2.4 EnergyConfig 的配置(仅管理员)
pub fun set_energy_config(
energy_config: &mut EnergyConfig,
admin_acl: &AdminACL,
assembly_type_id: u64,
energy_required: u64, // 该类型建筑运行需要多少能量
) {
admin_acl.verify_sponsor(ctx);
if (energy_config.assembly_energy.contains(assembly_type_id)) {
*energy_config.assembly_energy.borrow_mut(assembly_type_id) = energy_required;
} else {
energy_config.assembly_energy.add(assembly_type_id, energy_required);
};
}
3. Fuel 模块(重点:时间费率计算)
3.1 核心数据结构
// world/sources/primitives/fuel.move
pub struct FuelConfig has key {
id: UID,
// fuel_type_id → 效率倍数(BPS,10000 = 100%)
fuel_efficiency: Table<u64, u64>,
}
public struct Fuel has store {
type_id: Option<u64>, // 当前填充的燃料类型
quantity: u64, // 剩余燃料数量
max_capacity: u64, // 燃料槽最大容量
burn_rate_in_ms: u64, // 基础燃烧速率(ms/单位)
is_burning: bool, // 是否正在燃烧
burn_start_time: u64, // 最近一次开始燃烧的时间戳
previous_cycle_elapsed_time: u64, // 上一次周期的剩余时间(防止精度丢失)
last_updated: u64, // 最后更新时间
}
3.2 燃烧周期计算(精读)
这是 Fuel 模块最复杂的部分:
fun calculate_units_to_consume(
fuel: &Fuel,
fuel_config: &FuelConfig,
current_time_ms: u64,
): (u64, u64) { // 返回:(消耗单位数, 剩余毫秒数)
if (!fuel.is_burning || fuel.burn_start_time == 0) {
return (0, 0)
};
// 1. 从 FuelConfig 读取该燃料类型的效率
let fuel_type_id = *option::borrow(&fuel.type_id);
let fuel_efficiency = fuel_config.fuel_efficiency.borrow(fuel_type_id);
// 2. 实际消耗速率 = 基础速率 × 效率系数
let actual_consumption_rate_ms =
(fuel.burn_rate_in_ms * fuel_efficiency) / PERCENTAGE_DIVISOR;
// 例如:burn_rate=3600000ms(1hr/单位), efficiency=5000(50%)
// 实际每单位 = 3600000 * 5000 / 10000 = 1800000ms(30分钟)
// 3. 计算经过的总时间(含上一周期剩余时间)
let elapsed_ms = if (current_time_ms > fuel.burn_start_time) {
current_time_ms - fuel.burn_start_time
} else { 0 };
// 保留上一周期的"零头"时间,避免精度丢失
let total_elapsed_ms = elapsed_ms + fuel.previous_cycle_elapsed_time;
// 4. 整除得到消耗单位数
let units_to_consume = total_elapsed_ms / actual_consumption_rate_ms;
// 5. 取余得到下一周期的起始时间
let remaining_elapsed_ms = total_elapsed_ms % actual_consumption_rate_ms;
(units_to_consume, remaining_elapsed_ms)
}
为什么需要 previous_cycle_elapsed_time?
这段设计体现的是“链上定时计费”常见的一个难点:你没法像游戏服务器那样每秒 tick 一次,只能在离散交易里结算已经流逝的时间。所以 `previous_cycle_elapsed_time` 实际是在保存上次结算没能整除掉的那部分时间尾差。如果没有它,系统每次结算都会向下取整,长期下来会系统性少扣燃料,经济模型就会被慢慢掏空。
时间轴示例(burn_rate = 1小时/单位):
│───────────────────────────────────────────────────│
0 60min 90min 120min
第一次 update(90min时):
elapsed = 90min
units = 90min / 60min = 1 单位消耗
remaining = 90min % 60min = 30min ← 保存到 previous_cycle_elapsed_time
第二次 update(120min时):
elapsed = 30min(从上次 burn_start_time 算)
total = 30min + 30min(previous) = 60min
units = 60min / 60min = 1 单位消耗
remaining = 0
3.3 update 函数:批量结算
/// 游戏服务器定期调用此函数,结算燃料消耗
pub(package) fun update(
fuel: &mut Fuel,
assembly_id: ID,
assembly_key: TenantItemId,
fuel_config: &FuelConfig,
clock: &Clock,
) {
// 未燃烧 → 直接返回
if (!fuel.is_burning || fuel.burn_start_time == 0) { return };
let current_time_ms = clock.timestamp_ms();
if (fuel.last_updated == current_time_ms) { return }; // 同一区块内幂等
let (units_to_consume, remaining_elapsed_ms) =
calculate_units_to_consume(fuel, fuel_config, current_time_ms);
if (fuel.quantity >= units_to_consume) {
// 有足够燃料:正常消耗
consume_fuel_units(fuel, ..., units_to_consume, remaining_elapsed_ms, current_time_ms);
fuel.last_updated = current_time_ms;
} else {
// 燃料耗尽:自动停止燃烧
stop_burning(fuel, assembly_id, assembly_key, fuel_config, clock);
}
}
3.4 一个已知 Bug(源码注释)
pub(package) fun start_burning(fuel: &mut Fuel, ...) {
// ...
if (fuel.quantity != 0) {
// todo : fix bug: consider previous cycle elapsed time
fuel.quantity = fuel.quantity - 1; // Consume 1 unit to start the clock
};
启动燃烧时直接扣 1 单位,但没有考虑 previous_cycle_elapsed_time 可能导致这个单位被重复计算。这是源码中明确注释的已知 Bug。学习要点:即使是生产合约也会有 Bug,读源码时要批判性思考。
4. Builder 如何感知燃料状态?
Builder 扩展通常不直接操作 Fuel 对象(它是 pub(package) 内部字段),但可以通过建筑的状态间接判断:
use world::assemblies::gate::{Self, Gate};
use world::status;
/// 检查 Gate 是否在线(间接反映燃料状态)
pub fun is_gate_operational(gate: &Gate): bool {
gate.status().is_online()
}
当燃料耗尽时,游戏服务器会调用 stop_burning,然后建筑的 Status 会变为 Offline,Builder 合约通过 Status 感知:
// 只有在线建筑才能处理跳跃请求
assert!(source_gate.status.is_online(), ENotOnline);
这也是一个很重要的边界:World 内核把燃料细节藏在包内,不是为了限制 Builder,而是为了避免扩展直接篡改底层计费状态。Builder 更适合围绕“是否在线”“剩余补给是否足够”“是否需要提醒/收费/捐赠”来做产品层逻辑,而不是自己发明另一套 fuel 账本。
5. Energy vs Fuel 的状态流转
Fuel 状态机:
EMPTY
│ deposit_fuel()
▼
LOADED
│ start_burning()
▼
BURNING ──── update() ────► 燃料充足继续 BURNING
│ │
│ ▼ 燃料耗尽
│ OFFLINE(建筑下线)
│ stop_burning()
▼
STOPPED(保留 previous_cycle_elapsed_time)
Energy 状态机(更简单):
OFF
│ start_energy_production()
▼
ON(持续提供 max_energy_production 的容量)
│ stop_energy_production()
▼
OFF
6. FuelEfficiency 设计:支持多种燃料类型
pub struct FuelConfig has key {
id: UID,
fuel_efficiency: Table<u64, u64>, // fuel_type_id → efficiency_bps
}
不同类型的燃料(不同 type_id)有不同的效率:
| fuel_type_id | 燃料名称 | efficiency_bps | 说明 |
|---|---|---|---|
| 1001 | 标准燃料 | 10000 (100%) | 基准效率 |
| 1002 | 高效燃料 | 15000 (150%) | 燃烧更久 |
| 1003 | 普通燃料棒 | 8000 (80%) | 便宜但低效 |
效率越高,同等燃料量能维持建筑运行越长时间。Builder 可以在扩展中要求玩家使用特定类型燃料。
7. 实战练习
- 燃料计算器:给定
burn_rate_in_ms = 3600000,fuel_efficiency = 7500,剩余quantity = 10,计算还能运行多少小时 - 燃料预警合约:写一个 Builder 扩展,当 Gate 的燃料剩余量不足 5 单位时,自动向物主发送一个链上事件提醒
- 燃料捐献系统:设计一个共享
FuelDonationPool,允许任意玩家向建筑捐赠燃料
本章小结
| 概念 | 要点 |
|---|---|
EnergySource | 功率容量系统,预留/释放模式 |
Fuel | 消耗品系统,基于时间的燃烧周期 |
previous_cycle_elapsed_time | 防止时间取整导致的精度损失 |
fuel_efficiency | 不同燃料类型的效率倍数(BPS) |
| 已知 Bug | start_burning 的 1 单位扣除未考虑前序剩余时间 |
下一章:Extension 模式实战 —— 用官方
extension_examples的两个真实示例,掌握 Builder 扩展的标准开发流程。
第30章:Extension 模式实战——官方示例精读
学习目标:通过精读
world-contracts/contracts/extension_examples/中两个真实的官方扩展示例,掌握 EVE Frontier Builder 扩展的标准开发模式。
状态:已映射到官方示例目录。正文是结构化讲解,建议边读边打开扩展示例源码。
最小调用链
authorize_extension<XAuth> -> 写入 ExtensionConfig -> 业务入口校验规则 -> 调用 World Assembly API
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
AdminCap | 配置扩展规则的管理能力 | 看谁能写配置、谁只能读配置 |
XAuth / witness 类型 | 绑定扩展授权身份 | 看 witness 类型如何成为扩展开关 |
| 配置对象 / 动态字段键 | 保存扩展规则 | 看规则 key 与业务入口读取是否一致 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
authorize_extension<XAuth> | 把 witness 授权到 World 建筑 | 授权类型和扩展包类型是否完全一致 |
| 配置写入入口 | 初始化 tribe / bounty 等规则 | 写入 key 和读取 key 是否匹配 |
| 扩展业务入口 | 实际执行业务规则 | 是否只读自己配置,不假设 World 内核被改写 |
最容易误读的点
- Extension 模式不是“改 World 合约源码”,而是通过 witness 和配置对象挂接行为
- 授权成功不代表业务就能跑,配置 key 不一致一样会读不到规则
- witness 类型一旦写错,问题通常不在逻辑,而在授权链本身
Extension 模式真正厉害的地方,不是“可以插一段自定义代码”,而是它把扩展能力控制在一个很清晰的边界里:World 继续掌握核心资产和核心状态,Builder 只在被允许的切面上改写规则。这让 EVE 的扩展更像受约束的组合,而不是任意 monkey patch。你可以改变谁能过门、过门前要交什么、满足什么配置,但不能偷偷改写 Gate 本身的底层所有权和世界规则。
1. Extension 模式是什么?
EVE Frontier 的 Builder 扩展系统允许任何开发者修改游戏建筑(Gate、Turret、StorageUnit 等)的行为,而无需修改 World 合约本身。
核心设计:Typed Witness 授权模式
World 合约 Builder 扩展包
───────────── ─────────────
Gate has key { pub struct XAuth {}
extension: Option<TypeName> ←──── gate::authorize_extension<XAuth>()
}
当 gate 激活 XAuth 时,游戏引擎
会调用 XAuth 所在包的扩展函数
2. 官方示例概览
extension_examples 包含两个典型示例:
| 示例文件 | 功能 | 授权类型 |
|---|---|---|
tribe_permit.move | 只允许特定部族的角色使用传送门 | 身份过滤 |
corpse_gate_bounty.move | 提交尸体作为“通行费“才能使用传送门 | 物品消耗 |
两者都依赖共同的配置框架:config.move
3. 共享配置框架:config.move
module extension_examples::config;
use sui::dynamic_field as df;
/// 管理员能力
public struct AdminCap has key, store { id: UID }
/// 扩展的授权见证类型(Typed Witness)
public struct XAuth has drop {}
/// 扩展配置共享对象(用动态字段存储各种规则)
public struct ExtensionConfig has key {
id: UID,
admin: address,
}
/// 动态字段操作:添加/更新规则
public fun set_rule<K: copy + drop + store, V: store>(
config: &mut ExtensionConfig,
_: &AdminCap, // 只有 AdminCap 持有者可以设置规则
key: K,
value: V,
) {
if (df::exists_(&config.id, key)) {
df::remove<K, V>(&mut config.id, key);
};
df::add(&mut config.id, key, value);
}
/// 检查规则是否存在
pub fun has_rule<K: copy + drop + store>(config: &ExtensionConfig, key: K): bool {
df::exists_(&config.id, key)
}
/// 读取规则
pub fun borrow_rule<K: copy + drop + store, V: store>(
config: &ExtensionConfig,
key: K,
): &V {
df::borrow(&config.id, key)
}
/// 获取 XAuth 实例(只能在包内调用)
pub(package) fun x_auth(): XAuth { XAuth {} }
设计亮点:ExtensionConfig 用动态字段存储不同类型的“规则“,每个规则有自己的 Key 类型(如 TribeConfigKey、BountyConfigKey),互不干扰,可以任意组合。
这也是为什么这里同时用了 dynamic field 和 typed witness。dynamic field 解决的是“规则怎么存、怎么扩”,typed witness 解决的是“谁有资格触发这套规则”。前者偏数据面,后者偏权限面。很多新手第一次写扩展时只顾着把配置表建出来,却忘了最关键的那条授权链,最后表现就是配置都在、代码也能编译,但 World 根本不会认这套扩展身份。
4. 示例一:部族通行证(tribe_permit.move)
功能
只有属于特定 tribe(部族)的角色才能通过这个 Gate。
核心结构
module extension_examples::tribe_permit;
/// 动态字段 Key
public struct TribeConfigKey has copy, drop, store {}
/// 动态字段 Value
public struct TribeConfig has drop, store {
tribe: u32, // 允许通过的部族 ID
}
颁发通行证(核心逻辑)
pub fun issue_jump_permit(
extension_config: &ExtensionConfig,
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
_: &AdminCap, // 需要 AdminCap(防止滥用)
clock: &Clock,
ctx: &mut TxContext,
) {
// 1. 读取部族配置
assert!(extension_config.has_rule<TribeConfigKey>(TribeConfigKey {}), ENoTribeConfig);
let tribe_cfg = extension_config.borrow_rule<TribeConfigKey, TribeConfig>(TribeConfigKey {});
// 2. 验证角色部族
assert!(character.tribe() == tribe_cfg.tribe, ENotStarterTribe);
// 3. 有效期 5 天(以毫秒计)
let expires_at_timestamp_ms = clock.timestamp_ms() + 5 * 24 * 60 * 60 * 1000;
// 4. 调用 world::gate 颁发 JumpPermit NFT
gate::issue_jump_permit<XAuth>( // 用 XAuth 作为见证
source_gate,
destination_gate,
character,
config::x_auth(), // 获取见证实例
expires_at_timestamp_ms,
ctx,
);
}
管理员配置
pub fun set_tribe_config(
extension_config: &mut ExtensionConfig,
admin_cap: &AdminCap,
tribe: u32,
) {
extension_config.set_rule<TribeConfigKey, TribeConfig>(
admin_cap,
TribeConfigKey {},
TribeConfig { tribe },
);
}
5. 示例二:尸体悬赏传送(corpse_gate_bounty.move)
功能
玩家必须将背包中一个特定类型的“尸体物品“存入 Builder 的 StorageUnit,才能获得通过 Gate 的许可。
完整流程
pub fun collect_corpse_bounty<T: key + store>(
extension_config: &ExtensionConfig,
storage_unit: &mut StorageUnit, // Builder 的物品库
source_gate: &Gate,
destination_gate: &Gate,
character: &Character, // 玩家角色
player_inventory_owner_cap: &OwnerCap<T>, // 玩家的物品所有权凭证
corpse_item_id: u64, // 要提交的尸体 item_id
clock: &Clock,
ctx: &mut TxContext,
) {
// 1. 读取悬赏配置(需要什么类型的尸体)
assert!(extension_config.has_rule<BountyConfigKey>(BountyConfigKey {}), ENoBountyConfig);
let bounty_cfg = extension_config.borrow_rule<BountyConfigKey, BountyConfig>(BountyConfigKey {});
// 2. 从玩家背包取出尸体物品
// OwnerCap<T> 证明玩家有权操作该物品
let corpse = storage_unit.withdraw_by_owner<T>(
character,
player_inventory_owner_cap,
corpse_item_id,
1, // 数量
ctx,
);
// 3. 验证尸体类型是否匹配悬赏要求
assert!(corpse.type_id() == bounty_cfg.bounty_type_id, ECorpseTypeMismatch);
// 4. 将尸体存入 Builder 的 StorageUnit(作为"收藏")
storage_unit.deposit_item<XAuth>(
character,
corpse,
config::x_auth(),
ctx,
);
// 5. 颁发有效期 5 天的 JumpPermit
let expires_at_timestamp_ms = clock.timestamp_ms() + 5 * 24 * 60 * 60 * 1000;
gate::issue_jump_permit<XAuth>(
source_gate, destination_gate, character,
config::x_auth(), expires_at_timestamp_ms, ctx,
);
}
6. 两种模式的对比
tribe_permit(身份验证):
玩家 → [提供 Character 对象] → 验证 tribe_id → 颁发 JumpPermit
corpse_gate_bounty(物品消耗):
玩家 → [提供尸体物品] → 转移给 Builder → 颁发 JumpPermit
| 属性 | tribe_permit | corpse_gate_bounty |
|---|---|---|
| 验证方式 | 角色属性 | 物品所有权 |
| 消耗资源 | 无(通行证有时效) | 消耗一个尸体物品 |
| 可重复使用 | 是(每次需 AdminCap 签发) | 每次都需消耗物品 |
| 适用场景 | 社交门控(如联盟专属) | 经济激励(如赏金猎人) |
这两个官方例子其实对应了 Builder 最常见的两类扩展思路:身份过滤和资源交换。前者重点在“你是谁”,后者重点在“你拿什么来换”。一旦看懂这两个母模式,很多别的玩法都只是变体,例如白名单市场、战利品兑换、任务门票、会籍权限、耗材开启等,都可以沿着这两条路去组合。
7. Builder 开发清单
根据两个官方示例,开发一个标准 Extension 的步骤:
1. 定义 XAuth 见证类型(每个扩展包一个)
2. 创建 ExtensionConfig 共享对象
3. 创建 AdminCap(用于管理配置)
4. 定义规则结构体(XxxConfig)和对应的 Key 类型(XxxConfigKey)
5. 实现管理函数:set_xxx_config(需要 AdminCap)
6. 实现核心逻辑:检查规则 → 业务逻辑 → 调用 gate::issue_jump_permit<XAuth>
7. 在 init() 中创建并转移 ExtensionConfig 和 AdminCap
真正落地时,建议再多检查一件事:扩展失败后,World 的核心状态是否仍然安全。一个好的扩展即使读不到配置、权限不匹配、付款不足,也应该只是让本次业务 abort,而不是让 Gate、StorageUnit、Character 进入半完成状态。这也是为什么 World 把核心资产操作口收得很紧,尽量让失败回滚停留在扩展边界内。
8. 我的第一个 Extension:付费通道
module my_toll::paid_gate;
use my_toll::config::{Self, AdminCap, XAuth, ExtensionConfig};
use world::{character::Character, gate::{Self, Gate}};
use sui::{coin::{Self, Coin}, sui::SUI, balance::{Self, Balance}};
use sui::clock::Clock;
public struct TollConfigKey has copy, drop, store {}
public struct TollConfig has drop, store { toll_amount: u64 }
public struct TollVault has key {
id: UID,
balance: Balance<SUI>,
}
public fun pay_toll_and_jump(
extension_config: &ExtensionConfig,
vault: &mut TollVault,
source_gate: &Gate,
destination_gate: &Gate,
character: &Character,
mut payment: Coin<SUI>,
clock: &Clock,
ctx: &mut TxContext,
) {
let toll_cfg = extension_config.borrow_rule<TollConfigKey, TollConfig>(TollConfigKey {});
assert!(coin::value(&payment) >= toll_cfg.toll_amount, 0);
let toll = coin::split(&mut payment, toll_cfg.toll_amount, ctx);
balance::join(&mut vault.balance, coin::into_balance(toll));
if (coin::value(&payment) > 0) {
transfer::public_transfer(payment, ctx.sender());
} else {
coin::destroy_zero(payment);
};
let expires = clock.timestamp_ms() + 60 * 60 * 1000; // 1 小时通行证
gate::issue_jump_permit<XAuth>(
source_gate, destination_gate, character,
config::x_auth(), expires, ctx,
);
}
本章小结
| 概念 | 要点 |
|---|---|
Typed Witness (XAuth) | 每个扩展包的唯一授权凭证,传入 gate::issue_jump_permit<XAuth> |
ExtensionConfig | 用动态字段存储可扩展规则,支持任意规则类型组合 |
TribeConfigKey/BountyConfigKey | 不同规则的标识 Key,避免类型碰撞 |
AdminCap | 控制谁能修改扩展配置 |
OwnerCap<T> | 玩家物品操作的授权凭证 |
下一章:炮塔 AI 扩展开发 —— 通过
world::turret分析目标优先级队列系统,开发自定义的炮塔 AI 扩展。
第31章:炮塔 AI 扩展开发
学习目标:深入理解
world::turret模块的目标优先级系统,掌握通过 Extension 模式自定义炮塔 AI 行为的完整实现方法。
状态:教学示例。正文关注优先级模型和扩展切入点,具体字段仍应以官方
turret模块源码为准。
最小调用链
飞船进入范围/触发 aggression -> turret 模块收集候选目标 -> 扩展规则排序 -> 执行攻击决策
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
TargetCandidate | 炮塔决策输入候选集 | 看哪些字段参与过滤、哪些字段参与排序 |
ReturnTargetPriorityList | 扩展返回的优先级结果 | 看扩展到底返回“排序建议”还是“直接开火命令” |
BehaviourChangeReason | 触发本次重算的原因 | 看 AI 刷新来自进入范围、攻击行为还是状态变化 |
OnlineReceipt | 炮塔在线状态相关凭证 | 看扩展逻辑是否依赖在线前置条件 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
| 炮塔候选集计算路径 | 收集可攻击目标 | 过滤条件是否先于排序 |
| 扩展优先级入口 | 自定义 AI 排序规则 | 返回值是否符合 World 侧预期 |
| 授权与上线入口 | 挂接扩展到炮塔 | 扩展是否真的被启用且状态同步 |
最容易误读的点
- 炮塔 AI 的扩展点通常是“排序”,不是绕过内核直接接管开火
- 只改优先级不改过滤条件,炮塔仍可能攻击不该攻击的目标
- 候选目标字段来自游戏事件和内核状态,不应凭前端或链下缓存臆造
这一章要先分清两件事:谁有资格成为候选目标,以及候选目标之间谁排第一。前者是过滤问题,决定目标是否进入候选集;后者是排序问题,决定先打谁。大多数 Builder AI 扩展真正能安全影响的是后者,而不是完全推翻前者。这样设计的目的是把“世界规则”与“局部策略”拆开,避免一个扩展包直接把炮塔变成任何它想要的武器。
1. 炮塔(Turret)是什么?
Smart Turret 是 EVE Frontier 中一种可编程空间建筑,可以对进入其范围的飞船自动开火。
两个关键行为触发点:
| 触发器 | 说明 |
|---|---|
InProximity | 飞船进入炮塔范围 |
Aggression | 飞船开始/停止攻击己方建筑 |
默认行为:攻击所有进入范围的飞船。
Builder 扩展的能力:自定义目标优先级排序——决定炮塔优先攻击哪些目标。
2. TargetCandidate 数据结构
当游戏引擎需要决定炮塔该打谁时,它构造一批 TargetCandidate 并传入扩展函数:
// world/sources/assemblies/turret.move
pub struct TargetCandidate has copy, drop, store {
item_id: u64, // 目标的 in-game ID(飞船/NPC)
type_id: u64, // 目标类型
group_id: u64, // 目标所属组(0=NPC)
character_id: u32, // 飞行员的角色 ID(NPC 为 0)
character_tribe: u32, // 飞行员部族(NPC 为 0)
hp_ratio: u64, // 剩余生命值百分比(0-100)
shield_ratio: u64, // 剩余护盾百分比(0-100)
armor_ratio: u64, // 剩余装甲百分比(0-100)
is_aggressor: bool, // 是否正在攻击建筑
priority_weight: u64, // 优先级权重(越大越优先)
behaviour_change: BehaviourChangeReason, // 触发这次更新的原因
}
触发原因枚举
pub enum BehaviourChangeReason has copy, drop, store {
UNSPECIFIED,
ENTERED, // 飞船进入炮塔范围
STARTED_ATTACK, // 飞船开始攻击
STOPPED_ATTACK, // 飞船停止攻击
}
重要设计:每次调用,每个目标候选人只有一个最相关的原因(游戏引擎选最重要的那个)。
这说明 BehaviourChangeReason 更像一次决策重算的上下文提示,而不是完整战斗历史。它告诉扩展“为什么这次要重算优先级”,却不保证把过去所有事件都带进来。因此 Builder 在写 AI 时,不要假设单次调用里能看到完整仇恨链或完整战斗日志;如果真的需要长期记忆,应该额外设计自己的配置或统计对象。
3. 返回格式:ReturnTargetPriorityList
扩展函数最终must返回一个优先级列表:
pub struct ReturnTargetPriorityList has copy, drop, store {
target_item_id: u64, // 目标的 in-game ID
priority_weight: u64, // 自定义优先级分数(越大越优先)
}
炮塔攻击的是列表中 priority_weight 最高的目标(相同权重时打第一个)。
换句话说,扩展返回的是建议顺序,不是“立即执行某个攻击动作”的命令式接口。这个差别很关键。命令式接口意味着扩展可以越权控制底层武器行为,而优先级接口只让扩展在内核已经允许的候选集上表达偏好,整体安全边界会稳很多。
4. 默认优先级规则(内置逻辑)
当 Builder 未配置扩展时,炮塔使用以下默认规则:
// 默认权重增量常量
const STARTED_ATTACK_WEIGHT_INCREMENT: u64 = 10000; // 主动攻击者 +10000
const ENTERED_WEIGHT_INCREMENT: u64 = 1000; // 进入范围者 +1000
// world::turret::get_target_priority_list(默认版本)
pub fun get_target_priority_list(
turret: &Turret,
candidates: vector<TargetCandidate>,
): vector<ReturnTargetPriorityList> {
effective_weight_and_excluded(candidates)
}
fun effective_weight_and_excluded(
candidates: vector<TargetCandidate>,
): vector<ReturnTargetPriorityList> {
let mut result = vector::empty();
candidates.do!(|candidate| {
let weight = match (candidate.behaviour_change) {
BehaviourChangeReason::STARTED_ATTACK => {
candidate.priority_weight + STARTED_ATTACK_WEIGHT_INCREMENT
},
BehaviourChangeReason::ENTERED => {
candidate.priority_weight + ENTERED_WEIGHT_INCREMENT
},
_ => candidate.priority_weight,
};
// 使用 0 表示"排除该目标不攻击",其他值表示优先级
if (weight > 0) {
result.push_back(ReturnTargetPriorityList {
target_item_id: candidate.item_id,
priority_weight: weight,
});
}
});
result
}
默认策略:主动攻击者 > 进入范围者 > 其他。
5. Extension 机制:TypeName 指向扩展包
pub struct Turret has key {
id: UID,
// ...
extension: Option<TypeName>, // 保存了 Builder 扩展包的类型名称
}
当游戏引擎需要决定目标优先级时:
- 读取
turret.extension - 如果是
None:调用world::turret::get_target_priority_list(默认逻辑) - 如果是
Some(TypeName):解析包 ID → 调用该包的get_target_priority_list函数
6. 开发自定义炮塔 AI
场景:只攻击联盟成年后的玩家飞船(保护新手)
module my_turret::ai;
use world::turret::{Turret, TargetCandidate, ReturnTargetPriorityList};
use sui::dynamic_field as df;
/// 配置:新手保护阈值(低于此 group_id 的不攻击)
public struct AiConfig has key {
id: UID,
protected_tribe_ids: vector<u32>, // 受保护的部族(如新手部族)
prefer_aggressors: bool, // 是否优先攻击主动攻击者
}
/// 这是游戏引擎会调用的标准入口函数名(固定签名)
public fun get_target_priority_list(
turret: &Turret,
candidates: vector<TargetCandidate>,
ai_config: &AiConfig, // Builder 的配置对象
): vector<ReturnTargetPriorityList> {
let mut result = vector::empty<ReturnTargetPriorityList>();
candidates.do!(|candidate| {
// 规则1:受保护部族 → 跳过(权重 0 = 排除)
if (vector::contains(&ai_config.protected_tribe_ids, &candidate.character_tribe)) {
return // 不加入结果列表 = 不攻击
};
// 规则2:计算优先级权重
let mut weight: u64 = 1000; // 基础权重
// 主动攻击者优先
if (candidate.is_aggressor && ai_config.prefer_aggressors) {
weight = weight + 50000;
};
// 血量越低优先级越高(补刀策略)
let hp_score = (100 - candidate.hp_ratio) * 100;
weight = weight + hp_score;
// 护盾破碎时附加权重
if (candidate.shield_ratio == 0) {
weight = weight + 5000;
};
result.push_back(ReturnTargetPriorityList {
target_item_id: candidate.item_id,
priority_weight: weight,
});
});
result
}
策略对比:多种 AI 模式
默认 AI:
主动攻击者 (+10000) > 进入范围 (+1000)
补刀 AI(血最低优先):
is_aggressor bonus + (100-hp_ratio)*100 + shield_broken bonus
精英护卫 AI(保护己方):
同族飞船权重=0 + 敌族根据 hp_ratio 排序
反 PvE AI(优先 NPC):
character_id==0 (NPC) → 超高权重 + 玩家 → 低权重
7. 授权扩展到炮塔
Builder 需要先将扩展的 TypeName 注册到炮塔:
// 调用 world 合约提供的函数,将自定义 AI 类型注册到炮塔
// (需要 OwnerCap<Turret>)
turret::authorize_extension<my_turret::ai::AiType>(
turret,
owner_cap,
ctx,
);
之后游戏引擎就会在需要决策时调用该扩展包的 get_target_priority_list。
生产环境里更容易出问题的地方通常不是 AI 数学公式本身,而是“扩展到底有没有真的挂上去”。也就是说,Builder 排查顺序应该先查授权是否成功、炮塔是否在线、配置对象是否可读、TypeName 是否匹配,再去查权重算法。否则很容易把一个授权链问题误判成 AI 逻辑问题。
8. 高级:动态配置 AI 参数
/// 让炮塔 AI 可以动态更新配置(不需要重新部署合约)
pub fun update_protection_list(
ai_config: &mut AiConfig,
admin: address,
new_protected_tribes: vector<u32>,
ctx: &TxContext,
) {
assert!(ctx.sender() == admin, 0);
ai_config.protected_tribe_ids = new_protected_tribes;
}
9. 状态处理:OnlineReceipt
/// 炮塔在线的证明
pub struct OnlineReceipt {
turret_id: ID,
}
炮塔在执行某些操作前需要先确认炮塔在线。OnlineReceipt 是一次性凭证,用于在函数链中传递“已确认在线“的证明,避免重复检查。
10. 实战练习
- 基础 AI:实现一个“专注新手保护“AI——对
hp_ratio > 80的飞船(几乎满血,明显是老鸟)优先攻击,对hp_ratio < 30的(可能是新手)权重设为 0 - 联盟守护 AI:读取一个联盟成员列表,对非成员的飞船分配高优先级,对成员飞船权重为 0
- 排行榜 AI:记录被炮塔击落的各飞船类型数量,每周自动调整策略(击落越多的类型优先级越低——因为该类型玩家已经学会回避了)
本章小结
| 概念 | 要点 |
|---|---|
TargetCandidate | 目标候选人的完整战斗信息 |
BehaviourChangeReason | ENTERED / STARTED_ATTACK / STOPPED_ATTACK |
ReturnTargetPriorityList | 返回格式:item_id + priority_weight(0=排除) |
extension: Option<TypeName> | 炮塔保存扩展包的类型名称,引擎动态调用 |
| 默认权重 | STARTED_ATTACK +10000, ENTERED +1000 |
下一章:访问控制系统完整解析 —— 深入理解
world::access的 OwnerCap / GovernorCap / AdminACL / Receiving 模式,掌握 EVE Frontier 权限架构的核心设计。
第32章:KillMail 系统深度解析
学习目标:理解 EVE Frontier 链上战斗死亡记录的完整架构——从源码结构到与 Builder 扩展的交互方式。
状态:教学示例。正文代码为便于讲解而做了精简,源码验收请以仓库内实际
world-contracts文件为准。
最小调用链
游戏服务器 -> AdminACL 校验 -> create_killmail -> derived_object::claim -> share_object -> emit event
对应代码目录
关键 Struct
| 类型 | 作用 | 阅读重点 |
|---|---|---|
Killmail | 链上击杀记录共享对象 | 看唯一 key、时间戳、击杀双方与发生地如何落盘 |
LossType | 区分飞船/建筑损失 | 看它如何影响上层业务解释 |
KillmailRegistry | 注册表与索引入口 | 看它如何避免重复创建、如何定位记录 |
TenantItemId | 游戏内对象到链上映射键 | 看 tenant + item_id 如何形成稳定业务键 |
关键入口函数
| 入口 | 作用 | 你要确认什么 |
|---|---|---|
create_killmail | 创建击杀记录 | 是否先做 sponsor 校验、唯一性校验、防重放 |
derived_object::claim 相关路径 | 生成确定性对象 ID | 业务 key 是否稳定、是否会被重复 claim |
| registry 读写入口 | 建立查找关系 | Registry 是否只是索引,而不是记录本体 |
最容易误读的点
Killmail不是单纯事件日志,而是可查询、可索引的共享对象Registry不是为了“多存一份数据”,而是为了稳定检索和唯一性约束- 唯一性来自业务 key +
derived_object路径,不是随手生成一个新 UID 就完事
读这一章时,最好同时带着两个视角:对象视角和索引视角。对象视角关心的是“链上到底落了什么状态,后续合约能不能直接读它”;索引视角关心的是“链下服务怎样稳定发现它、聚合它、按业务键定位它”。KillMail 之所以比普通事件更重,是因为 EVE 把它当成可长期复用的世界状态,而不是一次性广播消息。很多 Builder 第一次接触时会觉得“既然已经 emit 事件,为什么还要 share_object 一份对象”,根本原因就在这里:事件适合广播和统计,对象才适合后续合约组合、权限校验和确定性寻址。
2.1 什么是 KillMail?
在 EVE Frontier 中,每一次玩家对玩家(PvP)的击杀事件都会在链上生成一条不可篡改的记录,称为 KillMail(击杀邮件)。这不只是一个日志——它是一个具有唯一对象 ID 的共享对象,任何人都可以在链上查询。
链上结构关系:
KillmailRegistry(注册表)
└── Killmail(共享对象)
├── killer_id : 击杀者 TenantItemId
├── victim_id : 被击杀者 TenantItemId
├── kill_timestamp (Unix 秒)
├── loss_type : SHIP | STRUCTURE
└── solar_system_id : 发生地星系
2.2 KillMail 的核心数据结构
源码精读(world/sources/killmail/killmail.move)
// === Enums ===
/// 击杀类型:飞船 or 建筑
public enum LossType has copy, drop, store {
SHIP,
STRUCTURE,
}
/// 链上 KillMail 共享对象
public struct Killmail has key {
id: UID,
key: TenantItemId, // 来自 item_id + tenant 的确定性 ID
killer_id: TenantItemId,
victim_id: TenantItemId,
reported_by_character_id: TenantItemId,
kill_timestamp: u64, // Unix timestamp(秒,非毫秒!)
loss_type: LossType,
solar_system_id: TenantItemId,
}
关键设计:Killmail 的
id不是随机生成的,而是通过derived_object::claim(registry, key)从KillmailRegistry确定性派生而来,保证了item_id → object_id的映射唯一性。
TenantItemId 是什么?
// world/sources/primitives/in_game_id.move
public struct TenantItemId has copy, drop, store {
item_id: u64, // 游戏内部的业务 ID
tenant: String, // 游戏租户标识(如 "evefrontier")
}
// 创建方式
let key = in_game_id::create_key(item_id, tenant);
这个设计让同一个 item_id 可以在不同 tenant(不同服务器/游戏版本)中复用,互不冲突。
2.3 KillMail 的创建流程
全流程解析
public fun create_killmail(
registry: &mut KillmailRegistry,
admin_acl: &AdminACL, // 只有授权服务器才能创建
item_id: u64, // 击杀记录的 in-game ID
killer_id: u64,
victim_id: u64,
reported_by_character: &Character, // 提交报告的角色(必须在场)
kill_timestamp: u64, // Unix 秒
loss_type: u8, // 1=SHIP, 2=STRUCTURE
solar_system_id: u64,
ctx: &mut TxContext,
) {
// 1. 验证调用者是授权服务器
admin_acl.verify_sponsor(ctx);
// 2. 用报告者的 tenant 生成 key
let tenant = reported_by_character.tenant();
let killmail_key = in_game_id::create_key(item_id, tenant);
// 3. 防止重复创建
assert!(!registry.object_exists(killmail_key), EKillmailAlreadyExists);
// 4. 验证关键字段非零
assert!(item_id != 0, EKillmailIdEmpty);
assert!(killer_id != 0, ECharacterIdEmpty);
// ...
// 5. 从注册表派生确定性 UID(核心机制)
let killmail_uid = derived_object::claim(registry.borrow_registry_id(), killmail_key);
// 6. 创建并共享
let killmail = Killmail { id: killmail_uid, ... };
transfer::share_object(killmail);
}
流程图
游戏服务器 → create_killmail()
↓
verify_sponsor (AdminACL 检查)
↓
create_key(item_id, tenant)
↓
object_exists? → 是 → ABORT EKillmailAlreadyExists
↓ 否
derived_object::claim → 确定性 UID
↓
Killmail {..} → share_object
↓
emit KillmailCreatedEvent
2.4 事件系统与链下索引
public struct KillmailCreatedEvent has copy, drop {
key: TenantItemId,
killer_id: TenantItemId,
victim_id: TenantItemId,
reported_by_character_id: TenantItemId,
loss_type: LossType,
kill_timestamp: u64,
solar_system_id: TenantItemId,
}
KillMail 采用事件索引 + 对象存储双轨制:
| 组件 | 用途 |
|---|---|
链上共享对象 Killmail | 可被合约读取,Builder 扩展可查询 |
KillmailCreatedEvent | 供索引服务实时监听,构建排行榜/统计 |
这套双轨设计里,事件不是状态真相,而是发现机制。索引器通常先靠事件知道“有一条新 KillMail 出现了”,再根据对象 ID 或业务 key 回到链上读取对象本体。这样做的好处是,链下排行榜、成就系统、战斗报表可以高吞吐地消费事件,但真正涉及奖励发放、争议仲裁、后续扩展读写时,仍然能回到对象这一层拿到稳定状态。否则一旦只靠事件,后续 Builder 合约就没有统一的链上读取入口。
2.5 Builder 如何使用 KillMail?
场景:击杀积分奖励系统
Builder 可以监听 KillmailCreatedEvent 事件,在自己的扩展合约中接收奖励请求:
module my_pvp::kill_reward;
use world::killmail::Killmail;
use world::access::OwnerCap;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
public struct RewardPool has key {
id: UID,
balance: Balance<SUI>,
reward_per_kill: u64,
owner: address,
}
/// 玩家提交 KillMail 对象领取 SUI 奖励
pub fun claim_kill_reward(
pool: &mut RewardPool,
killmail: &Killmail, // 传入链上 KillMail 对象
character_id: ID, // 调用者的角色 ID
ctx: &mut TxContext,
) {
// 验证 killmail.killer_id 对应当前调用者的角色
// (实际需要结合 OwnerCap 验证)
assert!(balance::value(&pool.balance) >= pool.reward_per_kill, 0);
let reward = coin::take(&mut pool.balance, pool.reward_per_kill, ctx);
transfer::public_transfer(reward, ctx.sender());
}
场景:基于 KillMail 的 NFT 勋章
/// 击杀 100 次后铸造"百杀勋章"NFT
public fun mint_centurion_badge(
tracker: &KillTracker, // 自建的击杀次数追踪对象
recipient: address,
ctx: &mut TxContext,
) {
assert!(tracker.kill_count >= 100, ENotEnoughKills);
// 铸造 NFT...
}
2.6 derived_object 模式深度解析
KillMail 使用了 Sui 的 derived_object(确定性对象 ID)模式,这是 EVE Frontier World 合约中的重要设计:
// 从注册表派生确定性 UID
let killmail_uid = derived_object::claim(registry.borrow_registry_id(), killmail_key);
为什么不用 object::new(ctx)?
| 对比 | object::new(ctx) | derived_object::claim() |
|---|---|---|
| ID 来源 | 随机(基于 tx digest) | 确定性(基于 key) |
| 重复创建 | 无法防止(每次都是新 ID) | 自动防止(key 只能用一次) |
| 链下预计算 | 不可能 | 可以(已知 key 即知 ID) |
| 适用场景 | 普通对象 | 游戏资产、KillMail 等有业务 ID 的对象 |
2.7 KillMail 注册表的设计
// world/sources/registry/killmail_registry.move
public struct KillmailRegistry has key {
id: UID,
// 注意:没有其他字段!所有数据通过 derived_object 存储
}
pub fun object_exists(registry: &KillmailRegistry, key: TenantItemId): bool {
derived_object::exists(®istry.id, key)
}
这个注册表极其精简——它只是一个 UID 容器,所有的 KillMail 作为其 derived children 存在于 Sui 的状态树中。
这里的关键设计哲学是:Registry 不保存业务明细,只提供命名空间和唯一性锚点。这种写法比“Registry 里再放一个 Table<key, object_id>”更轻,因为真正的唯一性已经由 derived_object 保证了。你可以把它理解成一个“父目录”而不是“数据库表”。一旦 Builder 读懂这个思路,后面再看角色、建筑、许可、凭证之类的确定性对象模式时会轻松很多。
2.8 安全性分析
仅服务器可创建
admin_acl.verify_sponsor(ctx);
verify_sponsor 检查调用者是否在 AdminACL.authorized_sponsors 列表中。普通玩家无法伪造 KillMail——每条击杀记录都由链接到游戏服务器密钥的地址签发。
防重放
assert!(!registry.object_exists(killmail_key), EKillmailAlreadyExists);
使用 derived_object 的存在性检查,天然防止同一场战斗被重复提交。
2.9 实战练习
- 读取 KillMail:写一个 PTB(可编程交易块),传入一个 KillMail 对象 ID,打印
killer_id、victim_id、kill_timestamp - 击杀积分合约:基于 KillMail 实现一个积分系统,每次击杀飞船得 100 分,击杀建筑得 50 分
- KillMail NFT 凭证:设计一个 Builder 扩展,允许受害者(victim)凭借 KillMail 对象 ID 申请“死亡补偿金“
本章小结
| 概念 | 要点 |
|---|---|
Killmail | 不可变的共享对象,记录 PvP 击杀事件 |
TenantItemId | item_id + tenant 的复合键,支持多租户 |
derived_object | 确定性对象 ID,防止重复,支持链下预计算 |
KillmailRegistry | 用 UID 作为 derived children 的父节点 |
| 安全机制 | AdminACL 验证 + derived_object 防重放 |
下一章:链下签名 × 链上验证 —— 游戏服务器如何用密钥签名事件,合约如何验证这些签名的真实性。
实战案例 8:Builder 竞赛系统(链上排行榜 + 自动奖励)
目标: 构建一套链上竞赛框架:在固定时间窗口内,玩家通过质押积分参与竞赛,排行榜记录链上,到时间自动结算,前三名获得 NFT 奖杯和代币奖励。
状态:代码骨架。仓库内已附
Move.toml、weekly_race.move和 dApp 目录,但积分上报授权、奖励资产类型与链下结算来源仍需按你的赛事业务补齐。
对应代码目录
最小调用链
创建赛事 -> 充值奖池 -> 链下聚合积分 -> 服务器授权上报积分 -> 到期结算 -> 分发奖池与奖杯
链下职责边界
本案例最容易写坏的地方,不是排行榜本身,而是链下协作边界。建议把职责拆清:
- 链上只负责:赛事生命周期、奖池资金、最终结算、奖杯铸造
- 服务器负责:监听跳跃事件、按赛季聚合分数、为积分上报签名
- 前端负责:展示当前积分、触发管理员操作、读取结算结果
如果你暂时补不齐服务器签名和积分聚合,不要把本案例宣传成“完整自动化比赛系统”;更准确的表述是“赛事合约骨架 + 排行榜结算模型”。
需求分析
场景: 你(Builder)每周举办“矿区争夺赛“,比谁在本周通过你的星门跳跃最多次:
- 📅 赛制:每周日 00:00 UTC 开始,下周六 23:59 结束
- 📊 积分:每次跳跃 +1 积分(通过监听 GateJumped 事件上报)
- 🏆 奖励:
- 🥇 第一名:Champion NFT 奖杯 + 500 ALLY Token
- 🥈 第二名:Elite NFT 奖杯 + 200 ALLY Token
- 🥉 第三名:Contender NFT 奖杯 + 100 ALLY Token
- 💡 关键:前三名由合约根据链上积分自动决定,不可人工干预
第一部分:竞赛合约
module competition::weekly_race;
use sui::table::{Self, Table};
use sui::object::{Self, UID, ID};
use sui::clock::Clock;
use sui::coin::{Self, Coin};
use sui::sui::SUI;
use sui::balance::{Self, Balance};
use sui::event;
use sui::transfer;
use std::string::{Self, String, utf8};
// 说明:此处省略了实际项目中的 `AdminACL` / `verify_sponsor`
// 导入与链下排行榜聚合逻辑,示例只展示合约建模方式。
// ── 常量 ──────────────────────────────────────────────────
const WEEK_DURATION_MS: u64 = 7 * 24 * 60 * 60 * 1000; // 7 天
// ── 数据结构 ───────────────────────────────────────────────
/// 竞赛(每周创建一个新的)
public struct Race has key {
id: UID,
season: u64, // 第几届
start_time_ms: u64,
end_time_ms: u64,
scores: Table<address, u64>, // 玩家地址 → 积分
top3: vector<address>, // 前三名(结算后填充)
is_settled: bool,
prize_pool_sui: Balance<SUI>,
admin: address,
}
/// 奖杯 NFT
public struct TrophyNFT has key, store {
id: UID,
season: u64,
rank: u8, // 1, 2, 3
score: u64,
winner: address,
image_url: String,
}
public struct RaceAdminCap has key, store { id: UID }
// ── 事件 ──────────────────────────────────────────────────
public struct ScoreUpdated has copy, drop {
race_id: ID,
player: address,
new_score: u64,
}
public struct RaceSettled has copy, drop {
race_id: ID,
season: u64,
winner: address,
second: address,
third: address,
}
// ── 初始化 ────────────────────────────────────────────────
fun init(ctx: &mut TxContext) {
transfer::transfer(RaceAdminCap { id: object::new(ctx) }, ctx.sender());
}
/// 创建新一届竞赛
public fun create_race(
_cap: &RaceAdminCap,
season: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
let start = clock.timestamp_ms();
let race = Race {
id: object::new(ctx),
season,
start_time_ms: start,
end_time_ms: start + WEEK_DURATION_MS,
scores: table::new(ctx),
top3: vector::empty(),
is_settled: false,
prize_pool_sui: balance::zero(),
admin: ctx.sender(),
};
transfer::share_object(race);
}
/// 充值奖池
public fun fund_prize_pool(
race: &mut Race,
_cap: &RaceAdminCap,
coin: Coin<SUI>,
) {
balance::join(&mut race.prize_pool_sui, coin::into_balance(coin));
}
// ── 积分上报(由赛事服务器或炮塔/星门扩展调用) ────────────
public fun report_score(
race: &mut Race,
player: address,
score_delta: u64, // 本次增加的积分
clock: &Clock,
admin_acl: &AdminACL, // 需要游戏服务器签名
ctx: &TxContext,
) {
verify_sponsor(admin_acl, ctx); // 验证是授权服务器
assert!(!race.is_settled, ERaceEnded);
assert!(clock.timestamp_ms() <= race.end_time_ms, ERaceEnded);
if !table::contains(&race.scores, player) {
table::add(&mut race.scores, player, 0u64);
};
let score = table::borrow_mut(&mut race.scores, player);
*score = *score + score_delta;
event::emit(ScoreUpdated {
race_id: object::id(race),
player,
new_score: *score,
});
}
// ── 结算(需要链下算出前三名后传入)────────────────────────
public fun settle_race(
race: &mut Race,
_cap: &RaceAdminCap,
first: address,
second: address,
third: address,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(!race.is_settled, EAlreadySettled);
assert!(clock.timestamp_ms() >= race.end_time_ms, ERaceNotEnded);
// 验证链上积分(防止传入假排名)
let s1 = *table::borrow(&race.scores, first);
let s2 = *table::borrow(&race.scores, second);
let s3 = *table::borrow(&race.scores, third);
assert!(s1 >= s2 && s2 >= s3, EInvalidRanking);
race.is_settled = true;
race.top3 = vector[first, second, third];
// 分发奖池:50% 给第一,30% 给第二,20% 给第三
let total = balance::value(&race.prize_pool_sui);
let prize1 = coin::take(&mut race.prize_pool_sui, total * 50 / 100, ctx);
let prize2 = coin::take(&mut race.prize_pool_sui, total * 30 / 100, ctx);
let prize3 = coin::take(&mut race.prize_pool_sui, balance::value(&race.prize_pool_sui), ctx);
transfer::public_transfer(prize1, first);
transfer::public_transfer(prize2, second);
transfer::public_transfer(prize3, third);
// 铸造奖杯 NFT
mint_trophy(race.season, 1, s1, first, ctx);
mint_trophy(race.season, 2, s2, second, ctx);
mint_trophy(race.season, 3, s3, third, ctx);
event::emit(RaceSettled {
race_id: object::id(race),
season: race.season,
winner: first,
second,
third,
});
}
fun mint_trophy(
season: u64,
rank: u8,
score: u64,
winner: address,
ctx: &mut TxContext,
) {
let (name, image_url) = match(rank) {
1 => (b"Champion Trophy", b"https://assets.example.com/trophies/gold.png"),
2 => (b"Elite Trophy", b"https://assets.example.com/trophies/silver.png"),
_ => (b"Contender Trophy", b"https://assets.example.com/trophies/bronze.png"),
};
let trophy = TrophyNFT {
id: object::new(ctx),
season,
rank,
score,
winner,
image_url: utf8(image_url),
};
transfer::public_transfer(trophy, winner);
}
const ERaceEnded: u64 = 0;
const EAlreadySettled: u64 = 1;
const ERaceNotEnded: u64 = 2;
const EInvalidRanking: u64 = 3;
第二部分:结算脚本(链下排名 + 链上结算)
// scripts/settle-race.ts
import { SuiClient } from "@mysten/sui/client"
import { Transaction } from "@mysten/sui/transactions"
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519"
const RACE_PKG = "0x_COMPETITION_PACKAGE_"
const RACE_ID = "0x_RACE_ID_"
async function settleRace() {
const client = new SuiClient({ url: "https://fullnode.testnet.sui.io:443" })
const adminKeypair = Ed25519Keypair.fromSecretKey(/* ... */)
// 1. 从链上读取所有积分(通过 ScoreUpdated 事件聚合)
const scoreMap = new Map<string, number>()
let cursor = null
do {
const page = await client.queryEvents({
query: { MoveEventType: `${RACE_PKG}::weekly_race::ScoreUpdated` },
cursor,
limit: 200,
})
for (const event of page.data) {
const { player, new_score } = event.parsedJson as any
scoreMap.set(player, Number(new_score)) // 取最新值
}
cursor = page.nextCursor
} while (cursor)
// 2. 排序找出前三名
const sorted = [...scoreMap.entries()]
.sort((a, b) => b[1] - a[1])
if (sorted.length < 3) {
console.log("参与人数不足,无法结算")
return
}
const [first, second, third] = sorted.slice(0, 3).map(([addr]) => addr)
console.log(`第一:${first}(${sorted[0][1]} 积分)`)
console.log(`第二:${second}(${sorted[1][1]} 积分)`)
console.log(`第三:${third}(${sorted[2][1]} 积分)`)
// 3. 提交结算交易
const tx = new Transaction()
tx.moveCall({
target: `${RACE_PKG}::weekly_race::settle_race`,
arguments: [
tx.object(RACE_ID),
tx.object("ADMIN_CAP_ID"),
tx.pure.address(first),
tx.pure.address(second),
tx.pure.address(third),
tx.object("0x6"), // Clock
],
})
const result = await client.signAndExecuteTransaction({
signer: adminKeypair,
transaction: tx,
})
console.log("结算成功!奖杯已发放。Tx:", result.digest)
}
settleRace()
第三部分:实时排行榜 dApp
// src/LeaderboardApp.tsx
import { useEffect, useState } from 'react'
import { useRealtimeEvents } from './hooks/useRealtimeEvents'
const RACE_PKG = "0x_COMPETITION_PACKAGE_"
interface ScoreEntry {
rank: number
address: string
score: number
}
export function LeaderboardApp() {
const [scores, setScores] = useState<Map<string, number>>(new Map())
const [timeLeft, setTimeLeft] = useState('')
const raceEnd = new Date('2026-03-08T00:00:00Z').getTime()
// 实时订阅积分更新
const events = useRealtimeEvents<{ player: string; new_score: string }>(
`${RACE_PKG}::weekly_race::ScoreUpdated`
)
useEffect(() => {
const updated = new Map(scores)
for (const e of events) {
updated.set(e.player, Number(e.new_score))
}
setScores(updated)
}, [events])
// 倒计时
useEffect(() => {
const timer = setInterval(() => {
const diff = raceEnd - Date.now()
if (diff <= 0) { setTimeLeft('已结束'); return }
const d = Math.floor(diff / 86400000)
const h = Math.floor((diff % 86400000) / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
setTimeLeft(`${d}天 ${h}时 ${m}分`)
}, 1000)
return () => clearInterval(timer)
}, [])
const sorted: ScoreEntry[] = [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([address, score], i) => ({ rank: i + 1, address, score }))
const medals = ['🥇', '🥈', '🥉']
return (
<div className="leaderboard">
<header>
<h1>🏆 第一届星门跳跃竞赛</h1>
<div className="countdown">
⏳ 剩余时间:<strong>{timeLeft}</strong>
</div>
</header>
<table className="ranking-table">
<thead>
<tr><th>排名</th><th>玩家</th><th>跳跃次数</th></tr>
</thead>
<tbody>
{sorted.map(({ rank, address, score }) => (
<tr key={address} className={rank <= 3 ? 'top3' : ''}>
<td>{medals[rank - 1] ?? rank}</td>
<td>{address.slice(0, 6)}...{address.slice(-4)}</td>
<td><strong>{score}</strong> 次</td>
</tr>
))}
{sorted.length === 0 && (
<tr><td colSpan={3}>暂无数据,等待第一次跳跃...</td></tr>
)}
</tbody>
</table>
</div>
)
}
🎯 完整回顾
合约层
├── weekly_race.move
│ ├── Race(共享对象,每届一个)
│ ├── TrophyNFT(奖杯对象)
│ ├── create_race() ← Admin 创建
│ ├── fund_prize_pool() ← Admin 充奖池
│ ├── report_score() ← 服务器上报积分(AdminACL 验证)
│ └── settle_race() ← Admin 传入前三名,合约验证并结算
结算脚本
└── settle-race.ts
├── QueryEvents 聚合所有积分
├── 排序计算前三名
└── 提交 settle_race() 交易
dApp 层
└── LeaderboardApp.tsx
├── subscribeEvent 实时更新排行榜
└── 竞赛倒计时
🔧 扩展练习
- 防刷分:在
report_score中限速(每个玩家每分钟最多上报 60 积分) - 公开验证:将每次积分上报的原始数据哈希也存链上,让任何人可以验算最终排名
- 赛季制:Admin 无法提前结束当届竞赛,合约强制执行时间轴
📚 关联文档
实战案例 10:太空资源争夺战(综合实战)
目标: 整合本课程所有知识,构建一个微型完整游戏:两个联盟争夺一片矿区的控制权,包含炮塔攻防、星门收费、物品存储、代币奖励和实时战报 dApp。
状态:综合案例。正文整合多个模块,是检验你是否真正把全书前半段串起来的最好案例。
对应代码目录
最小调用链
发放势力 NFT -> 星门/炮塔按势力校验 -> 玩家采矿获奖 -> WAR Token 发放 -> dApp 展示战况
项目全景
┌─────────────────────────────────────────────┐
│ 太空资源争夺战 │
│ │
│ 联盟 A 联盟 B │
│ Territory (炮塔 ×2) Territory (炮塔 ×2)│
│ ↑ ↑ │
│ ┌─[Gate A1]─── 中立矿区 ───[Gate B1]─┐ │
│ │ (存储箱 + 资源) │ │
│ └─────────────────────────────────────┘ │
│ │
│ 战斗规则: │
│ • 进入中立矿区需要通过对方炮塔检查 │
│ • 持有"势力 NFT"才能通过己方星门 │
│ • 矿区资源每小时刷新,先到先得 │
│ • 每次采矿获得 WAR Token(联盟代币) │
└─────────────────────────────────────────────┘
合约架构设计
war_game/
├── Move.toml
└── sources/
├── faction_nft.move # 势力 NFT(加入联盟的凭证)
├── war_token.move # WAR Token(战争代币)
├── faction_gate.move # 星门扩展(势力检查)
├── faction_turret.move # 炮塔扩展(enemy 检测)
├── mining_depot.move # 矿区存储箱扩展(资源采集)
└── war_registry.move # 游戏注册表(全局状态)
第一部分:核心合约
faction_nft.move
module war_game::faction_nft;
use sui::object::{Self, UID};
use sui::transfer;
use std::string::{Self, String, utf8};
public struct FACTION_NFT has drop {}
/// 势力枚举
const FACTION_ALPHA: u8 = 0;
const FACTION_BETA: u8 = 1;
/// 势力 NFT(入盟证明)
public struct FactionNFT has key, store {
id: UID,
faction: u8, // 0 = Alpha, 1 = Beta
member_since_ms: u64,
name: String,
}
public struct WarAdminCap has key, store { id: UID }
public fun enlist(
_admin: &WarAdminCap,
faction: u8,
member_name: vector<u8>,
recipient: address,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(faction == FACTION_ALPHA || faction == FACTION_BETA, EInvalidFaction);
let nft = FactionNFT {
id: object::new(ctx),
faction,
member_since_ms: clock.timestamp_ms(),
name: utf8(member_name),
};
transfer::public_transfer(nft, recipient);
}
public fun get_faction(nft: &FactionNFT): u8 { nft.faction }
public fun is_alpha(nft: &FactionNFT): bool { nft.faction == FACTION_ALPHA }
public fun is_beta(nft: &FactionNFT): bool { nft.faction == FACTION_BETA }
const EInvalidFaction: u64 = 0;
war_token.move
module war_game::war_token;
/// WAR Token(标准 Coin 设计,参考 Chapter 14)
public struct WAR_TOKEN has drop {}
fun init(witness: WAR_TOKEN, ctx: &mut TxContext) {
let (treasury, metadata) = sui::coin::create_currency(
witness, 6, b"WAR", b"War Token",
b"Earned through combat and mining in the Space Resource War",
option::none(), ctx,
);
transfer::public_transfer(treasury, ctx.sender());
transfer::public_freeze_object(metadata);
}
faction_gate.move(星门扩展)
module war_game::faction_gate;
use war_game::faction_nft::{Self, FactionNFT};
use world::gate::{Self, Gate};
use world::character::Character;
use sui::clock::Clock;
use sui::tx_context::TxContext;
public struct AlphaGateAuth has drop {}
public struct BetaGateAuth has drop {}
/// Alpha 联盟星门:只允许 Alpha 成员通过
public fun alpha_gate_jump(
source_gate: &Gate,
dest_gate: &Gate,
character: &Character,
faction_nft: &FactionNFT,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(faction_nft::is_alpha(faction_nft), EWrongFaction);
gate::issue_jump_permit(
source_gate, dest_gate, character, AlphaGateAuth {},
clock.timestamp_ms() + 30 * 60 * 1000, ctx,
);
}
/// Beta 联盟星门
public fun beta_gate_jump(
source_gate: &Gate,
dest_gate: &Gate,
character: &Character,
faction_nft: &FactionNFT,
clock: &Clock,
ctx: &mut TxContext,
) {
assert!(faction_nft::is_beta(faction_nft), EWrongFaction);
gate::issue_jump_permit(
source_gate, dest_gate, character, BetaGateAuth {},
clock.timestamp_ms() + 30 * 60 * 1000, ctx,
);
}
const EWrongFaction: u64 = 0;
mining_depot.move(矿区核心)
module war_game::mining_depot;
use war_game::faction_nft::{Self, FactionNFT};
use war_game::war_token::WAR_TOKEN;
use world::storage_unit::{Self, StorageUnit};
use world::character::Character;
use sui::coin::{Self, TreasuryCap};
use sui::clock::Clock;
use sui::object::{Self, UID};
use sui::event;
public struct MiningAuth has drop {}
/// 矿区状态
public struct MiningDepot has key {
id: UID,
resource_count: u64, // 当前可采数量
last_refresh_ms: u64, // 上次刷新时间
refresh_amount: u64, // 每次刷新补充量
refresh_interval_ms: u64, // 刷新间隔
alpha_total_mined: u64,
beta_total_mined: u64,
}
public struct ResourceMined has copy, drop {
miner: address,
faction: u8,
amount: u64,
faction_total: u64,
}
/// 采矿(同时检查势力 NFT 并发放 WAR Token 奖励)
public fun mine(
depot: &mut MiningDepot,
storage_unit: &mut StorageUnit,
character: &Character,
faction_nft: &FactionNFT, // 需要势力认证
war_treasury: &mut TreasuryCap<WAR_TOKEN>,
amount: u64,
clock: &Clock,
ctx: &mut TxContext,
) {
// 自动刷新资源
maybe_refresh(depot, clock);
assert!(amount > 0 && amount <= depot.resource_count, EInsufficientResource);
depot.resource_count = depot.resource_count - amount;
// 根据势力更新统计
let faction = faction_nft::get_faction(faction_nft);
if faction == 0 {
depot.alpha_total_mined = depot.alpha_total_mined + amount;
} else {
depot.beta_total_mined = depot.beta_total_mined + amount;
};
// 取出资源(从 SSU)
// storage_unit::withdraw_batch(storage_unit, character, MiningAuth {}, RESOURCE_TYPE_ID, amount, ctx)
// 发放 WAR Token 奖励(每单位资源 = 10 WAR)
let war_reward = amount * 10_000_000; // 10 WAR per unit,6 decimals
let war_coin = sui::coin::mint(war_treasury, war_reward, ctx);
sui::transfer::public_transfer(war_coin, ctx.sender());
event::emit(ResourceMined {
miner: ctx.sender(),
faction,
amount,
faction_total: if faction == 0 { depot.alpha_total_mined } else { depot.beta_total_mined },
});
}
fun maybe_refresh(depot: &mut MiningDepot, clock: &Clock) {
let now = clock.timestamp_ms();
if now >= depot.last_refresh_ms + depot.refresh_interval_ms {
depot.resource_count = depot.resource_count + depot.refresh_amount;
depot.last_refresh_ms = now;
}
}
const EInsufficientResource: u64 = 0;
第二部分:战报实时 dApp
// src/WarDashboard.tsx
import { useState, useEffect } from 'react'
import { useRealtimeEvents } from './hooks/useRealtimeEvents'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useConnection } from '@evefrontier/dapp-kit'
const WAR_PKG = "0x_WAR_PACKAGE_"
const DEPOT_ID = "0x_DEPOT_ID_"
interface DepotState {
resource_count: string
alpha_total_mined: string
beta_total_mined: string
last_refresh_ms: string
}
interface MiningEvent {
miner: string
faction: string
amount: string
faction_total: string
}
const FACTION_COLOR = { '0': '#3B82F6', '1': '#EF4444' } // Alpha=蓝, Beta=红
const FACTION_NAME = { '0': 'Alpha 联盟', '1': 'Beta 联盟' }
export function WarDashboard() {
const { isConnected, currentAddress } = useConnection()
const client = useCurrentClient()
const [depot, setDepot] = useState<DepotState | null>(null)
const [nextRefreshIn, setNextRefreshIn] = useState(0)
// 加载矿区状态
const loadDepot = async () => {
const obj = await client.getObject({ id: DEPOT_ID, options: { showContent: true } })
if (obj.data?.content?.dataType === 'moveObject') {
setDepot(obj.data.content.fields as DepotState)
}
}
useEffect(() => { loadDepot() }, [])
// 刷新倒计时
useEffect(() => {
if (!depot) return
const timer = setInterval(() => {
const refreshInterval = 60 * 60 * 1000 // 1小时
const nextRefresh = Number(depot.last_refresh_ms) + refreshInterval
setNextRefreshIn(Math.max(0, nextRefresh - Date.now()))
}, 1000)
return () => clearInterval(timer)
}, [depot])
// 实时战报
const miningEvents = useRealtimeEvents<MiningEvent>(
`${WAR_PKG}::mining_depot::ResourceMined`,
{ maxEvents: 20 }
)
useEffect(() => {
if (miningEvents.length > 0) loadDepot() // 有采矿事件就刷新矿区状态
}, [miningEvents])
// 计算领土控制百分比
const alpha = Number(depot?.alpha_total_mined ?? 0)
const beta = Number(depot?.beta_total_mined ?? 0)
const total = alpha + beta
const alphaPct = total > 0 ? Math.round(alpha * 100 / total) : 50
return (
<div className="war-dashboard">
<h1>⚔️ 太空资源争夺战</h1>
{/* 势力控制率 */}
<section className="control-bar-section">
<div className="control-labels">
<span style={{ color: FACTION_COLOR['0'] }}>
Alpha {alphaPct}%
</span>
<span style={{ color: FACTION_COLOR['1'] }}>
{100 - alphaPct}% Beta
</span>
</div>
<div className="control-bar">
<div
className="alpha-bar"
style={{ width: `${alphaPct}%`, background: FACTION_COLOR['0'] }}
/>
</div>
</section>
{/* 矿区状态 */}
<section className="depot-status">
<div className="stat-card">
<span>⛏ 剩余资源</span>
<strong>{depot?.resource_count ?? '-'}</strong>
</div>
<div className="stat-card">
<span>⏳ 下次刷新</span>
<strong>{Math.ceil(nextRefreshIn / 60000)} 分钟</strong>
</div>
<div className="stat-card alpha">
<span style={{ color: FACTION_COLOR['0'] }}>Alpha 采矿总量</span>
<strong>{depot?.alpha_total_mined ?? '-'}</strong>
</div>
<div className="stat-card beta">
<span style={{ color: FACTION_COLOR['1'] }}>Beta 采矿总量</span>
<strong>{depot?.beta_total_mined ?? '-'}</strong>
</div>
</section>
{/* 实时战报 */}
<section className="battle-log">
<h3>📡 实时战报</h3>
{miningEvents.length === 0 ? (
<p className="quiet">矿区沉寂中...</p>
) : (
<ul>
{miningEvents.map((e, i) => (
<li
key={i}
style={{ borderLeftColor: FACTION_COLOR[e.faction as '0' | '1'] }}
>
<span className="faction-tag" style={{ color: FACTION_COLOR[e.faction as '0' | '1'] }}>
[{FACTION_NAME[e.faction as '0' | '1']}]
</span>
{e.miner.slice(0, 8)}... 采集了 {e.amount} 单位资源
</li>
))}
</ul>
)}
</section>
</div>
)
}
完整部署流程
# 1. 编译并发布合约
cd war_game
sui move build
sui client publish --gas-budget 200000000
# 2. 初始化游戏对象
# 运行 scripts/init-game.ts:创建 MiningDepot、注册星门/炮塔扩展
# 3. 测试角色入盟
# scripts/enlist-player.ts:给测试玩家发放 FactionNFT
# 4. 启动 dApp
cd dapp
npm run dev
🎯 知识综合运用
| 本课程知识点 | 在本例的应用 |
|---|---|
| Chapter 3:Witness 模式 | MiningAuth, AlphaGateAuth, BetaGateAuth |
| Chapter 4:组件扩展注册 | 炮塔 + 星门 + 存储箱均有独立扩展 |
| Chapter 5:dApp + Hooks | useRealtimeEvents 驱动战报实时更新 |
| Chapter 11:OwnerCap | 联盟 Leader 持有各组件的 OwnerCap |
| Chapter 12:事件系统 | ResourceMined 事件驱动 dApp |
| Chapter 14:代币经济 | WAR Token 作为采矿奖励 |
| Chapter 17:安全审计 | 权限验证 + 资源不超量扣减 |
| Chapter 23:发布流程 | 多合约同时发布 + 初始化脚本 |
| Chapter 8:赞助交易 | 炮塔攻击验证需服务器签名 |
| Chapter 9:GraphQL | 实时查询矿区和战役状态 |
| Chapter 15:跨合约 | mining_depot 调用 faction_nft 的只读视图 |
| Chapter 13:NFT | FactionNFT 的 Display 展示势力信息 |
🔧 进阶挑战
- 联盟驱逐:Leader 可以将不活跃成员的 FactionNFT 撤销(转回 Admin 或销毁)
- 资源市场:在矿区附近部署 SSU,玩家可以把挖到的资源卖回给联盟换取更多 WAR Token
- 战争结算:7 天后,采矿总量领先的联盟自动获得奖池,合约自动结算分红
🎓 恭喜!你已完成所有实战案例
至此,你已经:
- ✅ 用 Move 从头编写了 10 种不同类型的合约
- ✅ 构建了 10 个完整的前端 dApp
- ✅ 掌握了从 NFT、市场到 DAO、竞赛的完整技术栈
- ✅ 理解了链上与链下的协同设计模式
你现在具备了在 EVE Frontier 中构建完整商业产品的所有技术能力。
📚 全课程关联文档
第33章:EVE Vault 钱包概述——zkLogin 原理与设计
学习目标:理解 EVE Vault 是什么,它为什么使用 zkLogin 而不是传统私钥,以及 zkLogin 的完整密码学工作原理。
状态:源码导读。密码学细节以 EVE Vault 当前实现和 Sui zkLogin 机制为准,正文偏重架构理解。
最小调用链
FusionAuth/OAuth 登录 -> 回调拿 code -> 交换 token -> 派生 zkLogin 地址 -> 保存登录态 -> 钱包可签名
对应代码目录
1. EVE Vault 是什么?
EVE Vault 是 EVE Frontier 的专属 Chrome 浏览器扩展钱包,基于以下技术栈构建:
| 层 | 技术 | 用途 |
|---|---|---|
| 扩展框架 | WXT + Chrome MV3 | 跨浏览器扩展构建 |
| UI 框架 | React + TanStack Router | 弹窗和审批页面 |
| 状态管理 | Zustand + Chrome Storage | 持久化用户状态 |
| 区块链 | Sui Wallet Standard | dApp 发现与交互协议 |
| 身份认证 | EVE Frontier FusionAuth (OAuth) | EVE 游戏账户登录 |
| 地址派生 | Sui zkLogin + Enoki | 从 OAuth 身份派生链上地址 |
核心设计理念:玩家无需管理私钥——用 EVE Frontier 的游戏账户登录,自动获得 Sui 区块链地址。
这章最重要的不是记住一串密码学名词,而是先看清它到底在替谁解决什么问题:
- 对玩家:降低钱包门槛
- 对 Builder:降低 onboarding 成本
- 对产品:把“游戏身份”和“链上身份”尽量收拢成一条体验链
2. 为什么不用传统私钥?
普通 Sui 钱包的痛点:
❌ 玩家需要保管助记词(12-24个单词)
❌ 助记词泄露 = 资产全损
❌ 游戏账户与链上身份是两套独立系统
❌ 新用户的学习门槛极高
EVE Vault 的方案:
✅ 用 EVE Frontier 游戏账户(邮箱登录)直接对应链上地址
✅ 地址由零知识证明(zkLogin)确定性派生
✅ 即使 OAuth token 被盗,需要 ZK 证明才能签名
✅ 游戏账户 = 链上身份,用户体验无缝衔接
这里真正的产品意义
不是“更先进”,而是让大量原本不会装传统钱包的玩家也能进入链上交互。
对 EVE Builder 来说,这直接影响:
- 连接流程能有多短
- 首次使用的心理成本有多低
- 赞助交易能否把最后一层摩擦也削掉
3. zkLogin 原理精讲
3.1 核心概念
zkLogin 是 Sui 原生支持的一种签名方案,它将 OAuth 身份与区块链地址绑定:
【传统钱包】
私钥 k → 公钥 PK → 地址 A
签名 = ed25519_sign(k, tx)
【zkLogin 钱包】
JWT (OAuth token) + Ephemeral Key → ZK Proof → 签名
↑
这个证明证明了"我持有有效的 JWT 且 JWT 对应地址 A"
3.2 zkLogin 地址公式
zkLogin_address = hash(
iss, // JWT 颁发者(如 "https://auth.evefrontier.com")
sub, // 用户的唯一 ID(EVE 账户 ID)
aud, // OAuth 客户端 ID
user_salt, // Enoki 保存的用户盐值(防止 sub 泄漏关联链上身份)
)
关键安全性:攻击者即使知道你的 EVE 账户 ID,没有 user_salt 就无法算出你的链上地址。user_salt 由 Enoki(Mysten Labs 的 zkLogin 服务)负责保管。
zkLogin 最值得抓住的直觉
不是“我有一个长期私钥”,而是:
我用 OAuth 身份证明“我是谁”,再用临时密钥证明“这次操作是我授权的”。
这也是为什么 zkLogin 体系里会同时出现:
- JWT
- salt
- 临时密钥
- ZK proof
它们分别在证明不同的事。
3.3 临时密钥对(Ephemeral Key Pair)
zkLogin 使用一个临时密钥对来执行实际签名:
登录流程:
1. 生成临时 ed25519 密钥对(有效期 = Sui Epoch,约 24h)
2. 将临时公钥的 nonce 嵌入 OAuth 请求
3. OAuth server 在 JWT 中返回含 nonce 的 token
4. 用临时私钥签名交易
5. 提交 ZK 证明 + 临时签名 → Sui 验证
临时私钥存储在 EVE Vault 的 Keeper 安全容器中(见第34章)。
为什么一定要有临时密钥?
因为 zkLogin 并不是让 OAuth token 直接变成签名器。临时密钥的作用是把“登录态”和“具体签名动作”连接起来,同时把风险窗口限制在一个较短周期内。
3.4 ZK Proof 生成
// packages/shared/src/wallet/zkProof.ts
interface ZkProofParams {
jwtRandomness: string; // 随机盐,防止 nonce 推算
maxEpoch: string; // 临时密钥的最大有效 Epoch
ephemeralPublicKey: PublicKey; // 临时公钥(嵌入 JWT nonce)
idToken: string; // 从 FusionAuth 获取的 JWT
enokiApiKey: string; // Enoki 服务密钥
network?: string; // devnet | testnet | mainnet
}
ZK Proof 的生成步骤:
- 收集上述参数
- 调用 Sui ZK Prover 端点(Enoki 托管)
- 返回包含
proofPoints、issBase64Details、headerBase64的 ZK 证明
3.5 JWT Nonce 的构造
zkLogin 最关键的设计是把临时公钥“嵌入“ JWT 中,这通过 nonce 字段实现:
// nonce = poseidon_hash(ephemeral_public_key, max_epoch, randomness)
// 这一步在请求 OAuth 前完成
const nonce = generateNonce(ephemeralPublicKey, maxEpoch, randomness);
// OAuth URL 中传入 nonce
const authUrl = `${fusionAuthUrl}/oauth2/authorize?`
+ `client_id=${CLIENT_ID}`
+ `&response_type=code`
+ `&nonce=${nonce}` // ← FusionAuth 会把这个 nonce 放进 JWT
+ `&scope=openid+profile+email`;
FusionAuth 在返回的 JWT(id_token)中包含:
{
"iss": "https://auth.evefrontier.com",
"sub": "user-12345", ← EVE 账户唯一 ID
"aud": "your-client-id",
"nonce": "H5SmVjkG...", ← 含临时公钥信息
"exp": 1712345678
}
Sui 的 ZK 验证器通过检查 nonce 中嵌入的临时公钥,确认签名确实来自该临时密钥对。
3.6 zkLogin 地址的 TypeScript 计算
import { computeZkLoginAddress } from "@mysten/sui/zklogin";
// 从 Enoki API 获取 user_salt 和地址
const { address, salt } = await fetch("https://api.enoki.mystenlabs.com/v1/zklogin", {
method: "POST",
headers: { "Authorization": `Bearer ${ENOKI_API_KEY}` },
body: JSON.stringify({ jwt: idToken }),
}).then(r => r.json());
// 验证:本地计算地址(与 Enoki 返回的地址相同)
const localAddress = computeZkLoginAddress({
claimName: "sub",
claimValue: decodedJwt.sub, // EVE 账户 ID
iss: decodedJwt.iss,
aud: decodedJwt.aud,
userSalt: BigInt(salt),
});
console.assert(address === localAddress);
4. EVE Vault 的认证流程
用户点击 "Sign in with EVE Vault"
│
▼
生成临时 Ed25519 密钥对 + JWT Nonce
│
▼
打开 FusionAuth OAuth 页面(chrome.identity API)
│
▼
用户用 EVE Frontier 账户登录
│
▼
FusionAuth 返回 JWT(包含 nonce)
│
▼
调用 Enoki API → 获取 user_salt + zkLogin 地址
│
▼
调用 ZK Prover → 生成 ZK Proof
│
▼
Popup 显示 zkLogin 地址 + SUI 余额
│
▼
dApp 调用 wallet.connect() → 获取地址 → 可以发交易
这条流程里最关键的不是步骤多,而是职责分明
- FusionAuth 负责确认用户是谁
- Enoki 负责辅助 zkLogin 地址和盐值
- Prover 负责生成可验证证明
- Vault 负责把这些东西组织成钱包能力
只要你把每层职责分清,这套机制就不会显得神秘。
5. 多网络支持
EVE Vault 支持同时连接多个测试网,可随时切换:
// packages/shared/src/types/wallet.ts
export class EveVaultWallet implements Wallet {
#currentChain: SuiChain = SUI_TESTNET_CHAIN;
get chains(): Wallet["chains"] {
return [SUI_TESTNET_CHAIN, SUI_DEVNET_CHAIN] as `sui:${string}`[];
}
// ...
}
弹窗左下角的网络切换器让玩家在 Devnet(开发测试)和 Testnet(演示/Pre-launch)之间切换,切换后地址相同(因为派生公式不含网络参数),但查询的节点会切换。
6. EVE Vault vs 传统 Sui 钱包对比
| 特性 | Sui Wallet / OKX | EVE Vault |
|---|---|---|
| 需要助记词 | ✅ 是 | ❌ 否 |
| 基于 OAuth 登录 | ❌ 否 | ✅ 是(EVE 账户) |
| 私钥存储位置 | 用户本地 | 无私钥(zkLogin) |
| 地址确定性 | 取决于私钥 | JWT + salt 确定性派生 |
| 签名方案 | ed25519 / secp256k1 | zkLogin(ZK Proof + 临时签名) |
| 赞助交易 | 部分支持 | ✅ EVE Frontier 原生支持 |
| dApp discover | Wallet Standard | Wallet Standard + EVE 扩展特性 |
7. 安全模型
Keeper 机制
临时私钥不存储在 chrome.storage(可被 JS 读取),而是存在 Keeper(一个隔离的 hidden document)中:
┌─────────────────────────────────────────┐
│ Chrome Extension 沙箱 │
│ │
│ Background Service Worker │
│ ↕ chrome.runtime.sendMessage │
│ Keeper (hidden iframe/document) │
│ ← 临时私钥仅在此内存中 │
│ ← 不写入 chrome.storage │
│ ← 关闭浏览器即清除 │
└─────────────────────────────────────────┘
锁定机制
浏览器关闭或一段时间不操作后,Keeper 自动清除临时私钥(“锁定“状态)。重新解锁需要重新生成 ZK Proof(有缓存,通常几秒内完成)。
8. 对 Builder 的意义
作为 Builder,你的 dApp 用户将通过 EVE Vault 连接,以下是关键影响:
- 无需私钥管理 UX:用户直接用游戏账户连接,降低 onboarding 门槛
- 赞助交易原生支持:EVE Vault 实现了
sign_sponsored_transaction,Builder 可以替用户付 Gas - 地址稳定性:玩家的链上地址与其 EVE 账户绑定,不会因“换设备“而改变
- 多网络:开发时用 Devnet,上线用 Testnet,地址不变
本章小结
| 概念 | 要点 |
|---|---|
| zkLogin | 无私钥的零知识签名方案,基于 OAuth JWT |
user_salt | Enoki 保管,防止 OAuth ID 与链上地址关联 |
| 临时密钥对 | 每次 Epoch 重新生成,Keeper 安全容器存储 |
| ZK Proof | 向 Enoki 请求,证明“合法 JWT 持有者“ |
| FusionAuth | EVE Frontier 的 OAuth 身份提供商 |
下一章:EVE Vault 技术架构与开发部署 —— Chrome MV3 的 5 个脚本层、消息通信协议、以及如何在本地构建和加载扩展。
第34章:EVE Vault 技术架构与开发部署
学习目标:理解 EVE Vault 的 Chrome MV3 架构(5 层脚本、消息协议、Keeper 安全容器),掌握本地构建和调试扩展的完整流程,以及 Monorepo 中各包的分工。
状态:源码导读。适合边读文档边打开扩展入口点与 background 代码核对消息流。
最小调用链
页面/内容脚本请求 -> background 分发消息 -> keeper 保护敏感状态 -> 审批页签名 -> 响应回发到调用方
对应代码目录
1. 项目结构(Monorepo)
evevault/
├── apps/
│ ├── extension/ # Chrome MV3 扩展(主体)
│ │ ├── entrypoints/ # WXT 入口点(每个 = 一个独立页面/脚本)
│ │ │ ├── background.ts # Service Worker(后台常驻)
│ │ │ ├── content.ts # 内容脚本(每个页面注入)
│ │ │ ├── injected.ts # 页面上下文脚本(注册钱包)
│ │ │ ├── popup/ # 扩展弹窗
│ │ │ ├── sign_transaction/ # 交易审批页
│ │ │ ├── sign_sponsored_transaction/ # 赞助交易审批页
│ │ │ ├── sign_personal_message/ # 消息签名审批页
│ │ │ ├── sign_and_execute_transaction/
│ │ │ └── keeper/ # 安全密钥容器
│ │ └── src/
│ │ ├── features/ # 功能模块(auth、wallet)
│ │ ├── lib/ # 核心库(adapters、background、utils)
│ │ └── routes/ # React 路由(TanStack Router)
│ └── web/ # Web 版本(即将推出)
└── packages/
└── shared/ # 跨 app 共享:类型、Sui 客户端、工具函数
└── src/
├── types/ # 消息类型、钱包类型、认证类型
├── sui/ # SuiClient、GraphQL 客户端
└── auth/ # Enoki 集成、zkLogin 工具
构建工具:Bun(包管理)+ Turborepo(构建缓存)+ WXT(扩展框架)
Monorepo 这里真正值得理解的是:
Vault 不是一个单页扩展,而是一组彼此隔离、通过消息协议协作的子系统。
所以看目录时,最好不要只看“文件在哪”,而是看“哪层持有哪些权力”。
2. Chrome MV3 的 5 层脚本架构
这 5 层架构真正解决的是浏览器扩展里的安全矛盾:
- dApp 需要一个好接入的钱包接口
- 但敏感状态又不能暴露给任意页面脚本
所以架构被刻意拆成:
- 页面层可发现
- 中转层可通信
- 后台层可调度
- Keeper 层可保密
- 审批页可让用户做最终确认
Chrome MV3 扩展中各脚本的隔离边界和通信方式:
┌──────────────────── 浏览器 Tab(网页)───────────────────────┐
│ │
│ dApp(网页 JavaScript) │
│ ↕ wallet-standard API(同进程调用) │
│ injected.ts ← 由 content.ts 注入到页面进程 │
│ EveVaultWallet 类注册到 @mysten/wallet-standard │
└───────────────────────────────────────────────────────────────┘
↕ window.postMessage(跨进程)
┌──────────────────── Chrome Extension 进程 ────────────────────┐
│ content.ts(内容脚本) │
│ 转发:页面 → background │
│ 转发:background → 页面 │
└───────────────────────────────────────────────────────────────┘
↕ chrome.runtime.sendMessage
┌──────────────────── Service Worker ────────────────────────────┐
│ background.ts │
│ OAuth 流程、Token 交换、Storage 管理 │
│ 处理签名请求(转发给 Keeper) │
│ ↕ chrome.runtime Port │
│ keeper.ts(隐藏 iframe,内存安全容器) │
│ 存储临时私钥(不写 chrome.storage) │
└─────────────────────────────────────────────────────────────────┘
↕ chrome.runtime.sendMessage
┌──────────────────── Extension Pages ───────────────────────────┐
│ popup/ ← 点击扩展图标显示 │
│ sign_transaction/ ← 交易审批弹窗 │
│ sign_sponsored_transaction/ ← 赞助交易审批 │
│ sign_personal_message/ ← 消息签名审批 │
└─────────────────────────────────────────────────────────────────┘
3. 消息系统(Message Protocol)
消息协议为什么是扩展系统的生命线?
因为这套扩展不是靠函数直接互调,而是靠跨进程消息驱动。
一旦消息类型、字段语义或响应约定变得混乱,就会出现最难排查的问题:
- 页面看起来请求发出去了
- background 也收到了
- 但 keeper 或审批页返回的语义已经不一致
所以这类系统里,消息协议本身就是“接口标准”。
所有跨进程通信通过标准化的消息类型定义:
// packages/shared/src/types/messages.ts
// 认证相关消息
export enum AuthMessageTypes {
AUTH_SUCCESS = "auth_success",
AUTH_ERROR = "auth_error",
EXT_LOGIN = "ext_login",
REFRESH_TOKEN = "refresh_token",
}
// Vault(加密容器)消息
export enum VaultMessageTypes {
UNLOCK_VAULT = "UNLOCK_VAULT",
LOCK = "LOCK",
CREATE_KEYPAIR = "CREATE_KEYPAIR",
GET_PUBLIC_KEY = "GET_PUBLIC_KEY",
ZK_EPH_SIGN_BYTES = "ZK_EPH_SIGN_BYTES", // 用临时私钥签名
SET_ZKPROOF = "SET_ZKPROOF",
GET_ZKPROOF = "GET_ZKPROOF",
CLEAR_ZKPROOF = "CLEAR_ZKPROOF",
}
// Wallet Standard 相关(dApp 触发)
export enum WalletStandardMessageTypes {
SIGN_PERSONAL_MESSAGE = "sign_personal_message",
SIGN_TRANSACTION = "sign_transaction",
SIGN_AND_EXECUTE_TRANSACTION = "sign_and_execute_transaction",
EVEFRONTIER_SIGN_SPONSORED_TRANSACTION = "sign_sponsored_transaction",
}
// Keeper 安全容器消息
export enum KeeperMessageTypes {
READY = "KEEPER_READY",
CREATE_KEYPAIR = "KEEPER_CREATE_KEYPAIR",
UNLOCK_VAULT = "KEEPER_UNLOCK_VAULT",
GET_PUBLIC_KEY = "KEEPER_GET_KEY",
EPH_SIGN = "KEEPER_EPH_SIGN", // 临时私钥签名
CLEAR_EPHKEY = "KEEPER_CLEAR_EPHKEY",
SET_ZKPROOF = "KEEPER_SET_ZKPROOF",
GET_ZKPROOF = "KEEPER_GET_ZKPROOF",
CLEAR_ZKPROOF = "KEEPER_CLEAR_ZKPROOF",
}
消息流:dApp 签名请求的完整路径
dApp 调用 wallet.signTransaction(tx)
↓ wallet-standard(同进程)
injected.ts (EveVaultWallet.signTransaction)
↓ window.postMessage({ type: "sign_transaction", ... })
content.ts
↓ chrome.runtime.sendMessage(...)
background.ts(walletHandlers.ts)
→ 打开 sign_transaction 审批窗口
← 用户点击"同意"
→ 发消息给 Keeper
↓ chrome.runtime Port
keeper.ts
→ 用临时私钥签名
→ 返回 ZK Proof + 签名
↓ chrome.runtime Port
background.ts
↓ chrome.runtime.sendMessage
content.ts
↓ window.postMessage
injected.ts
→ 返回 SignedTransaction 给 dApp
4. Wallet Standard 实现(SuiWallet.ts)
EVE Vault 通过实现 @mysten/wallet-standard 的 Wallet 接口,让所有支持 Wallet Standard 的 dApp 自动发现它:
// apps/extension/src/lib/adapters/SuiWallet.ts
export class EveVaultWallet implements Wallet {
readonly #version = "1.0.0" as const;
readonly #name = "Eve Vault" as const;
// 支持的 Sui 网络链
get chains(): Wallet["chains"] {
return [SUI_TESTNET_CHAIN, SUI_DEVNET_CHAIN] as `sui:${string}`[];
}
// 实现的 Wallet Standard 功能
get features() {
return {
[StandardConnect]: { connect: this.#connect },
[StandardDisconnect]: { disconnect: this.#disconnect },
[StandardEvents]: { on: this.#on },
[SuiSignTransaction]: { signTransaction: this.#signTransaction },
[SuiSignAndExecuteTransaction]: { signAndExecuteTransaction: this.#signAndExecuteTransaction },
[SuiSignPersonalMessage]: { signPersonalMessage: this.#signPersonalMessage },
// EVE Frontier 专有扩展特性
[EVEFRONTIER_SPONSORED_TRANSACTION]: {
signSponsoredTransaction: this.#signSponsoredTransaction,
},
};
}
}
注册到页面(injected.ts)
// apps/extension/entrypoints/injected.ts
import { registerWallet } from "@mysten/wallet-standard";
import { EveVaultWallet } from "../src/lib/adapters/SuiWallet";
// 在页面加载时立即注册
registerWallet(new EveVaultWallet());
dApp 通过 @mysten/wallet-standard 的 getWallets() 自动发现 EveVaultWallet,无需任何特殊集成。
5. Keeper:安全密钥容器
Keeper 是 EVE Vault 最独特的安全设计——临时私钥永远不离开 Keeper 进程的内存:
// apps/extension/entrypoints/keeper/keeper.ts
// Keeper 处理的消息类型
switch (message.type) {
case KeeperMessageTypes.CREATE_KEYPAIR:
// 生成新的 Ed25519 临时密钥对
// 私钥只在内存中,不写 chrome.storage
break;
case KeeperMessageTypes.EPH_SIGN:
// 用临时私钥对字节签名
// 只暴露签名结果,不暴露私钥
break;
case KeeperMessageTypes.CLEAR_EPHKEY:
// 清除内存中的临时私钥(锁定操作)
break;
}
安全保证:
- 临时私钥 = 内存变量,不序列化到 chrome.storage
- 浏览器关闭或 Keeper 崩溃 → 私钥自动销毁
- 重新解锁 → 重新生成新的临时密钥对
- Background/Popup 无法直接读取私钥,只能通过 Port 消息请求签名
Keeper 最重要的不是“神秘”,而是权限最小化。
它把最敏感的能力压缩成很少的几件事:
- 生成临时密钥
- 用临时密钥签名
- 清除临时密钥
除此之外,别的层尽量不要碰到私钥本体。
6. 本地开发配置
安装依赖
# 推荐使用 Bun
bun install
配置 .env
# apps/extension/.env
VITE_FUSION_SERVER_URL="https://auth.evefrontier.com"
VITE_FUSIONAUTH_CLIENT_ID=your-fusionauth-client-id
VITE_FUSION_CLIENT_SECRET=your-fusionauth-client-secret
VITE_ENOKI_API_KEY=your-enoki-api-key
EXTENSION_ID="your-extension-public-key"
启动开发模式
# 只运行扩展(推荐)
bun run dev:extension
# 运行所有 apps(扩展 + web)
bun run dev
开发模式下,WXT 会在 apps/extension/.output/chrome-mv3/ 生成扩展文件,并监听文件变化自动重建。
在 Chrome 中加载扩展
- 打开
chrome://extensions - 开启右上角「开发者模式」
- 点击「加载已解压的扩展程序」
- 选择
apps/extension/.output/chrome-mv3/
每次文件变化后,Chrome 会自动检测并提示更新(无需手动重新加载)。
7. 构建生产版本
# 构建 Chrome 扩展
bun run build:extension
# 输出:apps/extension/.output/chrome-mv3.zip
# 构建所有 apps
bun run build
# 清除所有缓存(构建时间变慢时使用)
bun run clean
8. FusionAuth OAuth 配置
在 FusionAuth 控制台需要添加以下重定向 URI(格式固定):
https://<extension-id>.chromiumapp.org/
Extension ID 是 Chrome 分配的扩展唯一标识符(可在 chrome://extensions 页面找到)。
必要的 OAuth 范围(Scopes):
openid(获取 JWT 格式的 token)profile(获取用户信息)email(用户邮箱)
9. Turborepo 构建缓存
项目使用 Turborepo 加速构建:
# turbo.json 定义了任务并行关系
# build:extension 依赖 shared 包的构建
bun run build:extension
# → 先 build packages/shared
# → 然后 build apps/extension(使用缓存)
# 强制重新构建(忽略缓存)
bun run build --force
10. E2E 测试
# tests/e2e/ 目录包含余额查询等端到端测试
bun run test:e2e
# 测试前需要钱包已登录并配置了测试账户
# tests/e2e/helpers/state.ts 提供状态管理工具
本章小结
| 组件 | 层级 | 功能 |
|---|---|---|
injected.ts | 页面进程 | 注册 EveVaultWallet 到 Wallet Standard |
content.ts | 内容脚本 | 消息桥接:页面 ↔ Background |
background.ts | Service Worker | OAuth、存储、请求协调 |
keeper.ts | 隐藏容器 | 临时私钥的安全存储与使用 |
popup/ | Extension Page | 用户界面:登录、地址、余额 |
sign_*/ | Extension Pages | 交易/消息审批 UI |
SuiWallet.ts | Adapter | Wallet Standard 完整实现 |
下一章:EVE Vault 与 dApp 集成实战 —— 如何在 Builder 的 dApp 中接入 EVE Vault,支持账户发现、赞助交易和中断处理。
Chapter 35:未来展望 — ZK 证明、完全去中心化与 EVM 互操作
目标: 了解 EVE Frontier 和 Sui 生态的前沿技术方向,思考如何为未来的关键升级提前做好架构准备,成为站在技术前沿的构建者。
状态:展望章节。正文以未来技术方向和架构预留为主。
35.1 当前的信任假设与局限
回顾我们整个课程中的架构,有几个核心的“信任假设“:
| 环节 | 当前依赖 | 局限性 |
|---|---|---|
| 临近性验证 | 游戏服务器签名 | 服务器可撒谎或宕机 |
| 位置隐私 | 服务器不泄露哈希映射 | 服务器知道所有位置 |
| 组件状态更新 | 游戏服务器提交 | 中心化瓶颈 |
| 游戏规则修改 | CCP 控制的合约升级 | 玩家无直接治理权 |
这些局限不是设计失误,而是现阶段技术和工程的取舍。EVE Frontier 官方路线图承诺逐步消除这些中心化依赖。
这一章最容易写成“技术愿景列表”,但真正有价值的视角是:
哪些未来方向值得今天就为它留接口,哪些则只需要知道,不必过早下注。
因为对 Builder 来说,未来感如果处理不好,就会变成两种常见问题:
- 过度预留,系统今天反而很臃肿
- 完全不预留,未来一变化就要重构
35.2 零知识证明(ZK Proofs)的应用前景
什么是 ZK 证明?
零知识证明允许一方(Prover)向另一方(Verifier)证明某件事是真的,而不泄露任何具体信息:
当前(服务器签名):
玩家 → "我在星门附近" → 服务器查询坐标 → 签名证明 → 链上验证签名
未来(ZK 证明):
玩家本地计算:"生成一个 ZK 证明,证明我知道一个坐标 (x,y),
使得 hash(x,y,salt) = 链上存储的哈希,
且 distance(x,y, 星门) < 20km"
→ 将 ZK 证明提交上链
→ Sui Verifier 智能合约验证证明(无需服务器)
ZK 对 EVE Frontier 的意义
现在 未来(ZK)
────────────────────────────────────────────────
临近性 → 服务器签名 临近性 → 玩家自证 ZK
位置隐私 → 信任服务器 位置隐私 → 数学保证
跳跃验证 → 需要服务器在线 跳跃验证 → 完全链上
链下仲裁 → CCP 决策 链下仲裁 → 社区 DAO
为 ZK 做好准备的合约设计
// 现在:用 AdminACL 验证服务器签名
public fun jump(
gate: &Gate,
admin_acl: &AdminACL, // 现在:验证服务器赞助
ctx: &TxContext,
) {
verify_sponsor(admin_acl, ctx); // 检查服务器在授权列表
}
// 未来(ZK 时代):替换验证逻辑,业务代码不变
public fun jump(
gate: &Gate,
proximity_proof: vector<u8>, // 换成 ZK 证明
proof_inputs: vector<u8>, // 公开输入(位置哈希、距离阈值)
verifier: &ZkVerifier, // Sui 的 ZK 验证合约
ctx: &TxContext,
) {
// 同一链上验证 ZK 证明
zk_verifier::verify_proof(verifier, proximity_proof, proof_inputs);
}
关键架构建议:现在就把位置验证封装成独立函数,未来只需替换验证逻辑,无需重写业务代码。
对今天的 Builder 来说,ZK 最现实的价值是什么?
不是马上自己写证明系统,而是先学会把“证明机制”和“业务状态机”拆开。
这样未来如果:
- 服务器签名换成 ZK
- 某些验证步骤变成本地生成证明
- 不同组件使用不同证明后端
你替换的是验证层,而不是整套产品逻辑。
35.3 完全去中心化游戏(Fully On-Chain Game)
区块链游戏的终极形态:游戏逻辑完全在链上,无任何中心化服务器。
理想的完全链上游戏:
所有游戏状态 → 链上对象
所有规则执行 → Move 合约
所有随机数 → 链上随机数(Sui Drand)
所有验证 → ZK 证明
所有治理 → DAO 投票
Sui Drand:链上可验证随机数
use sui::random::{Self, Random};
public fun open_loot_box(
loot_box: &mut LootBox,
random: &Random, // Sui 系统提供的随机数对象
ctx: &mut TxContext,
): Item {
let mut rng = random::new_generator(random, ctx);
let roll = rng.generate_u64() % 100; // 0-99 均匀分布
let item_tier = if roll < 60 { 1 } // 60% 普通
else if roll < 90 { 2 } // 30% 稀有
else { 3 }; // 10% 史诗
mint_item(item_tier, ctx)
}
链上 AI NPC(实验性)
结合 ZK 机器学习(ZKML),理论上可以把 NPC 的决策逻辑也放上链:
链上 NPC 合约 → 接收游戏状态输入
→ 在链上通过 ZKML 验证"AI 决策的正确性"
→ 输出行动结果
这里最需要现实一点的判断
“完全链上”并不自动等于“更适合现在的 EVE Builder 任务”。
很多今天真正有价值的产品,仍然是混合架构:
- 关键资产和规则上链
- 高速世界模拟留在链下
- 验证边界逐步前移
所以更实际的目标通常不是一步到位全链上,而是持续缩小“必须依赖中心化信任”的那部分面积。
35.4 Sui 与其他生态的互操作
Sui Bridge:跨链资产
// 未来:通过 Sui Bridge 从以太坊转入 EVE 游戏物品
const suiBridge = new SuiBridge({ network: "testnet" });
// 将以太坊上的某个 NFT 桥接到 Sui
await suiBridge.deposit({
sender: ethAddress,
recipient: suiAddress,
token: ethNftContractAddress,
tokenId: "12345",
});
状态证明(State Proof)
Sui 支持向其他链证明自身的链上状态,这使得跨链的资产证明成为可能:
EVE Frontier 玩家拥有稀有矿石 (Sui)
→ 生成 Sui State Proof
→ 在以太坊上的 DEX 中用 Sui 资产作为抵押品
互操作最值得关注的,不是“能不能桥”,而是“桥过去以后语义还对不对”
例如:
- 一件 EVE 资产到了别的链,还是不是原来那种权限或物品?
- 另一条链上的金融场景,是否理解它的真实风险?
- 桥接后失败、冻结、回滚时,用户怎么理解资产状态?
这意味着跨链不是纯技术扩展,也是一层产品语义迁移。
35.5 DAO 治理:Builder 参与游戏规则制定
随着游戏成熟,更多游戏参数可能开放 DAO 投票:
// 未来:费率参数由 DAO 投票决定
public fun update_energy_cost_via_dao(
new_cost: u64,
dao_proposal: &ExecutedProposal, // 已通过的 DAO 提案凭证
energy_config: &mut EnergyConfig,
) {
// 验证提案已通过且未过期
dao::verify_executed_proposal(dao_proposal);
energy_config.update_cost(new_cost);
}
不是所有参数都值得 DAO 化
更适合 DAO 的通常是:
- 中长期规则参数
- 高价值公共资源配置
- 影响多方利益的分润与治理项
不太适合完全 DAO 化的通常是:
- 高频运营参数
- 需要秒级响应的安全开关
- 明显属于执行层职责的日常动作
否则治理会从“集体决策”变成“系统阻塞”。
35.6 给构建者的长远建议
技术选择
✅ 现在就做:
- 将验证逻辑封装为可替换的模块
- 使用动态字段预留扩展空间
- 为 DAO 治理留好参数接口
- 保持合约模块化,方便升级
🔮 关注的技术方向:
- Sui ZK Proof 原生支持
- Sui Move 的类型系统扩展
- 跨链桥的安全性成熟
- ZKML 在游戏中的实际应用
商业定位
短期(现在可做):
- 星门收费、市场、拍卖等经济系统
- 联盟协作工具(分红、治理)
- 游戏数据统计面板和分析服务
中期(1-2年):
- 多租户 SaaS 平台(通用市场、任务框架)
- 跨联盟协议和标准
- 数据分析和商业智能
长期(ZK 成熟后):
- 完全去中心化的游戏副本(小游戏内游戏)
- ZK 驱动的隐私交易
- 跨链的 EVE 资产金融化
真正的长远建议可以压缩成一句话
先把今天真实能落地的系统做成模块化、可升级、边界清晰的产品,再去迎接未来能力。
因为未来真正会奖励的,不是“谁最早喊口号”,而是“谁今天的系统最容易演进到明天”。
35.7 本课程的终点是下一个起点
恭喜你完成了 EVE Frontier 构建者完整课程!你现在具备:
- ✅ Move 合约开发:从基础到高级模式
- ✅ 智能设施改造:炮塔、星门、存储箱的完整 API
- ✅ 经济系统设计:代币、市场、DAO 治理
- ✅ 全栈 dApp 开发:React + Sui SDK + 实时数据
- ✅ 生产级工程:测试、安全、升级、性能优化
接下来的行动:
- 完成 10 个实战案例,将知识转化为可部署的产品
- 加入 Builder 社区,分享你的合约,参与生态建设
- 关注官方更新,Sui 和 EVE Frontier 持续进化
- 构建你自己的宇宙,在这里,代码就是物理定律
“我们不只是在写代码。
我们在为一个宇宙制定物理法则。”— EVE Frontier Builder 精神
📚 最终参考资源
- EVE Frontier 官网
- 官方文档
- World Contracts 源码
- Sui 技术文档
- Move Book
- Sui ZK 相关
- Sui On-chain Randomness
- EVE Frontier Discord
EVE Frontier 案例 dApp 运行指南
为了方便你在学习实战案例时能直观地与智能合约进行交互,我们在本目录为所有 18 个 Example 分别生成了配套的 React / dApp 前端工程。
为了极大地节省磁盘空间并加快安装速度,所有的 dApp 都在这个目录(src/code/)被配置为了一个 pnpm workspace (Monorepo)。
🚀 1. 首次配置与安装
在运行任何一个案例之前,你需要先在 src/code/ 根目录下完成依赖安装。
打开终端,进入 code 目录:
cd EVE-Builder-Course/src/code
安装所有项目的共享依赖:
pnpm install
(提示:这个步骤可能需要1-2分钟,取决于你的网络情况,只需执行一次。)
🎮 2. 运行指定的案例 dApp
每一个案例(如 Example 01、Example 10)都有自己专属的前端包,命名规则为 evefrontier-example-XX-dapp。
假设你现在正在学习 Example 1: 炮塔白名单 (MiningPass),你想启动它的交互界面。
在 code 目录下,运行以下指令:
pnpm run dev --filter evefrontier-example-01-dapp
(你只需要把 01 换成你想要测试的章节编号,比如 05、18 即可)
终端会输出类似于以下的启动信息:
VITE v6.4.0 ready in 134 ms
➜ Local: http://localhost:5173/
点击或在浏览器打开 http://localhost:5173/,你就能看到该案例专属的前端界面了!
🛠 3. 页面功能说明
打开案例页面后,你会看到:
- Connect EVE Vault (右上角):点击此按钮可拉起 EVE Vault 钱包浏览器扩展进行连接。
- 案例主题标题:显示当前正在测试的案例(例如:“Example 1: 炮塔白名单 (MiningPass)”)。
- 交互动作按钮:点击大蓝框中的功能按钮(例如:“Mint MiningPass NFT”)。
- 如果钱包尚未连接,会提示
Please connect your EVE Vault wallet to interact with this dApp. - 按钮点击后将自动触发对应
target的 Move 合约调用,你可以在弹出的 EVE Vault 扩展面板上批准/确认这笔交易。 - 打开浏览器的“开发者工具 (F12) -> Console”可以查看详细的交易执行日志或失败报错。
- 如果钱包尚未连接,会提示
🏗 (进阶) 全部项目构建检查
如果你修改了 TypeScript 源码或 App.tsx,想要检查是否破坏了代码,可以在 code 目录使用聚合构建命令:
pnpm run build
这会顺次编译全部 18 个案例的前端代码,如果有语法错误,TS 编译器会明确给出报错的位置。
EVE Frontier 2026 Hackathon: Sui 核心特性创意库
本目录收录了 100 个专为 EVE Frontier x Sui 黑客松设计的硬核创意。每个创意均深度结合了 Sui 的底层优势(如 PTB、Dynamic Fields、Kiosk、DeepBook 等),并分配了独立的文件以供进一步细化架构和落地方案。
创意列表
- 太空闪电贷 (Space Flash Loan) - 单笔交易直接借船-采矿-跨星区高价兜售矿产-最后还船还息,若中间任何一步亏损或被击毁则全局时空倒流回…
- 舰队同步空投矩阵 - 一个 PTB 打包 100 名玩家的独立跳跃请求签名,确保所有人不在有任何延迟的情况下于同一个 Su…
- 复合毁灭重铸工厂 - 在一个 PTB 内同时要求销毁(Burn)5种分布在宇宙各地的不同零件,瞬间极速原位组装(Mint)…
- 一键脱壳求生方案 - 当主舰濒临爆炸的毫秒间,PTB 原子级执行卸载身上最值钱的高级雷达设备并转移进逃生舱对象中火速弹射…
- 多签联合悬赏资金池 - PTB 串联多重合约逻辑验证军团高层的多方签名后,才瞬间释放联盟保险箱底层的无尽物资用于犒赏三军…
- 过路费无感代付系统 - 大舰队雇主代替佣兵支付星门跳过路费,同时该佣兵将刚采集的矿石在同一个 PTB 内按比例反切划转给雇主…
- 极速无缝换装快切板 - 将全套飞船机甲从“采矿特化型”零延迟一键换肤拔换所有组件切换成“防空堡垒型”…
- 跨合约截胡套利脚本 - 利用 PTB 的前后调用链接,在这个星门低价收废料过账的同一纳秒在隔壁当做高价燃料倒满卖出…
- 自动均分平衡油箱网 - 通过 PTB 循环读取整支舰队 50 艘所有战舰的燃料罐状态,瞬间抽取高残余并平均调配给即将干涸的船…
- 破防暴击叠加引擎 - 在一个单一逻辑交易块之内极其夸张地连续高频调用炮塔 20 次射击函数,达成数学意义上完美的连击暴击蓄…
- 无尽外挂外设插槽 - 打破固定飞船几个装配位的传统概念,利用动态对象字段 (DOF) 实现无限量叠加装配微型外设扫描仪…
- 高度加密的黑匣子日志 - 将跳跃和交战敏感坐标历史直接利用 DF 深埋入飞船的极深子域结构,没有特定的检索秘钥绝对连扫都扫不出…
- 打破体积限制的套娃虫洞储存 - 由于每个储物箱因为代码有容量极限,利用 DOF 一层层做子域嵌套实现无限套娃的超级仓库空间…
- 鲜血进化的成长武器 - 根据炮管实际消灭人数,合约动态增加一个名为
veteran_stats的额外字段来凭空拔高它的… - 不染核心的军团痛车涂装 - 不更改船只自身高昂昂贵的防御架构主体模块,而是只将其外部材质包的链接随时通过增删 DF 来替换展现风…
- 极其恶毒的太空寄生虫病 - 敌方不仅是进行攻击,更是将一连串病毒字段强行插入你的飞船底层 DOF 中变成每小时都吸取护盾的不可解…
- 全员通报动态赏金刺配贴 - 这并非系统机制,而是给全宇宙极度犯众怒的海盗飞船脑门上被全权打上并挂载永远闪亮的红名动态悬赏标签…
- 柔性不关机税控升级台 - 不需要停止机器打补丁,星门依据通过船只,直接在底层随时以增删字段的形式扩充不同船型的精确税率小抄条款…
- 临时日抛口令防线大钥锁 - 给本公会成员贴上时效只有半天的身份特征子字段,大白银星门仅此才敞开…
- 防爆裂脱挂反应装甲墙 - 当飞船承受完全无法阻挡的一击致命炮火,底层逻辑主动解开并粉碎自身外部连接好的防御场对象用作绝对抵挡…
- 企业内网专属星矿局 - 设定只允许后缀为 “@amazon.com” 或者 “@sui.io” 的专属联盟企业员工靠 zkL…
- Twitch 粉头门禁卡大派发 - 通过与 Twitch SSO 结合的 zkLogin 取信确认,当红游戏主播能极其轻松通过这种协议将…
- Discord 社群绝对权力挂钩 - 将会长的游戏内顶级操作员大锁绑定到 Discord ID 上。如果换届在群里发生变更游戏内政权无缝由…
- 了无痕迹的无名卧底信使 - 不创建繁琐的 Web3 链子交互,直接利用极高匿名和用完即抛特点随便乱填临时邮箱创建超脱主号外且无法…
- 防肉鸡挂机邮箱强验证门 - 那些刷子和脚本当试图穿越极度肥沃且严密封锁核心带星门时会弹跳弹窗必须到绑的实体邮箱收一个动态验证码进…
- 地球地缘锚定局域国战 - 运用 Web2 天然能够捕抓物理定位与 IP 并和该次验证登录进行硬锚定,强行构建一个限制比如北美玩…
- 极其小白甚至白痴的傻瓜一键找回遗产库 - 由于极其简单依靠 OAuth 和社交账号的背书验证特性。即便被清空密码新手也能一键光复自己当初那条破…
- 跨不同游历世界同源生态大成就共享箱 - 通过这一同源技术接口你如果在以太坊曾经某个链游是个屠龙大师系统查验直接跨游发放你在这个大乱斗服一台黄…
- 防盗库线下现实闹钟联动警报器触发极点 - 这甚至不是游戏内的炮火!一旦星门被强行爆破黑客攻击它直接调回 Web2 直接拨打这账号身后预存现实手…
- 成年人分级豪赌深暗冰冷不设防极恶区域网关 - 这绝对不会放进小孩去因为这里涉及极大血腥残忍并且极大 SUI 大额搏杀,进入大门利用此必须强过政府级…
- 宇宙洗牌跳楼机虫洞引擎 - 抛开航道把命压上全扔进去!闭眼推杆之后,将有一半可能一飞冲天抵达满是资源伊甸园或者当场掉进全是恶鬼星…
- 致残极其拼人品薛定谔急救补给包修理台 - 投入天价不二价 10 SUI:里面有 50% 极其逆天拉满机体到极品战斗满编全状态;但甚至有 50%…
- 暴走跳弹几率折射偏移仪强化塔 - 它不是每次给加成 5% 极其枯燥设定;开火伤害极其看脸每一次炮击可能只打出零星抓痒数值也可能暴起一击…
- 海选捡垃圾废墟大奖盲盒打捞钩爪臂 - 在清扫成千上万一堆破烂残骸碎片里混杂极大极微弱的抽奖池掉落库。没准一抓钩下去你成为了全宇宙那个抽得最…
- 极其残酷且没道理恶劣外星宇宙引力天气系统台 - 每日正午由链上掷出的这惊天大骰子来强制决定这一天是会发生全宇宙掉半血削弱以及大引力减速和各种恶狠狠负…
- 完全无逻辑瞎逛流窜神秘财宝飞箱怪 - 系统彻底解放由这个真随机乱跑到处生成大黑箱;你需要到处跑甚至这宝物怪自己都不知自己将刷新在哪,极大丰…
- 只能活出一人绝命毒师对决生还转盘绞肉门 - 两个已经把身家性命打烂甚至结下死仇大联盟长不仅单挑并进密室锁定然后彻底交由程序抽签,三秒后大门打开一…
- 全盘皆乱大洗牌矿带成分狂乱变异变点重生地带 - 每天只要开采枯竭,系统就会通过摇号随机去彻底把下次不仅重生纯度、出产、连带种类元素像乱配对一样极其杂…
- 大轮盘恶霸强盗海关碰碰运气全免或者被彻底搜刮榨取交费站 - 他不仅连个固定定价都不贴你要想过去你就自己按大转轮!如果小可能彻底免费让你开心离去,但要是大那是毫不…
- 自适应变色龙绝境反弹反击大装甲层防卫甲 - 每次受到伤害时在接受瞬间由那个随机转数瞬间当场改变其各种属性抗性反克,不仅能反克甚至还会弹出各种极其…
- 入会即签卖身契换来的傻瓜全免打工特惠包租卡 - 新矿工一穷二白?军团大老板帮你包下你在里面的每一铲子甚至每一个呼吸每一脚油门费用,全部走它那深不见底…
- 免费大路看似宽阔且极其阴毒深渊大门伪善剥皮星区过站台 - 星门挂大喇叭完全不收你一分过门路水费极大勾引你经过,但在条款微小并且极其难发现的深处通过你的授权抽走…
- 商城极致体验免车船税狂飙极测试飞车场 - 给你顶配好船但是限定的不是燃料而是极具只在当前系统和这极大极其狂野的一个钟头试驾时间里,你疯狂甩炮和…
- 大爱无疆公益超速保险死无全尸回家大火箭秒级复苏单 - 当极具惨烈被爆机瞬间连全尸都没留下,红叉公益系统不需要由于你在这个时刻连按出来的仅有几毫厘的气数直接…
- 拉皮条裂变传销吸粉体验巨主播引流金大管道链 - 主播为了增加观众群只要你点击那个入驻小字号哪怕是什么都不懂摸黑砸砸开两枪,那些全由主播大后台直接全赞…
- 纯善贫民窟哪怕低安区甚至是那些垃圾聚集大黑洞免费大低保充电急救区 - 在这个极其惨绝人寰绝境下这是一个散发神性光辉纯由大佬建立的免费急救区,不仅完全免费而且由于极其可怜完…
- 挂包极其彻底托管后台机器人无脑大打钱脚本协议挂载插件 - 免除了每天自己各种烦躁点击,哪怕是你用来跑挂机脚本文消耗系统运算摩擦费用全让你那个托管的无所不包的后…
- 全包揽订阅大金主星空大土豪畅玩这星区专属尊贵 VIP 通行全免黑卡机制 - 交足大这天价 SUI 保镖月租金之后你哪怕在这个大指定区里每一秒按住这镭射炮狂轰滥炸扫了几千上万次都…
- 终极全服总动员并且不惜一切代价哪怕打空大金库燃烧到底全战争机器打响不计这极其庞大国力损耗大豁免打通特批最高总法令 - 当极具两方全服大决战这个统率直接拉开最牛的极其恐怖智能赞助条令让底层敢死队能够毫无顾虑放肆极其夸大超…
- 极其贴切且符合各种巨大联盟不仅出纳以及会计极为严丝合缝报销并且甚至极大体现后勤大体制完全报表对账补偿大系统台应用 - 完美解决由于引公采办一草一木由于因公导致这个耗损甚至是这磨损完全自动化报销补偿给前线打工人的智能福利…
- 垄断式战争武装专营店 - 顶级火炮只能从我开设的 Kiosk 中买,且任何人之后倒卖这尊火炮,都会被原制造者无视任何市场系统强…
- 纯血军团内部黑心国营商会 - 利用 Kiosk 设置白名单条件,这儿的平价军需只能验证你是否佩戴公会特定徽章才能进行购买,外人花一…
- 绝地禁售防黄牛大锁限令 - 限定某些超级功勋战舰 NFT 就算在 Kiosk 里挂售也无法被任意转移,彻底切断一切二手市场交易和…
- 有时效的不见光租赁黑展柜 - 将飞船放进 Kiosk 设定“Rent”借出给别人开,租期结束不仅它无论宇宙何处都会在瞬间被强制剥夺…
- 密封盲拍极品暗网拍卖行 - 利用 Kiosk 特性和密文出价,暗拍极度稀缺的绝版高级星门建城执照,价高者得但互不知道底牌…
- 强制分润大海盗分赃协定 - 这几个海盗团队将抢来的船只挂在 Kiosk 出售,规定售出资金强行瞬间无误差平分为 5 份打给 5 …
- 跨服恶霸星门承包权流转 - 这口大星门的管理和所有权作为 Kiosk 展品上架,任何土豪只要有钱都可以用预设极高违约溢价强行盘下…
- 纯粹摆显土豪艺术家飞船玻璃大展厅 - 不卖只炫耀!专攻审美的土豪购买全服第一台艺术旗舰放入不提供贩售价格仅仅供人仰望并设置成只要点赞才能查…
- 自适应跳楼折旧大甩卖二手黑车行 - 一旦出过事故每经过一次转手倒卖或者损坏修理再进入,Kiosk 的挂盘出售系统标价会自动由于战损痕迹强…
- 隐去真名大黑市洗钱符文深空网 - 挂上 Kiosk 时强行利用隐私或者代理逻辑去剥离卖家真实交易地址的深网黑市流转,从而完全规避任何大…
- 即时无滑点燃料挂单闪兑深池 - 不需要慢悠悠匹配,通过 DeepBook 构建全链的矿物/燃料交易对大单墙,矿工拉着矿船直接一键按最…
- 超高频极速差价真空无损搬砖机 - 全自动化监听运行于各个 DeepBook 接口极远星系之间的矿石微小差价,通过算法控制飞船穿梭疯狂做…
- 暗夜战争物资恐慌期货做空机 - 拥有绝密情报预判敌方即将惨败而且资源大崩溃,赶在消息走漏前在 DeepBook 上面用高杠杆挂上并且…
- 巨无霸军团级国家大金库资金护盘做市 - 为了公会大计直接将联盟税收那些富可敌国海量 SUI 和海量燃料组成的特大池子注入特定 DeepBoo…
- 绝命限价止损斩仓连逃带跑逃生舱 - 当飞船处于危险血量大残即将全军覆没时,通过连接程序设定触发特定危急价位会自动通过连入该星门 Deep…
- 战时即崩极高压借贷大平仓引擎 - 你抵押了星门贷款买巨炮去装逼,当敌方开始攻打你的星系,抵押大门由于地价剧烈贬值,这个 DeFi 合约…
- 打爆不仅人死甚至瞬间还要大爆仓破产倾家荡产大杠杆狂赚合约 - 这帮亡命赌徒抵押飞船开上了 100 倍恐怖杠杆去博 SUI 涨跌大走势,一旦看走眼不仅在链上你血本无…
- 极度跨星区全联盟大通证跨服资产综合权重大指数基金 - 将其全部打包直接发行一款包含这些最高级矿物指数成分汇总而且极大分量的联合大 ETF 代币公然送上了交…
- 全链反向对冲大灾难黑天鹅大爆船险 (Credit Default Swap) - 一旦那个最大土豪特定那一艘史诗级战列舰极其意外遭毁不仅会立刻触发兑现巨额期权衍生品超级赔付资金大套死…
- 全能极限聚合大 DEX 通道吃尽天下星门 - 任何人通过星门时,这机器不仅自动给你传送而且会在全网各种价单上给你匹配寻找最优价,将你过往垃圾残碎矿…
- 易读导航指路信标超级大站长定位 - 用极其清晰的
base.alliance.sui取代那些让人不可能记住的繁杂大合约极大对象这一… - 全游最高通缉大红榜 - 只要向全系统广播
kill.boss.sui这样简单粗暴且悬赏极高惹眼的大域名,所有人立刻闻风… - 跨次元超级大金主星门网冠名 - 财力极佳的巨型联盟不仅自己盖星门更是将其命名为现实著名商业大品牌
redbull.sui,借此拿… - 真假大将军防伪克隆标识铭牌 - 最高统帅直接绑定
commander-john.sui杜绝敌人使用假马甲乱入频道发号施令… - 国别改朝换代全网交接大系统 - 由于一切公国资产大权直接绑定在
king.blood.sui之上,转手该域名意味着瞬间将整个王… - 极紧迫生死相搏求救短地址 - 快被打爆没空输入参数时候直接公屏输入
help.me.sui一键全军驰援防线… - 太空冷笑话解压点智能分发仪 - 偏远停机坪的
tell.joke.sui里面不仅仅会自动广播各种太空段子专供无聊玩家挂机时解闷… - 绝对公开历史透明记名外交长廊 - 利用全服唯一
diplomacy.guild.sui这里无情且无法挂除地挂满了极其耻辱毁约背刺… - 极其无耻黑吃黑夺命深空伏击网 - 黑客通过篡改全服极度信任的地标级安全域名比如
home.sui的底层映射点,将一整支大舰队瞬间… - 完全树形的军团阶层超级分册名录网 - 使用
.admiral.fleet1.alliance.sui的绝妙域名多重后缀极其完美展示这… - 硬核去中心化不可磨灭星系通史全大录像 - 高达几十GB全记录的宇宙战争编年史战争录屏不再放 YouTube,直接用合约挂载进 Walrus 库…
- 赛博狂野百兆机甲痛车喷漆超清全图列阵 - 将毫无上限超高清的个性暴走飞船装甲极大材质包全传至 Walrus 以供整个游戏进行不受带宽限制的超逼…
- 拯救全服失传开源极品深空图纸库 - 一个永不丢失的海量由于年代久远或者早就全服没人懂了的超级远古飞船设计蓝图保存在 Walrus 中而成…
- 各种极品机密由于战线潜入之天价情报网赚 - 极其高密大舰队全方位集结暗中潜行极清百兆偷拍大视频保存在 Walrus 后并锁定只有极其极其天价密匙…
- 星际广播电台与航线播报网 - 把联盟广播、战争简报、航线预警和广告赞助做成可订阅、可打赏、可归档的深空媒体系统…
- 开源舰队 AI 策略仓库 - 让炮塔策略、物流调度、价格模型和联盟战术模板变成可授权、可订阅、可分成的规则插件市场…
- KillMail 取证回放台 - 围绕击杀记录做录像索引、赔付取证、战术复盘和战争教学的公开回放平台…
- 热土豆通缉信标 - 用 Hot Potato 思路做高风险追猎赛事、逃亡挑战和限时传递型 PvP 节目…
- 共享矿带抢采协议 - 把整条矿带做成共享资源池,让多人并发争夺、协作和干扰同一批高价值资源…
- 永久战争纪念碑 - 将联盟胜利、远征和重大牺牲铸造成不可篡改的纪念对象,而不是不合理的无敌要塞…
- 只认舰长的私有旗舰 - 设计强身份绑定的旗舰控制系统,解决高价值舰船的授权、封存和防劫持问题…
- 旗舰试驾与限时借舰库 - 用 Borrow 模式做高价值舰船的体验、教学、赛事赞助和押金租赁系统…
- 联盟多签金库与军费保险箱 - 把军费、税收和战时预算做成多签审批、可审计、防卷款跑路的联盟财务系统…
- 全服倒计时争夺战 - 基于链上时钟做公开倒计时的资源争夺、战争窗口、限时拍卖和撤离结算玩法…
- 蓝图母版与版税工厂 - 让舰船、装备和装饰蓝图以母版授权形式生产,并持续向原创者分发版税…
- 诅咒突变古神兵 - 设计会随击杀、重铸和献祭不断突变、变强也可能反噬主人的危险神兵系统…
- 红名自动截杀网 - 围绕红名、赏金和信誉数据,把多座星门和炮塔联成一张自动协防与封锁网络…
- 组件众筹超级战舰 - 让多人分组件认购、组装、运营和分摊战损,打造联盟级超级战舰项目…
- ZK 跨链身份映射 - 在不暴露完整隐私的前提下证明其他链或其他世界中的身份、成就和信誉…
- 治理权碎片寻回战役 - 把治理权抽象成安全的赛季事件碎片,让玩家通过协作占点、护送和解谜争夺治理资格…
EVE Frontier 2026 Hackathon: 常规创意库
本目录收录了 100 个基于 EVE Frontier ‘A Toolkit for Civilization’ 主题的常规黑客松创意。这些创意涵盖了实用工具、技术架构、脑洞创意、怪诞玩法以及实时服务器联动等五大赛道。
创意列表
- 智能星区燃料调度仪 (Smart Resource Router) - 监听全星区星门的跃迁频率,自动将联盟运输船的物流目的地修改为燃料库存低于 20% 的节点,确保整个防…
- 极危求救信标 (Automated SOS Beacon) - 改造储物单元(Storage Unit),当其装甲值(HP)低于 30% 时,智能合约自动在公共频道…
- 去中心化太空典当行 (Space Pawnshop) - 允许玩家将全服限量的 NFT 涂装或稀有蓝图锁入智能储物箱,合约自动根据预言机喂价发放高流动性的 S…
- 军团 CTA 出勤打卡器 (Guild Attendance Tracker) - 一个特殊的智能网关或炮塔,当发起 “Call to Arms” 集结令时,它会自动记录并在链上给所有…
- 动态拥堵收费星门 (Dynamic Toll Stargate) - 算法根据过去 1 小时内星门的通过流量计算收费。如果在激战期间有大批舰队想走捷径,过路费会呈指数级上…
- 链上无头赏金所 (Bounty Hunter Escrow) - 全匿名的智能合约,任何人都可以将 SUI 锁入其中并指定一个角色的 ID。当系统捕捉到该角色的确切死…
- 全自动化兵工厂 (Automated Ammo Factory) - 利用 Move 的
Borrow-Use-Return模式,在无需人工干预的情况下,自动吃进矿… - 共享充电桩网络 (Shared Battery Network) - 部署超大型能量源组件(EnergySource),允许全宇宙任何飞船(无论阵营)停靠补能,但按照实际…
- 太空运单撮合市场 (Logistics Queue Manager) - 类似太空中的滴滴货运。发货人锁定押金和报酬,货柜车司机接单。只有当特定物品真实地存入了远在数光年外的…
- 一次性急救包贩卖机 (Emergency Medical Bay) - 高危星区的救命稻草。出售即用即毁的动态 NFT,飞船在濒死时触发此 NFT 的销毁交互,可以瞬间通过…
- 炮塔限时租赁协议 (Mercenary Firepower Renting) - 利用时间戳将特定杀伤性炮塔的
OwnerCap临时授权给一个非本联盟的矿工玩家。24小时后授权… - 太空海关扣款机 (Customs & Tax Stargate) - 专门部署在交通要道的星门,不按次收费,而是强制扣除过境船只钱包内所有 SUI 余额的 1% 作为“过…
- 联盟战利品均分系统 (Automated Loot Distributor) - 将战后打扫战场的储物箱链接到专门的分账合约。舰队指挥官只要把海量战利品抛入其中,智能合约会瞬间将其均…
- 死人开关:遗产继承器 (Dead-Man’s Switch Inheritor) - 长达 30 天没有任何链上签名的玩家会被判定为“脑死亡”。其名下的所有高级智能组件(储物箱、星门)的…
- 情报黑市付费墙 (Spy Network Paywall) - 侦察兵在敌对星系边缘捕捉到的舰队集结坐标。他们将坐标加密成一段 Dynamic Field 附加在特…
- 去中心化采矿订单板 (Alliance Job Board) - 军团建造泰坦需要大量 Veldspar 矿。直接发布锁定 10,000 SUI 的链上悬赏金,任何散…
- 异星资源期货交易所 (Fuel Futures Exchange) - 将还在地下、未来预计开采产出的特定类型燃料代币化(发行期货 Token)。玩家可以在开战前在二级市场…
- 闭关锁国网关 (Border Control API) - 绝对防御!该星门不仅仅校验白名单配置,更强制验证玩家地址内是否持有某知名链上身份认证凭证(例如:只允…
- 智能勒索炮塔 (Automated Ransomware Turret) - 锁定敌方飞船后不直接击毁,而是将其引擎功率锁定(利用特定的 De-buff)。除非对方在 5 分钟内…
- 全宇宙全民基本收入发生器 (UBI Generator) - 一个纯公益的 DAO 组织。部署一组永动机级别的能源阵列,每天搜集溢出的能源并在链上均匀地向全服所有…
- 零知识隐秘星区穿梭 (ZK Fleet Movements) - 运用原生 zkLogin / ZK Proof 密码学手段,舰队指挥官可以证明自己“合法支付了星门过…
- 跨链硬资产 EVE 映射桥 (Cross-Chain Asset Bridge) - 技术难度极高的跨链机制。允许以太坊巨鲸将其钱包里的 USDC 锁定,并在 EVE 的贸易站内自动 1…
- 亚毫秒级高频贸易站 (HFT DeepBook Trading Post) - 放弃传统的低效 AMM,直接将 Sui 官方的高性能中央限价订单簿(DeepBook)源码融入进 E…
- Sui GraphQL 全图热力追踪器 (GraphQL Live Heatmap) - 开发一个超强性能的链下数据聚合器。通过实时监听 Move 合约中抛出的
TurretFired(… - 战略武器多重签名发射井 (Multi-Sig Missile Silo) - 在 Sui 链上实现复杂的 N-of-M 多重签名机制。一枚能毁灭整个星区的超级实体导弹,想点火必须…
- 亿级对象池并发优化器 (Optimized Object Registry) - 针对超大战场中的“物品爆装”问题,重写底层的
ObjectRegistry。利用动态字段(Dyn… - 混合签名异步结算架构 (Off-Chain Sig_Verify) - 将绝大部分不涉及资产转移的高频交互(如走位、瞄准)通过链下服务器发放临时 Ed25519 签名,玩家…
- 动态无限船长日志 (Dynamic Field Metadata Engine) - 使用对象树形嵌套设计。将飞船所经历过的每一场著名战役的描述作为
Dynamic Field追加… - 海绵宝宝赞助钱包 (Gas-Sponge Dapp-Kit) - 极大优化新手体验(UX)。直接集成
@evefrontier/dapp-kit的赞助交易代码:… - 物理临近性地缘证明 (Proximity Proof Protocol) - 利用强大的密码学算法(如哈希时空碰撞),证明两个没有从属关系的玩家,此刻在现实(游戏底层引擎)的 3…
- 反肉鸡链上验证节点 (Decentralized CAPTCHA Node) - 在挂机刷矿泛滥的区域部署。该智能组件会随机抛出一个完全在 Move VM 内生成且验证的逻辑谜题,无…
- 基于 Move Prover 的绝对安全金库 (Move Prover Invariants) - 不仅仅是写业务代码,同时附带数百行的形式化验证(Formal Verification)断言集。从数…
- 时序锁定的舰队集体牵引跳跃 (Batched Jump Router) - 一个极为精妙的 TS 脚本应用,利用事务批处理技术,将军团内 100 艘舰船分别进行授权的过程打包入…
- 无缝热更新的包管理器 (Upgradable Package Manager) - 由于修改不可变资产风险极大,设计一种可插拔的模块化网关。未来需要迭代网关 AI 或收费逻辑时,可以将…
- ERC-20式的插件标准扩展协议 (Cross-Package Combinator) - 提出并实现一套针对 EVE 组件定制化的标准函数签名(如 `ActionModifier::exec…
- 硬派极客 EVE Vault 硬件插件 (Vault Keeper Ledger Edition) - 挑战高难度浏览器扩展开发!为目前基于 zkLogin 的 EVE 钱包加上一层强制性的 Ledger…
- 链下高速状态通道 (State Channel Skirmishes) - 两群玩家赌上身家性命进行狗斗,但由于链上 TPS 不够顺畅,他们将双方飞船资产总库隔离锁定,进入链下…
- 安全红队自动化黑盒机 (Automated Security CI/CD) - 一个服务于各路 Builder 的网页测试平台。能够一键上传自己刚写好的 EVE 防御网合约,平台会…
- 哨戒兵高敏监听 Discord Bot (Event-Driven Alert Bot) - 一款全天候悬浮于 WebSocket 连接上的机器人。当特定的系统组件抛出类似于 `UnderAtt…
- 完全去中心化的加密暗网指令 (On-Chain Encrypted Orders) - 舰队高层的命令全都是明文?利用 Diffie-Hellman 密钥交换原理与 EVE 角色的链上公钥…
- 可进化的拓麻歌子电子宠物 (Evolving Tamagotchi Drones) - 存在于星舰货舱里的一只非常脆弱的电子眼 NFT。玩家必须每天按时喂它特定的燃料渣滓,如果长达 7 天…
- 深空加密巨幅广告牌 (Space Billboard Protocol) - 如果你占领了全服最繁忙十字路口的一块太空巨石。你可以在这块石头上挂载基于链上 Display 标准的…
- 太空神权政治与神罚 (Sectarian Religion Framework) - 游戏内可以信奉不存在的“虚空之神”。土豪们向自己信仰的“战神”祭祀(存入)大把的 SUI 币;神殿的…
- 星海烈士纪念金卡 (Bounty Target NFT Cards) - 如果你单挑战胜了当前公认的顶尖全服霸主(例如 CEO)。在这个被摧毁的瞬间,系统会自动提取该霸主的最…
- 银河同步心跳电台 (Intergalactic Radio Station) - 一个简单的链上文字数组队列,玩家花极小代价就能在数组尾部追加一条 Spotify 的音乐链接。任何接…
- 星系大发现的终身冠名权 (Asteroid Naming Rights) - 当未知的神秘星区第一次被挖空探测器扫描完毕,那个打下最后一块原矿的玩家,会被赋予一次神圣的链上权限。…
- 零重力俄罗斯轮盘 (Cosmic Roulette) - 把生死交给真正的随机数!在一个完全隔离的赌台网关里,两名驾驶员签订生死状,并把各自心爱的史诗级旗舰所…
- 涂装乱入大师 (Cosmetic Modding Pipeline) - 类似早期的 Steam 社区创意工坊。极大地释放艺术表现,只要符合特定的 3D 模型面数规格限制,玩…
- AI 诗人传记生成器 (Generative Lore Library) - 每一座屹立于虚空中的超级跨星系跳跃星门的落成,都是由千万名蓝领劳工一点一滴搬砖建成的。这套组件会利用…
- 嗜血吸血鬼护盾 (Vampiric Weaponry) - 这不是用来彻底击没敌人的野蛮火炮。它射出的射线会在击中敌人装甲后,巧妙地截取非常精准的小数点后特定额…
- 去中心化联盟股份化 (Alliance Stock Market) - 联盟不再是一个组织形式,更是一个注册制上市公司。联盟的所有核心产能(每天的总采矿量流水)公开透明不可…
- 链上太空版密室逃脱 (On-Chain Escape Room) - 一种用技术极客心酸搭建的“连环画密码盒”。这里有一排按顺序放置好的储物箱,你必须通过解读上一个储物箱…
- 生存倒计时的星空死神契约 (Space Lotteries - Tontines) - 这是一场纯粹精神威压的残酷游戏,1 签定契约的 100 个人各自质押大量的起步金到奖池里;随着太空大…
- 全透明无金融性质的信誉系统 (Reputation Score Graph) - EVE 中尔虞我诈实在太多。为了防止小团队里有内鬼搞事,建立一套只计算社会交互维度的网络体系。如果你…
- 无声的战死者绝唱碑亭 (Monument to the Fallen) - 这不再是一个有用或者有经济效能的物件,这是一个废弃、不连接任何能量管线、毫无光泽的超大报废星门。但任…
- 高风险多签政治和亲条约 (Diplomatic Marriage Smart Contract) - 在两方巨大联盟对决的最焦灼时刻为了展示诚意。双方首脑把自己能打开所有公会最高密级物资库的核心权限卡(…
- 星长民主代议制 (Planetary Election System) - 一片庞大且富饶的太阳系由于矿石储量丰富吸引无数打工仔。为了治理这片混乱地带,大家决定投入高昂燃料进行…
- 席卷银河系的大海贼宝藏 (Galactic Treasure Hunt) - 全服超级彩蛋事件,开发者个人自掏腰包隐藏了一笔数目极其惊人的加密 SUI 代币于一个神秘的微小保险锁…
- 玩家主导型的去重式无限委托网 (Player-Generated Quests) - 抛开传统网游固定死的打十只野猪给两件白板衣服的套路。引入由所有闲散玩家自行定义的悬赏面板功能,你可以…
- 反向侦查雷达之暗夜走私网络 (Black Market Contraband) - 设定部分高能破坏原件具有很强的辐射效应被服务器自动打上了 “违禁品(Illegal)” 标签,因此在…
- Twitch 弹幕即时防空警报 (Turret Tourette’s) - 部署一台极度神经质的星门防卫重炮。其枪口朝向和开火的布尔值逻辑判断完全不看任何游戏参数,而是将其外接…
- “薛定谔的瑞克小卷”星门 (The “Rickroll” Gateway) - 披着无害外衣实则令人精神崩塌的整蛊组件。它确实有 99% 的绝对概率进行一次标准的星系曲率折叠,可如…
- 全太阳系公共土嗨广播站 (SUI-fueled Space Jukebox) - 这是一台具有极强全服广播扰属性的设施。任何过路者无论愿意与否,只要你停在这个太阳系,这个大音响储物间…
- 情感索取狂AI自爆飞镖 (The Sentient Bomb) - 被抛出来的并不是会物理追踪热能的火药。而是一个内嵌极速连线着 GPT-4 的多话痨炸包箱,你在面临这…
- 代驾代死:深空版Uber外包群 (Space Uber for Ships) - 由于路途漫长,很多土豪不想自己花生命按时赶路。这产生了一个极不负责任的分布式闲置打鱼飞船托管接力应用…
- 宇宙深度单机相亲插件 (Intergalactic Tinder) - 在冰冷无声且充满死亡的虚拟天体带里滑动匹配。本应用只允许你在方圆一百光年以内的区域搜寻那些同样加装了…
- 消耗型邪教——胖企鹅神风特攻队 (Kamikaze Drone Factory) - 极具嘲讽与烧钱快感的奢靡玩具工厂。只有你在链上真实并且永远烧毁掉诸如 Pudgy Penguins …
- 深空巨魔喷子:“凯伦”AI (The “Karen” AI Turret) - 当你驾船路过此地时,它既不索要买路钱也不放出激光。它唯一的判定机制是扫描你飞船的尾气排放引擎质量,只…
- 强行发钱的精神病收费站 (Reverse Toll Booth) - 完全不符合经济学常识甚至属于富婆撒币逻辑产物。某位退隐的币圈传奇巨贾注入大额资金建立的怪异星门:只要…
- 混沌真假暗箱魔盒 (Schrödinger’s Cargo Box) - 采用黑手党模式和深层的完全隐秘不透明封装函数手段设计的一个中转储物集装箱平台。你满心期待把一个价值连…
- 全银河致命热土豆极速传花接力赛 (Space Potato Relay Racing) - 将原生的不可消亡底层 Move “Hot Potato” 这种没有 Drop 能力的纯逻辑毒药,转化…
- 风水与命格算命算力仪 (Blockchain Fortune Teller) - 将东方神秘主义通过极客手段搬进了全息投影面板:你路过的某个废弃空间站台不仅仅是一个摆设,当你触碰之时…
- 反常理的圣母白莲花“治愈”治疗仪炮 (The Pacifist Laser) - 这种武器造出来就是为了纯属折磨人并且利用整蛊系统漏洞!发射这种名为光束武器实际则是海量垃圾垃圾冗余运…
- 涂鸦狂人的太空重金贴皮战 (On-Chain Vandalism Graffiti) - 这种黑客协议将矛头直指了任何本应该是庄重肃穆或者属于知名土豪玩家辛苦打造的宇宙标志性豪宅、顶级舰队旗…
- 让人迷失的极简深渊恐惧补丁视觉重构 (The Existential Dread Mod) - 这不是一个简单的扩展插件,它是从深层次心理维度打击那些习惯了无数报表堆砌以及金钱数字狂欢的高端玩家:…
- 缺角材质渲染残缺魔方的拜物邪教 (Cult of the Floating Default Cube) - 因某种游戏内引擎因为偶然一次贴图渲染出错产生的漂浮的完全虚空的灰白紫黑相间的 Default 模型方…
- 公共广播频率超大噪声文字转语音骚扰推车 (Sui-to-Speech Propaganda Network) - 所有只要途径这片归属于这个强大狂躁大联盟统治和看守之下的关隘或者是跳跃航道空间节点。你的船舱内部的无…
- 金字塔顶端庞氏骗局跨界星门 (The Pyramid Scheme Stargate) - 一个利用贪婪和精明伪装出的一种金融收割镰刀游戏系统大网。其实通过这个有着捷径和便利称呼的网关原本是要…
- 利用天文星象玄学走势控制期货市场的盘口系统软件 (Astrological Market manipulation) - 既然在这款全宇宙最充满变故与冰冷数据交织的庞大宇宙经济引擎中无法判断稀有元素在次日的暴跌暴涨。有个怪…
- 附带真正流血效果的残酷老虎机抽箱机 (Space Gacha with Real Punishments) - 对于抽奖文化走火入魔并且带有自毁性质倾向一种极致展现。你不仅在此刻投下了所有可能令你破产的身价积蓄在…
- Stillness 全天候无缝星际公路救援拖车联盟 (Stillness Automated Resupply - SAR) - 一群如同活雷锋一般潜伏于目前仍在测试火并阶段的各个角落。这是基于后台代码自动化时刻待命的一批幽灵护卫…
- 实时前线势力板块变动活点地图应用 (Live Territory Map Integrator) - 脱离单一维度的信息战!这是一套庞大并且能够如同监控中心般俯瞰现在真人在玩在干嘛的超宽屏宏伟态势实时图…
- Stillness 微观经济脉动指数超级彭博机终端 (Stillness Economy Dashboard) - 这里没有任何飞船轰炸这里只有关于利益输送和资本变动最赤裸裸和腥风血雨的数字流动记录中心!开发人员成功…
- 活体打劫海关自动化追缴讨债黑社会系统车队 (Automated Toll Collector Fleet) - 一群利用机器全天自动待命脚本组成并且在关键路口列阵而停的可怕黑道打手!这并不是游戏内预设那些很容易规…
- 现实同频跨界巨星全息太空音乐节实时检票系统门票发行大卖贩售处 (Live Event Ticketing) - 并不是开玩笑!在这款目前有大量高粘性测试人群活跃在服务器的宇宙之中正在确确切切真真正在举办着某个类似…
- 一键报警深空黑水国际安保快反部队紧急护航派发热线 Discord 中心 (Stillness Distress Signal Network) - 极度危险随时都被狙杀在无人工区运送物资矿工的极其必须品!他们开发了一套专门用于通过特定的快捷发报模块…
- 血流成河实时真金白银价值排行悬赏绞肉积分大榜 (On-Chain Killboard for Stillness) - 这是一个充斥满屏幕鲜红色并且全服所有人都极其关注的实时权威并且不可造假的顶尖刺客排名黑榜风云榜!这个…
- Stillness 内鬼监控财务高压线贪污资产全线熔断抓捕冻结器功能模块 (Live Alliance Treason Monitor) - 为了防患那些身居高位但是包场私通或者突然有一天被对家重金收买想要卷款而逃甚至要拉整个公会作为垫被直接…
- 多边形无差别黑心跨区域倒爷超级贸易套利嗅探器前瞻雷达终端 (Stillness Dynamic Supply/Demand Tracker) - 这里不讲究温情仅仅讲究着极致的差利润!这套界面满是高科技密密麻麻数据图不断通过极高的刷新频率全地图范…
- 直播室弹幕与实时对线游戏内实景神仙打斗金主打赏连麦绝杀暗杀指令下注网络 (Live Streamer Bounty Overlay) - 这个模块系统完美模糊了一切什么是场外观众干预什么更是身处于局中当局者的极限打破边界四面墙大作:如果这…
- 代码写手随写随投火线生死时速一键热战区即走即用 CI/CD 直送大炮平台投射器 (Continuous Integration Deployer) - 那些极具疯狂热爱在这个完全由代码决定生死战局的顶尖技术极客前线黑心商人们和防御专家们他们急需的一种极…
- 大过滤器与宇宙审判长不可销毁永远挂榜游街活体历史耻辱柱罪证录档案馆 (Stillness Diplomatic Incident Logger) - 这是没有偏袒也不存在各种公关或者口水战中双方相互推诿并且泼脏水的混乱!这个大记事本终端极其冷冰并且绝…
- 无人机蜂群大军绝对服从冷酷集权控制同步采矿调度全境指挥旗舰模块 (Automated Live Mining Fleet Manager) - 抛弃一切闲散而且总是出错以及摸鱼甚至因为没注意去上厕所导致错失了好几块珍稀大原矿的散漫人工指挥部!这…
- 网络大崩溃与蓝屏宕机自然灾害巨额对冲理赔不讲理保险天灾保障兜底机构 (Live Server Status Oracle) - 游戏总是非常残酷的而且对于还仍然是个巨大草台班子的而且随时可能会因为挤掉线并且各种不完善导致的大炸服…
- 真实活体深空华尔街之狼交易不打烊巨型现期货深蓝星门交割大盘枢纽重置中心 (The Stillness “Stock Exchange”) - 你所以为的买卖还是两艘船小心翼翼如同见不得光的毒贩在某个角落进行微不足道的小数目接触交换防骗的那种极…
- 星系大航海房产黑中介与超级学区房不讲理二手囤积地皮拍卖行应用大炒家平台 (In-game Real Estate Brokerage) - 不要觉得在这个如此广阔并没有边界甚至完全真空连一块落脚的泥土都非常奢侈以及随时都在漂移宇宙中就没有地…
- 星际盖世太保大路条只认衣服不认人无差别人种全场面大清查拦截霸道大安检巨炮网关卡 (Live Contraband Scanner Hub) - 在这场充斥着极度自由并且毫无任何道德规矩或者是底线的极度疯狂甚至什么情况都发生的世界和时间大背景下当…
- 星空荒野大流浪与极度无助遇难荒岛幸存者极限极速极地救援并且能吃热饭热汤极暖心慈善红十字大飞机大公益抢险基金拨款援助总群 DAO 基地 (Stillness Rescue DAO) - 和上面那种随时让你骨头都不剩下和极端恶毒的大炮关口不同!世界上终有善良而且极具大爱充满并且在这个冷冰…
- 深空巨头垄断不眠不休大托拉斯大资本垄断无情超级做市收割巨鳄机器人黑心机器模块全自动割韭菜提款机 (Live Market Maker Bot - MMB) - 你不要极其天真并且极其单纯地去以为在这个充斥这极其无边无际甚至广袤并且完全没有任何约束缺乏且那些各种…
- 全宇宙极度悲惨死人谷永远安息极度恐怖阴风阵阵甚至极其诡异幽静完全荒凉甚至如同禁地一般不仅极其极其巨大甚至庞大无边且死气沉沉完全彻底没有一丝生机大墓碑排行榜大超级甚至带有悼词完全无人敢踏足极大并且绝望超大死人谷活体大纪念坑中心墓地遗迹 (The “Stillness Memorial”) - 在这样一个在这个无时无刻充斥每天随时可能发生极其惨烈并且到处充满了爆炸并且有着各种勾心斗角为了利益连…