基于UTXO的Actor模型设计与嵌套调用中间表达
前面,我论述了两种在UTXO模型上实现可变状态访问的方案,即基于 Intent-Script ( General Composable Intent Script(Covenant) and Asynchronous Invocation Paradigm - English / CKB Development & Technical Discussion - Nervos Talk),或者基于Open Transaction ( Dynamic Fine-Grained Cobuild OTX(PSBT) Design - English / CKB Development & Technical Discussion - Nervos Talk),用BTC的术语来说,可以称为基于Covenant,或者基于PSBT的方案。
使用这两个方案,可以让用户的意图串行被dApp所处理,但是在之前的方案中,我只论述了用户如何将意图发送给dApp,以及dApp之间如何利用intent cell异步通信,而没有论述dApp之间应该如何同步通信,乃至于串联多个dApp的同步调用。
Actor模型是一种计算的数学理论,基于 Actors 的概念。 Actor是计算的基本单元,它体现为三件事:
- 信息处理(计算)。
- 存储(状态)
- 通信
本方案将借助 Actor模型的思路来论述,每个 dApp 都接收来自用户的意图,以及来自其他 dApp 的请求,同时也可以给其他 dApp 来发送请求,但是由于 UTXO 并非链上计算系统,而是链下确定性的,亦即链上的 inputs 和 outputs 只有最初和最终的状态,而没有中间状态。
本方案将试图解决这个问题,通过在Witness 内固化整个Actor通信流程。
首先对本方案的应用进行定义,每个应用大体上会包含以下Cell:
- Configs Cell:
- data内放置整个应用依赖的配置
- typescript 符合 type_id 规则,以保证全局唯一。
- Lock 决定了谁能修改configs,也可以将lock设置为always-success,在 configs cell 的 typescript 里控制修改。
- Actor Cell:
- data内会放置应用的全局状态数据
- type script 符合type_id 规则,以保证全局唯一,同时 type script 通过扫描所有的输入 (intent-script or OTX),并处理意图,同时 type script 还应该扫描所有来自其他 Actor Cell 的请求,以及扫描自己对其他 Actor Cell 请求的返回。type script 还需要检查自己控制的 Asset Cell 和 Data Cell 经过处理完所有的意图和请求后的输出,与交易实际的输出一致。
- lock决定了谁能解锁Actor Cell,也可以将lock设置为always-success,在 typescript 里控制修改。
- Asset Cell:如 UDT Cell,DOB Cell,Capacity Cell
- Data 一般由资产 type 所决定。
- type script:定义资产类型
- lock:proxy lock,一般为 input_type_proxy,即通过判断 inputs 中是否存在对应的 type script 来判定是否解锁,而对应 Actor Cell 的 type script 则负责检查 Asset Cell 的前后变化是否符合规则。
- Data Cell:用于存储某些长度不定的数据, 如类似可追加数组,key-value字典等。
- Data 一般存储需要的data。
- type script:定义数据类型
- lock:可以用lock控制数据修改,也可以将lock设置为alway-success,把权限管理整合进唐type。
- 示例:
- 基于链表的 Data Cell,通过其type判定其确实是Actor Cell所存储的数据,直接从 cellDeps,或者inputs中读取数据使用。
- 基于累加器的Data Cell,通过其 type 判定其确实是Actor Cell所存储的数据,将需要访问的数据以及数据的证明放置在 Witness内,供 Actor Cell 读取使用。由于基于累加器的链上Data长度固定,所以通常可以把这一功能内置在 Actor Cell 的type script内更方便。
应用举例:
- Rollup:
- Rollup将在Configs Cell内放置配置
- 使用Asset Cell存放跨进Rollup中的资产
- 同时使用累加器处理 Rollup 的数据
- 用户通过 intent-cell 或者 OTX 来向 Rollup Deposit 资产
- Rollup type script 将负责检验链下状态转换的正确性,通过欺诈证明或者有效性证明。
- 使用某种共识算法的检验lock来决定 Rollup Cell的解锁。
- 侧链:
- 在Configs Cell内放置配置
- 使用Asset Cell存放跨进侧链中的资产
- 在 Cell 中存储侧链的状态根
- 用户通过 intent-cell 或者 OTX 来进行资产跨链
- 侧链 type script将负责检验跨链进出的正确性
- 使用某种共识算法的检验lock来决定侧链 Cell的解锁。
链上验证流程
前面论述了在 Cell 模型上构造基于Actor构造的基本结构,但仍未论述如何实现嵌套 Actor 的互相调用。
数据结构
table CrossCellInvocation {
from_script_hash: Byte32, // 调用方的脚本哈希
from_location: byte, // 调用方位置,例如 input_lock、input_type 或 output_type
target_script_hash: Byte32, // 被调用方的脚本哈希
target_location: byte, // 目标位置,例如 input_lock、input_type 或 output_type
target_index: u32, // 目标在处理序列中的索引
success: byte, // 调用是否成功,0 表示成功,非 0 表示失败,可使用错误码
signature: Byte8, // 方法签名
inputs: Bytes, // 输入参数
outputs: Bytes, // 输出结果
}
vector CrossCellInvocationVec <CrossCellInvocation>;
table IntentProcessTrace {
intent_index: u32, // Intent 的索引
success: byte, // Intent 处理是否成功
invocations: CrossCellInvocationVec, // 调用序列
}
vector IntentProcessTraceVec <IntentProcessTrace>;
table IntentProcessTraces {
receiver_script_hash: Byte32, // 接收者的脚本哈希
traces: IntentProcessTraceVec, // Intent 处理跟踪列表
}
修改:
-
为了节省空间,可以将数据结构里所有的 script_hash 变成 script_index,如 from_script_hash 变成 script_index,receiver_script_hash 变成 receiver_script_index,通过index和script_location,需要使用的时候可以通过 load_lock_hash 和 lock_type_hash 获取到对应的script_hash使用,这将大大减少空间占用。、
-
关于
target_index
的修改,为了简化,将数据结构做如下的修改:struct TargetIndex { target_script_hash: Byte32, // 被调用方的脚本哈希 target_location: byte, // 目标位置,例如 input_lock、input_type 或 output_type target_index: u32, // 目标在处理序列中的索引 } vector TargetIndexVec <TargetIndex>; table IntentProcessTrace { intent_index: u32, // Intent 的索引 target_indexs: TargetIndexVec // 所有涉及到的target的索引 success: byte, // Intent 处理是否成功 invocations: CrossCellInvocationVec, // 调用序列 }
即从每个
CrossCellInvocation
中删除target_index
这一项,而在IntentProcessTrace
里增加一项target_indexs
记录所有 target 的 index。
本文后面的论述并未按照上述改动修改,因为上述改动旨在减少空间占用,以及让结构显得更清晰,但不影响具体的设计逻辑。
Actor Cell Type Script 的状态转换流程
1. 初始化内存状态
- 读取资产和数据:
- Actor 首先从交易的输入(Inputs)中,读取属于自身的资产 Cell 和数据,将当前状态缓存到内存中。
- 维护处理计数器:
- 维护一个计数器
processed_intent_count
,初始值为 0。 - 每当 Actor 处理完一个 intent 后,该计数器加一,无论是直接处理自己的 intent,还是在处理过程中被其他 Actor 调用的 intent。
- 该计数器用于对链上处理进行排序,确保处理的顺序性和一致性。
- 维护一个计数器
2. 扫描 Witness 和 Inputs
-
扫描 Witness:
- 找到相关的 IntentProcessTraces:
- 找到所有以自身
receiver_script_hash
为目标的IntentProcessTraces
。 IntentProcessTrace
的intent_index
代表了该 trace 与第几个 intent 的处理对应。由于 Actor 可能作为被调方参与其他 intent 的处理,intent_index
可能不连续。
- 找到所有以自身
- 找到针对自身的 IntentProcessTraces:
- 在所有的
IntentProcessTrace
中,找到invocations
列表中target_script_hash
为自身且receiver_script_hash
非自身的CrossCellInvocation
。 - 这些代表了其他 Actor 对自身的调用,其中target_index代表这个intent在自己处理序列中位置。
- 在所有的
- 一致性检查:
- 在同一个
IntentProcessTrace
中,对于同一个 Actor 作为target
,target_index
应该完全一致。 target_index
代表该 intent 在 Actor 处理序列中的 intent 索引。- 示例: 如果
target_index
为 2,表示这是 Actor 处理的第三个 intent。
- 在同一个
- 找到相关的 IntentProcessTraces:
-
扫描 Inputs:
- 处理 Intent Cell 和 Open Transaction Inputs:
- 扫描所有对自身 Actor Cell 进行调用的 Intent Cell 或 Open Transaction(OTX)。
- 处理过程中涉及对内存状态的改变,例如更新资产、修改数据等。
- 处理 Intent Cell 和 Open Transaction Inputs:
3. 处理模式
每个 Actor 有两种进入 intent 处理 trace 的模式:
-
直接作为 Intent 的接收者(receiver):
- Actor 直接处理针对自身的 intent。
- 处理步骤:
- 根据
IntentProcessTrace
中的invocations
,按照顺序执行。 - 检查每个
invocation
是否与自身的预期操作一致,如果不一致则报错。
- 根据
-
被其他 Actor 间接调用:
- Actor 被其他 Actor 间接调用。
- 在调用其他 Actor 时,需要检查对应的 Actor Cell 是否在交易的输入(UTXO)中存在。
- 处理顺序:
- 对于被调用的 Actor,其在对应
IntentProcessTrace
中的target_index
,代表了该 intent 在自身处理 intent 队列中的位置。 - 例如,
target_index
为 0,表示该 intent 是 Actor 应该首先处理的 intent,无论是直接还是间接调用。
- 对于被调用的 Actor,其在对应
- 按照顺序处理 invocations:
- 对于每个
IntentProcessTrace
,按照invocations
的顺序处理整个 intent 的调用序列。 - 找到自身作为调用方(
from_script_hash
为自身)和被调方(target_script_hash
为自身)的CrossCellInvocation
。 - 所有的调用都需要与自身的处理流程匹配,确保一致性。
- 对于每个
4. Actor 处理一个 Trace
(1)作为调用方
-
查找匹配的 Invocation:
- 在需要请求其他 Actor 时,找到对应的
IntentProcessTrace
的invocations
,按照顺序执行。 - 找到满足以下条件的
CrossCellInvocation
:from_script_hash
为自身的脚本哈希。target_script_hash
为被调用 Actor 的脚本哈希。signature
与调用的方法签名一致。inputs
与调用的输入参数一致。
- 然后读取
invocation
中的outputs
,继续处理。 - 错误处理: 如果找不到匹配的
invocation
,则报错并终止处理。
- 在需要请求其他 Actor 时,找到对应的
-
支持循环调用:
- 如果在找到匹配自身作为调用方的
invocation
之前,遇到其他 Actor 调用自己的invocation
,说明存在循环调用。 - 可以先执行自身作为被调用方的
invocation
,然后继续处理。 - 错误处理: 如果扫描到一个
invocation
,其from_script_hash
为自身但无法匹配,则直接报错。
- 如果在找到匹配自身作为调用方的
(2)作为被调用方
-
检查 Invocation:
- 检查
signature
对应的方法,使用invocation.inputs
作为输入,执行方法逻辑。 - 验证执行结果的
success
和outputs
是否与invocation
中的success
和outputs
一致。 - 错误处理: 如果不一致,则报错并终止处理。
- 检查
-
处理循环调用:
- 如果在处理过程中,没有其他 Actor 调用自身,但出现了自身调用其他 Actor 的
invocation
,说明是循环调用。 - 将这些自身作为调用方的
invocation
压入栈中,直到读取到第一个自身被调用的invocation
。 - 然后开始处理栈中的
invocation
,直到处理完整个缓存栈。 - 错误处理: 在处理过程中,如果遇到任何不匹配的情况,立即报错。
- 如果在处理过程中,没有其他 Actor 调用自身,但出现了自身调用其他 Actor 的
5. 处理 Intent 和更新计数器
-
完成 intent 处理:
- 在成功处理完一个 intent 之后,Actor 将自身的
processed_intent_count
加一。
- 在成功处理完一个 intent 之后,Actor 将自身的
-
继续处理下一个 intent:
- 根据
IntentProcessTrace
中的 intent 顺序,以及 Actor 记录的自身target_index
,决定下一个要处理的 intent。 - 注意: 确保按照正确的顺序处理所有待处理的 intent,避免遗漏或重复处理。
- 根据
6. 处理失败的 Intent
-
检测失败状态:
- 如果
IntentProcessTrace
中的success
不等于 0,表示对应的 intent 整体处理失败。
- 如果
-
状态回滚:
- 在处理完对应的
IntentProcessTrace
后,所有涉及的 Actor 应该将内存状态恢复至处理该 intent 之前的状态。 - 实现方式: 可以采用状态快照,在处理前保存状态,处理失败时进行回滚。
- 在处理完对应的
7. 序列化和反序列化
-
依据方法定义:
inputs
和outputs
的序列化和反序列化方式,由 Actor 中signature
对应的方法决定。- 可以使用 Molecule 作为统一的 ABI 格式,确保不同 Actor 之间的数据可以正确解析和处理。
8. 最终状态检查
-
完成所有处理:
- 当 Actor Cell Type Script 处理完所有 intent 和 message 后,内存状态达到最终状态。
-
验证输出:
- 检查交易
outputs
中的输出是否符合内存中的最终状态,完成处理。 - 错误处理: 如果输出不一致,则报错并终止交易。
- 检查交易
Intent 和 Invocation 的处理差别
需要注意的是,Actor Cell Type Script 针对 Intent 和 Invocation 的处理方式不同。
-
处理 Intent:
- 当处理 Intent 时,Actor 不仅需要对输入的资产和数据进行处理,修改内存状态,还需要检查对应 intent 是否得到了满意的输出。
- 这包括验证输出的资产和容量(Capacity)是否符合规则,以及是否满足用户的期望。
- 理由: 直接面向用户的 Actor 必须检查其 intent 是否满足,因为没有其他脚本会执行这个检查,并且该输出直接反映在交易的输出 Cells 中。
-
处理 Invocation:
- 当处理 Invocation 时,Actor 只需要根据
invocation
中的inputs
和outputs
修改内存状态即可。 - 无需检查具体结果,因为这些处理涉及的是中间状态,并未反映在交易输出中。
- 示例: 当 Actor A 与 Actor B 通信,使用 30 USD 换取 40 CKB,Actor A 记录自身 USD 减少 30,CKB 增加 40;Actor B 反之。
- 理由: 最终,如果在交易输出中,Actor A 和 Actor B 控制的资产和数据符合处理结果,说明中间的消息通信是正确的,资产是隐式在 Actor 间传递。
- 当处理 Invocation 时,Actor 只需要根据
附加说明
-
计数器的作用:
processed_intent_count
用于对链上处理进行排序,确保处理的顺序性和一致性。intent_index
以及target_index
都是根据processed_intent_count
确定其处理位置的,即intent_index
和target_index
一起组成了一个连续的序列,分别是该 Actor 作为直接被调方或者间接被调方参与的 intent。
-
循环调用处理:
- 通过允许在找到匹配的
invocation
之前,执行其他 Actor 对自身的调用,以支持循环调用。 - 通过将自身作为调用方的
invocation
压栈缓存,以支持循环调用。
- 通过允许在找到匹配的
-
错误处理:
- 如果在扫描
invocations
时,发现from_script_hash
为自身但与执行流程无法匹配的情况,立即报错,防止不一致的发生。
- 如果在扫描
-
状态一致性:
- 在处理失败的 intent 时,必须将内存状态回滚,以保持整个系统状态的一致性。
IntentProcessTraces 的位置
-
特殊的 WitnessLayout:
- 设定一个特殊的 Witness 布局,用于放置
IntentProcessTraces
,以方便 Script 的扫描处理。 - 这些
traces
最好与 Intent 的处理顺序对应,以简化链上扫描。 - 建议: 明确 WitnessLayout 的格式和解析方法,确保所有参与者遵循相同的标准。
- 设定一个特殊的 Witness 布局,用于放置
链下 Generator 流程
之前讨论了链上如何检查嵌套调用中间表达的正确性,那么链下如何拼接完成 UTXO 交易呢?
-
共识约束:
- 所有需要同步组合的 Actor-Based dApp 应该在同一个共识系统的约束下,才有可能做到同步组合。
-
Intent 的排序和处理:
- 所有调用的开端都是 Intent Cell 或 OTX,只需要针对这些进行排序,并按顺序处理所有的 intent。
-
示例流程:
- 假设对于每一个链上的 Actor Cell,链下都存在一个对应的 Generator。
-
对于 Actor A,存在一个 intent 队列;对于 Actor B,也存在一个 intent 队列。
-
处理过程:
- 当 Actor A 的 Generator 处理到某个 intent 需要调用 Actor B 时,其向 Actor B 的 Generator 发送一条消息。
-
当 Actor B 接收到消息时,处理并返回对应的输出,期间可能涉及到对其他 Actor 的访问,将所有的消息记录成trace。
- 注意,如果出现循环调用,则循环调用的 invocation 要出现在 trace 的前面。
- 此时,Actor B 不能直接开始处理下一个 intent,而是处于锁定状态,等待来自 Actor A 的新消息,直到 Actor A 当前 intent 处理结束。
- 即一旦一个 intent 直接或间接地访问了某个 Actor,该 Actor 必须等待该 intent 完全执行完毕,才能释放并处理下一个 intent。
- 当一个 intent 处理完毕后,可以得到 intent 的 outputs 和处理过程中所有中间的消息调用,并构造
IntentProcessTrace
。
-
交易拼接:
- 在处理完一个 intent 序列后,将 intent cell、OTX,以及所有的
IntentProcessTraces
,根据其调用顺序,拼接成一个完整的交易。
- 在处理完一个 intent 序列后,将 intent cell、OTX,以及所有的
-
这样,链上的 Actor Cell Type Script 在扫描处理所有的 intent 和 OTX,以及
IntentProcessTraces
时,其顺序与链下拼接的顺序一致。
-
示例
以上面的图示为例,存在三个Actor,并且对于每一个intent,假设调用顺序如下:
- Intent-1 调用的入口都是 Actor-1,此时 Actor-1 的 processed_intent_count 为 0,则此时新创建一个intent_index为0的trace。
- Actor-1 首先调用 Actor-3,Actor-3 调用 Actor-1,Actor-1得到来自Actor-3的返回。
- Actor-1 再去调用 Actor-2,Actor-2 会调用 Actor-3,Actor-3再调用Actor-1,得到返回后Actor-2再调用Actor-1,最终 Actor-1 得到返回。
- Actor-1 再去调用Actor-3,直接得到返回。
- Actor-1 得到对intent的输出,检查intent输出是否正确。
- 对于 Intent-2,执行一样的流程。
- 执行完对 intent-2 的处理后,Actor-1,Actor-2,Actor-3各自检查自己在交易中的Asset输出和Data输出是否与处理结果一致。
其时序图如下:
sequenceDiagram
participant A1 as Actor-1
participant A2 as Actor-2
participant A3 as Actor-3
Note over A1,A3: Intent-1 Processing
A1->>A1: Create trace (intent_index = 0)
A1->>A3: Call(1->3)
A3->>A1: Call(3->1)
A1-->>A3: Return
A3-->>A1: Return
A1->>A2: Call(1->2)
A2->>A3: Call(2->3)
A3->>A1: Call(3->1)
A1-->>A3: Return
A3-->>A2: Return
A2->>A1: Call(2->1)
A1-->>A2: Return
A2-->>A1: Return
A1->>A3: Call(1->3)
A3-->>A1: Return
A1->>A1: Check intent output
Note over A1,A3: Intent-2 Processing
A1->>A1: Create trace (intent_index = 1)
A1->>A3: Call(1->3)
A3->>A1: Call(3->1)
A1-->>A3: Return
A3-->>A1: Return
A1->>A2: Call(1->2)
A2->>A3: Call(2->3)
A3->>A1: Call(3->1)
A1-->>A3: Return
A3-->>A2: Return
A2->>A1: Call(2->1)
A1-->>A2: Return
A2-->>A1: Return
A1->>A3: Call(1->3)
A3-->>A1: Return
A1->>A1: Check intent output
Note over A1,A3: Final Checks
A1->>A1: Check Asset and Data output
A2->>A2: Check Asset and Data output
A3->>A3: Check Asset and Data output
得到的traces如下:
IntentProcessTraces {
receiver: Actor-1
traces: {
{
intent_index: 0,
invocations: {
{
from: Acrot-3,
target: Actor-1,
target_index: 0, // 与 intent_index 一致
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-3,
target_index: 0,
signature
inputs
outputs
},
{
from: Acrot-3,
target: Actor-1,
target_index: 0,
signature
inputs
outputs
},
{
from: Acrot-2,
target: Actor-3,
target_index: 0,
signature
inputs
outputs
},
{
from: Acrot-2,
target: Actor-1,
target_index: 0,
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-2,
target_index: 0,
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-3,
target_index: 0,
signature
inputs
outputs
}
}
},
{
intent_index: 1,
invocations: {
{
from: Acrot-3,
target: Actor-1,
target_index: 1, // 与 intent_index 一致
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-3,
target_index: 1,
signature
inputs
outputs
},
{
from: Acrot-3,
target: Actor-1,
target_index: 1,
signature
inputs
outputs
},
{
from: Acrot-2,
target: Actor-3,
target_index: 1,
signature
inputs
outputs
},
{
from: Acrot-2,
target: Actor-1,
target_index: 1,
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-2,
target_index: 1,
signature
inputs
outputs
},
{
from: Acrot-1,
target: Actor-3,
target_index: 1,
signature
inputs
outputs
}
}
}
}
}
可以看出,Invocations中invocation的顺序与时序图中 return 的顺序一模一样,通过这种排列,即可实现循环调用。
结语
通过完成关于 UTXO 嵌套调用中间表达的链上检查与链下拼接的方案设计,对于 Cell 模型,结合之前设计的基于 intent-script 或者基于 OTX 的方案,实现同步嵌套调用并不是一件很困难的事,尽管还需要一定的工作量。
基于这个方案:
-
第一步: 各家应用可以对自己定序的 Actor 实现嵌套调用。
-
进一步: 可以实现通用的定序服务,在 CKB 上实现一个 Overlapped Layer,或者基于 Cell 模型的 Rollup/Sidechain。
- 该定序服务可以使用 PoS 或联合挖矿作为准入机制。
- 在这个 Overlapped Layer 上,使用共识排序解决对共享可变状态的争用问题。
-
设计对应的 DSL:
-
使用 DSL 编写的合约将被编译成两部分:
- 部署在 CKB 上的链上脚本。
- 部署在 Overlapped Layer 的链下 Generator。
-
用户可以使用 intent-cell 对部署在 Overlapped Layer 的合约发起调用。
-
Overlapped Layer 在收到 intent 后,进行排序,然后使用 Generator 拼接一个完整的批次(batch),发送到 CKB 链上验证。
-