多方状态通道方案【WIP】
- 1 一、需求描述
- 1.1 1、目标
- 2 二、需求分析
- 3 三、概要设计
- 3.1 1、总体架构
- 3.2 2、如何防止P2PNode作弊
- 3.3 3、如何选举状态通道的Leader节点
- 3.4 4、如何选择和Leader的最优通信路径问题
- 3.5 5、如何加载状态
- 3.6 6、如何结算状态
- 4 四、详细设计
- 4.1 1、合约设计
- 4.1.1 1.1 状态通道合约类图
- 4.1.2 1.2 给Dapp合约调用的合约接口
- 4.1.2.1 1.2.1 创建状态通道
- 4.1.2.2 1.2.2 加入状态通道
- 4.1.2.3 1.2.3 离开状态通道
- 4.1.2.4 1.2.4 关闭状态通道
- 4.1.3 1.3 给P2P Node的接口
- 4.1.3.1 1.3.1 保持心跳
- 4.1.3.2 1.3.2 创建提案
- 4.1.3.3 1.3.3 对提案进行投票
- 4.1.3.4 1.3.4 执行提案
- 4.2 2、P2P Node
- 4.2.1 2.1 模块图
- 4.2.2 2.2 Stream模块
- 4.2.2.1 2.2.1 加入状态通道
- 4.2.2.2 2.2.2 离开状态通道
- 4.2.2.3 2.2.3 调用状态通道合约函数
- 4.2.2.4 2.2.4 订阅状态通道最新状态
- 4.2.3 2.3 状态通道
- 4.2.3.1 2.3.1 Controller
- 4.2.3.1.1 2.3.1.1 初始化状态通道
- 4.2.3.1.2 2.3.1.2 处理加入状态通道请求
- 4.2.3.1.3 2.3.1.3 处理离开事件/离开状态通道请求
- 4.2.3.1.4 2.3.1.4 处理关闭事件/关闭状态通道请求
- 4.2.3.1.5 2.3.1.5 处理合约调用请求
- 4.2.3.1.6 2.3.1.6 处理状态订阅
- 4.2.3.2 2.3.2 MoveSandbox
- 4.2.3.2.1 2.3.2.1 初始化状态
- 4.2.3.2.2 2.3.2.2 执行交易
- 4.2.3.2.3 2.3.2.3 订阅状态
- 4.2.3.3 2.3.3 Proposals
- 4.2.3.3.1 2.3.3.1 结算状态通道提案
- 4.2.3.3.2 2.3.3.2 惩罚作弊者提案
- 4.2.3.4 2.3.4 Sessions
- 4.2.3.4.1 2.3.4.1 开启会话
- 4.2.3.4.2 2.3.4.2 心跳
- 4.2.3.4.3 2.3.4.3 结束会话
- 4.2.3.5 2.3.5 Peers
- 4.2.3.5.1 2.3.5.1 成员注册
- 4.2.3.5.2 2.3.5.2 处理成员下线
- 4.2.3.5.3 2.3.5.3 维护成员链接
- 4.2.3.5.4 2.3.5.4 检测Leader是否故障
- 4.2.3.1 2.3.1 Controller
- 4.2.4 2.4 Peer Conn Pool
- 4.2.4.1 2.4.1 获取连接
- 4.2.4.2 2.4.2 释放连接
- 4.3 3、SDK
- 4.3.1 3.1 初始化SDK
- 4.3.2 3.2 订阅状态
- 4.3.3 3.3 提供调用状态通道合约的方法
- 4.1 1、合约设计
- 5 四、参考资料
一、需求描述
1、目标
实现基于Move的多方状态通道方案,通过该方案支持多人实时游戏。
二、需求分析
1、用例图
对于多方状态通道系统,Dapp开发者可以API接口或者Stream接口,创建状态通道,加入状态通道,离开状态通道,关闭状态通道,获取状态通道的信息,给状态通道发送交易和订阅状态通道状态变更。
用例名称 | 描述 | 场景 |
---|---|---|
创建状态通道 | Dapp可以通过API创建出一个状态通道,创建状态通道时,可以传入初始资源,作为初始状态。同时需要指定合约代码,来控制状态的变更逻辑。 | 如果Dapp是协同编辑器,那么初始状态为文章的内容,初始合约为编辑器合约,编辑器合约保证同一段内容只能由一个人修改。 如果Dapp为聊天软件,那么初始状态为群信息,包括群成员和历史消息,初始合约为群合约,群合约负责对聊天内容进行排序,对一些铭感词进行过滤。 如果Dapp为Minecraft,那么初始状态为地块内所有方块的位置和材质信息,初始合约为游戏控制器合约,控制地块内的物理规则和碰撞检测。 |
加入状态通道 | 其他Dapp可以通过API加入已经存在的状态通道,加入状态通道时可以带入状态通道支持的资产。 | 对于协同编辑器Dapp,那么加入状态通道,就是参与协同编辑,这里需要做权限控制,防止未授权的人员编辑共享文档。
对于聊天Dapp,加入状态通道可以认为是加入群聊。用户可以通过状态通道的邀请链接加入状态通道。
对于Minecraft应用,加入状态通道,表示用户可以感知到地块了,如果用户在两个地块的交界处,那么Dapp将控制玩家同时加入这两个状态通道。当物体从A地块移动到B地块时,将触发状态通道的离开事件和进入事件。 |
离开状态通道 | 当状态通道的合约检测到对象离开状态通道定义的边界时,将触发离开事件。P2P节点监听到离开事件,将会把状态通道的状态写回区块链网络。 | 对于协同编辑器Dapp,如何用户希望离开编辑空间,可以给当前的状态通道合约发送退出交易,状态通道合约检查是否合法,如果合法将发送退出状态通道事件,同时保存用户编辑的内容到链上。
对于聊天DApp, 离开状态通道表示退出群聊,用户在群里的对话将自动保存到链上。
对于Minecraft,离开状态通道表示离开地块,地块的最新状态将保存回链上。 |
关闭状态通道 | 当用户主动发起关闭状态通道,并且状态通道合约检查合法,将触发状态通道关闭事件。 当最后一个用户离开状态通道时,也将触发状态通道的关闭事件 | 对于协同编辑器Dapp,关闭状态通道,表示大家都强制退出编辑状态。
对于聊天Dapp,退出状态通道表示退出聊天会话。聊天内容将自动上链。
对于Minecraft,关闭状态通达,表明最后一个玩家都已经离开了当前地块,那么当前地块的状态通道将关闭,状态通道的状态将合并回主链。 |
获取状态通道信息 | 状态通道信息,包括: 状态通道的全局唯一ID, 状态通道状态(创建中,运行中,已暂停,已中止), 所有成员的信息(成员地址,成员的PeerAddress,LastAliveTime), LeaderPeerAddress, Epoch, 初始锁仓资源, 初始状态, 状态控制合约代码, 是否加密, 加密公钥 | 对于协同编辑器,获取状态通道信息,查询LeaderPeerAddress,用于同步文章内容到本地,重建最终状态。
对于聊天Dapp, 获取状态通道信息,就是获取群聊信息。对于加密聊天,只有获取私钥的参与方才能加入状态通道,并且消息需要用户本地解密。
对于Minecraft, 获取状态通道信息,就是获取地块相关信息。 |
给发送交易 | 给状态通道发送交易,变更状态通道状态。 | 对于协同编辑器,发送交易,表示用户新增、修改或者删除了某片文本段。
对于聊天Dapp, 发送交易,表示给群聊里发送消息,撤回消息。
对于Minecraft, 发送交易,表示玩家移动、挖方块、放置方块或者合成新方块等。 |
订阅状态通道状态变更 | DAPP通过订阅状态通道的变更事件,在本地内存重建状态模型。包括获取最新的快照和后续的变更流。 | 对于协同编辑器,变更就是对文章的修改操作。
对于聊天Dapp, 变更就是新增的聊天内容,包括撤销的内容。
对于Minecraft,变更就是挖方块,放置方块等操作。 |
2、实体分析
在这个多方的状态通道系统中,存在很多实体,他们存在不同组件之中,一起协作组成状态通道系统。首先Dapp,可以直接通过API创建0到n个状态通道,同时需要连接到1个状态通道的P2PNode, 调用P2PNode的Stream式API,加入0到n个状态通道,Dapp保存状态通道ID和凭证用于后续和状态通道交互。随着状态通道的运行会产生很多交易,P2PNode中的状态通道控制器会定时将多个连续的交易变更集生成一个快照,快照中保留了状态通道某个时刻的最终状态,例如资产移动,资产属性修改等。快照的数据保存在P2PNode上,方便DApp订阅时查询。生成快照后历史交易就可以清理掉释放P2PNode的空间,DApp从P2PNode订阅状态通道的状态信息,包括先获取1个快照,然后从快照内的高度开始拉取状态通道内资产的变更事件。所有的交易都需要通过MoveVM验证合法性,才能同步给其他P2PNode。
三、概要设计
1、总体架构
Settlement Chain: 支持结算的链,可以是AptOS、Sui 或者 Rooch。
蓝色的P2P Node: 状态通道的Leader 节点,所有对状态通道的写操作,都需要转发给Leader节点
白色的P2P Node: 状态通道的Follower 节点,从Leader 节点同步最新的交易并验证执行。
P2P Node as Brower Plugin: 在浏览器插件中运行的P2P Node.
Dapp 分 Client代码和 Constract代码,Constract代码即可以运行在Settlement Chain上也可以运行在状态通道中。
2、如何防止P2PNode作弊
所有参与状态通道的P2P Node 需要抵押一定的虚拟资产,如果P2P Node 作弊或者不合作。将扣除抵押的原虚拟资产。为了防止每次进入状态通道都质押资产,用户可以选择将资产质押给状态通道共享质押合约。这样用户进入状态通道时,指定使用共享质押合约作为抵押就可以。当用户的P2P Node出现作弊或者不合作将从共享质押合约中扣出资产。
作弊的场景:
场景 | 1人状态通道 | 两人状态通道 | 三人状态通道 | n人状态通道 |
---|---|---|---|---|
Leader 修改MoveVM实现/合约代码 | ? | Follower 本地运行立即可以发现,然后举报,合约强制Leader退出通道,5分钟内不许再次进入,不罚款 | 任何Follower 本地运行立即可以发现,然后举报,两票赞成后,合约强制Leader退出状态通道,5分钟内不许再次进入,并罚款 | 任何Follower 本地运行立即可以发现,然后举报,两票赞成后,合约强制Leader退出状态通道,5分钟内不许再次进入,并罚款 |
Leader 丢弃/延迟对手的交易 |
| Follower发现Leader没有回复自己的交易,可以举报,合约关闭状态通道,不罚款 | Follower发现Leader没有回复自己的交易,可以举报,合约关闭状态通道,不罚款 |
|
3、如何选举状态通道的Leader节点
所有加入状态通道的成员,需要和合约保持心跳,15s内有心跳的成员认为是有效成员。
当状态通道小于等于5个成员:
第一个进入状态通道的成员,查询状态通道状态,没有Leader, 将调用合约方法申请成为Leader, 合约检查只有1个有效成员,将验证通过。
其他成员进入状态通道的成员,查询状态通道状态,发现有Leader, 他将从Leader同步状态,并和Leader保持心跳, 如果和Leader 15s 心跳没有响应,他调用状态通道合约申请自己为Leader, 合约检查之前的Leader如果有效将拒绝申请,如果之前的leader无效了,将通过申请。
对应申请失败的成员,将再次查询状态通道的最新状态,并和最新的Leader保持状态同步。
当状态通道大于5个成员:
需要选举5个成员作为候选成员,其他成员需要将投票权委托给候选成员,每个候选成员最大只能接受n/5-1个其他成员的委托。候选成员需要提供代理连接Leader节点功能,其他成员优先通过候选成员和Leader通讯。
问题:
选举过程中状态通道不可写?
Leader故障如何快速切换?
4、如何选择和Leader的最优通信路径问题
状态通道的Follower需要从Leader同步状态和转发交易,但是有可能Follower直连Leader响应延迟没有经其他Follower中转效率高,所以为了发现最优路径。所有的和Peer的心跳包需要带上和Leader通信的最优延迟。
例如:
Follower2 和 Leader的通信的最优路径是经过Follower3,那么他和Follower1的小跳包,需要带上和Leader通信的最优延迟10ms
Follower3和Leader通信的最优延迟为5ms
Follower1发现:
Peer | 和Leader的最优延迟 | 和自己通信延迟 | 和Leader通信总延迟 |
---|---|---|---|
Leader | 0ms | 100ms | 100ms |
Follower2 | 10ms | 5ms | 15ms |
Follower3 | 5ms | 20ms | 25ms |
那么Follower1 将选择经过Follower 和 Leader通信。
5、如何加载状态
首先Leader从链上状态通道合约中获取状态通道的代码和初始账号状态。为了防止Leader加载过程中有其他成员加入,每个加入成员需要分配一个加入序号,Leader需要记录他加载时的最大序号,Leader加载成功后,其他成员从Leader同步初始状态,并也从链上加载状态通道状态并执行验证。当有新成员加入时,加入序号加1,有Leader验证后并同步给其他成员节点。
6、如何结算状态
任何状态通道成员可以发起结算提案,一般状态通道的Leader会定时发起结算提案,有成员要离开状态通道时也会发起结算提案。结算提案包括结算的起始高度、结算高度、状态通道的变更集和发起方的签名。发起方创建结算提案后,需要通知所有Peer对提案进行投票,Peer收到通知后会使用本地交易数据对提案进行验证,验证的逻辑是计算起始高度和结算高度的变更集是否和提案中的变更集一致,如果一致认为提案没有问题,可以投赞成票,如果不一致投反对票。当发起方收到2/3的赞成票后,发起方执行提案,完成状态通道的结算。状态通道结算完成后,所有成员可以生成结算高度的快照,并清理结算高度之前的交易数据。
四、详细设计
1、合约设计
状态通道的合约包括两部分,一部分给Dapp合约调用的合约接口,另一部分给P2P Node调用的合约接口。
1.1 状态通道合约类图
使用示例
协同编辑器合约示例:
module rooch_demo::editor {
use std::string::{String, utf8};
use aptos_std::table::{Self, Table};
use aptos_framework::timestamp;
const EDITOR_ADDRESS:address = @rooch_demo;
const ERR_NOT_CONTRACT_OWNER: u64 = 0;
const DRIVE_ALREADY_REGISTERED: u64 = 1;
const ERR_ALREADY_INITIALIZED: u64 = 2;
struct File has store, copy, drop {
id: u64,
name: String,
content_type: vector<u8>,
content_hash: vector<u8>,
owner: address,
create_time: u64,
last_update_time: u64,
}
struct Folder has store, copy, drop {
id: u64,
name: String,
file_ids: vector<u64>,
folder_ids: vector<u64>,
owner: address,
create_time: u64,
last_update_time: u64,
}
struct Drive has key, store {
name: String,
files: Table<u64, File>,
folders: Table<u64, Folder>,
root_folder_id: u64,
next_file_id: u64,
private: bool,
public_key: String,
owner: address,
}
struct Element has store, copy, drop {
id: u128,
type: u16,
attributes: SimpleMap<String, String>,
children_ids: vector<u64>,
text: String,
}
struct Document has store, copy, drop {
drive_address: u64,
file: File,
title: vector<u8>,
elements: Table<u64, Element>,
root_element_id: u64
}
struct EditingDocument store {
drive_address: address,
file_id: u64,
state_channel_id: u64
}
struct DocumentEditor has store {
editingDocuments: Table<u64, EditingDoc<Document>>
}
public fun initialize(account: &signer) {
let account_addr = Signer::address_of(account);
assert!(account_addr==EDITOR_ADDRESS, Errors::requires_address(ERR_NOT_CONTRACT_OWNER));
assert!(!exists<DocumentEditor>(account_addr), Errors::already_published(ERR_ALREADY_INITIALIZED));
move_to(account, DocumentEditor{
editingDocuments: table::new<u64, EditingDoc<Document>>(),
})
}
public fun drive_register(account: &signer, name: vector<u8>, public_key: vector<u8>, private: bool) {
let addr = signer::address_of(account);
assert!(!exists<Server>(addr), error::already_exists(DRIVE_ALREADY_REGISTERED));
let drive = Drive {
name: utf8(name),
files: table::new<u64, File>(),
folders: table::new<u64, Folder>(),
root_folder_id: 0,
next_file_id: 0,
private: private,
public_key: public_key,
owner: account,
};
let rootFolder = Folder {
id: 0,
name: utf8(b'/'),
files: vector::empty<u64>(),
folders : vector::empty<u64>(),
owner: account,
create_time: timestamp::now_microseconds(),
last_update_time: 0,
}
vector::append<address>(&mut drive.folders, rootFolder);
drive.root_folder_id = rootFolder.id;
drive.next_file_id = drive.next_file_id + 1;
move_to(account, drive);
}
}
聊天合约示例:
module rooch_demo::chat {
use std::vector;
use std::string::{String, utf8};
use aptos_std::table::{Self, Table};
use rooch::state_channel::{Self, StateChannel};
const SERVER_ALREADY_REGISTERED: u64 = 0;
const CHAT_ACCOUNT_ALREADY_REGISTERED: u64 = 1;
struct Message has store {
id: u64,
type: u16,
props: SimpleMap<String, String>
content: String,
publish_time: u64
}
struct ChatGroup has store {
id: u64,
name: String,
admins: vector<address>
messages: Table<u64, Message>,
server_id: u64,
public_key: String,
create_time: u64,
}
struct ChatSession has store,copy,drop {
chat_group_id: u64,
state_channel_id: u64
}
struct Server has key {
name: String,
chatGroups: vector<ChatGroup>,
chatSessions: Table<u64, ChatSession>,
admins: vector<address>,
next_chat_group_id: u64,
}
struct ChatAccount has key {
name: String,
groups: Table<u64, PrivateKey>,
sessions: vector<ChatSession>,
}
public fun server_register(account: &signer, name: vector<u8>) {
let addr = signer::address_of(account);
assert!(!exists<Server>(addr), error::already_exists(SERVER_ALREADY_REGISTERED));
let server = Server {
name: utf8(name),
chatGroups: vector::empty<ChatGroup>(),
chatSessions: table::new<u64, ChatSession>(),
admins: vector::empty<address>(),
next_chat_group_id: 0,
};
vector::append<address>(&mut server.admins, addr);
move_to(account, server);
}
public fun chat_account_register(account: &signer, name: vector<u8>) {
let addr = signer::address_of(account);
assert!(!exists<ChatAccount>(addr), error::already_exists(CHAT_ACCOUNT_ALREADY_REGISTERED));
let chat_account = ChatAccount{
name: utf8(name),
groups: table::new<u64, PrivateKey>(),
sessions: table::new<u64, ChatSession>(),
};
move_to(account, chat_account);
}
}
MoveCraft合约示例:
module rooch_demo::movecraft {
use std::vector;
use std::string::{String, utf8};
use aptos_std::table::{Self, Table};
use rooch::state_channel::{Self, StateChannel};
const SERVER_ALREADY_REGISTERED: u64 = 0;
const CHAT_ACCOUNT_ALREADY_REGISTERED: u64 = 1;
struct Vector3 has store,copy,drop {
x: u128,
y: u128,
z: u128,
}
struct Position has store,copy,drop {
x: u128,
y: u128,
}
struct Rectangle has store,copy,drop {
top_left: Position,
bottom_right: Position,
}
struct Block has store {
id: u64,
type: u16,
props: SimpleMap<String, String>,
create_time: u64
}
struct Land has store {
id: u64,
name: String,
boundary: vector<Rectangle>
messages: Table<u64, Message>,
world_address: address,
owner: address,
create_time: u64,
}
struct Inventory has store {
blocks: SimpleMap<u16, vector<Block>>,
}
struct LandSession has store,copy,drop {
world_address: address,
land_id: u64,
channel_id: u64,
create_time: u64,
}
struct World has key {
name: String,
type: u8,
seed: u128,
blocks: Table<Vector3, Block>,
lands: Table<u64, Land>,
sessions: vector<LandSession>,
next_chat_group_id: u64,
}
struct GameAccount has key {
name: String,
sessions: vector<LandSession>,
}
public fun world_register(account: &signer, name: vector<u8>) {
let addr = signer::address_of(account);
assert!(!exists<Server>(addr), error::already_exists(SERVER_ALREADY_REGISTERED));
let server = Server {
name: utf8(name),
chatGroups: vector::empty<ChatGroup>(),
chatSessions: table::new<u64, ChatSession>(),
admins: vector::empty<address>(),
next_chat_group_id: 0,
};
vector::append<address>(&mut server.admins, addr);
move_to(account, server);
}
public fun game_account_register(account: &signer, name: vector<u8>) {
let addr = signer::address_of(account);
assert!(!exists<ChatAccount>(addr), error::already_exists(CHAT_ACCOUNT_ALREADY_REGISTERED));
let chat_account = ChatAccount{
name: utf8(name),
groups: table::new<u64, PrivateKey>(),
sessions: table::new<u64, ChatSession>(),
};
move_to(account, chat_account);
}
}
1.2 给Dapp合约调用的合约接口
1.2.1 创建状态通道
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
state |
| 初始状态 |
config | Config | 状态通道配置 |
返回值:
返回值 | 类型 | 能力 | 描述 |
---|---|---|---|
state_channel_id | u64 |
| 状态通道ID |
业务逻辑:
使用一个自定义的初始状态来创建一个状态通道。状态通道的合约代码通过状态的模块自动提取。
使用示例:
协同编辑器,创建状态通道
聊天示例:
MoveCraft示例:
1.2.2 加入状态通道
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 资产类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | &signer | 发起方 |
state_channel_id |
| 状态通道ID |
assets | Assets | 带入状态通道的资产 |
事件:
事件名称 | 事件数据 | 描述 |
---|---|---|
state_channel_join_event | { “state_channel_id“: u64, “member_address“: address, } | 加入状态通道事件 |
返回值:
无
业务逻辑:
加入某个状态通道,指定需要带入的资产。自动扣除押金。如果押金余额不够将加入失败。
示例:
协同编辑器,加入状态通道
聊天合约,加入群聊
MoveCraft合约,加入地块
1.2.3 离开状态通道
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | signer | 发起方 |
state_channel_id |
| 状态通道ID |
事件:
事件名称 | 事件数据 | 描述 |
---|---|---|
state_channel_leave_event | { “member_address“: “vector<u8>“, } | 离开状态通道事件 |
返回值:
无
业务逻辑:
离开某个状态通道,同时触发状态通道结算,其他用户可以继续使用该状态通道。
示例:
协同编辑器,离开状态通道
聊天合约,离开群聊
MoveCraft合约,离开地块
1.2.4 关闭状态通道
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | signer | 发起方 |
state_channel_id |
| 状态通道ID |
事件:
事件名称 | 事件数据 | 描述 |
---|---|---|
close_state_channel_event | {
} | 关闭状态通道事件 |
返回值:
无
业务逻辑:
关闭某个状态通道,同时触发状态通道结算。
示例:
协同编辑器,关闭状态通道
聊天合约,关闭群聊
MoveCraft合约,关闭地块
1.3 给P2P Node的接口
1.3.1 保持心跳
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | &signer | 发起方 |
state_channel_id |
| 状态通道ID |
返回值:
无
业务逻辑:
更新状态通道成员的, lastAliveTime
调用示例:
协同编辑器
聊天合约
MoveCraft合约
1.3.2 创建提案
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | signer | 发起方 |
state_channel_id |
| 状态通道ID |
proposal_type | u8 | 提案类型 |
proposal_data | vector<u8> | 提案数据 |
当 proposal_type == 1 时,表示结算提案
proposal_data 数据格式:
当 proposal_type == 2 时,表示惩罚提案
proposal_data 数据格式:
返回值:
无
业务逻辑:
发起提案。根据提案类型创建不同的提案。
调用示例:
协同编辑器
聊天合约
MoveCraft合约
1.3.3 对提案进行投票
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | signer | 发起方 |
state_channel_id |
| 状态通道ID |
proposal_id | u64 | 提案ID |
vote_option | u8 | 投票选项: 0:反对 1: 赞成 2:弃权 |
返回值:
无
业务逻辑:
对提案投票。
调用示例:
协同编辑器
聊天合约
MoveCraft合约
1.3.4 执行提案
类型参数:
类型参数 | 约束 | 描述 |
---|---|---|
| store | 状态类型 |
ProposalAction | store | 提案Action |
参数:
参数名称 | 类型 | 描述 |
---|---|---|
sender | signer | 发起方 |
state_channel_id |
| 状态通道ID |
proposal_id | u256 | 提案ID |
返回值:
ProposalAction
业务逻辑:
执行提案,检查提案是否投票通过,如果投票通过返回提案Action。
调用示例:
协同编辑器
聊天合约
MoveCraft合约
2、P2P Node
2.1 模块图
2.2 Stream模块
Steam模块,维护和SDK的双向流连接,并转发SDK的请求给对应的状态通道实例。
2.2.1 加入状态通道
命令:state_channel.join
参数:
参数 | 类型 | 描述 |
---|---|---|
sender | string | 用户钱包地址 |
state_channel_id | string | 状态通道ID |
错误码:
错误码 | 描述 |
---|---|
100001 | 状态通道不存在 |
2.2.2 离开状态通道
命令:state_channel.leave
参数:
参数 | 类型 | 描述 |
---|---|---|
sender | string | 用户钱包地址 |
state_channel_id | string | 状态通道ID |
错误码:
错误码 | 描述 |
---|---|
100001 | 状态通道不存在 |
2.2.3 调用状态通道合约函数
命令:state_channel.call
参数:
参数 | 类型 | 描述 |
---|---|---|
sender | string | 用户钱包地址 |
state_channel_id | string | 状态通道ID |
function | string | 函数名称 |
ty_args | []string | 泛型参数 |
args | []string | 参数 |
错误码:
错误码 | 描述 |
---|---|
100001 | 状态通道不存在 |
2.2.4 订阅状态通道最新状态
命令:state_channel.subscribe_state
参数:
参数 | 类型 | 描述 |
---|---|---|
sender | string | 用户钱包地址 |
state_channel_id | string | 状态通道ID |
错误码:
错误码 | 描述 |
---|---|
100001 | 状态通道不存在 |
返回值:
状态通道状态的快照。
2.3 状态通道
状态通道模块,负责状态通道的状态的分发和验证。
2.3.1 Controller
状态通道控制器,负责处理Sessions模块和Peers模块发送过来的请求。对于游戏类应用主动产生Tick交易。
2.3.1.1 初始化状态通道
业务逻辑:
当状态通道不存在时,调用状态通道合约接口,获取合约详细信息,检查自己是不是 Leader ,
如果是Leader:
就加载初始状态到MemStore,合约代码到 MoveVM,然后监听 state_channel.call 消息,如果有消息就执行合约调用,把调用产生的副作用,广播给其他 Peer 节点和连接的所有客户端。设置一个定时,触发Tick,运行合约的 OnTick 函数,如果有副作用产生就广播给Peer节点和连接的所有客户端。
如果是Follower:
和Leader Peer 建立连接, 然后从Leader Peer 获取通道的初始状态和合约代码。然后监听Leader发送过来的消息。如何是合约执行结果消息,就在本地的MoveVM中执行,如果执行产生的副作用和Leader一致,就广播给客户端。如果不一致,就调用主链的合约创建一个提案,惩罚Leader, 并请求其他Peer投票,如果收到的投票数足够,就执行提案惩罚Leader,获取罚金。
如果状态通道配置有Tick函数和间隔:
配置定时器,触发Tick交易。
2.3.1.2 处理加入状态通道请求
业务逻辑:
如果是Leader:
当某个SDK发送加入状态通道请求时,需要检查链上状态通道是否存在该成员地址,如果不存在拒绝加入请求。如果存在,创建成员加入交易,并将执行结果和交易一起广播给其他Peer。
如果是Follower:
当从Leader收到加入状态通道请求,从链上获取成员状态和带入的资产,验证交易是否合法。如果合法更新本地MoveSandbox状态,如果不合法,发起惩罚Leader的提案。
2.3.1.3 处理离开事件/离开状态通道请求
业务逻辑:
当调用合约或者调用Tick函数,如果返回的事件包括离开状态通道事件 或者 用户主动发起离开状态通道API调用:就发起提案结算状态通道的状态,投票达标后执行状态结算,结算成功后通知对应的Peer下线。
2.3.1.4 处理关闭事件/关闭状态通道请求
业务逻辑:
当调用合约或者调用Tick函数,如果返回的事件包括关闭状态通道事件 或者 用户主动发起关闭状态通道API调用:就发起提案结算状态通道的状态,投票达标后执行状态结算,结算成功后通知所有的Peer下线。
2.3.1.5 处理合约调用请求
业务逻辑:
当用户调用合约时,判断自己是否为Leader, 如果为Leader, 在本地执行合约调用,将结果广播给其他Peers, 然后保存到本地Store. 如果自己不是Leader, 将合约调用转发给Leader.
2.3.1.6 处理状态订阅
业务逻辑:
当用户请求订阅状态时,从本地获取快照返回给调用方,同时从快照高度开始从本地Store获取交易,在MoveVM中执行,并将执行产生的状态变更发送给调用方,如果已经运行过,直接返回状态变更。当从Leader接受到新的交易时,在MoveVM中运行,和Leader的结果对比,如果相同,则将变更转发给调用方,如果不相同发起举报投票。
2.3.1.7 处理投票请求
业务逻辑:
当收到其他成员发送的请求投票请求,
对于结算提案的投票:
首先验证投票的内容是否和本地的MoveSandbox状态一致,如果一致投赞成票,如果不一致投反对票。
对于惩罚Leader的投票:
首先使用本地的MoveSandbox状态验证Leader是否作弊,如果验证结果为Leader确实有作弊,投赞成票,否则投反对票。
2.3.2 MoveSandbox
MoveSandbox负责运行状态通道中的合约,生成新的状态变更,同时提供状态订阅功能。
2.3.2.1 初始化状态
2.3.2.2 执行交易
2.3.2.3 订阅状态
2.3.3 Proposals
2.3.3.1 结算状态通道提案
当有P2P成员希望离开状态通道时,需要发起结算状态通道提案。提案成功后方可离开状态通道,如果没有发起结算状态通道提案就离开状态通道,认为弃权,后续投票默认弃权。
提案参数:
参数 | 类型 | 描述 |
---|---|---|
state_channel_id | string | 状态通道ID |
from_height | u128 | 状态通道起始高度,需要和链上状态通道中的已结算高度匹配 |
to_height | u128 | 状态通道结算高度 |
change_sets | vector<u8> | 起始高度到待结算高度的所有变更集 |
2.3.3.2 惩罚作弊者提案
当状态通道收到Leader发送过来的同步消息,并在MoveVM验证结果不对时,可以发起Leader作弊的惩罚提案,如果提案执行成功,发起人为新的Leader.
提案参数:
参数 | 类型 | 描述 |
---|---|---|
target_address | string | P2P地址地址类型 |
state_channel_id | string | 状态通道ID |
raw_transaction | string | 原始交易 |
change_sets | vector<u8> | 改原始交易对应的变更集 |
target_sign | string | 目标地址对该交易的签名 |
2.3.4 Sessions
2.3.4.1 开启会话
当一个新的Dapp客户端连接上来时,需要创建一个会话来管理客户端连接。
会话开启后会自动订阅状态通道的状态。
2.3.4.2 心跳
会话开启后,需要客户端每5s给会话发送一个心跳消息,如果15s没有收到心跳,将触发离开状态通道流程。
2.3.4.3 结束会话
当Dapp客户端主动发起离开状态通道消息,或者超过15s没有收到心跳,将触发离开状态通道流程,流程结束后将关闭会话,释放会话资源。
2.3.5 Peers
状态通道成员管理。
2.3.5.1 成员注册
当新加入一个状态通道时,先查询状态通道合约,获取Leader的P2P地址,和Leader建立链接。
从Leader获取其他成员的地址,依次和其他成员建立链接。
从Leader订阅最新的状态通道状态。
如何合约中没有Leader, 将自己注册成Leader.
2.3.5.2 处理成员下线
当成员下线时,Leader会广播成员下线消息,收到成员下线消息,可以主动断开和成员的P2P链接。
2.3.5.3 维护成员链接
当和某个成员端口链接时,自动重连,直到收到Leader广播某个成员下线的消息。
2.3.5.4 检测Leader是否故障
和Leader保持心跳,如果15s没有收到心跳反馈,认为Leader下线,通知Controller发起选主提案。
2.4 Peer Conn Pool
当状态通道需要和Peer建立P2P连接时,为了让多个状态通道共享连接,这里使用连接池来管理。
该连接池支持获取连接和释放连接操作。
2.4.1 获取连接
当首次从连接池获取连接时,将触发建立连接操作,连接建立后放入池中,当有新的状态通道需要获取连接时,如果和Peer的连接已经存在,则直接返回连接池中的连接,同时将连接的使用次数加1。
2.4.2 释放连接
当状态通道需要释放和Peer的连接时,连接池的使用次数减1,当使用次数为0时,关闭物理连接。
3、SDK
3.1 初始化SDK
业务逻辑:
和状态通道P2PNode建立连接。发送请求加入某个状态通道。
3.2 订阅状态
业务逻辑:
发送请求给P2PNode, 请求订阅状态通道状态,P2PNode返回初始快照,和一个推送流。
3.3 提供调用状态通道合约的方法
业务逻辑:
准备调用合约的函数和参数,将请求发送给P2PNode.
四、参考资料
https://www.zhihu.com/question/368327749
https://zhuanlan.zhihu.com/p/66392432
https://cookbook.starcoin.org/zh/docs/concepts/multisig/
https://wiki.biligame.com/mc/%E5%AE%9A%E5%88%B6%E6%9C%8D%E5%8A%A1%E5%99%A8 《MineCraft定制服务器》
https://www.zhihu.com/question/24459078
https://www.bilibili.com/read/cv4243015/
https://mineplugin.org/Protocol